diff --git a/crates/ppvm-stim/src/validate.rs b/crates/ppvm-stim/src/validate.rs index 07b285dd0..1b7b9ab4c 100644 --- a/crates/ppvm-stim/src/validate.rs +++ b/crates/ppvm-stim/src/validate.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2026 The PPVM Authors // SPDX-License-Identifier: Apache-2.0 -use stim_parser::diagnostics::LineMap; +use stim_parser::diagnostics::{LineMap, Span}; use stim_parser::prelude::{ ExtendedInstruction, ExtendedProgram, GateName, GateOp, MeasureName, MeasureOp, MppOp, NoiseName, NoiseOp, PauliFactor, Target, @@ -27,6 +27,28 @@ pub enum ExecError { InvalidPauliProduct { line: usize, message: String }, } +/// `" in the instruction fused from lines 2-3"`, or `""` for a single line. +fn fused_from((line, last_line): (usize, usize)) -> String { + if line == last_line { + String::new() + } else { + format!(" in the instruction fused from lines {line}-{last_line}") + } +} + +/// First and last source line of a (possibly fused) instruction. +fn line_range(span: Span, line_map: &LineMap) -> (usize, usize) { + let last = span.end.saturating_sub(1).max(span.start); + (line_map.line_of(span.start), line_map.line_of(last)) +} + +fn fmt_target(t: Target) -> String { + match t { + Target::Qubit(q) => q.to_string(), + Target::Rec(k) => format!("rec[-{k}]"), + } +} + pub fn validate(program: &ExtendedProgram) -> Result<(), ExecError> { let mut measurements = 0usize; validate_slice(&program.instructions, &program.line_map, &mut measurements) @@ -51,9 +73,9 @@ fn validate_slice( span, .. }) => { - let line = span.line(line_map); - check_gate_supported(*name, line)?; - check_record_controls(*name, targets, *measurements, line)?; + let lines = line_range(*span, line_map); + check_gate_supported(*name, lines.0)?; + check_record_controls(*name, targets, *measurements, lines)?; } ExtendedInstruction::Noise(NoiseOp { name, span, .. }) => { check_noise_supported(*name, span.line(line_map))?; @@ -78,12 +100,12 @@ fn validate_slice( span, .. }) => { - let line = span.line(line_map); + let lines = line_range(*span, line_map); if let Some(&p) = args.first() { - check_probability(p, "MPP", line)?; + check_probability(p, "MPP", lines.0)?; } for product in products { - check_mpp_distinct_qubits(product, line)?; + check_mpp_distinct_qubits(product, lines)?; } *measurements = measurements.saturating_add(products.len()); } @@ -156,40 +178,49 @@ fn check_record_controls( name: GateName, targets: &[Target], measurements: usize, - line: usize, + lines: (usize, usize), ) -> Result<(), ExecError> { use GateName::*; - if !targets.iter().any(|t| matches!(t, Target::Rec(_))) { + let Some(&rec) = targets.iter().find(|t| matches!(t, Target::Rec(_))) else { return Ok(()); - } + }; let controlled = matches!(name, CX | ZCX | CNot | CY | ZCY | CZ | ZCZ); + let fused = fused_from(lines); if !controlled { return Err(ExecError::InvalidRecordControl { name: name.canonical_name().to_string(), - line, - message: "measurement-record controls (rec[-k]) are only valid on CX/CNOT, CY and CZ" - .to_string(), + line: lines.0, + message: format!( + "{}{fused} is not allowed: measurement-record controls (rec[-k]) are only \ + valid on CX/CNOT, CY and CZ", + fmt_target(rec) + ), }); } // Targets come in (control, target) pairs; the target may never be a record, // and a control may never look back past the start of the record. - for pair in targets.as_chunks::<2>().0 { - if matches!(pair[1], Target::Rec(_)) { + for &[control, target] in targets.as_chunks::<2>().0 { + if matches!(target, Target::Rec(_)) { return Err(ExecError::InvalidRecordControl { name: name.canonical_name().to_string(), - line, - message: "measurement record cannot be a gate target".to_string(), + line: lines.0, + message: format!( + "measurement record cannot be a gate target (pair '{} {}'{fused})", + fmt_target(control), + fmt_target(target) + ), }); } - if let Target::Rec(k) = pair[0] + if let Target::Rec(k) = control && k > measurements { return Err(ExecError::InvalidRecordControl { name: name.canonical_name().to_string(), - line, + line: lines.0, message: format!( "rec[-{k}] looks back before the start of the measurement record \ - ({measurements} measurement(s) recorded so far)" + ({measurements} measurement(s) recorded so far; pair 'rec[-{k}] {}'{fused})", + fmt_target(target) ), }); } @@ -204,16 +235,25 @@ fn check_record_controls( /// CX-ladder gadget in the executor assumes one factor per qubit, so a repeat /// would silently misbehave. We reject all repeats with a single clear error /// rather than emulate the folding. -fn check_mpp_distinct_qubits(product: &[PauliFactor], line: usize) -> Result<(), ExecError> { +fn check_mpp_distinct_qubits( + product: &[PauliFactor], + lines: (usize, usize), +) -> Result<(), ExecError> { for (i, factor) in product.iter().enumerate() { if product[..i].iter().any(|prev| prev.qubit == factor.qubit) { return Err(ExecError::InvalidPauliProduct { - line, + line: lines.0, message: format!( - "qubit {} appears more than once; MPP products must act on distinct qubits. \ - If you have a use case for repeated Paulis in a product, please open an \ - issue at https://github.com/QuEraComputing/ppvm/issues", - factor.qubit + "qubit {} appears more than once in {}{}; MPP products must act on \ + distinct qubits. If you have a use case for repeated Paulis in a product, \ + please open an issue at https://github.com/QuEraComputing/ppvm/issues", + factor.qubit, + product + .iter() + .map(|f| format!("{}{}", f.axis.as_char(), f.qubit)) + .collect::>() + .join("*"), + fused_from(lines) ), }); } diff --git a/crates/ppvm-stim/tests/validate.rs b/crates/ppvm-stim/tests/validate.rs index 66b6ac819..cd66d2c6f 100644 --- a/crates/ppvm-stim/tests/validate.rs +++ b/crates/ppvm-stim/tests/validate.rs @@ -214,3 +214,67 @@ fn valid_leakage_accepted() { let prog = parse_extended("I_ERROR[leakage](0.5, 0.5) 0").expect("parse_extended"); assert_eq!(validate(&prog), Ok(())); } + +// ---- fused instructions: quote the operand and name the line range -------- + +/// The `(line, message)` of a record-control or Pauli-product error. +fn line_and_message(e: &ExecError) -> (usize, &str) { + match e { + ExecError::InvalidRecordControl { line, message, .. } + | ExecError::InvalidPauliProduct { line, message } => (*line, message), + other => panic!("{other:?}"), + } +} + +#[test] +fn single_line_error_has_no_fused_note() { + let e = err_from_src("M 0\nCX rec[-2] 1"); + let (line, message) = line_and_message(&e); + assert_eq!(line, 2); + assert!(message.contains("pair 'rec[-2] 1'"), "{message}"); + assert!(!message.contains("fused"), "{message}"); +} + +#[test] +fn lookback_error_in_fused_run() { + let e = err_from_src("M 0\nCX rec[-1] 0\nCX rec[-5] 1"); + let (line, message) = line_and_message(&e); + assert_eq!(line, 2); + assert!( + message.contains("pair 'rec[-5] 1' in the instruction fused from lines 2-3"), + "{message}" + ); +} + +#[test] +fn record_target_error_in_fused_run() { + let e = err_from_src("M 0\nCX rec[-1] 0\nCX 1 rec[-1]"); + let (line, message) = line_and_message(&e); + assert_eq!(line, 2); + assert!( + message.contains("pair '1 rec[-1]' in the instruction fused from lines 2-3"), + "{message}" + ); +} + +#[test] +fn record_on_uncontrolled_gate_in_fused_run() { + let e = err_from_src("M 0\nH 1\nH rec[-1]"); + let (line, message) = line_and_message(&e); + assert_eq!(line, 2); + assert!( + message.starts_with("rec[-1] in the instruction fused from lines 2-3 is not allowed"), + "{message}" + ); +} + +#[test] +fn mpp_error_in_fused_run() { + let e = err_from_src("MPP X0\nMPP X1*X1"); + let (line, message) = line_and_message(&e); + assert_eq!(line, 1); + assert!( + message.contains("in X1*X1 in the instruction fused from lines 1-2;"), + "{message}" + ); +} diff --git a/crates/stim-parser/src/instructions/mod.rs b/crates/stim-parser/src/instructions/mod.rs index bb7cbde74..a98749631 100644 --- a/crates/stim-parser/src/instructions/mod.rs +++ b/crates/stim-parser/src/instructions/mod.rs @@ -403,6 +403,14 @@ impl NoiseName { pub fn canonical_name(self) -> &'static str { canonical_name(EntryKind::Noise(self)) } + + /// Correlated errors are one joint event over all targets, so never fused. + pub(crate) fn is_fusable(self) -> bool { + !matches!( + self, + NoiseName::CorrelatedError | NoiseName::ElseCorrelatedError + ) + } } impl MeasureName { diff --git a/crates/stim-parser/src/lib.rs b/crates/stim-parser/src/lib.rs index b74462c59..0a514ac81 100644 --- a/crates/stim-parser/src/lib.rs +++ b/crates/stim-parser/src/lib.rs @@ -28,7 +28,8 @@ pub fn parse(src: &str) -> Result { let mut sink = FailFast::new(); let result = Pipeline::new(src) .parse(&mut sink) - .and_then(|p| p.validate(&mut sink)); + .and_then(|p| p.validate(&mut sink)) + .map(|p| p.fuse()); match result { Ok(p) => Ok(p.finish()), Err(_) => Err(fail(sink, src)), @@ -43,7 +44,8 @@ pub fn parse_extended(src: &str) -> Result { let result = Pipeline::new(src) .parse(&mut sink) .and_then(|p| p.validate(&mut sink)) - .and_then(|p| p.lower(&mut sink)); + .and_then(|p| p.lower(&mut sink)) + .map(|p| p.fuse()); match result { Ok(p) => Ok(p.finish()), Err(_) => Err(fail(sink, src)), diff --git a/crates/stim-parser/src/pipeline/fuse.rs b/crates/stim-parser/src/pipeline/fuse.rs new file mode 100644 index 000000000..e26adbf38 --- /dev/null +++ b/crates/stim-parser/src/pipeline/fuse.rs @@ -0,0 +1,262 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Stage 4: fusion. Like Stim's `Circuit` parser, merges consecutive +//! instructions with the same name, tag and arguments (`H 0` / `H 1` becomes +//! `H 0 1`). Runs after every stage that can reject input, so those checks +//! see one source line at a time. + +use std::mem; + +use crate::ast::extended::ExtendedInstruction; +use crate::ast::shared::{GateOp, MeasureOp, MppOp, NoiseOp}; +use crate::ast::vanilla::Instruction; +use crate::diagnostics::Span; + +/// Fuse a list of instructions, recursing into `REPEAT` bodies. Never fuses +/// across a `skipped` node. +pub(crate) fn fuse(instructions: Vec, mut skipped: Vec) -> Vec { + // validate's and lower's records are each in source order, but concatenated. + skipped.sort_unstable_by_key(|s| s.start); + fuse_slice(instructions, &skipped) +} + +fn fuse_slice(instructions: Vec, skipped: &[Span]) -> Vec { + let mut out: Vec = Vec::with_capacity(instructions.len()); + for mut instr in instructions { + if let Some(body) = instr.body_mut() { + *body = fuse_slice(mem::take(body), skipped); + } + let next = *instr.span_mut(); + if let Some(prev) = out.last_mut() + && prev.can_fuse(&instr) + && !skipped_between(skipped, *prev.span_mut(), next) + { + prev.append_operands(instr); + prev.span_mut().end = next.end; + } else { + out.push(instr); + } + } + out +} + +/// Was any node skipped between `prev` and `next`? `skipped` is sorted by start. +fn skipped_between(skipped: &[Span], prev: Span, next: Span) -> bool { + let i = skipped.partition_point(|s| s.start <= prev.start); + skipped.get(i).is_some_and(|s| s.start < next.start) +} + +/// An instruction type the fusion pass can work on. +pub(crate) trait Fusable: Sized { + fn span_mut(&mut self) -> &mut Span; + /// The body of a `REPEAT` block, fused independently. + fn body_mut(&mut self) -> Option<&mut Vec>; + /// Can `next` be fused onto the end of `self`? + fn can_fuse(&self, next: &Self) -> bool; + /// Append `next`'s targets onto `self`. Precondition: `self.can_fuse(&next)`. + fn append_operands(&mut self, next: Self); +} + +// The shared op structs fuse on the same name , tag and args. +// TODO: spelling aliases such as `CX`/`CNOT` + +fn gates_fuse(a: &GateOp, b: &GateOp) -> bool { + a.name == b.name && a.tag == b.tag && a.args == b.args +} + +fn noises_fuse(a: &NoiseOp, b: &NoiseOp) -> bool { + a.name.is_fusable() && a.name == b.name && a.tag == b.tag && a.args == b.args +} + +fn measures_fuse(a: &MeasureOp, b: &MeasureOp) -> bool { + a.name == b.name && a.tag == b.tag && a.args == b.args +} + +fn mpps_fuse(a: &MppOp, b: &MppOp) -> bool { + a.tag == b.tag && a.args == b.args +} + +/// Never fused: annotations (`TICK`, `DETECTOR`, `OBSERVABLE_INCLUDE`, +/// coordinates), `REPEAT` blocks, and `CORRELATED_ERROR` / +/// `ELSE_CORRELATED_ERROR` (see `NoiseName::is_fusable`). +impl Fusable for Instruction { + fn span_mut(&mut self) -> &mut Span { + match self { + Instruction::Gate(op) => &mut op.span, + Instruction::Noise(op) => &mut op.span, + Instruction::Measure(op) => &mut op.span, + Instruction::Annotation(op) => &mut op.span, + Instruction::Mpp(op) => &mut op.span, + Instruction::MPad { span, .. } | Instruction::Repeat { span, .. } => span, + } + } + + fn body_mut(&mut self) -> Option<&mut Vec> { + match self { + Instruction::Repeat { body, .. } => Some(body), + _ => None, + } + } + + fn can_fuse(&self, next: &Self) -> bool { + use Instruction as I; + match (self, next) { + (I::Gate(a), I::Gate(b)) => gates_fuse(a, b), + (I::Noise(a), I::Noise(b)) => noises_fuse(a, b), + (I::Measure(a), I::Measure(b)) => measures_fuse(a, b), + (I::Mpp(a), I::Mpp(b)) => mpps_fuse(a, b), + ( + I::MPad { tag, prob, .. }, + I::MPad { + tag: t, prob: p, .. + }, + ) => tag == t && prob == p, + _ => false, + } + } + + fn append_operands(&mut self, next: Self) { + use Instruction as I; + match (self, next) { + (I::Gate(a), I::Gate(b)) => a.targets.extend(b.targets), + (I::Noise(a), I::Noise(b)) => a.targets.extend(b.targets), + (I::Measure(a), I::Measure(b)) => a.targets.extend(b.targets), + (I::Mpp(a), I::Mpp(b)) => a.products.extend(b.products), + (I::MPad { bits, .. }, I::MPad { bits: b, .. }) => bits.extend(b), + _ => unreachable!("append_operands called on a pair that can_fuse rejects"), + } + } +} + +/// Fuses by operation, not spelling: `S[T] 0` / `T 1` becomes one `T`. Never +/// fused: annotations and `REPEAT` blocks. +impl Fusable for ExtendedInstruction { + fn span_mut(&mut self) -> &mut Span { + use ExtendedInstruction as E; + match self { + E::Gate(op) => &mut op.span, + E::Noise(op) => &mut op.span, + E::Measure(op) => &mut op.span, + E::Annotation(op) => &mut op.span, + E::Mpp(op) => &mut op.span, + E::T { span, .. } + | E::TDag { span, .. } + | E::Rotation { span, .. } + | E::U3 { span, .. } + | E::Loss { span, .. } + | E::CorrelatedLoss { span, .. } + | E::Leakage { span, .. } + | E::ResetLoss { span, .. } + | E::ResetLeakage { span, .. } + | E::ResetState { span, .. } + | E::MPad { span, .. } + | E::Repeat { span, .. } => span, + } + } + + fn body_mut(&mut self) -> Option<&mut Vec> { + match self { + ExtendedInstruction::Repeat { body, .. } => Some(body), + _ => None, + } + } + + fn can_fuse(&self, next: &Self) -> bool { + use ExtendedInstruction as E; + match (self, next) { + (E::Gate(a), E::Gate(b)) => gates_fuse(a, b), + (E::Noise(a), E::Noise(b)) => noises_fuse(a, b), + (E::Measure(a), E::Measure(b)) => measures_fuse(a, b), + (E::Mpp(a), E::Mpp(b)) => mpps_fuse(a, b), + (E::T { .. }, E::T { .. }) + | (E::TDag { .. }, E::TDag { .. }) + | (E::ResetLoss { .. }, E::ResetLoss { .. }) + | (E::ResetLeakage { .. }, E::ResetLeakage { .. }) + | (E::ResetState { .. }, E::ResetState { .. }) => true, + ( + E::Rotation { axis, theta, .. }, + E::Rotation { + axis: a, theta: t, .. + }, + ) => axis == a && theta == t, + ( + E::U3 { + theta, phi, lambda, .. + }, + E::U3 { + theta: t, + phi: p, + lambda: l, + .. + }, + ) => theta == t && phi == p && lambda == l, + (E::Loss { p, .. }, E::Loss { p: q, .. }) => p == q, + (E::Leakage { p0, p1, .. }, E::Leakage { p0: q0, p1: q1, .. }) => p0 == q0 && p1 == q1, + (E::CorrelatedLoss { ps, .. }, E::CorrelatedLoss { ps: qs, .. }) => ps == qs, + ( + E::MPad { tag, prob, .. }, + E::MPad { + tag: t, prob: p, .. + }, + ) => tag == t && prob == p, + _ => false, + } + } + + fn append_operands(&mut self, next: Self) { + use ExtendedInstruction as E; + match (self, next) { + (E::Gate(a), E::Gate(b)) => a.targets.extend(b.targets), + (E::Noise(a), E::Noise(b)) => a.targets.extend(b.targets), + (E::Measure(a), E::Measure(b)) => a.targets.extend(b.targets), + (E::Mpp(a), E::Mpp(b)) => a.products.extend(b.products), + (E::T { targets, .. }, E::T { targets: t, .. }) + | (E::TDag { targets, .. }, E::TDag { targets: t, .. }) + | (E::Rotation { targets, .. }, E::Rotation { targets: t, .. }) + | (E::U3 { targets, .. }, E::U3 { targets: t, .. }) + | (E::Loss { targets, .. }, E::Loss { targets: t, .. }) + | (E::Leakage { targets, .. }, E::Leakage { targets: t, .. }) + | (E::ResetLoss { targets, .. }, E::ResetLoss { targets: t, .. }) + | (E::ResetLeakage { targets, .. }, E::ResetLeakage { targets: t, .. }) + | (E::ResetState { targets, .. }, E::ResetState { targets: t, .. }) => { + targets.extend(t) + } + (E::CorrelatedLoss { targets, .. }, E::CorrelatedLoss { targets: t, .. }) => { + targets.extend(t) + } + (E::MPad { bits, .. }, E::MPad { bits: b, .. }) => bits.extend(b), + _ => unreachable!("append_operands called on a pair that can_fuse rejects"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skipped_between_only_counts_spans_between_the_two() { + let skipped = [Span::new(5, 9), Span::new(20, 24)]; + assert!(skipped_between( + &skipped, + Span::new(0, 1), + Span::new(10, 11) + )); + assert!(!skipped_between( + &skipped, + Span::new(10, 11), + Span::new(15, 16) + )); + assert!(skipped_between( + &skipped, + Span::new(10, 11), + Span::new(30, 31) + )); + assert!(!skipped_between( + &skipped, + Span::new(25, 26), + Span::new(30, 31) + )); + } +} diff --git a/crates/stim-parser/src/pipeline/lower.rs b/crates/stim-parser/src/pipeline/lower.rs index 9cbf9941d..f2a16b75e 100644 --- a/crates/stim-parser/src/pipeline/lower.rs +++ b/crates/stim-parser/src/pipeline/lower.rs @@ -21,6 +21,7 @@ use crate::diagnostics::{Aborted, DiagnosticSink, Span}; use crate::instructions::{GateName, NoiseName}; use super::emit_skip; +use super::fuse::Fusable; type TagExtra<'src> = extra::Err>; @@ -74,30 +75,36 @@ fn parse_structured_tag(src: &str) -> Option { } /// Lower a vanilla [`Program`] into an [`ExtendedProgram`], forwarding every -/// recoverable error to the sink. +/// recoverable error to the sink. Also returns the spans of the instructions +/// it skipped. pub(crate) fn lower( program: Program, sink: &mut dyn DiagnosticSink, -) -> Result { +) -> Result<(ExtendedProgram, Vec), Aborted> { let line_map = Arc::clone(&program.line_map); - let instructions = lower_slice(program.instructions, sink)?; - Ok(ExtendedProgram { + let mut skipped = Vec::new(); + let instructions = lower_slice(program.instructions, sink, &mut skipped)?; + let program = ExtendedProgram { instructions, line_map, - }) + }; + Ok((program, skipped)) } /// Lower a (possibly nested) list of instructions. Lowered instructions are -/// pushed; recoverable errors are skipped; an aborting sink short-circuits the -/// whole walk. +/// pushed; recoverable errors are skipped (and recorded in `skipped`); an +/// aborting sink short-circuits the whole walk. fn lower_slice( src: Vec, sink: &mut dyn DiagnosticSink, + skipped: &mut Vec, ) -> Result, Aborted> { let mut out = Vec::with_capacity(src.len()); - for instr in src { - if let Some(lowered) = lower_one(instr, sink)? { - out.push(lowered); + for mut instr in src { + let span = *instr.span_mut(); + match lower_one(instr, sink, skipped)? { + Some(lowered) => out.push(lowered), + None => skipped.push(span), } } Ok(out) @@ -111,6 +118,7 @@ fn lower_slice( fn lower_one( instr: Instruction, sink: &mut dyn DiagnosticSink, + skipped: &mut Vec, ) -> Result, Aborted> { match instr { Instruction::Gate(op) => lower_gate(op, sink), @@ -141,7 +149,7 @@ fn lower_one( body, span, } => { - let body = lower_slice(body, sink)?; + let body = lower_slice(body, sink, skipped)?; Ok(Some(ExtendedInstruction::Repeat { tag, count, diff --git a/crates/stim-parser/src/pipeline/mod.rs b/crates/stim-parser/src/pipeline/mod.rs index 43a3dfe51..db9132b7b 100644 --- a/crates/stim-parser/src/pipeline/mod.rs +++ b/crates/stim-parser/src/pipeline/mod.rs @@ -1,10 +1,18 @@ // SPDX-FileCopyrightText: 2026 The PPVM Authors // SPDX-License-Identifier: Apache-2.0 -//! Typestate lowering pipeline: Source → Parsed → Validated → Lowered. +//! Typestate lowering pipeline: +//! +//! ```text +//! Source ─parse─▶ Parsed ─validate─▶ Validated ─fuse─▶ Fused +//! │ +//! └─lower─▶ Lowered ─fuse─▶ Fused +//! ``` +//! //! Each transition consumes `self` and returns the next state type, so //! illegal orderings do not compile. +mod fuse; mod lower; mod validate; @@ -52,12 +60,17 @@ pub struct Parsed { pub(crate) line_map: Arc, } // Once a Program exists it owns the LineMap, so the later states need only -// hold the program. +// hold the program and the spans of skipped nodes. pub struct Validated { pub(crate) program: Program, + pub(crate) skipped: Vec, } pub struct Lowered { pub(crate) program: ExtendedProgram, + pub(crate) skipped: Vec, +} +pub struct Fused

