Skip to content

core: fail loudly when a material reaches for something unwired (#59, #62) - #84

Open
petlenz wants to merge 1 commit into
mainfrom
fix/finalize-gate
Open

petlenz wants to merge 1 commit into
mainfrom
fix/finalize-gate

Conversation

@petlenz

@petlenz petlenz commented Sep 20, 2026

Copy link
Copy Markdown
Member

Closes #59, #62.

material_ref::get() was assert-only (#59)

The Release configuration is -O3 -DNDEBUG, so the only guard between a use-before-wire and a null dereference compiled away in exactly the builds that run real analyses. It now throws, naming the target.

Not the finalize-time sweep the issue proposed. wire_materials() already rejects a missing or wrongly-typed target, so after it succeeds every ref is wired by construction -- a sweep there would be unreachable and untestable. The reachable case is a read before finalize: a compute callback invoked by hand, a constructor that resolves too eagerly. That got no diagnosis at all.

Cost is one correctly-predicted branch, on a path the performance review measured as a plain pointer dereference and explicitly not a hotspot.

The vestigial virtual update() is gone (#62)

Nothing ever called it. Work happens only through callbacks bound to a property -- a member pointer passed to add_output(), or traits().update assigned directly. But the obvious way to write a material is

void update() override { m_out = ...; }

which compiled, ran nothing, and left the property at its initial value with no diagnostic. It was documented in exactly one place: an inline comment in weighted_sum.h.

Removing the virtual makes that a compile error on the override, which is the spelling a newcomer reaches for. It found one immediately -- a test material in test_drucker_prager.cpp had exactly that shape.

Materials that define update() keep it: it is their callback, passed as a member pointer, which never needed a virtual. Both solvers that call this->update() bind it themselves and are unaffected.

Tests

tests/test_wiring_contract.cpp, 5 tests. 305/305.

mutation result killed by
const get() check removed CAUGHT AMaterialRefReadBeforeFinalizeThrows
T& get() check removed CAUGHT AMaterialRefReadBeforeFinalizeThrows, TheUnwiredRefErrorNamesTheTarget
virtual update() restored CAUGHT the static_assert in MaterialInterfaceHasNoUpdateToOverride

The first version of the ref test was worthless and mutation testing is the only reason I know. It called get() only through a non-const path, so deleting the check from the const overload left the entire suite green. It now drives both overloads, before and after finalize().

Because #62's failure mode is a compile error in someone else's material, nothing here would otherwise notice the virtual coming back -- hence the static_assert guard, plus a test that a material's own update() still drives its property through add_output, so removing the virtual did not remove the mechanism.

…62)

material_ref::get() was guarded by an assert alone, and the Release
configuration is -O3 -DNDEBUG, so the only thing standing between a
use-before-wire and a null dereference compiled away in exactly the builds
that run real analyses. It now throws, naming the target.

Not the finalize-time sweep the issue proposed: wire_materials() already
rejects a missing or wrongly-typed target, so after it succeeds every ref
is wired by construction and a sweep would be unreachable and untestable.
The reachable case is a read BEFORE finalize -- a compute callback invoked
by hand, a constructor that resolves too eagerly -- which got no diagnosis
at all. The cost is one correctly-predicted branch on a path the
performance review measured as a plain pointer dereference and not a
hotspot.

material_interface's virtual update() is gone. It was vestigial from
before the property engine: nothing ever called it. Work happens only
through callbacks bound to a property, by passing a member pointer to
add_output() or by assigning traits().update directly. But the obvious way
to write a material is

    void update() override { m_out = ...; }

which compiled, ran nothing, and left the property at its initial value
with no diagnostic -- documented in one inline comment, in weighted_sum.h.
Removing the virtual makes that a compile error on the `override`, which
is the spelling a newcomer reaches for. It found one immediately: a test
material in test_drucker_prager.cpp had exactly that shape.

The materials that define update() keep it -- it is their callback, passed
to add_output as a member pointer, which never needed a virtual. Both
solvers that call this->update() bind it themselves and are unaffected.

305/305. Mutation tested: removing either get() overload's check fails
AMaterialRefReadBeforeFinalizeThrows, and restoring the virtual fails the
static_assert. The first version of the ref test reached only the mutable
overload, so deleting the const one's check left the suite green -- it now
drives both, before and after finalize.
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.

material_ref::get() is protected only by assert, Release builds dereference null

1 participant