Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,20 @@ Key files:
- `cpp/include/monoprop/MonomialPropagator.h`: the single templated C++ engine `MonomialPropagator<NumModes>`
(the Majorana/Pauli choice is a runtime `Basis`, not a separate class). Its `only_rotate_len_k`
arguments use `std::optional<size_t>`; `std::nullopt` means no gate-application length cap.
- `cpp/include/monoprop/Functional.h`: the two functional objects,
`ExpectationValueFunctional<NumModes>` and `ExpectationValueAndGradientFunctional<NumModes>`. Both derive
from `detail::FunctionalHandle<NumModes>` β€” the shared handle half β€” and each holds a
`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 plan holds the snapshot rather than the
choice of what to compute, so one plan type backs either kind (each factory call builds its own). A
functional borrows from its propagator (the inverted index always, the graph unless pared), so it
must not outlive it; the bindings pin that with
`nb::keep_alive<0, 1>`. It does **not** snapshot the initial-operator weights: it reads the
`detail::OperatorWeights` set the propagator has published, so it follows an
`update_initial_operator` instead of going stale β€” the one exception being a SchrΓΆdinger plan with a
`pare_threshold`, whose keep-set came from the coefficients the re-weight replaced, which throws
(`follows_weights` reports which case an object is).
- `src/monoprop/bindings/binder.h`: hand-written binding template; `tools/generate-*.py` generate the
per-mode-width `bindings.cpp` and `_dispatch.py` from it (do not hand-edit the generated files).
Both generators take the 32-mode storage-block rule from `tools/_binding_layout.py` β€” they must
Expand Down Expand Up @@ -98,6 +112,23 @@ Key files:
(`MajoranaAlgebra`, `PauliAlgebra` in `algebra/Algebra.h`) over shared structural primitives
(`algebra/AlgebraCommon.h`). The propagation backbone (the scan/fold in `detail/evolution/...`) is
templated on the algebra policy and bound to a runtime `Basis` once, via `with_algebra`.
- **`detail::FunctionalControl`** (`cpp/monoprop/detail/functional/Control.h`): the validity block a
propagator shares with every functional plan it makes β€” a structure revision, an alive flag, and the
name of the last structural change. A plan borrows from its propagator, so this is how it answers "is
the propagator still there, and does it still hold what I replay?" without dereferencing it. **Every
new mutating method must call `bump_structure_("its_name()")`** once the mutation has committed (a
rejected mutation must not bump); the settings that only gate the next build β€” the atols, the cutoff,
the cutoff type, the basis change β€” deliberately do not. Two halves go with that success-path bump:
guard the mutation itself with `bump_structure_on_unwind_` (`detail::BumpOnUnwind`), constructed after
the last rejection check and before the first write, so a mutator that throws part-way still
invalidates; and do not bump a fan-out whose children all no-op β€” decide with the facade-transparent
readers *before* fanning out, or `monoprop_PARTITIONS=auto` invalidates where `off` does not. A plan
additionally re-derives the operator's store pointer and inverted-index row count as a backstop, so a
missing bump reports staleness instead of folding a rebuilt index. The block also carries the
published `OperatorWeights`: a re-weight publishes a new set rather than bumping, which is what lets a
live functional follow it, and bumps only if it fails part-way. Publishing runs on the propagator's own thread (a facade publishes through
`for_each_partition_`), and a plan reads the set once per call so `op` and `core_term` cannot come from
two publications.
- **The partition facade**: `partitions > 1` makes a `MonomialPropagator` a facade over S single-partition
propagators, one hash partition each. Every method that fans out must use the private partition
vocabulary declared in `MonomialPropagator.h` (`for_each_partition_`, `map_partitions_`, `concat_partitions_`
Expand Down Expand Up @@ -137,6 +168,13 @@ mp = MajoranaPropagator(operator, initial_state, cutoff=4)

### Testing Structure

- `cpp/tests/functional_validity.cpp` is the **mutation table**: one row per public mutating method of
`MonomialPropagator`, recording what a functional built *before* that mutator ran does when called
*after* it β€” throw, or answer from its own snapshot. `MonomialPropagator::num_mutating_methods` pins
the roster and the table `static_assert`s against it, so adding a mutator means bumping that
constant and adding a row (the build fails until you do).
`tests/test_parameter_validation.py::TestFunctionalValidityTable` mirrors the same rows through the
Python front end, over `monoprop_PARTITIONS=off` and `=auto`.
- `tests/cases.py`: Parametrized test cases using `pytest-cases`; `load_problem()` loads a `tests/data/*.msgpack` fixture directly into the public API (`MonomialCircuit` + `MonomialOperator`). C++ tests use the equivalent `test_utils::load_case()` in `cpp/tests/TestData.h`
- Fixture msgpack schema is documented in `tests/data/README.md`
- Tests validate against exact solutions for small systems
Expand Down Expand Up @@ -167,6 +205,10 @@ mp = MajoranaPropagator(operator, initial_state, cutoff=4)
7. Add Python bindings in `src/monoprop/bindings/binder.h`
8. Regenerate bindings with `tools/generate-binders.py`
9. Test with both C++ and Python tests
10. If the new method mutates a `MonomialPropagator`: call `bump_structure_` from it and guard it with
`bump_structure_on_unwind_` (see `detail::FunctionalControl`), bump
`MonomialPropagator::num_mutating_methods`, and add its row to the mutation table (see "Testing
Structure"). A mutator with no row leaves its effect on a live functional unrecorded.

## Documentation Maintenance Policy

Expand Down
1 change: 1 addition & 0 deletions cpp/include/monoprop/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ target_sources(
"${PROJECT_BINARY_DIR}/include/${PROJECT_NAME}/Info.h"
"${PROJECT_BINARY_DIR}/include/${PROJECT_NAME}/Variants.h"
"${PROJECT_SOURCE_DIR}/cpp/include/${PROJECT_NAME}/Evolution.h"
"${PROJECT_SOURCE_DIR}/cpp/include/${PROJECT_NAME}/Functional.h"
"${PROJECT_SOURCE_DIR}/cpp/include/${PROJECT_NAME}/MPFunctions.h"
"${PROJECT_SOURCE_DIR}/cpp/include/${PROJECT_NAME}/MPGraph.h"
"${PROJECT_SOURCE_DIR}/cpp/include/${PROJECT_NAME}/MonomialPropagator.h"
Expand Down
217 changes: 217 additions & 0 deletions cpp/include/monoprop/Functional.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// Copyright 2026 Algorithmiq
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#pragma once

#include <cstddef>
#include <memory>
#include <type_traits>
#include <utility>
#include <variant>
#include <vector>

#include "monoprop/MPFunctions.h"
#include "monoprop/MPGraph.h"
#include "monoprop/TypeAliases.h"
#include "monoprop/Validation.h"
#include "monoprop/detail/evolution/CosineRecomputeCallbacks.h"
#include "monoprop/detail/functional/Control.h"
#include "monoprop/detail/mpi/Comm.h"
#include "monoprop/detail/operator/MPOperator.h"
#include "monoprop/detail/partition/PartitionGroup.h"

namespace monoprop {

template <size_t NumModes>
class MonomialPropagator;

namespace detail {

/// Immutable, shared functional replay plan.
/// Borrowed fields require the functional to outlive neither its propagator nor its partitions.
template <size_t NumModes>
class FunctionalPlan {
public:
/// A single-partition propagator's snapshot: one replay of its graph against its operator.
struct Local {
// Build-time weights; also used until a re-weight publishes new weights.
std::shared_ptr<const OperatorWeights> weights;
// Owned snapshot: operator rows can grow, but `op` cannot.
EvalState state; ///< the contraction partner, sparse (Heisenberg) or dense (Schrodinger)
VecZ parameter_mapping; ///< optimizer order: which parameter drives graph layer i
VecD gen_coeffs; ///< optimizer order, parallel to parameter_mapping
// Owned because `cos` holds raw pointers into graph layers.
std::shared_ptr<const MPGraph> graph;
// `cos` borrows columns from the propagator's inverted index.
CosCallbacks cos;
mpi::Comm comm{}; ///< real MPI across nodes, or the in-process comm across partitions

// Borrowed operator and inverted-index identity check.
// This catches a missing revision bump before the stale index is used.
const MPOperator<NumModes> *mp_op{nullptr};
const OperatorIndex<NumModes> *op_store{nullptr};
size_t inverted_index_rows{0};

// A coefficient-pared Schrodinger graph cannot follow a re-weight.
bool pared_from_operator{false};
};

/// A partition facade's snapshot: one child plan per partition, replayed together.
struct Fanout {
// Borrowed from the facade propagator.
partition::PartitionGroup<NumModes> *group{nullptr};
std::vector<std::shared_ptr<const FunctionalPlan>> partitions; ///< in partition order
};

/// Pins the propagator control block and its current revision.
FunctionalPlan(size_t num_params, std::shared_ptr<const FunctionalControl> control, Local local)
: num_params_(num_params),
control_(std::move(control)),
expected_revision_(control_->structure_revision.load()),
shape_(std::move(local)) {}

FunctionalPlan(size_t num_params, std::shared_ptr<const FunctionalControl> control, Fanout fanout)
: num_params_(num_params),
control_(std::move(control)),
expected_revision_(control_->structure_revision.load()),
shape_(std::move(fanout)) {}

/// Required parameter-axis length.
auto num_params() const -> size_t { return num_params_; }

/// Whether calls may follow re-weighted coefficients.
auto follows_weights() const -> bool {
if (const auto *fanout = std::get_if<Fanout>(&shape_)) {
// Child plans share picture and threshold.
return fanout->partitions.front()->follows_weights();
}
return !std::get<Local>(shape_).pared_from_operator;
}

/// Throw unless `params` and the propagator still match this plan.
// The facade validates its group; child plans validate on their partition masters.
auto validate(const VecD &params) const -> void {
// Check liveness before reading the borrowed operator for the layout check.
const bool alive = control_->propagator_alive.load();
const auto *local = alive ? std::get_if<Local>(&shape_) : nullptr;
validate_functional_state({.propagator_alive = alive,
.current_revision = control_->structure_revision.load(),
.expected_revision = expected_revision_,
.operator_layout_unchanged = local == nullptr || operator_layout_unchanged(*local),
.last_structural_change = control_->last_structural_change.load()});
validate_functional_call(params, num_params_);
}

/// Replay locally, or return partition 0's facade result.
template <typename Fn,
typename R = std::invoke_result_t<Fn &, const EvalRequest &, mpi::Comm, const CosCallbacks &>>
auto evaluate(Fn &&fn, const VecD &params) const -> R {
validate(params);
if (const auto *fanout = std::get_if<Fanout>(&shape_)) {
// Every partition must join its synchronized collective; partition 0 has the result.
return std::move(partition::collect_on_all(*fanout->group, [&](int r) -> R {
return fanout->partitions[static_cast<size_t>(r)]->evaluate(fn, params);
})[0]);
}
const auto &local = std::get<Local>(shape_);
// Keeps `request.op` alive for the call.
const auto weights = resolve_weights(local);
return fn(EvalRequest{.e_core = weights->core_term,
.state = local.state,
.op = weights->op,
.parameter_mapping = local.parameter_mapping,
.gen_coeffs = local.gen_coeffs,
.graph = local.graph->replay_view(),
.params = params},
local.comm,
local.cos);
}

private:
// Load matching `op` and `core_term` from the current weight publication.
auto resolve_weights(const Local &local) const -> std::shared_ptr<const OperatorWeights> {
auto published = control_->weights.load();
// No publication or no re-weight: build-time weights are current.
if (published == nullptr || published == local.weights) {
return local.weights;
}
validate_weight_refresh({.weights_revision = published->structure_revision,
.expected_revision = expected_revision_,
.may_follow_weights = follows_weights()});
return published;
}

// Do not call inverted_index(): it could rebuild the borrowed index.
static auto operator_layout_unchanged(const Local &local) -> bool {
return local.mp_op->store.get() == local.op_store && local.mp_op->inverted_index_.has_value()
&& local.mp_op->inverted_index_->rows() == local.inverted_index_rows;
}

size_t num_params_{0};
std::shared_ptr<const FunctionalControl> control_;
size_t expected_revision_{0};
std::variant<Local, Fanout> shape_;
};

/// Shared functional handle; derived types differ only in `operator()`.
template <size_t NumModes>
class FunctionalHandle {
public:
/// Required parameter-axis length.
auto num_params() const -> size_t { return plan_->num_params(); }

/// Whether calls may follow a re-weight.
auto follows_weights() const -> bool { return plan_->follows_weights(); }

protected:
explicit FunctionalHandle(std::shared_ptr<const FunctionalPlan<NumModes>> plan) : plan_(std::move(plan)) {}

std::shared_ptr<const FunctionalPlan<NumModes>> plan_;
};

} // namespace detail

/// Reusable expectation value: `fn(parameters) -> double`.
///
/// Borrows its propagator and throws after structural mutation. It follows re-weighted initial-operator
/// coefficients unless its graph was coefficient-pared.
template <size_t NumModes>
class ExpectationValueFunctional : public detail::FunctionalHandle<NumModes> {
public:
auto operator()(const VecD &parameters) const -> double { return this->plan_->evaluate(ev, parameters); }

private:
friend class MonomialPropagator<NumModes>;

explicit ExpectationValueFunctional(std::shared_ptr<const detail::FunctionalPlan<NumModes>> plan)
: detail::FunctionalHandle<NumModes>(std::move(plan)) {}
};

/// Expectation value and gradient from one backward pass.
/// `fn(parameters) -> (value, gradient)`, with the gradient in parameter-axis order.
template <size_t NumModes>
class ExpectationValueAndGradientFunctional : public detail::FunctionalHandle<NumModes> {
public:
auto operator()(const VecD &parameters) const -> std::pair<double, VecD> {
return this->plan_->evaluate(ev_and_grad, parameters);
}

private:
friend class MonomialPropagator<NumModes>;

explicit ExpectationValueAndGradientFunctional(std::shared_ptr<const detail::FunctionalPlan<NumModes>> plan)
: detail::FunctionalHandle<NumModes>(std::move(plan)) {}
};

} // namespace monoprop
Loading
Loading