Skip to content

Commit 3f79f96

Browse files
david-plclaude
andauthored
feat(stim-parser)!: require *pi in rotation/U3 tag angles (no bare rotation gates) (#163)
## Supersedes #159 This PR replaces #159 (`david/parse-bare-rotations`). It keeps **only** the tag-parsing tightening from that PR and **drops the bare rotation-gate support**, matching **tsim** semantics rather than **clifft**'s bare half-turn shorthand. #159 should be closed in favor of this one. ## What changes - **No bare `R_X` / `R_Y` / `R_Z` / `U3` gates.** The bare clifft-style mnemonics (`R_Z(0.5) 0`, `U3(a,b,c) 0`) are *not* registered, so they are rejected as `unknown instruction` — the same as on `main`. Only tsim's canonical tagged form is accepted. - **`*pi` is strictly required in rotation/U3 tag angles.** Mirroring tsim's parametric-tag convention, angles must be written in half-turns as `<n>*pi` (e.g. `I[R_Z(theta=0.5*pi)]`). A bare number (`I[R_Z(theta=0.5)]`) is now rejected with an `invalid-tag` diagnostic rather than silently read as radians. - **Printer re-emits the clean `<c>*pi` form** with the shortest exact coefficient (no `0.7599999999999999*pi` rounding tail), keeping `parse → print` a byte-for-byte fixpoint. - **`T` / `T_DAG` bare gates now reject tags** (`T[foo] 0` previously parsed and silently dropped the tag) — an orthogonal tightening included here. ### Behavior | Input | Result | |---|---| | `I[R_Z(theta=0.5*pi)] 0` | ✅ accepted, round-trips | | `I[R_Z(theta=0.5)] 0` | ❌ `parameter 'theta' must be written as <n>*pi (half-turns)` | | `R_Z(0.5) 0` | ❌ `unknown instruction 'R_Z'` | | `U3(0.5, 1.0, 1.5) 0` | ❌ `unknown instruction 'U3'` | | `I[U3(theta=0.3*pi, …)] 0` | ✅ accepted, round-trips | ## Commits 1. `feat(stim-parser)!: require *pi in rotation/U3 tag angles` 2. `fix(stim-parser): reject tags on bare T/T_DAG gates` 3. `fix(stim-parser): print shortest exact *pi coefficient (no rounding tail)` 4. `test(ppvm-stim): U3 with all angles nonzero exercises phi/lambda half-turns` ## Testing - `cargo test -p stim-parser -p ppvm-stim` — all green. - `pytest test/test_stim_api.py` — 22 passed (native module rebuilt). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cb642e4 commit 3f79f96

12 files changed

Lines changed: 309 additions & 71 deletions

File tree

crates/ppvm-stim/tests/executor.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,23 @@ fn rx_pi_flips_qubit() {
4646

4747
#[test]
4848
fn u3_pi_flip_via_y_axis() {
49-
let (results, _) = run("I[U3(theta=1.0*pi, phi=0.0, lambda=0.0)] 0\nM 0", 1);
49+
let (results, _) = run("I[U3(theta=1.0*pi, phi=0.0*pi, lambda=0.0*pi)] 0\nM 0", 1);
5050
assert_eq!(results, vec![Some(true)]);
5151
}
5252

53+
#[test]
54+
fn u3_all_angles_nonzero_exercises_phi_lambda() {
55+
// U3(theta=pi, phi=pi/2, lambda=pi/2) == Y (clifft Rz(phi)Ry(theta)Rz(lambda)),
56+
// so H·U3·H == H·Y·H == -Y and |0> -> |1> deterministically. The H frame makes
57+
// the outcome sensitive to phi *and* lambda (drop or mis-scale either and P(1)
58+
// collapses to ~0.5). Half-turn tag args 1.0/0.5/0.5 each get *pi at lowering.
59+
let tag = run(
60+
"H 0\nI[U3(theta=1.0*pi, phi=0.5*pi, lambda=0.5*pi)] 0\nH 0\nM 0",
61+
1,
62+
);
63+
assert_eq!(tag.0, vec![Some(true)]);
64+
}
65+
5366
#[test]
5467
fn t_gate_via_s_t_tag_no_op_on_zero() {
5568
let (results, _) = run("S[T] 0\nM 0", 1);

crates/stim-parser/src/ast/shared.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,14 @@ pub struct Tag {
7474
#[derive(Debug, Clone, PartialEq)]
7575
pub enum TagParam {
7676
Positional(f64),
77-
Named { key: String, value: f64 },
77+
/// A `key=value` tag parameter. `had_pi` records whether the value was
78+
/// written as a `<n>[*]pi` (or bare `pi`) expression — rotation/U3 tags
79+
/// require it (half-turn convention), and the printer re-emits `*pi`.
80+
Named {
81+
key: String,
82+
value: f64,
83+
had_pi: bool,
84+
},
7885
}
7986

8087
/// The rotation axis for an extended-dialect `R_X` / `R_Y` / `R_Z` rotation.

crates/stim-parser/src/pipeline/lower.rs

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ fn lower_gate(
104104
match name {
105105
// Native T / T_DAG mnemonics lower to the same sugar as `S[T]` / `S_DAG[T]`.
106106
T | TDag => {
107+
if let Some(tag) = tags.first() {
108+
return invalid_tag(
109+
&tag.name,
110+
name.canonical_name(),
111+
span,
112+
"bare T/T_DAG take no tags; use S[T] / S_DAG[T] for the tagged form",
113+
sink,
114+
);
115+
}
107116
let Some(targets) = qubit_targets(targets, name.canonical_name(), span, sink)? else {
108117
return Ok(None);
109118
};
@@ -445,7 +454,7 @@ fn exact_named_params<const N: usize>(
445454
sink,
446455
);
447456
}
448-
TagParam::Named { key, value } => {
457+
TagParam::Named { key, value, had_pi } => {
449458
let Some(index) = required.iter().position(|required_key| key == required_key)
450459
else {
451460
return invalid_tag(
@@ -465,6 +474,17 @@ fn exact_named_params<const N: usize>(
465474
sink,
466475
);
467476
}
477+
// Rotation/U3 angles are in half-turns: require the `<n>*pi` form,
478+
// mirroring tsim, so a bare number can't be mistaken for radians.
479+
if !had_pi {
480+
return invalid_tag(
481+
&tag.name,
482+
instruction,
483+
span,
484+
format!("parameter '{key}' must be written as <n>*pi (half-turns)"),
485+
sink,
486+
);
487+
}
468488
seen[index] = true;
469489
values[index] = *value;
470490
}
@@ -552,7 +572,7 @@ mod tests {
552572

553573
#[test]
554574
fn identity_rotation_x_lowers() {
555-
let prog = lower_extended("I[R_X(theta=0.5)] 0").expect("lower");
575+
let prog = lower_extended("I[R_X(theta=0.5*pi)] 0").expect("lower");
556576
match &prog.instructions[0] {
557577
ExtendedInstruction::Rotation {
558578
axis,
@@ -561,7 +581,7 @@ mod tests {
561581
..
562582
} => {
563583
assert_eq!(*axis, Axis::X);
564-
assert_eq!(*theta, 0.5);
584+
assert!((*theta - 0.5 * std::f64::consts::PI).abs() < 1e-12);
565585
assert_eq!(targets, &vec![0]);
566586
}
567587
other => panic!("{other:?}"),
@@ -570,7 +590,8 @@ mod tests {
570590

571591
#[test]
572592
fn identity_u3_lowers() {
573-
let prog = lower_extended("I[U3(theta=0.5, phi=1.0, lambda=1.5)] 0").expect("lower");
593+
let prog =
594+
lower_extended("I[U3(theta=0.5*pi, phi=1.0*pi, lambda=1.5*pi)] 0").expect("lower");
574595
match &prog.instructions[0] {
575596
ExtendedInstruction::U3 {
576597
theta,
@@ -579,15 +600,74 @@ mod tests {
579600
targets,
580601
..
581602
} => {
582-
assert_eq!(*theta, 0.5);
583-
assert_eq!(*phi, 1.0);
584-
assert_eq!(*lambda, 1.5);
603+
let pi = std::f64::consts::PI;
604+
assert!((*theta - 0.5 * pi).abs() < 1e-12);
605+
assert!((*phi - 1.0 * pi).abs() < 1e-12);
606+
assert!((*lambda - 1.5 * pi).abs() < 1e-12);
585607
assert_eq!(targets, &vec![0]);
586608
}
587609
other => panic!("{other:?}"),
588610
}
589611
}
590612

613+
#[test]
614+
fn bare_t_with_tag_is_rejected() {
615+
// A tag on bare T/T_DAG is meaningless (the tagged form is S[T]); reject
616+
// it rather than silently dropping it.
617+
let err = lower_extended("T[foo] 0").unwrap_err();
618+
assert_eq!(err.last().unwrap().code, Some("invalid-tag"));
619+
}
620+
621+
#[test]
622+
fn bare_t_dag_with_tag_is_rejected() {
623+
let err = lower_extended("T_DAG[foo] 0").unwrap_err();
624+
assert_eq!(err.last().unwrap().code, Some("invalid-tag"));
625+
}
626+
627+
#[test]
628+
fn rotation_tag_without_pi_is_rejected() {
629+
// Mirror tsim: rotation tag angles must be written as <n>*pi (half-turns).
630+
let err = lower_extended("I[R_Z(theta=0.5)] 0").unwrap_err();
631+
assert_eq!(err.last().unwrap().code, Some("invalid-tag"));
632+
}
633+
634+
#[test]
635+
fn u3_tag_without_pi_is_rejected() {
636+
let err = lower_extended("I[U3(theta=0.5, phi=1.0, lambda=1.5)] 0").unwrap_err();
637+
assert_eq!(err.last().unwrap().code, Some("invalid-tag"));
638+
}
639+
640+
#[test]
641+
fn rotation_tag_with_pi_is_accepted() {
642+
let prog = lower_extended("I[R_Z(theta=0.5*pi)] 0").expect("lower");
643+
match &prog.instructions[0] {
644+
ExtendedInstruction::Rotation { axis, theta, .. } => {
645+
assert_eq!(*axis, Axis::Z);
646+
assert!((*theta - 0.5 * std::f64::consts::PI).abs() < 1e-12);
647+
}
648+
other => panic!("{other:?}"),
649+
}
650+
}
651+
652+
#[test]
653+
fn rotation_tag_accepts_alternate_pi_syntax() {
654+
let half_pi = 0.5 * std::f64::consts::PI;
655+
for src in [
656+
"I[R_Z(theta=0.5*pi)] 0",
657+
"I[R_Z(theta=0.5 * pi)] 0",
658+
"I[R_Z(theta=0.5pi)] 0",
659+
] {
660+
let prog = lower_extended(src).unwrap_or_else(|e| panic!("{src}: {e:?}"));
661+
match &prog.instructions[0] {
662+
ExtendedInstruction::Rotation { axis, theta, .. } => {
663+
assert_eq!(*axis, Axis::Z);
664+
assert!((*theta - half_pi).abs() < 1e-12, "{src}");
665+
}
666+
other => panic!("{src}: {other:?}"),
667+
}
668+
}
669+
}
670+
591671
#[test]
592672
fn i_error_loss_lowers() {
593673
let prog = lower_extended("I_ERROR[loss](0.01) 0").expect("lower");

crates/stim-parser/src/pipeline/validate.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,7 @@ mod tests {
548548
TagParam::Named {
549549
key: "theta".to_string(),
550550
value: 0.25,
551+
had_pi: false,
551552
},
552553
],
553554
}],
@@ -559,7 +560,7 @@ mod tests {
559560
assert!(matches!(tags[0].params[0], TagParam::Positional(0.5)));
560561
assert!(matches!(
561562
&tags[0].params[1],
562-
TagParam::Named { key, value } if key == "theta" && *value == 0.25
563+
TagParam::Named { key, value, .. } if key == "theta" && *value == 0.25
563564
));
564565
}
565566
other => panic!("{other:?}"),

crates/stim-parser/src/print/mod.rs

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,12 @@ fn write_tags(out: &mut dyn fmt::Write, tags: &[Tag]) -> fmt::Result {
6969
}
7070
match p {
7171
TagParam::Positional(v) => write!(out, "{}", FloatLit(*v))?,
72-
TagParam::Named { key, value } => {
73-
write!(out, "{key}={}", FloatLit(*value))?;
72+
TagParam::Named { key, value, had_pi } => {
73+
if *had_pi {
74+
write!(out, "{key}={}*pi", FloatLit(pi_coeff(*value)))?;
75+
} else {
76+
write!(out, "{key}={}", FloatLit(*value))?;
77+
}
7478
}
7579
}
7680
}
@@ -165,6 +169,37 @@ impl fmt::Display for FloatLit {
165169
}
166170
}
167171

172+
/// The coefficient `c` to print for a `<c>*pi` literal carrying the radians
173+
/// `value`. The naive `value / PI` is correct but, because the division
174+
/// rounds, often prints a long tail (`0.76*pi` → `0.7599999999999999*pi`).
175+
///
176+
/// Parser-produced angles always originate from a decimal coefficient — a
177+
/// `<n>*pi` rotation/U3 tag — so a short decimal `c` with
178+
/// `c * PI == value` (bit-for-bit) exists. We return the shortest such `c`,
179+
/// which prints cleanly *and* re-parses back to exactly `value`. Requiring
180+
/// exact equality is what keeps `parse → print` lossless and the printer a
181+
/// fixpoint; for any `value` with no exact short form we fall back to the
182+
/// naive `value / PI` (same output as before).
183+
fn pi_coeff(value: f64) -> f64 {
184+
let pi = std::f64::consts::PI;
185+
let q = value / pi;
186+
if !q.is_finite() {
187+
return q;
188+
}
189+
// `{:.*e}` with `prec` digits after the mantissa point is `prec + 1`
190+
// significant digits; 17 sig-digits round-trips any f64, so by `prec = 16`
191+
// `candidate == q` and the loop has tried every shorter rounding first.
192+
for prec in 0..=16 {
193+
let candidate: f64 = format!("{q:.prec$e}")
194+
.parse()
195+
.expect("a formatted float always re-parses");
196+
if candidate * pi == value {
197+
return candidate;
198+
}
199+
}
200+
q
201+
}
202+
168203
// ---------------------------------------------------------------------------
169204
// StimPrint for shared *Op structs
170205
// ---------------------------------------------------------------------------
@@ -291,7 +326,14 @@ impl StimPrint for ExtendedInstruction {
291326
Axis::Y => "R_Y",
292327
Axis::Z => "R_Z",
293328
};
294-
write!(out, "I[{}(theta={})]", axis_tag, FloatLit(*theta))?;
329+
// theta is radians; re-emit the half-turn `<n>*pi` form the
330+
// rotation tags require (see exact_named_params).
331+
write!(
332+
out,
333+
"I[{}(theta={}*pi)]",
334+
axis_tag,
335+
FloatLit(pi_coeff(*theta))
336+
)?;
295337
write_usize_targets(out, targets)?;
296338
}
297339
ExtendedInstruction::U3 {
@@ -303,10 +345,10 @@ impl StimPrint for ExtendedInstruction {
303345
} => {
304346
write!(
305347
out,
306-
"I[U3(theta={}, phi={}, lambda={})]",
307-
FloatLit(*theta),
308-
FloatLit(*phi),
309-
FloatLit(*lambda),
348+
"I[U3(theta={}*pi, phi={}*pi, lambda={}*pi)]",
349+
FloatLit(pi_coeff(*theta)),
350+
FloatLit(pi_coeff(*phi)),
351+
FloatLit(pi_coeff(*lambda)),
310352
)?;
311353
write_usize_targets(out, targets)?;
312354
}
@@ -381,9 +423,9 @@ mod tests {
381423

382424
#[test]
383425
fn extended_printed_form_lowers_sugar_into_canonical_stim() {
384-
let src = "S[T] 0\nI[R_X(theta=0.25)] 1\nI_ERROR[loss](0.01) 2\n";
426+
let src = "S[T] 0\nI[R_X(theta=0.25*pi)] 1\nI_ERROR[loss](0.01) 2\n";
385427
let ast = parse_extended(src).unwrap();
386-
let expected = "S[T] 0\nI[R_X(theta=0.25)] 1\nI_ERROR[loss](0.01) 2\n";
428+
let expected = "S[T] 0\nI[R_X(theta=0.25*pi)] 1\nI_ERROR[loss](0.01) 2\n";
387429
assert_eq!(ast.to_stim(), expected);
388430
}
389431

@@ -393,4 +435,32 @@ mod tests {
393435
let ast = parse("CX rec[-1] 0\nMPP X0*Y1*Z2\n").unwrap();
394436
assert_eq!(ast.to_stim(), "CX rec[-1] 0\nMPP X0*Y1*Z2\n");
395437
}
438+
439+
#[test]
440+
fn rotation_pi_coeff_prints_clean_and_round_trips() {
441+
// theta is stored in radians as `c*PI`; printing `c = theta/PI` naively
442+
// would emit a rounding tail like `0.7599999999999999*pi`. The printer
443+
// recovers the short coefficient instead — for rotation and U3 tags —
444+
// and `print → parse → print` stays a fixpoint.
445+
for (src, expected) in [
446+
// Non-binary-friendly decimals that `theta/PI` mangles.
447+
("I[R_Z(theta=0.34*pi)] 0\n", "I[R_Z(theta=0.34*pi)] 0\n"),
448+
("I[R_Y(theta=0.76*pi)] 1\n", "I[R_Y(theta=0.76*pi)] 1\n"),
449+
("I[R_X(theta=-2.78*pi)] 2\n", "I[R_X(theta=-2.78*pi)] 2\n"),
450+
(
451+
"I[U3(theta=0.34*pi, phi=0.91*pi, lambda=0.07*pi)] 0\n",
452+
"I[U3(theta=0.34*pi, phi=0.91*pi, lambda=0.07*pi)] 0\n",
453+
),
454+
] {
455+
let printed = parse_extended(src).unwrap().to_stim();
456+
assert_eq!(printed, expected, "first print of {src:?}");
457+
assert!(
458+
!printed.contains("999999") && !printed.contains("000000"),
459+
"coefficient printed with a rounding tail: {printed:?}"
460+
);
461+
// Fixpoint: re-parsing and re-printing reproduces it byte-for-byte.
462+
let reprinted = parse_extended(&printed).unwrap().to_stim();
463+
assert_eq!(reprinted, printed, "printer is not a fixpoint for {src:?}");
464+
}
465+
}
396466
}

0 commit comments

Comments
 (0)