QisDebug
Zero-touch instrumentation and timeline debugging for Qiskit.
QisDebug attaches full circuit and execution visibility to your existing Qiskit code the way OpenTelemetry or Sentry attach to a web framework: one import, one activation call, and no changes to how you build, transpile, or run circuits. Everything that happens to a circuit (every gate appended, every transpiler pass, every statevector along the way, and the final results) is recorded onto a single per-circuit timeline you can inspect, export, or explore in a native GUI window or an interactive terminal viewer.
import qisdebug
debug = qisdebug.QisDebug()
debug.start()
# --- your existing Qiskit code, completely unchanged -----------------
from qiskit import QuantumCircuit, transpile
from qiskit_aer import AerSimulator
qc = QuantumCircuit(2, 2, name="bell")
qc.h(0)
qc.cx(0, 1)
qc.measure([0, 1], [0, 1])
backend = AerSimulator()
counts = backend.run(transpile(qc, backend), shots=1000).result().get_counts()
# ----------------------------------------------------------------------
print(debug.get(qc).summary())
debug.end()
Timeline 'bell' (id=ae87255e3948, 2 qubits, 2 clbits, 42 events)
gate_added 4
transpile_started 1
transpile_pass 31
transpile_finished 1
execution_submitted 1
state_snapshot 3
execution_finished 1
The three state snapshots are the actual simulator statevectors after each
gate: |00⟩, then (|00⟩+|01⟩)/√2, then the Bell state. QisDebug captured
them during the run you already made and stripped them from the results you
see.
Install
pip install qisdebug # core (requires qiskit>=1.0)
pip install "qisdebug[aer]" # + statevector capture on Aer
pip install "qisdebug[viz]" # + histogram/Bloch-sphere image rendering
pip install "qisdebug[gui]" # + native GUI timeline viewer
pip install "qisdebug[mcp]" # + MCP server for AI-agent debugging
Without an extra, the matching feature degrades instead of failing: the
timeline still records, the viewer falls back to the terminal, and
qisdebug-mcp prints what to install. qisdebug version shows which extras
are installed. See
INSTALL.md for
installing from source and the development extras.
What gets recorded
| Phase | Event types | How |
|---|---|---|
| Construction | gate_added |
Wraps QuantumCircuit.append/_append and in-place compose; records each instruction with qubits, clbits, and params after broadcasting resolves |
| Transpilation | transpile_started, transpile_pass, transpile_finished |
Wraps PassManager.run and injects a per-pass callback through Qiskit's own callback mechanism; transpile_pass carries a property_changes diff too |
| Execution | execution_submitted, state_snapshot, execution_finished |
Wraps AerBackend.run, BasicSimulator.run, and the Sampler/Estimator primitives; on Aer, runs an instrumented copy with save_statevector after every gate and strips the snapshots from your results. Samplers record their counts |
| Diagnostics | note |
QisDebug explains itself, e.g. why snapshot capture was skipped for a circuit |
All three phases land on one timeline per circuit: transpiled outputs inherit the source circuit's hidden id, so the run of a transpiled circuit is recorded against the circuit you actually wrote.
Public API
QisDebug is a class, not a module-level singleton: each instance is an
independently scoped instrumentation session with its own telemetry and its own
configuration, so more than one can be started at once (e.g. one scoped to a
suspect region, another covering a whole script) without either seeing the
other's timelines.
| Method | Purpose |
|---|---|
debug.start() |
Attach all instrumentation layers. Idempotent. Returns an ActivationReport saying which layers attached |
debug.end() |
Restore every original Qiskit method this instance was using |
debug.active |
Whether this instance's patches are currently applied |
debug.get(qc) |
The Timeline for a circuit, name, or Timeline; raises TimelineNotFoundError on a miss |
debug.timelines() |
Every timeline this instance has recorded, most recently touched first |
debug.circuits() |
The names of tracked circuits, most recently touched first |
debug.clear() |
Drop all recorded telemetry without touching active patches |
debug.configure(...) |
Adjust limits and switches (validated; see below) |
debug.diff(a, b, ...) |
Gate-alignment diff plus statevector/counts/depth deltas between two points in time; see below |
Importing qisdebug has no side effects. Nothing is patched until start()
is called, and with QisDebug() as debug: is an optional shorthand for
start()/end() around a scoped block.
Timeline
tl = debug.get(qc)
tl.events # every event, in true order (monotonic seq numbers)
tl.gate_events # construction events
tl.transpile_events # transpilation events
tl.execution_events # submission / snapshots / completion
tl.snapshots # state snapshots, each with .amplitudes() / .probabilities()
tl.circuit_at(n) # rebuild the circuit, registers included, as it looked after its first n gates
tl.summary() # compact text overview
Exporting timelines to JSON-serializable dicts (amplitudes as [re, im] pairs)
goes through the QisDebug instance too, with the same argument shapes as
show(). It always returns a list, even for a single circuit, so callers never
need to branch on the shape of the result:
debug.export() # every timeline this instance has recorded, as a list of dicts
debug.export(qc) # just this circuit's timeline, as a one-item list
debug.export([qc1, qc2]) # several circuits, as a list of dicts
Diffing two points in time
diff = debug.diff(qc_a, qc_b) # or debug.diff(qc, qc, seq_a=10, seq_b=40)
diff.gate_diff # aligned insert/delete/replace over gate_added events
diff.statevector_diff, diff.counts_diff # fidelity / total variation distance, when both sides have one
diff.depth_a, diff.depth_b # depth/size deltas from each side's last transpile event
a/b take the same circuit/name/Timeline shapes as debug.get();
seq_a/seq_b bound either side to "as of that event's seq" instead of the
whole timeline, so the same circuit can be diffed against an earlier moment of
its own replay. The interactive frontends (below) expose the same comparison as
"Mark A"/"Mark B" over the live timeline view.
Configuration
| Option | Default | Meaning |
|---|---|---|
max_circuits |
256 |
Tracked-circuit cap; least recently touched timelines are evicted first |
capture_statevectors |
True |
Insert statevector snapshots on Aer runs |
max_statevector_qubits |
12 |
Skip capture for wider circuits (snapshot memory grows as 2^n) |
max_snapshots_per_circuit |
256 |
Stop inserting snapshots past this count |
transpile_snapshots |
"changed" |
Keep full circuit snapshots for "all" passes, only "changed" ones, or "none" |
capture_property_set |
"changed" |
Diff/snapshot property_set for "all" passes, only "changed" ones, or "none" |
The timeline viewer
Everything is shown through the QisDebug instance itself:
debug.show() # every timeline this instance has recorded
debug.show(qc) # just this circuit's timeline
debug.show([qc1, qc2]) # several circuits, merged into one view
show() opens a native GUI window (HTML/CSS rendered through the OS webview
via pywebview) when a display is available, and falls back to a text-based
interactive CLI otherwise. Force either with debug.show(frontend="gui"|"cli")
or the QISDEBUG_FRONTEND environment variable. Either way, a slider/stepper
walks the full event sequence; each position shows the circuit as it existed at
that moment (drawn with Qiskit's own drawer), the statevector with amplitudes
and probabilities, or per-pass transpiler statistics. Both frontends read only
the public Timeline fields, so the instrumentation and the visualizer can
evolve independently.
Pin the event currently on screen as one side of a diff ("Mark A"/"Mark B" in
the GUI, mark a/mark b in the CLI). Once both are pinned, the diff between
them (the same comparison debug.diff() returns) renders automatically: a
per-gate diff, a side-by-side circuit-drawing split view, a summary across
every element common to both marked points, and a diff of any currently active
watch.
Debugging with an AI agent (MCP)
The same telemetry both viewers read is also exposed to coding agents through a Model Context Protocol server, so an agent can debug the runtime behavior of your circuits instead of reasoning about the code blind:
pip install "qisdebug[mcp,aer]"
claude mcp add qisdebug -- qisdebug-mcp # or the equivalent for your agent
The division of labor is deliberate. QisDebug does not try to decide what a quantum program should do; the agent does. QisDebug extracts the ground truth and does the arithmetic:
- Run under instrumentation.
run_scriptexecutes a script with QisDebug active and reports every circuit's id, registers, and an anomaly scan in one shot. To focus on a suspect region, the agent has the script build its own scopedqisdebug.QisDebug()and callstart()/end()around it, passingauto_activate=falseso this server's own instance stays untouched. - Extract mid-execution state.
get_events(filtered, paged),get_state_snapshot(raw amplitudes and probabilities at any captured step),get_circuit_text(the circuit as it looked after any gate), andget_property_set(the full layout/routing property_set a specific transpiler pass wrote;get_eventssurfaces the added/removed/changed keys inline asproperty_changes). - Watch what matters.
add_watchpins qubits, clbits, or registers;read_watchesreports their gate history, marginal P(1), and Bloch vector as of any point in the sequence. It is the same watch feature the GUI and CLI expose interactively. - Compare two points in time.
diff_timelines(circuit_id_a, circuit_id_b, seq_a=..., seq_b=...)reports the same gate-alignment plus statevector/counts/depth diff asdebug.diff(), for two different circuits or two moments of the same circuit's replay. - Check an expectation. The agent supplies the state or distribution it
believes the program should produce;
compare_statevectorandcompare_countsreport fidelity, total variation distance, and exactly which basis states or outcomes diverge.find_anomaliesis expectation-free and catches only impossible states (non-normalized or non-finite statevectors, failed runs, transpile blow-ups). - Keep the evidence.
save_timelinewrites the full JSON export to a file;set_configurationtunes capture depth before a run.
The server is a thin protocol wrapper: every tool delegates to the same store, rendering, and comparison code the GUI and CLI use.
Conditional breakpoints
Everything above is passive: it tells you what already happened. Conditional
breakpoints pause a live run the instant a condition matches, so you can
inspect state mid-execution instead of only replaying a finished timeline.
See examples/breakpoints.py
for a runnable, end-to-end example (python examples/breakpoints.py).
A condition is one of a small closed set of kinds, never arbitrary code, so
there's no eval-style injection surface:
| Kind | Matches when... |
|---|---|
gate |
A named gate (optionally on specific qubits) is added to a circuit |
seq |
Any event reaches a given sequence number |
watch |
A watched qubit/clbit/register's value (P(1)) crosses a numeric threshold |
anomaly |
The next event find_anomalies would flag, at or above a chosen severity |
Python API
import qisdebug
from qisdebug import GateMatchCondition, WatchThresholdCondition
debug = qisdebug.QisDebug()
debug.start()
handle = debug.add_breakpoint(GateMatchCondition(gate="cx"))
# or: debug.add_breakpoint(WatchThresholdCondition(
# circuit_id=circuit_id, watch_kind="qubit", ref="0",
# comparator="gt", threshold=0.9))
# On the thread building/running the circuit, execution blocks the instant
# the breakpoint matches. From another thread (or the one driving the run):
hit = debug.wait_for_breakpoint(timeout=10) # -> BreakpointHit | None
print(hit.condition, hit.event_summary)
debug.resume() # release the paused thread
debug.remove_breakpoint(handle)
debug.list_breakpoints()
MCP (agent-driven stepping)
Live pausing needs the script to run in the background, since a pause blocks
the thread. run_script(..., background=True) returns immediately with a
run_id instead of waiting for the script to finish:
add_breakpoint({"kind": "gate", "gate": "cx"})
run_script("script.py", background=True) # -> {"run_id": "..."}
poll_run(run_id) # -> running | paused | finished | error | stopped
# when paused, poll_run's response includes which condition hit and on what event
resume_run(run_id)
stop_run(run_id) # best-effort; guaranteed only while paused
Interactive frontends
Breakpoints also work as a navigation aid over an already-captured timeline: "jump to next hit" walks recorded events instead of blocking anything live.
- CLI:
break gate cx,break seq 40,break watch qubit 0 gt 0.9,break anomaly warning;breakpointslists them,unbreak <handle>removes one, andcontinuejumps to the next matching event. - GUI: a condition-builder row (kind dropdown + argument field, same syntax as the CLI) next to the watch row, a list of active breakpoints, and a "Run to next breakpoint →" button.
Version compatibility
Every patch target is checked for existence before patching; a missing internal
downgrades that layer with a logged warning instead of an error, and start()
reports exactly which layers attached.
| Python | Qiskit | qiskit-aer | Status |
|---|---|---|---|
| 3.10–3.14 | latest | latest | Tested in CI on every pull request: Linux on all five, macOS and Windows on 3.10 and 3.14 |
| ≥3.10 | ≥1.0 | ≥0.14 | Expected to work; layers degrade gracefully |
Limitations
- Statevector capture requires Aer and is skipped (with a
noteevent explaining why) for circuits containing reset, initialize, mid-circuit measurement, control flow, noise instructions, orparameter_binds, and for circuits wider thanmax_statevector_qubits. Counts and timing are still recorded. - Per-pass transpile telemetry applies to single-circuit runs; transpiling a list of circuits records start/finish boundaries only (multi-circuit runs may fan out to worker processes).
- Primitives (
StatevectorSampler, AerSamplerV2, …) record submission, completion timing, and, for samplers, each circuit's counts, but no statevector snapshots. A sampler that runs through a backend, like Aer'sSamplerV2, is recorded once as the primitive call you made. - Circuits built before
start()only record events from that point on. qc.copy()carries the hidden id with it, so a copy reports onto the same timeline as its source.
Development
pip install -e ".[dev]"
pre-commit install
pytest
The suite covers each hook independently (construction, execution, transpilation), the telemetry store, and the activation lifecycle, including hypothesis property tests that any random gate sequence is recorded exactly and the store never exceeds its cap.
See CONTRIBUTING.md for the contribution workflow, the commit convention, and how releases work.
Documentation
The full documentation, including an API reference for every module, is at qisdebug.readthedocs.io, with a version for each release. To build it locally with Sphinx:
pip install -e ".[docs]"
cd docs
make html
Open docs/_build/html/index.html in a browser.
Prior art
Qiskit Trebugger
requires replacing transpile with a special debug call and covers only the
transpiler stage. CircInspect provides step-through debugging for PennyLane.
QisDebug attaches passively, with near-zero setup, to unmodified Qiskit code
and covers the full lifecycle, from construction through transpilation to
execution.
Citing
If QisDebug helps your research, please cite it. The metadata lives in
CITATION.cff,
so GitHub's "Cite this repository" button gives a ready-made BibTeX or APA
entry. The DOI 10.5281/zenodo.23373538
always resolves to the latest release; Zenodo also lists a DOI for each
individual version, which is the one to cite if you need to pin the exact
release you used.
@software{upadhyay_qisdebug,
author = {Upadhyay, Krishna},
title = {{QisDebug: Zero-Touch Instrumentation and Timeline Debugging for Qiskit}},
doi = {10.5281/zenodo.23373538},
url = {https://github.com/kriss-u/QisDebug},
license = {MIT}
}
License
MIT
Metadata
Release files for qisdebug 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| qisdebug-0.2.0.tar.gz | 189.1 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| qisdebug-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 349.5 kB
Release files / qisdebug-0.2.0.tar.gz
| Download URL | qisdebug-0.2.0.tar.gz |
|---|---|
| Size | 189.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cdb837fb8344a3a20d57b050f6c4fe78d39f3fcd6fec61da39638407526fa65f
|
|
BLAKE2b-256 checksum How to use checksums |
3b61af76f062510ec6818902299f78b442e544b0b50a6344214ad570a4a02a80
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logRelease files / qisdebug-0.2.0-py3-none-any.whl
| Download URL | qisdebug-0.2.0-py3-none-any.whl |
|---|---|
| Size | 160.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
145b73cd8b33380b96234f1094b687f9e3b2051e22cc617b8787c474ea9b2417
|
|
BLAKE2b-256 checksum How to use checksums |
5111d04cf10bdf3caa06691ea34a08e333b5f6f5a4ea2390a49a17a2c4499536
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logSecurity for qisdebug 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for qisdebug-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.