Simulation¶
Clifft's Schrödinger Virtual Machine (SVM) executes compiled programs. The main simulation APIs are:
sample()for ordinary shot-based samplingsample_survivors()for post-selected samplingbasis_probabilities()for exact computational-basis probabilities of a unitary programrecord_probabilities()for exact joint probabilities of measurement recordsexecute()andget_statevector()for inspecting small final statessample_k()andsample_k_survivors()for stratified importance sampling
Sampling¶
clifft.sample() runs a compiled program for multiple shots and returns a SampleResult with measurement, detector, and observable outcomes:
import clifft
program = clifft.compile("""
H 0
CNOT 0 1
M 0 1
""")
# Sample 10,000 shots
result = clifft.sample(program, shots=10000, seed=42)
# result.measurements is a 2D array: (shots x num_measurements)
print(result.measurements.shape) # (10000, 2)
print(result.measurements[:5]) # First 5 shots
clifft.sample() returns a SampleResult object with .measurements, .detectors, .observables, and .exp_vals attributes, each represented as a NumPy array. For circuits without detectors, observables, or expectation-value probes, the corresponding arrays have zero columns.
For Stim-like compatibility, tuple unpacking is also supported:
Terminology follows Stim's model:
- Measurements are the raw results produced by
M,MX,MY, and related measurement instructions. - Detectors are declared parity checks over previous measurements using
DETECTOR. - Observables are logical observable parities declared with
OBSERVABLE_INCLUDE.
All three are returned per shot. Detectors and observables are empty arrays when the circuit does not declare them.
State Vector Extraction¶
For debugging and small circuits, execute() and get_statevector() let you inspect the final dense state vector:
import clifft
program = clifft.compile("""
H 0
CNOT 0 1
""")
state = clifft.State(
peak_rank=program.peak_rank,
num_measurements=program.num_measurements,
num_detectors=program.num_detectors,
num_observables=program.num_observables,
)
clifft.execute(program, state)
sv = clifft.get_statevector(program, state)
print(sv) # [0.707+0j, 0+0j, 0+0j, 0.707+0j]
State vector extraction is for small circuits
get_statevector() expands Clifft's factored representation into a dense \(2^n\) state vector over all physical qubits. This is useful for debugging and validation, but it is not the scalable simulation path.
Exact Probabilities¶
Clifft offers two exact-probability APIs:
clifft.basis_probabilities(program, bitstrings)— computational-basis probabilities for a unitary program.clifft.record_probabilities(program, records)— joint probabilities of measurement records for a unitary circuit that has measurements (no noise).
import clifft
unitary = clifft.compile("H 0\nCNOT 0 1")
ps = clifft.basis_probabilities(unitary, ["00", "01", "10", "11"])
print(ps) # [0.5, 0.0, 0.0, 0.5]
measured = clifft.compile("H 0\nCNOT 0 1\nM 0 1")
qs = clifft.record_probabilities(measured, ["00", "01", "10", "11"])
print(qs) # [0.5, 0.0, 0.0, 0.5]
See Strong Simulation: Exact Probabilities for walkthroughs of both APIs, their limitations, and how their runtime cost depends on the circuit.
Detectors, Observables, and Post-Selection¶
Circuits with DETECTOR and OBSERVABLE_INCLUDE annotations automatically produce detector and observable results alongside measurements:
import clifft
program = clifft.compile("""
H 0
CNOT 0 1
M 0 1
DETECTOR rec[-1] rec[-2]
OBSERVABLE_INCLUDE(0) rec[-1]
""")
result = clifft.sample(program, shots=10000, seed=42)
# result.detectors shape: (10000, num_detectors)
# result.observables shape: (10000, num_observables)
Syndrome Normalization¶
By default, detector and observable values are raw measurement parities. This matches the circuit definition, but some QEC workflows expect 0 to mean "matches the noiseless reference" and 1 to mean "differs from the noiseless reference."
Use normalize_syndromes=True at compile time to XOR detector and observable outputs against a noiseless reference:
import clifft
program = clifft.compile(
circuit_text,
normalize_syndromes=True,
)
result = clifft.sample(program, shots=10000, seed=42)
This is often useful before passing detector data to decoders. It also composes with post-selection: detectors that fire in the noiseless reference will not cause spurious discards after normalization.
You can also supply explicit reference parities if you've computed them yourself:
import clifft
program = clifft.compile(
circuit_text,
expected_detectors=[1, 0, 0, 1],
expected_observables=[1],
)
Note
normalize_syndromes=True is mutually exclusive with manually passing expected_detectors or expected_observables.
See Compiling Circuits for computing reference syndromes directly.
Post-Selection / Survivor Sampling¶
For circuits with post-selection, compile with a postselection_mask and sample with sample_survivors(). The mask has one entry per detector: set mask[i] = 1 to discard shots where detector i fires.
Mask format
postselection_mask is a flat list of flags with one element per detector. It is not bit-packed. If you are converting a bit-packed Sinter mask, unpack it first with numpy.unpackbits(..., count=num_det, bitorder="little").
import clifft
# Mark detectors 0 and 2 for post-selection
program = clifft.compile(circuit_text, postselection_mask=[1, 0, 1])
# Only returns stats for shots that pass post-selection
result = clifft.sample_survivors(program, shots=1_000_000, seed=42)
print(f"Survival rate: {result.passed_shots / result.total_shots:.4f}")
print(f"Logical errors: {result.logical_errors}")
The returned SampleResult object contains:
total_shots— number of shots attemptedpassed_shots— number that survived post-selectiondiscards— number discardedlogical_errors— count of logical errorsobservable_ones— NumPy array of per-observable error counts
Pass keep_records=True to also get the raw detectors and observables arrays for surviving shots.
Post-selection is implemented as survivor sampling. Marked detectors are checked during execution, and shots are discarded as soon as Clifft can determine that they fail the post-selection condition. This avoids spending full simulation time on shots that cannot contribute to the surviving sample.
Expectation Values¶
EXP_VAL is a non-destructive probe that computes the expectation value of a Pauli product operator on the current state, without collapsing it. This is useful for observing properties of the quantum state mid-circuit without affecting subsequent operations.
import clifft
import numpy as np
program = clifft.compile("""
H 0
CNOT 0 1
EXP_VAL X0*X1 Z0*Z1
M 0 1
""")
result = clifft.sample(program, shots=1000, seed=42)
# result.exp_vals is a 2D array: (shots x num_exp_vals)
print(result.exp_vals.shape) # (1000, 2)
print(np.mean(result.exp_vals, axis=0)) # [1.0, 1.0] for Bell state
Each EXP_VAL instruction takes one or more Pauli product strings, such as X0, Z0*Z1, or X0*Y1*Z2. Each product produces one column in result.exp_vals, with values in [-1, +1].
EXP_VAL is non-destructive: it does not collapse the state or affect later measurements. It is also Pauli-frame aware, so noisy operations that change the current frame are reflected in the reported value.
The Program object reports program.num_exp_vals. Circuits without EXP_VAL produce an empty array with shape (shots, 0).
Deterministic Seeds¶
All sampling functions accept an optional seed parameter for reproducible results:
import clifft
program = clifft.compile("H 0\nM 0")
r1 = clifft.sample(program, 100, seed=42)
r2 = clifft.sample(program, 100, seed=42)
assert (r1.measurements == r2.measurements).all() # Identical
If seed is omitted or set to None, Clifft uses hardware entropy from the operating system.
Importance Sampling (Forced k-Faults)¶
For circuits where logical errors are rare, standard Monte Carlo can require an impractical number of shots. Clifft provides stratified importance sampling via sample_k and sample_k_survivors, which force exactly k physical faults per shot. Results from different k strata must be combined using the corresponding Poisson-binomial probability \(P(K = k)\).
import clifft
result = clifft.sample_k_survivors(prog, shots=50_000, k=3, seed=42)
# Returns SampleResult with survivor metadata and surviving-shot arrays
Key API:
clifft.sample_k(program, shots, k, seed=None)-- Likesample(), but forces exactlykfaults. Only valid for programs without post-selection; post-selected programs must usesample_k_survivors(). Returns aSampleResultwith.measurements,.detectors, and.observables.clifft.sample_k_survivors(program, shots, k, seed=None, keep_records=False)-- Likesample_survivors(), but forces exactlykfaults. Returns aSampleResultwhose arrays contain only surviving shots plus survivor metadata.program.noise_site_probabilities-- 1D NumPy array of per-site fault probabilities, with quantum noise sites followed by readout noise entries. Use this for computing the Poisson-binomial PMF.
See the Importance Sampling Tutorial for a complete walkthrough.
Performance and Limits¶
Clifft's simulation cost is controlled primarily by the peak active dimension program.peak_rank, not by the total number of physical qubits. The SVM stores and updates a dense active state of size \(2^k\), where \(k\) is the number of simultaneously active qubits in Clifft's factored representation.
This means Clifft can handle circuits with many physical qubits when non-Clifford effects remain localized. It also means performance degrades as program.peak_rank grows: circuits with large sustained active dimension approach the cost of dense state-vector simulation.