@@ -219,6 +219,23 @@ struct EvolutionEquation {
219219 std::string doc;
220220};
221221
222+ // Phase D strain-coupled (verified-reachable 2026-06-15): an IMPLICIT evolution.
223+ // Instead of a rate `dx/dt = f(x)`, the state x is defined by a residual
224+ // `R(x, inputs) = 0` solved by a Newton solver (numsim-materials' backward_euler
225+ // on the graph-coupled path). Unlike a rate — which the rk_integrator contract
226+ // forbids from referencing inputs (the integrator owns discretization) — a
227+ // residual is EXPECTED to depend on a tensor strain input (that is the coupling),
228+ // so its expression is `tensor_to_scalar`-typed: a scalar R built from the scalar
229+ // state/params AND tensor inputs (e.g. `x - c*trace(eps)`). This unlocks real
230+ // return-map / plasticity-class models and a strain-coupled consistent tangent
231+ // dσ/dε = ∂σ/∂ε + ∂σ/∂x·(−∂R/∂ε / ∂R/∂x). A state variable carries EITHER a rate
232+ // (EvolutionEquation) OR a residual (this) — never both.
233+ struct ResidualEquation {
234+ std::size_t state_variable_idx;
235+ cas::expression_holder<cas::tensor_to_scalar_expression> residual;
236+ std::string doc;
237+ };
238+
222239// Phase 3a-2 (issue #75): tuning for the in-function Newton solve emitted
223240// by LocalNewtonLoweringPass. `tol` is the absolute residual threshold for
224241// convergence; `max_iter` caps the iteration count (no line search /
@@ -481,74 +498,11 @@ class ConstitutiveModel {
481498 ScalarStateVariableHandle const &state_var,
482499 cas::expression_holder<cas::scalar_expression> rate,
483500 std::string doc = " " ) -> void {
484- // PR #69 round-1 #3: cross-recipe hijack defense. The handle's
485- // `model_token` MUST equal `this` — otherwise the user passed a
486- // handle that came from a different ConstitutiveModel (or
487- // manually-constructed). Pure name-based matching would silently
488- // bind to a coincidentally-named state variable on this model.
489- if (state_var.model_token == nullptr ) {
490- throw std::runtime_error (std::format (
491- " ConstitutiveModel '{}': add_scalar_evolution_equation handle "
492- " has no model token. Handles must come from "
493- " add_scalar_state_variable on this model — manually-constructed "
494- " handles are not accepted." ,
495- m_name));
496- }
497- if (state_var.model_token != this ) {
498- throw std::runtime_error (std::format (
499- " ConstitutiveModel '{}': add_scalar_evolution_equation handle "
500- " came from a different ConstitutiveModel (handle.model_token = "
501- " {}, this = {}). Each handle is bound to the recipe that "
502- " created it; cross-recipe use would silently bind by name and "
503- " discretise the wrong state variable." ,
504- m_name, static_cast <void const *>(state_var.model_token ),
505- static_cast <void const *>(this )));
506- }
507-
508- // PR #69 round-1 review (CRITICAL): the handle's `current` MUST be a
509- // bare scalar leaf (i.e. `cas::scalar`), not a compound expression.
510- // `expression_holder::get<cas::scalar>()` would use an
511- // `assert(dynamic_cast != nullptr)` path that's stripped under
512- // `NDEBUG`, then UB on the unchecked `static_cast`. Use an explicit
513- // runtime dynamic_cast that throws a clear diagnostic instead.
514- auto const *typed = dynamic_cast <cas::scalar const *>(
515- state_var.current .data ().get ());
516- if (!typed) {
517- throw std::runtime_error (std::format (
518- " ConstitutiveModel '{}': add_scalar_evolution_equation handle's "
519- " `current` is not a bare scalar leaf symbol. Handles must come "
520- " from add_scalar_state_variable — you cannot synthesise one "
521- " from a compound expression like `K * alpha`." ,
522- m_name));
523- }
524- auto const &sv_name = typed->name ();
525-
526- std::size_t found_idx = m_state_variables.size ();
527- for (std::size_t i = 0 ; i < m_state_variables.size (); ++i) {
528- if (m_state_variables[i].kind == SymbolDecl::Kind::Scalar &&
529- m_state_variables[i].name == sv_name) {
530- found_idx = i;
531- break ;
532- }
533- }
534- if (found_idx == m_state_variables.size ()) {
535- // List registered scalar state variables to help debug.
536- std::string registered;
537- for (auto const &sv : m_state_variables) {
538- if (sv.kind == SymbolDecl::Kind::Scalar) {
539- if (!registered.empty ()) registered += " , " ;
540- registered += sv.name ;
541- }
542- }
543- throw std::runtime_error (std::format (
544- " ConstitutiveModel '{}': add_scalar_evolution_equation handle "
545- " names scalar state variable '{}' but no such state variable "
546- " is registered on this model. Did the handle come from a "
547- " different ConstitutiveModel, or was the state variable added "
548- " with add_tensor_state_variable instead? Registered scalar "
549- " state variables: [{}]." ,
550- m_name, sv_name, registered));
551- }
501+ auto const found_idx = resolve_scalar_state_var_index_ (
502+ state_var, " add_scalar_evolution_equation" );
503+ auto const &sv_name = m_state_variables[found_idx].name ;
504+ assert_state_var_unbound_ (found_idx, sv_name,
505+ " add_scalar_evolution_equation" );
552506
553507 // Validate that the rate expression's leaves are all declared
554508 // symbols on this model (PR #69 round-1 #4). Fail fast in the
@@ -562,6 +516,36 @@ class ConstitutiveModel {
562516 m_evolution_equations.push_back (std::move (eq));
563517 }
564518
519+ // Phase D strain-coupled: declare an IMPLICIT residual `R(x, inputs) = 0` for
520+ // an already-added scalar state variable, solved by a Newton solver. Unlike a
521+ // rate, the residual MAY (and typically does) reference tensor inputs (strain)
522+ // — that is the coupling — hence the `tensor_to_scalar`-typed residual. A state
523+ // variable carries EITHER a rate OR a residual, never both (enforced).
524+ //
525+ // Scope (current): the residual is `tensor_to_scalar`-typed, so a purely
526+ // scalar implicit residual (no tensor dependence) is intentionally NOT
527+ // expressible here — a strain-independent scalar evolution is the rate path's
528+ // job (add_scalar_evolution_equation). The residual also cannot reference the
529+ // framework time step `dt` (it is not auto-registered on this path): the
530+ // first target is rate-INDEPENDENT (e.g. return-map plasticity); a
531+ // rate-dependent (viscoplastic) residual needing `dt` is a follow-up. The
532+ // residual's differentiability (∂R/∂x, ∂R/∂ε) is checked at EMIT time, where
533+ // a non-differentiable t2s node (e.g. a piecewise if_then_else, cas#241)
534+ // surfaces a clear cas error.
535+ auto add_scalar_residual_equation (
536+ ScalarStateVariableHandle const &state_var,
537+ cas::expression_holder<cas::tensor_to_scalar_expression> residual,
538+ std::string doc = " " ) -> void {
539+ auto const found_idx = resolve_scalar_state_var_index_ (
540+ state_var, " add_scalar_residual_equation" );
541+ auto const &sv_name = m_state_variables[found_idx].name ;
542+ assert_state_var_unbound_ (found_idx, sv_name,
543+ " add_scalar_residual_equation" );
544+ validate_residual_expression_leaves_ (residual, sv_name);
545+ ResidualEquation eq{found_idx, std::move (residual), std::move (doc)};
546+ m_residual_equations.push_back (std::move (eq));
547+ }
548+
565549 // ─── Output declarations ────────────────────────────────────────
566550
567551 void add_output (std::string name,
@@ -769,6 +753,11 @@ class ConstitutiveModel {
769753 return m_evolution_equations;
770754 }
771755
756+ [[nodiscard]] auto residual_equations () const noexcept
757+ -> std::span<ResidualEquation const > {
758+ return m_residual_equations;
759+ }
760+
772761 // Phase 3a-2 (issue #75): opt into in-function local Newton solving.
773762 // When enabled (and the recipe has evolution equations),
774763 // `emit_compute_function` registers `LocalNewtonLoweringPass` instead
@@ -994,6 +983,126 @@ class ConstitutiveModel {
994983 m_parameters_cache.back ().is_time_step = true ;
995984 }
996985
986+ // Resolve a ScalarStateVariableHandle to its index in m_state_variables,
987+ // with the cross-recipe-hijack + bare-leaf defenses (PR #69 round-1 #3).
988+ // `caller` names the public method for the diagnostic. Shared by
989+ // add_scalar_evolution_equation and add_scalar_residual_equation.
990+ [[nodiscard]] auto resolve_scalar_state_var_index_ (
991+ ScalarStateVariableHandle const &state_var, std::string_view caller) const
992+ -> std::size_t {
993+ if (state_var.model_token == nullptr ) {
994+ throw std::runtime_error (std::format (
995+ " ConstitutiveModel '{}': {} handle has no model token. Handles must "
996+ " come from add_scalar_state_variable on this model — "
997+ " manually-constructed handles are not accepted." ,
998+ m_name, caller));
999+ }
1000+ if (state_var.model_token != this ) {
1001+ throw std::runtime_error (std::format (
1002+ " ConstitutiveModel '{}': {} handle came from a different "
1003+ " ConstitutiveModel (handle.model_token = {}, this = {}). Each handle "
1004+ " is bound to the recipe that created it; cross-recipe use would "
1005+ " silently bind by name and discretise the wrong state variable." ,
1006+ m_name, caller, static_cast <void const *>(state_var.model_token ),
1007+ static_cast <void const *>(this )));
1008+ }
1009+ auto const *typed =
1010+ dynamic_cast <cas::scalar const *>(state_var.current .data ().get ());
1011+ if (!typed) {
1012+ throw std::runtime_error (std::format (
1013+ " ConstitutiveModel '{}': {} handle's `current` is not a bare scalar "
1014+ " leaf symbol. Handles must come from add_scalar_state_variable — you "
1015+ " cannot synthesise one from a compound expression like `K * alpha`." ,
1016+ m_name, caller));
1017+ }
1018+ auto const &sv_name = typed->name ();
1019+ for (std::size_t i = 0 ; i < m_state_variables.size (); ++i) {
1020+ if (m_state_variables[i].kind == SymbolDecl::Kind::Scalar &&
1021+ m_state_variables[i].name == sv_name) {
1022+ return i;
1023+ }
1024+ }
1025+ std::string registered;
1026+ for (auto const &sv : m_state_variables) {
1027+ if (sv.kind == SymbolDecl::Kind::Scalar) {
1028+ if (!registered.empty ()) registered += " , " ;
1029+ registered += sv.name ;
1030+ }
1031+ }
1032+ throw std::runtime_error (std::format (
1033+ " ConstitutiveModel '{}': {} handle names scalar state variable '{}' but "
1034+ " no such state variable is registered on this model. Did the handle "
1035+ " come from a different ConstitutiveModel, or was the state variable "
1036+ " added with add_tensor_state_variable instead? Registered scalar state "
1037+ " variables: [{}]." ,
1038+ m_name, caller, sv_name, registered));
1039+ }
1040+
1041+ // A scalar state variable carries EXACTLY ONE evolution mechanism — a rate
1042+ // (EvolutionEquation) or an implicit residual (ResidualEquation). Reject a
1043+ // second binding rather than emitting a contradictory material.
1044+ void assert_state_var_unbound_ (std::size_t idx, std::string_view sv_name,
1045+ std::string_view caller) const {
1046+ for (auto const &e : m_evolution_equations) {
1047+ if (e.state_variable_idx == idx) {
1048+ throw std::runtime_error (std::format (
1049+ " ConstitutiveModel '{}': {} — state variable '{}' already has a "
1050+ " rate evolution equation; a state variable carries at most one "
1051+ " rate or residual." ,
1052+ m_name, caller, sv_name));
1053+ }
1054+ }
1055+ for (auto const &r : m_residual_equations) {
1056+ if (r.state_variable_idx == idx) {
1057+ throw std::runtime_error (std::format (
1058+ " ConstitutiveModel '{}': {} — state variable '{}' already has an "
1059+ " implicit residual equation; a state variable carries at most one "
1060+ " rate or residual." ,
1061+ m_name, caller, sv_name));
1062+ }
1063+ }
1064+ }
1065+
1066+ // Like validate_rate_expression_leaves_ but for a t2s residual: every leaf
1067+ // (scalar AND tensor) must be a declared symbol, and the state must itself
1068+ // appear (else ∂R/∂x ≡ 0 — a singular Newton Jacobian).
1069+ void validate_residual_expression_leaves_ (
1070+ cas::expression_holder<cas::tensor_to_scalar_expression> const &residual,
1071+ std::string_view sv_name) const {
1072+ LeafCollector lc;
1073+ lc.collect_t2s (residual);
1074+ std::vector<std::string> missing;
1075+ for (auto const &leaf_name : lc.scalar_names ()) {
1076+ bool found = false ;
1077+ for (auto const &[name, _] : m_scalar_symbols)
1078+ if (name == leaf_name) { found = true ; break ; }
1079+ if (!found) missing.push_back (" scalar '" + leaf_name + " '" );
1080+ }
1081+ for (auto const &leaf_name : lc.tensor_names ()) {
1082+ bool found = false ;
1083+ for (auto const &[name, _] : m_tensor_symbols)
1084+ if (name == leaf_name) { found = true ; break ; }
1085+ if (!found) missing.push_back (" tensor '" + leaf_name + " '" );
1086+ }
1087+ if (!missing.empty ()) {
1088+ std::string msg = std::format (
1089+ " ConstitutiveModel '{}': add_scalar_residual_equation residual for "
1090+ " state variable '{}' references undeclared symbol(s):" ,
1091+ m_name, sv_name);
1092+ for (auto const &m : missing) msg += std::format (" \n - {}" , m);
1093+ msg += " \n Call add_scalar_input / add_tensor_input / add_parameter before "
1094+ " referencing a symbol in a residual." ;
1095+ throw std::runtime_error (msg);
1096+ }
1097+ if (!lc.scalar_names ().contains (std::string (sv_name))) {
1098+ throw std::runtime_error (std::format (
1099+ " ConstitutiveModel '{}': add_scalar_residual_equation residual for "
1100+ " state variable '{}' does not reference its own state — the residual "
1101+ " must depend on '{}' (else ∂R/∂{} ≡ 0, a singular Newton Jacobian)." ,
1102+ m_name, sv_name, sv_name, sv_name));
1103+ }
1104+ }
1105+
9971106 // PR #69 round-1 #4: validate that the leaves of a rate expression
9981107 // are all registered symbols on this model. Called from
9991108 // `add_scalar_evolution_equation` to fail fast in the user's stack
@@ -1043,6 +1152,7 @@ class ConstitutiveModel {
10431152 std::vector<OutputDecl> m_outputs;
10441153 std::vector<StateVariable> m_state_variables; // Phase 2.1
10451154 std::vector<EvolutionEquation> m_evolution_equations; // Phase 2.2
1155+ std::vector<ResidualEquation> m_residual_equations; // Phase D strain-coupled
10461156 bool m_local_newton = false ; // Phase 3a-2 (issue #75)
10471157 NewtonOptions m_newton_options{}; // Phase 3a-2 (issue #75)
10481158 std::vector<TangentSpec> m_tangents; // Phase 3b-1 (issue #35)
0 commit comments