---
type: "article"
title: "Running Qiskit circuits on a near-Clifford simulator: building the clifft-qiskit provider"
summary: "Running Qiskit circuits on a near-Clifford simulator: building the clifft-qiskit provider\nMost quantum simulators are exponential in the qubit count. clifft, a simulator from the Unitary Foundation, is not: it is a fast, exact simulator for near-Clifford circuits, and its cost scales with the non-Clifford content of a circuit rather than its width. A circuit that is mostly Clifford with a handful of T gates and rotations simulates in time that grows as 2^k, where k is the small \"active\" dimension the non-Clifford operations open up, not 2^n in the total qubit count. On low-magic workloads that is the difference between a circuit that is tractable and one that is not.\nThere was one friction point. clifft's native input is Stim circuit text. If your circuit lives in Qiskit, as a large amount of quantum software does, you had to hand-write or hand-generate Stim before clifft could run it. During unitaryHACK 2026 I closed that gap by building clifft-qiskit, a Qiskit BackendV2 provider that lets you run a QuantumCircuit on clifft directly:\nfrom qiskit import QuantumCircuit\nfrom clifft_qiskit import ClifftProvider\n\nqc = QuantumCircuit(2, 2)\nqc.h(0); qc.cx(0, 1); qc.measure([0, 1], [0, 1])\n\nbackend = ClifftProvider().get_backend(\"clifft\")\ncounts = backend.run(qc, shots=1000).result().get_counts()\nprint(counts)  # {'11': ~500, '00': ~500}\n\nThe work started in the main repo (issue #39, PR #120), then moved to a standalone package (clifft-qiskit) with its own release automation (issue #2, PR #3), now published on PyPI. This post is a walk through that: the shape of the adapter, the one part that is easy to get wrong, how I validated it, and what it took to turn a subpackage into a released library.\nNote: I am a contributor, not a maintainer, of clifft. This is my reading of the code and my own work on the provider; corrections from the clifft team are welcome.\nWhy bridge to clifft at all\nBefore the adapter, the case for it. clifft keeps the exact state in a factored form: a compile-time Clifford frame (resolved once, ahead of time, using Stim's tableau simulator), a lightweight runtime Pauli frame updated per shot, and only a small active subset of the state carried as a dense complex array of size 2^k. Clifford gates are absorbed into the frame at zero per-shot cost; each T gate or rotation is what grows k. The published benchmarks (arXiv 2604.27058) put it at roughly 10x slower than Stim on pure-Clifford QEC sampling, but far ahead of other near-Clifford tools on magic-heavy workloads (their numbers include ~370x over Tsim on distance-3 cultivation and ~13x over SOFT on distance-5), and competitive with full statevector simulators like Aer and qsim on dense circuits.\nThe point for a Qiskit user is simple: for the large class of circuits that are mostly Clifford, clifft can be much cheaper than a general statevector simulator, and it is exact, not approximate. The only thing standing between a Qiskit QuantumCircuit and that speed was a format boundary. A provider removes it.\nThe design, decided up front\nThe issue that scoped this (#39) fixed the shape before any code: a single synchronous BackendV2, all-to-all connectivity, terminal measurement returning counts, a documented basis, clear errors on anything unsupported, and one hard constraint worth calling out - importing clifft must not require Qiskit. The simulator core stays dependency-light; the Qiskit dependency lives only in the provider. That single rule is what later justified pulling the provider into its own package.\nThe basis was chosen as Clifford+T plus single-qubit rotations. That choice is what keeps the provider exact rather than approximate, for reasons covered in the section on rotations below.\nThe provider is a translation layer over two clifft calls\nHere is the pipeline in one picture. A QuantumCircuit goes in, a counts dict comes out, and every quantum operation happens inside two clifft calls in the middle.\nThe entire pipeline lives in ClifftBackend.run(). Stripped to its spine:\ndef run(self, run_input, **options) -> ClifftJob:\n    shots = options.get(\"shots\", self.options.shots)\n    seed = options.get(\"seed\", self.options.seed)\n\n    for circ in circuits:\n        decomposed = transpile(circ, basis_gates=CLIFFT_BASIS, optimization_level=1)\n        stim_text, measured_clbits = qiskit_to_stim(decomposed)\n        if not measured_clbits:\n            raise QiskitError(\"clifft backend requires at least one measurement.\")\n\n        program = clifft.compile(stim_text)\n        sample = clifft.sample(program, shots=shots)          # + seed, if given\n        counts = counts_from_measurements(sample.measurements,\n                                          measured_clbits, decomposed.num_clbits)\n        ...\n\nRead that as three responsibilities:\nQiskit lowers the circuit. transpile(..., basis_gates=CLIFFT_BASIS) rewrites arbitrary gates into clifft's basis. I do not write a decomposition pass; Qiskit's transpiler already knows how, and pointing it at the right basis is enough.\nThe provider translates. qiskit_to_stim turns the lowered circuit into Stim text and returns a map from sample columns back to classical bits. This is the only code the provider truly owns, and it is deliberately small.\nclifft does the physics. clifft.compile(stim_text) returns a Program; clifft.sample(program, shots=...) returns a SampleResult whose .measurements is a (shots, num_measurements) array of bits. The exactness of Clifford+T and of the rotations is a property of the simulator, not of my adapter.\nThe Qiskit surface around this is thin. ClifftProvider().get_backend(\"clifft\") hands back a ClifftBackend(BackendV2); run() computes eagerly and wraps the finished Result in a ClifftJob(JobV1) whose submit() is a no-op and whose status() is already DONE. The backend's Target advertises the basis with num_qubits=None, which leaves the width unbounded so transpiling against the backend never caps the circuit. There is no async machinery because there is nothing to wait for: sampling has already happened by the time you hold the job.\nThe whole translation, on one page\nThe provider owns exactly one table. Eleven Clifford+T gates map to Stim by name, three rotations map and convert their angle, measurement records a classical bit, and anything else is refused.\nThe non-parameterized gates are a plain lookup:\n_GATE_MAP = {\n    \"h\": \"H\", \"x\": \"X\", \"y\": \"Y\", \"z\": \"Z\",\n    \"s\": \"S\", \"sdg\": \"S_DAG\", \"t\": \"T\", \"tdg\": \"T_DAG\",\n    \"cx\": \"CX\", \"cy\": \"CY\", \"cz\": \"CZ\",\n}\n\nThe rotations are the interesting three, and they carry a unit conversion that is easy to miss and matters:\n_PARAM_GATE_MAP = {\"rx\": \"R_X\", \"ry\": \"R_Y\", \"rz\": \"R_Z\"}\n\n# inside qiskit_to_stim, for a parameterized gate:\nangle = float(instruction.operation.params[0]) / math.pi     # radians -> half-turns\nlines.append(f\"{_PARAM_GATE_MAP[name]}({angle}) {qubits[0]}\")\n\nQiskit measures rotation angles in radians. clifft's R_X/R_Y/R_Z extensions measure them in half-turns (multiples of pi). Divide by pi on the way across and the two agree; miss it, and every rotation is wrong by a factor of pi with no error raised. That is the kind of mistake a shape test misses and a distribution test catches, which is what the validation section is for.\nEverything that is not in one of those two maps, and is not a measurement or a trivial no-op like barrier, is rejected:\nraise QiskitError(\n    f\"clifft backend does not support operation '{name}'. \"\n    f\"Supported basis: {supported}, measure.\"\n)\n\nThat is how reset and mid-circuit control end up rejected: the transpiler cannot lower a non-unitary into a unitary basis, so the unknown op reaches this branch and the error names both the offending operation and the full supported set. Refusing clearly is a feature; silently dropping an operation would be a correctness bug that surfaces as a wrong distribution three steps later.\nWhy rotations belong in the basis\nPutting rx, ry, rz in the basis is not a convenience, it is what keeps the provider exact. With those three in the target, Qiskit's transpiler can lower any single-qubit unitary (u, p, and friends) and any controlled rotation into the basis by exact algebraic identities. There is no Solovay-Kitaev approximation, no synthesizing a rotation out of a long H/T sequence to some tolerance. The comment in the code states it plainly:\n# With rx/ry/rz in the basis, the transpiler lowers any single-qubit unitary\n# (u, p, ...) and controlled rotations exactly into this set -- no approximate\n# (Solovay-Kitaev) synthesis is needed.\n\nThis only works because clifft treats rotations as first-class, native operations (they compile to exact complex-amplitude updates on the active array), rather than something to be approximated in a discrete gate set. The provider inherits that exactness directly. The general point is that a thin adapter can only expose what the engine underneath it supports, and the right choice is to surface that capability rather than hide it behind a weaker basis.\nGetting the counts right\nThe gate mapping is mechanical. The part that took the most care is the boundary where clifft's sample array becomes a Qiskit counts dict, because two different ordering conventions meet there.\nclifft returns measurements as a (shots, num_measurements) array where column j is the j-th M instruction in program order. Qiskit reports counts as bitstrings indexed by classical bit, little-endian, and a circuit is free to measure qubits into classical bits in any order it likes. Those are not the same indexing, so qiskit_to_stim returns a measured_clbits list alongside the Stim text: measured_clbits[j] is the Qiskit clbit that the j-th measurement writes to. Packing then respects that map:\nfor row in measurements:               # one row per shot\n    value = 0\n    for col, clbit in enumerate(measured_clbits):\n        if int(row[col]):\n            value |= 1 << clbit         # clbit i contributes bit 1<<i\n    key = hex(value)\n    counts[key] = counts.get(key, 0) + 1\n\nTwo things make this correct. First, the bit for measurement col is placed at 1 << clbit, not 1 << col, so a circuit that measures qubit 0 into clbit 2 lands in the right place. Second, the counts are keyed as hex and the experiment result carries a memory_slots = num_clbits header, which is what lets Qiskit's own get_counts() render those hex keys into correctly-widthed, correctly-ordered binary strings, identical to what AerSimulator would produce. Decoupling measurement order from clbit order is the reason a permuted-measurement circuit comes out matching a reference simulator rather than reordered, and there is a dedicated test for that case.\nValidating against a full statevector simulator\nA simulator adapter is only useful if it is correct, so the tests do not check shapes, they check distributions, against qiskit-aer as the reference. The suite runs each circuit on both backends and asserts that every outcome's probability agrees to within a tolerance (SHOTS = 4000, TOL = 0.05), across thirteen cases: Bell and GHZ-3 entanglement, a non-Clifford H-T-H, a Toffoli that must decompose into Clifford+T, native rx/ry/rz, a general u gate and a controlled rotation lowered exactly, list execution, the permuted-clbit case, and the error paths.\nTo sanity-check it fresh for this post, I ran an entangled rx/ry/rz circuit on both backends at 20,000 shots:\nThe largest per-outcome gap between clifft and Aer was 0.6 percentage points, which is sampling noise at this shot count, not a modelling difference, and it is an order of magnitude inside the suite's own 5-point tolerance. On the circuits both tools can represent, the distributions match, because clifft is computing the same exact result by a cheaper route. The unit conversion, the bit packing, and the transpilation basis are all checked at once by a test that would fail if any of them were wrong.\nFrom a subpackage to a package\nThe provider first landed inside the clifft repo as an optional clifft[qiskit] subpackage (PR #120). During review, the decision was to pull it out into a standalone repository and package, clifft-qiskit, rather than keep it in-tree. The reasoning is the same \"keep clifft dependency-light\" rule from the design, seen from the other side:\nclifft is a compiled C++ core with Python bindings, released on its own cadence. Qiskit's API moves faster and is pure-Python. Binding the two release cycles together helps neither.\nA separate package lets the provider declare qiskit>=2.0 and track it independently, while clifft keeps a minimal dependency surface and never imports Qiskit unless a user asks for the provider.\nThis is the established pattern in the ecosystem: a lean simulator core with satellite integration packages (qiskit-aer, the pytket extensions) that each track one external SDK.\nSo the code moved, the package directory was renamed from clifft/qiskit to a flat clifft_qiskit, and the dependency became explicit rather than an extra:\n[project]\nname = \"clifft-qiskit\"\nrequires-python = \">=3.12\"\ndependencies = [\"clifft>=0.4\", \"qiskit>=2.0\", \"numpy>=1.26\"]\ndynamic = [\"version\"]\n\n[tool.hatch.version]\nsource = \"vcs\"                 # version comes from the git tag, not a hardcoded string\n\nNothing about the physics changed. What changed is that the provider became a thing you can pip install and version on its own.\nOne tag ships the release\nThe last piece (PR #3) was release automation, so that cutting a version is a single action rather than a manual checklist. The pipeline is driven by a git tag.\nPush a vX.Y.Z tag and the workflow:\nBuilds with uv build, runs twine check, and does a wheel smoke test - it installs the freshly built wheel into a clean environment and runs the test suite against it, so a broken wheel fails before it can be published, not after.\nPublishes to TestPyPI on every run, using OIDC trusted publishing, so there are no PyPI API tokens stored in the repo at all.\nPublishes to PyPI, but only when the trigger is a tag push. A manual workflow_dispatch run deliberately stops at TestPyPI, which makes \"rehearse the release\" a safe, first-class operation.\nCuts a GitHub release, extracting the matching section from CHANGELOG.md as the notes.\nTwo supporting pieces make that sustainable. Versioning is derived from the tag by hatch-vcs instead of a string someone has to remember to bump (an early review caught a hardcoded \"0.1.0\" in the backend; the fix was to read the version from package metadata so it can never drift from the tag). And the changelog is generated by git-cliff from conventional-commit messages, with a CI check that enforces conventional PR titles, so the release notes assemble themselves from the commit history rather than being written by hand each time. The CI matrix runs Python 3.12 and 3.13 against both a pinned and an upgraded dependency set, so a breaking change in a future Qiskit shows up as a scheduled-run failure rather than a surprise in production.\nThe result of all of it is clifft-qiskit 0.1.0 on PyPI: pip install clifft-qiskit, and the four lines from the top of this post run a Qiskit circuit on a near-Clifford simulator.\nWhat I took away\nA good provider is a thin adapter, and thinness is the goal. The only code this package really owns is a gate-name table and a bit-packing loop. Everything hard - decomposition, the physics, the exactness - is delegated to Qiskit's transpiler and clifft's core. Recognizing what not to build was most of the work.\nExactness lives in the engine, so expose it, do not approximate it. Putting rx/ry/rz in the basis, because clifft supports them natively, is what lets the transpiler avoid Solovay-Kitaev entirely. A weaker basis would have made every rotation an approximation for no reason.\nThe boundary between two ordering conventions is where the bugs are. The gate map is trivial; the measurement-column-to-clbit mapping and the little-endian packing are where correctness is won or lost, and they are worth a dedicated test each.\nValidate distributions, not shapes. Checking every outcome's probability against Aer catches the radians-versus-half-turns class of bug that a \"did it return counts\" test never would.\nExtract a package when the cadences diverge. A compiled core and a fast-moving SDK wrapper want different release cycles; splitting them keeps the core's dependency surface honest.\nMake releasing routine. A tag-triggered build, a wheel smoke test, trusted publishing with no stored secrets, TestPyPI before PyPI, and an auto-generated changelog reduce \"cut a release\" to pushing one tag, which is about as much attention as the step deserves.\nIf you want to read the code, start at clifft_qiskit/backend.py (the BackendV2 and the run pipeline), then clifft_qiskit/_translate.py (the gate table, the angle conversion, and the counts packing). The package is on PyPI, the source is on GitHub, and clifft itself is documented at unitaryfoundation.github.io/clifft.\nCorrections and improvements welcome, especially from the clifft maintainers, who know the core far better than I do. Built during unitaryHACK 2026."
newsletter: "Engineering With Ashmit"
newsletter_handle: "engineeringwithashmit"
newsletter_url: "https://usecommune.com/n/engineeringwithashmit"
author: "Ashmit JaiSarita Gupta (@ashmitjsg)"
published: "2026-07-14T02:46:45.000Z"
canonical_url: "https://usecommune.com/n/engineeringwithashmit/a/running-qiskit-circuits-on-a-near-clifford-simulator-buildin"
markdown_url: "https://usecommune.com/n/engineeringwithashmit/a/running-qiskit-circuits-on-a-near-clifford-simulator-buildin.md"
chat_url: "https://usecommune.com/n/engineeringwithashmit/a/running-qiskit-circuits-on-a-near-clifford-simulator-buildin/chat"
source_url: "https://engineeringwithashmit.hashnode.dev/building-the-clifft-qiskit-provider"
body_source: "imported"
likes: 0
replies: 0
body_words: 2806
---

