Skip to content

Latest commit

 

History

History
817 lines (632 loc) · 23.8 KB

File metadata and controls

817 lines (632 loc) · 23.8 KB

ticit v0.1 Python API Reference

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.

Index

Installation

From the repository, build the mixed Rust/Python package with Maturin:

cd ticit_py
maturin develop

Python 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 gpu

The checked-in type stub is generated from the PyO3 declarations:

cargo run -p ticit_py --bin stub_gen

ticit.Circuit

class ticit.Circuit(stim_text: str = "")

A parsed, flattened circuit. Lowering and planning happen when compile() is called.

ticit.Circuit.__init__

def __init__(self, stim_text: str = "") -> None

Parses 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 == 1

Raises:

  • ticit.ParseError: the input is malformed or cannot be lowered.
  • ValueError: the input uses a valid but unsupported operation.

ticit.Circuit.from_text

@staticmethod
def from_text(stim_text: str) -> ticit.Circuit

Equivalent to ticit.Circuit(stim_text).

ticit.Circuit.from_file

@staticmethod
def from_file(path: str) -> ticit.Circuit

Reads 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.

ticit.Circuit.num_qubits

property num_qubits: int

Number of qubits named by the circuit.

ticit.Circuit.num_measurements

property num_measurements: int

Number of measurement results written to the measurement record.

ticit.Circuit.num_detectors

property num_detectors: int

Number of DETECTOR and DISCARD declarations.

ticit.Circuit.num_observables

property num_observables: int

One more than the largest OBSERVABLE_INCLUDE index, or zero when the circuit has no observable declarations.

ticit.Circuit.num_exp_vals

property num_exp_vals: int

Number of expectation values produced by EXP_VAL instructions.

ticit.Circuit.compile

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.Program

Compiles 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.

Pinned measurement parities

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".

ticit.Circuit.reference_sample

def circuit.reference_sample() -> ticit.ReferenceSample

Returns 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.

ticit.ReferenceSample

An immutable result with detectors: list[bool] and observables: list[bool] properties.

ticit.Program

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.

ticit.Program.backend

property backend: str

Either "cpu" or "gpu".

ticit.Program.num_qubits

property num_qubits: int

Number of circuit qubits.

ticit.Program.num_measurements

property num_measurements: int

Number of circuit measurement records.

ticit.Program.num_detectors

property num_detectors: int

Number of circuit detectors.

ticit.Program.num_observables

property num_observables: int

Number of circuit observable indices.

ticit.Program.num_exp_vals

property num_exp_vals: int

Number of expectation values in each result row.

ticit.Program.observable

property observable: int

Observable index whose accepted one outcomes are counted by SampleResult.logical_errors.

ticit.Program.has_postselection

property has_postselection: bool

Whether the compiled program rejects shots using at least one detector.

ticit.Program.sample

def program.sample(
    shots: int,
    seed: int | None = None,
    *,
    bit_packed: bool = False,
) -> ticit.SampleResult

Samples 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.

ticit.SampleResult

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.

Record arrays

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: bool

Program.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.

Count fields

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: int

The aliases follow both Clifft and ticit terminology:

  • total_shots == shots is the number of attempted shots.
  • discards == discarded is the number rejected by detector postselection.
  • passed_shots == accepted is the number retained.
  • shots == discarded + accepted always holds.
  • logical_errors counts accepted shots where the selected observable is one.
  • observable_ones[i] counts accepted rows where observable i is one.
  • observable identifies 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 == 0

Rate properties

property discard_rate: float
property logical_error_rate: float

discard_rate is discarded / shots. logical_error_rate is logical_errors / accepted. A zero denominator produces nan.

Timing fields

property compile_s: float
property presample_s: float
property execute_s: float
property sample_s: float
property active_threads: int
  • compile_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.

ticit.PauliString

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: int

x 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) -> bool

Out-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 * q performs Pauli multiplication; operands must have equal widths.
  • -p returns a copy multiplied by -1.
  • p == q compares width, phase, and packed body structurally.

Pauli constructor functions

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.PauliString

pauli_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.

ticit.MeasureResult

An immutable result returned by tableau-simulator measurements.

property outcome: bool
property probability: float
property deterministic: bool

outcome=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.

ticit.TableauSimulator

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).outcome

State and RNG:

property num_qubits: int
property rank: int
def reseed_rng(self, seed: int) -> None
def restore_rng_from(self, snapshot: ticit.TableauSimulator) -> None

Single-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) -> None

Two-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) -> None

Repeated 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) -> None

Controlled 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.MeasureResult

Forcing 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) -> None

reset 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.

ticit.SimulatorError

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.

ticit.ParseError

class ticit.ParseError(ValueError)

Raised when circuit source is malformed or fails during lowering.

ticit.parse

def ticit.parse(text: str) -> ticit.Circuit

Parses source text without preparing a sampler.

circuit = ticit.parse("M 0")
assert circuit.num_measurements == 1

ticit.parse_file

def ticit.parse_file(path: str) -> ticit.Circuit

Parses a UTF-8 circuit file. This is equivalent to ticit.Circuit.from_file(path).

ticit.compile

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.Program

Compatibility 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. Source DISCARD declarations 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 when backend="gpu".

Returns:

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 == 0

ticit.sample

def ticit.sample(
    program: ticit.Program,
    shots: int,
    seed: int | None = None,
    *,
    bit_packed: bool = False,
) -> ticit.SampleResult

Compatibility 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)

ticit.sample_survivors

def ticit.sample_survivors(
    program: ticit.Program,
    shots: int,
    seed: int | None = None,
    keep_records: bool = True,
    *,
    bit_packed: bool = False,
) -> ticit.SampleResult

A 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 backend

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_py with Cargo feature gpu.
  • A working CUDA environment supported by ticit's cutile backend is required.
  • GPU detector postselection uses the same per-detector postselection_mask as the CPU and Clifft APIs.
  • GPU sampling retains measurement, detector, observable, and expectation rows by default, just like CPU sampling. Set keep_records=False explicitly 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.