Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 66 additions & 26 deletions crates/ppvm-stim/src/validate.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)
Expand All @@ -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))?;
Expand All @@ -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());
}
Expand Down Expand Up @@ -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)
),
});
}
Expand All @@ -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::<Vec<_>>()
.join("*"),
fused_from(lines)
),
});
}
Expand Down
64 changes: 64 additions & 0 deletions crates/ppvm-stim/tests/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
8 changes: 8 additions & 0 deletions crates/stim-parser/src/instructions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions crates/stim-parser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ pub fn parse(src: &str) -> Result<Program, Diagnostics> {
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)),
Expand All @@ -43,7 +44,8 @@ pub fn parse_extended(src: &str) -> Result<ExtendedProgram, Diagnostics> {
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)),
Expand Down
Loading
Loading