Post-selection

PostSelection conditions results after the fact: it never touches circuits or engines. It applies equally to readout distributions (sampled or qarp.EXACT) and to statevectors, and always reports the success rate: the probability mass that survived the condition.

Conventions (qarpx LSB throughout, see Endianness): distribution keys are LSB-first tuples with qubit q at position q; for statevectors, bit q of the amplitude index is qubit q.

Two kinds of condition

PostSelection is constructed one of two ways, and the choice changes the shape of the output. This is the footgun to know about before using either:

  • Fixed-bit: PostSelection({qubit: bit, ...}). Collapses the selected qubits to a definite basis state, so they carry no more information and are removed from the output: distribution keys shrink to the surviving qubits (ascending order), statevectors compress from 2**n to 2**(n - k) amplitudes.

  • Sector: hamming_weight() / parity(). Projects onto a subspace (e.g. a fixed particle number under Jordan-Wigner) in which the selected qubits generally stay entangled with the rest of the register. There is nothing to drop, so the output keeps the full register width.

Mixing the two up silently produces the wrong-shaped output rather than an error, since both are valid outputs, just not the one you meant.

Fixed bits on a readout distribution

from qarp import PostSelection

# A 7-qubit readout distribution.
readout = {
    (1, 0, 1, 0, 0, 0, 1): 0.55,
    (1, 1, 0, 1, 0, 0, 1): 0.30,
    (0, 0, 1, 0, 0, 0, 0): 0.15,
}

# Keep outcomes where qubits 3, 4, 5 read 0; those qubits then drop out.
ps = PostSelection({3: 0, 4: 0, 5: 0})
out = ps.apply(readout)

print("conditioned distribution:", out.distribution)
print("success rate:", out.success_rate)

apply returns a PostSelected, a (distribution, success_rate) pair. The conditioned distribution is renormalised (probabilities sum to 1 over the surviving keys); success_rate is the probability mass that satisfied the condition before renormalisation. Zero surviving mass returns PostSelected({}, 0.0) rather than raising, so a condition with vanishing support does not break a parameter sweep.

The same spec applies to any Sampler output, whether it came from finite shots or the exact Born distribution (n_shots=qarp.EXACT):

import qarp
from qarp.algorithms import Sampler
from qarp.blocks import SimpleBlock
from qarp.engines import QarpEngine

bell = SimpleBlock(2, name="bell")
bell.h(0)
bell.cx(0, 1)
bell.build()

ps = PostSelection({0: 0})

for n_shots in (4000, qarp.EXACT):
    sampler = Sampler(ket=bell, n_shots=n_shots)
    engine = QarpEngine(seed=7)
    engine.build([sampler])
    out = ps.apply(engine.run()[0])
    print(f"n_shots={n_shots}:  distribution={out.distribution}  success={out.success_rate:.4f}")

On a Bell pair, conditioning qubit 0 on 0 forces qubit 1 to 0 in both cases; the sampled success rate carries shot noise around the exact value of 0.5.

Statevectors

apply_statevector() projects a statevector onto the condition and renormalises, returning (conditional_state, success_probability) with success = ‖P|ψ⟩‖². For a fixed-bit condition the selected qubits factor out of the state entirely, so the returned state is the (renormalised) state of the surviving qubits only:

import numpy as np
from qarp.blocks import SimpleBlock

ghz = SimpleBlock(3, name="ghz")
ghz.h(0)
ghz.cx(0, 1)
ghz.cx(1, 2)
ghz.build()

sv = ghz.statevector()

conditional, p = PostSelection({0: 0}).apply_statevector(sv, 3)
print("success probability:", p)
print("conditional state of qubits 1, 2:", np.round(conditional, 6))

Sector post-selection

hamming_weight() / parity() select a symmetry sector, e.g. a fixed particle number under Jordan-Wigner, rather than fixed qubit values. A sector is a subspace, not a basis state: the selected qubits stay entangled inside it, so (unlike the fixed-bit case) the output keeps the full register width.

# A state with particle-number leakage: mostly N=2, some N=1 amplitude.
sv = np.zeros(16, dtype=complex)
sv[0b0011] = np.sqrt(0.45)  # qubits 0, 1 occupied
sv[0b0101] = np.sqrt(0.45)  # qubits 0, 2 occupied
sv[0b0001] = np.sqrt(0.10)  # N=1 leakage

ps_n2 = PostSelection.hamming_weight(range(4), k=2)

projected, p = ps_n2.apply_statevector(sv, 4)
print("N=2 sector weight:", round(p, 4))
print("projected state norm:", round(float(np.linalg.norm(projected)), 4))

born = {tuple(int(i >> q) & 1 for q in range(4)): abs(a) ** 2
        for i, a in enumerate(sv) if abs(a) > 0}
out = ps_n2.apply(born)
print("conditioned distribution:", out.distribution)

Note that the last apply call above returns keys covering all 4 qubits. A sector condition constrains a joint property of the register, here the total weight over range(4), rather than pinning individual qubits to values, so there is nothing to factor out and no qubit is ever removed. This holds however few qubits the spec names: hamming_weight([0, 1], k=2) on the same input also returns full-width keys.

parity() follows the same shape rule: PostSelection.parity([0, 1], even=True) keeps the even-bit-sum sector of qubits 0 and 1, full register width preserved.

Reusable specs and parameter sweeps

A PostSelection instance is immutable and hashable (fixed-bit specs compare by their conditions; sector specs by predicate identity), so the same spec can be applied across a sweep without rebuilding it each time. success_rate() is a shortcut for apply(...).success_rate when only the rate is needed:

import numpy as np

ps = PostSelection({1: 0})

for theta in np.linspace(0, np.pi, 5):
    ket = SimpleBlock(2)
    ket.ry(0, theta)
    ket.cx(0, 1)
    ket.build()
    sampler = Sampler(ket=ket, n_shots=qarp.EXACT)
    engine = QarpEngine()
    engine.build([sampler])
    rate = ps.success_rate(engine.run()[0])
    print(f"{theta / np.pi:.2f}  success={rate:.4f}  cos^2(theta/2)={np.cos(theta / 2) ** 2:.4f}")

By construction, success_rate here equals \(\cos^2(\theta/2)\), including the vanishing-support point at \(\theta = \pi\), which reports rate 0.0 instead of raising.

Shot-noise caveat

On sampled input the success rate carries statistical error (\(\sigma \approx \sqrt{p(1-p)/N}\)), and the conditioned distribution rests on an effective N * success_rate shots, so quote error bars accordingly. With n_shots=qarp.EXACT input, both the success rate and the conditioned distribution are exact.