feat(propagator)!: ✨ name the evaluation functionals, and follow an initial-operator re-weight - #251
feat(propagator)!: ✨ name the evaluation functionals, and follow an initial-operator re-weight#251robertodr wants to merge 16 commits into
Conversation
Stage 0 of the function-object plan: write down today's behaviour before changing any of it. One table row per public mutating method of MonomialPropagator, asserted for the value and the gradient functional, with and without a pare threshold, in both pictures. Two rows record defects rather than intent, each with the stage that fixes it: - a pared plan owns its layers, so its layer count cannot move and the live-graph check never fires after build_graph or an in-place contract_partially; - set_parameter_mapping relabels in place, so neither the layer count nor the initial-operator epoch moves and the plan keeps replaying the old labels. Both get a witness test that shows the propagator's own answer *did* move while the functional's did not, so the rows cannot pass vacuously. num_mutating_methods pins the roster on the class and the table static_asserts against it, so a new mutator cannot land without recording what it does to a live functional. Assisted-by: ClaudeCode:claude-opus-5
Stage 1 of the function-object plan. make_functional_ returned an anonymous std::function whose twelve-item capture list carried the ownership and lifetime rules in five comment paragraphs, because the object had no declaration to put them on. Those captures are now the fields of detail::FunctionalPlan<NumModes>: the propagator snapshot a call replays, plus the checks that say the snapshot is still that propagator's. The plan's std::variant carries the single-partition shape and the facade shape, so both paths have one public type, and the two functional kinds over one snapshot share one plan -- a facade now fans out once instead of once per kind. expectation_value_functional and expectation_value_and_gradient_functional return ExpectationValueFunctional<NumModes> and ExpectationValueAndGradientFunctional<NumModes>. Both report num_params. Every C++ call site used auto, so none needed an edit. The bound classes carry nb::keep_alive<0, 1> on their factories, which closes the last of the three defects the plan found: the raw core class let a functional outlive the propagator whose inverted index it reads, and only the Python front-end's wrapper lambda was hiding it. No behaviour change. Both checks keep the same arithmetic, and the mutation table is unchanged. The module got 16 KiB smaller (2 406 200 -> 2 389 816 bytes): losing one layer of type erasure more than pays for two more classes per mode width. A functional call is 2-3% faster (energy 8.2 -> 8.0 us, energy+gradient 19.5 -> 18.8 us on a 964-term, 60-layer problem). Assisted-by: ClaudeCode:claude-opus-5
Stage 2 of the function-object plan. Two counters guarded a functional before: the graph's layer count and an initial-operator epoch. Between them they missed three mutations and read one raw pointer into the propagator they were supposed to protect. detail::FunctionalControl replaces both. A propagator shares one block with every plan it makes: a structure revision, an alive flag, and the name of the method that last moved the structure. bump_structure_ is called from build_graph, propagate, an inplace contract_partially and set_parameter_mapping -- and deliberately not from the update_setting_ family, whose atols, cutoff, cutoff type and basis change gate the next build and touch nothing a plan holds. What this closes: - set_parameter_mapping relabels in place, so the layer count and the epoch both held and a functional kept answering for the old labels. It now throws, naming the method. - propagate leaves the layer count at zero, so nothing saw the re-evolved operator. It now throws. - a pared plan owns its layers, so its layer count could never move; both build_graph and an inplace contract_partially slipped past it. The revision does not care which shape the plan has. - a functional that outlives its propagator reports the destruction instead of folding a dangling inverted-index pointer. A facade's plan checks the facade's own block, since the partition group goes with it. As a backstop for the still-borrowed inverted index, a plan also re-derives the operator's store pointer and index row count and compares them before it reads the index. That check comes from the data, so a future mutator that forgets its bump reports staleness rather than a wrong number. validate_expected_graph_layers and validate_expected_initial_operator are gone, replaced by validate_functional_state. Both were monoprop_EXPORT with no callers outside the .inl. Assisted-by: ClaudeCode:claude-opus-5
The layout backstop reads the propagator's operator store through a borrowed pointer, and it was written as one field of the aggregate passed to validate_functional_state. Every argument is evaluated before the callee runs, so on a destroyed propagator the read happened before the alive flag could stop it -- a heap-use-after-free inside the check whose job is to prevent exactly that. Aliveness is now settled in validate() and a dead propagator drops out of the argument list, leaving the control block, which is shared and outlives the propagator, as the only thing read. Found by running the C++ suite under the address and undefined-behaviour sanitisers; the ordinary build read plausible freed memory and passed. Assisted-by: ClaudeCode:claude-opus-5
Stage 3 of the function-object plan. Without a pare threshold the plan aliased the propagator's graph_ through a shared_ptr with an empty owner block, while its cosine callbacks held one raw CosMask pointer per layer -- pointers a later append_layer, slice_graph or maybe_compact_layers could move or free. The pared path already owned its layers, because pare_graph builds a new graph. Both paths now own their layers, so those pointers cannot dangle. The copy is cheap: a Layer is a shared_ptr to an immutable core plus an optional CosMask, and MPGraph::append never stores a cosine set, so copying a normally-built graph is one pointer copy per layer. Every number is bit-identical to stage 2 -- checked as hex floats across both pictures, both partition settings and both pare settings, for the value and the gradient. The C++ suite is clean under the address and undefined-behaviour sanitisers. Assisted-by: ClaudeCode:claude-opus-5
`update_initial_operator` used to invalidate every live functional: a call after it threw `StaleFunctionalGraph` rather than answer. It no longer does. A re-weight moves no structure -- `MPOperator::update_initial_operator` cannot add a store row, so the store, the inverted index and the graph all stay put -- and the only state it does move is a coefficient vector and the core term. Those two now travel together as a `detail::OperatorWeights` set, published into the shared `FunctionalControl` block instead of bumping the structure revision. A call reads the set with one atomic load, so `op` and `core_term` cannot come from two re-weights, and a facade publishes through `for_each_partition_` so every partition has published before the call returns. Two cases still throw: - A Schrodinger functional built with a `pare_threshold`. Its keep-set was thresholded from the very coefficients the re-weight replaced, so the pared graph it holds is not the graph the new coefficients ask for. Heisenberg pares the state, which a re-weight leaves alone, so it follows exactly. - A re-weight that fails part-way. `apply_initial_operator_` can throw with the core term already written, and a facade can have applied some partitions, so the failure path bumps the revision -- invalidating a functional that did not need it costs a rebuild, answering from a half-written operator costs a wrong number. The price of the new rule is that a functional is a live view of the weights: two calls with the same parameters give two answers across a re-weight, and a caller who needs a frozen value must build the functional again after the last one. The docstrings say so. Verified bit-for-bit rather than to a tolerance: a re-weighted propagator's functional returns exactly what a functional over a propagator built with those coefficients returns -- value and gradient, pared and unpared, one partition and the default count, and under MPI at 2 and 4 ranks. Assisted-by: ClaudeCode:claude-opus-5
…tional report it The re-weight rule was only in the commit that changed it. It now sits where a reader meets a functional: - The evaluation page carries the whole mutation table -- one row per public mutating method, saying what a call afterwards does -- and points at `cpp/tests/functional_validity.cpp`, which asserts the same rows. The initialisation page's re-weight section links to it. - `AGENTS.md` records where the weights live and who may publish them, next to the `bump_structure_` rule it qualifies. Both functional classes gain a read-only `follows_weights`, so a caller holding a functional and not its propagator can see which case it is instead of re-deriving it from the picture and the pare threshold. The front-end factories carry the attribute through onto the callable they return: the engine-level functional is not part of the public surface, so leaving it only there would put the contract out of reach of every user of `MajoranaPropagator` and `PauliPropagator`. Assisted-by: ClaudeCode:claude-opus-5
|
Docs preview: https://pr-251.monoprop-docs.pages.dev |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #251 +/- ##
==========================================
+ Coverage 97.70% 97.78% +0.08%
==========================================
Files 14 14
Lines 742 769 +27
Branches 98 98
==========================================
+ Hits 725 752 +27
Misses 12 12
Partials 5 5
Flags with carried forward coverage won't be shown. Click here to find out more. |
… fixture and the bound functional The rule that decides whether a functional may follow a re-weight was written twice, in opposite polarity: FunctionalPlan::follows_weights() returned !pared_from_operator, and validate_weight_refresh threw on it. resolve_weights now passes its own follows_weights(), so the property advertised out to Python and the check that enforces it are the same expression. The front end returned closures with engine attributes hand-copied onto them, a list that had already lost num_params. _BoundFunctional forwards by __getattr__ instead, so a property added on the C++ side surfaces without an edit here. Also: publish the initial-operator weights only when a set has already been handed out, since publishing copies the whole coefficient vector; read the facade's parameter axis off a child plan rather than rebuilding partition 0's gate arrays; fold the re-weighted propagator fixture into make_propagator with one named coefficient; drop a roster assertion that compared the table to a copy of itself, and pin the row count on the static_assert that is the real gate. Assisted-by: ClaudeCode:claude-opus-5
| assert after == pytest.approx(mp.expval(parameters)) | ||
| assert after == _value(getattr(mp, functional_name)()(parameters)) |
There was a problem hiding this comment.
question: would the test be cleaner if the reference value would be computed with a fresh mbs?
There was a problem hiding this comment.
no, that would defeat the point of the changes in this PR. Which is to ensure that functionals taken from a single propagator stay valid, under certain conditions. Basically, hardening what was done in #225
| ], | ||
| ) | ||
| def test_functional_invalidated_after_initial_operator_update( | ||
| def test_functional_follows_initial_operator_update( |
There was a problem hiding this comment.
perhaps this question should also compare gradients? for some reason it only checks expectation values
| assert after == _value(getattr(mp, functional_name)()(parameters)) | ||
|
|
||
|
|
||
| class TestFunctionalValidityTable: |
There was a problem hiding this comment.
I'm completely lost with this tests
There was a problem hiding this comment.
this PR adds ways to keep a functional valid when a limited set changes are made to the parent propagator. This test checks that this limited set of changes produces the desired behavior.
Co-authored-by: Roberto Di Remigio Eikås <robertodr@users.noreply.github.com> Signed-off-by: Roberto Di Remigio Eikås <robertodr@users.noreply.github.com>
the latter is not implemented in LLVM libc++
|



🤖 AI text below 🤖
Summary
make_functional_returned an anonymousstd::functionwhose twelve-item capture list carried itsownership and lifetime rules in five comment paragraphs, because the object had no name and no
declaration. This replaces it with two named function-object classes over one named plan, so those
rules become a member list a reader can check.
Reading the two validity checks closely enough to move them turned up three defects, all fixed here:
expected_layerswas read offthe live graph, then compared against the owned pared graph, whose layer count is fixed at
construction and equal to it by construction. A later
build_graphtherefore passed both checks,and the call went on to read the cosine callbacks' raw pointers into an inverted index that had
been grown or rebuilt — undefined behaviour.
set_parameter_mappingmoved neither check. It relabels the layers in place, so a functionalbuilt before the call kept the old labels and returned a wrong number with no message.
propagator. A call after the propagator's destruction read freed memory inside the check meant
to prevent exactly that. The Python front end hid it (the wrapper captured
self); the bound coreclass had no
keep_alive, and a test used that raw path.One
detail::FunctionalControlblock — a structure revision, an alive flag, and the name of the laststructural change — replaces both counters. Every mutating method bumps it, which is now pinned by a
build-time assertion:
MonomialPropagator::num_mutating_methodsstates the roster andcpp/tests/functional_validity.cppstatic_asserts its row count against it, so a new mutatorbreaks the build until its effect on a live functional is recorded.
On top of that, one documented behaviour change:
update_initial_operatorno longer invalidates alive functional. A re-weight moves no structure —
MPOperator::update_initial_operatorcannot adda store row, so the store, the inverted index and the graph all stay put — and the only state it does
move is a coefficient vector and the core term. Those travel together as a
detail::OperatorWeightsset published into the control block, which a call reads with one atomic load, so
opandcore_termcan never come from two different re-weights. The price is that a functional is a liveview of the weights: two calls with the same parameters give two answers across a re-weight, and a
caller who needs a frozen value must build the functional again. Two cases still throw — a
Schrödinger functional with a
pare_threshold, whose keep-set was thresholded from the verycoefficients the re-weight replaced, and a re-weight that fails part-way.
Landed as seven commits, one per stage of the plan, each green on its own.
Changes
cpp/tests/functional_validity.cpp(first commit) — the mutation table: one row per publicmutating method, recording what a functional built before that call does when called after it,
asserted for the value and the gradient functional, with and without a pare threshold, in both
pictures.
tests/test_parameter_validation.py::TestFunctionalValidityTablemirrors it through thePython front end over
monoprop_PARTITIONS=offand=auto.cpp/include/monoprop/Functional.h—ExpectationValueFunctional<NumModes>andExpectationValueAndGradientFunctional<NumModes>, each a handle on one shareddetail::FunctionalPlan<NumModes>. The plan'sstd::variantcarries the single-partition shape andthe partition-facade shape, so both paths have one public type, and the value and gradient
functional over one snapshot share one plan. Bound with
nb::keep_alive<0, 1>.cpp/monoprop/detail/functional/Control.h— the shared validity block, plus the publishedOperatorWeights.Validation.h/.cpp—validate_expected_graph_layersandvalidate_expected_initial_operatorgive way to
validate_functional_stateandvalidate_weight_refresh. StillStaleFunctionalGraph, so the promisedRuntimeErroris unchanged.cosholds a rawCosMaskpointer per layer, so a laterappend_layer,slice_graphormaybe_compact_layersmust not be able to move or free them. Thecopy is one pointer per layer for a normally built graph.
compared before any use of that index, so a mutation that forgets its
bump_structure_reportsstaleness instead of folding a rebuilt index through a pointer to the old one.
follows_weightson both classes, carried through onto the callables the front-end factoriesreturn, so a caller holding a functional can read the contract off it.
re-weight rule and its price in the docstrings of both factories and of
update_initial_operator(base class and both front ends);
AGENTS.mdrecords where the weights live and who may publishthem, next to the
bump_structure_rule it qualifies.Measured, since the plan expected the module to grow: it shrank by 16 KiB (2 406 200 → 2 389 816
bytes), and a call got 2–3% faster. Losing a layer of type erasure more than pays for two extra
classes per mode width.
Verification beyond the suites: the Stage 3 layer-ownership change was proved bit-identical to Stage 2
across eight configurations via hex-float fingerprints, and the weight refresh is asserted bit for
bit rather than to a tolerance — a re-weighted propagator's functional returns exactly what a
functional over a propagator built with those coefficients returns, for value and gradient, pared and
unpared, at
partitions=1and the default count, under MPI at 2 and 4 ranks. An ASan+UBSan run over232 cases is clean; it is also what caught defect 3's fix being incomplete in an intermediate commit
(
fix(propagator): check the alive flag before the operator backstop) — the ordinary build readplausible freed memory and passed all 213 tests.
Two notes for the reviewer:
written, because
apply_initial_operator_can throw with the core term already written and a facadecan have applied some partitions. Conservative, and no worse than before (the old code bumped
unconditionally). Making the single-partition path transactional — deferring the
core_term_writeuntil after
MPOperator::update_initial_operatorcommits — would make the check exact; it changesfailure-path semantics, so it is not in this PR.
.github/release.ymlgeneratesrelease notes from PR titles. The
feat!commit title and body carry the behaviour change instead.Checklist
docs/,CONTRIBUTING.md) if neededCHANGELOG/ release notes updated if applicable — no changelog file exists; see the note aboveAI/LLM disclosure
by the
Assisted-by:trailer on every commitImportant
By opening this PR I confirm that I have read CONTRIBUTING.md and I agree to the terms of the Contributor License Agreement.
🤖 Generated with Claude Code