Skip to content

feat(runtime): U(1)-conserving Pauli propagation helpers - #94

Closed
AlexSchuckert wants to merge 1 commit into
mainfrom
u1-conserving
Closed

AlexSchuckert wants to merge 1 commit into
mainfrom
u1-conserving

Conversation

@AlexSchuckert

Copy link
Copy Markdown
Collaborator

Summary

  • Adds exchange(a, b, θ) for exp[−i θ/2 (X_a X_b + Y_a Y_b)] on PauliSum.
  • Adds xyzz(a, b, θ_xy, θ_zz) combining XY exchange with a ZZ interaction.
  • Adds a Python-level apply_u1_trotter_step(edges, theta_xy, theta_zz, fields_z) helper.
  • Intended for XY and Heisenberg-style z-magnetization-conserving spin models.
  • Both exchange and xyzz go through the existing RotationTwo primitives (rxx/ryy/rzz); no new operator basis, no new backend.

The new trait U1Conserving<T: Config> lives at crates/ppvm-runtime/src/traits/branch/u1.rs; the PauliSum impl at crates/ppvm-runtime/src/sum/u1.rs. Exposed through the existing create_interface! PyO3 macro so every config variant picks them up. Python wrapper methods on RotationsMixin (exchange, xyzz) and PauliSum.apply_u1_trotter_step round 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. Aggressive CoefficientThreshold settings 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 == rxx then ryy on every two-qubit Pauli, at multiple angles.
  • xyzz == exchange then rzz.
  • Zero-angle identity for both gates.
  • Σ Z_i and Σ_{i<j} Z_iZ_j preservation through exchange/xyzz.
  • 4 hardening tests asserting truncation-robust Σ Z conservation under CoefficientThreshold(0.5), MaxPauliWeight(1), and a manual Trotter sweep.

Python (16 tests in ppvm-python/test/test_u1.py):

  • API surface: exchange, xyzz, apply_u1_trotter_step all exist and run.
  • Match against manual gate application.
  • Per-edge couplings and per-edge angle sequences.
  • 3 hardening tests for total-Z and full-pair ZZ conservation under aggressive truncation.

Test plan

  • cargo test --workspace
  • uv run --project ppvm-python --group dev pytest ppvm-python/test/
  • cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings
  • ruff format --check, ruff check

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>
Copilot AI review requested due to automatic review settings May 20, 2026 15:51

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👋 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-files locally; 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!

@github-actions

github-actions Bot commented May 20, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-05-29 12:44 UTC

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 U1Conserving with exchange and xyzz, plus a PauliSum implementation backed by rxx/ryy/rzz.
  • Exposes exchange/xyzz to Python via the native interface and adds Python wrappers plus a high-level PauliSum.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.

Comment on lines +428 to +431
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.
@david-pl

Copy link
Copy Markdown
Collaborator

@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?

@AlexSchuckert

Copy link
Copy Markdown
Collaborator Author

Thanks! Yes I think so, that would be equivalent.

@AlexSchuckert

Copy link
Copy Markdown
Collaborator Author

Superseded by #100 (deferred-truncate). That branch replaces exchange / xyzz with a generic truncate: bool = True kwarg on every gate plus an explicit PauliSum.truncate() — the user controls when the configured strategy fires, which subsumes the U(1)-conserving fusion this PR was doing as a special case (and generalises to any commuting-gate composition).

@AlexSchuckert
AlexSchuckert deleted the u1-conserving branch May 29, 2026 12:44
david-pl added a commit that referenced this pull request Jun 2, 2026
## 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>
david-pl added a commit that referenced this pull request Jun 3, 2026
…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>
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.

3 participants