Skip to content

core: write a material parameter in place, without invalidating bound references - #47

Merged
petlenz merged 5 commits into
mainfrom
feature/material-set-parameter
Sep 7, 2026
Merged

core: write a material parameter in place, without invalidating bound references#47
petlenz merged 5 commits into
mainfrom
feature/material-set-parameter

Conversation

@petlenz

@petlenz petlenz commented Sep 6, 2026

Copy link
Copy Markdown
Member

Adds material_interface::set_parameter<T>(), so a caller can change a material's parameter after construction and have the material see it — a UMAT re-reading PROPS between increments, a driver sweeping a modulus, a test varying one input without rebuilding the graph.

The branch predates the plasticity split and the UMAT layer; main is merged in here. The only collision was tests/CMakeLists.txt (both sides append a test registration). 274/274 tests pass, verified locally against the merge result.

What it does

Assigns through the non-const get<T>(), not insert(). insert_or_assign replaces the whole std::any, which relocates anything past its small-buffer — invalidating every reference a material bound at construction, which is how every material reads its parameters.

T is deliberately not deduced. std::type_identity_t makes the stored type an explicit argument, so set_parameter<double>("K", 250) compiles and set_parameter("K", 250) does not. Deduced, that literal was an int, threw std::bad_any_cast, and silently lost the write — and bad_any_cast derives from neither invalid_argument nor runtime_error, so a UMAT boundary catching those would miss it entirely and terminate. A surviving mismatch (set_parameter<float> against a stored double) is translated into an invalid_argument naming the key.

Writing "name" is rejected: the identity is cached in m_name at construction and used as the material_handler registry key, so a write left the parameter disagreeing with both — name() and lookups kept the old value while get_parameter<std::string>("name") reported one that resolved to nothing.

What it deliberately does not do

A write reaches only what the material re-reads through its bound reference. It does not touch anything derived at construction, copied into a member, or consumed once for wiring, and it is local to one material because each holds its own copy of the handler.

That is a real limitation, and the reason it is stated so precisely is that the original test suite hid it: every case went through a probe material that binds each parameter by reference and derives nothing — exactly the shape the API handles cleanly. Three tests now use shipped materials and pin the awkward behaviour instead:

test what it pins
WriteLandsButDerivedStateGoesStale linear_elasticity: K changes, stress does not
WriteIsLocalToTheMaterial the caller's handler keeps the old value
WritingAWiringKeyDoesNotRewire a source name written after finalize() leaves the input wired as before

Both fixes are mutation-verified: removing the "name" check and breaking the bad_any_cast translation each fail exactly their own test.

One note for review

The doc comment's original wording pointed at a recompute-style tangent as the mitigation for the staleness above, and noted no such class existed. One does now — isotropic_tangent (#28) rebuilds on every update with no memo. It sources K/G from other materials rather than from its own parameters, so it is not directly the set_parameter path, but it may be the better answer for a caller who wants a live modulus, and the comment could say so.

… references

Materials bind their parameters once, in the constructor:

    m_K(base::template get_parameter<value_type>("K"))

and hold the result as a `const value_type&`. That reference points at the
object inside the std::any inside the handler's map node, so a parameter can be
updated after construction and every material observes it — but only if the
write leaves the object where it is.

    template <typename T>
    void set_parameter(std::string const& key, T const& value) {
      m_parameter_handler.template get<T>(key) = value;
    }

Assigning through the NON-CONST get<T>() is the whole point, and insert() is
deliberately not used. insert() goes through insert_or_assign, which replaces
the entire std::any; for a value larger than std::any's small buffer that
destroys the contained object and constructs a new one elsewhere, dangling every
reference a material bound at construction.

The distinction is invisible with scalar parameters, which is what makes it
worth a test rather than a comment: a `double` fits the small buffer, so
insert() happens to preserve its address and an implementation built on insert()
would pass any test written against moduli — then break the first time someone
stored a tensor-valued parameter. test_set_parameter asserts the address is
stable for a 256-byte parameter, and separately pins the underlying
parameter_handler behaviour (insert relocates, assignment does not) so the
rationale lives next to the mechanism rather than only in this message.

This does nothing about quantities DERIVED from parameters. A material that
precomputes something in its constructor will not notice a later write; that is
a per-material concern and is handled where the derived value lives.

Motivation is host-driven material constants: Abaqus fixes PROPS per material
name, but CalculiX interpolates *USER MATERIAL constants by temperature, so they
genuinely vary between calls and the graph has to be able to follow them.
Kept the two facts a reader cannot recover from the code — insert_or_assign
relocates anything past std::any's small buffer, and derived values are not
invalidated — and cut the explanation around them.
…othing

Six of the ten review findings on this PR. Two are fixed in the API, one is
guarded, and the rest are limitations now pinned by tests against real materials
rather than left for a user to discover.

T is no longer deduced. std::type_identity_t makes the stored type an explicit
argument, so set_parameter<double>("K", 250) is fine while set_parameter("K",
250) fails to COMPILE. Previously it deduced int, threw std::bad_any_cast, and
lost the write — and bad_any_cast derives from neither invalid_argument nor
runtime_error, so a UMAT boundary catching those would miss it and terminate.
A remaining mismatch (set_parameter<float> against a stored double) is now
translated into an invalid_argument naming the key, instead of a bare
"bad any_cast" naming nothing.

Writing "name" is rejected. The identity is cached in m_name at construction and
used as the material_handler registry key, so a write left the parameter
disagreeing with both: name() and lookups kept the old value while
get_parameter<std::string>("name") reported one that resolved to nothing.

The remaining findings are real but not fixable at this level, so they are
documented precisely and pinned by tests. A write reaches only what the material
re-reads through its bound reference. It does not affect anything derived at
construction, copied into a member, or consumed once for wiring; and it is local
to one material, because each holds its own copy of the handler. The doc comment
previously claimed more than that, and pointed at isotropic_tangent's "recompute"
as the mitigation — a class that does not exist on this branch.

The test gap was the reason all of this shipped. Every case went through a probe
material that binds each parameter by reference and derives nothing, which is
precisely the shape the API handles cleanly. Three new tests use SHIPPED
materials and pin the awkward truth instead:

  WriteLandsButDerivedStateGoesStale   linear_elasticity: K changes, stress does
                                       not, because the tangent was built once
  WriteIsLocalToTheMaterial            the caller's handler keeps the old value
  WritingAWiringKeyDoesNotRewire       a source name written after finalize()
                                       leaves the input wired as before

Plus guards for the two fixes, both mutation-verified: removing the name check
and breaking the bad_any_cast translation each fail exactly their own test.

Not addressed: parameter_handler::insert remains public and still relocates, so
set_parameter only avoids the hazard for callers who go through it. And the
underlying gap — no way to invalidate anything derived from a parameter — is a
framework-level design question, currently answered per-material by
isotropic_tangent's "recompute" flag on the child branch.

184 -> 190 tests.
Same four facts -- in-place assignment through get<T>() rather than insert(),
why T is not deduced, and what a write does not reach -- with the prose and the
parenthetical examples cut. 15 lines to 12.
@petlenz
petlenz merged commit dbc6d1f into main Sep 7, 2026
1 check passed
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