# Running Qiskit circuits on a near-Clifford simulator: building the clifft-qiskit provider

Most quantum simulators are exponential in the qubit count. [clifft](https://github.com/unitaryfoundation/clifft), a simulator from the Unitary Foundation, is not: it is a fast, exact simulator for **near-Clifford** circuits, and its cost scales with the *non-Clifford* content of a circuit rather than its width. A circuit that is mostly Clifford with a handful of T gates and rotations simulates in time that grows as `2^k`, where `k` is the small "active" dimension the non-Clifford operations open up, not `2^n` in the total qubit count. On low-magic workloads that is the difference between a circuit that is tractable and one that is not.

There was one friction point. clifft's native input is **Stim circuit text**. If your circuit lives in Qiskit, as a large amount of quantum software does, you had to hand-write or hand-generate Stim before clifft could run it. During [unitaryHACK 2026](https://unitaryhack.dev/) I closed that gap by building **clifft-qiskit**, a Qiskit `BackendV2` provider that lets you run a `QuantumCircuit` on clifft directly:

```python
from qiskit import QuantumCircuit
from clifft_qiskit import ClifftProvider

qc = QuantumCircuit(2, 2)
qc.h(0); qc.cx(0, 1); qc.measure([0, 1], [0, 1])

backend = ClifftProvider().get_backend("clifft")
counts = backend.run(qc, shots=1000).result().get_counts()
print(counts)  # {'11': ~500, '00': ~500}
```

The work started in the main repo ([issue #39](https://github.com/unitaryfoundation/clifft/issues/39), [PR #120](https://github.com/unitaryfoundation/clifft/pull/120)), then moved to a standalone package ([clifft-qiskit](https://github.com/unitaryfoundation/clifft-qiskit)) with its own release automation ([issue #2](https://github.com/unitaryfoundation/clifft-qiskit/issues/2), [PR #3](https://github.com/unitaryfoundation/clifft-qiskit/pull/3)), now published on [PyPI](https://pypi.org/project/clifft-qiskit/). This post is a walk through that: the shape of the adapter, the one part that is easy to get wrong, how I validated it, and what it took to turn a subpackage into a released library.

> Note: I am a contributor, not a maintainer, of clifft. This is my reading of the code and my own work on the provider; corrections from the clifft team are welcome.

## Why bridge to clifft at all

Before the adapter, the case for it. clifft keeps the exact state in a factored form: a compile-time Clifford frame (resolved once, ahead of time, using Stim's tableau simulator), a lightweight runtime Pauli frame updated per shot, and only a small active subset of the state carried as a dense complex array of size `2^k`. Clifford gates are absorbed into the frame at zero per-shot cost; each T gate or rotation is what grows `k`. The published benchmarks ([arXiv 2604.27058](https://arxiv.org/abs/2604.27058)) put it at roughly 10x slower than Stim on pure-Clifford QEC sampling, but far ahead of other near-Clifford tools on magic-heavy workloads (their numbers include ~370x over Tsim on distance-3 cultivation and ~13x over SOFT on distance-5), and competitive with full statevector simulators like Aer and qsim on dense circuits.

The point for a Qiskit user is simple: for the large class of circuits that are mostly Clifford, clifft can be much cheaper than a general statevector simulator, and it is exact, not approximate. The only thing standing between a Qiskit `QuantumCircuit` and that speed was a format boundary. A provider removes it.

## The design, decided up front

The issue that scoped this ([#39](https://github.com/unitaryfoundation/clifft/issues/39)) fixed the shape before any code: a single synchronous `BackendV2`, all-to-all connectivity, terminal measurement returning counts, a documented basis, clear errors on anything unsupported, and one hard constraint worth calling out - **importing clifft must not require Qiskit**. The simulator core stays dependency-light; the Qiskit dependency lives only in the provider. That single rule is what later justified pulling the provider into its own package.

The basis was chosen as Clifford+T plus single-qubit rotations. That choice is what keeps the provider exact rather than approximate, for reasons covered in the section on rotations below.

## The provider is a translation layer over two clifft calls

Here is the pipeline in one picture. A `QuantumCircuit` goes in, a counts dict comes out, and every quantum operation happens inside two clifft calls in the middle.

![The clifft-qiskit pipeline: a QuantumCircuit is transpiled to clifft's basis, emitted as Stim text, then compiled and sampled by clifft, and the measurements are packed into Qiskit counts](https://cdn.hashnode.com/uploads/covers/637e3426361eabd5d57eac79/3a7c6355-170d-45fb-8ae9-a29436d56522.png)

The entire pipeline lives in `ClifftBackend.run()`. Stripped to its spine:

```python
def run(self, run_input, **options) -> ClifftJob:
    shots = options.get("shots", self.options.shots)
    seed = options.get("seed", self.options.seed)

    for circ in circuits:
        decomposed = transpile(circ, basis_gates=CLIFFT_BASIS, optimization_level=1)
        stim_text, measured_clbits = qiskit_to_stim(decomposed)
        if not measured_clbits:
            raise QiskitError("clifft backend requires at least one measurement.")

        program = clifft.compile(stim_text)
        sample = clifft.sample(program, shots=shots)          # + seed, if given
        counts = counts_from_measurements(sample.measurements,
                                          measured_clbits, decomposed.num_clbits)
        ...
```

Read that as three responsibilities:

1. **Qiskit lowers the circuit.** `transpile(..., basis_gates=CLIFFT_BASIS)` rewrites arbitrary gates into clifft's basis. I do not write a decomposition pass; Qiskit's transpiler already knows how, and pointing it at the right basis is enough.
2. **The provider translates.** `qiskit_to_stim` turns the lowered circuit into Stim text and returns a map from sample columns back to classical bits. This is the only code the provider truly owns, and it is deliberately small.
3. **clifft does the physics.** `clifft.compile(stim_text)` returns a `Program`; `clifft.sample(program, shots=...)` returns a `SampleResult` whose `.measurements` is a `(shots, num_measurements)` array of bits. The exactness of Clifford+T and of the rotations is a property of the simulator, not of my adapter.

The Qiskit surface around this is thin. `ClifftProvider().get_backend("clifft")` hands back a `ClifftBackend(BackendV2)`; `run()` computes eagerly and wraps the finished `Result` in a `ClifftJob(JobV1)` whose `submit()` is a no-op and whose `status()` is already `DONE`. The backend's `Target` advertises the basis with `num_qubits=None`, which leaves the width unbounded so transpiling against the backend never caps the circuit. There is no async machinery because there is nothing to wait for: sampling has already happened by the time you hold the job.

## The whole translation, on one page

The provider owns exactly one table. Eleven Clifford+T gates map to Stim by name, three rotations map and convert their angle, measurement records a classical bit, and anything else is refused.

![The QuantumCircuit to Stim translation table: eleven Clifford+T gates map by name, three rotations map and convert radians to half-turns, measurement records a clbit, and any other operation raises QiskitError](https://cdn.hashnode.com/uploads/covers/637e3426361eabd5d57eac79/a2188c2b-169a-40bd-b62e-4a02bc5602f9.png)

The non-parameterized gates are a plain lookup:

```python
_GATE_MAP = {
    "h": "H", "x": "X", "y": "Y", "z": "Z",
    "s": "S", "sdg": "S_DAG", "t": "T", "tdg": "T_DAG",
    "cx": "CX", "cy": "CY", "cz": "CZ",
}
```

The rotations are the interesting three, and they carry a unit conversion that is easy to miss and matters:

```python
_PARAM_GATE_MAP = {"rx": "R_X", "ry": "R_Y", "rz": "R_Z"}

# inside qiskit_to_stim, for a parameterized gate:
angle = float(instruction.operation.params[0]) / math.pi     # radians -> half-turns
lines.append(f"{_PARAM_GATE_MAP[name]}({angle}) {qubits[0]}")
```

Qiskit measures rotation angles in radians. clifft's `R_X`/`R_Y`/`R_Z` extensions measure them in **half-turns** (multiples of pi). Divide by pi on the way across and the two agree; miss it, and every rotation is wrong by a factor of pi with no error raised. That is the kind of mistake a shape test misses and a distribution test catches, which is what the validation section is for.

Everything that is not in one of those two maps, and is not a measurement or a trivial no-op like `barrier`, is rejected:

```python
raise QiskitError(
    f"clifft backend does not support operation '{name}'. "
    f"Supported basis: {supported}, measure."
)
```

That is how `reset` and mid-circuit control end up rejected: the transpiler cannot lower a non-unitary into a unitary basis, so the unknown op reaches this branch and the error names both the offending operation and the full supported set. Refusing clearly is a feature; silently dropping an operation would be a correctness bug that surfaces as a wrong distribution three steps later.

### Why rotations belong in the basis

Putting `rx`, `ry`, `rz` in the basis is not a convenience, it is what keeps the provider exact. With those three in the target, Qiskit's transpiler can lower any single-qubit unitary (`u`, `p`, and friends) and any controlled rotation into the basis by exact algebraic identities. There is no [Solovay-Kitaev](https://en.wikipedia.org/wiki/Solovay%E2%80%93Kitaev_theorem) approximation, no synthesizing a rotation out of a long H/T sequence to some tolerance. The comment in the code states it plainly:

```python
# With rx/ry/rz in the basis, the transpiler lowers any single-qubit unitary
# (u, p, ...) and controlled rotations exactly into this set -- no approximate
# (Solovay-Kitaev) synthesis is needed.
```

This only works because clifft treats rotations as first-class, native operations (they compile to exact complex-amplitude updates on the active array), rather than something to be approximated in a discrete gate set. The provider inherits that exactness directly. The general point is that a thin adapter can only expose what the engine underneath it supports, and the right choice is to surface that capability rather than hide it behind a weaker basis.

## Getting the counts right

The gate mapping is mechanical. The part that took the most care is the boundary where clifft's sample array becomes a Qiskit counts dict, because two different ordering conventions meet there.

clifft returns `measurements` as a `(shots, num_measurements)` array where column `j` is the `j`-th `M` instruction in program order. Qiskit reports counts as bitstrings indexed by **classical bit**, little-endian, and a circuit is free to measure qubits into classical bits in any order it likes. Those are not the same indexing, so `qiskit_to_stim` returns a `measured_clbits` list alongside the Stim text: `measured_clbits[j]` is the Qiskit clbit that the `j`-th measurement writes to. Packing then respects that map:

```python
for row in measurements:               # one row per shot
    value = 0
    for col, clbit in enumerate(measured_clbits):
        if int(row[col]):
            value |= 1 << clbit         # clbit i contributes bit 1<<i
    key = hex(value)
    counts[key] = counts.get(key, 0) + 1
```

Two things make this correct. First, the bit for measurement `col` is placed at `1 << clbit`, not `1 << col`, so a circuit that measures qubit 0 into clbit 2 lands in the right place. Second, the counts are keyed as hex and the experiment result carries a `memory_slots = num_clbits` header, which is what lets Qiskit's own `get_counts()` render those hex keys into correctly-widthed, correctly-ordered binary strings, identical to what `AerSimulator` would produce. Decoupling measurement order from clbit order is the reason a permuted-measurement circuit comes out matching a reference simulator rather than reordered, and there is a dedicated test for that case.

## Validating against a full statevector simulator

A simulator adapter is only useful if it is correct, so the tests do not check shapes, they check **distributions**, against `qiskit-aer` as the reference. The suite runs each circuit on both backends and asserts that every outcome's probability agrees to within a tolerance (`SHOTS = 4000`, `TOL = 0.05`), across thirteen cases: Bell and GHZ-3 entanglement, a non-Clifford `H-T-H`, a Toffoli that must decompose into Clifford+T, native `rx`/`ry`/`rz`, a general `u` gate and a controlled rotation lowered exactly, list execution, the permuted-clbit case, and the error paths.

To sanity-check it fresh for this post, I ran an entangled `rx`/`ry`/`rz` circuit on both backends at 20,000 shots:

![clifft-qiskit versus qiskit-aer on an entangled rotation circuit at 20,000 shots: the two backends' measured outcome probabilities agree to within 0.6 percentage points, well inside the test suite's 5-point tolerance](https://cdn.hashnode.com/uploads/covers/637e3426361eabd5d57eac79/f7748b82-acb2-407f-950d-4f16adf77bb0.png)

The largest per-outcome gap between clifft and Aer was **0.6 percentage points**, which is sampling noise at this shot count, not a modelling difference, and it is an order of magnitude inside the suite's own 5-point tolerance. On the circuits both tools can represent, the distributions match, because clifft is computing the same exact result by a cheaper route. The unit conversion, the bit packing, and the transpilation basis are all checked at once by a test that would fail if any of them were wrong.

## From a subpackage to a package

The provider first landed inside the clifft repo as an optional `clifft[qiskit]` subpackage ([PR #120](https://github.com/unitaryfoundation/clifft/pull/120)). During review, the decision was to pull it out into a standalone repository and package, [clifft-qiskit](https://github.com/unitaryfoundation/clifft-qiskit), rather than keep it in-tree. The reasoning is the same "keep clifft dependency-light" rule from the design, seen from the other side:

- clifft is a compiled C++ core with Python bindings, released on its own cadence. Qiskit's API moves faster and is pure-Python. Binding the two release cycles together helps neither.
- A separate package lets the provider declare `qiskit>=2.0` and track it independently, while clifft keeps a minimal dependency surface and never imports Qiskit unless a user asks for the provider.
- This is the established pattern in the ecosystem: a lean simulator core with satellite integration packages (qiskit-aer, the pytket extensions) that each track one external SDK.

So the code moved, the package directory was renamed from `clifft/qiskit` to a flat `clifft_qiskit`, and the dependency became explicit rather than an extra:

```toml
[project]
name = "clifft-qiskit"
requires-python = ">=3.12"
dependencies = ["clifft>=0.4", "qiskit>=2.0", "numpy>=1.26"]
dynamic = ["version"]

[tool.hatch.version]
source = "vcs"                 # version comes from the git tag, not a hardcoded string
```

Nothing about the physics changed. What changed is that the provider became a thing you can `pip install` and version on its own.

## One tag ships the release

The last piece ([PR #3](https://github.com/unitaryfoundation/clifft-qiskit/pull/3)) was release automation, so that cutting a version is a single action rather than a manual checklist. The pipeline is driven by a git tag.

![The clifft-qiskit release pipeline: pushing a version tag builds the package and smoke-tests the wheel, publishes to TestPyPI always and PyPI only for a tag, then cuts a GitHub release from the changelog, with a manual run acting as a dry run that stops at TestPyPI](https://cdn.hashnode.com/uploads/covers/637e3426361eabd5d57eac79/d7d20136-d2f5-490c-a03e-4fd956fea7a3.png)

Push a `vX.Y.Z` tag and the workflow:

1. **Builds** with `uv build`, runs `twine check`, and does a **wheel smoke test** - it installs the freshly built wheel into a clean environment and runs the test suite against it, so a broken wheel fails before it can be published, not after.
2. **Publishes to TestPyPI** on every run, using OIDC **trusted publishing**, so there are no PyPI API tokens stored in the repo at all.
3. **Publishes to PyPI**, but only when the trigger is a tag push. A manual `workflow_dispatch` run deliberately stops at TestPyPI, which makes "rehearse the release" a safe, first-class operation.
4. **Cuts a GitHub release**, extracting the matching section from `CHANGELOG.md` as the notes.

Two supporting pieces make that sustainable. Versioning is derived from the tag by `hatch-vcs` instead of a string someone has to remember to bump (an early review caught a hardcoded `"0.1.0"` in the backend; the fix was to read the version from package metadata so it can never drift from the tag). And the changelog is generated by `git-cliff` from conventional-commit messages, with a CI check that enforces conventional PR titles, so the release notes assemble themselves from the commit history rather than being written by hand each time. The CI matrix runs Python 3.12 and 3.13 against both a pinned and an upgraded dependency set, so a breaking change in a future Qiskit shows up as a scheduled-run failure rather than a surprise in production.

The result of all of it is `clifft-qiskit 0.1.0` on PyPI: `pip install clifft-qiskit`, and the four lines from the top of this post run a Qiskit circuit on a near-Clifford simulator.

## What I took away

- **A good provider is a thin adapter, and thinness is the goal.** The only code this package really owns is a gate-name table and a bit-packing loop. Everything hard - decomposition, the physics, the exactness - is delegated to Qiskit's transpiler and clifft's core. Recognizing what *not* to build was most of the work.
- **Exactness lives in the engine, so expose it, do not approximate it.** Putting `rx`/`ry`/`rz` in the basis, because clifft supports them natively, is what lets the transpiler avoid Solovay-Kitaev entirely. A weaker basis would have made every rotation an approximation for no reason.
- **The boundary between two ordering conventions is where the bugs are.** The gate map is trivial; the measurement-column-to-clbit mapping and the little-endian packing are where correctness is won or lost, and they are worth a dedicated test each.
- **Validate distributions, not shapes.** Checking every outcome's probability against Aer catches the radians-versus-half-turns class of bug that a "did it return counts" test never would.
- **Extract a package when the cadences diverge.** A compiled core and a fast-moving SDK wrapper want different release cycles; splitting them keeps the core's dependency surface honest.
- **Make releasing routine.** A tag-triggered build, a wheel smoke test, trusted publishing with no stored secrets, TestPyPI before PyPI, and an auto-generated changelog reduce "cut a release" to pushing one tag, which is about as much attention as the step deserves.

If you want to read the code, start at `clifft_qiskit/backend.py` (the `BackendV2` and the `run` pipeline), then `clifft_qiskit/_translate.py` (the gate table, the angle conversion, and the counts packing). The package is on [PyPI](https://pypi.org/project/clifft-qiskit/), the source is on [GitHub](https://github.com/unitaryfoundation/clifft-qiskit), and clifft itself is documented at [unitaryfoundation.github.io/clifft](https://unitaryfoundation.github.io/clifft/).

*Corrections and improvements welcome, especially from the clifft maintainers, who know the core far better than I do. Built during unitaryHACK 2026.*

***

## Discussion

No replies yet.