{ + pub(crate) program: P, } impl<'a> Pipeline> { @@ -97,9 +110,9 @@ impl Pipeline { /// forwarding every recoverable error to the sink. pub fn validate(self, sink: &mut dyn DiagnosticSink) -> Result, Aborted> { let Parsed { tree, line_map } = self.state; - let program = validate::validate(tree, &line_map, sink)?; + let (program, skipped) = validate::validate(tree, &line_map, sink)?; Ok(Pipeline { - state: Validated { program }, + state: Validated { program, skipped }, }) } } @@ -109,23 +122,62 @@ impl Pipeline { /// [`ExtendedInstruction`] variants, forwarding every recoverable error to /// the sink. pub fn lower(self, sink: &mut dyn DiagnosticSink) -> Result, Aborted> { - let program = lower::lower(self.state.program, sink)?; + let Validated { + program, + mut skipped, + } = self.state; + let (program, lower_skipped) = lower::lower(program, sink)?; + skipped.extend(lower_skipped); Ok(Pipeline { - state: Lowered { program }, + state: Lowered { program, skipped }, }) } + /// Stage 4: fusion. Merges consecutive compatible instructions, as Stim + /// does (`H 0` / `H 1` becomes `H 0 1`). + pub fn fuse(self) -> Pipeline> { + let Validated { program, skipped } = self.state; + Pipeline { + state: Fused { + program: Program { + instructions: fuse::fuse(program.instructions, skipped), + ..program + }, + }, + } + } + pub fn finish(self) -> Program { self.state.program } } impl Pipeline { + /// Stage 4: fusion. Merges consecutive compatible instructions, as Stim + /// does (`H 0` / `H 1` becomes `H 0 1`). + pub fn fuse(self) -> Pipeline> { + let Lowered { program, skipped } = self.state; + Pipeline { + state: Fused { + program: ExtendedProgram { + instructions: fuse::fuse(program.instructions, skipped), + ..program + }, + }, + } + } + pub fn finish(self) -> ExtendedProgram { self.state.program } } +impl

