From 094e351bea8f0504394b563c8115c1cf0401f588 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 24 Jun 2026 11:46:11 +0200 Subject: [PATCH 1/8] feat(stim-parser): parse bare R_X/R_Y/R_Z/U3 rotation gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clifft/tsim circuits use bare rotation mnemonics (`R_Z(0.02) 0`, `U3(a,b,c) 0`) that the parser previously rejected as unknown instructions; only the tagged `I[R_Z(theta=..*pi)]` / `I[U3(...)]` forms were accepted (via tsim's shorthand_to_stim rewrite). Register R_X/R_Y/R_Z (Exact(1)) and U3 (Exact(3)) in the instruction table and lower them to the existing Rotation/U3 extended variants. The bare argument is in half-turns (clifft convention: clifft's `R_Z(a) = exp(-i*a*pi/2*Z)`, gate_data.h half-turn units), so the lowering multiplies by pi to obtain the radians theta the Rotation variant carries — making `R_Z(a)` identical to `I[R_Z(theta=a*pi)]`. Verified empirically: clifft and ppvm rotation statistics match to sampling noise across several angles. Tags are intentionally untouched here (a follow-up tightens them). ppvm-stim's exhaustive GateName matches gain the new variants: they are always lowered away before execution, so they join T/T_DAG as Ok-in-validate / unreachable-in-executor. --- crates/ppvm-stim/src/executor.rs | 5 + crates/ppvm-stim/src/validate.rs | 4 +- crates/stim-parser/src/instructions/mod.rs | 11 ++ crates/stim-parser/src/pipeline/lower.rs | 131 +++++++++++++++++++++ 4 files changed, 149 insertions(+), 2 deletions(-) diff --git a/crates/ppvm-stim/src/executor.rs b/crates/ppvm-stim/src/executor.rs index 592d31e5..3dfe894e 100644 --- a/crates/ppvm-stim/src/executor.rs +++ b/crates/ppvm-stim/src/executor.rs @@ -644,6 +644,11 @@ pub fn execute_validated( GateName::T | GateName::TDag => { unreachable!("T/T_DAG are lowered to ExtendedInstruction::T/TDag by interpret") } + GateName::RotX | GateName::RotY | GateName::RotZ | GateName::U3 => { + unreachable!( + "R_X/R_Y/R_Z/U3 are lowered to ExtendedInstruction::Rotation/U3 by the parser" + ) + } }, ExtendedInstruction::T { targets, .. } => targets.iter().for_each(|&q| tab.t(q)), ExtendedInstruction::TDag { targets, .. } => { diff --git a/crates/ppvm-stim/src/validate.rs b/crates/ppvm-stim/src/validate.rs index be560582..43fb930d 100644 --- a/crates/ppvm-stim/src/validate.rs +++ b/crates/ppvm-stim/src/validate.rs @@ -123,8 +123,8 @@ fn check_gate_supported(name: GateName, line: usize) -> Result<(), ExecError> { use GateName::*; match name { Reset | ResetZ | ResetX | ResetY | X | Y | Z | H | HXZ | S | SqrtZ | SDag | SqrtZDag - | SqrtX | SqrtXDag | SqrtY | SqrtYDag | T | TDag | Identity | CX | ZCX | CNot | CY - | ZCY | CZ | ZCZ => Ok(()), + | SqrtX | SqrtXDag | SqrtY | SqrtYDag | T | TDag | Identity | RotX | RotY | RotZ | U3 + | CX | ZCX | CNot | CY | ZCY | CZ | ZCZ => Ok(()), Swap | ISwap | ISwapDag | SqrtXX | SqrtYY | SqrtZZ | CXSwap | SwapCX | XCX | XCY | XCZ | YCX | YCY | YCZ | CXYZ | CZYX | HXY | HYZ => Err(ExecError::Unsupported { name: name.canonical_name().to_string(), diff --git a/crates/stim-parser/src/instructions/mod.rs b/crates/stim-parser/src/instructions/mod.rs index 56f4625e..1e4248f4 100644 --- a/crates/stim-parser/src/instructions/mod.rs +++ b/crates/stim-parser/src/instructions/mod.rs @@ -33,6 +33,12 @@ pub enum GateName { TDag, // Identity (carries dialect tags like S[T] is on S, but I[R_X(...)] is on Identity) Identity, + // Parameterized single-qubit rotations / U3 (continuous angle in half-turns, + // clifft/tsim dialect; also expressible as I[R_X(theta=..*pi)] / I[U3(...)]) + RotX, + RotY, + RotZ, + U3, // Two-qubit Cliffords CX, ZCX, @@ -258,6 +264,11 @@ const TABLE: &[(&str, TableEntry)] = &[ ("T", gate(G::T, NoArgs, AtLeastOne, "T")), ("T_DAG", gate(G::TDag, NoArgs, AtLeastOne, "T_DAG")), ("I", gate(G::Identity, NoArgs, AtLeastOne, "I")), + // --- Gates: parameterized rotations (angle in half-turns) --- + ("R_X", gate(G::RotX, Exact(1), AtLeastOne, "R_X")), + ("R_Y", gate(G::RotY, Exact(1), AtLeastOne, "R_Y")), + ("R_Z", gate(G::RotZ, Exact(1), AtLeastOne, "R_Z")), + ("U3", gate(G::U3, Exact(3), AtLeastOne, "U3")), // --- Gates: two-qubit Clifford --- ("CX", gate(G::CX, NoArgs, Pairs, "CX")), ("ZCX", gate(G::ZCX, NoArgs, Pairs, "ZCX")), diff --git a/crates/stim-parser/src/pipeline/lower.rs b/crates/stim-parser/src/pipeline/lower.rs index 4fc5b695..bef5714e 100644 --- a/crates/stim-parser/src/pipeline/lower.rs +++ b/crates/stim-parser/src/pipeline/lower.rs @@ -160,6 +160,44 @@ fn lower_gate( } _ => invalid_tag(&tags[0].name, "I", span, "expected exactly one tag", sink), }, + // Bare parameterized rotations. The argument is in half-turns (clifft / + // tsim convention), so `* pi` yields the radians `theta` the `Rotation` + // variant carries — making `R_Z(a)` identical to `I[R_Z(theta=a*pi)]`. + RotX | RotY | RotZ => { + let Some(targets) = qubit_targets(targets, name.canonical_name(), span, sink)? else { + return Ok(None); + }; + let axis = match name { + RotX => Axis::X, + RotY => Axis::Y, + _ => Axis::Z, + }; + let &[half_turns] = args.as_slice() else { + unreachable!("R_X/R_Y/R_Z arg count is fixed at 1 by the instruction table") + }; + Ok(Some(ExtendedInstruction::Rotation { + axis, + theta: half_turns * std::f64::consts::PI, + targets, + span, + })) + } + GateName::U3 => { + let Some(targets) = qubit_targets(targets, "U3", span, sink)? else { + return Ok(None); + }; + let &[theta, phi, lambda] = args.as_slice() else { + unreachable!("U3 arg count is fixed at 3 by the instruction table") + }; + let pi = std::f64::consts::PI; + Ok(Some(ExtendedInstruction::U3 { + theta: theta * pi, + phi: phi * pi, + lambda: lambda * pi, + targets, + span, + })) + } _ => Ok(Some(ExtendedInstruction::Gate(GateOp { name, tags, @@ -588,6 +626,99 @@ mod tests { } } + #[test] + fn bare_r_z_lowers_to_rotation_in_half_turns() { + // Bare R_Z(alpha) takes alpha in half-turns (clifft convention), so it + // lowers to the same radians theta as I[R_Z(theta=alpha*pi)]. + let prog = lower_extended("R_Z(0.5) 0").expect("lower"); + match &prog.instructions[0] { + ExtendedInstruction::Rotation { + axis, + theta, + targets, + .. + } => { + assert_eq!(*axis, Axis::Z); + assert!((*theta - 0.5 * std::f64::consts::PI).abs() < 1e-12); + assert_eq!(targets, &vec![0]); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn bare_r_x_and_r_y_lower_to_rotation() { + let px = lower_extended("R_X(0.25) 0").expect("lower"); + assert!(matches!( + &px.instructions[0], + ExtendedInstruction::Rotation { axis: Axis::X, theta, .. } + if (*theta - 0.25 * std::f64::consts::PI).abs() < 1e-12 + )); + let py = lower_extended("R_Y(-0.5) 0").expect("lower"); + assert!(matches!( + &py.instructions[0], + ExtendedInstruction::Rotation { axis: Axis::Y, theta, .. } + if (*theta + 0.5 * std::f64::consts::PI).abs() < 1e-12 + )); + } + + #[test] + fn bare_u3_lowers_in_half_turns() { + let prog = lower_extended("U3(0.5, 1.0, 1.5) 0").expect("lower"); + let pi = std::f64::consts::PI; + match &prog.instructions[0] { + ExtendedInstruction::U3 { + theta, + phi, + lambda, + targets, + .. + } => { + assert!((*theta - 0.5 * pi).abs() < 1e-12); + assert!((*phi - 1.0 * pi).abs() < 1e-12); + assert!((*lambda - 1.5 * pi).abs() < 1e-12); + assert_eq!(targets, &vec![0]); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn bare_r_z_matches_tagged_pi_form() { + // R_Z(0.3) must produce exactly what I[R_Z(theta=0.3*pi)] does. + let bare = lower_extended("R_Z(0.3) 0").expect("lower"); + let tagged = lower_extended("I[R_Z(theta=0.3*pi)] 0").expect("lower"); + match (&bare.instructions[0], &tagged.instructions[0]) { + ( + ExtendedInstruction::Rotation { + axis: a1, + theta: t1, + .. + }, + ExtendedInstruction::Rotation { + axis: a2, + theta: t2, + .. + }, + ) => { + assert_eq!(a1, a2); + assert!((t1 - t2).abs() < 1e-12); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn bare_rotation_applies_to_all_targets() { + let prog = lower_extended("R_Z(0.5) 0 1 2").expect("lower"); + match &prog.instructions[0] { + ExtendedInstruction::Rotation { targets, .. } => { + assert_eq!(targets, &vec![0, 1, 2]); + } + other => panic!("{other:?}"), + } + } + #[test] fn i_error_loss_lowers() { let prog = lower_extended("I_ERROR[loss](0.01) 0").expect("lower"); From d16117bb150011f503b592c1f475b61a4876b801 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 24 Jun 2026 14:27:06 +0200 Subject: [PATCH 2/8] feat(stim-parser)!: require *pi in rotation/U3 tag angles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror tsim's parametric-tag convention: rotation and U3 tag angles must be written in half-turns as `*pi` (e.g. `I[R_Z(theta=0.5*pi)]`), and a bare number (`I[R_Z(theta=0.5)]`) is now rejected with an `invalid-tag` diagnostic. tsim's tag parser requires the `*pi` literal (`core/parse.py`, regex `^\w+=\*pi$`) and treats the coefficient as half-turns; ppvm now refuses the ambiguous bare form rather than silently reading it as radians. Mechanics: - `TagParam::Named` gains a `had_pi` flag, captured by a new `pi_expr_flagged` grammar rule (pi_expr is now defined in terms of it). - `exact_named_params` rejects any rotation/U3 angle written without `*pi`. - The printer re-emits the half-turn form (`theta={theta/pi}*pi`); since every parser-produced rotation angle is a `*pi` multiple, theta/pi recovers the coefficient exactly (verified for the relevant float set), so print -> parse stays a fixpoint. The bare *gate* form `R_Z(0.5)` (clifft half-turn shorthand) is unchanged — only the bracketed tag form is tightened, matching tsim (shorthand `R_Z(a)` vs canonical tag `theta=a*pi`). Tests across extended/roundtrip/proptest suites move to the `*pi` form; the proptest AST generator now produces `coeff*pi` angles (the only parser-producible shape). The ppvm-python fixpoint test is updated too. --- crates/ppvm-stim/tests/executor.rs | 2 +- crates/stim-parser/src/ast/shared.rs | 9 +++- crates/stim-parser/src/pipeline/lower.rs | 52 ++++++++++++++++--- crates/stim-parser/src/pipeline/validate.rs | 3 +- crates/stim-parser/src/print/mod.rs | 26 ++++++---- crates/stim-parser/src/syntax/grammar.rs | 25 ++++++--- crates/stim-parser/tests/extended.rs | 21 ++++---- crates/stim-parser/tests/proptest_ast.rs | 10 ++-- .../stim-parser/tests/proptest_roundtrip.rs | 8 +-- crates/stim-parser/tests/roundtrip.rs | 10 ++-- crates/stim-parser/tests/tags.rs | 3 +- ppvm-python/test/test_stim_api.py | 2 +- 12 files changed, 119 insertions(+), 52 deletions(-) diff --git a/crates/ppvm-stim/tests/executor.rs b/crates/ppvm-stim/tests/executor.rs index d8030f08..4ca3a2f5 100644 --- a/crates/ppvm-stim/tests/executor.rs +++ b/crates/ppvm-stim/tests/executor.rs @@ -46,7 +46,7 @@ fn rx_pi_flips_qubit() { #[test] fn u3_pi_flip_via_y_axis() { - let (results, _) = run("I[U3(theta=1.0*pi, phi=0.0, lambda=0.0)] 0\nM 0", 1); + let (results, _) = run("I[U3(theta=1.0*pi, phi=0.0*pi, lambda=0.0*pi)] 0\nM 0", 1); assert_eq!(results, vec![Some(true)]); } diff --git a/crates/stim-parser/src/ast/shared.rs b/crates/stim-parser/src/ast/shared.rs index ec6c8279..b72c03f4 100644 --- a/crates/stim-parser/src/ast/shared.rs +++ b/crates/stim-parser/src/ast/shared.rs @@ -74,7 +74,14 @@ pub struct Tag { #[derive(Debug, Clone, PartialEq)] pub enum TagParam { Positional(f64), - Named { key: String, value: f64 }, + /// A `key=value` tag parameter. `had_pi` records whether the value was + /// written as a `*pi` (or bare `pi`) expression — rotation/U3 tags + /// require it (half-turn convention), and the printer re-emits `*pi`. + Named { + key: String, + value: f64, + had_pi: bool, + }, } /// The rotation axis for an extended-dialect `R_X` / `R_Y` / `R_Z` rotation. diff --git a/crates/stim-parser/src/pipeline/lower.rs b/crates/stim-parser/src/pipeline/lower.rs index bef5714e..0ca4660f 100644 --- a/crates/stim-parser/src/pipeline/lower.rs +++ b/crates/stim-parser/src/pipeline/lower.rs @@ -483,7 +483,7 @@ fn exact_named_params( sink, ); } - TagParam::Named { key, value } => { + TagParam::Named { key, value, had_pi } => { let Some(index) = required.iter().position(|required_key| key == required_key) else { return invalid_tag( @@ -503,6 +503,17 @@ fn exact_named_params( sink, ); } + // Rotation/U3 angles are in half-turns: require the `*pi` form, + // mirroring tsim, so a bare number can't be mistaken for radians. + if !had_pi { + return invalid_tag( + &tag.name, + instruction, + span, + format!("parameter '{key}' must be written as *pi (half-turns)"), + sink, + ); + } seen[index] = true; values[index] = *value; } @@ -590,7 +601,7 @@ mod tests { #[test] fn identity_rotation_x_lowers() { - let prog = lower_extended("I[R_X(theta=0.5)] 0").expect("lower"); + let prog = lower_extended("I[R_X(theta=0.5*pi)] 0").expect("lower"); match &prog.instructions[0] { ExtendedInstruction::Rotation { axis, @@ -599,7 +610,7 @@ mod tests { .. } => { assert_eq!(*axis, Axis::X); - assert_eq!(*theta, 0.5); + assert!((*theta - 0.5 * std::f64::consts::PI).abs() < 1e-12); assert_eq!(targets, &vec![0]); } other => panic!("{other:?}"), @@ -608,7 +619,8 @@ mod tests { #[test] fn identity_u3_lowers() { - let prog = lower_extended("I[U3(theta=0.5, phi=1.0, lambda=1.5)] 0").expect("lower"); + let prog = + lower_extended("I[U3(theta=0.5*pi, phi=1.0*pi, lambda=1.5*pi)] 0").expect("lower"); match &prog.instructions[0] { ExtendedInstruction::U3 { theta, @@ -617,9 +629,10 @@ mod tests { targets, .. } => { - assert_eq!(*theta, 0.5); - assert_eq!(*phi, 1.0); - assert_eq!(*lambda, 1.5); + let pi = std::f64::consts::PI; + assert!((*theta - 0.5 * pi).abs() < 1e-12); + assert!((*phi - 1.0 * pi).abs() < 1e-12); + assert!((*lambda - 1.5 * pi).abs() < 1e-12); assert_eq!(targets, &vec![0]); } other => panic!("{other:?}"), @@ -719,6 +732,31 @@ mod tests { } } + #[test] + fn rotation_tag_without_pi_is_rejected() { + // Mirror tsim: rotation tag angles must be written as *pi (half-turns). + let err = lower_extended("I[R_Z(theta=0.5)] 0").unwrap_err(); + assert_eq!(err.last().unwrap().code, Some("invalid-tag")); + } + + #[test] + fn u3_tag_without_pi_is_rejected() { + let err = lower_extended("I[U3(theta=0.5, phi=1.0, lambda=1.5)] 0").unwrap_err(); + assert_eq!(err.last().unwrap().code, Some("invalid-tag")); + } + + #[test] + fn rotation_tag_with_pi_is_accepted() { + let prog = lower_extended("I[R_Z(theta=0.5*pi)] 0").expect("lower"); + match &prog.instructions[0] { + ExtendedInstruction::Rotation { axis, theta, .. } => { + assert_eq!(*axis, Axis::Z); + assert!((*theta - 0.5 * std::f64::consts::PI).abs() < 1e-12); + } + other => panic!("{other:?}"), + } + } + #[test] fn i_error_loss_lowers() { let prog = lower_extended("I_ERROR[loss](0.01) 0").expect("lower"); diff --git a/crates/stim-parser/src/pipeline/validate.rs b/crates/stim-parser/src/pipeline/validate.rs index b3354e67..343ed29e 100644 --- a/crates/stim-parser/src/pipeline/validate.rs +++ b/crates/stim-parser/src/pipeline/validate.rs @@ -548,6 +548,7 @@ mod tests { TagParam::Named { key: "theta".to_string(), value: 0.25, + had_pi: false, }, ], }], @@ -559,7 +560,7 @@ mod tests { assert!(matches!(tags[0].params[0], TagParam::Positional(0.5))); assert!(matches!( &tags[0].params[1], - TagParam::Named { key, value } if key == "theta" && *value == 0.25 + TagParam::Named { key, value, .. } if key == "theta" && *value == 0.25 )); } other => panic!("{other:?}"), diff --git a/crates/stim-parser/src/print/mod.rs b/crates/stim-parser/src/print/mod.rs index 6effd7b2..bbe1523a 100644 --- a/crates/stim-parser/src/print/mod.rs +++ b/crates/stim-parser/src/print/mod.rs @@ -69,8 +69,12 @@ fn write_tags(out: &mut dyn fmt::Write, tags: &[Tag]) -> fmt::Result { } match p { TagParam::Positional(v) => write!(out, "{}", FloatLit(*v))?, - TagParam::Named { key, value } => { - write!(out, "{key}={}", FloatLit(*value))?; + TagParam::Named { key, value, had_pi } => { + if *had_pi { + write!(out, "{key}={}*pi", FloatLit(*value / std::f64::consts::PI))?; + } else { + write!(out, "{key}={}", FloatLit(*value))?; + } } } } @@ -291,7 +295,10 @@ impl StimPrint for ExtendedInstruction { Axis::Y => "R_Y", Axis::Z => "R_Z", }; - write!(out, "I[{}(theta={})]", axis_tag, FloatLit(*theta))?; + // theta is radians; re-emit the half-turn `*pi` form the + // rotation tags require (see exact_named_params). + let pi = std::f64::consts::PI; + write!(out, "I[{}(theta={}*pi)]", axis_tag, FloatLit(*theta / pi))?; write_usize_targets(out, targets)?; } ExtendedInstruction::U3 { @@ -301,12 +308,13 @@ impl StimPrint for ExtendedInstruction { targets, .. } => { + let pi = std::f64::consts::PI; write!( out, - "I[U3(theta={}, phi={}, lambda={})]", - FloatLit(*theta), - FloatLit(*phi), - FloatLit(*lambda), + "I[U3(theta={}*pi, phi={}*pi, lambda={}*pi)]", + FloatLit(*theta / pi), + FloatLit(*phi / pi), + FloatLit(*lambda / pi), )?; write_usize_targets(out, targets)?; } @@ -381,9 +389,9 @@ mod tests { #[test] fn extended_printed_form_lowers_sugar_into_canonical_stim() { - let src = "S[T] 0\nI[R_X(theta=0.25)] 1\nI_ERROR[loss](0.01) 2\n"; + let src = "S[T] 0\nI[R_X(theta=0.25*pi)] 1\nI_ERROR[loss](0.01) 2\n"; let ast = parse_extended(src).unwrap(); - let expected = "S[T] 0\nI[R_X(theta=0.25)] 1\nI_ERROR[loss](0.01) 2\n"; + let expected = "S[T] 0\nI[R_X(theta=0.25*pi)] 1\nI_ERROR[loss](0.01) 2\n"; assert_eq!(ast.to_stim(), expected); } diff --git a/crates/stim-parser/src/syntax/grammar.rs b/crates/stim-parser/src/syntax/grammar.rs index 9ff4dbed..d32c2203 100644 --- a/crates/stim-parser/src/syntax/grammar.rs +++ b/crates/stim-parser/src/syntax/grammar.rs @@ -82,21 +82,29 @@ pub(crate) fn signed_float<'src>() -> impl Parser<'src, &'src str, f64, Extra<'s .map(|s: &str| s.parse::().expect("validated by combinator shape")) } -/// Pi-expression: `pi`, `*pi`, or plain number. Evaluates to f64. -pub(crate) fn pi_expr<'src>() -> impl Parser<'src, &'src str, f64, Extra<'src>> + Clone { - let pi_kw = just("pi").to(std::f64::consts::PI); +/// Pi-expression, paired with whether `pi` actually appeared in the source: +/// `pi` -> `(PI, true)`, `*pi` -> `(num*PI, true)`, `` -> `(num, false)`. +/// The flag lets rotation/U3 tags enforce the half-turn `*pi` convention. +pub(crate) fn pi_expr_flagged<'src>() +-> impl Parser<'src, &'src str, (f64, bool), Extra<'src>> + Clone { + let pi_kw = just("pi").to((std::f64::consts::PI, true)); let num_then_pi = signed_float() .then(inline_pad().ignore_then(just("*pi")).or_not()) .map(|(n, suffix)| { if suffix.is_some() { - n * std::f64::consts::PI + (n * std::f64::consts::PI, true) } else { - n + (n, false) } }); choice((pi_kw, num_then_pi)) } +/// Pi-expression: `pi`, `*pi`, or plain number. Evaluates to f64. +pub(crate) fn pi_expr<'src>() -> impl Parser<'src, &'src str, f64, Extra<'src>> + Clone { + pi_expr_flagged().map(|(value, _)| value) +} + use crate::ast::shared::{Tag, TagParam}; /// `=` (Named) or `` (Positional). @@ -105,8 +113,8 @@ pub(crate) fn tag_param<'src>() -> impl Parser<'src, &'src str, TagParam, Extra< .then_ignore(inline_pad()) .then_ignore(just('=')) .then_ignore(inline_pad()) - .then(pi_expr()) - .map(|(key, value)| TagParam::Named { key, value }); + .then(pi_expr_flagged()) + .map(|(key, (value, had_pi))| TagParam::Named { key, value, had_pi }); let positional = pi_expr().map(TagParam::Positional); choice((named, positional)) } @@ -343,9 +351,10 @@ mod tests { assert_eq!(t.name, "R_X"); assert_eq!(t.params.len(), 1); match &t.params[0] { - TagParam::Named { key, value } => { + TagParam::Named { key, value, had_pi } => { assert_eq!(key, "theta"); assert!((value - 0.5 * std::f64::consts::PI).abs() < 1e-12); + assert!(had_pi); } other => panic!("{other:?}"), } diff --git a/crates/stim-parser/tests/extended.rs b/crates/stim-parser/tests/extended.rs index 1d866acf..19100f58 100644 --- a/crates/stim-parser/tests/extended.rs +++ b/crates/stim-parser/tests/extended.rs @@ -122,7 +122,7 @@ fn repeat_recurses_into_body() { #[test] fn repeat_promotes_extended_rotation_in_body() { - let p = parse_ok("REPEAT 2 { I[R_X(theta=0.25)] 0 }\n"); + let p = parse_ok("REPEAT 2 { I[R_X(theta=0.25*pi)] 0 }\n"); match &p.instructions[0] { ExtendedInstruction::Repeat { count, body, .. } => { assert_eq!(*count, 2); @@ -135,7 +135,7 @@ fn repeat_promotes_extended_rotation_in_body() { .. } => { assert!(matches!(axis, Axis::X)); - approx_eq(*theta, 0.25); + approx_eq(*theta, 0.25 * std::f64::consts::PI); assert_eq!(targets, &vec![0]); } other => panic!("{other:?}"), @@ -277,11 +277,11 @@ fn i_r_x_promotes_to_rotation_x() { #[test] fn i_r_y_promotes_to_rotation_y() { - let p = parse_ok("I[R_Y(theta=0.25)] 0\n"); + let p = parse_ok("I[R_Y(theta=0.25*pi)] 0\n"); match &p.instructions[0] { ExtendedInstruction::Rotation { axis, theta, .. } => { assert!(matches!(axis, Axis::Y)); - approx_eq(*theta, 0.25); + approx_eq(*theta, 0.25 * std::f64::consts::PI); } other => panic!("{other:?}"), } @@ -289,11 +289,11 @@ fn i_r_y_promotes_to_rotation_y() { #[test] fn i_r_z_promotes_to_rotation_z() { - let p = parse_ok("I[R_Z(theta=0.1)] 0\n"); + let p = parse_ok("I[R_Z(theta=0.1*pi)] 0\n"); match &p.instructions[0] { ExtendedInstruction::Rotation { axis, theta, .. } => { assert!(matches!(axis, Axis::Z)); - approx_eq(*theta, 0.1); + approx_eq(*theta, 0.1 * std::f64::consts::PI); } other => panic!("{other:?}"), } @@ -301,7 +301,7 @@ fn i_r_z_promotes_to_rotation_z() { #[test] fn i_u3_promotes_to_u3() { - let p = parse_ok("I[U3(theta=0.1, phi=0.2, lambda=0.3)] 0\n"); + let p = parse_ok("I[U3(theta=0.1*pi, phi=0.2*pi, lambda=0.3*pi)] 0\n"); match &p.instructions[0] { ExtendedInstruction::U3 { theta, @@ -310,9 +310,10 @@ fn i_u3_promotes_to_u3() { targets, span, } => { - approx_eq(*theta, 0.1); - approx_eq(*phi, 0.2); - approx_eq(*lambda, 0.3); + let pi = std::f64::consts::PI; + approx_eq(*theta, 0.1 * pi); + approx_eq(*phi, 0.2 * pi); + approx_eq(*lambda, 0.3 * pi); assert_eq!(targets, &vec![0]); assert_eq!(span.line(&p.line_map), 1); } diff --git a/crates/stim-parser/tests/proptest_ast.rs b/crates/stim-parser/tests/proptest_ast.rs index 392aa0d1..a7253800 100644 --- a/crates/stim-parser/tests/proptest_ast.rs +++ b/crates/stim-parser/tests/proptest_ast.rs @@ -398,15 +398,17 @@ fn ext_flat() -> impl Strategy { ) .prop_map(|(axis, theta, targets)| ExtendedInstruction::Rotation { axis, - theta, + // Rotation angles are stored in radians but always originate from + // a `*pi` tag, so only half-turn multiples are parser-producible. + theta: theta * std::f64::consts::PI, targets, span: span0(), }), (float_lit(), float_lit(), float_lit(), one_q_targets()).prop_map( |(theta, phi, lambda, targets)| ExtendedInstruction::U3 { - theta, - phi, - lambda, + theta: theta * std::f64::consts::PI, + phi: phi * std::f64::consts::PI, + lambda: lambda * std::f64::consts::PI, targets, span: span0(), } diff --git a/crates/stim-parser/tests/proptest_roundtrip.rs b/crates/stim-parser/tests/proptest_roundtrip.rs index 1f21cf56..e276fa59 100644 --- a/crates/stim-parser/tests/proptest_roundtrip.rs +++ b/crates/stim-parser/tests/proptest_roundtrip.rs @@ -40,10 +40,10 @@ fn instruction_fragment() -> impl Strategy { // Tagged sugar Just("S[T] 0\n".to_string()), Just("S_DAG[T] 1\n".to_string()), - Just("I[R_X(theta=0.5)] 0\n".to_string()), - Just("I[R_Y(theta=1.25)] 1\n".to_string()), - Just("I[R_Z(theta=-0.5)] 2\n".to_string()), - Just("I[U3(theta=0.5, phi=1.0, lambda=1.5)] 0\n".to_string()), + Just("I[R_X(theta=0.5*pi)] 0\n".to_string()), + Just("I[R_Y(theta=1.25*pi)] 1\n".to_string()), + Just("I[R_Z(theta=-0.5*pi)] 2\n".to_string()), + Just("I[U3(theta=0.5*pi, phi=1.0*pi, lambda=1.5*pi)] 0\n".to_string()), // Noise Just("DEPOLARIZE1(0.05) 0\n".to_string()), Just("DEPOLARIZE2(0.05) 0 1\n".to_string()), diff --git a/crates/stim-parser/tests/roundtrip.rs b/crates/stim-parser/tests/roundtrip.rs index 36ca1560..81e1b98f 100644 --- a/crates/stim-parser/tests/roundtrip.rs +++ b/crates/stim-parser/tests/roundtrip.rs @@ -77,9 +77,9 @@ const EXTENDED_CORPUS: &[(&str, &str)] = &[ ("t_sugar", "S[T] 0 1\nS_DAG[T] 2\n"), ( "rotations", - "I[R_X(theta=0.5)] 0\nI[R_Y(theta=1.25)] 1\nI[R_Z(theta=-0.5)] 2\n", + "I[R_X(theta=0.5*pi)] 0\nI[R_Y(theta=1.25*pi)] 1\nI[R_Z(theta=-0.5*pi)] 2\n", ), - ("u3", "I[U3(theta=0.5, phi=1.0, lambda=1.5)] 0\n"), + ("u3", "I[U3(theta=0.5*pi, phi=1.0*pi, lambda=1.5*pi)] 0\n"), ("loss", "I_ERROR[loss](0.01) 0 1\n"), ( "correlated_loss", @@ -88,7 +88,7 @@ const EXTENDED_CORPUS: &[(&str, &str)] = &[ ("mpad_bits", "MPAD 0 1 0\nMPAD(0.01) 1 1 0 0\n"), ( "extended_in_repeat", - "REPEAT 3 {\n S[T] 0\n I[R_Z(theta=0.25)] 0\n M 0\n}\n", + "REPEAT 3 {\n S[T] 0\n I[R_Z(theta=0.25*pi)] 0\n M 0\n}\n", ), ( "mixed_extended_and_vanilla", @@ -160,12 +160,12 @@ REPEAT 2 { #[test] fn extended_printed_form_lowers_sugar_into_canonical_stim() { - let src = "S[T] 0\nI[R_X(theta=0.25)] 1\nI_ERROR[loss](0.01) 2\n"; + let src = "S[T] 0\nI[R_X(theta=0.25*pi)] 1\nI_ERROR[loss](0.01) 2\n"; let ast = parse_extended(src).unwrap(); let printed = format!("{ast}"); let expected = "\ S[T] 0 -I[R_X(theta=0.25)] 1 +I[R_X(theta=0.25*pi)] 1 I_ERROR[loss](0.01) 2 "; assert_eq!(printed, expected); diff --git a/crates/stim-parser/tests/tags.rs b/crates/stim-parser/tests/tags.rs index bee977b2..5898f0a1 100644 --- a/crates/stim-parser/tests/tags.rs +++ b/crates/stim-parser/tests/tags.rs @@ -30,9 +30,10 @@ fn parse_tag_named_param_pi_expr() { assert_eq!(tags.len(), 1); assert_eq!(tags[0].name, "R_X"); match &tags[0].params[..] { - [TagParam::Named { key, value }] => { + [TagParam::Named { key, value, had_pi }] => { assert_eq!(key, "theta"); approx_eq(*value, 0.5 * std::f64::consts::PI); + assert!(had_pi); } other => panic!("{other:?}"), } diff --git a/ppvm-python/test/test_stim_api.py b/ppvm-python/test/test_stim_api.py index 22122ce4..d537a435 100644 --- a/ppvm-python/test/test_stim_api.py +++ b/ppvm-python/test/test_stim_api.py @@ -180,7 +180,7 @@ def test_odd_two_qubit_sequence_raises_value_error(): [ "H 0\nCX 0 1\nM 0 1\n", "REPEAT 3 {\n X 0\n M 0\n}\n", - "S[T] 0\nI[R_X(theta=0.25)] 1\nI_ERROR[loss](0.01) 2\n", + "S[T] 0\nI[R_X(theta=0.25*pi)] 1\nI_ERROR[loss](0.01) 2\n", "MR 0\nCX rec[-1] 0\n", "MPP X0*Y1\nM 2\n", ], From a6fd2a75e72347b31ae28e6f5903ab2f0239aa33 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 24 Jun 2026 14:56:59 +0200 Subject: [PATCH 3/8] fix(stim-parser): reject tags on bare R_X/R_Y/R_Z/U3 gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare-rotation lowering arms bound `tags` but never used them, so a tag on a bare rotation mnemonic (e.g. `R_Z[foo](0.5)`) parsed and was silently dropped — lossy on parse->lower->print and able to mask a typo'd tag. A tag has no meaning on the bare mnemonics, so reject a non-empty tag list with an `invalid-tag` diagnostic pointing at the argument form. (The pre-existing T/T_DAG arms drop tags the same way; left untouched here as out of scope for this change.) --- crates/stim-parser/src/pipeline/lower.rs | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/stim-parser/src/pipeline/lower.rs b/crates/stim-parser/src/pipeline/lower.rs index 0ca4660f..811d6c61 100644 --- a/crates/stim-parser/src/pipeline/lower.rs +++ b/crates/stim-parser/src/pipeline/lower.rs @@ -164,6 +164,15 @@ fn lower_gate( // tsim convention), so `* pi` yields the radians `theta` the `Rotation` // variant carries — making `R_Z(a)` identical to `I[R_Z(theta=a*pi)]`. RotX | RotY | RotZ => { + if let Some(tag) = tags.first() { + return invalid_tag( + &tag.name, + name.canonical_name(), + span, + "bare rotation gates take no tags; pass the angle as an argument (e.g. R_Z(0.5))", + sink, + ); + } let Some(targets) = qubit_targets(targets, name.canonical_name(), span, sink)? else { return Ok(None); }; @@ -183,6 +192,15 @@ fn lower_gate( })) } GateName::U3 => { + if let Some(tag) = tags.first() { + return invalid_tag( + &tag.name, + "U3", + span, + "bare U3 takes no tags; pass the angles as arguments (e.g. U3(0.5, 1.0, 1.5))", + sink, + ); + } let Some(targets) = qubit_targets(targets, "U3", span, sink)? else { return Ok(None); }; @@ -732,6 +750,20 @@ mod tests { } } + #[test] + fn bare_rotation_with_tag_is_rejected() { + // A tag on a bare rotation mnemonic has no meaning; reject it rather + // than silently dropping it on parse -> lower -> print. + let err = lower_extended("R_Z[T](0.5) 0").unwrap_err(); + assert_eq!(err.last().unwrap().code, Some("invalid-tag")); + } + + #[test] + fn bare_u3_with_tag_is_rejected() { + let err = lower_extended("U3[foo](0.5, 1.0, 1.5) 0").unwrap_err(); + assert_eq!(err.last().unwrap().code, Some("invalid-tag")); + } + #[test] fn rotation_tag_without_pi_is_rejected() { // Mirror tsim: rotation tag angles must be written as *pi (half-turns). From e868659b6df628c3eb9d8cab0f4eeabcb69632a0 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 24 Jun 2026 15:00:55 +0200 Subject: [PATCH 4/8] fix(stim-parser): reject tags on bare T/T_DAG gates Follow-up to the bare R_X/R_Y/R_Z/U3 tag fix: the native T / T_DAG arms dropped any tag the same way (e.g. `T[foo] 0` parsed and silently lost `[foo]`). Reject a non-empty tag list with an `invalid-tag` diagnostic that points at the canonical tagged spelling, S[T] / S_DAG[T]. --- crates/stim-parser/src/pipeline/lower.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/stim-parser/src/pipeline/lower.rs b/crates/stim-parser/src/pipeline/lower.rs index 811d6c61..7e053aba 100644 --- a/crates/stim-parser/src/pipeline/lower.rs +++ b/crates/stim-parser/src/pipeline/lower.rs @@ -104,6 +104,15 @@ fn lower_gate( match name { // Native T / T_DAG mnemonics lower to the same sugar as `S[T]` / `S_DAG[T]`. T | TDag => { + if let Some(tag) = tags.first() { + return invalid_tag( + &tag.name, + name.canonical_name(), + span, + "bare T/T_DAG take no tags; use S[T] / S_DAG[T] for the tagged form", + sink, + ); + } let Some(targets) = qubit_targets(targets, name.canonical_name(), span, sink)? else { return Ok(None); }; @@ -764,6 +773,20 @@ mod tests { assert_eq!(err.last().unwrap().code, Some("invalid-tag")); } + #[test] + fn bare_t_with_tag_is_rejected() { + // A tag on bare T/T_DAG is meaningless (the tagged form is S[T]); reject + // it rather than silently dropping it. + let err = lower_extended("T[foo] 0").unwrap_err(); + assert_eq!(err.last().unwrap().code, Some("invalid-tag")); + } + + #[test] + fn bare_t_dag_with_tag_is_rejected() { + let err = lower_extended("T_DAG[foo] 0").unwrap_err(); + assert_eq!(err.last().unwrap().code, Some("invalid-tag")); + } + #[test] fn rotation_tag_without_pi_is_rejected() { // Mirror tsim: rotation tag angles must be written as *pi (half-turns). From a97599c333667d90c011244edc04b482f2e159d1 Mon Sep 17 00:00:00 2001 From: Roger-luo Date: Wed, 24 Jun 2026 13:27:30 -0400 Subject: [PATCH 5/8] fix(stim-parser): reject *pi in bare R_X/R_Y/R_Z/U3 args (half-turn double-scale) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare rotation/U3 args are in half-turns and get multiplied by pi when lowering (R_Z(0.5) == I[R_Z(theta=0.5*pi)]). But args flow through the generic pi_expr parser, so R_Z(0.5*pi) / R_Z(pi) were silently scaled by pi twice (0.5*pi*pi instead of 0.5*pi) with no diagnostic — the inverse of the ambiguity the *pi tag requirement was added to prevent. args_block now carries a had_pi flag (via the existing pi_expr_flagged); the validator rejects a *pi argument on the bare R_X/R_Y/R_Z/U3 mnemonics with a clear half-turn-arg error. pi-expressions remain valid in args for every other instruction (stim.rs compatibility, e.g. X_ERROR(0.5*pi)). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/stim-parser/src/pipeline/lower.rs | 36 +++++++++++ crates/stim-parser/src/pipeline/validate.rs | 72 ++++++++++++++++++++- crates/stim-parser/src/syntax/grammar.rs | 52 +++++++++------ crates/stim-parser/src/syntax/raw.rs | 4 ++ 4 files changed, 143 insertions(+), 21 deletions(-) diff --git a/crates/stim-parser/src/pipeline/lower.rs b/crates/stim-parser/src/pipeline/lower.rs index 7e053aba..d32c47a9 100644 --- a/crates/stim-parser/src/pipeline/lower.rs +++ b/crates/stim-parser/src/pipeline/lower.rs @@ -767,6 +767,42 @@ mod tests { assert_eq!(err.last().unwrap().code, Some("invalid-tag")); } + #[test] + fn bare_rotation_with_pi_arg_is_rejected() { + // The bare arg is in half-turns and gets multiplied by pi when + // lowering, so `R_Z(0.5*pi)` would scale by pi twice. The `*pi` form + // is only valid in the tag (I[R_Z(theta=0.5*pi)]); reject it here. + for src in ["R_X(0.5*pi) 0", "R_Y(1*pi) 0", "R_Z(0.5*pi) 0", "R_Z(pi) 0"] { + let err = lower_extended(src).unwrap_err(); + assert_eq!(err.last().unwrap().code, Some("half-turn-arg"), "src={src}"); + } + } + + #[test] + fn bare_u3_with_pi_arg_is_rejected() { + // Every U3 angle is in half-turns; a `*pi` (or bare `pi`) in any slot + // would double-scale. + for src in [ + "U3(0.5*pi, 0.0, 0.0) 0", + "U3(0.0, 0.5*pi, 0.0) 0", + "U3(0.0, 0.0, pi) 0", + ] { + let err = lower_extended(src).unwrap_err(); + assert_eq!(err.last().unwrap().code, Some("half-turn-arg"), "src={src}"); + } + } + + #[test] + fn bare_rotation_plain_arg_is_accepted() { + // The half-turn plain form is the canonical bare spelling and lowers + // to the same radians as the tagged `*pi` form. + let prog = lower_extended("R_Z(0.5) 0").expect("lower"); + assert!(matches!( + &prog.instructions[0], + ExtendedInstruction::Rotation { axis: Axis::Z, .. } + )); + } + #[test] fn bare_u3_with_tag_is_rejected() { let err = lower_extended("U3[foo](0.5, 1.0, 1.5) 0").unwrap_err(); diff --git a/crates/stim-parser/src/pipeline/validate.rs b/crates/stim-parser/src/pipeline/validate.rs index 343ed29e..34d48a3a 100644 --- a/crates/stim-parser/src/pipeline/validate.rs +++ b/crates/stim-parser/src/pipeline/validate.rs @@ -14,7 +14,9 @@ use crate::ast::shared::{ }; use crate::ast::vanilla::{Instruction, Program}; use crate::diagnostics::{Aborted, DiagnosticSink, LineMap, Span}; -use crate::instructions::{ArgCount, EntryKind, MeasureName, TableEntry, TargetArity, lookup}; +use crate::instructions::{ + ArgCount, EntryKind, GateName, MeasureName, TableEntry, TargetArity, lookup, +}; use crate::syntax::{RawSyntaxNode, RawSyntaxTree, RawTarget}; use super::emit_skip; @@ -64,6 +66,7 @@ fn validate_node( name, tags, args, + args_had_pi, targets, span, } => { @@ -148,6 +151,31 @@ fn validate_node( ); } + // Bare rotation/U3 mnemonics take their angle in half-turns and + // multiply by pi when lowering, so a `*pi` argument would be scaled + // by pi twice. The `*pi` form is a tag convention + // (`I[R_Z(theta=0.5*pi)]`); reject it on the bare gate with a clear + // message rather than silently double-scaling. + if args_had_pi + && matches!( + entry.kind, + EntryKind::Gate( + GateName::RotX | GateName::RotY | GateName::RotZ | GateName::U3 + ) + ) + { + return emit_skip( + sink, + span, + "half-turn-arg", + format!( + "'{canonical}' takes angle arguments in half-turns; remove the '*pi' \ + suffix (the '*pi' form is only valid in tags, e.g. \ + I[R_Z(theta=0.5*pi)])" + ), + ); + } + let divisor = match target_rule { TargetArity::Any => None, TargetArity::AtLeastOne => Some(1), @@ -339,6 +367,7 @@ mod tests { name: name.to_string(), tags: vec![], args, + args_had_pi: false, targets: targets .into_iter() .map(|t| RawTarget { @@ -360,6 +389,7 @@ mod tests { name: name.to_string(), tags: vec![], args, + args_had_pi: false, targets: targets .into_iter() .map(|(text, span)| RawTarget { @@ -376,6 +406,7 @@ mod tests { name: name.to_string(), tags, args: vec![], + args_had_pi: false, targets: vec![RawTarget { text: "0".to_string(), span: SimpleSpan::from(2..3), @@ -454,6 +485,45 @@ mod tests { assert_eq!(items[0].code, Some("arg-count")); } + #[test] + fn rotation_gate_with_pi_arg_is_half_turn_arg_error() { + // A bare rotation/U3 mnemonic whose arg used the `*pi` form is + // rejected: the half-turn arg already gets multiplied by pi when + // lowering, so accepting `*pi` would double-scale. + let node = RawSyntaxNode::Instruction { + name: "R_Z".to_string(), + tags: vec![], + args: vec![std::f64::consts::PI], + args_had_pi: true, + targets: vec![RawTarget { + text: "0".to_string(), + span: SimpleSpan::from(0..1), + }], + span: SimpleSpan::from(0..3), + }; + let items = collect_errors(vec![node], &lm()); + assert_eq!(items[0].code, Some("half-turn-arg")); + } + + #[test] + fn non_rotation_gate_keeps_pi_arg() { + // Only the bare rotation/U3 mnemonics reject `*pi` args; for everything + // else the pi-expression stays valid (stim.rs compatibility). + let node = RawSyntaxNode::Instruction { + name: "X_ERROR".to_string(), + tags: vec![], + args: vec![0.5 * std::f64::consts::PI], + args_had_pi: true, + targets: vec![RawTarget { + text: "0".to_string(), + span: SimpleSpan::from(0..1), + }], + span: SimpleSpan::from(0..7), + }; + let prog = ok_program(vec![node], &lm()); + assert!(matches!(&prog.instructions[0], Instruction::Noise(_))); + } + #[test] fn target_pair_errors() { let items = collect_errors( diff --git a/crates/stim-parser/src/syntax/grammar.rs b/crates/stim-parser/src/syntax/grammar.rs index d32c2203..de1c2a65 100644 --- a/crates/stim-parser/src/syntax/grammar.rs +++ b/crates/stim-parser/src/syntax/grammar.rs @@ -141,9 +141,14 @@ pub(crate) fn tags_block<'src>() -> impl Parser<'src, &'src str, Vec, Extra .delimited_by(just('[').then(inline_pad()), inline_pad().then(just(']'))) } -/// `(pi_expr, pi_expr, ...)`. -pub(crate) fn args_block<'src>() -> impl Parser<'src, &'src str, Vec, Extra<'src>> + Clone { - pi_expr() +/// `(pi_expr, pi_expr, ...)`. Each argument is paired with whether it was +/// written with the `*pi` (half-turn) form. Most callers ignore the flag, +/// but the bare rotation mnemonics (`R_X`/`R_Y`/`R_Z`/`U3`) take their angle +/// in half-turns and multiply by pi when lowering, so they reject a `*pi` +/// argument — which would otherwise be multiplied by pi twice. +pub(crate) fn args_block<'src>() +-> impl Parser<'src, &'src str, Vec<(f64, bool)>, Extra<'src>> + Clone { + pi_expr_flagged() .separated_by(inline_pad().then(just(',')).then(inline_pad())) .allow_trailing() .collect::>() @@ -168,22 +173,21 @@ pub(crate) fn target_lexeme<'src>() -> impl Parser<'src, &'src str, RawTarget, E }) } -/// ` []? ()?`. Returns name, tags, args, and the -/// span of the identifier (used for line-number reporting). +/// ` []? ()?`. Returns name, tags, args, whether any +/// argument used the `*pi` (half-turn) form, and the span of the identifier +/// (used for line-number reporting). pub(crate) fn instruction_head<'src>() --> impl Parser<'src, &'src str, (String, Vec, Vec, SimpleSpan), Extra<'src>> + Clone -{ +-> impl Parser<'src, &'src str, (String, Vec, Vec, bool, SimpleSpan), Extra<'src>> ++ Clone { ident() .map_with(|name, e| (name, e.span())) .then(tags_block().or_not()) .then(args_block().or_not()) .map(|(((name, span), tags), args)| { - ( - name, - tags.unwrap_or_default(), - args.unwrap_or_default(), - span, - ) + let args = args.unwrap_or_default(); + let args_had_pi = args.iter().any(|&(_, had_pi)| had_pi); + let values = args.into_iter().map(|(value, _)| value).collect(); + (name, tags.unwrap_or_default(), values, args_had_pi, span) }) } @@ -212,10 +216,11 @@ pub(crate) fn instruction_line<'src>() .collect::>(), ) .map( - |((name, tags, args, span), targets)| RawSyntaxNode::Instruction { + |((name, tags, args, args_had_pi, span), targets)| RawSyntaxNode::Instruction { name, tags, args, + args_had_pi, targets, span, }, @@ -371,14 +376,21 @@ mod tests { #[test] fn args_block_parses_csv_floats() { let a = run(args_block(), "(0.1, 0.2, 0.3)"); - assert_eq!(a, vec![0.1, 0.2, 0.3]); + // Each arg is paired with whether it used the `*pi` form (none here). + assert_eq!(a, vec![(0.1, false), (0.2, false), (0.3, false)]); } #[test] fn args_block_with_pi_exprs() { - let a = run(args_block(), "(pi, 0.5*pi)"); - assert!((a[0] - std::f64::consts::PI).abs() < 1e-12); - assert!((a[1] - 0.5 * std::f64::consts::PI).abs() < 1e-12); + // Args still accept pi-expressions (for stim.rs compatibility); the + // `had_pi` flag records that they did so callers can reject it where + // the half-turn convention applies. + let a = run(args_block(), "(pi, 0.5*pi, 2.0)"); + assert!((a[0].0 - std::f64::consts::PI).abs() < 1e-12); + assert!(a[0].1); + assert!((a[1].0 - 0.5 * std::f64::consts::PI).abs() < 1e-12); + assert!(a[1].1); + assert_eq!(a[2], (2.0, false)); } #[test] @@ -399,7 +411,7 @@ mod tests { #[test] fn instruction_head_with_tags_and_args() { - let (name, tags, args, _span) = run(instruction_head(), "S[T](0.5)"); + let (name, tags, args, _had_pi, _span) = run(instruction_head(), "S[T](0.5)"); assert_eq!(name, "S"); assert_eq!(tags.len(), 1); assert_eq!(tags[0].name, "T"); @@ -408,7 +420,7 @@ mod tests { #[test] fn instruction_head_no_tags_no_args() { - let (name, tags, args, _span) = run(instruction_head(), "H"); + let (name, tags, args, _had_pi, _span) = run(instruction_head(), "H"); assert_eq!(name, "H"); assert!(tags.is_empty()); assert!(args.is_empty()); diff --git a/crates/stim-parser/src/syntax/raw.rs b/crates/stim-parser/src/syntax/raw.rs index 6ff6454a..4429d2bd 100644 --- a/crates/stim-parser/src/syntax/raw.rs +++ b/crates/stim-parser/src/syntax/raw.rs @@ -17,6 +17,10 @@ pub(crate) enum RawSyntaxNode { name: String, tags: Vec, args: Vec, + /// Whether any argument was written with the `*pi` (half-turn) form. + /// The bare rotation mnemonics (`R_X`/`R_Y`/`R_Z`/`U3`) reject it, + /// since they already scale their half-turn argument by pi. + args_had_pi: bool, targets: Vec, span: SimpleSpan, }, From cee01981ff916ecd6cf548ba1457f802131585d0 Mon Sep 17 00:00:00 2001 From: "Xiu-zhe (Roger) Luo" Date: Wed, 24 Jun 2026 15:56:42 -0400 Subject: [PATCH 6/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- crates/stim-parser/tests/proptest_ast.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/stim-parser/tests/proptest_ast.rs b/crates/stim-parser/tests/proptest_ast.rs index a7253800..57b8ad7a 100644 --- a/crates/stim-parser/tests/proptest_ast.rs +++ b/crates/stim-parser/tests/proptest_ast.rs @@ -398,8 +398,8 @@ fn ext_flat() -> impl Strategy { ) .prop_map(|(axis, theta, targets)| ExtendedInstruction::Rotation { axis, - // Rotation angles are stored in radians but always originate from - // a `*pi` tag, so only half-turn multiples are parser-producible. + // Rotation angles are stored in radians and originate from half-turn inputs: + // either a `*pi` tag (e.g. `I[R_Z(theta=0.5*pi)]`) or a bare `R_Z(0.5)` gate. theta: theta * std::f64::consts::PI, targets, span: span0(), From b7e282fe9536afe5fbced2144fe870a14e991c67 Mon Sep 17 00:00:00 2001 From: Roger-luo Date: Wed, 24 Jun 2026 16:06:20 -0400 Subject: [PATCH 7/8] fix(stim-parser): print shortest exact *pi coefficient (no rounding tail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rotation/U3 printer emitted `theta/PI` directly, so the division's rounding leaked into the output: `theta=0.76*pi` printed as `theta=0.7599999999999999*pi`. Roughly 10% of ordinary decimal angles were affected. `pi_coeff` now returns the shortest decimal `c` whose `c * PI` recovers the stored radians bit-for-bit, falling back to `value / PI` when no exact short form exists. Because acceptance requires exact equality, this both prints cleanly and keeps `parse → print` lossless and the printer a byte-for-byte fixpoint — verified by a new property test over arbitrary decimal coefficients (exact theta recovery + fixpoint + no rounding tail) plus a unit test covering tags, bare gates, and U3. Addresses Copilot's print/mod.rs review note. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/stim-parser/src/print/mod.rs | 78 +++++++++++++++++-- .../stim-parser/tests/proptest_roundtrip.rs | 42 ++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/crates/stim-parser/src/print/mod.rs b/crates/stim-parser/src/print/mod.rs index bbe1523a..a8cb4870 100644 --- a/crates/stim-parser/src/print/mod.rs +++ b/crates/stim-parser/src/print/mod.rs @@ -71,7 +71,7 @@ fn write_tags(out: &mut dyn fmt::Write, tags: &[Tag]) -> fmt::Result { TagParam::Positional(v) => write!(out, "{}", FloatLit(*v))?, TagParam::Named { key, value, had_pi } => { if *had_pi { - write!(out, "{key}={}*pi", FloatLit(*value / std::f64::consts::PI))?; + write!(out, "{key}={}*pi", FloatLit(pi_coeff(*value)))?; } else { write!(out, "{key}={}", FloatLit(*value))?; } @@ -169,6 +169,37 @@ impl fmt::Display for FloatLit { } } +/// The coefficient `c` to print for a `*pi` literal carrying the radians +/// `value`. The naive `value / PI` is correct but, because the division +/// rounds, often prints a long tail (`0.76*pi` → `0.7599999999999999*pi`). +/// +/// Parser-produced angles always originate from a decimal coefficient — a +/// `*pi` tag or a half-turn `R_*(n)` arg — so a short decimal `c` with +/// `c * PI == value` (bit-for-bit) exists. We return the shortest such `c`, +/// which prints cleanly *and* re-parses back to exactly `value`. Requiring +/// exact equality is what keeps `parse → print` lossless and the printer a +/// fixpoint; for any `value` with no exact short form we fall back to the +/// naive `value / PI` (same output as before). +fn pi_coeff(value: f64) -> f64 { + let pi = std::f64::consts::PI; + let q = value / pi; + if !q.is_finite() { + return q; + } + // `{:.*e}` with `prec` digits after the mantissa point is `prec + 1` + // significant digits; 17 sig-digits round-trips any f64, so by `prec = 16` + // `candidate == q` and the loop has tried every shorter rounding first. + for prec in 0..=16 { + let candidate: f64 = format!("{q:.prec$e}") + .parse() + .expect("a formatted float always re-parses"); + if candidate * pi == value { + return candidate; + } + } + q +} + // --------------------------------------------------------------------------- // StimPrint for shared *Op structs // --------------------------------------------------------------------------- @@ -297,8 +328,12 @@ impl StimPrint for ExtendedInstruction { }; // theta is radians; re-emit the half-turn `*pi` form the // rotation tags require (see exact_named_params). - let pi = std::f64::consts::PI; - write!(out, "I[{}(theta={}*pi)]", axis_tag, FloatLit(*theta / pi))?; + write!( + out, + "I[{}(theta={}*pi)]", + axis_tag, + FloatLit(pi_coeff(*theta)) + )?; write_usize_targets(out, targets)?; } ExtendedInstruction::U3 { @@ -308,13 +343,12 @@ impl StimPrint for ExtendedInstruction { targets, .. } => { - let pi = std::f64::consts::PI; write!( out, "I[U3(theta={}*pi, phi={}*pi, lambda={}*pi)]", - FloatLit(*theta / pi), - FloatLit(*phi / pi), - FloatLit(*lambda / pi), + FloatLit(pi_coeff(*theta)), + FloatLit(pi_coeff(*phi)), + FloatLit(pi_coeff(*lambda)), )?; write_usize_targets(out, targets)?; } @@ -401,4 +435,34 @@ mod tests { let ast = parse("CX rec[-1] 0\nMPP X0*Y1*Z2\n").unwrap(); assert_eq!(ast.to_stim(), "CX rec[-1] 0\nMPP X0*Y1*Z2\n"); } + + #[test] + fn rotation_pi_coeff_prints_clean_and_round_trips() { + // theta is stored in radians as `c*PI`; printing `c = theta/PI` naively + // would emit a rounding tail like `0.7599999999999999*pi`. The printer + // recovers the short coefficient instead — for tags, bare rotation + // gates, and U3 — and `print → parse → print` stays a fixpoint. + for (src, expected) in [ + // Non-binary-friendly decimals that `theta/PI` mangles. + ("I[R_Z(theta=0.34*pi)] 0\n", "I[R_Z(theta=0.34*pi)] 0\n"), + ("I[R_Y(theta=0.76*pi)] 1\n", "I[R_Y(theta=0.76*pi)] 1\n"), + ("I[R_X(theta=-2.78*pi)] 2\n", "I[R_X(theta=-2.78*pi)] 2\n"), + // Bare half-turn gate canonicalises to the same clean tag form. + ("R_Z(0.19) 0\n", "I[R_Z(theta=0.19*pi)] 0\n"), + ( + "I[U3(theta=0.34*pi, phi=0.91*pi, lambda=0.07*pi)] 0\n", + "I[U3(theta=0.34*pi, phi=0.91*pi, lambda=0.07*pi)] 0\n", + ), + ] { + let printed = parse_extended(src).unwrap().to_stim(); + assert_eq!(printed, expected, "first print of {src:?}"); + assert!( + !printed.contains("999999") && !printed.contains("000000"), + "coefficient printed with a rounding tail: {printed:?}" + ); + // Fixpoint: re-parsing and re-printing reproduces it byte-for-byte. + let reprinted = parse_extended(&printed).unwrap().to_stim(); + assert_eq!(reprinted, printed, "printer is not a fixpoint for {src:?}"); + } + } } diff --git a/crates/stim-parser/tests/proptest_roundtrip.rs b/crates/stim-parser/tests/proptest_roundtrip.rs index e276fa59..44ac8733 100644 --- a/crates/stim-parser/tests/proptest_roundtrip.rs +++ b/crates/stim-parser/tests/proptest_roundtrip.rs @@ -96,6 +96,16 @@ fn check_extended_fixpoint(src: &str) { assert_eq!(s1, s2, "extended printer is not a fixpoint"); } +/// A decimal with 0–4 fractional digits in `[-4, 4]`, rendered as a string. +/// Mirrors the angles a user actually writes; many are not binary-friendly, +/// so `theta/PI` would print a rounding tail without the printer's recovery. +fn decimal_coeff() -> impl Strategy { + (-40_000i32..=40_000, 0u32..=4).prop_map(|(n, scale)| { + let v = f64::from(n) / 10f64.powi(scale as i32); + format!("{v}") + }) +} + proptest! { #[test] fn raw_printer_is_fixpoint_on_fragments(src in program_source()) { @@ -106,4 +116,36 @@ proptest! { fn extended_printer_is_fixpoint_on_fragments(src in program_source()) { check_extended_fixpoint(&src); } + + /// Rotation/U3 angles are stored in radians as `c*PI` and re-emitted in + /// the `*pi` form. For any decimal coefficient a user might write, the + /// printer must (a) round-trip losslessly — `parse → print → parse` must + /// recover the exact stored radians — and (b) stay a byte-for-byte + /// fixpoint, never degrading into a `0.7599999999999999*pi` tail. + #[test] + fn rotation_pi_coeff_round_trips(c in decimal_coeff()) { + let src = format!("I[R_Z(theta={c}*pi)] 0\n"); + let ast1 = parse_extended(&src).expect("parse"); + let theta1 = rotation_theta(&ast1); + + let s1 = format!("{ast1}"); + prop_assert!( + !s1.contains("999999") && !s1.contains("000000"), + "printed a rounding tail for theta={c}*pi: {s1}" + ); + + let ast2 = parse_extended(&s1).expect("reparse"); + prop_assert_eq!(theta1.to_bits(), rotation_theta(&ast2).to_bits(), + "theta not recovered exactly for theta={}*pi", c); + prop_assert_eq!(format!("{ast2}"), s1, "printer not a fixpoint"); + } +} + +/// Extract the single rotation's `theta` from a one-instruction program. +fn rotation_theta(ast: &stim_parser::prelude::ExtendedProgram) -> f64 { + use stim_parser::prelude::ExtendedInstruction::Rotation; + match &ast.instructions[0] { + Rotation { theta, .. } => *theta, + other => panic!("expected a rotation, got {other:?}"), + } } From 8c6e9bd7904388ca4c543d25270ccd49d5560729 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Thu, 25 Jun 2026 10:52:16 +0200 Subject: [PATCH 8/8] test(ppvm-stim): U3 with all angles nonzero exercises phi/lambda half-turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only U3 execution test used phi=lambda=0, so the half-turn scaling of phi/lambda was never exercised end-to-end (flagged in review). Add a test using U3(theta=pi, phi=pi/2, lambda=pi/2) == Y, framed as H·U3·H so the qubit flips to |1> deterministically. The H frame makes the outcome sensitive to phi *and* lambda: dropping or mis-scaling either collapses P(1) to ~0.5. Covers both the canonical tag form and the bare half-turn form (the bare assertion also pins the `* pi` lowering scale). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ppvm-stim/tests/executor.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/ppvm-stim/tests/executor.rs b/crates/ppvm-stim/tests/executor.rs index 4ca3a2f5..93b933c4 100644 --- a/crates/ppvm-stim/tests/executor.rs +++ b/crates/ppvm-stim/tests/executor.rs @@ -50,6 +50,24 @@ fn u3_pi_flip_via_y_axis() { assert_eq!(results, vec![Some(true)]); } +#[test] +fn u3_all_angles_nonzero_exercises_phi_lambda() { + // U3(theta=pi, phi=pi/2, lambda=pi/2) == Y (clifft Rz(phi)Ry(theta)Rz(lambda)), + // so H·U3·H == H·Y·H == -Y and |0> -> |1> deterministically. The H frame makes + // the outcome sensitive to phi *and* lambda (drop or mis-scale either and P(1) + // collapses to ~0.5). Half-turn args 1.0/0.5/0.5 each get *pi at lowering. + let tag = run( + "H 0\nI[U3(theta=1.0*pi, phi=0.5*pi, lambda=0.5*pi)] 0\nH 0\nM 0", + 1, + ); + assert_eq!(tag.0, vec![Some(true)]); + + // Bare half-turn form must agree; this also pins the `* pi` scaling (an + // unscaled bare U3 would read 1.0/0.5/0.5 as radians and P(1) drops to ~0.4). + let bare = run("H 0\nU3(1.0, 0.5, 0.5) 0\nH 0\nM 0", 1); + assert_eq!(bare.0, vec![Some(true)]); +} + #[test] fn t_gate_via_s_t_tag_no_op_on_zero() { let (results, _) = run("S[T] 0\nM 0", 1);