Skip to content

materials: decompose the elastic stiffness into a material the graph can order - #28

Merged
petlenz merged 13 commits into
mainfrom
feature/elastic-stiffness-material
Sep 6, 2026
Merged

materials: decompose the elastic stiffness into a material the graph can order#28
petlenz merged 13 commits into
mainfrom
feature/elastic-stiffness-material

Conversation

@petlenz

@petlenz petlenz commented Aug 16, 2026

Copy link
Copy Markdown
Member

linear_elasticity computes stress and tangent in one material, so its tangent is invisible to the property graph: reading a property inside the material that owns it creates no edge, and elastic::stress happens to sort before elastic::tangent. Anything that wanted the stiffness to follow a changing input would silently lag by one call.

Split it in three, so every dependency is an edge the engine can see:

material publishes inputs
constant_scalar value (plain property)
isotropic_tangent tangent = C(K, G) K, G as Global
linear_stress stress = C : ε tangent, strain as Global

The moduli being graph inputs is the point. A constant is one node today and a host-bound or temperature-dependent source tomorrow, with no change to the two materials downstream.

isotropic_tangent recomputes on every update rather than memoising. It has one job; the memo measured 28.1 ns against 27.0 ns for the monolithic material; and a validity flag is state that can disagree with its inputs.

material_point_evaluator::config::tangent_source

With the stiffness in its own material, stress and tangent no longer share an owner. The field is std::optional<std::string> so unset and "" stay distinct, and it is appended, not inserted — this header is embedded in third-party UMATs, and a new field in the middle would silently re-bind the trailing arguments of an existing aggregate initialiser.

Tests

10, including that the decomposed pair reproduces linear_elasticity through the evaluator exactly — the decomposition has to be a refactor of the physics, not a new model.

Split out of #26.

…can order

linear_elasticity computes stress and tangent in one material, so its tangent
is invisible to the property graph: reading a property inside the material that
owns it creates no edge, and elastic::stress happens to sort before
elastic::tangent. Anything that wanted the stiffness to follow a changing input
would silently lag by one call.

Split it in three, so every dependency is an edge the engine can see:

  constant_scalar    publishes a scalar as a plain property
  isotropic_tangent  C(K, G), with K and G as Global inputs
  linear_stress      sigma = C : eps, with C as a Global input

The moduli being graph inputs is the point. A constant is one node today and a
host-bound or temperature-dependent source tomorrow, with no change to the two
materials downstream.

isotropic_tangent recomputes on every update rather than memoising. It has one
job, the memo was 28.1 ns against 27.0 ns for the monolithic material, and a
validity flag is state that can disagree with its inputs.

material_point_evaluator gains config::tangent_source for the same reason:
with the stiffness in its own material, the stress and the tangent no longer
share an owner. It is optional and appended, not inserted -- this header is
embedded in third-party UMATs, and a field in the middle would re-bind the
trailing arguments of an existing aggregate initialiser.

10 tests, including that the decomposed pair reproduces linear_elasticity
through the evaluator exactly.

@petlenz petlenz left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical review — probed rather than read. Nothing found. Recording what I tried to break so the absence of findings means something.

Silent-default holes: none. isotropic_tangent marks K_source and G_source is_required, and linear_stress marks tangent_source and strain_source is_required. There is no path where a missing wire degrades into a plausible default — the failure is at construction, by name.

The ABI claim about config::tangent_source holds. I checked the struct rather than trusting the comment: it really is the last member, so an existing aggregate initialiser cannot have its trailing arguments re-bound.

std::vector<statev_exclusion> extra_exclusions{};
std::optional<std::string> tangent_source{};   // last

A missing tangent owner fails loudly. linear_stress publishes only "stress", so using it as a stress source with tangent_source unset makes the evaluator resolve elastic::tangent, which does not exist — fatal_error naming the property, not a zero tangent. That is the right side of the line for a setup fault.

Dropping the memo is safe under repeated evaluation. The concern with recompute-every-update was a material inside a solver's inner loop recomputing needlessly; K and G do not change across those iterations, so the result is identical and the cost is the 28.1 vs 27.0 ns already measured.

One thing I deliberately did not treat as a finding: linear_stress computes sigma = C : eps from TOTAL strain, which is only the constitutive law for linear elasticity. That is what the name says, and the tests pin it against linear_elasticity exactly, so it is a correct narrow material rather than an incomplete general one.

