Spec and Runtime

This split is enabling infrastructure for the core abstraction described in Operations and the DAG. Operations are defined once at module-import time (the Spec) and instantiated per case (the Runtime) so that the same operation definition can back many independent solver runs without state leaking through module globals.

See also

For the lifecycle overview that places this page in context, see Solver structure.

NeoFOAM splits every model and solver into two layers:

  • A Spec (ModelSpec, SolverSpec) — the immutable definition. Created once at module-import time. Decorators like @spec.load, @spec.build, and @spec.operation(...) register callables onto the spec without executing anything.

  • A Runtime (ModelRuntime, SolverRuntime) — the mutable per-instance state, created from a spec via spec.instantiate(...). Holds the loaded config, resolved dependencies, and (for solvers) the argv and SolverState.

The mental model is:

Module Import Time              Solver Startup
──────────────────              ──────────────
ModelSpec   (definition)  ──►   ModelRuntime  (instance)
SolverSpec  (definition)  ──►   SolverRuntime (instance)

What makes this hard

OpenFOAM C++ leans hard on singleton-with-class-level-state for turbulence models, transport models, fvOptions, and runtime-selection factories. The class definition is the registry entry; the registry entry is the configuration target; New(...) allocates and configures in one step. That pattern works for one solver running once in one process — but it breaks when:

  • Tests want to spin up several independent runtimes of the same model in one process.

  • Multi-domain simulations want to hold multiple runtimes of one spec concurrently (different regions, different boundary conditions).

  • Schema-introspection and UI tooling want to ask “what could be loaded?” without committing to loading anything.

A Python-first surface where testability and concurrency matter cannot inherit the singleton pattern unchanged.

Mechanism

The split has three concrete payoffs:

Multiple independent runs from one definition. A spec is essentially a class definition with extra structure — defined once at import time and then re-used. Tests can spin up many independent runtimes without leaking state through module globals.

Plugin discovery without instantiation. The plugin registry stores ModelSpec instances, not runtimes. That matters because schema introspection (solver_inputs(), scheme_inputs()) and UI generation need to know what could be loaded without committing to loading anything. If specs and runtimes were the same object, plugin discovery would be a side-effecting operation — incompatible with cheap --list-plugins calls or with editor integrations that build forms from schemas.

Explicit lifecycle. By the time a runtime exists, LOAD has already happened. By the time operations execute, BUILD has already happened. The spec/runtime split makes “what’s allowed when” obvious in the API: you cannot accidentally call a runtime-only method on a spec, and you cannot register a new @spec.operation after the runtime is built.

What lives where

On the Spec

On the Runtime

Decorator-registered callables

Loaded config (mutated by RESOLVE)

Plugin-system registration

Per-instance name (manifest entries override)

Class-level _config_class

state.core_models, state.optional_models (solver only)

Operation definitions

argv (solver only)

Detect / load / build hooks

Methods to run the registered hooks (run_resolve, run_build, operations)

Trade-offs

The cost is one indirection: writing a model takes two parts (the spec declaration plus the runtime methods that the framework calls on it), where a single class would feel more compact. The benefit is everything above. For a framework whose top-priority capability is runtime model composition, that is a price worth paying — but it is a price, and a reader coming from OpenFOAM C++ will notice it.

When this matters in practice

The split is what makes the following possible:

  • Schema-introspection without instantiation. solver_inputs() walks the registered specs and returns Pydantic config classes without running any model code. See Schema introspection.

  • Independent test runtimes. Each test creates its own runtime, with its own loaded config — no module-global cleanup between tests.

  • Multi-domain simulations. The fluid solver and the solid solver in a conjugate-heat-transfer setup may be runtimes of two different specs; nothing prevents both from being active simultaneously.

  • Late binding of name. Manifest-loaded models override the runtime name so that the same spec can appear multiple times with different parameters (e.g. several heat sources).

The Spec/Runtime contract surfaces in Three-stage initialization (the spec is created at import time, the runtime is created at LOAD) and in The discriminated-union plugin registry (the registry holds specs, not instances).