Source code for pprop.propagator.pruning
"""
Pruning strategies for Heisenberg-picture Pauli propagation.
Overview
--------
During Heisenberg evolution a :class:`~pprop.pauli.sentence.PauliDict` can
grow large as gates split each Pauli word into multiple branches. Many of
these branches are guaranteed to contribute zero to the final expectation
value :math:`\\langle 0 | O | 0 \\rangle` and can be discarded early,
before they are evolved further and spawn even more branches.
This submodule provides a small framework for such *pruning strategies*:
* :class:`Pruner` — abstract base class defining the interface.
* :class:`DeadQubitPruner` — removes words that carry an ``X`` or ``Y``
operator on a qubit that will never be touched by any remaining gate and
therefore can never be driven back to the :math:`Z/I` subspace required
for a non-zero expectation value.
* :class:`XYWeightPruner` — removes words whose XY-weight (number of qubits
carrying ``X`` or ``Y``) exceeds the maximum reduction achievable by all
remaining gates in the causal cone of that word. Since each gate reduces
XY-weight by at most 1, a word with too high a weight simply cannot reach
XY-weight 0 before the circuit ends.
All pruners share the same two-phase lifecycle:
1. **Setup** (:meth:`Pruner.setup`) — called *once* before the evolution
loop with the full list of gates in reversed order. This is where each
pruner precomputes any auxiliary data structures it needs (e.g. suffix
sets of active qubits).
2. **Prune** (:meth:`Pruner.prune`) — called *once per gate step*, just
before the gate is applied, with the current :class:`~pprop.pauli.sentence.PauliDict`
and the index of the current step. Dead entries are removed in-place.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import List
from ..gates.base import Gate
from ..pauli.sentence import PauliDict
[docs]
class Pruner(ABC):
"""
Abstract base class for Heisenberg-evolution pruning strategies.
A :class:`Pruner` encapsulates a single dead-term elimination heuristic.
Subclasses must implement :meth:`setup` and :meth:`prune`.
The contract is:
* :meth:`setup` is called exactly *once*, before the evolution loop
starts, with ``reversed_gates``, the circuit gates in the order they
are consumed during Heisenberg propagation (i.e. reversed with respect
to the original circuit order). Implementations should use this call
to precompute any per-step data they need.
* :meth:`prune` is called at the *beginning* of each loop iteration,
before the gate at position ``step`` is applied. It must remove all
entries from ``paulidict`` that are provably dead, i.e. whose evolved
descendants can never contribute to the expectation value, and leave
all other entries untouched.
.. warning::
With prunings, the Pauli words carried in ``paulidict`` during evolution
are **not** the exact Heisenberg-evolved observable.
They are pruned to retain only those terms that can possibly survive the
:func:`~pprop.pauli.sentence.to_expectation` zero-bracket
filter (i.e. words composed entirely of :math:`Z` and :math:`I`).
"""
[docs]
@abstractmethod
def setup(self, reversed_gates: List[Gate]) -> None:
"""
Precompute auxiliary data for the full gate sequence.
Called once before the evolution loop.
Parameters
----------
reversed_gates : list[Gate]
Gates in the order they will be consumed during Heisenberg
evolution (i.e. the original circuit gates reversed).
"""
...
[docs]
@abstractmethod
def prune(self, paulidict: PauliDict, step: int) -> None:
"""
Remove provably dead entries from *paulidict* in-place.
Called at the start of each loop iteration, before the gate at
index ``step`` in ``reversed_gates`` is applied.
Parameters
----------
paulidict : PauliDict
The live observable terms at the current evolution step.
Modified in-place: dead entries are deleted.
step : int
Index of the gate that is about to be applied (0-based, indexing
into ``reversed_gates`` as passed to :meth:`setup`).
"""
...
[docs]
class DeadQubitPruner(Pruner):
"""
Prune Pauli words that have a frozen ``X`` or ``Y`` on an inactive qubit.
Correctness argument
--------------------
A Pauli word contributes to :math:`\\langle 0 | O | 0 \\rangle` only if
every qubit carries either :math:`Z` or :math:`I` after *all* remaining
gates have been applied (see :func:`~pprop.pauli.sentence.to_expectation`).
A gate can change the operator on a qubit **only if that qubit appears in
the gate's wire list**. Therefore, if a qubit currently carries ``X`` or
``Y`` and no remaining gate (including the gate about to be applied at
the current step) touches that qubit, its operator is permanently frozen
in the non-:math:`ZI` state, the word can never reach zero-bracket and
can be safely discarded.
"""
def __init__(self) -> None:
# Populated by setup(); empty until then.
self._active_qubits_from: List[set] = []
[docs]
def setup(self, reversed_gates: List[Gate]) -> None:
"""
Build the suffix-union table of active qubits.
Iterates over ``reversed_gates`` from right to left, accumulating the
union of wire sets so that ``_active_qubits_from[i]`` contains every
qubit touched by gates ``i, i+1, …, n-1``.
Parameters
----------
reversed_gates : list[Gate]
Gates in Heisenberg traversal order (reversed circuit order).
"""
n = len(reversed_gates)
# Sentinel: no gates remain after the last step.
self._active_qubits_from = [set() for _ in range(n + 1)]
# Right-to-left pass: each entry inherits all qubits from the next
# step and adds the wires of the current gate.
for i in range(n - 1, -1, -1):
self._active_qubits_from[i] = self._active_qubits_from[i + 1].copy()
self._active_qubits_from[i].update(reversed_gates[i].wires)
[docs]
def prune(self, paulidict: PauliDict, step: int) -> None:
"""
Delete words with a frozen ``X`` or ``Y`` from *paulidict*.
For each Pauli word, iterates over its non-identity support and
checks whether any qubit carrying ``X`` or ``Y`` is absent from
``_active_qubits_from[step]``. If so, the word is removed.
Parameters
----------
paulidict : PauliDict
Observable terms at the current step. Modified in-place.
step : int
Index of the gate about to be applied.
"""
active = self._active_qubits_from[step]
# Build a bitmask of all active qubits.
active_mask = 0
for q in active:
active_mask |= (1 << q)
# A word is dead if it has any X or Y on an inactive qubit.
# X or Y iff the x-bit is set. Inactive qubits are those NOT in active_mask.
dead = PauliDict({
pw: [] for pw, _ in paulidict.items()
if pw.x & ~active_mask
})
paulidict.remove_keys_from_dict(dead)
[docs]
class XYWeightPruner(Pruner):
"""
Prune Pauli words whose XY-weight exceeds the maximum reduction
achievable by all remaining gates combined.
Correctness argument
--------------------
A Pauli word can only contribute to the expectation value if its
XY-weight reaches zero by the end of evolution. Each gate can reduce
the XY-weight by at most ``gate.max_xy_reduction`` (verified to be 1
for all single- and two-qubit gates in this gate set). Therefore if
the current XY-weight exceeds the total reduction budget remaining,
the word can never reach the :math:`ZI` subspace and is pruned.
"""
def __init__(self) -> None:
self._budget: List[int] = []
[docs]
def setup(self, reversed_gates: List[Gate]) -> None:
"""
Build the suffix-sum budget table.
Parameters
----------
reversed_gates : list[Gate]
Gates in Heisenberg traversal order (reversed circuit order).
"""
# Determine gate dependencies, this is used during propagation
# to determine when a word can be discarded when pruning is active
self.gate_dep = []
for i, gate in enumerate(reversed_gates):
deps = set(gate.wires)
ops = []
for other_gate in reversed_gates[i+1:]:
if not deps.isdisjoint(other_gate.wires):
deps |= set(other_gate.wires)
ops.append(other_gate)
self.gate_dep.append(len(ops))
[docs]
def prune(self, paulidict: PauliDict, step: int) -> None:
"""
Delete words whose XY-weight exceeds the remaining budget.
Parameters
----------
paulidict : PauliDict
Observable terms at the current step. Modified in-place.
step : int
Index of the gate about to be applied.
"""
budget = self.gate_dep[step]
dead = PauliDict({
pw: [] for pw, _ in paulidict.items()
if pw.x.bit_count() > budget
})
paulidict.remove_keys_from_dict(dead)