diff --git a/examples/recipe_registry_gen.cpp b/examples/recipe_registry_gen.cpp index b6370cf..a14b812 100644 --- a/examples/recipe_registry_gen.cpp +++ b/examples/recipe_registry_gen.cpp @@ -78,8 +78,18 @@ int main(int argc, char **argv) { int exit_code = 0; for (auto const &entry : numsim::examples::registry()) { - std::cout << entry.name << " -> " << target->target_name() << "\n"; auto model = entry.build(); + // Capability query BEFORE emit (#137): a recipe outside the target's + // scope is reported and skipped, so one unsupported recipe cannot abort + // the rest of the catalogue (#135). Deliberately NO try/catch around + // emit() — a recipe that passes can_emit but fails to emit is a defect, + // and the crash keeps this generator useful as a loud smoke check. + if (auto const supported = target->can_emit(model); !supported) { + std::cout << entry.name << " -> " << target->target_name() + << " SKIPPED (" << supported.error() << ")\n"; + continue; + } + std::cout << entry.name << " -> " << target->target_name() << "\n"; for (auto const &file : target->emit(model)) { if (!write_file(out_dir, file)) { exit_code = 2; diff --git a/include/numsim_codegen/targets/moose_material.h b/include/numsim_codegen/targets/moose_material.h index e3e8981..be338a3 100644 --- a/include/numsim_codegen/targets/moose_material.h +++ b/include/numsim_codegen/targets/moose_material.h @@ -38,6 +38,11 @@ class MooseMaterialTarget : public Target { [[nodiscard]] auto emit(ConstitutiveModel const &model) const -> std::vector override; + // Up-front scope guards (stateful inputs, evolution without local Newton, + // >1 tangent, `Jacobian_mult` name collision) as a query; shares the exact + // reason strings with the emit() throws (#137). + [[nodiscard]] auto can_emit(ConstitutiveModel const &model) const + -> std::expected override; [[nodiscard]] auto target_name() const -> std::string override; private: diff --git a/include/numsim_codegen/targets/numsim_material.h b/include/numsim_codegen/targets/numsim_material.h index 61af844..48ffc3e 100644 --- a/include/numsim_codegen/targets/numsim_material.h +++ b/include/numsim_codegen/targets/numsim_material.h @@ -28,6 +28,14 @@ class NumSimMaterialTarget : public Target { public: [[nodiscard]] auto emit(ConstitutiveModel const &model) const -> std::vector override; + // Up-front scope guards as a query, routed per sub-contract exactly like + // emit(): the Mode-B residual scope for residual recipes, the rk_integrator + // rate scope otherwise. Shares the exact reason strings with the emit() + // throws (#137). Success does not guarantee emit() succeeds — emit-time + // validation (name collisions, unbound leaves, non-finite defaults) may + // still throw. + [[nodiscard]] auto can_emit(ConstitutiveModel const &model) const + -> std::expected override; [[nodiscard]] auto target_name() const -> std::string override; }; diff --git a/include/numsim_codegen/targets/standalone_cxx.h b/include/numsim_codegen/targets/standalone_cxx.h index 16a0ce6..db966e3 100644 --- a/include/numsim_codegen/targets/standalone_cxx.h +++ b/include/numsim_codegen/targets/standalone_cxx.h @@ -26,6 +26,9 @@ class StandaloneCxxTarget : public Target { StandaloneCxxTarget(LinearAlgebraEmitter const &&) = delete; [[nodiscard]] auto emit(ConstitutiveModel const &model) const -> std::vector override; + // can_emit (#137): not overridden — this target has no up-front scope + // guards (it accepts every recipe shape), so the base's conservative + // "try emit" success is exact. [[nodiscard]] auto target_name() const -> std::string override; private: diff --git a/include/numsim_codegen/targets/target.h b/include/numsim_codegen/targets/target.h index 3ad00e5..9c461cf 100644 --- a/include/numsim_codegen/targets/target.h +++ b/include/numsim_codegen/targets/target.h @@ -3,6 +3,7 @@ #include +#include #include #include @@ -39,6 +40,23 @@ class Target { [[nodiscard]] virtual auto emit(ConstitutiveModel const &model) const -> std::vector = 0; + // Capability query (#137): would this target's UP-FRONT scope guards accept + // the recipe's shape? On rejection the error carries the exact reason string + // the matching emit() throw uses — one message, two transports — so a + // generic driver can skip-and-report without catching exceptions. + // + // Success does NOT guarantee emit() succeeds: can_emit checks only the + // up-front recipe-shape guards; emit-time validation (name collisions with + // synthesized members, unbound expression leaves, non-finite parameter + // defaults, pass-level checks) may still throw. The default implementation + // is conservatively permissive — it reports success ("try emit"), so a + // target without shape guards needs no override and one with them still + // rejects loudly inside emit(). + [[nodiscard]] virtual auto can_emit(ConstitutiveModel const & /*model*/) const + -> std::expected { + return {}; + } + // Human-readable name of the target framework — used in error messages // and diagnostics. [[nodiscard]] virtual auto target_name() const -> std::string = 0; diff --git a/src/targets/moose_material.cpp b/src/targets/moose_material.cpp index d684576..f0d8b77 100644 --- a/src/targets/moose_material.cpp +++ b/src/targets/moose_material.cpp @@ -4,11 +4,14 @@ #include #include +#include #include +#include #include #include #include #include +#include namespace numsim::codegen { @@ -438,12 +441,17 @@ auto emit_source(ConstitutiveModel const &model, std::string const &app_name, return os.str(); } -} // anonymous namespace - -// ─── Public methods ──────────────────────────────────────────── - -auto MooseMaterialTarget::emit(ConstitutiveModel const &model) const - -> std::vector { +// ─── Up-front scope guards ───────────────────────────────────── +// +// The recipe-SHAPE preconditions this backend rejects, computed ONCE and used +// by both can_emit (returned as std::unexpected) and emit (thrown) — one +// message, two transports, no drift (#137). Returns the first rejection +// reason, or nullopt when the shape is in scope. Deeper emit-time validation +// (tensor-rank storage mapping, non-finite parameter defaults, non-constant +// state initials) is NOT checked here — can_emit success does not guarantee +// emit success. +auto scope_rejection(ConstitutiveModel const &model) + -> std::optional { // Stateful symbols would need old/new MaterialProperty pair handling // that the MOOSE backend doesn't implement yet. Fail loudly rather // than silently emit a regular read. @@ -455,12 +463,11 @@ auto MooseMaterialTarget::emit(ConstitutiveModel const &model) const // the output guard, mirror the pattern below. for (auto const &i : model.inputs()) { if (i.role.is_stateful) { - throw std::runtime_error( - "MooseMaterialTarget: stateful role '" + i.role.name + - "' on input '" + i.name + - "' requires the History machinery (old/new MaterialProperty pair, " - "stateful initialisation) which is not implemented in this phase. " - "See the numsim-codegen Phase B roadmap."); + return "MooseMaterialTarget: stateful role '" + i.role.name + + "' on input '" + i.name + + "' requires the History machinery (old/new MaterialProperty pair, " + "stateful initialisation) which is not implemented in this phase. " + "See the numsim-codegen Phase B roadmap."; } } @@ -473,24 +480,22 @@ auto MooseMaterialTarget::emit(ConstitutiveModel const &model) const // residual/Jacobian-output mode is for external drivers via the // standalone target. if (!model.evolution_equations().empty() && !model.local_newton_enabled()) { - throw std::runtime_error( - "MooseMaterialTarget: recipe '" + model.name() + - "' has evolution equations but local Newton solving is not enabled. " - "Call enable_local_newton() so the generated MOOSE Material solves " - "its state variables internally. (The residual/Jacobian-output mode " - "is for external drivers — use StandaloneCxxTarget for that.)"); + return "MooseMaterialTarget: recipe '" + model.name() + + "' has evolution equations but local Newton solving is not enabled. " + "Call enable_local_newton() so the generated MOOSE Material solves " + "its state variables internally. (The residual/Jacobian-output mode " + "is for external drivers — use StandaloneCxxTarget for that.)"; } // Phase 5 (issue #37): all consistent tangents map to the single framework // `_Jacobian_mult` property — more than one would emit a duplicate member / // property. MOOSE has exactly one consistent-tangent slot. (Tangents live in // `tangents()`, not `outputs()`, on the pre-pass model the backend sees.) if (model.tangents().size() > 1) { - throw std::runtime_error( - "MooseMaterialTarget: recipe '" + model.name() + - "' requests more than one consistent tangent " - "(roles::ConsistentTangent)." - " MOOSE has a single _Jacobian_mult slot, so only one tangent can be " - "wired. Emit additional tangents via StandaloneCxxTarget."); + return "MooseMaterialTarget: recipe '" + model.name() + + "' requests more than one consistent tangent " + "(roles::ConsistentTangent)." + " MOOSE has a single _Jacobian_mult slot, so only one tangent can " + "be wired. Emit additional tangents via StandaloneCxxTarget."; } // PR #82 review: when a tangent is wired, the backend emits a hardcoded // `_Jacobian_mult` member. A regular output literally named "Jacobian_mult" @@ -499,14 +504,33 @@ auto MooseMaterialTarget::emit(ConstitutiveModel const &model) const if (!model.tangents().empty()) { for (auto const &o : model.outputs()) { if (o.name == "Jacobian_mult") { - throw std::runtime_error( - "MooseMaterialTarget: recipe '" + model.name() + - "' has both a consistent tangent and an output named " - "'Jacobian_mult', which collides with the framework consistent-" - "tangent member. Rename the output."); + return "MooseMaterialTarget: recipe '" + model.name() + + "' has both a consistent tangent and an output named " + "'Jacobian_mult', which collides with the framework consistent-" + "tangent member. Rename the output."; } } } + return std::nullopt; +} + +} // anonymous namespace + +// ─── Public methods ──────────────────────────────────────────── + +auto MooseMaterialTarget::can_emit(ConstitutiveModel const &model) const + -> std::expected { + if (auto reason = scope_rejection(model)) { + return std::unexpected(std::move(*reason)); + } + return {}; +} + +auto MooseMaterialTarget::emit(ConstitutiveModel const &model) const + -> std::vector { + if (auto const reason = scope_rejection(model)) { + throw std::runtime_error(*reason); + } return { EmittedFile{model.name() + ".h", emit_header(model), "include/materials", EmittedFile::Kind::Header}, diff --git a/src/targets/numsim_material.cpp b/src/targets/numsim_material.cpp index 640a103..f80e4cf 100644 --- a/src/targets/numsim_material.cpp +++ b/src/targets/numsim_material.cpp @@ -17,11 +17,14 @@ #include #include +#include #include +#include #include #include #include #include +#include #include #include @@ -83,25 +86,26 @@ std::string tensor_cxx_type(std::size_t dim, std::size_t rank) { // evolution equation `dx/dt = f(x, params)`, and NOTHING this increment can't // emit. Rejecting (rather than silently emitting a partial material) is the // whole contract — a code generator that quietly drops a declared output is a -// correctness hazard. -void check_scope(ConstitutiveModel const &model) { +// correctness hazard. Returns the first rejection reason — thrown by emit(), +// reported by can_emit() (one message, two transports, #137) — or nullopt +// when the recipe shape is in scope. +std::optional +rate_scope_rejection(ConstitutiveModel const &model) { auto const svs = model.state_variables(); auto const eqs = model.evolution_equations(); if (svs.size() != 1 || eqs.size() != 1) { - throw std::runtime_error( - "NumSimMaterialTarget: first increment supports exactly one scalar " - "state variable with one scalar evolution equation (the rk_integrator " - "rate contract). Coupled / multi-state systems need the " - "numsim-materials vector solver (numsim-materials#12)."); + return "NumSimMaterialTarget: first increment supports exactly one scalar " + "state variable with one scalar evolution equation (the " + "rk_integrator rate contract). Coupled / multi-state systems need " + "the numsim-materials vector solver (numsim-materials#12)."; } if (svs[0].kind != SymbolDecl::Kind::Scalar) { // Defensive: unreachable through the public API today (evolution equations // are scalar-only — add_scalar_evolution_equation binds only scalar state), // so a tensor state has no equation and is caught above. Kept for the day a // tensor-evolution API lands; until then this branch cannot fire. - throw std::runtime_error( - "NumSimMaterialTarget: tensor-valued state is not yet supported " - "(needs Mandel + the vector solver, numsim-materials#11/#12)."); + return "NumSimMaterialTarget: tensor-valued state is not yet supported " + "(needs Mandel + the vector solver, numsim-materials#11/#12)."; } // Outputs (scalar AND tensor, e.g. stress = f(state, strain)) are emitted as // properties with their own update callbacks. Only tensor INTERNAL STATE is @@ -117,29 +121,28 @@ void check_scope(ConstitutiveModel const &model) { for (auto const &o : model.outputs()) if (o.name == t.of_output && o.kind == OutputDecl::Kind::Tensor) of_ok = true; if (!of_ok) { - throw std::runtime_error( - "NumSimMaterialTarget: tangent '" + t.name + "' differentiates '" + - t.of_output + "', which is not a declared tensor output (stress)."); + return "NumSimMaterialTarget: tangent '" + t.name + "' differentiates '" + + t.of_output + "', which is not a declared tensor output (stress)."; } bool wrt_ok = false; for (auto const &in : model.inputs()) if (in.name == t.wrt_input && in.kind == SymbolDecl::Kind::Tensor) wrt_ok = true; if (!wrt_ok) { - throw std::runtime_error( - "NumSimMaterialTarget: tangent '" + t.name + "' differentiates w.r.t. '" + - t.wrt_input + "', which is not a declared tensor input (strain)."); + return "NumSimMaterialTarget: tangent '" + t.name + + "' differentiates w.r.t. '" + t.wrt_input + + "', which is not a declared tensor input (strain)."; } } // Tensor inputs (e.g. strain) ARE wired (Global-edge input_property); scalar // inputs are a separate small follow-up — reject those loudly for now. for (auto const &in : model.inputs()) { if (in.kind != SymbolDecl::Kind::Tensor) { - throw std::runtime_error( - "NumSimMaterialTarget: scalar input '" + in.name + - "' is not yet wired into the material — a Phase B follow-up (tensor " - "inputs like strain ARE supported)."); + return "NumSimMaterialTarget: scalar input '" + in.name + + "' is not yet wired into the material — a Phase B follow-up " + "(tensor inputs like strain ARE supported)."; } } + return std::nullopt; } // ── Mode-B strain-coupled residual emission ──────────────────────────────── @@ -169,71 +172,79 @@ void check_scope(ConstitutiveModel const &model) { // generated residual material therefore correctly models only states whose // solved INCREMENT is non-negative. This is surfaced in the emitted header; // lifting it needs an unclamped solver mode upstream (numsim-materials). -std::vector emit_residual_material(ConstitutiveModel const &model) { - // ── Scope validation (residual contract) ── +// ── Scope validation (residual contract) ── +// Exactly ONE scalar Newton unknown (the residual's state). Any OTHER state +// variable must be an INTERNAL variable — set by a post-solve update equation, +// not solved. This is the #92 path: a scalar solve (no vector solver / Mandel) +// with tensor/scalar HISTORY (e.g. J2 plastic strain εᵖ) carried across steps. +// Returns the first rejection reason — thrown by emit_residual_material, +// reported by can_emit (one message, two transports, #137) — or nullopt. +std::optional +residual_scope_rejection(ConstitutiveModel const &model) { auto const reqs = model.residual_equations(); auto const svs = model.state_variables(); auto const ueqs = model.update_equations(); - // Exactly ONE scalar Newton unknown (the residual's state). Any OTHER state - // variable must be an INTERNAL variable — set by a post-solve update equation, - // not solved. This is the #92 path: a scalar solve (no vector solver / Mandel) - // with tensor/scalar HISTORY (e.g. J2 plastic strain εᵖ) carried across steps. if (reqs.size() != 1) { - throw std::runtime_error( - "NumSimMaterialTarget: a residual material needs exactly one residual " - "equation (one scalar Newton unknown). Coupled multi-unknown return maps " - "need the numsim-materials vector solver (numsim-materials#12)."); + return "NumSimMaterialTarget: a residual material needs exactly one " + "residual equation (one scalar Newton unknown). Coupled " + "multi-unknown return maps need the numsim-materials vector solver " + "(numsim-materials#12)."; } { std::size_t const newton_idx = reqs[0].state_variable_idx; std::set updated; for (auto const &ue : ueqs) { if (ue.state_variable_idx == newton_idx) { - throw std::runtime_error( - "NumSimMaterialTarget: the Newton state '" + - svs[newton_idx].name + - "' has both a residual and an update equation — a state is either " - "solved (residual) or updated post-solve (internal variable), not " - "both."); + return "NumSimMaterialTarget: the Newton state '" + + svs[newton_idx].name + + "' has both a residual and an update equation — a state is " + "either solved (residual) or updated post-solve (internal " + "variable), not both."; } if (!updated.insert(ue.state_variable_idx).second) { - throw std::runtime_error( - "NumSimMaterialTarget: internal variable '" + - svs[ue.state_variable_idx].name + - "' has more than one update equation."); + return "NumSimMaterialTarget: internal variable '" + + svs[ue.state_variable_idx].name + + "' has more than one update equation."; } } for (std::size_t i = 0; i < svs.size(); ++i) { if (i == newton_idx || updated.contains(i)) continue; - throw std::runtime_error( - "NumSimMaterialTarget: state variable '" + svs[i].name + - "' is neither the Newton unknown (a residual) nor an internal " - "variable (an update equation). Add one via " - "add_scalar/tensor_update_equation, or remove it."); + return "NumSimMaterialTarget: state variable '" + svs[i].name + + "' is neither the Newton unknown (a residual) nor an internal " + "variable (an update equation). Add one via " + "add_scalar/tensor_update_equation, or remove it."; } } if (!model.evolution_equations().empty()) { // Unreachable through the public API (a state carries a rate XOR a residual, // enforced by the recipe), but a residual recipe must never also carry a // rate — guard against a future API that could mix them. - throw std::runtime_error( - "NumSimMaterialTarget: a residual recipe must not also declare rate " - "(evolution) equations — the state is defined by the residual alone."); + return "NumSimMaterialTarget: a residual recipe must not also declare " + "rate (evolution) equations — the state is defined by the residual " + "alone."; } for (auto const &in : model.inputs()) { if (in.kind != SymbolDecl::Kind::Tensor) { - throw std::runtime_error( - "NumSimMaterialTarget: scalar input '" + in.name + - "' is not yet wired into a residual material — a follow-up (tensor " - "inputs like strain ARE supported)."); + return "NumSimMaterialTarget: scalar input '" + in.name + + "' is not yet wired into a residual material — a follow-up " + "(tensor inputs like strain ARE supported)."; } } if (model.outputs().empty()) { - throw std::runtime_error( - "NumSimMaterialTarget: a residual material needs at least one output " - "(e.g. stress) to anchor compute() — the output's pull drives the " - "Newton solve. A state-only solve has no consumer."); + return "NumSimMaterialTarget: a residual material needs at least one " + "output (e.g. stress) to anchor compute() — the output's pull " + "drives the Newton solve. A state-only solve has no consumer."; + } + return std::nullopt; +} + +std::vector emit_residual_material(ConstitutiveModel const &model) { + if (auto const reason = residual_scope_rejection(model)) { + throw std::runtime_error(*reason); } + auto const reqs = model.residual_equations(); + auto const svs = model.state_variables(); + auto const ueqs = model.update_equations(); auto const &req = reqs[0]; auto const &sv = svs[req.state_variable_idx]; @@ -909,15 +920,33 @@ std::vector emit_residual_material(ConstitutiveModel const &model) } // namespace +auto NumSimMaterialTarget::can_emit(ConstitutiveModel const &model) const + -> std::expected { + // Route exactly like emit(): residual recipes are judged against the Mode-B + // scope, everything else against the rate (rk_integrator) scope. Only the + // up-front shape guards are queried — emit-time validation (reserved-name / + // member collisions, unbound expression leaves, non-finite defaults) can + // still throw after a success here. + auto reason = model.residual_equations().empty() + ? rate_scope_rejection(model) + : residual_scope_rejection(model); + if (reason) { + return std::unexpected(std::move(*reason)); + } + return {}; +} + auto NumSimMaterialTarget::emit(ConstitutiveModel const &model) const -> std::vector { // Strain-coupled implicit-residual recipes take the Mode-B path; the rate - // (rk_integrator) path below handles the rest. Routing here (before - // check_scope) keeps each path's scope validation self-contained. + // (rk_integrator) path below handles the rest. Routing here (before the + // rate-scope guard) keeps each path's scope validation self-contained. if (!model.residual_equations().empty()) { return emit_residual_material(model); } - check_scope(model); + if (auto const reason = rate_scope_rejection(model)) { + throw std::runtime_error(*reason); + } auto const &sv = model.state_variables()[0]; auto const &eq = model.evolution_equations()[0]; @@ -936,7 +965,8 @@ auto NumSimMaterialTarget::emit(ConstitutiveModel const &model) const // Tensor inputs (e.g. strain): each is wired from a producer material via a // Global-edge input_property, read by the property name `` (the // producer must publish a property of that name). Each gets a `_source` - // string parameter naming the producer. check_scope already rejected scalars. + // string parameter naming the producer. The rate-scope guard already + // rejected scalars. std::vector tensor_inputs; std::set tensor_input_names; for (auto const &in : model.inputs()) { @@ -1125,8 +1155,9 @@ auto NumSimMaterialTarget::emit(ConstitutiveModel const &model) const for (auto const &[name, h] : model.tensor_symbol_map()) { if (name == t.wrt_input) eps = h; } - // check_scope already verified both resolve; guard belt-and-braces so a - // future check_scope/emit drift surfaces as a clear error, not cas::diff UB. + // The rate-scope guard already verified both resolve; guard belt-and-braces + // so a future scope-guard/emit drift surfaces as a clear error, not + // cas::diff UB. if (!sigma.is_valid() || !eps.is_valid()) { throw std::runtime_error( "NumSimMaterialTarget: tangent '" + t.name + diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f0aa040..414a8be 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -35,6 +35,7 @@ add_executable(numsim_codegen_tests LinearAlgebraEmitterTest.cpp NumSimMaterialTargetTest.cpp TargetFactoryTest.cpp + CanEmitTest.cpp ) target_link_libraries(numsim_codegen_tests diff --git a/tests/CanEmitTest.cpp b/tests/CanEmitTest.cpp new file mode 100644 index 0000000..d06da5f --- /dev/null +++ b/tests/CanEmitTest.cpp @@ -0,0 +1,183 @@ +// #137: Target::can_emit — the up-front scope guards as a QUERY. Each concrete +// target must (a) accept a recipe its emit() supports, (b) reject an +// out-of-scope recipe with EXACTLY the message its emit() throws (one message, +// two transports — the refactor's whole point), and (c) stay conservative: +// can_emit success does not guarantee emit() success (emit-time validation +// such as non-finite parameter defaults still throws). + +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace numsim::codegen { +namespace { + +using namespace numsim::cas; + +// σ = 2μ ε — in scope for StandaloneCxx and MOOSE; out of scope for +// NumSimMaterial's rate contract (no state variable / evolution equation). +auto build_elastic_shear() -> ConstitutiveModel { + ConstitutiveModel m("ElasticShear"); + auto mu = m.add_parameter("mu", 0.5); + auto eps = m.add_tensor_input("eps", 3, 2, roles::Strain); + m.add_output("stress", 2 * mu * eps, roles::Stress); + return m; +} + +// dα/dt = K·α — the canonical NumSimMaterial rate recipe. Out of scope for +// MOOSE (evolution equations without enable_local_newton()). +auto build_linear_hardening() -> ConstitutiveModel { + ConstitutiveModel m("LinearHardening"); + auto K = m.add_parameter("K", -1.0); + auto alpha = + m.add_scalar_state_variable("alpha", make_expression(0.0)); + m.add_scalar_evolution_equation(alpha, K * alpha.current); + return m; +} + +// R(z, ε) = z − c·tr(ε), σ = z·ε — the Mode-B residual recipe NumSimMaterial +// supports. +auto build_return_map() -> ConstitutiveModel { + ConstitutiveModel m("ReturnMap"); + auto c = m.add_parameter("c", 2.0); + auto eps = m.add_tensor_input("strain", 3, 2, roles::Strain); + auto z = + m.add_scalar_state_variable("z", make_expression(0.0)); + m.add_scalar_residual_equation(z, z.current - c * trace(eps)); + m.add_output("stress", z.current * eps, roles::Stress); + return m; +} + +// Returns the emit() exception message (or a sentinel) so the equality tests +// can compare against the can_emit reason. +auto emit_throw_message(Target const &t, ConstitutiveModel const &m) + -> std::string { + try { + // Expected to throw; the [[nodiscard]] return is never reached. + [[maybe_unused]] auto const discarded = t.emit(m); + } catch (std::exception const &e) { + return e.what(); + } + return ""; +} + +// ─── StandaloneCxx: no scope guards — the base default ("try emit") ───────── + +TEST(CanEmit, StandaloneAcceptsEverythingViaBaseDefault) { + StandaloneCxxTarget const t; + EXPECT_TRUE(t.can_emit(build_elastic_shear()).has_value()); + EXPECT_TRUE(t.can_emit(build_linear_hardening()).has_value()); +} + +// ─── MooseMaterial ─────────────────────────────────────────────────────────── + +TEST(CanEmit, MooseAcceptsSupportedRecipe) { + EXPECT_TRUE(MooseMaterialTarget{}.can_emit(build_elastic_shear()).has_value()); +} + +TEST(CanEmit, MooseRejectsEvolutionWithoutLocalNewtonWithEmitMessage) { + MooseMaterialTarget const t; + auto const m = build_linear_hardening(); + auto const verdict = t.can_emit(m); + ASSERT_FALSE(verdict.has_value()); + // The documented reason... + EXPECT_NE(verdict.error().find("local Newton solving is not enabled"), + std::string::npos) + << verdict.error(); + // ...and byte-for-byte the message emit() throws. + EXPECT_EQ(verdict.error(), emit_throw_message(t, m)); +} + +TEST(CanEmit, MooseRejectsStatefulInputWithEmitMessage) { + MooseMaterialTarget const t; + auto m = build_elastic_shear(); + m.add_tensor_input("eps_p", 3, 2, + Role{.name = "plastic_strain", + .is_stateful = true, + .expected_rank = 2}); + auto const verdict = t.can_emit(m); + ASSERT_FALSE(verdict.has_value()); + EXPECT_NE(verdict.error().find("requires the History machinery"), + std::string::npos) + << verdict.error(); + EXPECT_EQ(verdict.error(), emit_throw_message(t, m)); +} + +// ─── NumSimMaterial: rate path ─────────────────────────────────────────────── + +TEST(CanEmit, NumSimAcceptsRateRecipe) { + EXPECT_TRUE( + NumSimMaterialTarget{}.can_emit(build_linear_hardening()).has_value()); +} + +TEST(CanEmit, NumSimRejectsStatelessRecipeWithEmitMessage) { + NumSimMaterialTarget const t; + auto const m = build_elastic_shear(); // no state variable → rate scope fails + auto const verdict = t.can_emit(m); + ASSERT_FALSE(verdict.has_value()); + EXPECT_NE(verdict.error().find("exactly one scalar state variable"), + std::string::npos) + << verdict.error(); + EXPECT_EQ(verdict.error(), emit_throw_message(t, m)); +} + +// ─── NumSimMaterial: residual (Mode-B) path ───────────────────────────────── + +TEST(CanEmit, NumSimAcceptsResidualRecipe) { + EXPECT_TRUE(NumSimMaterialTarget{}.can_emit(build_return_map()).has_value()); +} + +TEST(CanEmit, NumSimRejectsOutputlessResidualWithEmitMessage) { + NumSimMaterialTarget const t; + ConstitutiveModel m("NoOutput"); + auto c = m.add_parameter("c", 2.0); + auto eps = m.add_tensor_input("strain", 3, 2, roles::Strain); + auto z = + m.add_scalar_state_variable("z", make_expression(0.0)); + m.add_scalar_residual_equation(z, z.current - c * trace(eps)); + auto const verdict = t.can_emit(m); + ASSERT_FALSE(verdict.has_value()); + EXPECT_NE(verdict.error().find("needs at least one output"), + std::string::npos) + << verdict.error(); + EXPECT_EQ(verdict.error(), emit_throw_message(t, m)); +} + +// ─── Polymorphic use through the factory (the registry generator's path) ──── + +TEST(CanEmit, QueryableThroughTargetBasePointer) { + auto const t = make_target("numsim_material"); + EXPECT_TRUE(t->can_emit(build_linear_hardening()).has_value()); + EXPECT_FALSE(t->can_emit(build_elastic_shear()).has_value()); +} + +// ─── Documented limitation: success does not guarantee emit() success ─────── + +TEST(CanEmit, SuccessDoesNotGuaranteeEmitSuccess) { + // A non-finite parameter default passes the up-front SHAPE guards (can_emit + // only checks those) but is rejected by emit-time validation — the header- + // documented contract. + ConstitutiveModel m("NanDefault"); + auto K = + m.add_parameter("K", std::numeric_limits::quiet_NaN()); + auto alpha = + m.add_scalar_state_variable("alpha", make_expression(0.0)); + m.add_scalar_evolution_equation(alpha, K * alpha.current); + + NumSimMaterialTarget const t; + EXPECT_TRUE(t.can_emit(m).has_value()); + EXPECT_NE(emit_throw_message(t, m).find("non-finite default"), + std::string::npos); +} + +} // namespace +} // namespace numsim::codegen