Pipeline> { + pub fn finish(self) -> P { + self.state.program + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/stim-parser/src/pipeline/validate.rs b/crates/stim-parser/src/pipeline/validate.rs index d051db460..498b1d5a1 100644 --- a/crates/stim-parser/src/pipeline/validate.rs +++ b/crates/stim-parser/src/pipeline/validate.rs @@ -19,31 +19,38 @@ use crate::syntax::{RawSyntaxNode, RawSyntaxTree, RawTarget}; use super::emit_skip; -/// Walk the raw syntactic tree and build the validated [`Program`]. +/// Walk the raw syntactic tree and build the validated [`Program`]. Also +/// returns the spans of the nodes it skipped. pub(crate) fn validate( tree: RawSyntaxTree, line_map: &Arc, sink: &mut dyn DiagnosticSink, -) -> Result { - let instructions = validate_slice(tree, line_map, sink)?; - Ok(Program { +) -> Result<(Program, Vec), Aborted> { + let mut skipped = Vec::new(); + let instructions = validate_slice(tree, line_map, sink, &mut skipped)?; + let program = Program { instructions, line_map: Arc::clone(line_map), - }) + }; + Ok((program, skipped)) } /// Validate a (possibly nested) list of raw nodes into instructions. Valid -/// instructions are pushed; recoverable errors are skipped; an aborting sink -/// short-circuits the whole walk. +/// instructions are pushed; recoverable errors are skipped (and recorded in +/// `skipped`); an aborting sink short-circuits the whole walk. fn validate_slice( nodes: Vec, line_map: &Arc, sink: &mut dyn DiagnosticSink, + skipped: &mut Vec, ) -> Result, Aborted> { let mut out = Vec::with_capacity(nodes.len()); for node in nodes { - if let Some(instruction) = validate_node(node, line_map, sink)? { - out.push(instruction); + let (RawSyntaxNode::Instruction { span, .. } | RawSyntaxNode::Repeat { span, .. }) = &node; + let span: Span = (*span).into(); + match validate_node(node, line_map, sink, skipped)? { + Some(instruction) => out.push(instruction), + None => skipped.push(span), } } Ok(out) @@ -58,6 +65,7 @@ fn validate_node( node: RawSyntaxNode, line_map: &Arc, sink: &mut dyn DiagnosticSink, + skipped: &mut Vec, ) -> Result, Aborted> { match node { RawSyntaxNode::Instruction { @@ -181,7 +189,7 @@ fn validate_node( span, } => { let span: Span = span.into(); - let body = validate_slice(body, line_map, sink)?; + let body = validate_slice(body, line_map, sink, skipped)?; Ok(Some(Instruction::Repeat { tag, count, @@ -399,7 +407,7 @@ mod tests { /// return the produced program. fn ok_program(nodes: Vec, line_map: &Arc) -> Program { let mut sink = FailFast::new(); - let program = validate(nodes, line_map, &mut sink).expect("validation should succeed"); + let (program, _) = validate(nodes, line_map, &mut sink).expect("validation should succeed"); assert!(!sink.saw_error(), "expected no diagnostics"); program } @@ -410,7 +418,7 @@ mod tests { /// resulting program is empty (asserted here as a guard). fn collect_errors(nodes: Vec, line_map: &Arc) -> Vec { let mut sink = Collect::new(); - let program = validate(nodes, line_map, &mut sink).expect("Collect never aborts"); + let (program, _) = validate(nodes, line_map, &mut sink).expect("Collect never aborts"); assert!( program.instructions.is_empty(), "collect_errors expects every input node to be invalid" @@ -689,7 +697,7 @@ mod tests { // Collect skips the bad instruction but keeps the good one. let mut sink = Collect::new(); - let prog = validate( + let (prog, skipped) = validate( vec![ instr("FROBNICATE", vec![], vec!["0"], (0, 10)), instr("H", vec![], vec!["0"], (0, 1)), @@ -699,6 +707,7 @@ mod tests { ) .expect("Collect never aborts"); assert_eq!(prog.instructions.len(), 1); + assert_eq!(skipped, vec![Span::new(0, 10)]); assert!(matches!( &prog.instructions[0], Instruction::Gate(GateOp { diff --git a/crates/stim-parser/tests/fusion.rs b/crates/stim-parser/tests/fusion.rs new file mode 100644 index 000000000..ba81e9f68 --- /dev/null +++ b/crates/stim-parser/tests/fusion.rs @@ -0,0 +1,251 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Line fusion (#291). Expected outputs are `str(stim.Circuit(src))` from +//! Stim 1.15, except where noted. + +use stim_parser::diagnostics::{Collect, FailFast}; +use stim_parser::prelude::*; + +/// `(source, what Stim prints for it)`. +const STIM_PARITY: &[(&str, &str)] = &[ + // --- fused: the cases from the issue --- + ("H 0\nH 1", "H 0 1\n"), + ("CX 0 1\nCX 2 3", "CX 0 1 2 3\n"), + ("M 0\nM 1", "M 0 1\n"), + ("X_ERROR(0.1) 0\nX_ERROR(0.1) 1", "X_ERROR(0.1) 0 1\n"), + ("H[tag] 0\nH[tag] 1", "H[tag] 0 1\n"), + ("H 0\nH 0", "H 0 0\n"), // targets are not deduplicated + // --- fused: other families --- + ("R 0\nR 1", "R 0 1\n"), + ("MR(0.1) 0\nMR(0.1) 1", "MR(0.1) 0 1\n"), + ("MPP X0\nMPP Z1", "MPP X0 Z1\n"), + ("MPAD 0\nMPAD 1", "MPAD 0 1\n"), + ( + "DEPOLARIZE2(0.1) 0 1\nDEPOLARIZE2(0.1) 2 3", + "DEPOLARIZE2(0.1) 0 1 2 3\n", + ), + ( + "HERALDED_ERASE(0.1) 0\nHERALDED_ERASE(0.1) 1", + "HERALDED_ERASE(0.1) 0 1\n", + ), + ( + "I_ERROR[loss](0.1) 0\nI_ERROR[loss](0.1) 1", + "I_ERROR[loss](0.1) 0 1\n", + ), + ("CX rec[-1] 0\nCX rec[-1] 1", "CX rec[-1] 0 rec[-1] 1\n"), + // args compare as numbers, not as source text + ("X_ERROR(0.1) 0\nX_ERROR(0.10) 1", "X_ERROR(0.1) 0 1\n"), + // comments and blank lines are invisible + ("H 0\n# c\n\nH 1", "H 0 1\n"), + // fusion also happens inside REPEAT bodies + ("REPEAT 2 {\nH 0\nH 1\n}", "REPEAT 2 {\n H 0 1\n}\n"), + // runs of more than two lines + ("H 0\nH 1\nH 2\nX 3\nX 4", "H 0 1 2\nX 3 4\n"), + // --- not fused --- + ( + "X_ERROR(0.1) 0\nX_ERROR(0.2) 1", + "X_ERROR(0.1) 0\nX_ERROR(0.2) 1\n", + ), + ("M(0.1) 0\nM 1", "M(0.1) 0\nM 1\n"), + ("H[a] 0\nH[b] 1", "H[a] 0\nH[b] 1\n"), + ("H 0\nTICK\nH 1", "H 0\nTICK\nH 1\n"), + ("H 0\nX 1\nH 2", "H 0\nX 1\nH 2\n"), + ("TICK\nTICK", "TICK\nTICK\n"), + ( + "DETECTOR rec[-1]\nDETECTOR rec[-1]", + "DETECTOR\nDETECTOR\n", // rec targets on annotations are dropped by this parser + ), + ( + "QUBIT_COORDS(0.5) 0\nQUBIT_COORDS(0.5) 1", + "QUBIT_COORDS(0.5) 0\nQUBIT_COORDS(0.5) 1\n", + ), + ( + "SHIFT_COORDS(0.5)\nSHIFT_COORDS(0.5)", + "SHIFT_COORDS(0.5)\nSHIFT_COORDS(0.5)\n", + ), + ( + "CORRELATED_ERROR(0.1) 0\nCORRELATED_ERROR(0.1) 1", + "CORRELATED_ERROR(0.1) 0\nCORRELATED_ERROR(0.1) 1\n", + ), + ( + "REPEAT 2 {\nH 0\n}\nREPEAT 2 {\nH 0\n}", + "REPEAT 2 {\n H 0\n}\nREPEAT 2 {\n H 0\n}\n", + ), + ("REPEAT 2 {\nH 0\n}\nH 1", "REPEAT 2 {\n H 0\n}\nH 1\n"), + ("H 0\nREPEAT 2 {\nH 1\n}", "H 0\nREPEAT 2 {\n H 1\n}\n"), + // spelling aliases are deliberately not fused (Stim would fuse these) + ("CX 0 1\nCNOT 2 3", "CX 0 1\nCNOT 2 3\n"), + ("M 0\nMZ 1", "M 0\nMZ 1\n"), +]; + +/// Parse each source with `parse_fn` and compare the printed program. +fn assert_prints( + cases: &[(&str, &str)], + parse_fn: impl Fn(&str) -> Result, +) { + for (src, expected) in cases { + let printed = parse_fn(src) + .unwrap_or_else(|e| panic!("{src:?}: {e}")) + .to_string(); + assert_eq!(&printed, expected, "source: {src:?}"); + } +} + +#[test] +fn fusion_matches_stim() { + assert_prints(STIM_PARITY, parse); +} + +#[test] +fn per_line_arity_is_checked_before_fusing() { + // Each line alone has an odd target count; Stim rejects this too. + let err = parse("CX 0\nCX 1").unwrap_err(); + assert_eq!(err.iter().next().unwrap().code, Some("target-count")); +} + +#[test] +fn fused_span_covers_the_whole_run() { + // Spans cover the name token: first line's name to last line's name. + let src = "H 0\n# c\nH 1\nX 2"; + let p = parse(src).unwrap(); + let Instruction::Gate(op) = &p.instructions[0] else { + panic!("{:?}", p.instructions[0]); + }; + assert_eq!(&src[op.span.start..op.span.end], "H 0\n# c\nH"); + assert_eq!(op.span.line(&p.line_map), 1); +} + +// ---- extended AST: fusion runs after lowering ---------------------------- + +/// `(source, what parse_extended prints for it)`. +const EXTENDED: &[(&str, &str)] = &[ + ("S[T] 0\nS[T] 1", "S[T] 0 1\n"), + // The extended AST describes operations, not spelling: both lines are `T`. + ("S[T] 0\nT 1", "S[T] 0 1\n"), + ("T[a] 0\nT[b] 1", "S[T] 0 1\n"), + ("S_DAG[T] 0\nT 1", "S_DAG[T] 0\nS[T] 1\n"), + ( + "I[R_X(theta=0.5*pi)] 0\nI[R_X(theta=0.5*pi)] 1", + "I[R_X(theta=0.5*pi)] 0 1\n", + ), + ( + "I[R_X(theta=0.5*pi)] 0\nI[R_X(theta=0.25*pi)] 1", + "I[R_X(theta=0.5*pi)] 0\nI[R_X(theta=0.25*pi)] 1\n", + ), + ( + "I[R_X(theta=0.5*pi)] 0\nI[R_Z(theta=0.5*pi)] 1", + "I[R_X(theta=0.5*pi)] 0\nI[R_Z(theta=0.5*pi)] 1\n", + ), + ( + "I[U3(theta=0.5*pi, phi=1.0*pi, lambda=1.5*pi)] 0\nI[U3(theta=0.5*pi, phi=1.0*pi, lambda=1.5*pi)] 1", + "I[U3(theta=0.5*pi, phi=1.0*pi, lambda=1.5*pi)] 0 1\n", + ), + ( + "I_ERROR[loss](0.1) 0\nI_ERROR[loss](0.1) 1", + "I_ERROR[loss](0.1) 0 1\n", + ), + ( + "I_ERROR[loss](0.1) 0\nI_ERROR[loss](0.2) 1", + "I_ERROR[loss](0.1) 0\nI_ERROR[loss](0.2) 1\n", + ), + ( + "I_ERROR[leakage](0.1, 0.2) 0\nI_ERROR[leakage](0.1, 0.2) 1", + "I_ERROR[leakage](0.1, 0.2) 0 1\n", + ), + ( + "I_ERROR[correlated_loss](0.1) 0 1\nI_ERROR[correlated_loss](0.1) 2 3", + "I_ERROR[correlated_loss](0.1, 0.0, 0.0) 0 1 2 3\n", + ), + ( + "I_ERROR[reset_loss] 0\nI_ERROR[reset_loss] 1\nI_ERROR[reset_leakage] 2", + "I_ERROR[reset_loss] 0 1\nI_ERROR[reset_leakage] 2\n", + ), + ("H 0\nH 1\nM 0\nM 1", "H 0 1\nM 0 1\n"), + ( + "REPEAT 2 {\nS[T] 0\nT 1\n}", + "REPEAT 2 {\n S[T] 0 1\n}\n", + ), +]; + +#[test] +fn extended_fusion() { + assert_prints(EXTENDED, parse_extended); +} + +#[test] +fn vanilla_keeps_spellings_apart() { + // Plain `parse` follows Stim: different names never fuse. + assert_eq!(parse("S[T] 0\nT 1").unwrap().instructions.len(), 2); +} + +#[test] +fn lowering_checks_each_line_before_fusing() { + // Fusing first would have turned two odd target counts into a valid `0 1`. + let err = parse_extended("I_ERROR[correlated_loss](0.1) 0\nI_ERROR[correlated_loss](0.1) 1") + .unwrap_err(); + assert_eq!(err.iter().next().unwrap().code, Some("invalid-tag")); + assert!(err.to_string().starts_with("error at line 1,"), "{err}"); +} + +#[test] +fn lowering_errors_point_at_their_own_line() { + // Not reported as part of a fused `T 0 rec[-1]` starting on line 1. + let err = parse_extended("T 0\nT rec[-1]").unwrap_err(); + assert_eq!( + err.iter().next().unwrap().code, + Some("record-target-not-allowed") + ); + assert!(err.to_string().starts_with("error at line 2,"), "{err}"); +} + +#[test] +fn unfused_pipeline_keeps_one_instruction_per_line() { + let mut sink = FailFast::new(); + let p = Pipeline::new("H 0\nH 1") + .parse(&mut sink) + .and_then(|p| p.validate(&mut sink)) + .map(|p| p.finish()) + .unwrap(); + assert_eq!(p.instructions.len(), 2); + + let mut sink = FailFast::new(); + let p = Pipeline::new("H 0\nH 1") + .parse(&mut sink) + .and_then(|p| p.validate(&mut sink)) + .and_then(|p| p.lower(&mut sink)) + .map(|p| p.finish()) + .unwrap(); + assert_eq!(p.instructions.len(), 2); +} + +// ---- error recovery: never fuse across a skipped line --------------------- + +#[test] +fn does_not_fuse_across_a_line_skipped_by_validate() { + // `TIKC` may have been meant as a barrier. + let mut sink = Collect::new(); + let p = Pipeline::new("H 0\nTIKC\nH 1") + .parse(&mut sink) + .and_then(|p| p.validate(&mut sink)) + .map(|p| p.fuse().finish()) + .expect("Collect never aborts"); + assert_eq!(sink.into_items().len(), 1); + assert_eq!(p.to_string(), "H 0\nH 1\n"); +} + +#[test] +fn does_not_fuse_across_a_line_skipped_by_lower() { + let mut sink = Collect::new(); + let p = Pipeline::new("S[T] 0\nI_ERROR[bogus] 0\nS[T] 1\nREPEAT 2 {\nT 0\nI_ERROR 0\nT 1\n}") + .parse(&mut sink) + .and_then(|p| p.validate(&mut sink)) + .and_then(|p| p.lower(&mut sink)) + .map(|p| p.fuse().finish()) + .expect("Collect never aborts"); + assert_eq!(sink.into_items().len(), 2); + assert_eq!( + p.to_string(), + "S[T] 0\nS[T] 1\nREPEAT 2 {\n S[T] 0\n S[T] 1\n}\n" + ); +} diff --git a/crates/stim-parser/tests/proptest_ast.rs b/crates/stim-parser/tests/proptest_ast.rs index 262a62e35..a778d757a 100644 --- a/crates/stim-parser/tests/proptest_ast.rs +++ b/crates/stim-parser/tests/proptest_ast.rs @@ -14,15 +14,17 @@ //! Spans are zeroed before comparison: `print` doesn't know the source //! position the AST originated from, so re-parsed spans reflect the //! printed layout, not the generated one. +//! +//! The printed form is re-parsed without the fusion stage: `parse` would merge +//! adjacent compatible instructions, while the printer emits one per line. use std::sync::Arc; use proptest::prelude::*; -use stim_parser::diagnostics::{LineMap, Span}; +use stim_parser::diagnostics::{FailFast, LineMap, Span}; use stim_parser::prelude::{ AnnotationKind, AnnotationOp, Axis, ExtendedInstruction, ExtendedProgram, GateName, GateOp, - Instruction, MeasureName, MeasureOp, NoiseName, NoiseOp, Program, Target, parse, - parse_extended, + Instruction, MeasureName, MeasureOp, NoiseName, NoiseOp, Pipeline, Program, Target, }; // ---- generators -------------------------------------------------------- @@ -544,9 +546,14 @@ proptest! { fn raw_print_parse_roundtrip(mut prog in program()) { zero_spans_raw(&mut prog.instructions); let printed = format!("{prog}"); - let mut reparsed = parse(&printed).unwrap_or_else(|e| { - panic!("reparse failed for generated program: {e}\n--printed--\n{printed}") - }); + let mut sink = FailFast::new(); + let mut reparsed = Pipeline::new(&printed) + .parse(&mut sink) + .and_then(|p| p.validate(&mut sink)) + .map(|p| p.finish()) + .unwrap_or_else(|_| { + panic!("reparse failed: {:?}\n--printed--\n{printed}", sink.into_items()) + }); zero_spans_raw(&mut reparsed.instructions); prop_assert_eq!(prog, reparsed, "raw print/parse not inverse on:\n{}", printed); } @@ -557,9 +564,15 @@ proptest! { fn extended_print_parse_roundtrip(mut prog in ext_program()) { zero_spans_ext(&mut prog.instructions); let printed = format!("{prog}"); - let mut reparsed = parse_extended(&printed).unwrap_or_else(|e| { - panic!("reparse_extended failed for generated program: {e}\n--printed--\n{printed}") - }); + let mut sink = FailFast::new(); + let mut reparsed = Pipeline::new(&printed) + .parse(&mut sink) + .and_then(|p| p.validate(&mut sink)) + .and_then(|p| p.lower(&mut sink)) + .map(|p| p.finish()) + .unwrap_or_else(|_| { + panic!("reparse failed: {:?}\n--printed--\n{printed}", sink.into_items()) + }); zero_spans_ext(&mut reparsed.instructions); prop_assert_eq!(prog, reparsed, "extended print/parse not inverse on:\n{}", printed); } diff --git a/crates/stim-parser/tests/syntax.rs b/crates/stim-parser/tests/syntax.rs index 3bddf0b9d..678ab358a 100644 --- a/crates/stim-parser/tests/syntax.rs +++ b/crates/stim-parser/tests/syntax.rs @@ -51,7 +51,7 @@ fn line_numbers_are_one_indexed_and_count_blank_and_comment_lines() { #[test] fn leading_indentation_tolerated() { - let p = parse(" H 0\n\tH 1").unwrap(); + let p = parse(" H 0\n\tX 1").unwrap(); assert_eq!(p.instructions.len(), 2); } diff --git a/crates/stim-parser/tests/tags.rs b/crates/stim-parser/tests/tags.rs index 3c58c6262..4e05caf67 100644 --- a/crates/stim-parser/tests/tags.rs +++ b/crates/stim-parser/tests/tags.rs @@ -28,7 +28,8 @@ fn empty_and_absent_tags_are_empty_strings() { }; assert!(tag.is_empty()); } - assert_eq!(p.to_string(), "H 0\nH 1\n"); + // Equal (empty) tags mean the two lines fuse, exactly as in Stim. + assert_eq!(p.to_string(), "H 0 1\n"); } #[test]