Skip to content

Commit a18bb8a

Browse files
author
petlenz
committed
Address #85 review: fix convergence off-by-one + guard scalar-path Newton locals
1 parent f3f81b7 commit a18bb8a

4 files changed

Lines changed: 168 additions & 54 deletions

File tree

‎include/numsim_codegen/code_emit/linear_algebra_emitter.h‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,16 @@ class LinearAlgebraEmitter {
5959
// step sets `true` when `‖R‖∞ < tol`, so the render frame can detect a
6060
// max-iter/singular failure after the loop. The step also `break`s without
6161
// setting it when the dense solve is non-finite (singular Jacobian).
62+
// `iter_var` / `max_iter` (issue #85): the loop runs `iter_var <= max_iter`,
63+
// so the residual of the final update is checked; the step caps the UPDATE at
64+
// `max_iter` by `break`ing (post-convergence-check) when `iter_var == max_iter`.
6265
virtual void emit_newton_step(
6366
std::ostream &os, std::string const &prefix,
6467
std::vector<std::string> const &unknowns,
6568
std::vector<std::string> const &residual_rhs,
6669
std::vector<std::vector<std::string>> const &jacobian_rhs,
67-
double tol, std::string const &converged_flag) const = 0;
70+
double tol, std::string const &converged_flag,
71+
std::string const &iter_var, int max_iter) const = 0;
6872
};
6973

