Skip to content

[Draft] 1855 experimental exact simulators quax - #1866

Closed
erichulburd wants to merge 5 commits into
masterfrom
1855-experimental-exact-simulators-quax
Closed

erichulburd wants to merge 5 commits into
masterfrom
1855-experimental-exact-simulators-quax

Conversation

@erichulburd

Copy link
Copy Markdown
Contributor

Description

Insert your PR description here. Thanks for contributing to pyQuil! 🙂

Checklist

  • The PR targets the master branch
  • The above description motivates these changes.
  • The change is atomic and can be described by a single commit (your PR will be squashed on merge).
  • All changes to code are covered via unit tests.
  • Parameters and return values have type hints with PEP 484 syntax.
  • Functions and classes have useful Sphinx-style docstrings.
  • (New Feature) The docs have been updated accordingly.
  • (Bugfix) The associated issue is referenced above using auto-close keywords.

bramathon and others added 5 commits August 19, 2026 11:40
#1855)

Add the pyquil.simulation core (_resolver pipeline + vectorized gate constructor)
and the grad-able PureStateVectorSimulator and DensityMatrixSimulator, with qutrit
support and noise-model integration. Restore the density-matrix ResetChannel tests
deferred from #1854. Part of splitting PR #1848 into a reviewable stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promote the channel base classes to public names, since consumers must branch on
them: `_ChannelBase` -> `ChannelBase`, `_ResetChannelBase` -> `ResetChannelBase`.
Both docstrings now state that they are the type to test in an `isinstance` check
and explain what goes wrong otherwise.

Fix two silent noise drops in the resolver. Gate and reset channels each come in
a Lindbladian-backed and a raw-superoperator flavor, but the dispatch branched on
the concrete Lindbladian classes:

    if isinstance(channel, Channel):       # missed SuperopChannel
    if isinstance(channel, ResetChannel):  # missed SuperopResetChannel

Both fell through to the *ideal* operation, so the noise vanished with no error.
This was the common case rather than an exotic one: every derived channel
operation -- composition, `pauli_twirl`, `to_coherent_channel`,
`to_stochastic_channel` -- returns a `SuperopChannel`. Verified at the resolver:
a `SuperopChannel` on `X 0` previously emitted the ideal X superoperator, and a
`SuperopResetChannel` at fidelity 0.90 gave a post-reset state fidelity of
exactly 1.0.

Also make a measurement inside a cycle honor `measurement="superop"`. The cycle
path emitted the constituent's `process` verbatim, leaking a `QuantumInstrument`
into the grad-able pipeline that a standalone MEASURE correctly collapses to its
total channel.

Test and doc updates for the merged API:

- The five deferred `ResetChannel` end-to-end tests restored by the rebase now
  target `SuperopResetChannel.from_reset_fidelity`.
  `test_noisy_reset_reduces_fidelity` needed the resolver fix as well as the
  rename -- its `0.85 < fidelity < 1.0` band only holds once the channel is
  actually applied (it lands at exactly 0.90).
- `DefGate` dimension assertions match the current message, which says "power of
  a prime qudit dimension" rather than "perfect power".
- `test_cycle_channel_applies_process_faithfully` builds a `SuperopChannel` with
  `ideal_unitary=` instead of the removed `Channel(..., target_unitary=...)`.
- New `TestChannelFlavorDispatch` and `TestCycleMeasurementMode` cover both
  flavors of gate and reset channel, the derived-channel path, and both
  measurement modes. All four new assertions fail against the old dispatch.
- Use each constituent's `qubits` property in the cycle path rather than
  reaching through to `inst.get_qubit_indices()`, whose return type varies by
  family (a list for gates, a set-or-None for resets) and did not type-check.
- Note in the architecture docs that `from_isa` now takes a QCS
  `InstructionSetArchitecture`, and point at `from_compiler_isa` for rpcq.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QB5EGDshZbFtBvyfPPQqU
…/grad tests

Three silent wrong-answer bugs, each now covered by a regression test:

- The compressor advertised a *sorted* subsystem for every group, but `compress`
  emits an unmerged operation verbatim, still in its own operand order. The
  density-matrix path dispatches purely on the advertised subsystem, so any
  non-ascending multi-qubit gate was applied to permuted qudits:
  `X 1; X 2; CCNOT 2 1 0` gave |011> instead of |111>. A 3+ qubit gate can never
  merge under the default max_subsystem_size=2, so it is always a singleton and
  every reversed CCNOT/CSWAP in a noisy simulation was wrong. Singleton groups
  now report the instruction's own operand order, which is also what the
  state-vector path already derived independently (hence its immunity). Verified
  across all operand permutations of CCNOT/CSWAP/CNOT/CZ/SWAP for
  max_subsystem_size 1-4.

