materials: isotropic_tangent + linear_stress — a stiffness the graph can order - #26
Open
petlenz wants to merge 9 commits into
Open
materials: isotropic_tangent + linear_stress — a stiffness the graph can order#26petlenz wants to merge 9 commits into
petlenz wants to merge 9 commits into
Conversation
petlenz
added a commit
that referenced
this pull request
Aug 13, 2026
…onsumers Six findings from the review of #26. recomputes() could lie. "recompute" was held as a `const bool&` into the parameter store while the callback binding is frozen at construction, so flipping the flag with set_parameter made the accessor report that the tangent tracks K and G when no callback existed and it was permanently stale. It is now read once and stored BY VALUE, so the accessor describes what was actually bound. RecomputeIsReadOnceAndTheAccessorCannotLie pins it, and fails if the member is changed back to a reference. assert_constants_unchanged() had no callers anywhere, so the "a debug assertion catches it" promise in the header was false. Removed, along with the claim. The header now says plainly that nothing detects the stale-tangent misconfiguration automatically — the material cannot observe a write it has no callback for — and points at the test that pins what happens instead. Deleting it also removes the NDEBUG-conditional data members, which gave a header-only class template two layouts across mixed-config translation units, and the use of assert without including <cassert>. The ordering test could pass on a graph missing the property it orders. Both indices used 0 as a not-found sentinel, and the tangent has no inputs so index 0 is exactly where it legitimately lands. Now std::optional with ASSERT_TRUE on both; verified by renaming the searched property, which the old version tolerated and the new one rejects. linear_stress could not substitute for linear_elasticity in existing consumers, which contradicted the header's drop-in claim. Measured, the blast radius was smaller than it looked: small_strain_plasticity and rk_plasticity already take their tangent from a separately NAMED source, so they work with the decomposed pair unchanged — a J2 chain with isotropic_tangent replacing linear_elasticity outright produces bit-identical results. Only three consumers resolved both stress and tangent from one name, and each now takes an optional "tangent_source" that defaults to the existing source: material_point_evaluator (config field), isotropic_damage (parameter), weighted_sum (a parallel list, one entry per term, since its sources are pairs). No existing configuration or behaviour changes; this widens an interface rather than refactoring one. DrivesJ2PlasticityIdenticallyToLinearElasticity is the test that should have existed from the start. The previous coverage compared two contexts built in the same file, which is exactly why a claim about pre-existing consumers went unchecked. Both materials are now registered in register_default_materials(), so they are reachable from the runtime factory rather than only from hand-written C++. bool is already in the JSON type registry, so "recompute" converts. update_tangent() reuses plasticity_detail::make_IIdev instead of spelling out the isotropic basis a fourth time. 193/193 tests pass.
petlenz
added a commit
that referenced
this pull request
Aug 14, 2026
The recompute flag encoded a choice — does this stiffness follow its moduli — as a boolean, and a boolean can disagree with how the graph was actually built. It did: the flag was a live reference into the parameter store while the callback was bound once at construction, so flipping it made recomputes() claim tracking that did not exist. That was a symptom. The cause is that a parameter has no edge to the material reading it, so nothing orders a change to it against the values derived from it. K and G are now Global inputs. Which material you wire IS the choice, and it cannot desynchronise from anything: constant_scalar fixed moduli, a plain (non-history) property any "value" producer moduli that vary, e.g. with temperature constant_scalar publishes a PLAIN property deliberately. statev_map enumerates history properties, so a history-valued constant would consume one STATEV slot per integration point, per constant, and would have to be named in the exclusion list forever. Measured: with a plain property, nstatv is 0 with the moduli not mentioned at all. An earlier prototype used external_scalar_source and did cost those slots — that material is history-based because it exists for time, where the old/new pair is the point. The callback is now always bound, because inputs are not wired until finalize() and nothing can be computed in a constructor that reads them. It self-guards on the moduli, so fixed constants cost two comparisons rather than a rank-4 rebuild. Measured per ctx.update(): 28.1 ns guarded against 308.5 ns unguarded, and 31.7 ns for the old parameter-with-no-callback path — so the wired version is not slower than what it replaces, and 11x faster than rebuilding blindly. FixedModuliAreRebuiltExactlyOnce pins the guard via a recomputations() counter. One behaviour genuinely changes: the tangent is built on the first update rather than at construction, since the inputs do not exist before finalize(). Every consumer goes through ctx.update() first, but TangentIsBuiltOnTheFirstUpdateNotAtConstruction records it. EveryProducerIsOrderedBeforeItsConsumer now checks the whole chain — K and G before the stiffness, the stiffness before the stress — rather than one pair. Note this branch no longer uses set_parameter at all: the test writes through ctx.get_mutable on the constant's published property instead. #26 could be rebased off #25 and reviewed independently. 194 tests pass.
…can order
linear_elasticity owns its tangent and builds it once in the constructor, which
is optimal while K and G are fixed but leaves no way to recompute it — and, more
awkwardly, no way to recompute it CORRECTLY even if one were added.
A material reading its own property creates no edge in the property graph. The
engine builds edges from input_dependencies and from material-level input
wiring, so `elastic::stress` and `elastic::tangent` both hang off the same
single edge from the strain source and their relative order falls out of
constructor registration order:
strain_in::strain
elastic::stress <- reads m_C
elastic::tangent <- writes m_C, too late
Attaching an update callback to the tangent would therefore have produced a
stress lagging the constants by one call, silently. Splitting the stiffness into
its own material turns that invisible intra-material read into a real Global
edge, which the topological sort honours:
stiffness::tangent <- produced first
elastic::stress
That is the change. Same physics, different graph shape, and the ordering is now
guaranteed by construction rather than by registration order.
isotropic_tangent binds its update callback ONLY when the "recompute" parameter
is true. add_output ignores a null callback, so with recompute=false the
property carries no callback at all and the engine skips it outright — the
per-call cost is zero, not merely small, and the behaviour matches
linear_elasticity exactly. The tangent is built once in the constructor either
way, so it is valid before the first update() regardless.
recompute = false constants fixed (Abaqus: PROPS cannot vary per name)
recompute = true constants vary between calls (CalculiX interpolates
*USER MATERIAL constants by temperature)
It defaults to false, matching linear_elasticity. The failure mode of the wrong
setting is worth naming: recompute=false while the constants do move leaves a
stale tangent with the stress still correct, so it costs convergence rate rather
than accuracy and produces no diagnostic. A debug assertion catches it;
RecomputeFalseIgnoresALaterParameterWrite pins the behaviour so it is recorded
rather than discovered.
linear_stress is linear_elasticity minus tangent ownership: sigma = C : eps with
C as a Global input. Any material producing a "tangent" property is a drop-in,
so anisotropic, temperature-dependent or damage-degraded stiffnesses can be
substituted without consumers knowing. This is the decomposition isotropic_damage
already uses (state function + yield + damage law + assembler).
Nothing existing is modified. linear_elasticity is untouched and remains the
better choice whenever the moduli are fixed — one fewer material in the graph
and the tangent computed once. j2_plasticity needs no change either: its
elastic_source only requires some material producing "tangent".
Depends on the parent commit for set_parameter, which the write-and-retrigger
test uses. recompute=true has no purpose without a way to write parameters, so
the two are only meaningful together.
…onsumers Six findings from the review of #26. recomputes() could lie. "recompute" was held as a `const bool&` into the parameter store while the callback binding is frozen at construction, so flipping the flag with set_parameter made the accessor report that the tangent tracks K and G when no callback existed and it was permanently stale. It is now read once and stored BY VALUE, so the accessor describes what was actually bound. RecomputeIsReadOnceAndTheAccessorCannotLie pins it, and fails if the member is changed back to a reference. assert_constants_unchanged() had no callers anywhere, so the "a debug assertion catches it" promise in the header was false. Removed, along with the claim. The header now says plainly that nothing detects the stale-tangent misconfiguration automatically — the material cannot observe a write it has no callback for — and points at the test that pins what happens instead. Deleting it also removes the NDEBUG-conditional data members, which gave a header-only class template two layouts across mixed-config translation units, and the use of assert without including <cassert>. The ordering test could pass on a graph missing the property it orders. Both indices used 0 as a not-found sentinel, and the tangent has no inputs so index 0 is exactly where it legitimately lands. Now std::optional with ASSERT_TRUE on both; verified by renaming the searched property, which the old version tolerated and the new one rejects. linear_stress could not substitute for linear_elasticity in existing consumers, which contradicted the header's drop-in claim. Measured, the blast radius was smaller than it looked: small_strain_plasticity and rk_plasticity already take their tangent from a separately NAMED source, so they work with the decomposed pair unchanged — a J2 chain with isotropic_tangent replacing linear_elasticity outright produces bit-identical results. Only three consumers resolved both stress and tangent from one name, and each now takes an optional "tangent_source" that defaults to the existing source: material_point_evaluator (config field), isotropic_damage (parameter), weighted_sum (a parallel list, one entry per term, since its sources are pairs). No existing configuration or behaviour changes; this widens an interface rather than refactoring one. DrivesJ2PlasticityIdenticallyToLinearElasticity is the test that should have existed from the start. The previous coverage compared two contexts built in the same file, which is exactly why a claim about pre-existing consumers went unchecked. Both materials are now registered in register_default_materials(), so they are reachable from the runtime factory rather than only from hand-written C++. bool is already in the JSON type registry, so "recompute" converts. update_tangent() reuses plasticity_detail::make_IIdev instead of spelling out the isotropic basis a fourth time. 193/193 tests pass.
…to empty
There is no is_optional trait in this framework — the available checks are
is_required, check_range, set_default, check_data_type and check_enum. But
check_parameter only runs the checks that were registered, and the JSON
visitor's accept() returns early for a key the input does not contain, so a
parameter DECLARED WITH NO CHECK is already optional. That is how to spell it:
para.template insert<std::string>("tangent_source"); // optional
paired with a contains() test at the use site, which is the idiom
small_strain_plasticity already uses for "yield_function".
This replaces set_default(std::string{}) plus an empty-string test. Two reasons
it is better. Absent and empty become distinguishable: a deck that supplies
tangent_source="" now fails to wire instead of silently falling back to the
stress source, which is what a user typing an empty value would want to hear
about. And unlike small_strain_plasticity's "yield_function" — which is probed
with contains() but never declared — the key stays in the schema, so the JSON
layer knows it rather than warning about an unrecognised key.
material_point_evaluator's config is C++ rather than a parameter handler, so it
uses std::optional<std::string> for the same distinction.
weighted_sum's per-term list follows the same rule: absent means every term
takes its tangent from the material that produces its stress.
Three tests pin the semantics, including the configuration the review showed was
impossible before: the decomposed pair driving the UMAT evaluator, producing
stress and DDSDDE identical to linear_elasticity over ten increments. The
evaluator still throws when the tangent cannot be resolved at all, so omitting
tangent_source with a stress-only material is a loud failure rather than a
mystery.
193 -> 196 tests.
Roughly halved: 132 comment lines down to 70 across the new headers and tests. Kept the facts that took work to establish — no graph edge for an intra-material read, recompute is read once because that is when the callback is bound, the optional-by-no-check idiom, the 0-sentinel hazard in the ordering test — and cut the narrative around them.
The recompute flag encoded a choice — does this stiffness follow its moduli — as a boolean, and a boolean can disagree with how the graph was actually built. It did: the flag was a live reference into the parameter store while the callback was bound once at construction, so flipping it made recomputes() claim tracking that did not exist. That was a symptom. The cause is that a parameter has no edge to the material reading it, so nothing orders a change to it against the values derived from it. K and G are now Global inputs. Which material you wire IS the choice, and it cannot desynchronise from anything: constant_scalar fixed moduli, a plain (non-history) property any "value" producer moduli that vary, e.g. with temperature constant_scalar publishes a PLAIN property deliberately. statev_map enumerates history properties, so a history-valued constant would consume one STATEV slot per integration point, per constant, and would have to be named in the exclusion list forever. Measured: with a plain property, nstatv is 0 with the moduli not mentioned at all. An earlier prototype used external_scalar_source and did cost those slots — that material is history-based because it exists for time, where the old/new pair is the point. The callback is now always bound, because inputs are not wired until finalize() and nothing can be computed in a constructor that reads them. It self-guards on the moduli, so fixed constants cost two comparisons rather than a rank-4 rebuild. Measured per ctx.update(): 28.1 ns guarded against 308.5 ns unguarded, and 31.7 ns for the old parameter-with-no-callback path — so the wired version is not slower than what it replaces, and 11x faster than rebuilding blindly. FixedModuliAreRebuiltExactlyOnce pins the guard via a recomputations() counter. One behaviour genuinely changes: the tangent is built on the first update rather than at construction, since the inputs do not exist before finalize(). Every consumer goes through ctx.update() first, but TangentIsBuiltOnTheFirstUpdateNotAtConstruction records it. EveryProducerIsOrderedBeforeItsConsumer now checks the whole chain — K and G before the stiffness, the stiffness before the stress — rather than one pair. Note this branch no longer uses set_parameter at all: the test writes through ctx.get_mutable on the constant's published property instead. #26 could be rebased off #25 and reviewed independently. 194 tests pass.
petlenz
changed the base branch from
feature/material-set-parameter
to
feature/vector-solver
August 14, 2026 20:03
petlenz
force-pushed
the
feature/tangent-generator
branch
from
August 14, 2026 20:03
43d2ad6 to
2b2d38d
Compare
Member
Author
|
Rebased onto |
This was referenced Aug 14, 2026
…st file Five findings from the review of #26. tangent_sources was matched to terms by index with no length check, so only TRAILING terms could be left unlisted. A user with two constituents writing ["stiff_B"] for the second one silently got it applied to the FIRST: both names resolve, wire_inputs() succeeds, and the only symptom is a wrong summed tangent — degraded or failed global Newton while the stresses still converge correctly, which is close to the hardest failure to trace. A longer list was truncated in silence. It now requires one entry per term, and "" keeps that term's own tangent so a non-leading term can be overridden alone. I removed that escape myself in an earlier commit when switching to contains(), which is what made leading-only overrides the only possibility. weighted_sum had no test file at all — the one material in the repo with zero coverage, and I had just added a parameter to it. It now has five tests: the mixture rule itself, absent/overridden/empty-entry tangent ownership, and the length check. The last two are mutation-verified. isotropic_tangent's doc told users to wire varying moduli from "any material publishing value", but the property name was hardcoded and the only host-driven scalar in the repo publishes "state" — following the comment gave wire_inputs(): property 'temperature::value' not found at finalize(). Rather than correct the comment, K_property/G_property now default to "value" and can be overridden, so the documented path works. isotropic_tangent gains invalidate(). The memo guard keys on K and G, which assumes this material is the sole writer of its "tangent" property; material_context::get_mutable() hands out a mutable reference to exactly that, so anything writing it without writing it back would wedge the memo permanently with no way out. material_point_evaluator::config's tangent_source moved from the middle of the aggregate to the end. std::string converts implicitly to std::optional<std::string>, so a downstream aggregate initialiser would still compile and silently re-bind its trailing arguments — a confusing upgrade error in a header meant to be embedded in third-party UMATs. isotropic_damage's class doc now mentions the optional tangent_source, which the change was about and the doc still denied. 189 -> 194 tests.
Neither had a caller outside the tests that existed to exercise them, which is the same reason #25 was closed: do not ship public API nothing uses. recomputations() was a counter for testing the memo guard. invalidate() was added to let a caller recover after writing the "tangent" property directly through material_context::get_mutable(), which would otherwise leave the guard satisfied and the stiffness never rebuilt — but nothing in the repo does that, and get_mutable already documents that the caller is responsible for restoring consistency. The three tests that read the counter now check observable behaviour instead: the tangent is zero before the first update and correct after, repeated updates with fixed moduli stay stable, and a changed modulus still propagates. Two consequences worth stating rather than discovering later. The self-guard is now untested. It is a pure optimisation — 28.1 ns per update against 308.5 ns without it — with no behavioural signature, so removing it would be a silent 11x regression on this path, not a test failure. The get_mutable wedge is reopened. Writing stiffness::tangent directly and not writing it back leaves m_valid true with matching cached moduli, so the guard returns early forever. Recovering means rebuilding the context. 194 tests pass.
One job: recompute the stiffness from its inputs on every update. No cached
moduli, no validity flag, nothing to invalidate.
What that removes, beyond the code: the guard was an optimisation with no
behavioural signature, so it could only be tested through a counter that
existed for the test — and the counter was itself unused API. It also assumed
this material was the sole writer of its "tangent" property, so anything writing
that through material_context::get_mutable() and not writing it back left the
guard permanently satisfied and the stiffness never rebuilt. With no memo there
is no wedge, and that review finding closes by construction rather than by
adding an escape hatch for it.
The cost is real and worth stating plainly. Measured per ctx.update():
isotropic_tangent + linear_stress 308.2 ns
linear_elasticity (monolithic) 27.0 ns
So this pair is ~11x the monolithic material on the elastic path, and the header
now says so and points at linear_elasticity for fixed moduli. That is the right
default anyway: the decomposition exists for moduli that CHANGE, where a memo
would be missing most of the time, and for pluggable stiffness, where correctness
of ordering is the point rather than throughput.
No test changed; behaviour is identical, only slower. 194 tests pass.
test_tangent_generator compared tensors by nested index loops and by picking out single components — C(0,0,0,0) against a hand-derived K + 4G/3. That is the style 41d217f already replaced elsewhere in this repo with tmech expressions. Comparisons now go through tmech: TensorsIdentical(a, b) norm(a - b) == 0, for the exactness claims tmech::almost_equal(a,b,e) for the tolerant ones, as test_vector_newton does and expected values are built as tensors rather than scalars — isotropic(K, G) constructs the reference stiffness independently, so the tests check the whole tensor rather than one component that happens to be easy to derive by hand. Fixtures are tmech expressions too: uniaxial() is v * (e1 (x) e1). The evaluator tests compared the host's flat buffers slot by slot. They now convert both sides back to tensors first, which is both closer to what the host consumes and immune to a slot permutation applied on both sides — which a componentwise check would cancel out. Net: 6 index loops replaced by 12 tensor comparisons, and the J2 comparison carries stress tensors through instead of flattening to one component per step. Verified the tests still bite: perturbing the volumetric coefficient from 3 to 3.0001 fails 5 of them. (My first attempt at that check reported zero failures — the grep pattern missed gtest's "[ FAILED ]" spacing, not a coverage hole.) 194 tests pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #24. Stacked on #25 — review that first.
linear_elasticitybuilds its tangent once in the constructor. Optimal whileKand
Gare fixed, but there is no way to recompute it — and no way to recompute itcorrectly even if one were added.
The measurement that motivated this
A material reading its own property creates no edge in the property graph, so both
its outputs hang off the same single edge from the strain source and their order
falls out of constructor registration order:
Attaching an update callback to
tangent— the obvious fix — therefore yields astress lagging the constants by one call, silently. I verified the order is
unchanged with the callback attached, so that option is tested-and-rejected rather
than assumed.
Consuming another material's property creates a real Global edge, which the sort
honours:
What is added
isotropic_tangent— paramsK,G,recompute; outputtangentlinear_stress— inputstangent+strain; outputstressrecomputebinds the update callback only when true.add_outputignores a nullcallback, so with
recompute=falsethe property has no callback at all and theengine skips it — zero per-call cost, matching
linear_elasticity. The tangent isbuilt once in the constructor either way, so it is valid before the first
update().Tests
StiffnessIsOrderedBeforeTheStressThatConsumesItMatchesLinearElasticityExactlyRecomputeFalseLeavesThePropertyWithoutACallbackRecomputeTrueBindsTheCallbackTangentIsValidBeforeTheFirstUpdateRecomputeTrueFollowsAParameterWrittenInPlaceRecomputeFalseIgnoresALaterParameterWriteAdditive
Nothing existing changes.
linear_elasticityis untouched and remains the betterchoice when the moduli are fixed.
j2_plasticityneeds no change: itselastic_sourceonly requires some material producingtangent.Open question for review
recomputedefaults tofalse, matchinglinear_elasticity. The wrong settingleaves a stale tangent with the stress still correct — costing convergence rate,
not accuracy, and producing no diagnostic. A debug assertion catches it. Worth
deciding whether the default should instead be
true(safe, but everyone pays arank-4 rebuild per integration point until they opt out).
Base
Targets
feature/material-set-parameter. 191/191 tests pass.