7074
// Eigen implementation — fixed-size `Eigen::Matrix<double,N,·>` + partial-pivot
@@ -86,7 +90,8 @@ class EigenLinearAlgebraEmitter final : public LinearAlgebraEmitter {
8690
std::vector<std::string> const &unknowns,
8791
std::vector<std::string> const &residual_rhs,
8892
std::vector<std::vector<std::string>> const &jacobian_rhs,
89-
double tol, std::string const &converged_flag) const override {
93+
double tol, std::string const &converged_flag,
94+
std::string const &iter_var, int max_iter) const override {
9095
auto const n = unknowns.size();
9196
auto L = [&](char const *s) { return prefix + "_" + s; };
9297
// Residual vector R (size N).
@@ -108,10 +113,16 @@ class EigenLinearAlgebraEmitter final : public LinearAlgebraEmitter {
108113
// Convergence on the residual ∞-norm (issue #85: record it).
109114
os << " if (" << L("r") << ".cwiseAbs().maxCoeff() < " << tol
110115
<< ") {\n " << converged_flag << " = true;\n break;\n }\n";
116+
// issue #85: the loop runs one pass past max_iter to check the final
117+
// update's residual (above); cap the UPDATE itself at max_iter.
118+
os << " if (" << iter_var << " == " << max_iter
119+
<< ") {\n break;\n }\n";
111120
// Solve J·Δx = R. issue #85: partialPivLu does NO rank check — on a
112121
// singular J it returns garbage (often non-finite). Guard the solution and
113-
// break rather than propagate it; a near-singular-but-finite step is caught
114-
// by the outer convergence flag (the loop exhausts max_iter).
122+
// break rather than propagate it. (A near-singular-but-finite solution is
123+
// NOT caught here — it either fails to converge within max_iter, or, like any
124+
// Newton-without-globalization, may reach a spurious root; inherent, not a
125+
// guarantee this guard provides.)
115126
os << " Eigen::Matrix<double, " << n << ", 1> " << L("dx") << " = "
116127
<< L("J") << ".partialPivLu().solve(" << L("r") << ");\n";
117128
os << " if (!" << L("dx") << ".allFinite()) {\n break;\n }\n";
@@ -128,6 +139,13 @@ class EigenLinearAlgebraEmitter final : public LinearAlgebraEmitter {
128139
// `cwiseAbs().maxCoeff()`, `arma::solve` vs `partialPivLu().solve`), yet it
129140
// slots behind the same four methods. NOTE: `arma::solve` links LAPACK/BLAS, a
130141
// heavier downstream dependency than header-only Eigen.
142+
//
143+
// STATUS: opt-in DEMONSTRATOR, kept for a possible future non-Eigen backend.
144+
// Nothing in the library selects it (Eigen is the default and the only backend
145+
// any target uses), so its emitted code is exercised by STRING assertions only
146+
// (LinearAlgebraEmitterTest) — it is never compiled or run. Treat the emitted
147+
// Armadillo (e.g. the bool-overload `arma::solve(dx, J, r)` below) as
148+
// plausible-but-unverified until a gated compile test is added.
131149
class ArmadilloLinearAlgebraEmitter final : public LinearAlgebraEmitter {
132150
public:
133151
[[nodiscard]] auto includes() const -> std::vector<std::string> override {
@@ -145,7 +163,8 @@ class ArmadilloLinearAlgebraEmitter final : public LinearAlgebraEmitter {
145163
std::vector<std::string> const &unknowns,
146164
std::vector<std::string> const &residual_rhs,
147165
std::vector<std::vector<std::string>> const &jacobian_rhs,
148-
double tol, std::string const &converged_flag) const override {
166+
double tol, std::string const &converged_flag,
167+
std::string const &iter_var, int max_iter) const override {
149168
auto const n = unknowns.size();
150169
auto L = [&](char const *s) { return prefix + "_" + s; };
151170
// Residual vector R (size N) — element assignment (Armadillo has no
@@ -165,6 +184,9 @@ class ArmadilloLinearAlgebraEmitter final : public LinearAlgebraEmitter {
165184
// Convergence on the residual ∞-norm (issue #85: record it).
166185
os << " if (arma::norm(" << L("r") << ", \"inf\") < " << tol
167186
<< ") {\n " << converged_flag << " = true;\n break;\n }\n";
187+
// issue #85: cap the update at max_iter (see the Eigen sibling).
188+
os << " if (" << iter_var << " == " << max_iter
189+
<< ") {\n break;\n }\n";
168190
// Solve J·Δx = R via arma::solve's bool overload, which returns false on a
169191
// singular / rank-deficient system (issue #85) — break rather than update
170192
// with garbage. A near-singular-but-finite step is caught by the outer

‎include/numsim_codegen/recipe.h‎

Lines changed: 106 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -255,23 +255,43 @@ struct UpdateEquation {
255255
std::string doc;
256256
};
257257

258-
// Phase 3a-2 (issue #75): tuning for the in-function Newton solve emitted
259-
// by LocalNewtonLoweringPass. `tol` is the absolute residual threshold for
260-
// convergence; `max_iter` caps the iteration count. Recipe-level for now
258+
// Phase 3a-2 (issue #75): tuning for the in-function Newton solve emitted by
259+
// LocalNewtonLoweringPass — i.e. the SELF-CONTAINED (standalone / MOOSE-local)
260+
// target path. `tol` is the ABSOLUTE residual ∞-norm threshold; `max_iter` is
261+
// the maximum number of Newton UPDATES (the emitted loop additionally checks the
262+
// residual of that final update before deciding failure). Recipe-level for now
261263
// (uniform across all evolution equations); per-equation tuning can be added
262264
// when a real consumer needs it.
265+
//
266+
// SCOPE (issue #85): these options drive ONLY the self-contained emitted loop.
267+
// The graph-coupled NumSimMaterialTarget (Mode B) delegates to numsim-materials'
268+
// `backward_euler`, which has its own convergence handling and ignores
269+
// `on_failure` — a residual-equation recipe emitted there is unaffected by this
270+
// struct.
271+
//
272+
// `tol` is ABSOLUTE: a well-scaled solve whose residual carries large physical
273+
// units may never reach a tiny absolute `tol` and will then be reported as
274+
// FAILED (below). Set `tol` to the residual's scale; a relative test is a future
275+
// enhancement.
263276
struct NewtonOptions {
264-
// Failure policy (issue #85): what the generated solve does when it fails to
265-
// converge — the loop exhausts `max_iter` without the residual reaching `tol`,
266-
// OR the Newton step is non-finite (singular Jacobian). Without this the loop
277+
// Failure policy (issue #85): what the generated solve does when it fails —
278+
// the loop exhausts `max_iter` updates without the residual reaching `tol`, OR
279+
// the Newton step is non-finite (singular Jacobian). Without this the loop
267280
// silently wrote back the last (un-converged) iterate, corrupting the host FE
268281
// solve with no signal.
269-
// NaN — write a quiet NaN to the failed state variable(s); it propagates
270-
// into every output derived from them, so the host detects a
271-
// non-finite result (MOOSE cutback-friendly — no throw in a QP loop).
282+
// NaN — write a quiet NaN to the failed state variable(s). It propagates
283+
// through ordinary arithmetic into outputs derived from the state,
284+
// so a host detects a non-finite result (MOOSE cutback-friendly — no
285+
// throw in a QP loop). CONTRACT: the host must check the STATE
286+
// out-param(s), which are always NaN'd. NaN is NOT guaranteed to
287+
// survive a derived output that passes it through `std::fmax`/`fmin`
288+
// (IEEE min/max ignore a NaN operand — e.g. a Macaulay bracket /
289+
// `max(·,0)` in a return map), so an output alone can read finite.
290+
// Use `Throw` when the failure must be unmissable.
272291
// Throw — throw std::runtime_error from the generated compute function
273292
// (clean for standalone drivers; unsafe inside a MOOSE QP loop).
274-
// NaN is the default: universally detectable and safe on every backend.
293+
// NaN is the default: universally detectable on the state and safe on every
294+
// backend.
275295
enum class OnFailure { NaN, Throw };
276296

277297
double tol = 1e-10;
@@ -1542,6 +1562,42 @@ inline auto tensor_arg_count(RecipeView model) -> int {
15421562
return n;
15431563
}
15441564

1565+
// Identifiers already live in the generated function's scope: every declared
1566+
// symbol (inputs, params, each state var's current + `_old`) and every output
1567+
// (`<out>` and its `<out>_out` param). A Newton solve's scaffolding locals must
1568+
// avoid all of these. (issue #85 / PR #83.)
1569+
inline auto newton_reserved_ids(RecipeView model) -> std::set<std::string> {
1570+
std::set<std::string> reserved;
1571+
for (auto const &[nm, _] : model.scalar_symbol_map())
1572+
reserved.insert(nm);
1573+
for (auto const &[nm, _] : model.tensor_symbol_map())
1574+
reserved.insert(nm);
1575+
for (auto const &o : model.outputs()) {
1576+
reserved.insert(o.name);
1577+
reserved.insert(o.name + "_out");
1578+
}
1579+
return reserved;
1580+
}
1581+
1582+
// Mangle `seed` (by appending `_`) until none of `seed + "_" + suffix` collides
1583+
// with a reserved identifier — so the solve's scaffolding locals (`<p>_R`,
1584+
// `<p>_iter`, `<p>_converged`, …) are guaranteed unique in the emitted scope.
1585+
// Shared by the scalar-segment and coupled-system loops (issue #85 closes the
1586+
// scalar-path gap: PR #83 had mangled only the coupled path).
1587+
inline auto collision_free_prefix(std::string seed,
1588+
std::set<std::string> const &reserved,
1589+
std::vector<std::string> const &suffixes)
1590+
-> std::string {
1591+
auto collides = [&](std::string const &pre) {
1592+
for (auto const &s : suffixes)
1593+
if (reserved.contains(pre + "_" + s)) return true;
1594+
return false;
1595+
};
1596+
while (collides(seed))
1597+
seed += "_";
1598+
return seed;
1599+
}
1600+
15451601
// issue #85: emit the post-loop failure action for a local Newton solve. Runs
15461602
// after the loop, keyed on the `converged_flag` the loop sets true only on a
15471603
// genuine `‖R‖ < tol` exit. On failure (max-iter exhausted or a singular
@@ -1790,28 +1846,47 @@ inline auto render_compute_function(
17901846

17911847
// Newton iteration segments first — they declare + solve the local
17921848
// state-variable iterates that downstream output expressions reference.
1849+
//
1850+
// The iterate keeps the state-variable name `<sv>` (output expressions
1851+
// reference it), but the solve's scaffolding locals use a collision-free
1852+
// prefix `<p>` (issue #85: PR #83 mangled only the coupled path, leaving
1853+
// `<sv>_R`/`_J`/`_iter`/`_converged` able to redeclare a like-named symbol).
1854+
auto const scalar_reserved = detail::newton_reserved_ids(model);
1855+
std::vector<std::string> const scalar_suffixes{"R", "J", "d", "iter",
1856+
"converged"};
17931857
for (auto const &seg : newton) {
17941858
auto const &sv = seg.state_var_name;
1859+
auto const p =
1860+
detail::collision_free_prefix(sv, scalar_reserved, scalar_suffixes);
17951861
os << " double " << sv << " = " << sv << "_old;\n";
17961862
// issue #85: track convergence so a max-iter exhaustion or a singular
17971863
// Jacobian is detected rather than silently writing the last iterate.
1798-
os << " bool " << sv << "_converged = false;\n";
1799-
os << " for (int " << sv << "_iter = 0; " << sv << "_iter < "
1800-
<< seg.max_iter << "; ++" << sv << "_iter) {\n";
1864+
os << " bool " << p << "_converged = false;\n";
1865+
// `<= max_iter` so the residual of the FINAL (max_iter-th) update is checked
1866+
// before the loop exits — a solve that converges on its last allowed update
1867+
// must be recognised, not falsely NaN'd. The update itself is still capped
1868+
// at max_iter (the `== max_iter` guard below), so `max_iter` is the update
1869+
// budget.
1870+
os << " for (int " << p << "_iter = 0; " << p << "_iter <= "
1871+
<< seg.max_iter << "; ++" << p << "_iter) {\n";
18011872
os << seg.loop_local_decls; // already 4-indented, trailing newline
1802-
os << " double " << sv << "_R = " << seg.residual_rhs << ";\n";
1803-
os << " double " << sv << "_J = " << seg.jacobian_rhs << ";\n";
1804-
os << " if (std::abs(" << sv << "_R) < " << seg.tol << ") {\n "
1805-
<< sv << "_converged = true;\n break;\n }\n";
1873+
os << " double " << p << "_R = " << seg.residual_rhs << ";\n";
1874+
os << " double " << p << "_J = " << seg.jacobian_rhs << ";\n";
1875+
os << " if (std::abs(" << p << "_R) < " << seg.tol << ") {\n " << p
1876+
<< "_converged = true;\n break;\n }\n";
1877+
os << " if (" << p << "_iter == " << seg.max_iter
1878+
<< ") {\n break;\n }\n";
18061879
// A singular / ill-scaled Jacobian makes the update non-finite; stop rather
1807-
// than propagate inf/nan through further iterations. Near-singular-but-finite
1808-
// steps are caught by the convergence flag (the loop exhausts max_iter).
1809-
os << " double " << sv << "_d = " << sv << "_R / " << sv << "_J;\n";
1810-
os << " if (!std::isfinite(" << sv << "_d)) {\n break;\n }\n";
1811-
os << " " << sv << " -= " << sv << "_d;\n";
1880+
// than propagate inf/nan. (A near-singular-but-finite step is not caught
1881+
// here — it either fails to converge within max_iter, or, like any
1882+
// Newton-without-globalization, may reach a spurious root; that is inherent,
1883+
// not introduced by this guard.)
1884+
os << " double " << p << "_d = " << p << "_R / " << p << "_J;\n";
1885+
os << " if (!std::isfinite(" << p << "_d)) {\n break;\n }\n";
1886+
os << " " << sv << " -= " << p << "_d;\n";
18121887
os << " }\n";
18131888
detail::emit_newton_failure_policy(
1814-
os, sv + "_converged", {sv}, seg.throw_on_failure, model.name(),
1889+
os, p + "_converged", {sv}, seg.throw_on_failure, model.name(),
18151890
"state variable '" + sv + "'");
18161891
}
18171892

@@ -1831,35 +1906,22 @@ inline auto render_compute_function(
18311906
auto suffixes = la.local_suffixes();
18321907
suffixes.emplace_back("iter"); // render's loop counter
18331908
suffixes.emplace_back("converged"); // issue #85 convergence flag
1834-
std::set<std::string> reserved_ids;
1835-
for (auto const &[nm, _] : model.scalar_symbol_map())
1836-
reserved_ids.insert(nm);
1837-
for (auto const &[nm, _] : model.tensor_symbol_map())
1838-
reserved_ids.insert(nm);
1839-
for (auto const &o : model.outputs()) {
1840-
reserved_ids.insert(o.name);
1841-
reserved_ids.insert(o.name + "_out");
1842-
}
1909+
auto const reserved_ids = detail::newton_reserved_ids(model);
18431910
for (auto const &sys : newton_systems) {
1844-
std::string p = sys.unknowns[0]; // seed; mangled below until collision-free
1845-
auto collides = [&](std::string const &pre) {
1846-
for (auto const &s : suffixes) {
1847-
if (reserved_ids.contains(pre + "_" + s)) return true;
1848-
}
1849-
return false;
1850-
};
1851-
while (collides(p)) {
1852-
p += "_";
1853-
}
1911+
auto const p =
1912+
detail::collision_free_prefix(sys.unknowns[0], reserved_ids, suffixes);
18541913
for (auto const &u : sys.unknowns) {
18551914
os << " double " << u << " = " << u << "_old;\n";
18561915
}
18571916
os << " bool " << p << "_converged = false;\n"; // issue #85
1858-
os << " for (int " << p << "_iter = 0; " << p << "_iter < "
1917+
// `<= max_iter`: the residual of the final update is checked before exit
1918+
// (see the scalar path); emit_newton_step caps the update at max_iter.
1919+
os << " for (int " << p << "_iter = 0; " << p << "_iter <= "
18591920
<< sys.max_iter << "; ++" << p << "_iter) {\n";
18601921
os << sys.loop_local_decls; // shared CSE temps (4-indented)
18611922
la.emit_newton_step(os, p, sys.unknowns, sys.residual_rhs,
1862-
sys.jacobian_rhs, sys.tol, p + "_converged");
1923+
sys.jacobian_rhs, sys.tol, p + "_converged",
1924+
p + "_iter", sys.max_iter);
18631925
os << " }\n";
18641926
std::string desc = "coupled system {";
18651927
for (std::size_t i = 0; i < sys.unknowns.size(); ++i)

‎tests/LinearAlgebraEmitterTest.cpp‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ TEST(LinearAlgebraEmitter, EigenEmitsDenseSolveBody) {
3838
EigenLinearAlgebraEmitter const e;
3939
std::ostringstream os;
4040
e.emit_newton_step(os, "sys", {"a", "b"}, {"Ra", "Rb"},
41-
{{"J00", "J01"}, {"J10", "J11"}}, 1e-10, "sys_converged");
41+
{{"J00", "J01"}, {"J10", "J11"}}, 1e-10, "sys_converged",
42+
"sys_iter", 25);
4243
auto const src = os.str();
4344
// Fixed-size matrices, row-major fill, ∞-norm convergence, partial-pivot LU.
4445
EXPECT_NE(src.find("Eigen::Matrix<double, 2, 1> sys_r"), std::string::npos)
@@ -51,8 +52,9 @@ TEST(LinearAlgebraEmitter, EigenEmitsDenseSolveBody) {
5152
<< src;
5253
EXPECT_NE(src.find("sys_J.partialPivLu().solve(sys_r)"), std::string::npos)
5354
<< src;
54-
// issue #85: convergence recorded, and the singular solve is guarded.
55+
// issue #85: convergence recorded, update capped at max_iter, singular guard.
5556
EXPECT_NE(src.find("sys_converged = true"), std::string::npos) << src;
57+
EXPECT_NE(src.find("sys_iter == 25"), std::string::npos) << src;
5658
EXPECT_NE(src.find("sys_dx.allFinite()"), std::string::npos) << src;
5759
// Each unknown updated from the solution vector.
5860
EXPECT_NE(src.find("a -= sys_dx(0)"), std::string::npos) << src;
@@ -76,7 +78,8 @@ TEST(LinearAlgebraEmitter, ArmadilloEmitsDenseSolveBody) {
7678
ArmadilloLinearAlgebraEmitter const a;
7779
std::ostringstream os;
7880
a.emit_newton_step(os, "sys", {"a", "b"}, {"Ra", "Rb"},
79-
{{"J00", "J01"}, {"J10", "J11"}}, 1e-10, "sys_converged");
81+
{{"J00", "J01"}, {"J10", "J11"}}, 1e-10, "sys_converged",
82+
"sys_iter", 25);
8083
auto const src = os.str();
8184
// Fixed-size types, element assignment, ∞-norm via arma::norm, arma::solve.
8285
EXPECT_NE(src.find("arma::vec::fixed<2> sys_r"), std::string::npos) << src;
@@ -90,6 +93,7 @@ TEST(LinearAlgebraEmitter, ArmadilloEmitsDenseSolveBody) {
9093
EXPECT_NE(src.find("arma::solve(sys_dx, sys_J, sys_r)"), std::string::npos)
9194
<< src;
9295
EXPECT_NE(src.find("sys_converged = true"), std::string::npos) << src;
96+
EXPECT_NE(src.find("sys_iter == 25"), std::string::npos) << src;
9397
EXPECT_NE(src.find("a -= sys_dx(0)"), std::string::npos) << src;
9498
EXPECT_NE(src.find("b -= sys_dx(1)"), std::string::npos) << src;
9599
// No Eigen leakage.
@@ -106,7 +110,8 @@ TEST(LinearAlgebraEmitter, EveryBackendEmitsItsUsageMarker) {
106110
for (auto const *la : backends) {
107111
std::ostringstream os;
108112
la->emit_newton_step(os, "p", {"a", "b"}, {"Ra", "Rb"},
109-
{{"J00", "J01"}, {"J10", "J11"}}, 1e-10, "p_converged");
113+
{{"J00", "J01"}, {"J10", "J11"}}, 1e-10, "p_converged",
114+
"p_iter", 10);
110115
auto const src = os.str();
111116
EXPECT_NE(src.find(la->usage_marker()), std::string::npos)
112117
<< "marker '" << la->usage_marker() << "' absent from its emission";

0 commit comments

Comments
 (0)