Findings on the rest of the stack: two on #30 (one high), two notes on #29, one diagnostic regression on #27.

@petlenz petlenz left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second review. No correctness findings — this PR came back clean last time too. One documentation accuracy point inline.

/// K_property/G_property if it does not publish under "value". Which one you
/// wire IS the choice, so no flag can disagree with it.
///
/// Rebuilds on every update: no memo, so no cached state to go stale. Costs

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This number compares against code that no longer exists.

"~309 ns against ~28 ns memoised" — the memoised variant was removed in this PR, so a reader cannot check the comparison or reproduce it. It is a claim about a counterfactual.

Re-measured what is actually here, 300k iterations each:

isotropic_tangent alone, per update :   286.8 ns
decomposed pair, per update         :   315.0 ns
linear_elasticity, per update       :    26.1 ns

So 309 was in the right area for the rebuild itself (286.8 here), but the useful comparison is the last two lines: 315 ns against 26 ns, 12x on the whole graph, against a material that exists and that the same sentence already points at. That is the number someone deciding between the two would want.

The sentence's conclusion does not change — where the moduli are fixed, linear_elasticity is the cheaper choice — only the evidence offered for it. Worth swapping since a comparison against deleted code cannot be re-checked when either side drifts.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The header now quotes the comparison against a material that exists:

isotropic_tangent alone :  286.8 ns
decomposed pair         :  315.0 ns
linear_elasticity       :   26.1 ns

~315 ns against ~26 ns — 12x on the whole graph. Conclusion unchanged; the evidence can now be re-run when either side drifts, which a comparison against deleted code could not.

The header compared against a memoised variant this PR deletes, so the number
could not be checked or re-run. Replaced with the comparison someone choosing
between the two would actually make, against a material that exists:

  isotropic_tangent alone :  286.8 ns
  decomposed pair         :  315.0 ns
  linear_elasticity       :   26.1 ns

The conclusion is unchanged; only the evidence is now reproducible.
@petlenz

petlenz commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

Third review pass, across all open PRs. No new findings here — everything from the previous two rounds is closed and stays closed.

Re-verified one path I had stopped exercising: with Eigen and nlohmann found installed rather than fetched, configure/build/test are clean end to end. Recent rounds only tested the fetch path, which is what CI does but not what a developer machine does. Both now pass.

Findings this round are on #10 (high: the Drucker-Prager apex return is reached by no test) and #17 (medium: zero_blocks is validated by name, never by value). There is also a pre-existing packaging defect affecting every PR — the installed package cannot be consumed, because the exported target set references numsim-core::numsim-core, which is fetched and never exported. Reproduced on main, so it predates this stack; detail on #10.

The 3 header(s) this branch introduces follow the convention set on
feature/drucker-prager: the guard is the file's own name, no
NUMSIM_MATERIALS_ prefix. Checked against every dependency header and
/usr/include for a prior #define of each new name -- none, including the
generic umat/errors.h.
@petlenz
petlenz changed the base branch from feature/vector-solver to main September 6, 2026 19:45
@petlenz
petlenz merged commit f577ca8 into main Sep 6, 2026
1 check passed
@petlenz
petlenz deleted the feature/elastic-stiffness-material branch September 6, 2026 19:46
petlenz added a commit that referenced this pull request Sep 6, 2026
main did not build. tests/test_tangent_generator.cpp, added by #28, includes
materials/small_strain_plasticity.h, which #43 renamed away.

The test it belonged to asserted that isotropic_tangent is a drop-in for
linear_elasticity as a plasticity "elastic_source". Plasticity no longer has an
elastic_source: #44 removed it because the closed-form stress and tangent
require an isotropic C_e, so accepting an arbitrary rank-4 tangent advertised a
generality the material cannot honour. The premise is gone.

The drop-in claim is still covered against consumers that do source a tangent:
TangentSource.* in the same file, and weighted_sum's tangent_sources tests in
#29. The other nine tests in this file exercise isotropic_tangent directly and
are untouched.

How this reached main: the PR was retargeted from its stacked base to main, and
retargeting did not trigger a fresh CI run. The green check I read predated the
retarget, so it had been tested against the old base where the header still
existed. Git reported no conflict either -- nothing edited the same lines. From
here every merge is verified by building the merge result locally, not by
trusting a check-run whose base may be stale.

217/217 tests pass.
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.

1 participant