Static analysis · Qiskit · Soufflé Datalog

LintQ in Soufflé

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.

Soufflé 2.5 ✓ verified extractor: 13/13 unit tests ✓ 2 engine schemas OpAfterMeas · DoubleMeas · MissingReset · GhostCompose

01Overview

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.

Two complementary reimplementations were produced (each independently verified with Soufflé 2.5):
  • 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.

02Architecture

A target Qiskit script is lowered to tab-separated EDB facts, reasoned over by the Soufflé core engine, and emitted as diagnostic CSVs.

Qiskit scripttarget .py
Python fact extractorast.NodeVisitor → .facts (TSV)
EDBStmt · CFGEdge · GateOp · MeasureOp · Assign · CircuitAlloc · CircuitCall
Soufflé engineCFG closure · quantum analyses
WarningsWarn*.csv

Frontend — extractor/ast_to_facts.py

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

Backend — rules/lintq.dl & engine/lintq.dl

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

Control-flow graph of the sample circuit

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.

flowchart LR s1[1: qc = QuantumCircuit] --> s2[2: qc.h 0] s2 --> s3[3: qc.cx 0,1] s3 --> s4[4: qc.h 0] s4 --> s5[5: qc.cx 0,1] s5 --> s6[6: qc.measure 0,0] s6 --> s7[7: qc.measure 0,0 ## DoubleMeas] s7 --> s8[8: qc2 = QuantumCircuit] s8 --> s9[9: qc2.h 0] s9 --> s10[10: qc2.measure 0,0] s10 --> s11[11: qc2.h 0 ## OpAfterMeas] s11 --> s12[12: sub = QuantumCircuit] s12 --> s13[13: sub.h 0] s13 --> s14[14: qc.compose sub ## GhostCompose]

Diagram rendered with Mermaid. If it does not display, the same control flow is the CFGEdge.facts edge list shown in §04.

03The analyses

Four correctness checks across the two engines. Each rule is shown from the actual .dl source.

1 · OpAfterMeas — gate after a measurement

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

2 · DoubleMeas — redundant consecutive measurement

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

3 · MissingReset — use of an uninitialised qubit

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

4 · GhostCompose — discarded compose() return

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

04Real sample warnings

Both engines were run on real inputs and produced exactly the expected diagnostics. No false positives on clean programs.

The buggy program

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

Extracted facts (extractor schema)

# 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

Warnings emitted (engine/, extractor schema)

CheckOutput rowMeaning
OpAfterMeas11  qc2  h  0  10 stmt 11 (qc2.h(0)) on qubit 0 after measure at stmt 10.
DoubleMeas7  qc  0  6 stmt 7 (qc.measure(0,0)) re-measures qubit 0; first measure at stmt 6.
GhostCompose14  qc  sub stmt 14 (qc.compose(sub)) discards its return value.

Rules engine on an independent fact set

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.

CheckOutput rowMeaning
OpAfterMeas4  0  3 op 4 (gate on q0) after measure 3, no reset between.
DoubleMeas6  7  1 meas 6 → meas 7 on qubit 1 with nothing between.
MissingReset8  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.

05Evaluation — 10-example detector benchmark

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.

How a result is presented

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.

Per-detector precision & recall

DetectorBug classEngineTPFPFNTNPrecisionRecallN/A excl.Limitation
A_OpAfterMeasOpAfterMeasA4000100%100%61
A_DoubleMeasDoubleMeasA1002100%100%70
A_GhostComposeGhostComposeA1001100%100%80
B_OpAfterMeasOpAfterMeasB3001100%100%60
B_DoubleMeasDoubleMeasB1002100%100%70
B_MissingResetMissingResetB2006100%100%20

✓ 10 examples · 6 detectors · 0 false positives · 0 false negatives · all precision = recall = 100%.

Eval set composition

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.

ExampleBug classRoleWhat the raw output shows
ex01OpAfterMeasFIREA & B both emit the post-measure gate row
ex02OpAfterMeasFALSE_POSA falsely fires (reset-blind); B correctly silent — the documented limitation
ex03OpAfterMeasFIRE (2-qubit)flags gate on measured q1, not unmeasured q0
ex06DoubleMeasFIREboth engines emit the redundant consecutive measure
ex07DoubleMeasSILENTreset between measures → both engines silent
ex10OpAfterMeas + DoubleMeascrosscx suppresses DoubleMeas, triggers OpAfterMeas
ex11MissingResetFIREB emits the first-op-is-not-reset row
ex14MissingResetFIRE (2-qubit)B flags uninitialised qubit via cx
ex15GhostComposeFIREA emits the discarded compose() return
ex16GhostComposeSILENTassigned 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).

06Run it

Prerequisites: Python 3.11+ and Soufflé 2.5.

Engine A — extractor → engine/

# 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
Gotcha: the extractor writes to 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).

Engine B — rules/

# 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
Note: Soufflé 2.5 requires the -D out directory to pre-exist — run.sh creates it before invoking souffle.

07Source & project layout

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

Reference. Paltenghi, M. and Pradel, M. Analyzing Quantum Programs with LintQ: A Static Analysis Framework for Qiskit. FSE 2024. DOI: 10.1145/3660802. The original implementation targets CodeQL; this project is an independent Soufflé reimplementation for dependency-free, sub-second linting.