- `DAGGER` was dropped: `DAGGER RX(0.7) 0` applied RX(+0.7). The root cause is in
  `get_instruction_unitary`, so channel fidelities were also measured against the
  wrong ideal unitary. DAGGER is now applied (parity of the modifier count) and
  matches pyQuil's reference simulator exactly. CONTROLLED and FORKED add a qudit
  to the instruction and are rejected explicitly rather than silently ignored.

- A global `RESET` never consulted the noise model, so reset channels were honoured
  for `RESET <q>` but silently dropped for `RESET`. It now resolves per qubit
  through the same path as a targeted reset.

Errors and validation, replacing raw XLA/KeyError failures:

- Parameter-count mismatches (too few, too many, or omitted) now report the
  expected count instead of an out-of-range gather, a silent truncation, and a bare
  KeyError respectively.
- Duplicate entries in `qubits=` are rejected; a qubit outside the register names
  itself and points at `qubits=`.
- Expression-valued parameters (`RX(theta[0]/2)`) and feed-forward parameters
  (`RX(ro[0])` after a MEASURE) are rejected with an explanation. Full expression
  support would have to become part of the vmap batching key, so it is left as
  follow-up rather than half-implemented.
- A DEFCIRCUIT body may name a literal qubit; arity mismatches between an
  invocation and its DEFCIRCUIT are reported as such.
- `PureStateVectorSimulator` validates the *expanded* operations, so a MEASURE or
  RESET reached through a DEFCIRCUIT is rejected by name instead of failing later
  with a padding shape error.

Docstrings:

- The big-endian convention is now stated in the module docstring, with the
  contrast against the rest of pyQuil made explicit, and repeated on each
  simulator's `qubits` parameter. It was previously pinned only by a test.
- All three simulator `__init__`s are documented, including `max_subsystem_size`
  (a pure performance knob) and the fact that MEASURE is applied as dephasing so
  no classical outcome is recorded.
- `:param:`/`:return:` added to the public pipeline methods and the resolver entry
  points, matching house style.
- The docs no longer present `TrajectorySimulator` as available; its import would
  raise ImportError.

Tests (+191):

- `test_density_matrix.py` is a superset of the state-vector tests: every gate-only
  program is checked to reproduce |psi><psi|, to be invariant under
  max_subsystem_size, and to be physical, then cross-checked against pyQuil's
  in-repo (little-endian) reference simulators. Noise coverage spans 1Q and 2Q
  depolarizing with analytic Bloch-vector shrink factors, exact Pauli channels,
  coherent over-rotation and random coherent error, T1/T2 decoherence including
  its state dependence, readout noise, ideal/noisy/global resets, and qutrits with
  explicit leakage population. Two Lindblad results are frozen as end-to-end
  goldens; they were produced with qutip's `mesolve`, but since quax's `evolve` is
  already tested against qutip upstream they pin the pyQuil-side chain (T1/T2
  conversion, gate-Hamiltonian scaling, subsystem embedding, application) rather
  than corroborating the integrator. qutip is deliberately not a dependency, so the
  values are hard-coded with the snippet that produced them.
- `test_simulator_jit_grad.py` pins the jit/grad claim for both simulators against
  central finite differences and closed forms (<Z> = cos(theta) for RY), covering
  grad-under-jit, second derivatives, vmap over parameter batches, gradients
  through entangling programs, through a noise channel, and through measurement
  dephasing.

Noise on runtime-parametric gates is intentionally unsupported and now documented
as such: channels are keyed by instruction, and a symbolic `RX(theta[0]) 0` matches
no concrete gate. This is a design decision rather than a gap -- the only genuinely
continuous hardware gate is `RZ`, which is virtual and so has no pulse and no noise,
while every other rotation is calibrated at fixed angles and is correctly described
by a channel on that concrete instruction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QB5EGDshZbFtBvyfPPQqU
…, drop barriers

Withdrawn features (both added earlier in this branch, now reversed by request):

- **Gate modifiers are no longer supported at all.** DAGGER support is removed;
  `_validate_and_count_dagger_modifiers` becomes `_reject_gate_modifiers`, which
  raises for any modifier. Rejecting is the half that matters: the original defect
  was DAGGER being *silently dropped*, so `DAGGER RX(0.7)` simulated `RX(+0.7)`.
