Repository navigation
feat(runtime): U(1)-conserving Pauli propagation helpers - #94
AlexSchuckert wants to merge 1 commit into
Conversation
Adds exchange(a, b, theta) for exp[-i θ/2 (XX + YY)] and a convenience
xyzz(a, b, theta_xy, theta_zz) combining the XY exchange with a ZZ
interaction. Both are exposed on PauliSum<T> via a new U1Conserving
trait and surfaced through the existing create_interface! macro so every
config variant picks them up.
Also adds a Python-level apply_u1_trotter_step(edges, theta_xy,
theta_zz, fields_z) helper that composes xyzz and per-site rz for one
Trotter slice of a z-magnetization-conserving Hamiltonian.
Includes Rust and Python correctness tests:
- exchange == rxx ∘ ryy on every two-qubit Pauli at multiple angles
- xyzz == exchange ∘ rzz
- zero-angle identity for both gates
- Σ Z_i is preserved by exchange / xyzz / apply_u1_trotter_step
- The 2-D U(1) sector (Z_a − Z_b, Y_aX_b − X_aY_b) rotates correctly
- apply_u1_trotter_step matches manual gate application
- Per-edge couplings and per-edge angle sequences work as documented
Plus four Rust + three Python hardening tests pinning truncation-robust
conservation under CoefficientThreshold(0.5) and MaxPauliWeight(1),
including the full Σ_{i<j} Z_iZ_j correlator over all pairs. The
trait/method docstrings document precisely when conservation is
truncation-robust and call out the per-gate floating-point ε floor.
A short Heisenberg-dynamics notebook example lives in
docs/notebooks/u1_heisenberg.py.
Intended for XY and Heisenberg-style z-magnetization-conserving spin
models, building inside the existing PauliSum infrastructure without a
new operator basis or simulator backend.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
👋 Thanks for opening your first pull request against PPVM!
A quick note on contribution terms: by submitting this PR you
agree that your contribution is licensed under the
Apache License 2.0
and that you accept the
PPVM Contributor License Agreement.
Please skim those before a maintainer reviews — opening this PR
counts as your acceptance.
A few things that will speed up review:
- Read
CONTRIBUTING.md
for the workflow, build commands, and style notes. - Run
prek run --all-fileslocally; CI runs the same checks. - Use Conventional Commits
for commit messages.
We'll get to your PR as soon as we can. Thanks for contributing!
|
There was a problem hiding this comment.
Pull request overview
Adds U(1)-conserving (total-Z / z-magnetization conserving) Pauli propagation helpers across the Rust runtime and Python API surface, enabling ergonomic construction of XY / XXZ / Heisenberg-style dynamics while reusing existing rxx/ryy/rzz rotation primitives.
Changes:
- Introduces a new runtime trait
U1Conservingwithexchangeandxyzz, plus aPauliSumimplementation backed byrxx/ryy/rzz. - Exposes
exchange/xyzzto Python via the native interface and adds Python wrappers plus a high-levelPauliSum.apply_u1_trotter_step(...)helper. - Adds Rust + Python tests and a documentation notebook demonstrating U(1)-symmetric Trotter dynamics.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ppvm-python/test/test_u1.py | New Python tests for exchange, xyzz, and apply_u1_trotter_step, including truncation hardening checks. |
| ppvm-python/src/ppvm/paulisum.py | Adds apply_u1_trotter_step helper and documents the U(1) helper APIs. |
| ppvm-python/src/ppvm/mixins.py | Adds Python-level exchange/xyzz methods on RotationsMixin. |
| docs/notebooks/u1_heisenberg.py | New notebook example for U(1)-conserving XY/Heisenberg chain Trotter dynamics. |
| crates/ppvm-runtime/src/traits/mod.rs | Re-exports the new U1Conserving trait. |
| crates/ppvm-runtime/src/traits/branch/mod.rs | Wires in the new traits/branch/u1.rs module. |
| crates/ppvm-runtime/src/traits/branch/u1.rs | Defines the U1Conserving trait and its documentation/guarantees. |
| crates/ppvm-runtime/src/sum/mod.rs | Adds the sum/u1.rs module. |
| crates/ppvm-runtime/src/sum/u1.rs | Implements U1Conserving for PauliSum and adds comprehensive Rust tests. |
| crates/ppvm-python-native/src/interface.rs | Exposes exchange/xyzz through the PyO3 interface and applies truncation after each call. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| All terms generated by the unitary commute with the total `Z` | ||
| operator, so the ordering is mathematically irrelevant — only | ||
| the per-edge product matters. The fixed call order is chosen | ||
| for predictable test output. |
|
@AlexSchuckert the changes here look fine, since they are mostly small additions. Just to clarify, however, could this also be achieved by allowing control over when to truncate from the python side? |
|
Thanks! Yes I think so, that would be equivalent. |
|
Superseded by #100 ( |
## Summary
Adds a `preserve_strings` keep-set on `PauliSum` that tells `truncate()`
to never drop a chosen set of Pauli strings, **regardless of which
truncation strategy is active**.
**Motivation.** For transport diagnostics like `Σ_j <Z_j(t) Z_i(0)>`,
the answer depends only on the projection of the propagated `Z_i(t)`
onto the L single-Z strings. Plain `CoefficientThreshold` (or any other
truncation strategy) drops those exact strings once their coefficients
drift toward the cutoff (typically the tail of a spreading operator at
far sites), causing the conserved-charge component to leak. With
`preserve_strings` set to the single-Z list, the small chosen set is
always kept regardless of coefficient, and `<Σ Z>` is preserved to
floating-point precision.
## Design
`preserve` is **not** itself a truncation strategy — it composes with
any [`Strategy`](crates/ppvm-runtime/src/traits/strategy.rs) via a
snapshot-and-restore post-filter inside `PauliSum::truncate`:
```rust
pub fn truncate(&mut self) {
if self.preserve_strings.is_empty() { /* fast path: just run strategy */ }
// snapshot the current coefficients of preserved keys
let saved: Vec<(W, V)> = …;
// run the configured strategy verbatim
self.strategy.truncate(self.data_mut());
// re-insert any preserved entry the strategy dropped
for (k, v) in saved { … }
}
```
That's the whole mechanism. It composes with `CoefficientThreshold`,
`MaxPauliWeight`, `CombinedStrategy(both)`, or anything else without
modifying any strategy. The strategy runs unchanged in the middle.
## API
Rust:
```rust
let mut s: PauliSum<Cfg> = PauliSum::builder()
.n_qubits(L)
.strategy(CoefficientThreshold(1e-3)) // any strategy
.preserve_strings(preserve::single_z(L))
.build();
```
Python:
```python
ps = PauliSum.new(
L, f"Z{i}",
min_abs_coeff=1e-3, # any truncation knob
max_pauli_weight=L, # …or combinations
preserve_strings=preserve_single_z(L),
)
```
Module-level helpers `preserve::single_z(n)` and
`preserve::from_strings(...)` (and the Python `preserve_single_z`
re-export) build the keep-set.
## History of this PR
This branch had two earlier commits implementing the keep-set as
`PreserveConfig<W>` that *replaced* the configured strategy inside
`truncate`, and bundled a `weight_lambda` "virtual DAOE" knob. Review
feedback said the keep-set shouldn't be a separate strategy. The third
commit (`41892e0`) is that refactor:
- Drops `PreserveConfig<W>` — keep-set is now just
`HashSet<T::PauliWordType>`.
- Drops `weight_lambda` / `base_threshold` entirely.
- Drops Python `preserve_threshold` / `preserve_weight_lambda` kwargs.
- Rewrites `PauliSum::truncate` as the snapshot-and-restore post-filter
above; the strategy always runs verbatim.
Net effect of commit 3: **+203 / −352 lines** — the implementation gets
smaller and strictly more general. The earlier commit (`0525523`,
relaxing `ACMapRetain::retain` from `Fn + Sync + Send` to `FnMut`) is
kept because the cleanup is independently useful.
## Tests
**Rust** (5 tests in `crates/ppvm-runtime/src/sum/preserve.rs::tests`):
- `single_z_builds_correct_set`, `from_strings_round_trip` — keep-set
helpers.
- `truncate_restores_preserved_string_dropped_by_strategy` — explicit
`CoefficientThreshold(0.5)` + tiny-coef preserved key.
- `preserve_single_z_conserves_total_z_under_aggressive_truncation` —
end-to-end `Σ Z` through XY exchange + `CoefficientThreshold(0.5)`.
- `empty_preserve_falls_back_to_strategy_unchanged` — no preserve set →
fast path.
**Python** (8 tests in `ppvm-python/test/test_preserve.py`):
- `preserve_single_z` helper, dataclass round-trip, length validation.
- **New**: `test_preserved_string_survives_coefficient_truncation` —
preserve + `min_abs_coeff`.
- **New**: `test_preserved_string_survives_weight_truncation` — preserve
+ `max_pauli_weight`.
- **New**: `test_preserved_string_survives_combined_truncation` —
preserve + both at once.
- End-to-end transport diagnostic: `preserve` reduces `<Σ Z>` drift by
≥10× vs plain.
- `test_no_preserve_uses_existing_strategy` — default behaviour
unchanged.
## Test plan
- [x] `cargo test --workspace` (83 runtime tests, all pass)
- [x] `uv run --project ppvm-python --group dev pytest
ppvm-python/test/` (110 pass, of which 8 are preserve)
- [x] `cargo fmt`, `cargo clippy --no-deps`
- [x] `ruff check ppvm-python/`
## Notes
- The change is **strictly additive**. Callers that don't set
`preserve_strings` get exactly the previous behaviour (their configured
strategy, unchanged).
- The relaxed `retain` bound from commit 2 is a strict relaxation —
every existing caller still satisfies `FnMut`.
- Independent of #94 (U(1)-conserving propagation helpers). They're
complementary but neither depends on the other.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: David Plankensteiner <david-pl@users.noreply.github.com>
…100) ## Summary Replaces the per-gate auto-truncate with an opt-out: every gate method on `PauliSum` / `LossyPauliSum` (and `GeneralizedTableau`, for signature symmetry) gains a `truncate: bool = True` kwarg, and `PauliSum` gains a top-level `truncate()` method. Default behaviour is **exactly** unchanged; existing user code is unaffected. ```python ps.rxx(a, b, theta, truncate=False) ps.ryy(a, b, theta, truncate=False) ps.truncate() # one strategy run at the end of the commuting pair ``` ## Why Supersedes #94 (`u1-conserving`). That branch added a dedicated `exchange` (and `xyzz`) gate whose only job was to call `rxx + ryy` back-to-back in Rust so the PyO3-level auto-truncate fired once instead of twice — the U(1) charge structure is preserved by the single PyO3 truncate, not by `rxx + ryy`'s two truncates. Letting the Python caller control `truncate` directly subsumes `exchange` and generalises to any sequence of commuting gates (`xyzz = rxx + ryy + rzz`, longer fusions, mixed Clifford-rotation blocks). No new dedicated gates needed, no new Rust traits, smaller API surface. ## Design - The PyO3 `PauliSum` interface auto-truncate now lives behind `if truncate { self.inner.truncate(); }` on every gate. The kwarg defaults to `true`, so callers that don't pass it see no change. - A new `PauliSum.truncate()` calls the configured strategy explicitly. - The Python mixins (`CliffordMixin`, `RotationsMixin`, `CliffordExtensionMixin`, `NoiseMixin`, `LossMixin`) forward `truncate=truncate` to the PyO3 layer. - `GeneralizedTableau`'s PyO3 binding also accepts the kwarg and silently ignores it — the tableau backend doesn't auto-truncate, so there's nothing to defer, but the parallel signature lets the mixins serve both backends uniformly. ## Tests `ppvm-python/test/test_truncate_kwarg.py` (7 cases): - `test_default_truncate_kwarg_is_true` — backward-compat: no kwarg ≡ explicit `truncate=True`. - `test_truncate_false_then_explicit_matches_implicit` — at a threshold that doesn't actually drop anything, deferred ≡ immediate. - `test_truncate_false_keeps_intermediate_terms_alive` — at a threshold where the immediate strategy *would* drop intermediates, `truncate=False` keeps them visible in the post-gate state. - `test_truncate_false_pair_reproduces_old_exchange` — the new idiom produces the same state as what `exchange` did internally. - `test_deferred_truncate_preserves_total_z_with_loose_threshold` — sanity: rxx+ryy chain with deferred truncate conserves Σ Z exactly. - `test_truncate_method_drops_below_threshold_keys` — explicit `truncate()` is wired through and runs the configured strategy. - `test_noise_channels_accept_truncate_kwarg` — the kwarg works on `pauli_error` too, not only on rotations. ## Test plan - [x] `cargo test -p ppvm-runtime --lib` (78 pass) - [x] `uv run --project ppvm-python --group dev pytest ppvm-python/test/` (109 pass) - [x] `cargo fmt`, `cargo clippy --no-deps`, `ruff check ppvm-python/` ## Notes - Closes #94: the `u1-conserving` branch and `exchange` / `xyzz` gates are no longer needed. The deferred-truncate idiom in this PR replaces them; see `test_truncate_false_pair_reproduces_old_exchange` for the one-line migration. - The `u1-conserving` branch will be deleted from the remote once this PR lands. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: David Plankensteiner <david-pl@users.noreply.github.com>
Summary
exchange(a, b, θ)forexp[−i θ/2 (X_a X_b + Y_a Y_b)]onPauliSum.xyzz(a, b, θ_xy, θ_zz)combining XY exchange with aZZinteraction.apply_u1_trotter_step(edges, theta_xy, theta_zz, fields_z)helper.exchangeandxyzzgo through the existingRotationTwoprimitives (rxx/ryy/rzz); no new operator basis, no new backend.The new trait
U1Conserving<T: Config>lives atcrates/ppvm-runtime/src/traits/branch/u1.rs; thePauliSumimpl atcrates/ppvm-runtime/src/sum/u1.rs. Exposed through the existingcreate_interface!PyO3 macro so every config variant picks them up. Python wrapper methods onRotationsMixin(exchange,xyzz) andPauliSum.apply_u1_trotter_stepround out the surface.Conservation under truncation
The trait and Python docstrings document the precise guarantee:
{I, Z}-only Pauli observables (Σ_i Z_i,Σ_{i<j} Z_iZ_j, …) are conserved through propagation up to per-gate floating-point precision (~1e-15 per gate, accumulating linearly), as long as the truncation cutoff sits comfortably below the conserved-coefficient magnitude (~1) and well above the per-gate ε floor. AggressiveCoefficientThresholdsettings close to 1, or very long circuits, can break this; the hardening tests below pin the safe regime.Tests
Rust (11 tests in
crates/ppvm-runtime/src/sum/u1.rs::tests):exchange==rxxthenryyon every two-qubit Pauli, at multiple angles.xyzz==exchangethenrzz.Σ Z_iandΣ_{i<j} Z_iZ_jpreservation throughexchange/xyzz.Σ Zconservation underCoefficientThreshold(0.5),MaxPauliWeight(1), and a manual Trotter sweep.Python (16 tests in
ppvm-python/test/test_u1.py):exchange,xyzz,apply_u1_trotter_stepall exist and run.Test plan
cargo test --workspaceuv run --project ppvm-python --group dev pytest ppvm-python/test/cargo fmt --check,cargo clippy --workspace --all-targets -- -D warningsruff format --check,ruff check