Data-driven differential testing
This is the runnable foundation for data-driven / equivalence-partition
testing of the quantum processors: define a table of input combinations once,
fan it out to every framework branch with identical parameters, and (later) vote
on the results. This document covers the two pieces that exist today —
parameter externalization and QuantumTestCaseSource — and the canvas
wiring that connects them. The K-way consensus oracle is the next layer.
1. Parameter externalization (the attribute contract)
A separate processor on the wire can only touch the FlowFile (content +
attributes), never another processor's configured properties. The bridge is
NiFi Expression Language: a property declared with
ExpressionLanguageScope.FLOWFILE_ATTRIBUTES can be set on the canvas to
${attr} and is then resolved from a FlowFile attribute at run time.
To let one test row drive a processor, set its properties to EL referencing a
shared attribute namespace. The Grover family now has every knob
EL-enabled (Marked State, Num Iterations, Shots, Insert Barriers,
Output Format):
| Attribute | Drives |
|---|---|
grover.marked_state |
Marked State property |
grover.num_iterations |
Num Iterations property |
grover.shots |
Shots property |
grover.insert_barriers |
Insert Barriers property |
circuit.output_format |
Output Format property |
On the canvas, set e.g. Num Iterations = ${grover.num_iterations}. Because the
literal default is preserved, a processor used without these attributes still
behaves exactly as before. Use the replaceEmpty form to keep a fallback:
${grover.num_iterations:replaceEmpty('1')}.
Since the contract is just attribute names, the same test table drives the Qiskit, Cirq, and Qrisp branches identically — that is what makes the frameworks comparable in a single run.
2. QuantumTestCaseSource
Expands a compact Test Matrix into a JSON array of test-case rows (one trigger FlowFile in → one table out). Two spec shapes, detected by JSON type:
- Parameter axes (JSON object) — Cartesian product:
json {"grover.marked_state": ["00", "11", "000"], "grover.num_iterations": ["1", "2"]}→ 6 cases. - Explicit table (JSON array) — each element is one case used verbatim, so
you can attach a per-row
test.expected(ground-truth oracle):json [{"grover.marked_state": "0000", "test.partition": "all-zeros", "test.expected": "0000"}, {"grover.marked_state": "1", "test.partition": "n=1", "test.expected": "1"}]
Every row is stamped with bookkeeping fields (never clobbering a value the row sets itself):
test.case_id—{prefix}-000,{prefix}-001, … (stable, ordered)test.run_id— shared by all rows of one generation; the correlation key the future consensus oracle uses to group the K framework results of one runtest.partition— equivalence-class label (per-row, or the static property)
2a. Arithmetic case manifests (Case Input Mode)
Arithmetic Suite states a rule for picking operand pairs, so the canvas
shows boundary:2 and not the seven pairs it selects. For a campaign whose
inputs a reader has to be able to check, Case Input Mode adds two modes that
state the pairs themselves. The default is legacy-auto, which reproduces the
historical precedence exactly — every existing canvas keeps working untouched.
| Mode | Reads | Use it for |
|---|---|---|
legacy-auto (default) |
non-blank Arithmetic Suite, else Test Matrix |
everything that already exists |
generated-suite |
Arithmetic Suite, required |
making the generated path explicit |
inline-arithmetic-json |
the Arithmetic Cases property |
a readable demonstration — the cases are on the processor |
flowfile-content |
the incoming FlowFile | a reviewed, version-controlled file, fetched with a stock FetchFile |
For example, save the following manifest as arithmetic-cases.json and load
it through FetchFile, or paste it into the Arithmetic Cases property:
{
"schema": "quanifi.arithmetic-cases/v1",
"suite_id": "arithmetic-campaign1-boundary2-v1",
"operation": "add",
"bit_width": 2,
"cases": [{"a": 0, "b": 0}, {"a": 0, "b": 3}, {"a": 3, "b": 0},
{"a": 3, "b": 3}, {"a": 1, "b": 3}, {"a": 1, "b": 1},
{"a": 3, "b": 1}]
}
A case declares operands and nothing else. Expected answers, carry
classification and test.partition are derived by arithmetic_spec.make_case()
— the same function a generated suite goes through — so a typo in a hand-edited
file cannot become the oracle. Version 1 is a closed schema: any unknown
top-level or per-case key is a rejection, which is what stops
"expected_result": 5 from quietly becoming a second source of truth. Array
order is significant and is preserved.
Both explicit modes reject the whole input rather than emitting a prefix of it,
and neither falls back to the other property on error — a malformed file cannot
silently run a stale definition. The offending bytes come back on failure with
testsource.error and a stable testsource.error_code.
Three digests, three different questions
| Digest | Covers | Changes when |
|---|---|---|
case set (testsource.case_set_sha256) |
operation, bit width, ordered pairs | a case is added, removed, changed or reordered |
manifest (testsource.manifest_sha256) |
the above plus schema and suite_id |
also when the file is relabelled |
raw (testsource.raw_sha256) |
the exact supplied bytes | also when whitespace or key order changes |
The case set is the scientific input identity. A different case-set digest
is a different test-case condition, and its results must not be pooled with a
campaign pinned to the old one. The converse does not follow: the same digest is
necessary but not sufficient for pooling — the operator, implementation set,
loci, shots, layout policy, device and blocking rules must match too. Renaming
suite_id moves the manifest digest but not the case-set digest, and is not by
itself a new treatment.
A generated suite gets a case-set digest as well (after derivation), which is
how the generated and manifest paths can be shown to be the same treatment. It
has no manifest or raw digest and keeps its existing
testsource.suite/.bit_width/.suite_seed metadata instead.
Fail-closed guards
Expected Case Count, Expected Case Set SHA-256 and
Expected Manifest SHA-256 are blank by default and refuse to emit any row
when they do not match. A hardware campaign should set all three: the batch size
downstream is a static number, so a quietly changed case list leaves the
submitter waiting forever for a circuit nobody will build.
Expected Case Count checks testsource.base_case_count, the count before
any Layer-B mutation expansion. testsource.count keeps its old meaning (rows
emitted, after expansion). They are equal on the arithmetic canvas because every
catalogued Layer-B operator targets a grover.*/circuit.*/sim.* attribute
that an arithmetic row does not carry — gate mutations there come from
QuantumMutator, not from this processor.
All the new properties are declared ExpressionLanguageScope.NONE. They are
campaign controls, and an incoming FlowFile attribute must not be able to
rewrite which cases an armed batch runs. NiFi Parameter Contexts still
substitute into them at deployment time.
Provenance
Source-level: testsource.input_mode, testsource.case_set_sha256,
testsource.base_case_count, and — explicit modes only —
testsource.manifest_sha256, .raw_sha256, .schema, .suite_id.
The same identity is published a second time under the arithmetic. prefix
(arithmetic.test_case_set_sha256, .test_manifest_sha256, .test_suite_id,
.test_suite_schema) plus a per-row arithmetic.test_case_ordinal. That
duplication is load-bearing: the batch submitter captures everything under its
Attribute Prefix onto the manifest entry, so only the arithmetic.* copies
survive into a hardware result. The ordinal records the declared position —
parallel branch arrival and the submitter's kind-based interleave decide the
actual submission order, which batch.entry_index records.
3. Canvas wiring
GenerateFlowFile # any trigger; content ignored
-> QuantumTestCaseSource # Test Matrix -> JSON array of rows
-> SplitJson # JsonPath Expression = $ (array -> one FF per row)
-> EvaluateJsonPath # Destination = flowfile-attribute, one entry per field:
| # grover.marked_state = $['grover.marked_state']
| # grover.num_iterations = $['grover.num_iterations']
| # test.case_id = $['test.case_id']
| # test.run_id = $['test.run_id']
| # test.expected = $['test.expected'] (if present)
+-> QiskitGroverCircuit -> QiskitAerSimulator ----.
+-> CirqGroverCircuit -> CirqSimulator ---------+--> (consensus oracle, next layer)
+-> QrispGroverSearch ---------------------------/
Each framework branch sets its properties to ${grover.*} EL, so all three run
the same case. QuantumMutator, the gate-level mutator, placed between one branch's builder and simulator makes
that branch the dissenter the oracle should flag.
Equivalence partitions worth seeding
The Grover code paths suggest natural partitions / boundaries: n = 1
(degenerate diffuser), all-zeros vs all-ones vs mixed marked_state (different
X-layer paths), and optimal vs over-rotated num_iterations (the over-rotated
row doubles as a mutant).
Gotchas for the (future) vote
- Bit-order normalization. Qiskit counts are little-endian; Cirq/Qrisp use a different convention. The consensus oracle must canonicalize distribution keys before comparing, or it will report false disagreements.
- Shot noise. Compare on argmax or a distance threshold, never equality.