The ticit package performs exact batch sampling of noisy, adaptive,
Clifford-dominated quantum circuits:
import ticit
compiled_sampler = ticit.Circuit("""
H 0
M 0
OBSERVABLE_INCLUDE(0) rec[-1]
""").compile()
result = compiled_sampler.sample(shots=10_000, seed=42)
print(result.logical_error_rate)Like Clifft, SampleResult contains NumPy arrays for
per-shot measurements, detectors, observables, and expectation values. It also
includes aggregate postselection and logical-error counters.
ticit.Circuitticit.Programticit.SampleResultticit.PauliString- Pauli constructor functions
ticit.MeasureResultticit.TableauSimulatorticit.SimulatorErrorticit.ParseErrorticit.parseticit.parse_fileticit.compileticit.sampleticit.sample_survivors- GPU backend
From the repository, build the mixed Rust/Python package with Maturin:
cd ticit_py
maturin developPython 3.10 or newer is required. The wheel uses PyO3's abi3-py310 stable
ABI. To include CUDA support, build with the gpu Cargo feature:
cd ticit_py
maturin develop --features gpuThe checked-in type stub is generated from the PyO3 declarations:
cargo run -p ticit_py --bin stub_genclass ticit.Circuit(stim_text: str = "")A parsed, flattened circuit. Lowering and planning happen when compile() is
called.
def __init__(self, stim_text: str = "") -> NoneParses circuit source. An empty string creates an empty circuit.
import ticit
circuit = ticit.Circuit("H 0\nM 0\nDETECTOR rec[-1]")
assert circuit.num_qubits == 1
assert circuit.num_measurements == 1
assert circuit.num_detectors == 1Raises:
ticit.ParseError: the input is malformed or cannot be lowered.ValueError: the input uses a valid but unsupported operation.
@staticmethod
def from_text(stim_text: str) -> ticit.CircuitEquivalent to ticit.Circuit(stim_text).
@staticmethod
def from_file(path: str) -> ticit.CircuitReads a UTF-8 circuit file and returns its parsed circuit. Raises OSError if
the file cannot be read and ticit.ParseError if its
contents are invalid.
property num_qubits: intNumber of qubits named by the circuit.
property num_measurements: intNumber of measurement results written to the measurement record.
property num_detectors: intNumber of DETECTOR and DISCARD declarations.
property num_observables: intOne more than the largest OBSERVABLE_INCLUDE index, or zero when the circuit
has no observable declarations.
property num_exp_vals: intNumber of expectation values produced by EXP_VAL instructions.
def circuit.compile(
postselection_mask: Sequence[int] | None = None,
*,
normalize_syndromes: bool = False,
expected_detectors: Sequence[int] | None = None,
expected_observables: Sequence[int] | None = None,
pin_measurements: Sequence[tuple[Sequence[int], bool]] | None = None,
backend: str = "cpu",
observable: int = 0,
threads: int = 1,
sample_chunk_shots: int = 0,
batch_size: int = 0,
gpu_chunk_shots: int = 1_048_576,
) -> ticit.ProgramCompiles the parsed circuit into a reusable sampler. postselection_mask
contains one zero/nonzero flag per detector. normalize_syndromes=True
computes a noiseless reference sample on the CPU during preparation and XORs
detector and observable outcomes against it. Explicit expected_detectors and
expected_observables may be supplied instead, but cannot be combined with
normalize_syndromes. Both CPU and GPU sampling use the prepared vectors.
pin_measurements takes (records, value) pairs, each requiring that the XOR
of those measurement records is value in the noiseless circuit, in every
shot. Use it when a circuit is one compiled path of an adaptive program and the
path is only valid for one outcome of a logical measurement: without it, half
the shots per branch land on the wrong path and are discarded.
A parity the circuit leaves free is pinned by forcing the last measurement
branch it depends on. A parity the circuit already determines must already
equal value, or compilation raises ValueError.
Pins act on measurement branches, so noise still flips the recorded parity
and a decoder still sees the errors it must correct; what becomes deterministic
is the parity the noiseless circuit would have produced. Each pinned shot is
conditioned on a probability-one-half branch outcome, so sampling every
combination of the pinned parities with equal shots reproduces the
unconditioned distribution exactly. Sampling raises ValueError if a pinned
branch turns out not to be a fair coin, because such a shot would carry a
weight this API does not report.
The reference sample normalize_syndromes=True computes obeys the same pins,
so a pinned observable normalizes against the value the pinned shots share.
Pinning requires backend="cpu".
def circuit.reference_sample() -> ticit.ReferenceSampleReturns the full noiseless detector and observable parity vectors. This is the
same CPU reference used by normalize_syndromes=True. Where the circuit leaves
a measurement free, any outcome gives a valid noiseless sample and this picks
one; pin_measurements is what constrains that choice.
An immutable result with detectors: list[bool] and
observables: list[bool] properties.
A circuit prepared for repeated calls to
Program.sample. Programs are created by
Circuit.compile; ticit.Program() has no public
constructor.
CPU programs retain their planned program, expression plan, worker states, and buffers between calls. GPU programs retain the parsed circuit, backend configuration, and CPU-produced reference vectors; current GPU planning and device allocation occur in each sample call.
property backend: strEither "cpu" or "gpu".
property num_qubits: intNumber of circuit qubits.
property num_measurements: intNumber of circuit measurement records.
property num_detectors: intNumber of circuit detectors.
property num_observables: intNumber of circuit observable indices.
property num_exp_vals: intNumber of expectation values in each result row.
property observable: intObservable index whose accepted one outcomes are counted by
SampleResult.logical_errors.
property has_postselection: boolWhether the compiled program rejects shots using at least one detector.
def program.sample(
shots: int,
seed: int | None = None,
*,
bit_packed: bool = False,
) -> ticit.SampleResultSamples the compiled circuit. shots must be positive. seed=None chooses
fresh OS-provided entropy; an integer makes the result reproducible. Calls
release the Python GIL and are serialized around the program's reusable worker
buffers.
bit_packed=True returns the three bit arrays with shape
(rows, ceil(num_bits / 8)). Bit k is stored in byte k // 8 at
1 << (k % 8), equivalent to numpy.packbits(..., axis=1, bitorder="little").
For postselected programs, the record arrays contain one row per surviving shot.
Per-shot records, aggregate counters, and timing from one sampling call.
Instances are immutable and are returned by
Program.sample, ticit.sample, and
ticit.sample_survivors.
property measurements: numpy.ndarray # uint8, (rows, output measurement bytes)
property detectors: numpy.ndarray # uint8, (rows, output detector bytes)
property observables: numpy.ndarray # uint8, (rows, output observable bytes)
property exp_vals: numpy.ndarray # float64, (rows, program.num_exp_vals)
property bit_packed: boolProgram.sample returns passed_shots rows. The three
bit arrays can also be tuple-unpacked as
measurements, detectors, observables = result, matching Clifft. Without
packing, each bit occupies one byte. sample_survivors retains rows by
default; passing keep_records=False explicitly returns zero-row arrays with
the requested packed or unpacked column count.
property total_shots: int
property shots: int
property discards: int
property discarded: int
property passed_shots: int
property accepted: int
property logical_errors: int
property observable_ones: numpy.ndarray # uint64, (program.num_observables,)
property observable: intThe aliases follow both Clifft and ticit terminology:
total_shots == shotsis the number of attempted shots.discards == discardedis the number rejected by detector postselection.passed_shots == acceptedis the number retained.shots == discarded + acceptedalways holds.logical_errorscounts accepted shots where the selected observable is one.observable_ones[i]counts accepted rows where observableiis one.observableidentifies that selected observable index.
result = ticit.Circuit("M 0").compile().sample(shots=100, seed=1)
assert result.total_shots == 100
assert result.passed_shots == 100
assert result.discards == 0property discard_rate: float
property logical_error_rate: floatdiscard_rate is discarded / shots. logical_error_rate is
logical_errors / accepted. A zero denominator produces nan.
property compile_s: float
property presample_s: float
property execute_s: float
property sample_s: float
property active_threads: intcompile_s: CPU circuit-planning time, or GPU planning/setup/JIT warmup time.presample_s: exogenous-noise generation and expression evaluation.execute_s: factored circuit execution and result reduction.sample_s: wall-clock steady-state sampling time.active_threads: CPU workers that received work; one for GPU sampling.
With multiple CPU workers, presample_s and execute_s are sums of worker
time and can exceed sample_s.
class ticit.PauliString(nqubits: int = 0)A packed Pauli operator. The represented operator is
i**phase_exponent * product(X**x * Z**z). Constructing by qubit count creates
identity; ticit.pauli_string parses a dense
literal.
p = ticit.pauli_string("IXYZ")
assert p.nqubits == 4
assert str(p) == "IXYZ"
assert str(-p) == "-IXYZ"Read-only properties:
property nqubits: int
property x: list[int]
property z: list[int]
property phase_exponent: intx and z are copies of the packed LSB-first 64-bit words. Qubit q is bit
q & 63 of word q >> 6.
Methods:
@staticmethod
def from_text(text: str) -> ticit.PauliString
def xbit(self, q: int) -> bool
def zbit(self, q: int) -> bool
def set_xbit(self, q: int, value: bool) -> None
def set_zbit(self, q: int, value: bool) -> None
def set_phase(self, phase_exponent: int) -> None
def phase_shift(self, delta: int) -> None
def has_nonidentity_body(self) -> bool
def same_body(self, other: ticit.PauliString) -> boolOut-of-range qubit indices raise ValueError. set_phase and phase_shift
reduce their inputs modulo four. same_body ignores phase.
Operators:
str(p)renders the coefficient and dense body.p * qperforms Pauli multiplication; operands must have equal widths.-preturns a copy multiplied by -1.p == qcompares width, phase, and packed body structurally.
def ticit.pauli_identity(nqubits: int) -> ticit.PauliString
def ticit.pauli_x(nqubits: int, q: int) -> ticit.PauliString
def ticit.pauli_y(nqubits: int, q: int) -> ticit.PauliString
def ticit.pauli_z(nqubits: int, q: int) -> ticit.PauliString
def ticit.pauli_string(text: str) -> ticit.PauliString
def ticit.neg(pauli: ticit.PauliString) -> ticit.PauliStringpauli_string accepts I, X, Y, and Z case-insensitively; _ aliases
identity. String position is the qubit index. The single-axis constructors
raise ValueError when q >= nqubits.
An immutable result returned by tableau-simulator measurements.
property outcome: bool
property probability: float
property deterministic: booloutcome=False represents eigenvalue +1 and outcome=True represents -1.
probability is the pre-projection branch probability. deterministic is true
when the state forced the outcome.
class ticit.TableauSimulator(num_qubits: int, seed: int | None = None)A procedural Clifford+T simulator in Stim's TableauSimulator style. It starts
in |0...0>. Writing a missing qubit grows the register; read-only peek_*
operations reject missing qubits. seed=None uses OS entropy.
sim = ticit.TableauSimulator(2, seed=7)
sim.h(0)
sim.cx(0, 1)
assert sim.peek_observable_expectation(ticit.pauli_string("XX")) == 1
assert sim.measure(0).outcome == sim.measure(1).outcomeState and RNG:
property num_qubits: int
property rank: int
def reseed_rng(self, seed: int) -> None
def restore_rng_from(self, snapshot: ticit.TableauSimulator) -> NoneSingle-qubit Clifford gates:
def h(self, q: int) -> None
def s(self, q: int) -> None
def s_dag(self, q: int) -> None
def x(self, q: int) -> None
def y(self, q: int) -> None
def z(self, q: int) -> None
def sqrt_x(self, q: int) -> None
def sqrt_x_dag(self, q: int) -> None
def sqrt_y(self, q: int) -> None
def sqrt_y_dag(self, q: int) -> None
def c_xyz(self, q: int) -> None
def c_zyx(self, q: int) -> None
def h_xy(self, q: int) -> None
def h_yz(self, q: int) -> NoneTwo-qubit Clifford gates:
def cx(self, control: int, target: int) -> None
def cnot(self, control: int, target: int) -> None
def cy(self, control: int, target: int) -> None
def cz(self, a: int, b: int) -> None
def swap(self, a: int, b: int) -> None
def iswap(self, a: int, b: int) -> None
def iswap_dag(self, a: int, b: int) -> None
def xcx(self, control: int, target: int) -> None
def xcy(self, control: int, target: int) -> None
def xcz(self, control: int, target: int) -> None
def ycx(self, control: int, target: int) -> None
def ycy(self, control: int, target: int) -> None
def ycz(self, control: int, target: int) -> None
def zcx(self, control: int, target: int) -> None
def zcy(self, control: int, target: int) -> None
def zcz(self, a: int, b: int) -> NoneRepeated operands raise ValueError. cx, cnot, and zcx are aliases;
cz and zcz are aliases; cy and zcy are aliases.
Pauli and non-Clifford operations:
def pauli(self, pauli: ticit.PauliString) -> None
def controlled_pauli(
self,
control: ticit.PauliString,
target: ticit.PauliString,
) -> None
def t(self, q: int) -> None
def t_dag(self, q: int) -> None
def t_pauli(self, axis: ticit.PauliString, adjoint: bool) -> None
def ccz(self, a: int, b: int, c: int) -> NoneControlled Pauli axes must be positive, Hermitian, and commuting. T rotation
axes must be Hermitian. Argument failures raise ValueError; exponential rank
growth beyond the engine cap raises
ticit.SimulatorError.
Measurements and postselection:
def measure(self, q: int) -> ticit.MeasureResult
def measure_observable(self, observable: ticit.PauliString) -> ticit.MeasureResult
def postselect_observable(
self,
observable: ticit.PauliString,
desired_value: bool,
) -> ticit.MeasureResult
def postselect_x(self, q: int, desired_value: bool) -> ticit.MeasureResult
def postselect_y(self, q: int, desired_value: bool) -> ticit.MeasureResult
def postselect_z(self, q: int, desired_value: bool) -> ticit.MeasureResultForcing an outcome with zero probability raises
ticit.SimulatorError and leaves the state unchanged.
Non-collapsing expectations and resets:
def peek_observable_expectation(self, observable: ticit.PauliString) -> float
def peek_x(self, q: int) -> float
def peek_y(self, q: int) -> float
def peek_z(self, q: int) -> float
def reset(self, q: int) -> None
def reset_x(self, q: int) -> None
def reset_y(self, q: int) -> None
def reset_z(self, q: int) -> Nonereset and reset_z prepare |0>; reset_x prepares |+>; reset_y
prepares |+i>.
Dense inspection:
def state_vector(self) -> list[complex]Reconstructs a length-2**num_qubits state vector. This is intended only for
tests and small registers because both time and memory are exponential.
class ticit.SimulatorError(RuntimeError)Raised for live-state failures such as rank overflow, impossible
postselection, or pruning that would erase the state. Invalid axes, repeated
qubits, and out-of-range read-only access instead raise ValueError.
class ticit.ParseError(ValueError)Raised when circuit source is malformed or fails during lowering.
def ticit.parse(text: str) -> ticit.CircuitParses source text without preparing a sampler.
circuit = ticit.parse("M 0")
assert circuit.num_measurements == 1def ticit.parse_file(path: str) -> ticit.CircuitParses a UTF-8 circuit file. This is equivalent to
ticit.Circuit.from_file(path).
def ticit.compile(
stim_text: str,
postselection_mask: Sequence[int] | None = None,
expected_detectors: Sequence[int] | None = None,
expected_observables: Sequence[int] | None = None,
normalize_syndromes: bool = False,
*,
pin_measurements: Sequence[tuple[Sequence[int], bool]] | None = None,
backend: str = "cpu",
observable: int = 0,
threads: int = 1,
sample_chunk_shots: int = 0,
batch_size: int = 0,
gpu_chunk_shots: int = 1_048_576,
) -> ticit.ProgramCompatibility wrapper that parses and prepares a circuit. New code should use
Circuit.compile. The positional parameters mirror
Clifft's compile function so existing call sites need minimal changes.
Arguments:
stim_text: circuit in ticit's Stim-style text format.postselection_mask: one zero/nonzero flag per detector. Nonzero flags reject a shot when that detector parity is one. SourceDISCARDdeclarations are unioned with this mask.expected_detectors: explicit detector reference bits, one per detector.expected_observables: explicit observable reference bits, one per index.normalize_syndromes: compute a noiseless reference on the CPU during preparation. Mutually exclusive with explicit reference vectors.pin_measurements:(records, value)parities every shot's noiseless circuit must produce; see Pinned measurement parities.backend:"cpu"or"gpu".observable: observable index counted as a logical error.threads: maximum CPU worker count; must be positive.sample_chunk_shots: CPU shots assigned to one scheduling chunk. Zero uses ticit's automatic value.batch_size: CPU shots executed together in one bit-packed batch. Zero uses a value based on peak active width.gpu_chunk_shots: maximum shots allocated in one GPU launch group; must be positive whenbackend="gpu".
Returns:
- A reusable
ticit.Program.
Raises:
ticit.ParseError: malformed circuit source.ValueError: invalid options, reference length, mask length, or backend name.RuntimeError:backend="gpu"was requested from a CPU-only build.
program = ticit.compile(
"H 0\nM 0\nOBSERVABLE_INCLUDE(0) rec[-1]",
backend="cpu",
threads=2,
)
assert program.observable == 0def ticit.sample(
program: ticit.Program,
shots: int,
seed: int | None = None,
*,
bit_packed: bool = False,
) -> ticit.SampleResultCompatibility wrapper for Program.sample. Postselected
programs return survivor rows.
program = ticit.Circuit("M 0").compile()
a = program.sample(shots=64, seed=123)
b = program.sample(shots=64, seed=123)
assert (a.discards, a.logical_errors) == (b.discards, b.logical_errors)def ticit.sample_survivors(
program: ticit.Program,
shots: int,
seed: int | None = None,
keep_records: bool = True,
*,
bit_packed: bool = False,
) -> ticit.SampleResultA Clifft-compatible name for postselected sampling. Its counters are identical
to ticit.sample(program, shots, seed). It returns survivor rows by default;
keep_records=False is the explicit aggregate-only mode and avoids record
materialization.
bit_packed has the same meaning as on
Program.sample.
GPU selection is a compile option, not a separate Python module:
program = ticit.Circuit(
"H 0\nM 0\nOBSERVABLE_INCLUDE(0) rec[-1]"
).compile(backend="gpu", gpu_chunk_shots=1_048_576)
result = ticit.sample_survivors(program, shots=1_000_000, seed=42)Requirements and current limits:
- Build
ticit_pywith Cargo featuregpu. - A working CUDA environment supported by ticit's
cutilebackend is required. - GPU detector postselection uses the same per-detector
postselection_maskas the CPU and Clifft APIs. - GPU sampling retains measurement, detector, observable, and expectation rows
by default, just like CPU sampling. Set
keep_records=Falseexplicitly when only aggregate counters are wanted. - GPU planning, device allocation, and one-time cuTile JIT warmup currently
occur during the sampling call and are reported in
compile_s.