Static analysis · Qiskit · Soufflé Datalog

LintQ in Soufflé

The LintQ quantum-program correctness checks (Paltenghi et al., 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.

05Run 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.

06Source & 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, Moriconi, et al. LintQ: A Static Analysis Framework for Qiskit Quantum Programs. FSE 2024. The original implementation targets CodeQL; this project is an independent Soufflé reimplementation for dependency-free, sub-second linting.