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/ppvm-stim/tests/executor.rs b/crates/ppvm-stim/tests/executor.rs index d8030f08..93b933c4 100644 --- a/crates/ppvm-stim/tests/executor.rs +++ b/crates/ppvm-stim/tests/executor.rs @@ -46,10 +46,28 @@ 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)]); } +#[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); 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/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..d32c47a9 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); }; @@ -160,6 +169,62 @@ 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 => { + 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); + }; + 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 => { + 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); + }; + 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, @@ -445,7 +510,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( @@ -465,6 +530,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; } @@ -552,7 +628,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, @@ -561,7 +637,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:?}"), @@ -570,7 +646,66 @@ 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, + phi, + lambda, + targets, + .. + } => { + 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:?}"), + } + } + + #[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, @@ -579,15 +714,140 @@ mod tests { targets, .. } => { - assert_eq!(*theta, 0.5); - assert_eq!(*phi, 1.0); - assert_eq!(*lambda, 1.5); + 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 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_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(); + 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). + 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..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( @@ -548,6 +618,7 @@ mod tests { TagParam::Named { key: "theta".to_string(), value: 0.25, + had_pi: false, }, ], }], @@ -559,7 +630,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..a8cb4870 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(pi_coeff(*value)))?; + } else { + write!(out, "{key}={}", FloatLit(*value))?; + } } } } @@ -165,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 // --------------------------------------------------------------------------- @@ -291,7 +326,14 @@ 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). + write!( + out, + "I[{}(theta={}*pi)]", + axis_tag, + FloatLit(pi_coeff(*theta)) + )?; write_usize_targets(out, targets)?; } ExtendedInstruction::U3 { @@ -303,10 +345,10 @@ impl StimPrint for ExtendedInstruction { } => { write!( out, - "I[U3(theta={}, phi={}, lambda={})]", - FloatLit(*theta), - FloatLit(*phi), - FloatLit(*lambda), + "I[U3(theta={}*pi, phi={}*pi, lambda={}*pi)]", + FloatLit(pi_coeff(*theta)), + FloatLit(pi_coeff(*phi)), + FloatLit(pi_coeff(*lambda)), )?; write_usize_targets(out, targets)?; } @@ -381,9 +423,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); } @@ -393,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/src/syntax/grammar.rs b/crates/stim-parser/src/syntax/grammar.rs index 9ff4dbed..de1c2a65 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)) } @@ -133,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::>() @@ -160,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) }) } @@ -204,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, }, @@ -343,9 +356,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:?}"), } @@ -362,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] @@ -390,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"); @@ -399,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, }, 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..57b8ad7a 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 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(), }), (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..44ac8733 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()), @@ -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:?}"), + } } 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", ],