The discriminated-union plugin registry¶
NeoFOAM’s plugin system is built on Pydantic discriminated unions wrapped in a runtime-extensible registry. This page explains why — why discriminated unions, why a registry, and why both together.
What makes this hard¶
OpenFOAM solves “which model implementation should I instantiate?”
with addToRunTimeSelectionTable. The dictionary supplies a string;
the table maps it to a constructor. Subclasses register themselves at
static-init time. It is a well-understood pattern and it works.
What it does not offer:
Validation. The string is checked for existence in the table; the configuration the string activates is not. Each solver has to re-implement its own argument parsing.
Schema export. There is no way to ask “what are the valid choices for
RAS.RASModelin this build?” without opening the source.Type safety on the consumer side. A
tmp<RASModel>returned fromRASModel::Newdoes not tell the C++ compiler which subclass it actually is; downcasts are explicit and brittle.
A Python-first surface where input validation, UI generation, and AI-assisted templating are first-class capabilities cannot rely on “runtime string lookup with no schema.” It needs a primitive that does selection, validation, and schema export from the same definition.
Mechanism: discriminated unions plus a registry¶
Pydantic’s discriminated union is a tagged-union type. A field of type
Union[Cat, Dog, Lizard] with discriminator='pet_type' resolves
to the right subclass based on the value of pet_type in the input
dictionary:
class Cat(BaseModel):
pet_type: Literal['cat']
meows: int
class Dog(BaseModel):
pet_type: Literal['dog']
barks: float
class Model(BaseModel):
pet: Union[Cat, Dog] = Field(discriminator='pet_type')
This gives type-safe selection: Model(pet={'pet_type': 'dog',
'barks': 3.14}) constructs a Dog automatically and rejects
unknown pet_type values at validation time.
For NeoFOAM the primitive solves three problems at once:
Type-safe configuration loading. A turbulence-model field in
turbulencePropertiesselects betweenkEpsilon,SpalartAllmaras,laminar, etc. Discriminated unions encode that selection in the type system.Schema generation.
BaseModel.model_json_schema()on a discriminated-union model produces a JSON schema with the discriminator mapping baked in. The same schema drives input validation, UI form generation, and AI-assisted case templating.One source of truth. The list of valid types is whatever is currently in the union — there is no separate “registry of names” that can drift from the type system.
The limitation: closed at definition time¶
Pydantic’s discriminated union must be defined at model creation time:
pet: Union[Cat, Dog, Lizard] = Field(discriminator='pet_type')
The members are baked in. You cannot add a fourth pet type from a third-party package without rebuilding the parent model. That breaks the goal of “third parties can ship their own physics models without modifying core NeoFOAM code.”
The registry: rebuild on registration¶
NeoFOAM’s PluginSystem wraps the discriminated-union pattern in a
runtime-extensible registry. Each base class decorated with
@PluginSystem.register(...) gets a registry of subclasses;
@<Base>.register on a subclass appends to the registry and
rebuilds the parent’s discriminated union to include the new member:
@PluginSystem.register(discriminator_variable="shape", discriminator="shape_type")
class ShapeInterface(BaseModel):
color: str
@ShapeInterface.register
class CircleConfig(BaseModel):
shape_type: Literal["circle"]
radius: float
@ShapeInterface.register
class SquareConfig(BaseModel):
shape_type: Literal["square"]
side: float
After both register calls, ShapeInterface.plugin_model is a
Pydantic model whose shape field is a discriminated union over
Circle, Square. Add a third subclass and the union grows;
remove one and it shrinks.
sequenceDiagram
autonumber
participant Mod as Plugin module
participant Sub as Subclass<br/>(e.g. TriangleConfig)
participant Reg as ShapeInterface<br/>registry
participant Pyd as Pydantic<br/>schema cache
participant Con as Consumer code
Mod->>Sub: define class
Mod->>Reg: @ShapeInterface.register
Reg->>Reg: append Triangle to plugin_registry
Reg->>Pyd: rebuild plugin_model<br/>(Union[Circle, Square, Triangle])
Note over Pyd: cache invalidated for<br/>ShapeInterface.plugin_model
rect rgb(232, 245, 233)
Note over Con,Pyd: Correct path
Con->>Reg: ShapeInterface.create({"shape_type": "triangle", ...})
Reg->>Pyd: validate against<br/>current plugin_model
Pyd-->>Con: Triangle instance
end
rect rgb(255, 235, 238)
Note over Con,Pyd: Wrong path (footgun)
Con->>Sub: ShapeInterface(...) directly
Note over Con: Pydantic uses ShapeInterface's<br/>own schema, not the rebuilt union
Sub-->>Con: validation error or<br/>silently wrong subclass
end
The construction goes through a classmethod
(ShapeInterface.create(...) or a plugin-specific helper) because
Pydantic caches schemas — instances have to be built from the
current plugin_model, not from ShapeInterface itself.
Trade-offs¶
The combined design has costs:
Import-order sensitivity.
registerhas a side effect on the parent model. Importing a third-party plugin package after a consumer has already constructed from the registry means the consumer was constructed against a stale union. In practice, this is contained to plugin-registration modules that always import their plugins before anything tries to construct from the registry, but it is a real footgun.Indirect construction. Users have to call
<Base>.create(...)rather than<Base>(...)because the parent’s schema cache would otherwise be stale. The error message when this is forgotten is not always obvious.Schema cache invalidation. Pydantic caches generated schemas per type. The framework rebuilds those caches when registration changes the union; subtle bugs are possible if a consumer captures
plugin_modelearly and reuses it.
The OpenFOAM analogue (addToRunTimeSelectionTable) does not have
these footguns — it has different ones (no validation, no schema
export, no type safety). The trade is honest: register-rebuild gives
the schema-driven workflow at the cost of some import-order
discipline.
When this matters in practice¶
The combination of discriminated union + registry is what lets NeoFOAM:
Validate input files against the currently-installed plugin set. A typo in a turbulence-model name produces a Pydantic error that names the field, the bad value, and the valid alternatives — instead of a generic “model not found” from a runtime-selection table.
Generate a JSON schema that reflects every plugin available in the current environment, third-party plugins included. This is what drives the schema-introspection capability documented in Schema introspection.
Avoid carrying a “string name → factory” dictionary in parallel with the type system. The discriminator value is the type identifier. Renames stay consistent because there is only one place to rename.
In incompressibleFluid, the plugin interface is
incompressibleFluidModel (see
src/neofoam/solver/incompressibleFluid/models/incompressibleFluidModel.py).
Boussinesq and Spalart–Allmaras register against it; the registry’s
discriminated union is what
StagedInit.solver_inputs() walks during schema introspection.
For the two ways incompressibleFluid actually picks up registered
models at startup (plugin registry vs core specs), see
Model discovery: plugin vs core spec.