- **Literal qubits in a DEFCIRCUIT body are rejected.** Quil permits them, but such
  a qubit is invisible to `Program.get_qubit_indices`, so it escapes the register
  the simulator sizes itself for. The arity checks are kept.

Extend gate batching to the density-matrix simulator:

`_build_vectorized_unitary_constructor` becomes
`_build_vectorized_operator_constructor(..., as_superop)`, with `_embed_op_to_group`
and `_make_group_fold` parameterized by stack width. Embedding and lifting commute
for a unitary, so the lift happens last and the existing embedding machinery is
reused unchanged; constant non-unitary operations (channel SuperOps, instrument
total channels) join the eager path, since `qx.embed` handles them and `to_superop`
is idempotent. `DensityMatrixSimulator` drops `_stack_superops` and, with it,
`_const_op_stack` -- the cache existed only to dodge the slow trace that batching
removes, so deleting it also removes mutable state from a jit-friendly method.

Measured, parametric programs:

    6q x 10L   DM compile 17.67s -> 0.32s    (55x; DM/SV ratio 51x -> 1.0x)
    4q x 5L    DM compile  3.64s -> 0.27s

The batching itself was reviewed for possible simplification and kept: removing it
costs 518x in compile time (180s vs 0.35s at 12q x 20L, with XLA printing "Very slow
compile?"). That justification is now recorded in the function docstring and the
architecture doc rather than left as an assertion.

Remove barrier nodes:

`barrier_nodes` was derived from QuantumInstrument ops, and `measurement="superop"`
produces none -- so it was always empty, i.e. untested dead code rather than a live
constraint on the differentiable simulators. Operation order is protected by the
quotient-graph convexity check and the lexicographic topological emission, both
independent of barriers. The trajectory simulator will introduce instrument merging
along with the code that needs it.

Bake in the measurement mode:

`measurement` is removed from `ProgramSimulator.__init__`, where it was accepted,
documented, and then silently ignored (`resolve_for_gradable` hard-coded "superop",
so `ProgramSimulator(measurement="instrument").resolve()` returned a SuperOp). The
parameter remains on `expand_program`/`resolve_program`, which are the mechanism and
which #1856 needs for the instrument path. The mode is now unrepresentable at the
level where it was being ignored.

Rename gradable -> differentiable:

`_GradableSimulator` -> `_DifferentiableSimulator`, `resolve_for_gradable` ->
`resolve_for_differentiable`, and "grad-able" -> "differentiable" in prose.

Resolver simplifications:

- Remove `_ExpansionContext` and `_build_expansion_context`. Measured at 1-4 ms,
  under 6% of a resolve, paid once per construction -- not worth a cache. The four
  derivations are recomputed in `expand_program` and the `context` parameter is gone.
- Remove `_freeze_resolver`. `Resolution` now has a `resolve` method that iterates
  its own fields, and the `callable(item)` test plus `cast` becomes
  `isinstance(item, ParametricGate)`, which is what was meant.

Architecture documentation:

- Qudit support is promoted to a first-class goal; it was only a parenthetical.
- The dimension-inference double expansion is acknowledged as the least elegant part
  of the pipeline, with the circularity that forces it and the two rejected
  alternatives spelled out.
- `compute`'s parameter vector is documented as optional, including the
  one-slot-per-gate-occurrence layout.
- The two new restrictions are stated, as is why rejecting beats ignoring.

`ProgramSimulator` keeps its name and its inheritance; a note records why it is a
shared base rather than a composed "simulation plan" (only two consumers), so the
question does not resurface in review of #1856/#1857.

Verification: 1838 unit tests pass (suite runtime 212s -> 130s from the compile win),
11 doctests, ruff format/check and mypy clean on the pinned toolchain. Correctness
held fixed across the density-matrix rewrite: all 120 operand-permutation cases still
agree between the two simulators, and the notebook's section-4 optimum-shift figures
are bit-identical (global depolarizing shift exactly 0.000000, E/E_ideal exactly
0.9500).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QB5EGDshZbFtBvyfPPQqU
@erichulburd
erichulburd requested a review from a team as a code owner September 8, 2026 23:47
@erichulburd
erichulburd marked this pull request as draft September 8, 2026 23:48
@erichulburd

Copy link
Copy Markdown
Contributor Author

See #1867

@erichulburd erichulburd closed this Sep 8, 2026
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.

2 participants