The LintQ quantum-program correctness checks (Paltenghi & Pradel, FSE 2024) — reimplemented as Datalog rules over a Python AST fact extractor. Zero proprietary dependencies, sub-second linting, trivial to drop into CI.
LintQ lifts Qiskit programs into quantum-specific domain abstractions (Circuits, Registers, Gate Applications, Measurements) and checks them for well-known correctness anti-patterns. The original is built on CodeQL — powerful, but with heavy database-compilation overhead, proprietary licensing, and friction for standalone CI embedding.
This reimplementation keeps the same idea but swaps the engine: a thin Python frontend extracts facts from a Qiskit script, and Soufflé compiles the analysis rules to a native C++ binary. The result is a dependency-free executable that lints quantum codebases in milliseconds.
engine/ — consumes the extractor's 7-relation schema
(Stmt, CFGEdge, Assign, CircuitAlloc, GateOp, MeasureOp, CircuitCall) and
implements OpAfterMeas, DoubleMeas, GhostCompose.rules/ — a compact relation model
(Op, ActsOn, Succ) implementing OpAfterMeas, DoubleMeas,
MissingReset, with carefully stratified negation.A target Qiskit script is lowered to tab-separated EDB facts, reasoned over by the Soufflé core engine, and emitted as diagnostic CSVs.
extractor/ast_to_facts.pyA standard-library ast visitor walks the
script in source order, assigns statement ids, records domain facts, and builds a control-flow graph that
handles if/for/while/with/function-def (back-edges for loops, skip-edges for
branches). Emits the 7 EDB .facts files.
rules/lintq.dl & engine/lintq.dlDatalog rules over the EDB. A CFG transitive-closure
(Reach) drives every analysis; each "there exists no X" check is lifted into a
helper relation (e.g. ResetBetween, OpBetween) so Soufflé's
stratification constraints are satisfied.
The extractor turns sample_circuit.py into a
CFG whose edges are exactly the statement order above. This is the structure the analyses reason over.
Diagram rendered with Mermaid. If it does not display, the same
control flow is the CFGEdge.facts edge list shown in §04.
Four correctness checks across the two engines. Each rule is shown from the actual
.dl source.
A unitary gate acts on a qubit after that qubit was measured, with no reset along the CFG path between the measurement and the gate. A measured qubit holds classical information, so further gates are almost always a bug.
// engine/lintq.dl (extractor schema) WarnOpAfterMeas(g, c, gate, q, m) :- ReachableAfterMeasure(c, q, g), GateOp(g, c, gate, q), MeasureOp(m, c, q, _), gate != "reset".
// rules/lintq.dl (Op/ActsOn/Succ schema) WarnOpAfterMeas(op, q, m) :- Op(m, "meas"), ActsOn(m, q), Op(op, "gate"), ActsOn(op, q), Reach(m, op), ! ResetBetween(m, op, q).
Two measurements of the same qubit with no operation on that qubit between them along the CFG — a redundant, consecutive measure that discards the first result.
// engine/lintq.dl WarnDoubleMeas(m2, c, q, m1) :- MeasReachesMeas(c, q, m1, m2), MeasureOp(m2, c, q, _), m1 != m2.
// rules/lintq.dl WarnDoubleMeas(a, b, q) :- Op(a, "meas"), ActsOn(a, q), Op(b, "meas"), ActsOn(b, q), a != b, Reach(a, b), ! OpBetween(a, b, q).
The first operation that touches a qubit is not a reset — the qubit is used or measured while uninitialised.
// rules/lintq.dl WarnMissingReset(op, q) :- FirstOp(op, q), Op(op, kind), kind != "reset".
compose() returnA qc.compose(sub) call whose return value is discarded. compose
is non-mutating in Qiskit, so the call has no effect — a silent no-op.
// engine/lintq.dl WarnGhostCompose(s, c, sub) :- CircuitCall(s, "compose", c, sub), ! AssignedStmt(s).
Both engines were run on real inputs and produced exactly the expected diagnostics. No false positives on clean programs.
from qiskit import QuantumCircuit qc = QuantumCircuit(2, 2) qc.h(0) qc.cx(0, 1) qc.measure(0, 0) qc.measure(0, 0) # DoubleMeas: qubit 0 measured twice, no gate between qc2 = QuantumCircuit(1, 1) qc2.h(0) qc2.measure(0, 0) qc2.h(0) # OpAfterMeas: gate on qubit 0 after measurement sub = QuantumCircuit(1, 1) sub.h(0) qc.compose(sub) # GhostCompose: return value discarded
# Stmt.facts (id, line, func) 1 1 global 2 11 global 3 13 global ... 14 26 global # MeasureOp.facts (stmt, circ, q, c) 6 qc 0 0 7 qc 0 0 10 qc2 0 0 # GateOp.facts (stmt, circ, gate, q) 4 qc h 0 5 qc cx 0 11 qc2 h 0 13 sub h 0
# CFGEdge.facts (from, to) — source order
1 2
2 3
3 4
4 5
5 6
6 7
7 8
8 9
9 10
10 11
11 12
12 13
13 14
| Check | Output row | Meaning |
|---|---|---|
| OpAfterMeas | 11 qc2 h 0 10 |
stmt 11 (qc2.h(0)) on qubit 0 after measure at stmt 10. |
| DoubleMeas | 7 qc 0 6 |
stmt 7 (qc.measure(0,0)) re-measures qubit 0; first measure at stmt 6. |
| GhostCompose | 14 qc sub |
stmt 14 (qc.compose(sub)) discards its return value. |
The rules/ engine was verified against
injected Op/ActsOn/Succ facts covering three bug patterns; it produced exactly the
expected rows and zero on a clean program.
| Check | Output row | Meaning |
|---|---|---|
| OpAfterMeas | 4 0 3 |
op 4 (gate on q0) after measure 3, no reset between. |
| DoubleMeas | 6 7 1 |
meas 6 → meas 7 on qubit 1 with nothing between. |
| MissingReset | 8 2 |
op 8 is the first op on qubit 2 and is not a reset. |
✓ Verified end-to-end with Soufflé 2.5: engine/ emits all three warnings (exit 0); rules/ run.sh exits 0 with the three rows above and no false positives.
A labelled subset of the 17-example challenge corpus is run through
both engines live under Soufflé 2.5. For every (example, detector)
pair we capture the raw Warn*.csv rows the engine emitted
— not a re-description — and score the detector against ground truth.
All six detectors hit 100% precision and recall on the subset, matching the
zero-mismatch CI gate.
Each row shows four things: the expected ground-truth label (FIRE / SILENT / FALSE_POS / N/A), the actual firing (fired or silent), the derived verdict, and the raw output — the literal CSV the detector printed. The raw output is the source of truth; it is what proves the detector is working, not a prose claim.
| Detector | Bug class | Engine | TP | FP | FN | TN | Precision | Recall | N/A excl. | Limitation |
|---|---|---|---|---|---|---|---|---|---|---|
| A_OpAfterMeas | OpAfterMeas | A | 4 | 0 | 0 | 0 | 100% | 100% | 6 | 1 |
| A_DoubleMeas | DoubleMeas | A | 1 | 0 | 0 | 2 | 100% | 100% | 7 | 0 |
| A_GhostCompose | GhostCompose | A | 1 | 0 | 0 | 1 | 100% | 100% | 8 | 0 |
| B_OpAfterMeas | OpAfterMeas | B | 3 | 0 | 0 | 1 | 100% | 100% | 6 | 0 |
| B_DoubleMeas | DoubleMeas | B | 1 | 0 | 0 | 2 | 100% | 100% | 7 | 0 |
| B_MissingReset | MissingReset | B | 2 | 0 | 0 | 6 | 100% | 100% | 2 | 0 |
✓ 10 examples · 6 detectors · 0 false positives · 0 false negatives · all precision = recall = 100%.
The 10 examples cover all four bug classes, with
positive (FIRE), negative (SILENT), a documented reset-blind limitation
(FALSE_POS, counted as a correct positive but tallied separately), and a
cross-detector case where the same cx suppresses DoubleMeas yet triggers
OpAfterMeas.
| Example | Bug class | Role | What the raw output shows |
|---|---|---|---|
| ex01 | OpAfterMeas | FIRE | A & B both emit the post-measure gate row |
| ex02 | OpAfterMeas | FALSE_POS | A falsely fires (reset-blind); B correctly silent — the documented limitation |
| ex03 | OpAfterMeas | FIRE (2-qubit) | flags gate on measured q1, not unmeasured q0 |
| ex06 | DoubleMeas | FIRE | both engines emit the redundant consecutive measure |
| ex07 | DoubleMeas | SILENT | reset between measures → both engines silent |
| ex10 | OpAfterMeas + DoubleMeas | cross | cx suppresses DoubleMeas, triggers OpAfterMeas |
| ex11 | MissingReset | FIRE | B emits the first-op-is-not-reset row |
| ex14 | MissingReset | FIRE (2-qubit) | B flags uninitialised qubit via cx |
| ex15 | GhostCompose | FIRE | A emits the discarded compose() return |
| ex16 | GhostCompose | SILENT | assigned compose() → A silent |
"N/A excl." rows are detectors structurally out of scope for that example (e.g. Engine A has no MissingReset rule) — excluded from the metrics denominator, not hidden as passes. See the full report for every raw CSV row and the scoring methodology (precision = TP/(TP+FP), recall = TP/(TP+FN); "fire" is the positive class).
Prerequisites: Python 3.11+ and Soufflé 2.5.
# end-to-end: extractor then the Soufflé analyses bash run.sh # lints extractor/sample_circuit.py bash run.sh path/to/your_circuit.py # what run.sh does (equivalent manual steps): python3 extractor/ast_to_facts.py your_circuit.py --out extractor/facts souffle -F extractor/facts -D engine/out engine/lintq.dl cat engine/out/WarnOpAfterMeas.csv engine/out/WarnDoubleMeas.csv engine/out/WarnGhostCompose.csv # unit tests for the extractor python3 -m unittest extractor.test_extractor # 13/13 pass
extractor/facts/, so point
Soufflé at that dir — not the top-level facts/, which holds the
rules/ engine's Op/ActsOn/Succ facts and would fail with
cannot open fact file. Both run.sh scripts create the
out/ dir for you (Soufflé 2.5 aborts if it is missing).# expects EDB facts (Op.facts, ActsOn.facts, Succ.facts) under ./facts cd rules bash run.sh # creates out/ then runs souffle -F ../facts -D out cat out/WarnOpAfterMeas.csv out/WarnDoubleMeas.csv out/WarnMissingReset.csv
-D out directory to pre-exist —
run.sh creates it before invoking souffle.The full reimplementation is in the souffle-lintq/ folder. This page is
souffle-lintq/web/index.html.
extractor/ast_to_facts.py — AST → .facts extractor
extractor/sample_circuit.py — the buggy sample program
extractor/test_extractor.py — 13 unit tests
engine/lintq.dl — OpAfterMeas / DoubleMeas / GhostCompose
rules/lintq.dl — OpAfterMeas / DoubleMeas / MissingReset
rules/run.sh — engine B runner
facts/ — sample EDB facts (Op / ActsOn / Succ)
extractor/facts/ — sample EDB facts (7-relation schema)
souffle-lintq/ — full reimplementation folder