From c88873e3025eea6a2f170bdc2073cbd7fddcd87e Mon Sep 17 00:00:00 2001 From: Josh Cudby Date: Wed, 12 Aug 2026 15:28:51 +0000 Subject: [PATCH 1/2] fix(propagator): :bug: invalidate functionals after update_initial_operator expectation_value_functional and expectation_value_and_gradient_functional snapshot the initial operator's coefficients. update_initial_operator re-weighted the operator in place without invalidating functionals built beforehand, so a stale functional would silently answer for coefficients the propagator no longer holds. It now bumps a re-weight epoch counter that every functional call checks, raising the same StaleFunctionalGraph error used for a rebuilt graph. Assisted-by: ClaudeCode:claude-sonnet-5 --- cpp/include/monoprop/MonomialPropagator.h | 4 ++ cpp/monoprop/Validation.cpp | 13 +++++- cpp/monoprop/Validation.h | 8 +++- .../MonomialPropagatorImpl.h | 8 ++++ cpp/tests/update_initial_operator.cpp | 33 +++++++++++++++ docs/content/docs/features/evaluation.mdx | 10 +++++ src/monoprop/majorana_propagator.py | 4 +- src/monoprop/monomial_propagator.py | 18 ++++++++- src/monoprop/pauli_propagator.py | 4 +- tests/test_parameter_validation.py | 40 +++++++++++++++++++ 10 files changed, 135 insertions(+), 7 deletions(-) diff --git a/cpp/include/monoprop/MonomialPropagator.h b/cpp/include/monoprop/MonomialPropagator.h index 2b09f350..e99f3f70 100644 --- a/cpp/include/monoprop/MonomialPropagator.h +++ b/cpp/include/monoprop/MonomialPropagator.h @@ -315,6 +315,10 @@ class MonomialPropagator { std::optional lower_atol_, upper_atol_; double core_term_{0.0}; + // Bumped by every initial-operator re-weight. A functional snapshots the operator coefficients, so + // it captures this and rejects a later call once it moves, as it does for a rebuilt graph. + size_t initial_operator_epoch_{0}; + size_t logical_num_modes_{NumModes}; CutoffType cutoff_type_; diff --git a/cpp/monoprop/Validation.cpp b/cpp/monoprop/Validation.cpp index f702072d..eecb7e90 100644 --- a/cpp/monoprop/Validation.cpp +++ b/cpp/monoprop/Validation.cpp @@ -26,8 +26,9 @@ class ValidationError : public std::runtime_error { using std::runtime_error::runtime_error; }; -// The graph was rebuilt after a functional captured its layer count, so the functional's parameter -// mapping no longer describes the graph. +// The propagator was mutated after a functional captured what it replays: a rebuilt graph leaves the +// functional's parameter mapping describing a graph that is gone, a re-weight leaves its snapshotted +// operator coefficients stale. class StaleFunctionalGraph : public std::runtime_error { public: using std::runtime_error::runtime_error; @@ -107,6 +108,14 @@ auto validate_expected_graph_layers(size_t current_layers, size_t expected_layer } } +auto validate_expected_initial_operator(size_t current_epoch, size_t expected_epoch) -> void { + if (current_epoch != expected_epoch) { + throw StaleFunctionalGraph("MP object has been modified since the functional was created. " + "The initial operator was re-weighted, so the coefficients the functional " + "snapshotted are stale; create a new functional."); + } +} + auto validate_only_rotate_len_k_(std::optional only_rotate_len_k, size_t max_k) -> void { if (!only_rotate_len_k.has_value()) { return; diff --git a/cpp/monoprop/Validation.h b/cpp/monoprop/Validation.h index 7124c06b..2d3a820a 100644 --- a/cpp/monoprop/Validation.h +++ b/cpp/monoprop/Validation.h @@ -23,8 +23,9 @@ namespace monoprop { // Each of these throws when the stated condition does not hold: ValidationError for inconsistent -// arguments, StaleFunctionalGraph when the propagator was mutated after a functional captured its -// layer count. Both derive from std::runtime_error, so catching that still catches either. +// arguments, StaleFunctionalGraph when the propagator was mutated after a functional captured the +// graph and operator it replays. Both derive from std::runtime_error, so catching that still catches +// either. monoprop_EXPORT auto validate_coefficient_lengths(const VecZ ¶meter_mapping, const VecD &gen_coeffs) -> void; @@ -40,6 +41,9 @@ monoprop_EXPORT auto validate_functional_call(const VecD ¶meters, size_t exp // The graph must still have the layer count the functional was built against. monoprop_EXPORT auto validate_expected_graph_layers(size_t current_layers, size_t expected_layers) -> void; +// The initial operator must not have been re-weighted since the functional snapshotted its coefficients. +monoprop_EXPORT auto validate_expected_initial_operator(size_t current_epoch, size_t expected_epoch) -> void; + // only_rotate_len_k is optional; when set it must satisfy 0 < k <= max_k. monoprop_EXPORT auto validate_only_rotate_len_k_(std::optional only_rotate_len_k, size_t max_k) -> void; diff --git a/cpp/monoprop/detail/monomial_propagator/MonomialPropagatorImpl.h b/cpp/monoprop/detail/monomial_propagator/MonomialPropagatorImpl.h index 7d0722f3..3aaba2be 100644 --- a/cpp/monoprop/detail/monomial_propagator/MonomialPropagatorImpl.h +++ b/cpp/monoprop/detail/monomial_propagator/MonomialPropagatorImpl.h @@ -201,6 +201,7 @@ MonomialPropagator::MonomialPropagator(const MonomialPropagator &other lower_atol_(other.lower_atol_), upper_atol_(other.upper_atol_), core_term_(other.core_term_), + initial_operator_epoch_(other.initial_operator_epoch_), logical_num_modes_(other.logical_num_modes_), cutoff_type_(other.cutoff_type_), basis_change_(other.basis_change_), @@ -349,6 +350,7 @@ auto MonomialPropagator::packed_inline_width_() const -> size_t { template auto MonomialPropagator::apply_initial_operator_(const OperatorDict &op_dict) -> std::pair, VecD> { + ++initial_operator_epoch_; if (partition_group_) { // The facade holds no local terms of its own, so the return is empty. for_each_partition_([&](MonomialPropagator &s) { s.update_initial_operator(op_dict); }); @@ -939,6 +941,9 @@ auto MonomialPropagator::make_functional_(Fn &&func, std::optionallayers(). + const auto *epoch = &initial_operator_epoch_; + const auto expected_epoch = initial_operator_epoch_; const auto &inverted_index = mp_op_.inverted_index(); // One owning handle either way: pare hands back a heap-owned MPGraph the functional must keep alive @@ -974,9 +979,12 @@ auto MonomialPropagator::make_functional_(Fn &&func, std::optional R { validate_expected_graph_layers(graph->layers(), expected_layers); + validate_expected_initial_operator(*epoch, expected_epoch); return func(EvalRequest{.e_core = core_term, .state = state, .op = op, diff --git a/cpp/tests/update_initial_operator.cpp b/cpp/tests/update_initial_operator.cpp index a2b42bb4..76dc74ac 100644 --- a/cpp/tests/update_initial_operator.cpp +++ b/cpp/tests/update_initial_operator.cpp @@ -52,6 +52,39 @@ BOOST_AUTO_TEST_CASE(update_initial_operator_updates_core_expval) { auto updated_fn = simulator.expectation_value_functional(std::nullopt); BOOST_TEST(updated_fn(empty_params) == 2.75, tt::tolerance(1e-12)); + + // The functional built before the re-weight snapshotted the old coefficients, so it must reject + // the call rather than answer for an operator the propagator no longer holds. + BOOST_CHECK_THROW(expval_fn(empty_params), std::runtime_error); +} + +BOOST_AUTO_TEST_CASE(update_initial_operator_invalidates_gradient_functional) { + constexpr size_t n_modes = 2; + OperatorDict initial_ham; + initial_ham[VecZ{}] = std::complex{1.0, 0.0}; + + VecZ initial_state{0, 1}; + MonomialPropagator simulator(initial_ham, + 2 * n_modes, + initial_state, + std::nullopt, + MPI_COMM_SELF, + std::nullopt, + std::nullopt, + CutoffType::Support, + std::nullopt); + + const VecD empty_params; + auto grad_fn = simulator.expectation_value_and_gradient_functional(std::nullopt); + BOOST_TEST(grad_fn(empty_params).first == 1.0, tt::tolerance(1e-12)); + + OperatorDict updated; + updated[VecZ{}] = std::complex{2.75, 0.0}; + simulator.update_initial_operator(updated); + + BOOST_CHECK_THROW(grad_fn(empty_params), std::runtime_error); + BOOST_TEST(simulator.expectation_value_and_gradient_functional(std::nullopt)(empty_params).first == 2.75, + tt::tolerance(1e-12)); } BOOST_AUTO_TEST_CASE(update_initial_operator_throws_for_unknown_term_in_heisenberg) { diff --git a/docs/content/docs/features/evaluation.mdx b/docs/content/docs/features/evaluation.mdx index eb450dca..75943bcc 100644 --- a/docs/content/docs/features/evaluation.mdx +++ b/docs/content/docs/features/evaluation.mdx @@ -47,6 +47,16 @@ expval, grad = expval_grad_fn(parameters) Shorter aliases are also available: `sim.expval_functional()` and `sim.expval_and_grad_functional()`. +A functional is built against the graph and initial-operator coefficients present when it is +created, so mutating either — +[build_graph][monoprop.monomial_propagator.MonomialPropagator.build_graph], +[contract_partially][monoprop.monomial_propagator.MonomialPropagator.contract_partially], +[update_initial_operator][monoprop.monomial_propagator.MonomialPropagator.update_initial_operator] — +invalidates it: calling it afterwards raises `RuntimeError` rather than returning a value for state +the propagator no longer holds. Build a new functional after such a call. The direct +[expectation_value][monoprop.monomial_propagator.MonomialPropagator.expectation_value] path always +reflects the current operator. + Both functionals accept an optional `pare_threshold` — see *Paring* below. ## Paring diff --git a/src/monoprop/majorana_propagator.py b/src/monoprop/majorana_propagator.py index 39135ab8..477b70fd 100644 --- a/src/monoprop/majorana_propagator.py +++ b/src/monoprop/majorana_propagator.py @@ -127,7 +127,9 @@ def update_initial_operator(self, new_operator: MajoranaOperator) -> None: Re-weights the initial operator the graph is evaluated against, without touching the evolution graph or rebuilding the simulator. Only the initial operator is - affected -- the gates and their generator coefficients are unchanged. + affected -- the gates and their generator coefficients are unchanged. Functionals created + earlier are invalidated; see + [update_initial_operator][monoprop.monomial_propagator.MonomialPropagator.update_initial_operator]. Args: new_operator: A [MajoranaOperator][monoprop.majorana.MajoranaOperator] whose terms diff --git a/src/monoprop/monomial_propagator.py b/src/monoprop/monomial_propagator.py index 27b88b79..63be1c0b 100644 --- a/src/monoprop/monomial_propagator.py +++ b/src/monoprop/monomial_propagator.py @@ -382,6 +382,10 @@ def expectation_value_functional( ) -> Callable[..., float]: """Return a reusable callable computing the expectation value from parameters. + The callable is built against the graph and initial-operator coefficients present now, so + mutating either -- [build_graph][], [contract_partially][], [update_initial_operator][] -- + invalidates it; build a new one after such a call. + Args: pare_threshold: Edge-retention cutoff for this functional's masked plan: edges contributing below it are pared away and skipped during replay, trading memory and @@ -389,6 +393,10 @@ def expectation_value_functional( Returns: A callable ``fn(parameters=None) -> float``. + + Raises: + RuntimeError: From the returned callable, if the propagator was mutated after this + functional was created. """ fn = self._simulator.expectation_value_functional(pare_threshold) return lambda parameters=None: fn(self._bind(parameters)) @@ -398,13 +406,18 @@ def expectation_value_and_gradient_functional( ) -> Callable[..., tuple]: """Return a reusable callable computing (expectation value, gradient). - Like [expectation_value_functional][], but one backward pass also yields the gradient. + Like [expectation_value_functional][], but one backward pass also yields the gradient. It is + invalidated by the same mutations. Args: pare_threshold: See [expectation_value_functional][]. Returns: A callable ``fn(parameters=None) -> (float, np.ndarray)``, gradient in parameter order. + + Raises: + RuntimeError: From the returned callable, if the propagator was mutated after this + functional was created. """ fn = self._simulator.expectation_value_and_gradient_functional(pare_threshold) @@ -528,6 +541,9 @@ def update_initial_operator(self, new_operator: T_op) -> None: Each concrete front-end implements this over its own operator type, encoding the terms into the engine's raw index tuples. + Functionals hold the coefficients they were built with, so any created earlier are + invalidated -- they raise instead of answering for the replaced operator. + Args: new_operator: A [MajoranaOperator][monoprop.majorana.MajoranaOperator] or [PauliOperator][monoprop.pauli.PauliOperator], per the front-end, whose terms replace diff --git a/src/monoprop/pauli_propagator.py b/src/monoprop/pauli_propagator.py index 36b0bf76..1b0ec4e8 100644 --- a/src/monoprop/pauli_propagator.py +++ b/src/monoprop/pauli_propagator.py @@ -220,7 +220,9 @@ def update_initial_operator(self, new_operator: PauliOperator) -> None: affected -- the gates and their generator coefficients are unchanged. Unlike the base method, which takes the engine's raw symplectic-slot keys, this accepts qubit Pauli terms and encodes them via - [get_local_operator][monoprop.pauli.PauliOperator.get_local_operator]. + [get_local_operator][monoprop.pauli.PauliOperator.get_local_operator]. Functionals created + earlier are invalidated; see + [update_initial_operator][monoprop.monomial_propagator.MonomialPropagator.update_initial_operator]. Args: new_operator: A [PauliOperator][monoprop.pauli.PauliOperator] whose terms replace the diff --git a/tests/test_parameter_validation.py b/tests/test_parameter_validation.py index 8f279f13..f9d92852 100644 --- a/tests/test_parameter_validation.py +++ b/tests/test_parameter_validation.py @@ -119,6 +119,46 @@ def test_functional_invalidated_after_graph_mutation(self, serial_comm): with pytest.raises(RuntimeError, match=r"MP object has been modified"): functional([1.0, 2.0]) + def test_expectation_value_functional_invalidated_after_initial_operator_update( + self, serial_comm + ): + mp, _ = _two_gate_graph(serial_comm) + functional = mp.expectation_value_functional() + parameters = [0.3, 0.7] + functional(parameters) + + mp.update_initial_operator( + MajoranaOperator({(0, 1): 2.0j, (2, 3): 0.5j}, num_modes=2) + ) + + # The functional snapshotted the old coefficients, so it must reject the call rather than + # keep answering for the operator the propagator no longer holds. + with pytest.raises(RuntimeError, match=r"MP object has been modified"): + functional(parameters) + + rebuilt = mp.expectation_value_functional()(parameters) + assert rebuilt == pytest.approx(mp.expval(parameters)) + + def test_expectation_value_and_gradient_functional_invalidated_after_initial_operator_update( + self, serial_comm + ): + mp, _ = _two_gate_graph(serial_comm) + grad_functional = mp.expectation_value_and_gradient_functional() + parameters = [0.3, 0.7] + grad_functional(parameters) + + mp.update_initial_operator( + MajoranaOperator({(0, 1): 2.0j, (2, 3): 0.5j}, num_modes=2) + ) + + # The functional snapshotted the old coefficients, so it must reject the call rather than + # keep answering for the operator the propagator no longer holds. + with pytest.raises(RuntimeError, match=r"MP object has been modified"): + grad_functional(parameters) + + rebuilt_expval, _ = mp.expectation_value_and_gradient_functional()(parameters) + assert rebuilt_expval == pytest.approx(mp.expval(parameters)) + class TestEvolvedOperatorBothPictures: def test_schrodinger_returns_state_dict(self, serial_comm): From 104928e6ccfbd4b14bf9aa30bda2c221c51d7ecc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:54:48 +0000 Subject: [PATCH 2/2] test(propagator): :white_check_mark: cover functional invalidation matrix Assisted-by: Copilot:gpt-5.6-sol Co-authored-by: robertodr <3708689+robertodr@users.noreply.github.com> --- tests/test_parameter_validation.py | 98 +++++++++++++++++++++--------- 1 file changed, 69 insertions(+), 29 deletions(-) diff --git a/tests/test_parameter_validation.py b/tests/test_parameter_validation.py index f9d92852..a38bbbea 100644 --- a/tests/test_parameter_validation.py +++ b/tests/test_parameter_validation.py @@ -18,8 +18,9 @@ import pytest -from monoprop import Circuit, ExpGate, MajoranaPropagator +from monoprop import Circuit, ExpGate, MajoranaPropagator, PauliPropagator from monoprop.majorana import MajoranaOperator +from monoprop.pauli import PauliOperator def _two_gate_graph(serial_comm): @@ -119,44 +120,83 @@ def test_functional_invalidated_after_graph_mutation(self, serial_comm): with pytest.raises(RuntimeError, match=r"MP object has been modified"): functional([1.0, 2.0]) - def test_expectation_value_functional_invalidated_after_initial_operator_update( - self, serial_comm + @pytest.mark.parametrize( + ( + "propagator_cls", + "initial_operator", + "updated_operator", + "gate_generators", + "cutoff", + ), + [ + pytest.param( + MajoranaPropagator, + MajoranaOperator({(0, 1): 1.0j, (2, 3): 0.5j}, num_modes=2), + MajoranaOperator({(0, 1): 2.0j, (2, 3): 0.5j}, num_modes=2), + ( + MajoranaOperator({(0,): 1.0}, num_modes=2), + MajoranaOperator({(1,): 1.0}, num_modes=2), + ), + 4, + id="majorana", + ), + pytest.param( + PauliPropagator, + PauliOperator({"ZZ": 1.0, "XX": 0.5}, num_qubits=2), + PauliOperator({"ZZ": 2.0, "XX": 0.5}, num_qubits=2), + ( + PauliOperator({"XI": 1.0}, num_qubits=2), + PauliOperator({"IY": 1.0}, num_qubits=2), + ), + 2, + id="pauli", + ), + ], + ) + @pytest.mark.parametrize( + "schrodinger_cutoff", [None, 2], ids=["heisenberg", "schrodinger"] + ) + @pytest.mark.parametrize( + "functional_name", + [ + "expectation_value_functional", + "expectation_value_and_gradient_functional", + ], + ) + def test_functional_invalidated_after_initial_operator_update( + self, + serial_comm, + propagator_cls, + initial_operator, + updated_operator, + gate_generators, + cutoff, + schrodinger_cutoff, + functional_name, ): - mp, _ = _two_gate_graph(serial_comm) - functional = mp.expectation_value_functional() + mp = propagator_cls( + initial_operator, + [0, 1], + cutoff=cutoff, + schrodinger_cutoff=schrodinger_cutoff, + comm=serial_comm, + ) + mp.build_graph( + Circuit(tuple(ExpGate(generator) for generator in gate_generators), 2) + ) + functional = getattr(mp, functional_name)() parameters = [0.3, 0.7] functional(parameters) - mp.update_initial_operator( - MajoranaOperator({(0, 1): 2.0j, (2, 3): 0.5j}, num_modes=2) - ) + mp.update_initial_operator(updated_operator) # The functional snapshotted the old coefficients, so it must reject the call rather than # keep answering for the operator the propagator no longer holds. with pytest.raises(RuntimeError, match=r"MP object has been modified"): functional(parameters) - rebuilt = mp.expectation_value_functional()(parameters) - assert rebuilt == pytest.approx(mp.expval(parameters)) - - def test_expectation_value_and_gradient_functional_invalidated_after_initial_operator_update( - self, serial_comm - ): - mp, _ = _two_gate_graph(serial_comm) - grad_functional = mp.expectation_value_and_gradient_functional() - parameters = [0.3, 0.7] - grad_functional(parameters) - - mp.update_initial_operator( - MajoranaOperator({(0, 1): 2.0j, (2, 3): 0.5j}, num_modes=2) - ) - - # The functional snapshotted the old coefficients, so it must reject the call rather than - # keep answering for the operator the propagator no longer holds. - with pytest.raises(RuntimeError, match=r"MP object has been modified"): - grad_functional(parameters) - - rebuilt_expval, _ = mp.expectation_value_and_gradient_functional()(parameters) + rebuilt = getattr(mp, functional_name)()(parameters) + rebuilt_expval = rebuilt[0] if isinstance(rebuilt, tuple) else rebuilt assert rebuilt_expval == pytest.approx(mp.expval(parameters))