Skip to content

Commit 1fcd094

Browse files
authored
Merge pull request #99 from NumSim-Stack/phase-d-implicit-residual-api
Phase D PR1: recipe API for implicit residual equations (strain-coupled scalar state)
2 parents 2ac5aa8 + e1b7121 commit 1fcd094

2 files changed

Lines changed: 323 additions & 68 deletions

File tree

include/numsim_codegen/recipe.h

Lines changed: 178 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -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 += "\nCall 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

Comments
 (0)