Skip to content

Commit fa15304

Browse files
authored
Dem polish (#314)
* Add DetectorErrorModel.from_guppy and rework lowered QIS replay to strict global-order MeasId pairing * Trace Guppy result() tags to MeasIds for reorder-robust tag-referenced detectors in from_guppy * Accept D0/L0 id form in from_guppy detectors/observables (normalized to int) * Document tracked-Pauli qubit-numbering limitation in from_guppy * Add sound HUGR-based result()-tag to measurement extraction in pecos-hugr-qis * Excise proven-unsound runtime result()-tag linkage; keep sound HUGR extractor and positional from_guppy * Remove diagnostic dump scaffolding from result_tags tests * Make result_tags clippy-clean (doc backticks, elide lifetime) * Wire sound HUGR-backed result_tags into from_guppy (Rust-centric, loop-guarded fail-loud) * Address external review: harden HUGR extractor, fix replay (#5/#6), revert gap-4 wiring, fail-loud schema, correct docs * Revert broken dynamic-control guard, fail-loud on out-of-range metadata refs, add regression tests * Route all circuit-ingest and public DEM paths through fallible try_build, reject inconsistent num_measurements * Enforce num_measurements/influence-map consistency in try_build, closing the public-builder bypass * Replace TRY004 noqas with a private _MetadataError type; drop stray ccengine blank line * Consolidate detector/observable schema validation into the Rust DEM builder; thin from_guppy wrapper * Tolerate redundant records+meas_ids co-presence, fail loud on non-redundant; fix logical_circuit regression * Correct stale proposal 001 and try_build rustdoc to match redundancy-tolerant meas_ids semantics * Wire sound result_tags into from_guppy for the straight-line case; commit cross-check that HUGR ordinal == trace MeasId order * Fix result_tags rewriter: strict-parse + redundancy-check instead of mask + additive; make correspondence test load-bearing * Round-10 doc nits: clarify result_tags is from_guppy-only and alternatives-not-additive * Add proposal 002: runtime-loop result_tags via dataflow-bound measurement provenance * Add proposals 003 (hand-authored tracked Paulis) and 004 (sound DEM for measurement-dependent control) * Add proposals 005 (array-valued result) and 006 (linear-combination result); index relationships * Move docs/proposals/ to pecos-docs; update code refs to by-name-only; fix stale qalloc.py docs path * Fix typos pre-commit hook flag in reject_tracked_pauli rustdoc (mis-ingested -> parsed as the wrong thing) * Drop "in pecos-docs" pointer from public code refs; refresh uv.lock for pymdown-extensions * Remove all 'see proposal' / pecos-docs references from public source; pecos-docs is private by design * Remove stale doc stubs and the in-source pointer to a moved future-work note * Extract _batched_stabilizers and _normalize_ancilla_budget into pecos.qec.surface._ancilla_batching * Add ancilla_budget kwarg to get_num_qubits with same clamping as normalize_ancilla_budget * Support ancilla-budgeted surface-code memory in from_guppy via batched Guppy codegen * Address round-4 review: fix ruff/black format conflict, add concrete batch-order pin tests, type-validate ancilla_budget kwarg, add PECOS-native decoder smoke * Add constrained-surface mismatched-num_measurements fail-loud test through the generic Rust validation path * Harden constrained-ancilla from_guppy path: fail-loud trace contract, constrained topology caching, honest measurement-order check, and regression tests * Generalize surface/sampler DEM paths: patch-faithful traced_qis + cache identity, unified distance validation, de-duplicated geometry helpers, and DemBuilder-parity sampler JSON validation Sampler JSON now shares DemBuilder-style stamped-MeasId resolution, duplicate-ID rejection, records/meas_ids redundancy, and measurement-order frame validation. * Apply just lint: rustfmt, black, doc-markdown backticks, and typo fix
1 parent aad8293 commit fa15304

39 files changed

Lines changed: 3878 additions & 816 deletions

crates/pecos-hugr-qis/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ The compiler supports standard LLVM optimization levels:
6262
pub mod array;
6363
pub mod compiler;
6464
pub mod prelude;
65+
pub mod result_tags;
6566
mod utils;
6667

6768
// Re-export main types and functions
@@ -74,6 +75,8 @@ pub use compiler::{
7475
// Re-export read_hugr_envelope from utils
7576
pub use utils::read_hugr_envelope;
7677

78+
pub use result_tags::{extract_result_tag_measurements, measurement_op_count};
79+
7780
// Re-export inkwell's OptimizationLevel for convenience
7881
pub use tket::hugr::llvm::inkwell::OptimizationLevel;
7982

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
//! Extract the Guppy `result(tag, ...)` -> measurement binding from a HUGR.
2+
//!
3+
//! This is the *sound* source of the tag<->measurement association: in the
4+
//! compiled HUGR, a `tket.result` op's dataflow input is wired (transitively)
5+
//! from the measurement op(s) that produced its value. That wiring is fixed at
6+
//! compile time and is immune to any later QIS/Selene measurement reordering,
7+
//! unlike a runtime op-stream heuristic.
8+
//!
9+
//! Measurement identity here is the *ordinal* of the measurement op in HUGR
10+
//! traversal order. This module only recovers the structural binding; whether
11+
//! that HUGR ordinal coincides with the QIS-trace `result_id`/`MeasId` order
12+
//! is a separate property of the Guppy -> HUGR / Guppy -> trace pipelines
13+
//! agreeing on measurement ordering. Within the narrow scope this module
14+
//! supports (straight-line `result_bool <- tket.bool:read <-
15+
//! Measure/MeasureFree`), that correspondence is **committed-test verified**
16+
//! end-to-end by
17+
//! `tests/qec/test_from_guppy_result_tags.py::test_result_tags_match_positional_records`
18+
//! (a scrambled-`result()`-order Guppy program: `result_tags` DEM
19+
//! byte-identical to the positional-records DEM). Outside that scope
20+
//! (computed / constant / array-valued `result()`, runtime loops) the
21+
//! correspondence is undefined and the extractor / runtime-loop guard reject
22+
//! the case rather than relying on it.
23+
//!
24+
//! Note: a *runtime* loop (e.g. `for _ in range(comptime(n))`, as the surface
25+
//! code uses for rounds) is NOT unrolled in the HUGR -- it has one static
26+
//! measure/result op executed n times. Static extraction therefore yields
27+
//! `tag -> static-measure-op`; expanding that to per-iteration runtime `MeasIds`
28+
//! requires a separate static-op -> runtime-measurement correspondence.
29+
30+
use std::collections::{BTreeMap, HashMap};
31+
32+
use tket::hugr::ops::OpType;
33+
use tket::hugr::types::Term;
34+
use tket::hugr::{HugrView, IncomingPort, Node};
35+
36+
fn extension_ids(op: &OpType) -> Option<(&str, String)> {
37+
let ext = op.as_extension_op()?;
38+
Some((
39+
ext.extension_id().as_ref(),
40+
ext.unqualified_id().to_string(),
41+
))
42+
}
43+
44+
fn is_measurement(op: &OpType) -> bool {
45+
matches!(
46+
extension_ids(op),
47+
Some((ext, ref name))
48+
if ext == "tket.quantum" && (name == "Measure" || name == "MeasureFree")
49+
)
50+
}
51+
52+
/// Number of *static* measurement ops in the HUGR.
53+
///
54+
/// For a straight-line program this equals the runtime measurement count; for
55+
/// a program with a runtime loop it is strictly smaller (the loop body's
56+
/// measure op is counted once). Callers use the mismatch to detect that
57+
/// per-occurrence tag binding is not statically available.
58+
#[must_use]
59+
pub fn measurement_op_count<H: HugrView<Node = Node>>(hugr: &H) -> usize {
60+
hugr.nodes()
61+
.filter(|&n| is_measurement(hugr.get_optype(n)))
62+
.count()
63+
}
64+
65+
/// Map each `result(tag, <measurement>)` to the measurement ordinal it records.
66+
///
67+
/// **Sound by construction, narrow by design.** Only the canonical pattern
68+
/// `result(tag, <a single raw measurement bit>)` is recognized: a
69+
/// `tket.result:result_bool` op whose value input is *exactly*
70+
/// `tket.bool:read` of a measurement op. The compiled chain is verified to be
71+
/// precisely `result_bool <- tket.bool:read <- Measure/MeasureFree`.
72+
///
73+
/// Any other shape is **deliberately excluded** (the tag is omitted from the
74+
/// returned map) rather than guessed at -- e.g. computed values
75+
/// (`result("x", m0 == m1)` lowers through `tket.bool:eq`), constants
76+
/// (`result("x", True)` lowers through a `Const`), and array-valued
77+
/// `result(...)` (`result_array_bool` lowers through `collections.borrow_arr`
78+
/// machinery that does not cleanly expose per-element measurement provenance).
79+
/// Resolving those structurally would silently misbind (equality is not
80+
/// parity; an empty record set is not a detector), so they are not returned.
81+
///
82+
/// A tag repeated across the program accumulates its ordinals in traversal
83+
/// order; callers handle occurrence disambiguation / loop guarding.
84+
#[must_use]
85+
pub fn extract_result_tag_measurements<H: HugrView<Node = Node>>(
86+
hugr: &H,
87+
) -> BTreeMap<String, Vec<usize>> {
88+
// Pass 1: ordinal for every measurement op, in traversal order.
89+
let mut meas_ordinal: HashMap<Node, usize> = HashMap::new();
90+
for node in hugr.nodes() {
91+
if is_measurement(hugr.get_optype(node)) {
92+
let next = meas_ordinal.len();
93+
meas_ordinal.insert(node, next);
94+
}
95+
}
96+
97+
// single_linked_output source op, if any.
98+
let src_op = |node: Node, port: usize| -> Option<Node> {
99+
hugr.single_linked_output(node, IncomingPort::from(port))
100+
.map(|(s, _)| s)
101+
};
102+
103+
// Pass 2: accept only result_bool <- tket.bool:read <- measurement.
104+
let mut out: BTreeMap<String, Vec<usize>> = BTreeMap::new();
105+
for node in hugr.nodes() {
106+
let op = hugr.get_optype(node);
107+
let Some((ext, name)) = extension_ids(op) else {
108+
continue;
109+
};
110+
if ext != "tket.result" || name != "result_bool" {
111+
continue; // arrays / non-bool result ops: not soundly resolvable
112+
}
113+
let Some(ext_op) = op.as_extension_op() else {
114+
continue;
115+
};
116+
let Some(tag) = ext_op.args().iter().find_map(|a| match a {
117+
Term::String(s) => Some(s.clone()),
118+
_ => None,
119+
}) else {
120+
continue;
121+
};
122+
123+
// result_bool value input (port 0) must be exactly `tket.bool:read`.
124+
let Some(read) = src_op(node, 0) else {
125+
continue;
126+
};
127+
match extension_ids(hugr.get_optype(read)) {
128+
Some((e, ref n)) if e == "tket.bool" && n == "read" => {}
129+
_ => continue, // e.g. tket.bool:eq (computed) -> exclude
130+
}
131+
// ... whose input (port 0) must be a measurement op.
132+
let Some(meas) = src_op(read, 0) else {
133+
continue;
134+
};
135+
let Some(&ord) = meas_ordinal.get(&meas) else {
136+
continue; // e.g. a Const -> exclude
137+
};
138+
out.entry(tag).or_default().push(ord);
139+
}
140+
out
141+
}
142+
143+
#[cfg(test)]
144+
mod tests {
145+
use super::*;
146+
use crate::read_hugr_envelope;
147+
148+
// Fixtures compiled from Guppy (committed so the regression does not
149+
// depend on a Python toolchain at test time):
150+
// scrambled: result() declared c,a,b over measures a,b,c (raw scalars)
151+
// looped: for _ in range(comptime(3)): result("synx", measure(q))
152+
// computed: result("eq", m0==m1) ; result("const", True)
153+
// arr: result("pair", measure_array(qs)) (array-valued)
154+
const SCRAMBLED: &[u8] = include_bytes!("../tests/fixtures/scrambled.hugr");
155+
const LOOPED: &[u8] = include_bytes!("../tests/fixtures/looped.hugr");
156+
const COMPUTED: &[u8] = include_bytes!("../tests/fixtures/computed.hugr");
157+
const ARR: &[u8] = include_bytes!("../tests/fixtures/arr.hugr");
158+
159+
/// Foundation: `result()` declared in scrambled order (c, a, b) over
160+
/// measurements made in order (a, b, c) must still bind each tag to ITS
161+
/// OWN measurement. This is the exact case the prior runtime read/store
162+
/// heuristic got wrong (it produced `{tag_c: [0,1,2]}`); the HUGR
163+
/// structural binding is immune to declaration/measurement-order skew.
164+
#[test]
165+
fn scrambled_binds_each_tag_to_its_measurement() {
166+
let hugr = read_hugr_envelope(SCRAMBLED).unwrap();
167+
let map = extract_result_tag_measurements(&hugr);
168+
assert_eq!(
169+
map,
170+
BTreeMap::from([
171+
("tag_a".to_string(), vec![0]),
172+
("tag_b".to_string(), vec![1]),
173+
("tag_c".to_string(), vec![2]),
174+
]),
175+
"tag must bind to its own measurement regardless of result() order",
176+
);
177+
}
178+
179+
/// Documents the known limitation: a runtime `for _ in range(comptime(n))`
180+
/// loop is NOT unrolled in the HUGR, so a tag emitted once per iteration
181+
/// has a single static measure op. Per-iteration expansion needs a
182+
/// separate static-op -> runtime-measurement correspondence.
183+
#[test]
184+
fn looped_tag_is_single_static_measure_op() {
185+
let hugr = read_hugr_envelope(LOOPED).unwrap();
186+
let map = extract_result_tag_measurements(&hugr);
187+
assert_eq!(
188+
map.get("synx").map(Vec::as_slice),
189+
Some([0].as_slice()),
190+
"runtime loop is not unrolled in HUGR: one static measure op",
191+
);
192+
}
193+
194+
/// Soundness: a computed `result("eq", m0 == m1)` (lowers through
195+
/// `tket.bool:eq`) and a constant `result("const", True)` (lowers through
196+
/// a `Const`) must NOT be returned -- resolving them would silently
197+
/// misbind (equality is not parity; no measurement at all).
198+
#[test]
199+
fn computed_and_constant_tags_are_excluded() {
200+
let hugr = read_hugr_envelope(COMPUTED).unwrap();
201+
let map = extract_result_tag_measurements(&hugr);
202+
assert!(
203+
!map.contains_key("eq") && !map.contains_key("const"),
204+
"computed/constant tags must be excluded, got {map:?}",
205+
);
206+
}
207+
208+
/// Soundness: an array-valued `result("pair", measure_array(qs))` lowers
209+
/// through `collections.borrow_arr` machinery with no clean per-element
210+
/// measurement provenance, so it must NOT be returned.
211+
#[test]
212+
fn array_valued_tag_is_excluded() {
213+
let hugr = read_hugr_envelope(ARR).unwrap();
214+
let map = extract_result_tag_measurements(&hugr);
215+
assert!(
216+
!map.contains_key("pair"),
217+
"array-valued result tag must be excluded, got {map:?}",
218+
);
219+
}
220+
}
12.2 KB
Binary file not shown.
5.81 KB
Binary file not shown.
9.07 KB
Binary file not shown.
5.88 KB
Binary file not shown.

crates/pecos-qec/src/fault_tolerance/dem_builder.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ mod mem_builder;
8383
pub(crate) mod sampler;
8484
mod types;
8585

86-
pub use builder::{DemBuilder, DemBuilderError};
86+
pub use builder::{DemBuilder, DemBuilderError, resolve_result_tags};
8787
pub use dem_sampler::{SamplingEngine, SamplingStatistics};
8888
pub use equivalence::{
8989
ComparisonDetails, ComparisonMethod, DemParseError, EffectKey, EquivalenceResult,

0 commit comments

Comments
 (0)