Support code-declared inlined feedback in memory circuits#665
Conversation
Codes may declare record->detector feedback corrections as data via two new virtuals (get_inlined_feedback / get_observable_inlined_feedback, empty default = existing behavior); memory_circuit folds the declared records into its detector and observable definitions, so measurement schemes with readout byproducts get deterministic detectors through sample_memory_circuit without a physical correction gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: kvmto <kmato@nvidia.com>
…d_feedback The record->detector composition rule now lives in detector_error_model as a CSR layout builder; the memory_circuit kernel is a thin emitter walking the layout. Behavior is bit-identical; all suites pass unchanged. Signed-off-by: kvmto <kmato@nvidia.com>
…amed device helpers The kernel keeps its flat-matrix signature; all record-composition logic lives in reusable __qpu__ functions in device/inlined_feedback.h. The host CSR layout builder (build_inlined_feedback_layout) is retained for record-stream consumers and pinned to the kernel by an M2D conformance test Signed-off-by: kvmto <kmato@nvidia.com>
Signed-off-by: kvmto <kmato@nvidia.com>
Validate feedback matrices before conversion, clarify raw sampling semantics, reject unsupported qpp-cpu Python sampling, and expand detector/observable conformance coverage. Signed-off-by: kvmto <kmato@nvidia.com>
eliotheinrich
left a comment
There was a problem hiding this comment.
Looks good, Kevin! I left a few comments; I think the only blocking issue is the Python API for build_inlined_feedback_layout, if I understand correctly.
| build_inlined_feedback_layout(const cudaqx::tensor<uint8_t> &feedback, | ||
| const cudaqx::tensor<uint8_t> &obs_feedback, | ||
| std::size_t num_syndromes_per_round, | ||
| std::size_t num_observables); |
There was a problem hiding this comment.
Is this function part of the public API? I'm a little confused what it's for, as it seems like the feedback tensors are usually accessed via something like flatten_feedback_tensor(code.get_inlined_feedback(), ...). If it is supposed to be exposed, it should probably have a Python API.
| inline __qpu__ std::vector<cudaq::measure_result> cross_round_detector_records( | ||
| const std::vector<cudaq::measure_result> &prev, | ||
| const std::vector<cudaq::measure_result> &curr, std::size_t j, | ||
| const std::vector<std::size_t> &feedback_flat, std::size_t num_cols) { | ||
| std::size_t weight = row_support_weight(feedback_flat, j, num_cols); | ||
| std::vector<cudaq::measure_result> det(2 + weight); | ||
| det[0] = prev[j]; | ||
| det[1] = curr[j]; | ||
| std::size_t idx = 2; | ||
| for (std::size_t k = 0; k < num_cols; ++k) | ||
| if (feedback_flat[j * num_cols + k] != 0) | ||
| det[idx++] = prev[k]; | ||
| return det; | ||
| } |
There was a problem hiding this comment.
This function is called one per numCol, and does two sweeps over numCols (once in row_support_weight and once on L41-L43). Might it make sense to operate on a sparse feedback_flat?
| cudaq::dem_from_kernel( | ||
| cudaq::qec::memory_circuit, &noise, /*options=*/{}, m2d, m2o, stabRound, | ||
| prep, numData, numAncx, numAncz, numRounds, xVec, zVec, obs_flat, num_obs, | ||
| /*measure_in_x_basis=*/false, feedback_flat, obs_feedback_flat); |
There was a problem hiding this comment.
I don't think end-to-end feedback for X-type circuits is exercised anywhere; could be cheap to add here
Problem
memory_circuithardcodes the record→detector combination rule: every detector is "same record slot, two consecutive rounds" plus fixed boundary closures. This assumes each returned ancilla measurement is already a clean stabilizer value. Measurement schemes whose readout carries record-conditioned byproducts — e.g. superdense (paired-ancilla) extraction, where one ancilla's outcome heralds a Pauli byproduct on part of the plaquette — cannot express their true detector parities. Today such codes must apply the correction as a physical gate (an extra noise location — on hardware this correction is a classical frame update, not a gate) or abandon the framework path entirely.Stim solves this with record-controlled Paulis plus
with_inlined_feedback(); the CUDA-Q kernel language has no equivalent, anddem_from_kernelrejects measurement-dependent branching. This PR provides the cudaqx-level resolution.Design
Make the record→detector combination rule a declarative property of the code, with the identity default preserving today's behavior exactly:
The code declares data, not behavior (device kernels cannot call host callbacks mid-trace; the data rides the same rail as the parity matrices):
virtual cudaqx::tensor<uint8_t> code::get_inlined_feedback() const— shape[numCols × numCols],numCols = num_ancilla_qubits,[Z][X]per-round record order. Entry(j, k) = 1: the cross-round detector for recordjadditionally XORs recordkof the earlier round; the final boundary detector for recordjadditionally XORs recordkof the last round. Default: empty (identity).virtual cudaqx::tensor<uint8_t> code::get_observable_inlined_feedback() const— shape[num_observables × numCols]; entry(m, k) = 1: observablemadditionally XORs recordkof every round. Default: empty.experiments.cppvalidates and threads the flattened data into everymemory_circuitinvocation; inside the kernel, empty feedback keeps the existing code paths verbatim (the legacycudaq::detectors(prev, curr)line and observable emission are untouched under the default), while non-empty feedback emits per-recordcudaq::detectorparities, extended boundary detectors, and per-round observable record collection (onelogical_observablecall per observable, order unchanged). Python plugins simply define the two methods returning numpy arrays; the nanobind bridge forwards them.Wrong declarations fail loudly: stim's determinism analysis inside
dem_from_kernelvalidates every declared parity on each call.Tests
test_qec46,test_qec_stim7,test_decoders43,test_dem_sampling36) and Pythontest_code.pypass unchanged — with empty feedback the emitted circuit structure is bit-identical to main.[[0,1],[0,0]]/[[0,1]]— derivation documented in-code; verified unique by exhaustive search): noiselesssample_memory_circuityields all-zero syndromes and deterministic logical readout. Negative control: the identical circuit without the declaration throws stim's "non-deterministic detectors" — proving the feature does the work.@qec.codeplugin (positive + negative), exercising the trampoline and handle paths.Notes for reviewers
stabilizer_round— with this feature its byproduct correction moves from a physical CX into the detector definitions, making the framework-path DEM noise-faithful to the classical-feedforward convention.Update: rule extraction (second + third commits)
The initial commit computed the record-composition arithmetic inline in the kernel. Two follow-up commits restructure this into the intended data → rule → emission split, with no user-visible change (all suites bit-identical):
lib/device/inlined_feedback.h: named, documented__qpu__record builders (cross_round_detector_records,boundary_detector_records,observable_feedback_offsets,collect_observable_feedback_round,observable_support_records). Thememory_circuitkernel is now a thin emitter: state prep, rounds, andcudaq::detector/cudaq::logical_observablecalls over the vectors these helpers return. Its signature is unchanged (the two flattened matrices — the declared data, mirroring the getters).cudaq::qec::build_inlined_feedback_layoutindetector_error_model: the same composition rule computed host-side as a CSRinlined_feedback_layout, for record-stream consumers that cannot run a kernel (the planned realtimeD_sparseaugmentation and batch record folds). Unit-tested standalone.HostLayoutMatchesKernelM2D) derives the expected measurement→detector matrix from the host layout and asserts it equals the M2D thatdem_from_kernelextracts from the kernel's emissions on the toy. The device fold and the host rule cannot drift apart silently.Emission stays in-kernel deliberately: stim builds the probabilistic DEM and runs its determinism gate only over kernel-declared detectors.
Test tally after the refactor:
test_qec55 (49 + 5 layout unit tests + 1 conformance),test_qec_stim7,test_decoders43,test_dem_sampling36, Pythontest_code.py21.Update: basis-split observable feedback (fourth commit)
Working the motivating case (#658) through this API surfaced a defect in the original design: observable inlined feedback is basis-dependent, while the single
get_observable_inlined_feedback()was threaded identically into Z- and X-basis memory experiments. In the superdense color code, the heralding records must be folded into the Z-basis logical observable but not the X-basis one; with a single basis-blind matrix, every X-basis run fails stim's determinism gate (confirmed independently by GF(2) analysis of the record recursion and by noiseless sampling).The fix mirrors the existing basis-selected rail for the observable data matrix (
is_z_prep ? get_observables_z() : get_observables_x()): the getter is split intoget_observable_inlined_feedback_z()/_x()(empty defaults), and each driver queries only the selected getter — pinned by a selection test in both directions. The detector-feedback matrix remains a single basis-neutral getter, which #658's validation confirms is sufficient (its detector matrix serves both bases unchanged).Status of the motivating case: with this API, the color-code plugin (#658) declares its matrices and its framework-path DEM matches an independently constructed reference DEM bit-exactly in both bases (mechanism sets and probabilities), replacing the physical correction gate entirely.
Status
Draft for design feedback on the API shape and semantics.
🤖 Generated with Claude Code