The library's central abstraction is a property graph. The graph it actually builds and sorts is a material graph, and there are three unrelated ways for one material to read another's value. This issue is the umbrella for fixing that, plus the five design changes that become cheap once it is fixed.
Everything here keeps run-time assembly. None of it proposes compile-time composition, and none of it is motivated by performance: the graph costs ~5% of a J2 plastic UMAT call (13 of 287 ns), and std::function dispatch measures within 0.2 ns of a raw function pointer (#77 has the numbers).
Work is additive wherever possible: new mechanisms land alongside the old ones, materials port one at a time behind their existing tests, and nothing is removed until nothing uses it.
1. Property-granular edges, and one dataflow
The problem. property_engine.h:174 iterates prop->traits().input_dependencies to add edges. Nothing in the tree ever writes to that field. Ordering is therefore per material, which
- reports cycles that do not exist at property level (
A.x -> B.y and B.z -> A.w reads as A <-> B);
- gives a cycle error that names no material and no property;
- means
EdgeKind::Local edges create no ordering at all -- only Global edges are added.
And there are three access paths that do not agree:
| path |
ordered by the graph? |
null-checked? |
| engine topological push |
yes, per material |
n/a |
update_source() pull, inside a material |
no |
yes |
material_ref |
no |
assert only, and Release is -O3 -DNDEBUG |
The ordering that makes the kinematic-hardening rig in #55 work is a BFS-level accident: solver::g has indegree 0 and kin::stress has indegree 1, and the sort is FIFO BFS. A model taking its strain over a Local edge gets no such protection.
The change.
- Populate
input_dependencies when an input_property is wired. The field is already there and already read.
- Make cycle and ordering errors print the property path.
- Make pull the primary mechanism and push a convenience. The return maps genuinely need pull -- an inner Newton re-evaluates hardening at trial states the topological order knows nothing about -- so
ctx.update() becomes "pull each sink" rather than a second, separate mechanism. This also removes the dead hardening evaluation the performance work counted: 5 hardening.compute() calls per plastic step, exactly 1 of them scheduled by the graph and immediately overwritten.
- Fold
material_ref into the edge mechanism, or remove it.
Subsumes #68. Related: #59 (the material_ref null check), #69.
2. Split the physics model from the graph adapter
The cleanest code in the tree is j2_kinematic_system in #55: a plain struct with compute_residual / compute_jacobian, no graph, no registry, testable against a closed form and a finite difference.
The shipped materials are the opposite. Each publishes properties, owns parameters, embeds a solver, writes history and implements physics in one class -- which is why the three plasticity materials share roughly half their code by copy and J2 is written twice. That duplication has already cost a real bug: the explicit RK stage passed G where the others pass effective_modulus(G), a 3x error in the multiplier.
The change. Define a residual-system concept (residual, jacobian, unknowns) in the shape #55 already has; write one generic return_map_material adapter that wires any such system into the graph; port materials one at a time behind their existing tests.
New physics then needs no graph knowledge at all. Starts with #75.
3. Parameter descriptors instead of std::any + type_index
Dynamic assembly needs type erasure at construction, not at access -- and the materials already resolve parameters to typed references in their constructors. What is left is a type_index-keyed reader registry that cannot distinguish vector_newton's zero_blocks from weighted_sum's terms (both are vector<pair<string,string>>), so a malformed weighted_sum term reports a zero_blocks error.
The change. One descriptor per parameter, declared once: name, type, required, range, unit, doc string. Read by the C++ path, the JSON path, the error messages, and a generated documentation table.
That this one seam covers #65, #66, #67, #74 and the type table in #72 is the evidence it is the right seam.
4. finalize() as the single validation gate
Validation is currently scattered and mostly absent. finalize() is the only point that sees the whole model, so it should reject, each naming the material and property:
Once it does, the hot path can assume a valid graph. commit()/revert() also need the check_finalized their siblings have (#60).
5. Stop using history properties as Newton mailboxes
m_kappa.new_value() = trial; m_H.update_source(); uses committed state as a scratch channel, conflating "state the driver commits" with "an iterate I am trying". It is also why the STATEV shear-factor problem (#63) is latent rather than visible.
The change. Give the pull path an explicit "evaluate at this value" argument, so a trial state never has to be written into history to be seen.
Keeps the existing rule that materials and solvers never call commit()/revert() -- the driver does.
6. "Solver" as a first-class concept
Solvers being materials is elegant and stays. But converged() is a convention rather than a contract, which is how rk_integrator shipped both unregistered and without one (#61). Make it an interface the conformance test checks, and extend that test to solvers/ (#35).
Not changing
Order
1 -> 4 -> 3 -> 2 -> 6 -> 5. Item 1 is the prerequisite: the library is named for a graph it does not build, and 2, 4 and 6 are all cleaner once it does.
All references are to main at 91bd26f.
The library's central abstraction is a property graph. The graph it actually builds and sorts is a material graph, and there are three unrelated ways for one material to read another's value. This issue is the umbrella for fixing that, plus the five design changes that become cheap once it is fixed.
Everything here keeps run-time assembly. None of it proposes compile-time composition, and none of it is motivated by performance: the graph costs ~5% of a J2 plastic UMAT call (13 of 287 ns), and
std::functiondispatch measures within 0.2 ns of a raw function pointer (#77 has the numbers).Work is additive wherever possible: new mechanisms land alongside the old ones, materials port one at a time behind their existing tests, and nothing is removed until nothing uses it.
1. Property-granular edges, and one dataflow
The problem.
property_engine.h:174iteratesprop->traits().input_dependenciesto add edges. Nothing in the tree ever writes to that field. Ordering is therefore per material, whichA.x -> B.yandB.z -> A.wreads asA <-> B);EdgeKind::Localedges create no ordering at all -- only Global edges are added.And there are three access paths that do not agree:
update_source()pull, inside a materialmaterial_refassertonly, and Release is-O3 -DNDEBUGThe ordering that makes the kinematic-hardening rig in #55 work is a BFS-level accident:
solver::ghas indegree 0 andkin::stresshas indegree 1, and the sort is FIFO BFS. A model taking its strain over a Local edge gets no such protection.The change.
input_dependencieswhen aninput_propertyis wired. The field is already there and already read.ctx.update()becomes "pull each sink" rather than a second, separate mechanism. This also removes the dead hardening evaluation the performance work counted: 5hardening.compute()calls per plastic step, exactly 1 of them scheduled by the graph and immediately overwritten.material_refinto the edge mechanism, or remove it.Subsumes #68. Related: #59 (the
material_refnull check), #69.2. Split the physics model from the graph adapter
The cleanest code in the tree is
j2_kinematic_systemin #55: a plain struct withcompute_residual/compute_jacobian, no graph, no registry, testable against a closed form and a finite difference.The shipped materials are the opposite. Each publishes properties, owns parameters, embeds a solver, writes history and implements physics in one class -- which is why the three plasticity materials share roughly half their code by copy and J2 is written twice. That duplication has already cost a real bug: the explicit RK stage passed
Gwhere the others passeffective_modulus(G), a 3x error in the multiplier.The change. Define a residual-system concept (
residual,jacobian,unknowns) in the shape #55 already has; write one genericreturn_map_materialadapter that wires any such system into the graph; port materials one at a time behind their existing tests.New physics then needs no graph knowledge at all. Starts with #75.
3. Parameter descriptors instead of
std::any+type_indexDynamic assembly needs type erasure at construction, not at access -- and the materials already resolve parameters to typed references in their constructors. What is left is a
type_index-keyed reader registry that cannot distinguishvector_newton'szero_blocksfromweighted_sum'sterms(both arevector<pair<string,string>>), so a malformedweighted_sumterm reports azero_blockserror.The change. One descriptor per parameter, declared once: name, type, required, range, unit, doc string. Read by the C++ path, the JSON path, the error messages, and a generated documentation table.
That this one seam covers #65, #66, #67, #74 and the type table in #72 is the evidence it is the right seam.
4.
finalize()as the single validation gateValidation is currently scattered and mostly absent.
finalize()is the only point that sees the whole model, so it should reject, each naming the material and property:material_refs (material_ref::get() is protected only by assert, Release builds dereference null #59)Once it does, the hot path can assume a valid graph.
commit()/revert()also need thecheck_finalizedtheir siblings have (#60).5. Stop using history properties as Newton mailboxes
m_kappa.new_value() = trial; m_H.update_source();uses committed state as a scratch channel, conflating "state the driver commits" with "an iterate I am trying". It is also why the STATEV shear-factor problem (#63) is latent rather than visible.The change. Give the pull path an explicit "evaluate at this value" argument, so a trial state never has to be written into history to be seen.
Keeps the existing rule that materials and solvers never call
commit()/revert()-- the driver does.6. "Solver" as a first-class concept
Solvers being materials is elegant and stays. But
converged()is a convention rather than a contract, which is howrk_integratorshipped both unregistered and without one (#61). Make it an interface the conformance test checks, and extend that test tosolvers/(#35).Not changing
material_point_evaluator. The UMAT layer is the best-reviewed part of the codebase.std::functiondispatch, the historycommit()assign,tangent_to_buffer. All measured; see the "not worth changing" list in Yield-function policies rebuild the rank-4 IIdev on every call: 15-22% of a Drucker-Prager / RK plastic step #77.Order
1 -> 4 -> 3 -> 2 -> 6 -> 5. Item 1 is the prerequisite: the library is named for a graph it does not build, and 2, 4 and 6 are all cleaner once it does.
All references are to
mainat 91bd26f.