43 KiB
Extending stellar-model physics
MeanField treats a stellar model as a compile-time collection of independent physics specifications. An EOS, a surface condition, an integral constraint, and a phase condition each describe only their own contribution. The library then infers the model type, generated scalar blocks, residual blocks, Jacobian edges, normalization, and manifest entries from that collection.
The important rule is: add one declaration and, where needed, one implementation per physics specification—never a specialization for a whole combination of constraints. A new integral must compose with every already-supported EOS, surface, and other constraint without naming those combinations.
Every downstream layer folds the same canonical specification pack. The symbolic compiler appends generated blocks and Jacobian edges, the manifest and normalization layers project metadata for those exact blocks, the prepared equilibrium operator owns one statically typed runtime slot per specification, and the default preconditioner folds the corresponding border actions. “Variadic” therefore means one inferred C++ type and one inferred block system for each pack, not a runtime list and not a library of hand-written pack combinations.
The small physics-facing contract
A specification is an ordinary physics class with:
- a
Parametersaggregate; - a constructor from
Parameters; and - one nested
ModelDefinitionmade from a role-specific alias.
The declaration is the single source used by SpecificationTraits and SpecificationContribution; an extension author does not specialize either trait.
EOS
The EOS still uses the existing relation vocabulary. ModelDefinition only tells the stellar-model compiler which role the object has.
class LinearBarotrope final {
public:
struct Parameters final {
double densityPerEnthalpy;
};
using ModelDefinition = mean_field::eos::ConstitutiveLaw<
LinearBarotrope,
"LinearBarotrope">;
using Relations = mean_field::eos::RelationCatalog<
mean_field::eos::DensityFromSpecificEnthalpy>;
explicit constexpr LinearBarotrope(Parameters p) noexcept
: m_densityPerEnthalpy(p.densityPerEnthalpy) {
}
[[nodiscard]] constexpr mean_field::dimensions::DensityValue evaluate(
mean_field::eos::DensityFromSpecificEnthalpy,
mean_field::dimensions::SpecificEnthalpyValue h
) const noexcept {
return mean_field::dimensions::DensityValue{m_densityPerEnthalpy * h.value()};
}
[[nodiscard]] constexpr auto partialDerivative(
mean_field::eos::DensityFromSpecificEnthalpy,
mean_field::eos::WithRespectTo<mean_field::dimensions::quantity::SpecificEnthalpy>,
mean_field::dimensions::SpecificEnthalpyValue
) const noexcept {
return mean_field::eos::PartialDerivative<
mean_field::dimensions::quantity::Density,
mean_field::dimensions::quantity::SpecificEnthalpy>{m_densityPerEnthalpy};
}
private:
double m_densityPerEnthalpy;
};
static_assert(mean_field::eos::EquationOfStateModel<LinearBarotrope>);
static_assert(mean_field::models::ModelSpecification<LinearBarotrope>);
The explicit quantity type is what prevents accidental pressure/density/enthalpy interchange.
Surface condition
A surface condition declares the physical quantity it fixes. Describing a new condition is intentionally separate from implementing its finite-element carrier equation.
class IsothermalSurface final {
public:
struct Parameters final {
mean_field::dimensions::TemperatureValue Tsurface;
};
using ModelDefinition = mean_field::surface::BoundaryCondition<
IsothermalSurface,
"IsothermalSurface">;
using PhysicalQuantity = mean_field::dimensions::quantity::Temperature;
using TargetValue = mean_field::dimensions::TemperatureValue;
explicit constexpr IsothermalSurface(Parameters p) noexcept
: m_target(p.Tsurface) {
}
[[nodiscard]] constexpr TargetValue targetTemperature() const noexcept {
return m_target;
}
private:
TargetValue m_target;
};
Integral constraint
An integral says which stellar state variables it reads (stellar::Reads), which equations its generated coordinate changes (stellar::Changes), how its generated value and residual are normalized, and how they appear in diagnostics. The ordinary declaration uses physics names; the equilibrium compiler translates them to numerical blocks.
class PreparedFixedBaryonMass;
class FixedBaryonMass final {
public:
struct Parameters final {
mean_field::dimensions::MassValue Mbaryon;
};
using ScalarDescription = mean_field::stellar::ScalarConstraint<
mean_field::dimensions::quantity::Mass,
mean_field::dimensions::quantity::SpecificEnergy,
mean_field::dimensions::quantity::Mass,
"fixed_baryon_mass.multiplier", "C_b",
"fixed_baryon_mass.residual", "R_Mb">;
using TargetValue = typename ScalarDescription::TargetValue;
using ModelDefinition = mean_field::integral::FixedScalarWithMultiplier<
FixedBaryonMass,
"FixedBaryonMass",
mean_field::stellar::Reads<
mean_field::stellar::state::Density,
mean_field::stellar::state::SurfaceShape>,
mean_field::stellar::Changes<
mean_field::stellar::equation::HydrostaticBalance>,
ScalarDescription>;
using EquilibriumPhysics =
mean_field::operators::LocalSpecificationEquilibriumPhysics<
PreparedFixedBaryonMass>;
explicit FixedBaryonMass(Parameters p) : m_target(p.Mbaryon) {
}
// This conventional accessor lets the generic manifest read the target.
[[nodiscard]] TargetValue target() const noexcept {
return m_target;
}
private:
TargetValue m_target;
};
FixedIntegralWithPhysicalCoordinate has the same declaration shape, but its generated scalar is a physical state coordinate rather than a Lagrange multiplier. FixedAngularMomentum, whose generated coordinate is angular velocity, is the current example.
stellar::ScalarConstraint names three independent physical quantities: the
target, the generated Newton coordinate, and the appended constraint residual.
The wrapper derives numerical scale laws and diagnostic unit labels from those
strong quantity types, so a total energy cannot accidentally be normalized as
a specific energy merely because two free-form strings agree. The quantities
need not be the same: FixedCentralDensity has a density target and a
specific-enthalpy phase residual. The wrapper contains no assembly logic. The
lower-level FixedIntegralWithMultiplier spelling remains available for
unusual compatibility declarations; opting either its normalization or
manifest into typed dimensional metadata requires the complete typed pair. The
current generated-constraint
compiler supports exactly one scalar value and one scalar residual per
generated specification; a distributed or multi-component generated quantity
requires an explicit extension of that compiler boundary and is not obtained by
switching to the lower-level spelling.
If the target and appended residual have different quantity types, the specification also supplies the physical reference used to report and scale that residual:
[[nodiscard]] mean_field::dimensions::SpecificEnergyValue
residualReference() const noexcept {
return m_enthalpyReference;
}
The return type must be exactly the QuantityValue for
ScalarDescription::ConstraintResidualQuantity. Omitting this accessor, or
returning the target's quantity instead, makes
CompilableRootManifestFor<Model, Form> false. Thus a density target cannot
silently provide the scale for an energy-valued equation. The built-in
FixedCentralDensity phase condition is the deliberate special case: its
specific-enthalpy reference is derived from the selected EOS when the
constraint is compiled.
When an integral residual also depends on the coordinate generated by that same specification, use stellar::state::OwnGeneratedCoordinate. The compiler resolves that relative name to the specification's exact generated type. Prefer the exported mean_field::stellar::{state,equation} names and the stellar::Reads, stellar::Changes, stellar::ScalarConstraint, and stellar::Derivative wrappers in ordinary physics code. These wrappers are aliases over the compiler's canonical types: they improve readability without introducing a parallel implementation or weakening any compile-time check.
Phase condition
A phase condition uses the same vocabulary but generates a solver-border coordinate. It fixes a degeneracy; it is not automatically a new physical invariant.
class FixedCentralEnthalpy final {
public:
struct Parameters final {
mean_field::dimensions::SpecificEnthalpyValue hcenter;
};
using ScalarDescription = mean_field::stellar::ScalarConstraint<
mean_field::dimensions::quantity::SpecificEnthalpy,
mean_field::dimensions::quantity::SpecificEnthalpy,
mean_field::dimensions::quantity::SpecificEnthalpy,
"fixed_central_enthalpy.border", "lambda_hc",
"fixed_central_enthalpy.residual", "R_hc">;
using TargetValue = typename ScalarDescription::TargetValue;
using ModelDefinition = mean_field::constraint::ScalarPhaseCondition<
FixedCentralEnthalpy,
"FixedCentralEnthalpy",
mean_field::stellar::Reads<
mean_field::stellar::state::SpecificEnthalpy>,
mean_field::stellar::Changes<
mean_field::stellar::equation::HydrostaticBalance>,
ScalarDescription>;
explicit FixedCentralEnthalpy(Parameters p) : m_target(p.hcenter) {
}
[[nodiscard]] auto target() const noexcept {
return m_target;
}
private:
TargetValue m_target;
};
Generated arity is inferred by the role alias: a constitutive law or boundary condition generates no scalar pair, while the three scalar constraint aliases each generate one balanced scalar value/residual pair. This zero-or-one-pair rule is the deliberate current generated-constraint boundary, not a claim that arbitrary-rank new fields are already supported. A malformed normalization or manifest remains inspectable but fails its corresponding completeness query.
Exact residual and Jacobian providers
A declaration determines the complete provider topology. The nested EquilibriumPhysics alias above connects that declaration to its numerical formulas.
Most prepared physics classes keep the simple Physics(const Specification&)
constructor. A specification which declares both state::Density and
state::SurfaceShape in Reads may instead request the current physical
density-volume integral through
stellar::DensityVolumeIntegralContext<Specification>:
integrateDensity(rho)evaluates the distributed, mapped-volume finite-element integral and returnsdimensions::MassValue;linearizeDensityIntegral(deltaRho)returns its exact density action; andlinearizeSurfaceShapeIntegral(deltaShape)returns its exact geometry action.
Both declarations are mandatory because integral rho dV depends on the
deformed volume. A density-only declaration is rejected before the context type
can be formed, so it cannot hide a missing geometry column in the inferred
Jacobian. A future fixed/reference-geometry integral would be a separate,
explicitly named service rather than a weakened form of this one.
The context is a small, copyable, non-owning handle. It exposes neither the
physical core nor FEM objects, uses no heap type erasure, and delegates global
reduction and reusable scratch storage to the physical core. Its numerical
methods are valid in PrepareAfterPhysical and subsequent residual, Jacobian,
or border-action work, after the core has prepared the current state; a physics
constructor should only retain the handle. This service is deliberately scoped
to the current core's density-volume integral rather than being an untyped
general-purpose backend escape hatch.
PreparedFixedBaryonMass is still an ordinary concrete class. It does not need
a model template, block list, offset table, or MFEM access. A representative
context-using shape is:
class PreparedFixedBaryonMass final {
public:
using Report = BaryonMassPreparationReport;
using IntegralContext =
mean_field::stellar::DensityVolumeIntegralContext<FixedBaryonMass>;
PreparedFixedBaryonMass(
const FixedBaryonMass &specification,
IntegralContext integrals
) : m_target(specification.target()), m_integrals(integrals) {
}
template <typename State>
[[nodiscard]] Report PrepareAfterPhysical(const State &state) {
// Only names from stellar::Reads plus this specification's generated
// coordinate are visible here.
const auto density = state.density();
const auto multiplier = state.generatedCoordinate();
m_currentMass = m_integrals.integrateDensity(density);
m_massResidual = m_currentMass.value() - m_target.value();
prepareHydrostaticTerm(multiplier);
m_prepared = true;
return {};
}
template <typename Row>
[[nodiscard]] auto AddResidual(
mean_field::stellar::equation::OwnConstraint,
Row &row
) const {
return row.add(m_massResidual);
}
template <typename Row>
[[nodiscard]] auto AddResidual(
mean_field::stellar::equation::HydrostaticBalance,
Row &row
) const {
return row.add(m_hydrostaticTerm);
}
template <typename Direction, typename Row>
[[nodiscard]] auto AddJacobianAction(
mean_field::stellar::Derivative<
mean_field::stellar::equation::OwnConstraint,
mean_field::stellar::state::Density>,
const Direction &direction,
Row &row
) const {
return row.add(
m_integrals.linearizeDensityIntegral(direction.density()).value());
}
template <typename Direction, typename Row>
[[nodiscard]] auto AddJacobianAction(
mean_field::stellar::Derivative<
mean_field::stellar::equation::OwnConstraint,
mean_field::stellar::state::SurfaceShape>,
const Direction &direction,
Row &row
) const {
return row.add(m_integrals
.linearizeSurfaceShapeIntegral(direction.surfaceShape()).value());
}
// Reads is conservative for all changed equations. This particular
// derivative is mathematically absent, so say that explicitly.
template <typename Direction, typename Row>
[[nodiscard]] mean_field::stellar::StructuralZero AddJacobianAction(
mean_field::stellar::Derivative<
mean_field::stellar::equation::HydrostaticBalance,
mean_field::stellar::state::Density>,
const Direction &,
Row &
) const noexcept {
return mean_field::stellar::zeroDerivative;
}
template <typename Direction, typename Row>
[[nodiscard]] mean_field::stellar::StructuralZero AddJacobianAction(
mean_field::stellar::Derivative<
mean_field::stellar::equation::HydrostaticBalance,
mean_field::stellar::state::SurfaceShape>,
const Direction &,
Row &
) const noexcept {
return mean_field::stellar::zeroDerivative;
}
template <typename Direction, typename Row>
[[nodiscard]] auto AddJacobianAction(
mean_field::stellar::Derivative<
mean_field::stellar::equation::HydrostaticBalance,
mean_field::stellar::state::OwnGeneratedCoordinate>,
const Direction &direction,
Row &row
) const {
return row.add(applyMultiplierDerivative(
direction.generatedCoordinate()));
}
[[nodiscard]] bool IsPrepared() const noexcept {
return m_prepared;
}
private:
// Physics data and helper functions; no solver block bookkeeping.
FixedBaryonMass::TargetValue m_target;
IntegralContext m_integrals;
mean_field::dimensions::MassValue m_currentMass{0.0};
double m_massResidual{0.0};
double m_hydrostaticTerm{0.0};
bool m_prepared{false};
};
The hydrostatic helper formula above is a placeholder for the extension's actual physics; the integral and its two directional actions are the real typed service contract. The compiler derives these calls from Reads and Changes:
- it requests one
AddResidual(EquationTag, Row&)provider for the specification's own constraint equation and for each distinct equation inChanges; - it requests
OwnConstraintderivatives with respect to every state inReads; and - it requests each
Changesequation's derivatives with respect to every state inReadsplusOwnGeneratedCoordinate.
The compiler, not the physics class, enumerates and invokes this set. Every Jacobian call receives a direction containing exactly one declared state and a row that can modify exactly one declared equation. Named accessors such as density(), specificEnthalpy(), and generatedCoordinate() are available only when that source is the selected derivative. value() is the backend-independent accessor for a one-source extension tag that does not yet have a named convenience accessor.
Every provider returns one of two statements:
- return the token from
row.add(scalar_or_vector)or, for a residual entry,row.addEntry(index, value); or - return
stellar::structuralZerofor an intentionally zero residual contribution, and conventionallystellar::zeroDerivativefor an intentionally zero derivative.
This is stronger than a broad callback that is merely callable. Omitting one inferred overload makes CompleteStellarEquilibriumPhysicsProvider<Physics, Specification, Model> false at compile time. Within a callback, adding twice is rejected at runtime. Returning ContributionAdded without one addition, or returning StructuralZero after an addition, is also rejected immediately. Separate specifications may still add to the same assembled equation; additive composition across specification slots is intentional.
The exact row API exposes no assignment, mutable root vector, block offset, or MFEM alias synchronization. Consequently one specification cannot redirect a derivative to another legal row or erase the physical core or another specification's contribution. The lower-level SpecificationEquilibriumPhysics spelling remains available when a concrete implementation genuinely depends on the complete Model type, but it uses this same exact adapter and guarantees.
For the current isobaric surface, the pressure condition owns selected rows of the specific-enthalpy equation. Exact residual and Jacobian rows automatically suppress contributions on those replaced rows. A constraint computes its ordinary full hydrostatic contribution; it does not reproduce the surface-row mask.
Composition and model access
Construction order does not affect the inferred type:
auto model = mean_field::model::StellarModel(
LinearBarotrope({.densityPerEnthalpy = 2.0}),
IsothermalSurface({.Tsurface = mean_field::dimensions::TemperatureValue{1.0e6}}),
FixedBaryonMass({.Mbaryon = mean_field::dimensions::MassValue{1.0}}),
FixedCentralEnthalpy({.hcenter = mean_field::dimensions::SpecificEnthalpyValue{0.25}})
);
using Model = std::remove_cvref_t<decltype(model)>;
static_assert(Model::template specificationRoleCount<
mean_field::models::SpecificationRole::invariant> == 1);
const auto &eos = model.equationOfState();
const auto &surface = model.surfaceCondition();
const auto &mass = model.specification<FixedBaryonMass>();
equationOfState() is always available because a valid model has exactly one constitutive law. surfaceCondition() and specificationForRole<Role>() are available only when that role is unique. Roles such as integral constraints may occur many times; use SpecificationsForRole<Role> at compile time and specification<ExactType>() at runtime.
Stable names are identities within a role. Two independently developed invariants therefore choose descriptive stable names; they do not coordinate global numeric ordinals. Reusing a stable name in the same role is rejected at compile time, while the same readable name may intentionally appear in two different roles. Manifest lookup always compares the full (role, stable name) identity.
Coupling constraints to one another
Global constraints are not required to be mutually independent. A specification can refer to another specification in the same model using physics-level owner tags:
class CoupledVirialCondition final {
public:
using ModelDefinition = mean_field::integral::FixedScalarWithMultiplier<
CoupledVirialCondition,
"CoupledVirialCondition",
mean_field::stellar::Reads<
mean_field::stellar::state::Density,
mean_field::stellar::state::GeneratedCoordinateOf<FixedBaryonMass>>,
mean_field::stellar::Changes<
mean_field::stellar::equation::HydrostaticBalance,
mean_field::stellar::equation::ConstraintOf<FixedBaryonMass>>,
VirialScalarDescription>;
// Parameters, target(), and the two local physics packages follow the
// same pattern as an uncoupled scalar constraint.
};
GeneratedCoordinateOf<FixedBaryonMass> means “read the scalar coordinate owned by this exact specification type.” ConstraintOf<FixedBaryonMass> means “my generated coordinate changes that specification's scalar equation.” OwnGeneratedCoordinate and OwnConstraint are the corresponding relative names for the declaring specification. These declarations generate ordinary stellar::Derivative<Equation, State> calls, so cross-constraint formulas use the same exact residual/Jacobian and preconditioner protocols as every other edge. For a cross-generated direction, the equilibrium view's generic one-source accessor is value(); a border-preconditioner direction may use values() or generatedCoordinate<Owner>().
The compiler resolves owner references after collecting the complete canonical specification pack, so declaration order is irrelevant. A referenced owner that is absent, does not generate the requested coordinate/residual, or cannot be mapped into the final form makes symbolic compilation fail. It is never converted into a runtime lookup or silently omitted.
Capability ladder
Description is deliberately easier than executable solver support. Each rung is an ordinary compile-time capability query, so an unsupported physics component is rejected at the public overload boundary instead of producing an error deep inside a constructor or solver body.
For a model declaration, the first two rungs are:
static_assert(mean_field::models::SelfDescribingModelSpecification<FixedBaryonMass>);
static_assert(mean_field::models::ModelSpecification<FixedBaryonMass>);
static_assert(mean_field::operators::StellarEquilibriumSpecificationCompilable<FixedBaryonMass>);
static_assert(mean_field::operators::StellarEquilibriumSymbolicallyCompilable<Model>);
static_assert(mean_field::models::CompleteGeneratedScalarDimensionsFor<FixedBaryonMass>);
static_assert(mean_field::models::CompleteGeneratedNormalizationFor<FixedBaryonMass>);
static_assert(mean_field::normalization::CompleteGeneratedPhysicalRieszNormalizationFor<FixedBaryonMass>);
static_assert(mean_field::models::CompleteGeneratedManifestFor<FixedBaryonMass>);
using Form = mean_field::operators::CompiledStellarEquilibriumForm<Model>;
// This example's new surface has no manifest/runtime adapter yet.
static_assert(!mean_field::operators::CompilableRootManifestFor<Model, Form>);
// These become true after the per-specification runtime, EOS core, and surface
// implementations are added. They are intentionally false for this example.
static_assert(!mean_field::operators::StellarEquilibriumPhysicsAvailableFor<
FixedBaryonMass, Model>);
static_assert(!mean_field::operators::hasCompleteStellarEquilibriumRuntime<Model>);
static_assert(!mean_field::operators::hasStellarEquilibriumCoreRuntime<Model>);
Model::hasCompleteEquilibriumDeclaration reports only that the model's physics declarations are structurally complete and balanced. It deliberately makes no claim about backend support. The clearly named query for block and Jacobian compilation is StellarEquilibriumSymbolicallyCompilable<Model>; StellarEquilibriumSystemCompilable<Model> remains its compatibility spelling. Only CompiledStellarEquilibriumSystem<Model> exposes an equilibrium-system compilation classification. This distinction lets an astronomer write and inspect a new physical declaration even when one of its physics names has not yet been mapped to a numerical block.
Once a model has an executable core, surface runtime, manifest, and all of its
per-specification residual/Jacobian actions, it satisfies
equilibrium::StellarEquilibriumModel and can be discretized into a Problem.
The remaining solver-facing capabilities are then checked on that inferred
problem type:
// The selected discretization prescription has a valid preparation path.
static_assert(mean_field::normalization::NormalizableStellarEquilibriumProblem<Problem>);
using PhysicalRieszDiscretization = mean_field::equilibrium::StellarDiscretizationFor<
mean_field::normalization::PhysicalRieszDiagonal<>>;
using PhysicalRieszProblem = mean_field::equilibrium::StellarEquilibriumProblem<
Model, PhysicalRieszDiscretization>;
// These stronger queries are specifically for the Physical Riesz path.
static_assert(mean_field::normalization::PhysicalRieszStellarEquilibriumProblem<
PhysicalRieszProblem>);
static_assert(mean_field::normalization::NormalizableStellarEquilibriumProblem<
PhysicalRieszProblem>);
static_assert(mean_field::normalization::PhysicalRieszStellarEquilibriumCore<
typename PhysicalRieszProblem::PhysicalCoreType>);
// The EOS-specific material/surface surrogate supports this exact core.
static_assert(mean_field::preconditioning::MaterialSurfacePreconditionerProblem<Problem>);
static_assert(mean_field::preconditioning::StellarStructurePreconditionerProblem<Problem>);
// Every generated border has an action, and the structure block is supported.
static_assert(mean_field::preconditioning::CompleteSpecificationBorderActionsFor<Problem>);
static_assert(mean_field::preconditioning::DefaultStellarPreconditionerAvailableFor<Problem>);
The complete ladder is:
- Model description: the object is constructible and has one valid, self-owned definition.
- Blocks and Jacobian: its block lists are valid, generated rows and columns balance, and every declared edge endpoint exists in the final model. The compiler performs two passes, so one constraint may name a block generated by another constraint without depending on declaration order.
- Executable equilibrium root:
StellarEquilibriumCoreRuntime<EOS>selects the concrete physicalCoreTypefor the constitutive law. Surface compilation/runtime, root-manifest construction, and every specification's nestedEquilibriumPhysicspackage (or core-authorized library backend entry) are checked separately. For an ordinary nested package,CompleteStellarEquilibriumPhysicsProviderproves that every compiler-enumerated residual row and derivative has an exact provider. Each call receives one read-only source and one additive-only row; the current isobaric row-replacement mask is applied by that row. Passing these checks makes onePreparedVariadicStellarEquilibriumOperator<Model>; it does not select a hand-written class for the particular constraint pack. - Normalization:
NormalizableStellarEquilibriumProblem<Problem>means the prescription stored in the discretization has a validprepareNormalization(problem)operation.Unnormalizedis the identity path and therefore remains available to a lean alternate core. A Physical Riesz discretization additionally requiresPhysicalRieszStellarEquilibriumCore<Problem::PhysicalCoreType>, a structural contract for the density, enthalpy, gravity-gradient, gravity-potential, and surface-deformation maps used to assemble the physical Gram diagonals. It also requires complete per-specification physical-scale contributions. A new core can opt in by exposing that physical interface; it does not inherit from or name the Polytrope core. - Material and stellar-structure preconditioner:
MaterialSurfacePreconditionerProblem<Problem>checks that the EOS-specific material/surface backend is registered for the problem's exact selected core type.StellarStructurePreconditionerProblem<Problem>is the corresponding public capability for the default coupled material/surface-plus-gravity structure block; it currently inherits the same support boundary. - Generated border and full default preconditioner:
CompleteSpecificationBorderPhysicsProviderproves the exact border-incident derivatives for one physics action.CompleteSpecificationBorderActionsFor<Problem>folds that requirement over the model's specifications.DefaultStellarPreconditionerAvailableFor<Problem>is the final conjunction of a discretized equilibrium problem, complete per-specification border actions, and a supported stellar-structure block.makePreconditioner(problem)is available only at this rung.
No rung is specialized for a complete EOS/surface/constraint combination. The model type determines the packs, the compiler folds their declarations, and each numerical layer folds the corresponding per-component hooks. If a query fails, implement the missing hook for the individual EOS, surface, integral, or phase condition; do not weaken the query and do not add a model-pack specialization.
Adding another normalization family
A normalization prescription is a compile-time discretization choice, not a
model constraint. Adding one does not add cases to discretize, the stellar
operator, or the solver-facing adapter. Its public
NormalizationCompilation<Prescription, Form> specialization owns both the
complete coordinate plan and the early runtime-compatibility declaration for
the selected physical core and specification pack. The library independently
validates that the advertised plan covers every inferred value and residual
block exactly once.
Most new policies use only the public problem/manifest interface when they
prepare their diagonal factors. They can reuse
RuntimePreparedNormalizationCompilation rather than spelling core or
specification concepts:
namespace my_normalization {
class UniformDiagonal final
: public mean_field::normalization::NormalizationPrescriptionTag {
public:
UniformDiagonal(double stateFactor, double residualFactor);
[[nodiscard]] double stateFactor() const noexcept;
[[nodiscard]] double residualFactor() const noexcept;
};
} // namespace my_normalization
namespace mean_field::normalization {
template <typename Form>
requires mean_field::utils::blocks::block_form_is_valid_v<Form>
struct NormalizationCompilation<my_normalization::UniformDiagonal, Form>
: RuntimePreparedNormalizationCompilation<
my_normalization::UniformDiagonal,
Form> { };
} // namespace mean_field::normalization
The policy's namespace then provides
prepareStellarNormalization(const UniformDiagonal&, const Problem&) -> DiagonalNormalization. It is found from the exact policy argument carried by
Problem::NormalizationPrescriptionType; no central registry or built-in-policy
switch is edited. The generated plan labels every block with
RuntimePreparedCoordinate<UniformDiagonal> rather than falsely claiming an
identity or Physical Riesz formula, and the returned diagonal is dimension-checked
against the complete inferred problem. discretize rejects a policy
whose compilation omits runtimeAvailableFor, and
also rejects a third-party runtime policy whose plan does not name that exact
policy on every coordinate. Thus merely providing a preparation function cannot
bypass or contradict the compile-time plan. The same discretize boundary
rejects a policy whose preparation operation is absent or returns the wrong
type, before a solver-facing problem is constructed. A policy that requires private facilities
of a particular physical core writes its own runtimeAvailableFor<Core, Specifications> predicate in the same compilation specialization; Physical
Riesz is the reference example. Ordinary EOS, surface, and constraint authors
do not use this extension point—their dimensional scalar wrappers continue to
generate the appropriate per-block normalization metadata automatically.
Radial seeds and border preconditioning
The optional numerical hooks also compose per specification. A specification that intentionally leaves a spherical seed unchanged can say so without touching the seed compiler:
using RadialProjection = mean_field::seed::projection::Use<
mean_field::seed::projection::NoStateChange>;
A specification with a generated coordinate supplies a similarly wrapped physics rule. The rule receives RadialProjectionScales, RadialProjectionState, and only its own generated coordinate. It does not calculate block offsets or inspect the other constraints. RadialProfileProjectableModel<Model> is true only when every specification has a complete rule and exactly one rule supplies the radial mass scale. Thus a newly introduced EOS, surface condition, or constraint cannot be advertised as projectable and then fail deep inside the function body.
For an ordinary generated constraint, no second Jacobian implementation is
needed for border preconditioning. The default adapter projects the same exact
AddJacobianAction providers from EquilibriumPhysics onto the subset whose
row or source is a generated border. This is why the FixedBaryonMass
declaration above needs only its EquilibriumPhysics alias: the residual,
Jacobian, and default border preconditioner have one source of numerical
formulas.
An advanced implementation may deliberately provide a different border approximation. In that case it opts in explicitly:
using SpecificationBorderPhysics =
mean_field::preconditioning::LocalSpecificationBorderPhysics<MyPreparedBorderAction>;
The action uses the same astronomy-facing stellar::Derivative<Equation, State> vocabulary as the equilibrium Jacobian. For example, a phase condition declared with Reads<SpecificEnthalpy> and Changes<HydrostaticBalance> has two border-incident derivatives:
class MyPreparedBorderAction {
public:
explicit MyPreparedBorderAction(const PreparedMyPhaseCondition &physics);
template <typename Direction, typename Row>
[[nodiscard]] auto ApplyJacobianAction(
mean_field::stellar::Derivative<
mean_field::stellar::equation::OwnConstraint,
mean_field::stellar::state::SpecificEnthalpy>,
const Direction &direction,
Row &row
) const {
return row.add(
evaluatePhaseDerivative(direction.specificEnthalpy()));
}
template <typename Direction, typename Row>
[[nodiscard]] auto ApplyJacobianAction(
mean_field::stellar::Derivative<
mean_field::stellar::equation::HydrostaticBalance,
mean_field::stellar::state::OwnGeneratedCoordinate>,
const Direction &direction,
Row &row
) const {
return row.add(
evaluateMultiplierAction(direction.generatedCoordinate()(0)));
}
};
The framework derives the full derivative list from Reads and Changes, then partitions it into structure-to-border, border-to-structure, and border-to-border edges. It invokes only the border-incident edges. Cross-constraint edges are partitioned in exactly the same way, including edges between two generated borders.
A purely structure-to-structure derivative is checked separately. The default
structure preconditioner accepts it only when the selected physical core names
that exact (specification, coupling) as a backend capability or the nested
equilibrium provider returns stellar::StructuralZero. A custom nonzero term
on even an otherwise familiar base edge therefore makes
DefaultStellarPreconditionerAvailableFor<Problem> false instead of being
silently dropped.
Each direction contains exactly one source and each row can update exactly one destination. For this example, asking the first direction for density() or the second row to write a gravity-potential equation is ill-formed. An exact-zero border derivative returns stellar::zeroDerivative. Missing an incident overload makes CompleteSpecificationBorderPhysicsProvider<Action, Specification, Problem> false at compile time; PreparedSpecificationBorderPhysicsActionFor additionally checks construction and move requirements. The single-use row.add(...) token has the same duplicate and result-consistency checks as equilibrium assembly.
For an explicit approximation, the action constructor receives only the
prepared equilibrium physics for its own specification. It can snapshot
state-dependent coefficients without access to the enclosing problem, physical
core, or other constraints. LocalSpecificationBorderPhysics<Action> is the
common wrapper for a concrete local class. SpecificationBorderPhysics<ActionTemplate>
is available when the action genuinely needs the inferred Problem type; both
names use the same adapter and neither reimplements pack traversal.
The library handles problem binding, operation partitioning, offsets, alias synchronization, surface-row ownership, and aggregation. The preconditioner lifecycle includes the prepared-operator generation, so changing state-dependent contribution data invalidates and rebuilds the border Schur complement even when the caller reuses the same dependency stamps.
These contracts establish structural completeness and safe composition; they do not establish preconditioner quality. The current default structure solve and generated-border Schur approximation are provisional numerical choices. A problem satisfying DefaultStellarPreconditionerAvailableFor has every required action and a registered backend, but it may still be poorly conditioned or ineffective for a new regime. Iteration counts, scaled spectra, parameter continuation behavior, and nonlinear convergence must be measured separately for each important physical family.
What works end to end today
As of the current checkpoint:
- arbitrary well-formed self-described specifications can form distinct canonical
StellarModeltypes, subject to the model-pack role and balance contracts; - generated scalar blocks, residual rows, and Jacobian edges are inferred from
stellar::Readsandstellar::Changes, including named edges between constraints; - the residual/Jacobian runtime and generated-border preconditioner enumerate those inferred rows and edges and require exact providers or explicit structural zeros;
- generated Physical Riesz declarations and generated manifest metadata are projected generically, while Physical Riesz field diagonals are available to any selected core satisfying
PhysicalRieszStellarEquilibriumCore; - the variadic outer layers currently wrap a fixed five-field barotropic physical core: density, surface deformation, gravity gradient, gravity potential, and specific enthalpy;
- each generated constraint currently adds exactly one scalar value block and one scalar residual block around that core;
- the executable equilibrium kernels are registered for
eos::Polytropewithsurface::Isobaric; - the default material/surface and stellar-structure preconditioner kernels are likewise registered for that Polytrope/Isobaric backend and its exact physical core;
- per-specification runtime and prepared border actions exist for
FixedTotalMass,FixedAngularMomentum, andFixedCentralDensity; and makePreconditioner(problem)is exposed only whenDefaultStellarPreconditionerAvailableFor<Problem>proves both the structure backend and every generated border action are present.
The five-field statement is a real boundary, not merely a missing convenience alias. A scalar constraint over those existing fields can compose without changing the core. A genuinely new distributed field—magnetic induction, temperature, composition, or another PDE state—requires extending the shared physical-field vocabulary and mapping, the selected executable core and discretization, its residual/Jacobian implementation, normalization metadata, manifest, and the applicable structure preconditioner. Supplying a backend block type in Reads does not by itself make that field part of the physical core. This work is still per new field/equation family, not per complete combination of constraints.
The nonbarotropic EOS and isothermal-surface mocks in the compile-contract tests are symbolic coverage. They prove that EOS choice, surface choice, and constraint packs produce distinct canonical types and correctly inferred block/Jacobian structures; they are not executable stellar models and do not claim a compatible five-field core or default preconditioner. Their rejection by equilibrium::StellarEquilibriumModel and discretize is intentional.
A new EOS or surface can therefore be described and type-checked today, but discretize will reject it until its per-EOS core or per-surface compilation/runtime support (including carrier/manifest handling) is registered. A new integral or phase condition can obtain its scalar block/Jacobian pair, normalization, and ordinary target() manifest structure from ModelDefinition, but still needs its residual/Jacobian runtime. The ordinary constraint places that concrete implementation on itself as EquilibriumPhysics = operators::LocalSpecificationEquilibriumPhysics<MyPreparedPhysics>; its exact providers are reused automatically by the default generated-border preconditioner. Only a deliberately distinct border approximation adds SpecificationBorderPhysics. Neither path specializes a combination-level registry.
The aggregate StellarEquilibriumRuntimeContribution registry is not an alternative astronomy-facing API. It can access the complete model, FEM objects, mapper, dependency stamps, and physical core, so an entry is selected only when the exact specification also appears in the selected concrete core type's BackendSpecifications list. A raw specialization—or a custom EOS that merely reuses the built-in core type—cannot add itself to that core's list and is therefore ignored. A custom core author may authorize its own backend entries in that single list; if an authorized entry and a nested EquilibriumPhysics package both exist for the same specification, the model is rejected as genuinely ambiguous. Ordinary EOS, surface, and constraint authors should use the restricted nested packages and never manage this list.
An alternate executable core may choose the identity Unnormalized path without implementing Physical Riesz maps. Selecting PhysicalRieszDiagonal for that same core fails PhysicalRieszStellarEquilibriumProblem and NormalizableStellarEquilibriumProblem cleanly. Likewise, an executable problem whose EOS/core pair has no material-surface backend fails MaterialSurfacePreconditionerProblem, while a problem with one missing constraint action fails CompleteSpecificationBorderActionsFor; neither failure requires nor invites a combination-specific fallback. These are availability and structural-correctness claims, not evidence that the current preconditioner will deliver acceptable Krylov or nonlinear convergence.
Those remaining hooks are per physics component. Adding FixedMagneticEnergy over existing state fields should require work for FixedMagneticEnergy itself. Adding a genuinely new magnetic field/equation additionally requires the shared field-family work listed above—not new implementations for Polytrope + Isobaric + FixedTotalMass + FixedMagneticEnergy, then another for every other constraint combination. A converged rotating solution and its nonlinear solver remain downstream goals; the compile-time architecture and provisional preconditioner do not, by themselves, establish that convergence result.