Skip to the content.

Architecture

The core idea

Qloop separates circuit content (what a circuit does, and what “correct” means for it) from pipeline mechanics (how correctness gets checked). A circuit author writes one file implementing a small contract; the framework’s generic tests iterate over every registered circuit and run whichever checks that circuit’s contract implementation makes possible. Nothing in the test suite, CI config, or a registration list needs to change when a circuit is added.

This is possible because of three things working together: the CircuitSpec contract, the auto-discovery registry, and generic (registry-driven) test files instead of one test file per circuit.

CircuitSpec — the plugin contract

Defined in qloop/core/spec.py. Every circuit plugin subclasses this.

class CircuitSpec(ABC):
    name: ClassVar[str]
    n_qubits: ClassVar[int]
    tags: ClassVar[list[str]] = []

    @abstractmethod
    def build(self, **params) -> QuantumCircuit: ...

    def reference_state(self, **params) -> Statevector | None: ...      # Stage 2
    def reference_expectation(self, **params) -> float | None: ...      # Stage 2
    def invariants(self) -> list[Invariant]: ...                        # Stage 3
    def invariants_for(self, **params) -> list[Invariant]: ...          # Stage 3
    def param_space(self) -> ParamSpace: ...                            # Stage 3 (fuzzing)
    def budget(self) -> Budget: ...                                     # Stage 4
    def expected_distribution(self, **params) -> dict[str, float] | None: ...  # Stage 5
    def stim_program(self, **params): ...                               # Stage 3b (Clifford/Stim)
    def stim_param_space(self) -> ParamSpace: ...                       # Stage 3b

Only build(), name, and n_qubits are required. Every other method has a default (None, [], or ParamSpace.empty()) that causes the corresponding pipeline stage to skip visibly for that circuit — never a silent pass. This is the framework’s central design invariant: a missing declaration is always observable in test output, never invisible.

Supporting types

The registry — auto-discovery

qloop/core/registry.py. A lazy singleton (registry, module-level). On first access (registry.all(), registry.get(), registry.by_tag()):

  1. pkgutil.iter_modules walks qloop/circuits/, importing every module.
  2. CircuitSpec.__subclasses__() (recursively) finds every subclass.
  3. Filters: __module__ must start with qloop.circuits. (so test-helper subclasses defined elsewhere never leak in), the class must be concrete (no unimplemented abstract methods), and it must have name/n_qubits set.
  4. Each surviving subclass is instantiated and validated (name non-empty, n_qubits positive, build() returns a QuantumCircuit) and registered. A duplicate name raises immediately.

Two files in qloop/circuits/ are not circuits — _qaoa_common.py and _bb_code_common.py hold logic shared between related circuit pairs (qaoa-maxcut/qaoa-ring, bb-code-72/bb-code-144). They define no CircuitSpec subclass, so discovery silently skips them. This is the supported pattern for sharing code between plugins without violating “one file per circuit.”

The six pipeline stages

# Stage Driven by Skip condition
1 Lint & static ruff check . n/a
2 Exact verification reference_state() / reference_expectation() Both None, or n_qubits > MAX_STATEVECTOR_QUBITS
3 Property tests invariants() + invariants_for() + param_space() param_space() empty → only default-params check runs
3b Clifford/Stim stim_program() + stim_param_space() stim_program() returns None
4 Transpilation matrix budget() Never skips — every registered circuit gets a budget
5 Noisy simulation expected_distribution() None
6 Hardware smoke qloop/pipeline/run.py::submit_hardware Stubbed; nightly only, continue-on-error: true

Stages 1–5 (plus 3b) gate every PR via tests/generic/*.py, each parametrized over registry.all() × targets (or just registry.all() for stages 2/3/3b). Stage 6 runs nightly.

Why stage 3b (Stim) exists

Two circuits in the corpus (bb-code-72, bb-code-144) are 144 and 288 qubits — far beyond 2^n-amplitude statevector simulation, but Clifford-only, so a polynomial-time stabilizer simulator (Stim) can verify them exactly. stim_program(**params) returns a stim.Circuit with DETECTOR instructions; tests/generic/test_stim_all.py asserts detectors are quiescent absent an injected error and fire when one is injected. stim_param_space() is declared separately from param_space() because they drive genuinely different sweeps (Stim error-injection parameters vs. Hypothesis-fuzzed circuit parameters) — a circuit could in principle want both.

Stage 4 in more depth: budgets are per-target, not per-circuit

coupling_map_for(target, n_qubits) in qloop/backends/noise.py generates a CouplingMap scaled to the circuit: CouplingMap.from_heavy_hex(d) for a d large enough to cover n_qubits, CouplingMap.from_full(n_qubits) for all-to-all. (For n_qubits at or below the size of the four original seed circuits, it falls back to small fixed maps for backward compatibility — this only matters if you’re reading old code, not for new circuits.) Because Budget.for_target() only distinguishes heavy-hex from everything else, a circuit’s sim-ideal and sim-noisy-alltoall budgets share the same (non-_limited) fields — this catches people off guard the first time (see color_832_ccz.py’s budget comment for a worked example where sim-ideal leaves a StatePreparation gate undecomposed, giving trivial depth, while sim-noisy-alltoall forces full decomposition to a much larger depth using that same budget field).

qloop/backends/ and qloop/pipeline/

Test suite structure

tests/
  test_framework/     Registry unit tests (discovery, dedup, by_tag) +
                       contract tests (every registered spec satisfies
                       CircuitSpec's structural requirements)
  generic/             The registry-driven tests: test_exact_all.py,
                       test_properties_all.py, test_stim_all.py,
                       test_transpile_all.py, test_noisy_all.py — each
                       iterates registry.all() (× targets where relevant)
  test_exact/          Original hand-written tests for bell/grover (predate
  test_properties/     the framework; kept for continuity, not required
  test_transpile/      for new circuits — the generic/ tests already cover
  test_noisy/          them)

If you’re adding a circuit, you never touch anything under tests/. See Adding a circuit.

What deliberately isn’t built

A few things came up during development that were consciously not built, and it’s worth knowing why rather than assuming they’re oversights:

This pattern — state what wasn’t done and why, rather than silently shipping an unverified reconstruction — comes directly from a real bug caught during development: an early bivariate-bicycle syndrome-extraction schedule looked correct by a simple qubit-conflict argument, but direct Stim simulation showed it produced non-deterministic syndromes. Conflict-freedom was necessary but not sufficient. The fix was verification, not more careful reasoning by inspection.