Skip to content

Property engine: residual systems set up at run time (#218) - #229

Open
petlenz wants to merge 13 commits into
mainfrom
feature/issue-218
Open

petlenz wants to merge 13 commits into
mainfrom
feature/issue-218

Conversation

@petlenz

@petlenz petlenz commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Closes #218 (part of epic #221).

Stacked: merge after #226 and #227 (#222 and #223 are already in main). This branch contains both (their latest commits merged in, conflicts in form_groups, partial_check.h and the derivative API resolved in the two merge commits); the diff against main shrinks to this PR once they are in.

What and why

A system node (registered as "system", class residual_system, solvers/system.h) owns unknowns and equations set up at run time:

{ "name": "plastic", "type": "system", "method": "newton",
  "unknowns":  [{ "name": "dlambda", "kind": "scalar",     "seed": 0 },
                { "name": "dbeta",   "kind": "sym_tensor", "seed": "history" }],
  "equations": [{ "residual": "flow::r_l", "for": "dlambda" },
                { "residual": "flow::r_b", "for": "dbeta" }],
  "tolerance": 1e-12, "max_iter": 25 }
  • The unknowns are plain outputs of the node (history outputs with "seed": "history"). The residuals are outputs of any nodes and are read over local edges, so the topological sort runs unknowns -> members -> residuals.
  • Members = callbacks downstream of the unknowns (non-local edges) and upstream of the residuals. A system without unknowns (converges(), picard) follows the closed-loop rule of Property engine: iteration groups converge local edges inside update() (#214) #226 instead. The Property engine: iteration groups, the fixed point moves into update() #214 rules still apply: no stateful() member, and a system with unknowns shares no member with another system or group. A system's unknowns count as part of it when stages are placed, so a system reading another system's unknowns runs after it.
  • "for" pairs each residual with an unknown and fixes the row order. Both use the Mandel layout of core/derivatives.h (the packing Property engine: partial derivatives per callback, chain rule by forward accumulation #217 uses, shared with solvers/unknown_layout.h).
  • newton: J = dR/dx comes from the declared partials. A multi-seed accumulation plan, built at finalize with the unknowns as seeds, is evaluated on every iteration. A path without a partial throws at finalize and names the path. A block with no path stays zero; residual_system::zero_blocks() lists those blocks, derived from the graph (this replaces the hand-declared zero_blocks / verify_zero_blocks).
  • newton_fd: J by forward differences, one Mandel component at a time, rerunning the members, with a step of sqrt(eps) * max(|x_j|, ||x||_inf) (1 when x is zero), so small-scale unknowns converge like Newton. No partials needed. The unknowns are put back at x before a throw.
  • picard: x <- x - R. With no unknowns it is the Property engine: iteration groups, the fixed point moves into update() #214 iteration group, with the closed-loop rule of Property engine: iteration groups converge local edges inside update() (#214) #226. material_base::converges() now declares such a system (one residual, no unknowns, passes counted as before). convergence_declaration / converging_outputs() become system_declaration / systems(), so there is one group mechanism in form_groups() / solve().
  • Carried over from vector_newton: evaluate-first convergence (|R|_inf <= tolerance after the update; max_iter counts Newton updates, so a linear system converges with max_iter = 1), no hidden projection, and an LU per iteration. Not converging, a non-finite residual (never converged, even with an infinite tolerance), or a singular Jacobian (non-finite dx) throws cutback_error that names the system. On a singular Jacobian the unknowns keep the last finite iterate. iterations() is set before a throw; zero_blocks() throws before finalize().
  • update_property() on a target that reads a system's unknowns or members runs that system's solve (a converges() group still runs once, as Property engine: iteration groups converge local edges inside update() (#214) #226 tests).
  • Implicit function theorem in differentiate(): a requested d target / d p whose upstream reaches a system's unknowns (also through another system) gets those unknowns as extra seed columns and their residuals as extra targets. One accumulation pass gives D[R] = [B | J]. Then fold() computes dx/dp = -J^-1 B for each system in stage order and adds D_x dx/dp to the p columns of every node; a non-finite dx/dp (singular J at the converged point) throws a cutback_error naming the system. A seed that is an output of a passed system's members throws logic_error (the system solves it again). This matches the issue's tangent_through(): unknowns out[x] = dx/dp and members read outside get D_p + D_x dx/dp. Doing the fold after one pass over all nodes avoids per-system bookkeeping. material_point::derivative() therefore works through a system.
  • "seed": "history": the unknown is a history output. The solve starts from its old value, which statev_map / material_point keep per point. A numeric seed sets a scalar's start value. A tensor seed must be 0.

Decisions / deviations

Tests

  • test_residual_system (new, 36 cases):
    • The test_vector_newton scalar cases as systems take exactly the iterations vector_newton needs (fewest max_iter that converges) and land on its root; one fewer max_iter gives a cutback.
    • Exact on max_iter, cutback naming the system, singular Jacobian, point-local cold start, a failed solve does not move the next.
    • Mixed scalar + tensor in 3D/2D, and the nonlinear mixed case in 4 updates, as measured for vector_newton.
    • The same system split over two residual nodes converges identically. Its derived zero blocks are [["x","y"]], the old declared zero_blocks; the linear split and the single node have none.
    • newton_fd with and without partials.
    • dg/dε and dB/dε through material_point::derivative match the analytic IFT result (major-asymmetric). A residual read outside has total derivative 0.
    • Missing partial on the Newton path throws at finalize. Picard with unknowns. Validation: names, pairing, method, tensor seed, residual kind, stateful member, disjoint systems, no path.
    • "seed": "history": two points evaluated alternately in one context give the same x and iterations as each alone, with fewer iterations than a cold start.
    • JSON document, bad seed/kind, non-list and unknown-key documents.
    • Review fixes: two coupled systems (the second reads the first's unknown) with the tangent checked against central differences; newton_fd and picard through derivative(); NaN and Inf residuals; tolerance = 0 and max_iter = 0 with a converged seed; small-scale unknowns; iterations() after a throw; zero_blocks() before finalize; unknowns restored after a singular newton_fd step; singular J in fold(); a member output as seed.
  • test_system_plasticity (new): j2_kinematic_plasticity as a system matches the shipped stress, tangent and back stress to 1e-12 (relative); its tangent matches central differences of the stress; update_property on its stress equals update(). Only recovery needs more than one Newton step. No zero blocks, and the test model's partials pass check_partials.
  • test_iteration_groups: all Property engine: iteration groups, the fixed point moves into update() #214 tests unchanged and passing. New: a picard system without unknowns over the same relaxer reproduces the group (same x, probe once per update, cutback naming the system).

Gate

GCC 14 + ASan locked gate: BUILD=0, TEST=0, warnings=0 — 1692 tests (0 warnings).

#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.
…on (#217)

Callbacks declare partials() / independent_of() next to reads(); the engine
plans a requested derivative over the target's upstream and accumulates the
Mandel-packed partials. Hosts request derivatives via material_context and
material_point. check_partials() verifies declared partials by central
differences. thermal_eigenstrain and linear_elasticity declare their partials,
giving the first DDSDDT.
…issue-218

# Conflicts:
#	include/numsim-materials/core/property_engine.h
A "system" node owns unknowns and equations from any nodes. update()
solves it between what it reads and what reads it: newton with J from
the declared partials (structural zero blocks from missing paths),
newton_fd, or picard; converges() is a picard system without unknowns.
Non-convergence throws cutback_error naming the system. Derivatives
through a system follow from the implicit function theorem at the
converged point; "seed": "history" warm-starts an unknown per point.

check_partials packs every analytic block before the perturbed reruns.
With the partials requested, a rerun at a perturbed read refilled the blocks, so a later partial of the same callback was compared at the perturbed state. Found while building #218.
…, fill at once

check_partials perturbs every read declared independent_of() and requires the
outputs of the callback to stay put; its step follows the read block, its
roundoff allowance is bounded by the analytic block, and the reads and outputs
are restored when a perturbed rerun throws.

derivative<R> accepts only the scalar or a tensor of the context's dimension,
refuses calls from a callback, builds its message on the error path only, and
on an evaluated context reruns the callbacks of the new plan so the result is
filled at once. has_path() tells independence from a typo.
…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.
…ewton_fd steps by the size of x

update_property() runs the solve of a system whose unknowns or members the target reads.
fold() throws a cutback_error naming the system when dx/dp is not finite and reuses J, B and dx/dp.
newton_fd takes a step relative to ||x||_inf and puts the unknowns back before a throw.
iterations() is set before a throw, zero_blocks() before finalize() throws, a seed that a passed
system reruns throws, two unknowns may not share a residual, and the JSON readers require lists
and reject unknown keys.
@petlenz

petlenz commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Review fixes for the residual systems, in three commits on top of the previous head: two forward merges and one fix commit (e192818). Merge order stays: after #226 and #227.

Forward merges

feature/issue-214 (#226) and feature/issue-217 (#227) both got review-fix commits and a merge of main since this branch last took them. Both are merged in (merge commits only). The conflicts were:

The merged tree passed the full gate before the first push (1666 tests, 0 warnings). The first merge commit was made locally before the second merge, since git cannot merge while one is open; it was only pushed together with the gated result.

Review findings

  • update_property() skipped the system solve: fixed. A target that reads a system's unknowns or members runs that solve (members in exclude are not rerun). A converges() group still runs once, as Property engine: iteration groups converge local edges inside update() (#214) #226's UpdatePropertyOnAMemberRunsItsUpstreamOnce expects, so the solve is for systems with unknowns only. Test: SystemPlasticity.UpdatePropertySolvesTheSystemLikeUpdate (red: relative stress difference 0.35).
  • fold() never checked for singularity: fixed. A non-finite dx/dp throws a cutback_error naming the system; J, B, dx/dp and the LU are members of the fold entry and reused. Test: ASingularJacobianAtTheConvergedPointThrowsACutbackNamingTheSystem (red: no throw).
  • newton_fd step: now sqrt(eps) * max(|x_j|, ||x||_inf). Deviation from the brief: when every unknown is zero the scale is 1, not tiny, since a vanishing first step would give a zero Jacobian. The unknowns are scattered back before a throw (also when a member throws). Tests: NewtonFdConvergesAsFastAsNewtonOnSmallUnknowns (red: cutback after 50 iterations at scale 1e-9), NewtonFdLeavesTheUnknownsAtTheIterate... (red: y = 1.5e-8).
  • iterations() is set before a throw: fixed (red: 0 against 7).
  • zero_blocks() before finalize() throws a logic_error naming the system (a planned flag in the report). Red: no throw.
  • derivative(p) with p a member output (or filled by a member) of a passed system throws logic_error. Red: no throw.
  • Readers: system.h::check() rejects two equations with one residual; the JSON readers of unknowns and equations require lists of objects and reject unknown keys naming the key. Reds: validation case 'a::r', JSON seeed and forr.
  • Tests that were green on the first run (regression coverage, no fix needed after the merge): two coupled systems with the tangent checked against central differences, FD check of the J2 system tangent, NaN and Inf residuals (including an infinite tolerance), tolerance = 0 / max_iter = 0 with a converged seed, picard and newton_fd through derivative().

The gate on the fix commit: BUILD=0, TEST=0, warnings=0, 1692 tests. The PR body is updated (merge order, newton_fd step, new behaviours, test counts).

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: residual systems set up at run time (unknowns and equations from any nodes, Newton/Picard, implicit function theorem)

1 participant