Skip to content

Property engine: iteration groups converge local edges inside update() (#214) - #226

Open
petlenz wants to merge 4 commits into
mainfrom
feature/issue-214
Open

petlenz wants to merge 4 commits into
mainfrom
feature/issue-214

Conversation

@petlenz

@petlenz petlenz commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Closes #214 (part of epic #221).

Merge after #222 and #223. This branch contains both. Its own change is the last commit.

What and why

Until now, material_point::evaluate() ran the fixed point of a local edge by rerunning the whole graph. Every callback outside the loop ran once per pass: recorders, plots and steppers. The loop also worked only in hosts that set residual_source. This PR moves the fixed point into update().

  • Declaration. material_base::converges(residual, tolerance, max_passes) takes the residual output and references to the two parameters, which are read on every update. Engine API: material_interface::converging_outputs().
  • Groups. At finalize(), property_engine::form_groups() builds one iteration group per declaration. A local edge P→Q (Q reads P's previous value; the typed edges of Property engine: typed edges, graph export (DOT/JSON) and a named cycle (#213, #188) #223) seeds the group only if it closes a loop: P is downstream of Q through non-local edges and upstream of the residual (upstream_of()). The group is the callbacks on the paths from such a Q to the residual. Solver-internal pairs whose both edges are local (j2_plasticity and its hardening law) never seed a group. The callback list is then split into stages. Before each group come the callbacks it reads that have not run yet. Then the group itself. Then everything else.
  • update(). It runs the stages in order. A group reruns until |residual| <= tolerance, and a residual that is not finite (NaN or infinity) never converges, even with an infinite tolerance. After max_passes, or at a non-finite residual, it throws numsim::materials::cutback_error: 'mt::residual' did not reach 1e-10 in 200 passes (last …).
  • Constants. Constant folding, retire() and verify() are unchanged. The active callbacks are redistributed into their stages whenever the active list changes (reactivate(), retire()).
  • update_property() on a group member runs its upstream once, without iterating. The code is unchanged: it uses m_active in topological order.
  • Checks at finalize(), each naming the node:
    • converges() with no closed local loop behind the residual (logic_error);
    • a stateful() callback inside a group (logic_error);
    • a second residual over the same callbacks (logic_error, "one residual per group");
    • a tolerance below zero or NaN, or max_passes below one (invalid_argument);
    • a loop that runs through the group of another residual, i.e. nested loops (logic_error, names both residuals);
    • groups that read each other (logic_error).
  • mean_field_composite_local_nonlinear declares converges(residual). Its new parameters are tolerance (default 1e-10) and max_passes (default 200), matching what the reports and tests used. orientation_phases passes both through to the generated nonlinear composite and rejects them for a linear one.
  • Compatibility.
    • material_point's residual_source, residual_tolerance and max_passes still work. With a converging composite, they find the residual converged after the one update and do nothing. They are marked deprecated in the comments and docs. I did not use [[deprecated]]: these are runtime parameters, and existing tests still use them.
    • The UMAT config::residual_property and the report "point" keys are deprecated the same way and still parsed.
    • The three report decks now set "tolerance"/"max_passes" on the composite (on the orientation_phases entry for short_fibre_damage), not on "point".
  • Docs: writing_a_material.md §6, mean_field.md §5, README core API, uvwmaterial-mapping.md.

Decisions and deviations from the issue

  • Nested loops are rejected, not supported. A loop that passes through the group of another residual would freeze the outer loop while the inner one converges once. finalize() throws instead and names both residuals. Nested support is out of scope.
  • Composite defaults change the behaviour against the host. mean_field_composite_local_nonlinear now converges inside update() with its own tolerance (default 1e-10) and max_passes (default 200). The host's residual_tolerance/max_passes no longer govern it: with a converging composite they find the residual converged after the one update and do nothing. A deck that relied on a looser host tolerance or a different pass count must set tolerance/max_passes on the composite.
  • The residual must be normalised. tolerance is compared with the residual as is. The composite's residual is the largest relative change of a phase tangent, ||C - C_used|| / ||C||, so 1e-10 is a relative bound. A custom converges() residual must be scaled the same way. A zero or NaN tangent gives a NaN residual (0/0) and throws cutback_error instead of passing.
  • Lifetime. converges() keeps pointers to tolerance and max_passes. The overloads that take a temporary (converges(r, 1e-10, 100)) are deleted, so the call is ill-formed instead of dangling.
  • Groups in series. Groups are formed in topological order of their residuals. Each one leaves out the callbacks of an earlier group, and the local edges that close an earlier group's loop do not seed it again. With the plain formula downstream(consumer) ∩ upstream(residual), a group that reads the converged output of another group would contain that group too, and finalize would report an overlap. Now the upstream group converges first and the downstream group iterates only its own callbacks. A second residual over the same loop is still an error, which is the issue's "one residual per group". Groups are disjoint by construction.
  • Stages, not a single prefix/suffix. With several groups, a callback can sit between two of them. Stages generalise the issue's prefix / groups / suffix, and with one group they are exactly that. A callback that neither the group nor anything before it reads goes after the group. Such a callback runs once, on the converged values.
  • Linear wiring. A nonlinear composite with no local edge now throws at finalize(), as the issue asks. test_mean_field_damage's all-global comparison model (build(false)) therefore uses mean_field_composite_local_linear.
  • Error type. cutback_error is the host-neutral numsim::materials::cutback_error of Host-neutral errors.h and external_state_source.h out of umat/ (#212) #222, so the umat::cutback_error alias catches it too.
  • Engine footprint. Engine changes stay in new members (form_groups(), distribute(), iterate(), run(), m_stages, m_stage_of) plus three call sites. compute_execution_order() is untouched, to keep the conflict with Property engine: partial derivatives per callback, chain rule by forward accumulation #217 small.

Tests

tests/test_iteration_groups.cpp (new): a guess node reads relax::x over a local edge, and relaxer declares converges(residual). A stateful probe sits upstream.

  • update() iterates to the residual.
  • A callback before the group runs once per update, counted by the stateful probe.
  • A property_recorder after the group writes one row per update holding the converged value. This failed before the change: the host loop wrote one row per pass.
  • residual_source on material_point still works and adds no rows.
  • max_passes = 3 throws cutback_error naming relax::residual. The context is failed until revert(), and max_passes is reread after set_parameter().
  • A NaN residual, and an infinite residual under an infinite tolerance, stop at pass 1.
  • Settings changed after finalize() (negative tolerance, max_passes 0) throw invalid_argument at the next update.
  • update_property() on a member runs one pass.
  • An upstream constant stays folded and follows set_parameter().
  • A group downstream of another runs after it.
  • converges() without a local edge, a local edge whose producer is outside the group or behind the residual, a stateful() member, an overlapping second residual, nested loops, a loop through another group, and invalid settings each throw at finalize, naming the node.
  • Two solver halves that read each other over local edges, upstream of a residual, are not members: a pass counter shows each runs once per update.
  • The stage order is proven by a call log: the first group's callbacks all run before the second group's first, and the probe runs once.
  • converges() and declare_converges() with a temporary tolerance or max_passes are rejected by static_assert.

tests/test_mean_field_damage.cpp:

  • TheEngineIteratesBitwiseAsTheMaterialPointDid: 24 strain steps of loading and unloading, under strain and under uniaxial-stress control. The engine-owned loop and the old host loop give identical stress, tangent, damage, residual and driver residuals (EXPECT_EQ). The host loop is reproduced exactly: composite tolerance ∞, so one pass per update, plus residual_source, which repeats the whole update.
  • TheUmatEvaluatorConvergesWithoutAResidualProperty: the UMAT evaluator without residual_property converges every call. It failed before the change (residual 0.96 after one pass).
  • A NaN tangent and a zero tangent of the composite throw cutback_error.
  • ResidualDecreasesOverThePassesOfAStep now sets the composite tolerance to ∞ to observe single passes.

tests/test_orientation_phases.cpp: tolerance and max_passes pass through to the nonlinear composite and are rejected for a linear one.

Reports: I ran every docs/materials/*/report.json with material_report before the change (old decks with residual_source) and after it (new decks, engine-owned loop). All 208 CSVs of the 14 reports are bitwise identical, including mean_field_damage, mean_field_reuss_plastic and short_fibre_damage, and the driver iteration logs.

Gate

GCC 14, full build and ctest: BUILD=0 TEST=0, 0 warnings, 1538/1538 tests passed (after merging main).

#214)

material_base::converges(residual, tolerance, max_passes) declares a fixed
point. The engine forms one group per declaration from the callbacks between
a local edge's consumer and the residual, splits the callback list into
stages and reruns each group until the residual meets its tolerance, or
throws cutback_error after max_passes or on NaN. The nonlinear mean-field
composite declares its residual; material_point's residual_source, the UMAT
residual_property and the report "point" residual keys are deprecated.
…nverges() arguments (#214)

form_groups seeds a group only from a local edge P->Q whose producer is
downstream of its consumer and upstream of the residual, so solver-internal
pairs (both edges local) are no longer iterated. A loop that runs through the
group of another residual is rejected at finalize, naming both residuals.
iterate() treats a non-finite residual as not converged before the tolerance
test and rechecks the settings on every update. The nonlinear composite keeps
a NaN residual (std::max dropped it). converges() and declare_converges()
delete the overloads that take a temporary tolerance or max_passes.
@petlenz

petlenz commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Review findings and how each was addressed (fix commit on top of this branch, then main merged in).

  1. Group membership never checked to close a loop. form_groups now seeds a group only from a local edge P->Q whose producer P is downstream of its consumer Q through non-local edges and upstream of the residual. Solver-internal pairs where both edges are local (j2_plasticity and its hardening law) are excluded by this rule. Tests: a producer outside the group, and a producer behind the residual, throw at finalize ("no closed local loop"); two solver halves upstream of a residual are not members (pass counter shows one run per update).
  2. Each group must contain its loop / nested loops. The loop of a local edge is the set of callbacks between Q and P. If it lies entirely in an earlier group, the edge belongs to that group and does not seed this one. If it partly overlaps an earlier group, finalize throws a logic_error naming both residuals and the edge ("nested loops are not supported"). I did not add the separate "split across the groups of A and B" branch: a split loop always shares callbacks with the earlier group, so the overlap check above already catches it, and a second branch would be unreachable. Tests: a nested loop and a loop through another residual's group both throw with the names. The "groups read each other" branch is still reached (test), so it stays.
  3. NaN in the nonlinear composite. std::max dropped a NaN, so a NaN tangent gave residual 0. It now keeps a NaN (isnan(v) || v > r). iterate() treats a non-finite residual as not converged before the tolerance test, so an infinite tolerance no longer accepts Inf/NaN, and it rechecks tolerance >= 0 and max_passes >= 1 on every update. Tests: composite with a NaN tangent and with a zero tangent (0/0) throw cutback_error; an infinite residual under an infinite tolerance stops at pass 1; settings changed after finalize throw invalid_argument.
  4. converges() keeps pointers to temporaries. material_base::converges and material_interface::declare_converges have deleted overloads for value_type&& and int&& tolerance/max_passes. static_assert tests check that converges(r, 1e-10, 100) and the mixed forms are ill-formed while lvalue references still work.
  5. Stage order only checked by final values. A call log now proves it: the first group's callbacks all run before the second group's first, the probe runs once, and the second group iterates.
  6. Unreachable branch. The "iterates no callback outside the group" branch is kept in a reduced form: a second residual over the same loop reports "one residual per group" (test).

PR description updated: composite defaults change the behaviour against the host residual_tolerance/max_passes, the residual must be normalised, nested loops are rejected at finalize (nested support is out of scope).

Gate after the merge of main: GCC 14 -O1, BUILD=0 TEST=0, 0 warnings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Property engine: iteration groups, the fixed point moves into update()

1 participant