diff --git a/Cargo.lock b/Cargo.lock index ce6f7cf39..caa60ec3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1031,12 +1031,14 @@ name = "ppvm-tableau-sum" version = "0.1.0" dependencies = [ "bitvec", + "bytemuck", "criterion 0.8.2", "fxhash", "gxhash", "mimalloc", "num", "ppvm-pauli-sum", + "ppvm-pauli-word", "ppvm-tableau", "ppvm-traits", "rand 0.10.1", diff --git a/crates/ppvm-pauli-word/src/pattern/data.rs b/crates/ppvm-pauli-word/src/pattern/data.rs index f34e48802..7e0d17a68 100644 --- a/crates/ppvm-pauli-word/src/pattern/data.rs +++ b/crates/ppvm-pauli-word/src/pattern/data.rs @@ -8,11 +8,14 @@ use bincode::{Decode, Encode}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; +/// A single-qubit Pauli that is not the identity: `X`, `Y`, or `Z`. Encoded so +/// the low two bits match [`Pauli`](crate::Pauli) (`X = 1`, `Z = 2`, `Y = 3`), +/// which lets `From for Pauli` be a no-op transmute. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "bincode", derive(Encode, Decode))] #[repr(u8)] -pub(crate) enum NotIdentity { +pub enum NotIdentity { X = 1, Z = 2, Y = 3, diff --git a/crates/ppvm-pauli-word/src/pattern/mod.rs b/crates/ppvm-pauli-word/src/pattern/mod.rs index 2147de525..93465d567 100644 --- a/crates/ppvm-pauli-word/src/pattern/mod.rs +++ b/crates/ppvm-pauli-word/src/pattern/mod.rs @@ -10,4 +10,4 @@ mod parse; mod trace; pub use contains::Contains; -pub use data::PauliPattern; +pub use data::{NotIdentity, PauliPattern}; diff --git a/crates/ppvm-tableau-sum/Cargo.toml b/crates/ppvm-tableau-sum/Cargo.toml index 47eb0bb61..d88298f30 100644 --- a/crates/ppvm-tableau-sum/Cargo.toml +++ b/crates/ppvm-tableau-sum/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] bitvec = "1.0.1" +bytemuck = { version = "1", features = ["min_const_generics"] } fxhash = "0.2.1" num = "0.4.3" ppvm-traits = { version = "0.1.0", path = "../ppvm-traits" } @@ -12,6 +13,7 @@ ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" } ppvm-tableau = { version = "0.1.0", path = "../ppvm-tableau" } rand = "0.10.1" smallvec = "1.15" +ppvm-pauli-word = { version = "0.1.0", path = "../ppvm-pauli-word" } # Native-only deps: gxhash (AES, used for the word_fingerprint hasher — see the # target-gated alias in src/storage/mod.rs) and rayon (OS threads). Pruned on diff --git a/crates/ppvm-tableau-sum/examples/msd-noisy-bench.rs b/crates/ppvm-tableau-sum/examples/msd-noisy-bench.rs new file mode 100644 index 000000000..11e7f018a --- /dev/null +++ b/crates/ppvm-tableau-sum/examples/msd-noisy-bench.rs @@ -0,0 +1,242 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +// Deterministic timing harness for the msd-noisy build + sample workload. +// Mirrors examples/msd-noisy.rs but uses a fixed seed, runs the build several +// times (median), and asserts the final branch count so an optimization that +// silently changes the math is caught. Used by the autotune experiment +// `docs/autotune/2026-06-23-tableau-sum-build`. + +use std::time::Instant; + +use ppvm_pauli_sum::config::fx64hash::Byte8F64; +use ppvm_tableau::prelude::*; +use ppvm_tableau_sum::data::GeneralizedTableauSum; +use ppvm_tableau_sum::storage::EntryStore; + +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + +type GTabSum = GeneralizedTableauSum, u128>; + +fn encode(tab: &mut GTabSum, qubits: &[usize], p_loss: f64, p_depolarize: f64) { + if qubits.len() == 17 { + for i in [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16] { + tab.sqrt_y(qubits[i]); + tab.loss_channel(qubits[i], p_loss); + tab.depolarize1(qubits[i], p_depolarize); + } + for [i, j] in [[1, 3], [7, 10], [12, 14], [13, 16]] { + tab.cz(qubits[i], qubits[j]); + tab.loss_channel(qubits[i], p_loss); + tab.loss_channel(qubits[j], p_loss); + tab.depolarize1(qubits[i], p_depolarize); + tab.depolarize1(qubits[j], p_depolarize); + } + for i in [7, 16] { + tab.sqrt_y_dag(qubits[i]); + tab.loss_channel(qubits[i], p_loss); + tab.depolarize1(qubits[i], p_depolarize); + } + for [i, j] in [[4, 7], [8, 10], [11, 14], [15, 16]] { + tab.cz(qubits[i], qubits[j]); + tab.loss_channel(qubits[i], p_loss); + tab.loss_channel(qubits[j], p_loss); + tab.depolarize1(qubits[i], p_depolarize); + tab.depolarize1(qubits[j], p_depolarize); + } + for i in [4, 10, 14, 16] { + tab.sqrt_y_dag(qubits[i]); + tab.loss_channel(qubits[i], p_loss); + tab.depolarize1(qubits[i], p_depolarize); + } + for [i, j] in [[2, 4], [6, 8], [7, 9], [10, 13], [14, 16]] { + tab.cz(qubits[i], qubits[j]); + tab.loss_channel(qubits[i], p_loss); + tab.loss_channel(qubits[j], p_loss); + tab.depolarize1(qubits[i], p_depolarize); + tab.depolarize1(qubits[j], p_depolarize); + } + for i in [3, 6, 9, 10, 12, 13] { + tab.sqrt_y(qubits[i]); + tab.loss_channel(qubits[i], p_loss); + tab.depolarize1(qubits[i], p_depolarize); + } + for [i, j] in [[0, 2], [3, 6], [5, 8], [10, 12], [11, 13]] { + tab.cz(qubits[i], qubits[j]); + tab.loss_channel(qubits[i], p_loss); + tab.loss_channel(qubits[j], p_loss); + tab.depolarize1(qubits[i], p_depolarize); + tab.depolarize1(qubits[j], p_depolarize); + } + for i in [1, 2, 3, 4, 6, 7, 8, 9, 11, 12, 14] { + tab.sqrt_y(qubits[i]); + tab.loss_channel(qubits[i], p_loss); + tab.depolarize1(qubits[i], p_depolarize); + } + for [i, j] in [[0, 1], [2, 3], [4, 5], [6, 7], [8, 9], [12, 15]] { + tab.cz(qubits[i], qubits[j]); + tab.loss_channel(qubits[i], p_loss); + tab.loss_channel(qubits[j], p_loss); + tab.depolarize1(qubits[i], p_depolarize); + tab.depolarize1(qubits[j], p_depolarize); + } + for i in [0, 2, 5, 6, 8, 10, 12] { + tab.sqrt_y_dag(qubits[i]); + tab.loss_channel(qubits[i], p_loss); + tab.depolarize1(qubits[i], p_depolarize); + } + } +} + +fn build(seed: u64) -> GTabSum { + let n_qubits = 85; + let p_loss = 1e-4; + let p_depolarize = 1e-4; + let sum_cutoff = 1e-7; + + let mut tab: GTabSum = GeneralizedTableauSum::new_with_seed(n_qubits, 1e-10, sum_cutoff, seed); + let qubit_addrs: Vec = (0..n_qubits).collect(); + let ql: Vec<&[usize]> = qubit_addrs.chunks_exact(17).collect(); + + for q in ql.iter() { + let encoding_qubit = q[7]; + tab.h(encoding_qubit); + tab.loss_channel(encoding_qubit, p_loss); + tab.depolarize1(encoding_qubit, p_depolarize); + tab.t(encoding_qubit); + tab.loss_channel(encoding_qubit, p_loss); + tab.depolarize1(encoding_qubit, p_depolarize); + encode(&mut tab, q, p_loss, p_depolarize); + } + + for i in [0, 1, 4] { + for q in ql[i] { + tab.sqrt_x(*q); + tab.loss_channel(*q, p_loss); + tab.depolarize1(*q, p_depolarize); + } + } + for (control, target) in ql[0].iter().zip(ql[1]) { + tab.cz(*control, *target); + tab.loss_channel(*control, p_loss); + tab.loss_channel(*target, p_loss); + tab.depolarize1(*control, p_depolarize); + tab.depolarize1(*target, p_depolarize); + } + for (control, target) in ql[2].iter().zip(ql[3]) { + tab.cz(*control, *target); + tab.loss_channel(*control, p_loss); + tab.loss_channel(*target, p_loss); + tab.depolarize1(*control, p_depolarize); + tab.depolarize1(*target, p_depolarize); + } + for q in ql[0] { + tab.sqrt_y(*q); + tab.loss_channel(*q, p_loss); + tab.depolarize1(*q, p_depolarize); + } + for q in ql[3] { + tab.sqrt_y(*q); + tab.loss_channel(*q, p_loss); + tab.depolarize1(*q, p_depolarize); + } + for (control, target) in ql[0].iter().zip(ql[2]) { + tab.cz(*control, *target); + tab.loss_channel(*control, p_loss); + tab.loss_channel(*target, p_loss); + tab.depolarize1(*control, p_depolarize); + tab.depolarize1(*target, p_depolarize); + } + for (control, target) in ql[3].iter().zip(ql[4]) { + tab.cz(*control, *target); + tab.loss_channel(*control, p_loss); + tab.loss_channel(*target, p_loss); + tab.depolarize1(*control, p_depolarize); + tab.depolarize1(*target, p_depolarize); + } + for q in ql[0] { + tab.sqrt_x_dag(*q); + tab.loss_channel(*q, p_loss); + tab.depolarize1(*q, p_depolarize); + } + for (control, target) in ql[0].iter().zip(ql[4]) { + tab.cz(*control, *target); + tab.loss_channel(*control, p_loss); + tab.loss_channel(*target, p_loss); + tab.depolarize1(*control, p_depolarize); + tab.depolarize1(*target, p_depolarize); + } + for (control, target) in ql[1].iter().zip(ql[3]) { + tab.cz(*control, *target); + tab.loss_channel(*control, p_loss); + tab.loss_channel(*target, p_loss); + tab.depolarize1(*control, p_depolarize); + tab.depolarize1(*target, p_depolarize); + } + for block in ql.iter().take(5) { + for q in *block { + tab.sqrt_x_dag(*q); + tab.loss_channel(*q, p_loss); + tab.depolarize1(*q, p_depolarize); + } + } + + tab +} + +fn main() { + const BUILD_RUNS: usize = 5; + const N_SHOTS: usize = 20000; + const SEED: u64 = 12345; + + let mut build_times_ms: Vec = Vec::new(); + let mut branches = 0usize; + for r in 0..BUILD_RUNS { + let now = Instant::now(); + let tab = build(SEED + r as u64); + let ms = now.elapsed().as_secs_f64() * 1e3; + build_times_ms.push(ms); + branches = tab.len(); + } + build_times_ms.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let median_ms = build_times_ms[build_times_ms.len() / 2]; + let min_ms = build_times_ms[0]; + + // Accuracy fingerprint: the optimizations under test must not change the + // math, so the multiset of branch probabilities must be invariant (up to + // float summation-order noise). Capture sum(p), sum(p^2) (participation + // ratio), and the top-5 probabilities from a fresh deterministic build. + let tab_acc = build(SEED); + let mut probs: Vec = tab_acc.entries.iter().map(|(_, p)| *p).collect(); + probs.sort_by(|a, b| b.partial_cmp(a).unwrap()); + let sum_p: f64 = probs.iter().sum(); + let sum_p2: f64 = probs.iter().map(|p| p * p).sum(); + let top5: Vec = probs.iter().take(5).copied().collect(); + + // Sample timing on a fresh build. + let mut tab = build(SEED); + let mut sampler = tab.sampler(); + let now = Instant::now(); + sampler.sample_shots(N_SHOTS); + let per_shot_ns = now.elapsed().as_nanos() as f64 / N_SHOTS as f64; + + println!("branches = {}", branches); + println!("build_min_ms = {:.1}", min_ms); + println!("build_median_ms= {:.1}", median_ms); + println!("per_shot_ns = {:.1}", per_shot_ns); + println!("sum_p = {:.12}", sum_p); + println!("sum_p2 = {:.12}", sum_p2); + println!("top5_p = {:?}", top5); + println!("all_build_ms = {:?}", build_times_ms); + + // Accuracy guard: the optimizations under test must not change the math, + // so the final branch count must stay at the baseline value. + const EXPECTED_BRANCHES: usize = 2025; + if branches != EXPECTED_BRANCHES { + eprintln!( + "WARNING: branch count {} != baseline {} — accuracy/structure changed!", + branches, EXPECTED_BRANCHES + ); + } +} diff --git a/crates/ppvm-tableau-sum/src/noise.rs b/crates/ppvm-tableau-sum/src/noise.rs index 287f6e8f2..eedbde003 100644 --- a/crates/ppvm-tableau-sum/src/noise.rs +++ b/crates/ppvm-tableau-sum/src/noise.rs @@ -3,11 +3,12 @@ use std::fmt::Debug; -use bitvec::view::BitView; +use bitvec::view::{BitView, BitViewSized}; use num::{ Complex, One, PrimInt, ToPrimitive, Zero, complex::{Complex64, ComplexFloat}, }; +use ppvm_pauli_word::pattern::NotIdentity; use ppvm_tableau::{ data::GeneralizedTableau, sparsevec::SparseVector, tableau_index::TableauIndex, }; @@ -20,7 +21,9 @@ use rand::{RngExt, rngs::SmallRng}; use crate::{ data::GeneralizedTableauSum, - storage::{Branch, EntryStore, loss_mask, pauli_branch_phase_loss}, + storage::{ + Branch, BranchMutation, EntryStore, RowMasks, bit_at, loss_mask, pauli_branch_phase_loss, + }, }; fn single_qubit_loss_branch( @@ -106,25 +109,34 @@ where I: Debug, { fn loss_channel(&mut self, addr0: usize, p: ::Coeff) { - let mut branches = Vec::<(GeneralizedTableau, T::Coeff, u64, u64)>::with_capacity( - self.entries.len(), - ); + // Lazy branch materialization: describe each loss branch as a mutation + // of its parent entry. The merge clones the parent only when the branch + // survives as a NEW entry; merges/below-cutoff drops never clone. + let mut branches = + Vec::<(usize, BranchMutation, T::Coeff, u64, u64)>::with_capacity(self.entries.len()); + let mut idx = 0usize; self.entries .for_each_mut_with_keys(|tab, p_sum, word_fp, phase_loss| { - single_qubit_loss_branch( - addr0, - &p, - &mut self.rng, - &mut branches, - tab, - p_sum, - (word_fp, phase_loss), - ); + // Increment for EVERY entry, before the lost check, so + // parent_idx aligns with for_each_mut_with_keys' order. + let parent_idx = idx; + idx += 1; + if tab.is_lost[addr0] { + return; + } + branches.push(( + parent_idx, + BranchMutation::Loss { q: addr0 }, + p_sum.clone() * p.clone(), + word_fp, + phase_loss ^ loss_mask(addr0), + )); + *p_sum *= T::Coeff::one() - p.clone(); }); let needs_renormalize = self .entries - .insert_or_merge_batch(branches, &self.sum_cutoff); + .insert_or_merge_mutated_branches(branches, &self.sum_cutoff); if needs_renormalize { self.normalize_probabilities(); } @@ -196,44 +208,85 @@ where { fn pauli_error(&mut self, addr0: usize, p: [::Coeff; 3]) { let p_total: T::Coeff = p[0].clone() + p[1].clone() + p[2].clone(); - let mut branches = Vec::<(GeneralizedTableau, T::Coeff, u64, u64)>::with_capacity( + // Lazy branch materialization: describe each X/Y/Z branch as a Pauli + // mutation of its parent. The phase/loss delta is computed by walking the + // parent's column once (no clone) — X flips rows with z, Y with x^z, Z + // with x — matching what `pauli_branch_phase_loss` would produce. + let mut branches = Vec::<(usize, BranchMutation, T::Coeff, u64, u64)>::with_capacity( 3 * self.entries.len(), ); - + // Precompute the per-row sign masks once instead of recomputing the + // splitmix `sign_mask` per row per entry in the hot loop below. + let masks = RowMasks::new(self.n_qubits); + // The store-word index / bit position of column `addr0` are the same for + // every entry and row, so resolve them once (Lsb0 convention). + let bits_per_word = std::mem::size_of::<::Store>() * 8; + let word_idx = addr0 / bits_per_word; + let bit = addr0 % bits_per_word; + let mut idx = 0usize; self.entries .for_each_mut_with_keys(|tab, p_sum, word_fp, phase_loss| { + let parent_idx = idx; + idx += 1; if tab.is_lost[addr0] { return; } - let tab_seed_x = self.rng.random::(); - let tab_seed_y = self.rng.random::(); - let tab_seed_z = self.rng.random::(); - - let mut tab_branch_x = tab.fork(Some(tab_seed_x)); - let mut tab_branch_y = tab.fork(Some(tab_seed_y)); - let mut tab_branch_z = tab.fork(Some(tab_seed_z)); - - tab_branch_x.x(addr0); - tab_branch_y.y(addr0); - tab_branch_z.z(addr0); + let (mut dx, mut dy, mut dz) = (0u64, 0u64, 0u64); + for (row, pw) in tab.tableau.data.iter().enumerate() { + let xw = pw.word.xbits.data.as_raw_slice(); + let zw = pw.word.zbits.data.as_raw_slice(); + let x: bool = bit_at(xw, word_idx, bit); + let z: bool = bit_at(zw, word_idx, bit); + let m = masks.sign[row]; + if z { + dx ^= m; + } + if x ^ z { + dy ^= m; + } + if x { + dz ^= m; + } + } - // X/Y/Z flip only phase bits, never the Pauli words, so all three - // branches reuse the parent's word-fingerprint and derive their - // phase/loss hash from the parent's by XORing the flipped rows. - let hx = pauli_branch_phase_loss(tab, &tab_branch_x, phase_loss); - let hy = pauli_branch_phase_loss(tab, &tab_branch_y, phase_loss); - let hz = pauli_branch_phase_loss(tab, &tab_branch_z, phase_loss); - branches.push((tab_branch_x, p_sum.clone() * p[0].clone(), word_fp, hx)); - branches.push((tab_branch_y, p_sum.clone() * p[1].clone(), word_fp, hy)); - branches.push((tab_branch_z, p_sum.clone() * p[2].clone(), word_fp, hz)); + branches.push(( + parent_idx, + BranchMutation::Pauli { + op: NotIdentity::X, + addr0, + }, + p_sum.clone() * p[0].clone(), + word_fp, + phase_loss ^ dx, + )); + branches.push(( + parent_idx, + BranchMutation::Pauli { + op: NotIdentity::Y, + addr0, + }, + p_sum.clone() * p[1].clone(), + word_fp, + phase_loss ^ dy, + )); + branches.push(( + parent_idx, + BranchMutation::Pauli { + op: NotIdentity::Z, + addr0, + }, + p_sum.clone() * p[2].clone(), + word_fp, + phase_loss ^ dz, + )); *p_sum *= T::Coeff::one() - p_total.clone(); }); let needs_normalize = self .entries - .insert_or_merge_batch(branches, &self.sum_cutoff); + .insert_or_merge_mutated_branches(branches, &self.sum_cutoff); if needs_normalize { self.normalize_probabilities(); } diff --git a/crates/ppvm-tableau-sum/src/storage/entry_store.rs b/crates/ppvm-tableau-sum/src/storage/entry_store.rs index 2dfaf9eb4..2cd822263 100644 --- a/crates/ppvm-tableau-sum/src/storage/entry_store.rs +++ b/crates/ppvm-tableau-sum/src/storage/entry_store.rs @@ -5,6 +5,8 @@ use num::Complex; use ppvm_tableau::{data::GeneralizedTableau, sparsevec::SparseVector}; use ppvm_traits::config::Config; +use crate::storage::BranchMutation; + /// One branch produced by a noise channel: its tableau, coefficient, and the /// cached `(word_fingerprint, phase_loss_hash)` pair, so a merge can recompute /// the full fingerprint (`word_fp ^ phase_loss`) without re-hashing the tableau. @@ -54,6 +56,21 @@ pub trait EntryStore, I>>: Clone /// fingerprint is `word_fp ^ phase_loss` — no re-hashing of the tableau. fn insert_or_merge_batch(&mut self, branches: Vec>, cutoff: &T::Coeff) -> bool; + /// Like [`insert_or_merge_batch`](Self::insert_or_merge_batch), but each + /// branch is described lazily as a [`BranchMutation`] of a parent entry + /// referenced by `parent_idx` — its ordinal in the order yielded by the + /// immediately-preceding [`for_each_mut_with_keys`](Self::for_each_mut_with_keys) + /// call (entry index for `VecStorage`; flat bucket order for `MapStorage`). + /// The branch tableau is materialized ONLY when it survives as a new entry; + /// merges and below-cutoff drops never clone. `word_fp`/`phase_loss` are the + /// branch's already-computed fingerprint halves; full fp = `word_fp ^ phase_loss`. + /// Returns true if any branch was dropped (caller renormalizes). + fn insert_or_merge_mutated_branches( + &mut self, + branches: Vec<(usize, BranchMutation, T::Coeff, u64, u64)>, + cutoff: &T::Coeff, + ) -> bool; + fn retain(&mut self, f: F) where F: FnMut(&GeneralizedTableau, &T::Coeff) -> bool; diff --git a/crates/ppvm-tableau-sum/src/storage/map.rs b/crates/ppvm-tableau-sum/src/storage/map.rs index 15125f76c..7242f3cdf 100644 --- a/crates/ppvm-tableau-sum/src/storage/map.rs +++ b/crates/ppvm-tableau-sum/src/storage/map.rs @@ -15,8 +15,12 @@ use ppvm_traits::config::Config; use smallvec::SmallVec; use crate::storage::{ - EntryStore, fingerprint, phase_loss_hash, structurally_equal, word_fingerprint, + Branch, BranchMutation, EntryStore, RowMasks, apply_branch_mutation, fingerprint, + phase_loss_hash_with, structurally_equal, word_fingerprint, }; +use bitvec::view::BitView; +use num::PrimInt; +use ppvm_traits::traits::Clifford; type Bucket = SmallVec<[(GeneralizedTableau, ::Coeff); 1]>; @@ -75,6 +79,8 @@ where + Copy, I: TableauIndex + Send + Sync, C: SparseVector, I>, + GeneralizedTableau: Clifford, + <::Storage as BitView>::Store: PrimInt, { fn with_capacity(cap: usize) -> Self { Self { @@ -122,11 +128,21 @@ where F: FnMut(&mut GeneralizedTableau, &mut ::Coeff, u64, u64), { self.rebuild_if_dirty(); - for v in self.buckets.values_mut() { - for (tab, c) in v.iter_mut() { - let word_fp = word_fingerprint(tab); - let phase_loss = phase_loss_hash(tab); - f(tab, c, word_fp, phase_loss); + // Build the per-row mask table once; every tableau in the sum shares the + // same qubit count. Skip when there are no entries. + let masks = self + .buckets + .values() + .flat_map(|v| v.iter()) + .next() + .map(|(t, _)| RowMasks::new(t.is_lost.len())); + if let Some(masks) = masks { + for v in self.buckets.values_mut() { + for (tab, c) in v.iter_mut() { + let word_fp = word_fingerprint(tab); + let phase_loss = phase_loss_hash_with(tab, &masks); + f(tab, c, word_fp, phase_loss); + } } } } @@ -177,6 +193,34 @@ where needs_renormalize } + fn insert_or_merge_mutated_branches( + &mut self, + branches: Vec<(usize, BranchMutation, ::Coeff, u64, u64)>, + cutoff: &::Coeff, + ) -> bool { + self.rebuild_if_dirty(); + + // Materialize parents in the SAME order as for_each_mut_with_keys so + // parent_idx aligns. Correctness-only path: no clone savings here. + let parents: Vec<_> = self + .buckets + .values() + .flat_map(|v| v.iter()) + .map(|(t, _)| t.clone()) + .collect(); + + let real: Vec> = branches + .into_iter() + .map(|(parent_idx, mutation, p, word_fp, phase_loss)| { + let mut tab = parents[parent_idx].clone(); + apply_branch_mutation(&mut tab, mutation); + (tab, p, word_fp, phase_loss) + }) + .collect(); + + self.insert_or_merge_batch(real, cutoff) + } + fn retain(&mut self, mut f: F) where F: FnMut(&GeneralizedTableau, &::Coeff) -> bool, diff --git a/crates/ppvm-tableau-sum/src/storage/mod.rs b/crates/ppvm-tableau-sum/src/storage/mod.rs index 03d4c4a74..c4320146c 100644 --- a/crates/ppvm-tableau-sum/src/storage/mod.rs +++ b/crates/ppvm-tableau-sum/src/storage/mod.rs @@ -7,28 +7,47 @@ pub mod vec; pub use entry_store::{Branch, EntryStore}; use fxhash::FxHashMap; +use ppvm_traits::traits::Clifford; // Hasher for the structural `word_fingerprint`. gxhash (AES-based) is fastest on -// native but needs hardware AES and does not build on wasm32, so fall back to -// fxhash there. The fingerprint is a transient in-memory dedup key — collisions -// are resolved by `structurally_equal`, and it is never persisted or compared -// across builds — so the hasher may differ per target without affecting results. +// native and exposes a `gxhash64` bulk free function, but it needs hardware AES +// and does not build on wasm32, so fall back to fxhash there. The fingerprint is +// a transient in-memory dedup key — collisions are resolved by +// `structurally_equal`, and it is never persisted or compared across builds — so +// the hasher may differ per target without affecting results. +use bitvec::view::{BitView, BitViewSized}; #[cfg(target_arch = "wasm32")] use fxhash::FxHasher as FingerprintHasher; -#[cfg(not(target_arch = "wasm32"))] -use gxhash::GxHasher as FingerprintHasher; use num::{ - Complex, One, Zero, + Complex, One, PrimInt, Zero, complex::{Complex64, ComplexFloat}, }; +use ppvm_pauli_word::pattern::NotIdentity; use ppvm_tableau::{ data::GeneralizedTableau, sparsevec::SparseVector, tableau_index::TableauIndex, }; use ppvm_traits::config::Config; -use std::{ - hash::{Hash, Hasher}, - ops::AddAssign, -}; +#[cfg(target_arch = "wasm32")] +use std::hash::Hasher; +use std::ops::AddAssign; + +// Reusable per-thread scratch buffer for `word_fingerprint`. Gathering every +// row's word bytes into one contiguous slice lets us hash in a single bulk call +// instead of two tiny `Hash::hash` writes per row (high per-call overhead). +// Cleared (capacity retained) per call, so it adapts to any row count / qubit +// width without re-allocating. Bytes (not the storage word type) because the +// storage element width (`[u8; N]` vs `[u64; N]`) is generic at this call site. +thread_local! { + static WORD_FP_BUF: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +/// Read a single bit from a raw store-word slice (Lsb0 convention). Skips the +/// per-call word/bit recomputation and bounds-check that `BitArray`'s `Index` +/// does, for the hot per-row column reads in noise propagation. +#[inline] +pub(crate) fn bit_at(words: &[S], word_idx: usize, bit: usize) -> bool { + (words[word_idx] >> bit) & S::one() != S::zero() +} /// Hash of the `word` (Pauli content) of every row, in order. This is the /// expensive component (each word is several machine words wide) and is @@ -42,15 +61,31 @@ where I:, C: SparseVector, I>, { - let mut hasher = FingerprintHasher::default(); - for row in tab.tableau.data.iter() { - // Hash the Pauli bits directly: the `PauliWord` hash cache is disabled - // for tableau rows (`REHASH = false`), so `row.word.hash()` would feed - // a stale zero and make every tableau collide. - row.word.xbits.data.hash(&mut hasher); - row.word.zbits.data.hash(&mut hasher); - } - hasher.finish() + WORD_FP_BUF.with(|cell| { + let mut buf = cell.borrow_mut(); + // Clear retains capacity; refill with every row's bits as raw bytes. + buf.clear(); + for row in tab.tableau.data.iter() { + // Gather the Pauli bits directly: the `PauliWord` hash cache is + // disabled for tableau rows (`REHASH = false`), so hashing + // `row.word` would feed a stale zero and make every tableau collide. + // `xbits.data`/`zbits.data` are the `PauliStorage` backing array, + // which is `bytemuck::Pod`, so this byte view is safe and zero-copy. + buf.extend_from_slice(bytemuck::bytes_of(&row.word.xbits.data)); + buf.extend_from_slice(bytemuck::bytes_of(&row.word.zbits.data)); + } + + #[cfg(not(target_arch = "wasm32"))] + { + gxhash::gxhash64(&buf, 0) + } + #[cfg(target_arch = "wasm32")] + { + let mut hasher = FingerprintHasher::default(); + hasher.write(&buf); + hasher.finish() + } + }) } /// Per-row mask (splitmix64 of `(index, salt)`); a stable pure function used @@ -87,17 +122,25 @@ pub(crate) fn loss_mask(q: usize) -> u64 { row_mask(q, 0xC3C3_C3C3_C3C3_C3C3) } -/// XOR contribution of a single row's phase. -#[inline] -fn phase_contrib(row: usize, phase: u8) -> u64 { - let mut h = 0; - if phase & 1 != 0 { - h ^= imag_mask(row); - } - if phase & 2 != 0 { - h ^= sign_mask(row); +/// Precomputed per-row/per-qubit masks (sign, imag, loss). Built once per op and +/// indexed instead of recomputing the splitmix `row_mask` per row per entry. +pub(crate) struct RowMasks { + pub sign: Vec, // sign_mask(i) for i in 0..2*n_qubits + pub imag: Vec, // imag_mask(i) for i in 0..2*n_qubits + pub loss: Vec, // loss_mask(q) for q in 0..n_qubits +} + +impl RowMasks { + /// Build the mask tables. The tableau has `2 * n_qubits` rows (`sign`/`imag` + /// indexed by row); `loss` is indexed by qubit `0..n_qubits`. + pub(crate) fn new(n_qubits: usize) -> Self { + let n_rows = 2 * n_qubits; + Self { + sign: (0..n_rows).map(sign_mask).collect(), + imag: (0..n_rows).map(imag_mask).collect(), + loss: (0..n_qubits).map(loss_mask).collect(), + } } - h } /// XOR-combinable hash of `is_lost` plus every row's `phase`, formed as the @@ -105,17 +148,41 @@ fn phase_contrib(row: usize, phase: u8) -> u64 { /// XOR-combinable lets a branch inherit its parent's value and update only the /// rows it changed — a sign flip XORs [`sign_mask`], a loss XORs [`loss_mask`]. pub fn phase_loss_hash(tab: &GeneralizedTableau) -> u64 +where + T: Config, + C: SparseVector, I>, +{ + // Single implementation: build a one-shot mask table and delegate so the + // table-indexed and from-scratch values are guaranteed identical. + // `is_lost.len() == n_qubits` and is available under these minimal bounds. + let masks = RowMasks::new(tab.is_lost.len()); + phase_loss_hash_with(tab, &masks) +} + +/// Like [`phase_loss_hash`], but indexes a precomputed [`RowMasks`] instead of +/// recomputing the splitmix masks per row/qubit. Reproduces the same value: +/// phase bit 0 (imag) XORs `masks.imag[row]`, phase bit 1 (sign) XORs +/// `masks.sign[row]`, and a lost qubit `q` XORs `masks.loss[q]`. +pub(crate) fn phase_loss_hash_with( + tab: &GeneralizedTableau, + masks: &RowMasks, +) -> u64 where T: Config, C: SparseVector, I>, { let mut h = 0u64; for (row, ppw) in tab.tableau.data.iter().enumerate() { - h ^= phase_contrib(row, ppw.phase); + if ppw.phase & 1 != 0 { + h ^= masks.imag[row]; + } + if ppw.phase & 2 != 0 { + h ^= masks.sign[row]; + } } for (q, lost) in tab.is_lost.iter().enumerate() { if *lost { - h ^= loss_mask(q); + h ^= masks.loss[q]; } } h @@ -218,6 +285,156 @@ where true } +/// A lazily-described branch: a mutation applied to a parent entry. Used so the +/// merge can compute the branch fingerprint / structural identity without +/// cloning, materializing the tableau only for surviving new entries. +#[derive(Clone, Copy, Debug)] +pub enum BranchMutation { + /// Apply a non-identity Pauli at `addr0`: flips per-row sign bits only. + Pauli { op: NotIdentity, addr0: usize }, + /// Mark qubit `q` lost (set is_lost[q] = true). + Loss { q: usize }, +} + +/// Materialize a lazily-described branch into a (cloned) tableau in place. +pub(crate) fn apply_branch_mutation( + tab: &mut GeneralizedTableau, + m: BranchMutation, +) where + T: Config, + I: TableauIndex, + C: SparseVector, I>, + GeneralizedTableau: Clifford, +{ + match m { + BranchMutation::Pauli { op, addr0 } => match op { + NotIdentity::X => tab.x(addr0), + NotIdentity::Y => tab.y(addr0), + NotIdentity::Z => tab.z(addr0), + }, + BranchMutation::Loss { q } => { + tab.is_lost[q] = true; + } + } +} + +/// Like [`structurally_equal`], but compares `existing` against the *virtual* +/// tableau `parent + m` without materializing it. Mirrors `structurally_equal` +/// field-by-field, deriving each field of the virtual tableau from `parent`: +/// - `is_lost`: for `Loss { q }`, equals `parent`'s with index `q` forced true; +/// for `Pauli`, equals `parent`'s unchanged. +/// - `coefficients`: unchanged by both mutations. +/// - rows: for `Loss`, unchanged; for `Pauli`, each row's sign bit (phase bit 1) +/// is flipped per the per-column rule (X: z; Y: x^z; Z: x). +pub(crate) fn structurally_equal_mutated( + existing: &GeneralizedTableau, + parent: &GeneralizedTableau, + m: BranchMutation, + scratch: &mut FxHashMap>, +) -> bool +where + T: Config, + <::Storage as BitView>::Store: PrimInt, + T::Coeff: One + Zero + Clone + num::Num + PartialOrd, + Complex: std::ops::Mul> + + AddAssign + + From + + ComplexFloat + + Copy, + I: TableauIndex, + C: SparseVector, I>, +{ + // NOTE: comparing is_lost and rows is only necessary to avoid hash collisions + + match m { + BranchMutation::Loss { q } => { + // Virtual is_lost == parent's with index q forced true. + if existing.is_lost.len() != parent.is_lost.len() { + return false; + } + for (i, (&e, &p)) in existing + .is_lost + .iter() + .zip(parent.is_lost.iter()) + .enumerate() + { + let virt = if i == q { true } else { p }; + if e != virt { + return false; + } + } + } + BranchMutation::Pauli { .. } => { + // Virtual is_lost == parent's, unchanged. + if existing.is_lost != parent.is_lost { + return false; + } + } + } + + if existing.coefficients.len() != parent.coefficients.len() { + return false; + } + + // Cheaper row comparison first; coefficient compare is O(K) below. + match m { + BranchMutation::Loss { .. } => { + for (re, rp) in existing.tableau.data.iter().zip(parent.tableau.data.iter()) { + if re.phase != rp.phase || re.word != rp.word { + return false; + } + } + } + BranchMutation::Pauli { op, addr0 } => { + let bits_per_word = std::mem::size_of::<::Store>() * 8; + let word_idx = addr0 / bits_per_word; + let bit = addr0 % bits_per_word; + for (re, rp) in existing.tableau.data.iter().zip(parent.tableau.data.iter()) { + if re.word != rp.word { + return false; + } + let xw = rp.word.xbits.data.as_raw_slice(); + let zw = rp.word.zbits.data.as_raw_slice(); + let x: bool = bit_at(xw, word_idx, bit); + let z: bool = bit_at(zw, word_idx, bit); + let flip = match op { + NotIdentity::X => z, + NotIdentity::Y => x ^ z, + NotIdentity::Z => x, + }; + let virt_phase = rp.phase ^ ((flip as u8) << 1); + if re.phase != virt_phase { + return false; + } + } + } + } + + // Reuse the caller-owned scratch map instead of allocating per call. + // Clear retains capacity across invocations. Coefficients are unchanged + // by both mutations, so compare existing vs parent directly. + scratch.clear(); + scratch.reserve(parent.coefficients.len()); + for (val, idx) in parent.coefficients.iter() { + scratch.insert(*idx, *val); + } + + let threshold_sq = + existing.coefficient_threshold.clone() * existing.coefficient_threshold.clone(); + let zero = Complex { + re: T::Coeff::zero(), + im: T::Coeff::zero(), + }; + for (val0, idx0) in existing.coefficients.iter() { + let val1 = scratch.get(idx0).copied().unwrap_or(zero); + if (*val0 - val1).norm_sqr() >= threshold_sq { + return false; + } + } + + true +} + #[cfg(test)] mod fingerprint_tests { use super::{ diff --git a/crates/ppvm-tableau-sum/src/storage/vec.rs b/crates/ppvm-tableau-sum/src/storage/vec.rs index a235b32d6..7a13e23e8 100644 --- a/crates/ppvm-tableau-sum/src/storage/vec.rs +++ b/crates/ppvm-tableau-sum/src/storage/vec.rs @@ -13,7 +13,13 @@ use ppvm_tableau::{ }; use ppvm_traits::config::Config; -use crate::storage::{EntryStore, phase_loss_hash, structurally_equal, word_fingerprint}; +use crate::storage::{ + BranchMutation, EntryStore, RowMasks, apply_branch_mutation, phase_loss_hash_with, + structurally_equal, structurally_equal_mutated, word_fingerprint, +}; +use bitvec::view::BitView; +use num::PrimInt; +use ppvm_traits::traits::Clifford; #[derive(Clone)] pub struct VecStorage, I>> { @@ -103,12 +109,17 @@ where self.fingerprints.clear(); self.word_fingerprints.clear(); self.phase_loss_hashes.clear(); - for (t, _) in self.entries.iter() { - let wfp = word_fingerprint(t); - let plh = phase_loss_hash(t); - self.word_fingerprints.push(wfp); - self.phase_loss_hashes.push(plh); - self.fingerprints.push(wfp ^ plh); + // Build the per-row mask table once for all entries (every tableau in a + // sum shares the same qubit count). Skip when there are no entries. + if let Some((first, _)) = self.entries.first() { + let masks = RowMasks::new(first.is_lost.len()); + for (t, _) in self.entries.iter() { + let wfp = word_fingerprint(t); + let plh = phase_loss_hash_with(t, &masks); + self.word_fingerprints.push(wfp); + self.phase_loss_hashes.push(plh); + self.fingerprints.push(wfp ^ plh); + } } self.dirty = false; } @@ -125,6 +136,8 @@ where + Copy, I: TableauIndex + Send + Sync, C: SparseVector, I>, + GeneralizedTableau: Clifford, + <::Storage as BitView>::Store: PrimInt, { fn with_capacity(cap: usize) -> Self { Self { @@ -201,6 +214,72 @@ where needs_renormalize } + fn insert_or_merge_mutated_branches( + &mut self, + branches: Vec<(usize, BranchMutation, ::Coeff, u64, u64)>, + cutoff: &::Coeff, + ) -> bool { + // Defensive: should be a no-op since the caller's for_each_mut_with_keys + // already ran and the branch words were never mutated. + self.rebuild_fingerprints_if_dirty(); + + let mut fp_index: FxHashMap> = + FxHashMap::with_capacity_and_hasher(self.entries.len(), Default::default()); + for i in 0..self.entries.len() { + let fp = self.fingerprints[i]; + fp_index.entry(fp).or_default().push(i); + } + + let mut needs_renormalize = false; + for (parent_idx, mutation, p, word_fp, phase_loss) in branches { + let fp = word_fp ^ phase_loss; + + // Find a structurally-equal existing entry among the fp candidates + // WITHOUT materializing the branch tableau. The disjoint-field borrow + // (`self.entries` immutable + `self.scratch` mutable) is allowed. + let mut found: Option = None; + if let Some(candidates) = fp_index.get(&fp) { + for &i in candidates { + if structurally_equal_mutated( + &self.entries[i].0, + &self.entries[parent_idx].0, + mutation, + &mut self.scratch, + ) { + found = Some(i); + break; + } + } + } + + match found { + Some(i) => { + let p0 = &self.entries[i].1; + self.entries[i].1 = p0.clone() + p; + } + None => { + if &p > cutoff { + // Surviving new entry: materialize now (clone parent + + // apply mutation). Later branches' parent_idx still refer + // to the original entries — push never moves them. + let mut tab = self.entries[parent_idx].0.clone(); + apply_branch_mutation(&mut tab, mutation); + let new_idx = self.entries.len(); + self.entries.push((tab, p)); + self.fingerprints.push(fp); + self.word_fingerprints.push(word_fp); + self.phase_loss_hashes.push(phase_loss); + fp_index.entry(fp).or_default().push(new_idx); + } else { + needs_renormalize = true; + } + } + } + } + + needs_renormalize + } + fn retain(&mut self, mut f: F) where F: FnMut(&GeneralizedTableau, &::Coeff) -> bool, diff --git a/docs/superpowers/plans/2026-06-23-stim-parser-2.md b/docs/superpowers/plans/2026-06-23-stim-parser-2.md deleted file mode 100644 index 5bb8f6e99..000000000 --- a/docs/superpowers/plans/2026-06-23-stim-parser-2.md +++ /dev/null @@ -1,2018 +0,0 @@ -# `stim-parser-2` Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Reimplement `stim-parser` as a new crate `stim-parser-2` with a typestate lowering pipeline, a diagnostic-sink effect model, two AST enums sharing per-family payloads, a single-source instruction table, and a first-class canonical pretty-printer — then prove parity and swap it in. - -**Architecture:** Three-stage pipeline (`syntax → validate → lower`) driven by a typestate `Pipeline` whose transitions consume `self`. Each stage emits `Diagnostic`s to a caller-supplied `DiagnosticSink` whose `Flow` return value is the continuation decision. The chumsky syntax engine is retained; the AST is two enums (`Instruction`, `ExtendedInstruction`) sharing `GateOp`/`NoiseOp`/`MeasureOp`/`AnnotationOp`/`MppOp`. - -**Tech Stack:** Rust 2024, chumsky 0.12, thiserror 2, proptest 1. - -**Reference implementation:** The existing `crates/stim-parser/` is a working, well-tested reference that stays in the tree until the swap. Many tasks below are *port-and-adapt* from a named source file — open it, copy the logic, apply the stated transformation. This is intentional (DRY across crates) and not a placeholder. - -**Design spec:** `docs/superpowers/specs/2026-06-23-stim-parser-2-refactor-design.md`. - ---- - -## File Structure - -New crate `crates/stim-parser-2/`: - -| File | Responsibility | -|---|---| -| `Cargo.toml` | Crate manifest (name `stim-parser-2`, deps chumsky/thiserror, dev-dep proptest) | -| `src/lib.rs` | Module wiring, `prelude`, Tier-1 `parse`/`parse_extended` | -| `src/diagnostics/mod.rs` | `Severity`, `Flow`, `Diagnostic`, `DiagnosticSink`, `Aborted`, `Diagnostics` | -| `src/diagnostics/span.rs` | `Span`, `LineMap` | -| `src/diagnostics/sinks.rs` | `FailFast`, `Collect` | -| `src/instructions/mod.rs` | Name enums, arity enums, `TableEntry`/`EntryKind`, `TABLE`, `lookup`, `canonical_name`, completeness tests | -| `src/ast/mod.rs` | Re-exports | -| `src/ast/shared.rs` | `GateOp`/`NoiseOp`/`MeasureOp`/`AnnotationOp`/`MppOp`, `Target`, `PauliFactor`, `PauliAxis`, `Axis`, `Tag`, `TagParam` | -| `src/ast/vanilla.rs` | `Instruction`, `Program` | -| `src/ast/extended.rs` | `ExtendedInstruction`, `ExtendedProgram`, `measurement_count` | -| `src/syntax/mod.rs` | Re-exports + `run_on_parser_stack` | -| `src/syntax/raw.rs` | `RawSyntaxNode`, `RawTarget`, `RawSyntaxTree` | -| `src/syntax/grammar.rs` | chumsky combinators → `RawSyntaxTree` | -| `src/pipeline/mod.rs` | `Pipeline`, state structs, transitions | -| `src/pipeline/validate.rs` | `RawSyntaxTree` → `Program` | -| `src/pipeline/lower.rs` | `Program` → `ExtendedProgram` | -| `src/print/mod.rs` | `PrintOptions`, `StimPrint`, `Display` impls, `to_stim` | -| `tests/*.rs` | Ported integration tests + proptests | -| `tests/parity.rs` | Differential harness vs old `stim-parser` | - -Swap phase modifies: `Cargo.toml` (workspace members), `crates/ppvm-stim/Cargo.toml`, `crates/ppvm-stim/src/{lib,executor,validate}.rs`, `crates/ppvm-stim/benches/tableau-msd-stim.rs`. - ---- - -## Phase 0 — Crate scaffold - -### Task 0: Create the crate and register it in the workspace - -**Files:** -- Create: `crates/stim-parser-2/Cargo.toml` -- Create: `crates/stim-parser-2/src/lib.rs` -- Modify: `Cargo.toml` (workspace `members`) - -- [ ] **Step 1: Write `Cargo.toml`** - -```toml -[package] -name = "stim-parser-2" -version = "0.1.0" -edition = "2024" - -[dependencies] -chumsky = "0.12.0" -thiserror = "2.0.18" - -[dev-dependencies] -proptest = "1" -stim-parser = { version = "0.1.0", path = "../stim-parser" } # parity harness only; removed at swap -``` - -- [ ] **Step 2: Write a placeholder `src/lib.rs`** - -```rust -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 -``` - -- [ ] **Step 3: Register in workspace members** - -In the root `Cargo.toml`, change the line `"crates/ppvm-stim", "crates/stim-parser",` to add the new crate: - -```toml - "crates/ppvm-stim", "crates/stim-parser", "crates/stim-parser-2", -``` - -- [ ] **Step 4: Verify it builds** - -Run: `cargo build -p stim-parser-2` -Expected: `Finished` (empty crate compiles). - -- [ ] **Step 5: Commit** - -```bash -git add Cargo.toml crates/stim-parser-2/Cargo.toml crates/stim-parser-2/src/lib.rs -git commit -m "feat(stim-parser-2): scaffold new crate" -``` - ---- - -## Phase 1 — `diagnostics/` (Span, LineMap, sink, Diagnostics) - -### Task 1: `Span` and `LineMap` - -**Files:** -- Create: `crates/stim-parser-2/src/diagnostics/span.rs` -- Test: same file (`#[cfg(test)]`) -- Reference: `crates/stim-parser/src/line_map.rs` - -- [ ] **Step 1: Write the failing tests** - -```rust -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn line_col_at_line_start() { - let m = LineMap::new("abc\ndef\nghi"); - assert_eq!(m.line_col(0), (1, 1)); - assert_eq!(m.line_col(4), (2, 1)); - assert_eq!(m.line_col(8), (3, 1)); - } - - #[test] - fn line_col_mid_line() { - let m = LineMap::new("abc\ndef\nghi"); - assert_eq!(m.line_col(2), (1, 3)); - assert_eq!(m.line_col(6), (2, 3)); - } - - #[test] - fn span_resolves_against_line_map() { - let m = LineMap::new("X 0\nH 0"); - let span = Span::new(4, 5); - assert_eq!(span.line_col(&m), (2, 1)); - assert_eq!(span.line(&m), 2); - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `cargo test -p stim-parser-2 --lib span` -Expected: FAIL — `LineMap`/`Span` not found. - -- [ ] **Step 3: Implement `Span` and `LineMap`** - -Port `LineMap` verbatim from `crates/stim-parser/src/line_map.rs` (the `new`/`line_of`/`line_col`/`starts_at` methods and the `Debug` impl are unchanged), and add `Span` above it: - -```rust -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -//! Byte spans and the line/column map shared by every diagnostic. - -/// Half-open byte range `[start, end)` into the source string. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Span { - pub start: usize, - pub end: usize, -} - -impl Span { - pub fn new(start: usize, end: usize) -> Self { - Span { start, end } - } - - /// 1-indexed `(line, col)` of the span start. - pub fn line_col(&self, line_map: &LineMap) -> (usize, usize) { - line_map.line_col(self.start) - } - - /// 1-indexed line of the span start. - pub fn line(&self, line_map: &LineMap) -> usize { - line_map.line_of(self.start) - } -} - -impl From> for Span { - fn from(s: chumsky::span::SimpleSpan) -> Self { - Span::new(s.start, s.end) - } -} - -// ---- LineMap: ported verbatim from crates/stim-parser/src/line_map.rs ---- -``` - -Then paste the entire `LineMap` struct, its `Debug` impl, and its `impl LineMap { new, line_of, line_col, starts_at }` block from the reference file (drop that file's own `#[cfg(test)]` block — the tests live above). - -- [ ] **Step 4: Wire the module** - -Create `crates/stim-parser-2/src/diagnostics/mod.rs` with: - -```rust -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -mod span; -pub use span::{LineMap, Span}; -``` - -And in `src/lib.rs` add: - -```rust -pub mod diagnostics; -``` - -- [ ] **Step 5: Run to verify pass** - -Run: `cargo test -p stim-parser-2 --lib span` -Expected: PASS (3 tests). - -- [ ] **Step 6: Commit** - -```bash -git add crates/stim-parser-2/src -git commit -m "feat(stim-parser-2): add Span and LineMap" -``` - -### Task 2: `Diagnostic`, `Severity`, `Flow`, `DiagnosticSink`, `Aborted`, `Diagnostics` - -**Files:** -- Modify: `crates/stim-parser-2/src/diagnostics/mod.rs` -- Test: `crates/stim-parser-2/src/diagnostics/mod.rs` (`#[cfg(test)]`) - -- [ ] **Step 1: Write the failing test** - -```rust -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Arc; - - #[test] - fn diagnostics_display_renders_line_col() { - let line_map = Arc::new(LineMap::new("X 0\nBADINSTR 0")); - let diag = Diagnostic::error(Span::new(4, 12), "unknown-instruction", "unknown instruction 'BADINSTR'"); - let diags = Diagnostics::new(vec![diag], line_map); - assert_eq!( - diags.to_string(), - "error at line 2, col 1: unknown instruction 'BADINSTR'" - ); - } - - #[test] - fn diagnostics_is_empty_when_no_items() { - let line_map = Arc::new(LineMap::new("")); - assert!(Diagnostics::new(vec![], line_map).is_empty()); - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `cargo test -p stim-parser-2 --lib diagnostics::tests` -Expected: FAIL — types not found. - -- [ ] **Step 3: Implement the types** - -Add to `src/diagnostics/mod.rs` (above the `mod span;` line keep module decls together; types below): - -```rust -use std::fmt; -use std::sync::Arc; - -/// Severity of a diagnostic. Only `Error` aborts a `FailFast` run. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Severity { - Error, - Warning, -} - -/// A sink's continuation decision: keep processing the current stage, or -/// abort it as soon as possible. The handler returning `Flow` is how the -/// effect model "handles errors as continuations". -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Flow { - Continue, - Abort, -} - -/// One diagnostic, carrying a span so every message can render `line:col`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Diagnostic { - pub severity: Severity, - pub span: Span, - pub message: String, - /// Stable short kind tag (e.g. "unknown-instruction") for matching - /// without string-sniffing. - pub code: Option<&'static str>, -} - -impl Diagnostic { - pub fn error(span: Span, code: &'static str, message: impl Into) -> Self { - Diagnostic { severity: Severity::Error, span, message: message.into(), code: Some(code) } - } - - pub fn warning(span: Span, code: &'static str, message: impl Into) -> Self { - Diagnostic { severity: Severity::Warning, span, message: message.into(), code: Some(code) } - } -} - -/// A handler the pipeline emits diagnostics to. The returned `Flow` tells -/// the emitting stage whether to continue (recover) or abort. -pub trait DiagnosticSink { - fn emit(&mut self, diagnostic: Diagnostic) -> Flow; -} - -/// Marker returned by a pipeline transition that was told to `Abort`. -/// The diagnostics themselves live in the caller-owned sink. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Aborted; - -/// Aggregate returned by the Tier-1 `parse`/`parse_extended` functions on -/// failure. Owns a `LineMap` so `Display` can render `line:col`. -#[derive(Debug, Clone)] -pub struct Diagnostics { - items: Vec, - line_map: Arc, -} - -impl Diagnostics { - pub fn new(items: Vec, line_map: Arc) -> Self { - Diagnostics { items, line_map } - } - - pub fn iter(&self) -> impl Iterator { - self.items.iter() - } - - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } -} - -impl fmt::Display for Diagnostics { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for (i, d) in self.items.iter().enumerate() { - if i > 0 { - writeln!(f)?; - } - let (line, col) = d.span.line_col(&self.line_map); - let sev = match d.severity { - Severity::Error => "error", - Severity::Warning => "warning", - }; - write!(f, "{sev} at line {line}, col {col}: {}", d.message)?; - } - Ok(()) - } -} - -impl std::error::Error for Diagnostics {} -``` - -- [ ] **Step 4: Re-export the new types** - -Update the top of `src/diagnostics/mod.rs` so the public surface is: - -```rust -mod span; -pub use span::{LineMap, Span}; -``` - -(The new types are already `pub` at module root, so `pub mod diagnostics;` in `lib.rs` exposes them.) - -- [ ] **Step 5: Run to verify pass** - -Run: `cargo test -p stim-parser-2 --lib diagnostics::tests` -Expected: PASS (2 tests). - -- [ ] **Step 6: Commit** - -```bash -git add crates/stim-parser-2/src/diagnostics/mod.rs -git commit -m "feat(stim-parser-2): add Diagnostic, DiagnosticSink, Diagnostics" -``` - -### Task 3: `FailFast` and `Collect` sinks - -**Files:** -- Create: `crates/stim-parser-2/src/diagnostics/sinks.rs` -- Modify: `crates/stim-parser-2/src/diagnostics/mod.rs` -- Test: `crates/stim-parser-2/src/diagnostics/sinks.rs` (`#[cfg(test)]`) - -- [ ] **Step 1: Write the failing tests** - -```rust -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -#[cfg(test)] -mod tests { - use super::*; - use crate::diagnostics::{Diagnostic, DiagnosticSink, Flow, Severity, Span}; - - fn err() -> Diagnostic { Diagnostic::error(Span::new(0, 1), "x", "boom") } - fn warn() -> Diagnostic { Diagnostic::warning(Span::new(0, 1), "x", "heads up") } - - #[test] - fn fail_fast_aborts_on_first_error_and_keeps_it() { - let mut s = FailFast::new(); - assert_eq!(s.emit(warn()), Flow::Continue); // warnings don't abort - assert_eq!(s.emit(err()), Flow::Abort); - let items = s.into_items(); - assert_eq!(items.len(), 2); - assert_eq!(items[1].severity, Severity::Error); - } - - #[test] - fn collect_never_aborts_and_gathers_all() { - let mut s = Collect::new(); - assert_eq!(s.emit(err()), Flow::Continue); - assert_eq!(s.emit(err()), Flow::Continue); - assert_eq!(s.into_items().len(), 2); - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `cargo test -p stim-parser-2 --lib sinks` -Expected: FAIL — `FailFast`/`Collect` not found. - -- [ ] **Step 3: Implement the sinks** - -```rust -// (license header above) - -//! Two provided `DiagnosticSink` policies. - -use crate::diagnostics::{Diagnostic, DiagnosticSink, Flow, Severity}; - -/// Aborts the current stage on the first `Error` (warnings pass through). -/// Used by the Tier-1 `parse`/`parse_extended` functions. -#[derive(Debug, Default)] -pub struct FailFast { - items: Vec, - saw_error: bool, -} - -impl FailFast { - pub fn new() -> Self { - Self::default() - } - - pub fn saw_error(&self) -> bool { - self.saw_error - } - - pub fn into_items(self) -> Vec { - self.items - } -} - -impl DiagnosticSink for FailFast { - fn emit(&mut self, diagnostic: Diagnostic) -> Flow { - let is_error = diagnostic.severity == Severity::Error; - self.items.push(diagnostic); - if is_error { - self.saw_error = true; - Flow::Abort - } else { - Flow::Continue - } - } -} - -/// Never aborts; accumulates every diagnostic for one-pass reporting. -#[derive(Debug, Default)] -pub struct Collect { - items: Vec, -} - -impl Collect { - pub fn new() -> Self { - Self::default() - } - - pub fn into_items(self) -> Vec { - self.items - } -} - -impl DiagnosticSink for Collect { - fn emit(&mut self, diagnostic: Diagnostic) -> Flow { - self.items.push(diagnostic); - Flow::Continue - } -} -``` - -- [ ] **Step 4: Wire the module** - -In `src/diagnostics/mod.rs` add `mod sinks;` and `pub use sinks::{Collect, FailFast};`. - -- [ ] **Step 5: Run to verify pass** - -Run: `cargo test -p stim-parser-2 --lib sinks` -Expected: PASS (2 tests). - -- [ ] **Step 6: Commit** - -```bash -git add crates/stim-parser-2/src/diagnostics -git commit -m "feat(stim-parser-2): add FailFast and Collect sinks" -``` - ---- - -## Phase 2 — `instructions/` (single-source table) - -### Task 4: Name enums, arity enums, table types - -**Files:** -- Create: `crates/stim-parser-2/src/instructions/mod.rs` -- Modify: `crates/stim-parser-2/src/lib.rs` -- Reference: `crates/stim-parser/src/ast.rs` (enums + `canonical_name`), `crates/stim-parser/src/table.rs` - -- [ ] **Step 1: Port the name enums and arity enums** - -Into `src/instructions/mod.rs`, port verbatim from `crates/stim-parser/src/ast.rs`: -- `GateName`, `NoiseName`, `MeasureName`, `AnnotationKind` (the enum definitions only — *not* their `canonical_name` impls yet), -- `ArgCount`, `TargetArity`. - -Then port from `crates/stim-parser/src/table.rs`: `TableEntry`, `EntryKind` (keep `MPad` variant), and the `gate`/`noise`/`measure`/`measure_pairs`/`annotation` const constructors. Add the license header. Do **not** port `canonical_name` or `TABLE` yet (next steps). - -- [ ] **Step 2: Wire the module and build** - -In `src/lib.rs` add `pub mod instructions;`. -Run: `cargo build -p stim-parser-2` -Expected: `Finished`. - -- [ ] **Step 3: Commit** - -```bash -git add crates/stim-parser-2/src -git commit -m "feat(stim-parser-2): add instruction name/arity enums and table types" -``` - -### Task 5: The `TABLE` with a canonical column, `lookup`, `canonical_name` - -**Files:** -- Modify: `crates/stim-parser-2/src/instructions/mod.rs` -- Test: same file -- Reference: `crates/stim-parser/src/table.rs` (`TABLE`), `crates/stim-parser/src/ast.rs` (`canonical_name` impls) - -- [ ] **Step 1: Add a canonical column to `TableEntry`** - -Extend `TableEntry` with a `canonical: &'static str` field: - -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TableEntry { - pub kind: EntryKind, - pub args: ArgCount, - pub targets: TargetArity, - pub canonical: &'static str, -} -``` - -Update the const constructors to take and store it, e.g.: - -```rust -const fn gate(name: GateName, args: ArgCount, targets: TargetArity, canonical: &'static str) -> TableEntry { - TableEntry { kind: EntryKind::Gate(name), args, targets, canonical } -} -// …same shape for noise/measure/measure_pairs/annotation… -``` - -- [ ] **Step 2: Port `TABLE` with the canonical spelling per row** - -Port the full `TABLE` from `crates/stim-parser/src/table.rs`, adding the canonical spelling as the last constructor arg. The canonical spelling for each row is the spelling produced by the old `canonical_name()` for that variant (open `crates/stim-parser/src/ast.rs` and read the matching arm). Examples (note the alias rows `R`/`RZ`, `CX`/`ZCX`/`CNOT`, `S`/`SQRT_Z` each carry their *own* canonical spelling, matching the old behavior where every variant is distinct): - -```rust -const TABLE: &[(&str, TableEntry)] = &[ - ("R", gate(G::Reset, NoArgs, AtLeastOne, "R")), - ("RZ", gate(G::ResetZ, NoArgs, AtLeastOne, "RZ")), - ("RX", gate(G::ResetX, NoArgs, AtLeastOne, "RX")), - ("RY", gate(G::ResetY, NoArgs, AtLeastOne, "RY")), - ("X", gate(G::X, NoArgs, AtLeastOne, "X")), - // … port every remaining row from the reference table, canonical = old canonical_name(variant) … - ("CNOT", gate(G::CNot, NoArgs, Pairs, "CNOT")), - ("CX", gate(G::CX, NoArgs, Pairs, "CX")), - ("ZCX", gate(G::ZCX, NoArgs, Pairs, "ZCX")), - // … noise rows (I_ERROR keeps ArgCount::Deferred) … - // … measure rows via measure()/measure_pairs() … - // … annotation rows + MPAD (EntryKind::MPad, Optional(1), AtLeastOne, "MPAD") + TICK (None args, Any targets, "TICK") … -]; -``` - -Keep the `use` aliases from the reference (`use GateName as G;` etc.) and add nothing new. - -- [ ] **Step 3: Implement `lookup` and `canonical_name`** - -```rust -/// Look up a Stim instruction name. `None` means unknown. -pub fn lookup(name: &str) -> Option { - TABLE.iter().find(|(n, _)| *n == name).map(|(_, e)| *e) -} - -/// Canonical spelling for a decoded instruction kind, derived from the -/// same TABLE rows that drive parsing (single source of truth). -pub fn canonical_name(kind: EntryKind) -> &'static str { - TABLE - .iter() - .find(|(_, e)| e.kind == kind) - .map(|(_, e)| e.canonical) - .expect("every EntryKind has a TABLE row (enforced by completeness test)") -} -``` - -- [ ] **Step 4: Write the drift-proofing tests** - -```rust -#[cfg(test)] -mod table_tests { - use super::*; - - #[test] - fn every_table_key_is_unique() { - use std::collections::HashSet; - let mut seen = HashSet::new(); - for (key, _) in TABLE { - assert!(seen.insert(*key), "duplicate key {key:?} in TABLE"); - } - } - - #[test] - fn canonical_round_trips_through_lookup() { - // For every row, looking up its canonical spelling yields the same kind. - for (_, entry) in TABLE { - let via_canonical = lookup(entry.canonical) - .unwrap_or_else(|| panic!("canonical {:?} not in TABLE", entry.canonical)); - assert_eq!(via_canonical.kind, entry.kind, "canonical mismatch for {:?}", entry.canonical); - } - } - - #[test] - fn every_variant_has_exactly_one_row() { - // Build the set of kinds present and assert each expected variant appears. - // Gate/Noise/Measure/Annotation variant lists mirror the enums; if a - // variant is added without a row, canonical_name() will panic and this - // test (which calls it for every kind) will fail. - for (_, entry) in TABLE { - let _ = canonical_name(entry.kind); // must not panic - } - } -} -``` - -- [ ] **Step 5: Run to verify pass** - -Run: `cargo test -p stim-parser-2 --lib table_tests` -Expected: PASS (3 tests). - -- [ ] **Step 6: Commit** - -```bash -git add crates/stim-parser-2/src/instructions/mod.rs -git commit -m "feat(stim-parser-2): single-source instruction TABLE with canonical column" -``` - ---- - -## Phase 3 — `ast/` (shared payloads + two enums) - -### Task 6: Shared payload structs and leaf types - -**Files:** -- Create: `crates/stim-parser-2/src/ast/shared.rs`, `crates/stim-parser-2/src/ast/mod.rs` -- Modify: `crates/stim-parser-2/src/lib.rs` -- Reference: `crates/stim-parser/src/ast.rs` (`Target`, `PauliAxis`, `PauliFactor`, `Tag`, `TagParam`), `crates/stim-parser/src/extended/ast.rs` (`Axis`) - -- [ ] **Step 1: Write the failing test** - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn target_equals_bare_qubit_index() { - assert_eq!(Target::Qubit(3), 3usize); - assert_ne!(Target::Rec(1), 0usize); - assert_eq!(Target::Qubit(3).as_qubit(), Some(3)); - assert_eq!(Target::Rec(1).as_qubit(), None); - } - - #[test] - fn pauli_axis_char() { - assert_eq!(PauliAxis::X.as_char(), 'X'); - assert_eq!(PauliAxis::Z.as_char(), 'Z'); - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `cargo test -p stim-parser-2 --lib ast::shared` -Expected: FAIL — types not found. - -- [ ] **Step 3: Implement `shared.rs`** - -Port the leaf types verbatim from the reference: -- `Target` (with `as_qubit` and the `PartialEq` impl) — from `ast.rs`. -- `PauliAxis` (with `as_char`) and `PauliFactor` — from `ast.rs`. -- `Tag`, `TagParam` — from `ast.rs`. -- `Axis` — from `extended/ast.rs`. - -Then add the shared family payload structs (each carries `span: Span` instead of `line: usize`): - -```rust -use crate::diagnostics::Span; -use crate::instructions::{AnnotationKind, GateName, MeasureName, NoiseName}; - -#[derive(Debug, Clone, PartialEq)] -pub struct GateOp { - pub name: GateName, - pub tags: Vec, - pub args: Vec, - pub targets: Vec, - pub span: Span, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct NoiseOp { - pub name: NoiseName, - pub tags: Vec, - pub args: Vec, - pub targets: Vec, - pub span: Span, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct MeasureOp { - pub name: MeasureName, - pub tags: Vec, - pub args: Vec, - pub targets: Vec, - pub span: Span, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct AnnotationOp { - pub kind: AnnotationKind, - pub args: Vec, - pub targets: Vec, - pub span: Span, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct MppOp { - pub tags: Vec, - pub args: Vec, - pub products: Vec>, - pub span: Span, -} -``` - -- [ ] **Step 4: Wire `ast/mod.rs` and `lib.rs`** - -`src/ast/mod.rs`: - -```rust -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -pub mod shared; -pub use shared::*; -``` - -In `src/lib.rs` add `pub mod ast;`. - -- [ ] **Step 5: Run to verify pass** - -Run: `cargo test -p stim-parser-2 --lib ast::shared` -Expected: PASS (2 tests). - -- [ ] **Step 6: Commit** - -```bash -git add crates/stim-parser-2/src -git commit -m "feat(stim-parser-2): shared AST payloads and leaf types" -``` - -### Task 7: `Instruction` and `Program` (vanilla) - -**Files:** -- Create: `crates/stim-parser-2/src/ast/vanilla.rs` -- Modify: `crates/stim-parser-2/src/ast/mod.rs` - -- [ ] **Step 1: Write the failing test** - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::ast::shared::GateOp; - use crate::diagnostics::{LineMap, Span}; - use crate::instructions::GateName; - use std::sync::Arc; - - #[test] - fn program_holds_instructions() { - let p = Program { - instructions: vec![Instruction::Gate(GateOp { - name: GateName::H, - tags: vec![], - args: vec![], - targets: vec![], - span: Span::new(0, 1), - })], - line_map: Arc::new(LineMap::new("H 0")), - }; - assert_eq!(p.instructions.len(), 1); - } - - #[test] - fn program_eq_ignores_line_map() { - let g = || Instruction::Gate(GateOp { - name: GateName::H, tags: vec![], args: vec![], targets: vec![], span: Span::new(0, 1), - }); - let a = Program { instructions: vec![g()], line_map: Arc::new(LineMap::new("H 0")) }; - let b = Program { instructions: vec![g()], line_map: Arc::new(LineMap::new("\n\nH 0")) }; - assert_eq!(a, b); // equality is by instructions only - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `cargo test -p stim-parser-2 --lib ast::vanilla` -Expected: FAIL — `Instruction`/`Program` not found. - -- [ ] **Step 3: Implement `vanilla.rs`** - -```rust -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -//! Vanilla Stim AST. Tags are preserved verbatim; the parser does not -//! resolve the Stim dialect — that is the consumer's responsibility. - -use std::sync::Arc; - -use crate::ast::shared::{AnnotationOp, GateOp, MeasureOp, MppOp, NoiseOp, Tag}; -use crate::diagnostics::{LineMap, Span}; - -#[derive(Debug, Clone, PartialEq)] -pub enum Instruction { - Gate(GateOp), - Noise(NoiseOp), - Measure(MeasureOp), - Annotation(AnnotationOp), - Mpp(MppOp), - MPad { - tags: Vec, - prob: Option, - bits: Vec, - span: Span, - }, - Repeat { - count: u64, - body: Vec, - span: Span, - }, -} - -/// Vanilla program. Owns the `LineMap` so consumers can resolve any node's -/// `span` to `line:col` (spec §6.3). Equality is by `instructions` only — -/// the line map is positional metadata, not identity. -#[derive(Debug, Clone)] -pub struct Program { - pub instructions: Vec, - pub line_map: Arc, -} - -impl PartialEq for Program { - fn eq(&self, other: &Self) -> bool { - self.instructions == other.instructions - } -} -``` - -- [ ] **Step 4: Wire `ast/mod.rs`** - -Add `pub mod vanilla;` and `pub use vanilla::{Instruction, Program};`. - -- [ ] **Step 5: Run to verify pass** - -Run: `cargo test -p stim-parser-2 --lib ast::vanilla` -Expected: PASS (1 test). - -- [ ] **Step 6: Commit** - -```bash -git add crates/stim-parser-2/src/ast -git commit -m "feat(stim-parser-2): vanilla Instruction/Program AST" -``` - -### Task 8: `ExtendedInstruction`, `ExtendedProgram`, `measurement_count` - -**Files:** -- Create: `crates/stim-parser-2/src/ast/extended.rs` -- Modify: `crates/stim-parser-2/src/ast/mod.rs` -- Reference: `crates/stim-parser/src/extended/ast.rs` (`measurement_count`/`count_in_slice`) - -- [ ] **Step 1: Write the failing test** - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::ast::shared::{MeasureOp, GateOp}; - use crate::diagnostics::{LineMap, Span}; - use crate::instructions::{GateName, MeasureName}; - use std::sync::Arc; - - fn span() -> Span { Span::new(0, 1) } - - #[test] - fn measurement_count_scales_with_repeat() { - let m = ExtendedInstruction::Measure(MeasureOp { - name: MeasureName::M, tags: vec![], args: vec![], targets: vec![0, 1], span: span(), - }); - let prog = ExtendedProgram { - instructions: vec![ExtendedInstruction::Repeat { - count: 3, - body: vec![m], - span: span(), - }], - line_map: Arc::new(LineMap::new("")), - }; - assert_eq!(prog.measurement_count(), 6); - } - - #[test] - fn gate_op_is_shared_with_vanilla() { - // The same GateOp struct constructs an ExtendedInstruction::Gate. - let _ = ExtendedInstruction::Gate(GateOp { - name: GateName::H, tags: vec![], args: vec![], targets: vec![], span: span(), - }); - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `cargo test -p stim-parser-2 --lib ast::extended` -Expected: FAIL — types not found. - -- [ ] **Step 3: Implement `extended.rs`** - -```rust -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -//! Typed AST for Stim with PPVM tag-based extensions promoted to -//! first-class instruction variants. - -use std::sync::Arc; - -use crate::ast::shared::{AnnotationOp, Axis, GateOp, MeasureOp, MppOp, NoiseOp, Tag}; -use crate::diagnostics::{LineMap, Span}; - -#[derive(Debug, Clone, PartialEq)] -pub enum ExtendedInstruction { - // Pass-through families — the SAME structs as the vanilla AST. - Gate(GateOp), - Noise(NoiseOp), - Measure(MeasureOp), - Annotation(AnnotationOp), - Mpp(MppOp), - - // Promoted sugar. - T { targets: Vec, span: Span }, - TDag { targets: Vec, span: Span }, - Rotation { axis: Axis, theta: f64, targets: Vec, span: Span }, - U3 { theta: f64, phi: f64, lambda: f64, targets: Vec, span: Span }, - Loss { p: f64, targets: Vec, span: Span }, - CorrelatedLoss { ps: [f64; 3], targets: Vec<(usize, usize)>, span: Span }, - MPad { tags: Vec, prob: Option, bits: Vec, span: Span }, - Repeat { count: u64, body: Vec, span: Span }, -} - -/// Extended program. Owns the `LineMap` (spec §6.3) so `ppvm-stim` can -/// resolve a node's `span` to a line for `ExecError`. Equality by -/// `instructions` only. -#[derive(Debug, Clone)] -pub struct ExtendedProgram { - pub instructions: Vec, - pub line_map: Arc, -} - -impl PartialEq for ExtendedProgram { - fn eq(&self, other: &Self) -> bool { - self.instructions == other.instructions - } -} - -impl ExtendedProgram { - /// Total recorded bits the program produces, accounting for REPEAT - /// factors. Pure AST property; backend-agnostic. - pub fn measurement_count(&self) -> usize { - count_in_slice(&self.instructions, 1) - } -} - -fn count_in_slice(instructions: &[ExtendedInstruction], factor: u64) -> usize { - let mut total = 0usize; - let factor_usize = usize::try_from(factor).unwrap_or(usize::MAX); - for instr in instructions { - match instr { - ExtendedInstruction::Measure(op) => { - total = total.saturating_add(op.targets.len().saturating_mul(factor_usize)); - } - ExtendedInstruction::MPad { bits, .. } => { - total = total.saturating_add(bits.len().saturating_mul(factor_usize)); - } - ExtendedInstruction::Mpp(op) => { - total = total.saturating_add(op.products.len().saturating_mul(factor_usize)); - } - ExtendedInstruction::Repeat { count, body, .. } => { - total = total.saturating_add(count_in_slice(body, factor.saturating_mul(*count))); - } - ExtendedInstruction::Gate(_) - | ExtendedInstruction::Noise(_) - | ExtendedInstruction::Annotation(_) - | ExtendedInstruction::T { .. } - | ExtendedInstruction::TDag { .. } - | ExtendedInstruction::Rotation { .. } - | ExtendedInstruction::U3 { .. } - | ExtendedInstruction::Loss { .. } - | ExtendedInstruction::CorrelatedLoss { .. } => {} - } - } - total -} -``` - -Note: unlike the reference, `Measure` is now `ExtendedInstruction::Measure(MeasureOp)` (not `Raw(RawPassthrough::Measure{..})`), so the `count_in_slice` match arm reads `op.targets`. - -- [ ] **Step 4: Wire `ast/mod.rs`** - -Add `pub mod extended;` and `pub use extended::{ExtendedInstruction, ExtendedProgram};`. - -- [ ] **Step 5: Run to verify pass** - -Run: `cargo test -p stim-parser-2 --lib ast::extended` -Expected: PASS (2 tests). - -- [ ] **Step 6: Commit** - -```bash -git add crates/stim-parser-2/src/ast -git commit -m "feat(stim-parser-2): extended AST with shared payloads" -``` - ---- - -## Phase 4 — `syntax/` (chumsky grammar) - -### Task 9: `RawSyntaxNode`, `RawTarget`, parser-stack thread - -**Files:** -- Create: `crates/stim-parser-2/src/syntax/raw.rs`, `crates/stim-parser-2/src/syntax/mod.rs` -- Modify: `crates/stim-parser-2/src/lib.rs` -- Reference: `crates/stim-parser/src/parser.rs` (`RawSyntaxNode`, `RawTarget`, `run_on_parser_stack`, `PARSER_STACK_SIZE`) - -- [ ] **Step 1: Implement `raw.rs`** - -Port `RawSyntaxNode` and `RawTarget` from `crates/stim-parser/src/parser.rs`, changing the `Tag` import to `crate::ast::shared::Tag` and keeping `chumsky::span::SimpleSpan` for spans (the grammar works in chumsky spans; conversion to `Span` happens in validate). Add a type alias: - -```rust -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -use chumsky::span::SimpleSpan; -use crate::ast::shared::Tag; - -pub(crate) type RawSyntaxTree = Vec; - -#[derive(Debug, Clone)] -pub(crate) enum RawSyntaxNode { - Instruction { - name: String, - tags: Vec, - args: Vec, - targets: Vec, - span: SimpleSpan, - }, - Repeat { - count: u64, - body: Vec, - span: SimpleSpan, - }, -} - -#[derive(Debug, Clone)] -pub(crate) struct RawTarget { - pub text: String, - pub span: SimpleSpan, -} -``` - -- [ ] **Step 2: Implement `syntax/mod.rs` with `run_on_parser_stack`** - -Port `run_on_parser_stack` and `PARSER_STACK_SIZE` verbatim from `crates/stim-parser/src/parser.rs` (the `#[cfg(target_arch = "wasm32")]` carve-out included): - -```rust -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -mod grammar; -mod raw; - -pub(crate) use raw::{RawSyntaxNode, RawSyntaxTree, RawTarget}; - -// … paste PARSER_STACK_SIZE const and run_on_parser_stack fn here … -pub(crate) use self::stack::run_on_parser_stack; - -mod stack { - // (paste the const + fn; keep them in a submodule or inline — either is fine) -} -``` - -Simpler: inline the const + `pub(crate) fn run_on_parser_stack` directly in `mod.rs` (no `stack` submodule). Use whichever compiles cleanly. - -In `src/lib.rs` add `pub(crate) mod syntax;`. - -- [ ] **Step 3: Build** - -Run: `cargo build -p stim-parser-2` -Expected: FAIL — `grammar` module is empty/missing (next task). If you stubbed `grammar.rs` empty, expected `Finished`. Create an empty `grammar.rs` with just the license header for now. - -- [ ] **Step 4: Commit** - -```bash -git add crates/stim-parser-2/src -git commit -m "feat(stim-parser-2): raw syntax nodes and parser-stack thread" -``` - -### Task 10: Port the chumsky grammar - -**Files:** -- Modify: `crates/stim-parser-2/src/syntax/grammar.rs` -- Test: `crates/stim-parser-2/src/syntax/grammar.rs` (`#[cfg(test)]` — port the reference grammar tests) -- Reference: `crates/stim-parser/src/grammar.rs` (entire file) - -- [ ] **Step 1: Port the grammar verbatim with two import changes** - -Copy the entire body of `crates/stim-parser/src/grammar.rs` into the new `grammar.rs`. Apply exactly these changes: -- `use crate::ast::{Tag, TagParam};` → `use crate::ast::shared::{Tag, TagParam};` -- `use crate::parser::{RawSyntaxNode, RawTarget};` → `use crate::syntax::raw::{RawSyntaxNode, RawTarget};` -- Keep `program_parser`, `instruction_line`, `repeat_block`, all combinators, and the entire `#[cfg(test)] mod tests` unchanged (the test `use crate::parser::RawSyntaxNode;` becomes `use crate::syntax::raw::RawSyntaxNode;`). - -Mark `program_parser` `pub(crate)`. - -- [ ] **Step 2: Run the ported grammar tests to verify they pass** - -Run: `cargo test -p stim-parser-2 --lib grammar` -Expected: PASS (same ~30 grammar tests as the reference: `ident_matches…`, `signed_float_parses…`, `program_parses_repeat_block`, `program_rejects_oversized_repeat_count_without_panicking`, etc.). - -- [ ] **Step 3: Commit** - -```bash -git add crates/stim-parser-2/src/syntax/grammar.rs -git commit -m "feat(stim-parser-2): port chumsky grammar" -``` - ---- - -## Phase 5 — `pipeline/` (typestate + validate + lower) - -### Task 11: Pipeline skeleton and the `parse` transition - -**Files:** -- Create: `crates/stim-parser-2/src/pipeline/mod.rs` -- Modify: `crates/stim-parser-2/src/lib.rs` -- Reference: `crates/stim-parser/src/parser.rs` (`parse_impl`'s chumsky-driving + error-forwarding) - -- [ ] **Step 1: Write the failing test** - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::diagnostics::FailFast; - - #[test] - fn parse_transition_produces_parsed_state() { - let mut sink = FailFast::new(); - let parsed = Pipeline::new("H 0\n").parse(&mut sink); - assert!(parsed.is_ok()); - } - - #[test] - fn parse_transition_emits_diagnostic_and_aborts_on_syntax_error() { - let mut sink = FailFast::new(); - let res = Pipeline::new("REPEAT 2 {\nH 0\n").parse(&mut sink); // unclosed - assert!(res.is_err()); - assert!(sink.saw_error()); - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `cargo test -p stim-parser-2 --lib pipeline` -Expected: FAIL — `Pipeline` not found. - -- [ ] **Step 3: Implement the pipeline skeleton + `parse`** - -```rust -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -//! Typestate lowering pipeline: Source → Parsed → Validated → Lowered. - -mod lower; -mod validate; - -use std::sync::Arc; - -use chumsky::Parser; - -use crate::ast::{ExtendedProgram, Program}; -use crate::diagnostics::{Diagnostic, DiagnosticSink, Flow, LineMap, Severity, Span}; -use crate::diagnostics::Aborted; -use crate::syntax::{self, RawSyntaxTree}; - -pub struct Pipeline { - state: S, -} - -pub struct Source<'a> { - src: &'a str, -} -pub struct Parsed { - pub(crate) tree: RawSyntaxTree, - pub(crate) line_map: Arc, -} -// Once a Program exists it owns the LineMap (spec §6.3), so the later -// states need only hold the program. -pub struct Validated { - pub(crate) program: Program, -} -pub struct Lowered { - pub(crate) program: ExtendedProgram, -} - -impl<'a> Pipeline> { - pub fn new(src: &'a str) -> Self { - Pipeline { state: Source { src } } - } - - /// Stage 1: pure syntax. Forwards every chumsky error into the sink. - pub fn parse(self, sink: &mut dyn DiagnosticSink) -> Result, Aborted> { - let src = self.state.src; - let line_map = Arc::new(LineMap::new(src)); - let result = crate::syntax::program_parser().parse(src); - match result.into_result() { - Ok(tree) => Ok(Pipeline { state: Parsed { tree, line_map } }), - Err(errors) => { - for err in errors { - let span: Span = (*err.span()).into(); - let flow = sink.emit(Diagnostic::error(span, "syntax", err.to_string())); - if flow == Flow::Abort { - return Err(Aborted); - } - } - // All syntax errors forwarded; with a non-aborting sink we still - // cannot produce a tree, so abort the stage. - Err(Aborted) - } - } - } -} - -impl Pipeline { - pub fn finish(self) -> Program { - self.state.program - } -} - -impl Pipeline { - pub fn finish(self) -> ExtendedProgram { - self.state.program - } -} -``` - -Add to `src/lib.rs`: `pub mod pipeline;` and make `syntax::program_parser` reachable — in `src/syntax/mod.rs` add `pub(crate) use grammar::program_parser;`. - -Note: `Severity` import is used by later transitions; if the compiler warns it's unused now, remove it and re-add in Task 12/13. - -- [ ] **Step 4: Run to verify pass** - -Run: `cargo test -p stim-parser-2 --lib pipeline` -Expected: PASS (2 tests). The `validate`/`lower` modules are empty stubs for now — create `src/pipeline/validate.rs` and `src/pipeline/lower.rs` each with just the license header so the `mod` lines compile. - -- [ ] **Step 5: Commit** - -```bash -git add crates/stim-parser-2/src -git commit -m "feat(stim-parser-2): typestate pipeline skeleton + parse transition" -``` - -### Task 12: `validate` transition (RawSyntaxTree → Program) - -**Files:** -- Modify: `crates/stim-parser-2/src/pipeline/validate.rs`, `crates/stim-parser-2/src/pipeline/mod.rs` -- Test: `crates/stim-parser-2/src/pipeline/validate.rs` (port the reference `validate_tests`) -- Reference: `crates/stim-parser/src/parser.rs` (`validate_program`, `validate_node`, `check_arg_count`, `parse_mpp_products`, `parse_rec`, `qubit_indices`, `build_instruction`) - -- [ ] **Step 1: Implement `validate.rs`** - -Port the validation logic from `crates/stim-parser/src/parser.rs`, with these transformations: -- The entry point becomes `pub(crate) fn validate(tree: RawSyntaxTree, line_map: &Arc, sink: &mut dyn DiagnosticSink) -> Result`. It builds `Program { instructions, line_map: Arc::clone(line_map) }` so the program owns its line map (spec §6.3). -- Instead of `return Err(ParseError::…)`, emit a `Diagnostic::error(span, code, message)` to `sink` and honor the returned `Flow` (on `Abort` return `Err(Aborted)`; on `Continue` skip the offending instruction — push nothing and proceed). Use these `code`s and spans: - - unknown instruction → `code = "unknown-instruction"`, span = the instruction-name span. Message: `format!("unknown instruction '{name}'")`. - - arg-count → `code = "arg-count"`, span = name span. Message mirrors the reference `ParseError::ArgCount` text. - - target-count → `code = "target-count"`, span = name span. Message mirrors `ParseError::TargetCount`. - - invalid target → `code = "invalid-target"`, span = the target's span. Message: `format!("invalid target {:?}", t.text)`. - - invalid MPP target → `code = "invalid-mpp-target"`, span = target span. Message: `format!("invalid MPP target {:?}", t.text)`. -- Convert chumsky `SimpleSpan` → `Span` via `.into()` when constructing diagnostics and when storing spans on AST nodes. -- `build_instruction` now constructs the shared payload structs (`GateOp{..}`, `NoiseOp{..}`, etc.) with `span` set to the instruction-name span (`name_span.into()`), and returns `Instruction`. MPP builds `Instruction::Mpp(MppOp{..})`; MPAD builds `Instruction::MPad{..}`. -- `lookup`/`canonical_name`/`EntryKind`/`ArgCount`/`TargetArity` come from `crate::instructions`. -- Keep `parse_rec` and `parse_mpp_products` and `qubit_indices` as private helpers (the recursion into `Repeat` bodies calls `validate` recursively). - -The transformation from "first-error `Result`" to "emit-and-maybe-continue" is mechanical: each `return Err(e)` becomes: - -```rust -if sink.emit(diagnostic) == Flow::Abort { - return Err(Aborted); -} -// Continue: for a per-instruction error, skip pushing this instruction; for a -// per-target error inside the target loop, the instruction is abandoned — emit, -// then on Continue `continue` the outer instruction loop without pushing. -``` - -- [ ] **Step 2: Add the `validate` transition to `mod.rs`** - -```rust -impl Pipeline { - pub fn validate(self, sink: &mut dyn DiagnosticSink) -> Result, Aborted> { - let Parsed { tree, line_map } = self.state; - let program = validate::validate(tree, &line_map, sink)?; - Ok(Pipeline { state: Validated { program } }) - } -} -``` - -(No `finish` on `Pipeline`: `RawSyntaxTree` is crate-private, so the raw tree is never exposed.) - -- [ ] **Step 3: Port the validate tests** - -Port `validate_tests` from `crates/stim-parser/src/parser.rs`, adapting: -- Build a `Collect` or `FailFast` sink and call `validate(nodes, &lm(), &mut sink)`. -- Assert on the returned `Program`'s instructions (now `Instruction::Gate(GateOp{ name: GateName::H, .. })` etc.) and, for error cases, on `sink.into_items()[0].code` (e.g. `"unknown-instruction"`, `"arg-count"`, `"target-count"`, `"invalid-target"`). -- The reference test `invalid_target_uses_target_span_for_line_col` becomes: emit to a `Collect` sink, then resolve `items[0].span.line_col(&line_map)` and assert `(2, 5)`. - -Example adapted test: - -```rust -#[test] -fn unknown_instruction_emits_diagnostic() { - let mut sink = Collect::new(); - let nodes = vec![instr("FROBNICATE", vec![], vec!["0"], (0, 10))]; - let prog = validate(nodes, &lm(), &mut sink).unwrap(); // Collect never aborts - assert!(prog.instructions.is_empty()); - let items = sink.into_items(); - assert_eq!(items[0].code, Some("unknown-instruction")); -} -``` - -Port the `instr`, `instr_with_target_spans`, `instr_with_tags`, `lm` helpers from the reference (adjusting `RawTarget`/`RawSyntaxNode` import to `crate::syntax::raw`). - -- [ ] **Step 4: Run to verify pass** - -Run: `cargo test -p stim-parser-2 --lib validate` -Expected: PASS (the ported ~13 validate tests). - -- [ ] **Step 5: Commit** - -```bash -git add crates/stim-parser-2/src/pipeline -git commit -m "feat(stim-parser-2): validate transition (RawSyntaxTree -> Program)" -``` - -### Task 13: `lower` transition (Program → ExtendedProgram) - -**Files:** -- Modify: `crates/stim-parser-2/src/pipeline/lower.rs`, `crates/stim-parser-2/src/pipeline/mod.rs` -- Test: `crates/stim-parser-2/src/pipeline/lower.rs` -- Reference: `crates/stim-parser/src/extended/interpret.rs` (entire file) - -- [ ] **Step 1: Implement `lower.rs`** - -Port `interpret.rs` with these transformations: -- Entry point: `pub(crate) fn lower(program: Program, sink: &mut dyn DiagnosticSink) -> Result`. Capture `let line_map = Arc::clone(&program.line_map);` up front and build `ExtendedProgram { instructions, line_map }` at the end so the extended program also owns the line map (spec §6.3). -- Input is now the new `Instruction` enum: match `Instruction::Gate(op)` → `interpret_gate(op, sink)`, `Instruction::Noise(op)` → `interpret_noise(op, sink)`, `Instruction::Measure(op)` → `Ok(ExtendedInstruction::Measure(op))` (move the shared struct straight through — no `RawPassthrough`), `Instruction::Annotation(op)` → `Ok(ExtendedInstruction::Annotation(op))`, `Instruction::Mpp(op)` → `Ok(ExtendedInstruction::Mpp(op))`, `Instruction::MPad{..}` → convert bits, `Instruction::Repeat{..}` → recurse. -- `interpret_gate` takes a `GateOp` and returns `ExtendedInstruction`: the no-tag/other-gate fall-throughs return `ExtendedInstruction::Gate(op)` (reusing the same struct); the `T`/`TDag`/`S[T]`/`I[..]` paths build the sugar variants. Replace `qubit_targets(targets, name, line)` with a helper that, on a `Target::Rec`, emits `Diagnostic::error(op.span, "record-target-not-allowed", msg)` to the sink and returns `Err(Aborted)` (or skips on `Continue` — but record-target-on-sugar is fatal for that instruction, so emit then `Err(Aborted)` is the faithful behavior; on `Continue` from a permissive sink, skip the instruction). -- All the `ExtendedParseError::{InvalidTag, InvalidMPadBit, RecordTargetNotAllowed}` returns become `sink.emit(Diagnostic::error(span, code, message))` + `Flow` handling, with codes: - - invalid tag → `"invalid-tag"` (message: keep the reference's `format!`-built text). - - invalid MPAD bit → `"invalid-mpad-bit"`. - - record target not allowed → `"record-target-not-allowed"`. - - Span for these is the instruction's `op.span` (or the MPad/Mpp `span`). -- `exact_named_params`, `require_no_params`, `convert_mpad_bits`, `pair_targets`, `interpret_identity_tag` port across with the `line: usize` parameter replaced by `span: Span` (used only to build diagnostics). - -- [ ] **Step 2: Add the `lower` transition to `mod.rs`** - -```rust -impl Pipeline { - pub fn lower(self, sink: &mut dyn DiagnosticSink) -> Result, Aborted> { - let program = lower::lower(self.state.program, sink)?; - Ok(Pipeline { state: Lowered { program } }) - } -} -``` - -- [ ] **Step 3: Write/port lower tests** - -Port the gate/noise interpretation assertions exercised indirectly by `tests/extended.rs` as focused unit tests here. Minimum set: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::diagnostics::{Collect, FailFast}; - use crate::pipeline::Pipeline; - - fn lower_extended(src: &str) -> Result> { - let mut sink = FailFast::new(); - Pipeline::new(src) - .parse(&mut sink) - .and_then(|p| p.validate(&mut sink)) - .and_then(|p| p.lower(&mut sink)) - .map(|p| p.finish()) - .map_err(|_| sink.into_items()) - } - - #[test] - fn s_t_tag_lowers_to_t() { - let prog = lower_extended("S[T] 0\n").unwrap(); - assert!(matches!(prog.instructions[0], ExtendedInstruction::T { .. })); - } - - #[test] - fn native_t_lowers_to_t() { - let prog = lower_extended("T 0\n").unwrap(); - assert!(matches!(prog.instructions[0], ExtendedInstruction::T { .. })); - } - - #[test] - fn i_rx_lowers_to_rotation() { - let prog = lower_extended("I[R_X(theta=0.5)] 0\n").unwrap(); - assert!(matches!(prog.instructions[0], ExtendedInstruction::Rotation { .. })); - } - - #[test] - fn i_error_loss_lowers_to_loss() { - let prog = lower_extended("I_ERROR[loss](0.01) 0\n").unwrap(); - assert!(matches!(prog.instructions[0], ExtendedInstruction::Loss { .. })); - } - - #[test] - fn t_with_record_target_is_rejected() { - let err = lower_extended("T rec[-1]\n").unwrap_err(); - assert_eq!(err.last().unwrap().code, Some("record-target-not-allowed")); - } - - #[test] - fn i_error_without_tag_is_rejected() { - let err = lower_extended("I_ERROR(0.01) 0\n").unwrap_err(); - assert_eq!(err.last().unwrap().code, Some("invalid-tag")); - } -} -``` - -- [ ] **Step 4: Run to verify pass** - -Run: `cargo test -p stim-parser-2 --lib lower` -Expected: PASS (6 tests). - -- [ ] **Step 5: Commit** - -```bash -git add crates/stim-parser-2/src/pipeline -git commit -m "feat(stim-parser-2): lower transition (Program -> ExtendedProgram)" -``` - -### Task 14: Tier-1 `parse` / `parse_extended` + `prelude` - -**Files:** -- Modify: `crates/stim-parser-2/src/lib.rs` -- Test: `crates/stim-parser-2/src/lib.rs` (`#[cfg(test)]`) - -- [ ] **Step 1: Write the failing test** - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_returns_program() { - let prog = parse("H 0\nM 0\n").unwrap(); - assert_eq!(prog.instructions.len(), 2); - } - - #[test] - fn parse_extended_returns_extended_program() { - let prog = parse_extended("S[T] 0\n").unwrap(); - assert_eq!(prog.instructions.len(), 1); - } - - #[test] - fn parse_error_renders_line_col() { - let err = parse("REPEAT 2 {\nH 0\n").unwrap_err(); - assert!(err.to_string().starts_with("error at line")); - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `cargo test -p stim-parser-2 --lib tests::parse` -Expected: FAIL — `parse` not found. - -- [ ] **Step 3: Implement the Tier-1 functions** - -```rust -// in src/lib.rs, after the `pub mod` declarations -use std::sync::Arc; - -use crate::ast::{ExtendedProgram, Program}; -use crate::diagnostics::{Diagnostics, FailFast, LineMap}; -use crate::pipeline::Pipeline; -use crate::syntax::run_on_parser_stack; - -/// Parse Stim source into the vanilla [`Program`] AST. Uses a fail-fast -/// policy; the returned [`Diagnostics`] holds the first error. -pub fn parse(src: &str) -> Result { - run_on_parser_stack(|| { - let mut sink = FailFast::new(); - let result = Pipeline::new(src) - .parse(&mut sink) - .and_then(|p| p.validate(&mut sink)); - match result { - Ok(p) => Ok(p.finish()), - Err(_) => Err(Diagnostics::new(sink.into_items(), Arc::new(LineMap::new(src)))), - } - }) -} - -/// Parse Stim source into the extended-dialect [`ExtendedProgram`] AST. -pub fn parse_extended(src: &str) -> Result { - run_on_parser_stack(|| { - let mut sink = FailFast::new(); - let result = Pipeline::new(src) - .parse(&mut sink) - .and_then(|p| p.validate(&mut sink)) - .and_then(|p| p.lower(&mut sink)); - match result { - Ok(p) => Ok(p.finish()), - Err(_) => Err(Diagnostics::new(sink.into_items(), Arc::new(LineMap::new(src)))), - } - }) -} - -pub mod prelude { - pub use crate::ast::{ - AnnotationOp, Axis, ExtendedInstruction, ExtendedProgram, GateOp, Instruction, MeasureOp, - MppOp, NoiseOp, PauliAxis, PauliFactor, Program, Tag, TagParam, Target, - }; - pub use crate::diagnostics::{Diagnostic, Diagnostics, DiagnosticSink, Flow, Severity}; - pub use crate::instructions::{AnnotationKind, GateName, MeasureName, NoiseName}; - pub use crate::pipeline::Pipeline; - pub use crate::{parse, parse_extended}; -} -``` - -Ensure `run_on_parser_stack` is re-exported from `syntax`: in `src/syntax/mod.rs` it is `pub(crate) fn`, so `use crate::syntax::run_on_parser_stack;` resolves. - -- [ ] **Step 4: Run to verify pass** - -Run: `cargo test -p stim-parser-2 --lib` -Expected: PASS (all lib tests, including the 3 new ones). - -- [ ] **Step 5: Commit** - -```bash -git add crates/stim-parser-2/src/lib.rs -git commit -m "feat(stim-parser-2): Tier-1 parse/parse_extended + prelude" -``` - ---- - -## Phase 6 — `print/` (first-class canonical printer) - -### Task 15: `StimPrint`, `PrintOptions`, `Display`, `to_stim` - -**Files:** -- Create: `crates/stim-parser-2/src/print/mod.rs` -- Modify: `crates/stim-parser-2/src/lib.rs` -- Test: `crates/stim-parser-2/src/print/mod.rs` -- Reference: `crates/stim-parser/src/display.rs` (entire file — but note it has TWO printers `fmt_raw`/`fmt_raw_passthrough`; we collapse them) - -- [ ] **Step 1: Write the failing test (the canonical-shape spot checks from the reference)** - -```rust -#[cfg(test)] -mod tests { - use crate::{parse, parse_extended}; - - #[test] - fn vanilla_printed_form_is_canonical_shape() { - let src = "H 0 # trail\nCX 0 1\nDEPOLARIZE1(0.05) 0 1\nREPEAT 2 { X 0 }\n"; - let ast = parse(src).unwrap(); - let expected = "H 0\nCX 0 1\nDEPOLARIZE1(0.05) 0 1\nREPEAT 2 {\n X 0\n}\n"; - assert_eq!(ast.to_stim(), expected); - assert_eq!(format!("{ast}"), expected); - } - - #[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 ast = parse_extended(src).unwrap(); - let expected = "S[T] 0\nI[R_X(theta=0.25)] 1\nI_ERROR[loss](0.01) 2\n"; - assert_eq!(ast.to_stim(), expected); - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `cargo test -p stim-parser-2 --lib print` -Expected: FAIL — `to_stim` not found. - -- [ ] **Step 3: Implement `print/mod.rs`** - -Port the formatting from `crates/stim-parser/src/display.rs` with these structural changes: -- Define `PrintOptions { pub indent: std::borrow::Cow<'static, str> }` with `Default` = `Cow::Borrowed(" ")`. -- Define `pub trait StimPrint { fn print(&self, out: &mut dyn fmt::Write, opts: &PrintOptions, depth: usize) -> fmt::Result; }`. -- Implement `StimPrint` for `GateOp`, `NoiseOp`, `MeasureOp`, `AnnotationOp`, `MppOp` (the shared family writers — ported once from `fmt_raw`; this removes the `fmt_raw`/`fmt_raw_passthrough` duplication). The helpers `write_tags`, `write_args`, `write_usize_targets`, `write_targets`, `write_mpp_products`, and the `FloatLit` struct port verbatim (use `name.canonical_name()` → replace with `crate::instructions::canonical_name(EntryKind::Gate(self.name))`, or add an inherent `canonical_name()` accessor on each name enum that delegates to `crate::instructions::canonical_name` — pick one and use consistently). -- Implement `StimPrint` for `Instruction` (matches the shared-op arms by delegating to the op's `StimPrint`, plus `MPad`/`Repeat`) and for `ExtendedInstruction` (shared-op arms delegate; sugar arms ported from `fmt_ext`; `Repeat` recurses; `MPad` prints `bool` bits as `u8::from(bit)`). -- Implement `StimPrint` for `Program` and `ExtendedProgram` (iterate instructions at depth 0). -- Add inherent methods + `Display`: - -```rust -impl Program { - pub fn to_stim(&self) -> String { self.to_stim_with(&PrintOptions::default()) } - pub fn to_stim_with(&self, opts: &PrintOptions) -> String { - let mut s = String::new(); - let _ = self.print(&mut s, opts, 0); - s - } -} -impl fmt::Display for Program { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.print(f, &PrintOptions::default(), 0) - } -} -// …identical pair for ExtendedProgram… -``` - -Indentation uses `opts.indent` repeated `depth` times (replaces the hard-coded `const INDENT`). - -For the name-enum canonical accessor: add to `src/instructions/mod.rs`: - -```rust -impl GateName { pub fn canonical_name(self) -> &'static str { canonical_name(EntryKind::Gate(self)) } } -impl NoiseName { pub fn canonical_name(self) -> &'static str { canonical_name(EntryKind::Noise(self)) } } -impl MeasureName { pub fn canonical_name(self) -> &'static str { canonical_name(EntryKind::Measure(self)) } } -impl AnnotationKind { pub fn canonical_name(self) -> &'static str { canonical_name(EntryKind::Annotation(self)) } } -``` - -These let the printer and any consumer get a spelling without touching the table directly, and they keep `GateName: Display` (add a `Display` impl delegating to `canonical_name`, matching the reference). - -- [ ] **Step 4: Wire `lib.rs`** - -Add `pub mod print;` and to the `prelude` add `pub use crate::print::{PrintOptions, StimPrint};`. - -- [ ] **Step 5: Run to verify pass** - -Run: `cargo test -p stim-parser-2 --lib print` -Expected: PASS (2 tests). - -- [ ] **Step 6: Commit** - -```bash -git add crates/stim-parser-2/src -git commit -m "feat(stim-parser-2): first-class canonical StimPrint/Display printer" -``` - ---- - -## Phase 7 — Port the integration & property tests - -### Task 16: Port the integration tests - -**Files:** -- Create: `crates/stim-parser-2/tests/{errors,extended,gates,measure,noise,roundtrip,syntax,tags,record_targets,mpp}.rs` -- Reference: the same-named files under `crates/stim-parser/tests/` - -- [ ] **Step 1: Port each integration test file** - -For each reference file, copy it into `crates/stim-parser-2/tests/` and apply the API mapping: -- `use stim_parser::prelude::parse;` → `use stim_parser_2::prelude::parse;` -- `use stim_parser::extended::parse_extended;` → `use stim_parser_2::prelude::parse_extended;` -- `use stim_parser::ast::{…};` and `use stim_parser::extended::{…};` → `use stim_parser_2::prelude::{…};` -- AST matching changes: `RawInstruction::Gate { name, .. }` → `Instruction::Gate(GateOp { name, .. })`; `RawPassthrough::Measure { .. }` → `ExtendedInstruction::Measure(MeasureOp { .. })`; etc. -- Error assertions: where a test matched `ParseError::UnknownInstruction { .. }` / `ExtendedParseError::InvalidTag { .. }`, change to inspect the returned `Diagnostics` — `err.iter().next().unwrap().code == Some("unknown-instruction")` (or `"invalid-tag"`, `"arg-count"`, `"target-count"`, `"invalid-target"`, `"invalid-mpad-bit"`, `"record-target-not-allowed"`). The `errors.rs` file is the one most affected; map each asserted error variant to its `code` per the table in Tasks 12–13. -- `format!("{ast}")` round-trip assertions are unchanged (Display is preserved). - -Port `roundtrip.rs` last — it should pass unmodified except the imports, since the canonical output format is preserved byte-for-byte. - -- [ ] **Step 2: Run the full ported suite** - -Run: `cargo test -p stim-parser-2 --tests` -Expected: PASS. Fix any `code`/shape mismatches until green. If a reference test asserted an exact `ParseError` `Display` string, assert the new `Diagnostics` `Display` string instead (format: `error at line L, col C: `). - -- [ ] **Step 3: Commit** - -```bash -git add crates/stim-parser-2/tests -git commit -m "test(stim-parser-2): port integration tests" -``` - -### Task 17: Port the proptests - -**Files:** -- Create: `crates/stim-parser-2/tests/{proptest_ast,proptest_parse,proptest_roundtrip}.rs` -- Reference: same-named files under `crates/stim-parser/tests/` - -- [ ] **Step 1: Port the three proptest files** - -Copy each, applying the same import mapping as Task 16. `proptest_roundtrip.rs` uses only `parse`/`parse_extended` + `format!` and the `program_source()` strategy — port unchanged but for imports. `proptest_ast.rs` matches on AST node shapes — apply the `Instruction::Gate(GateOp{..})` mapping. `proptest_parse.rs` likewise. - -- [ ] **Step 2: Copy the proptest regressions seed file** - -```bash -cp crates/stim-parser/tests/proptest_ast.proptest-regressions crates/stim-parser-2/tests/proptest_ast.proptest-regressions -``` - -(If the seeds reference shapes that no longer reproduce, proptest will simply not find a failure; that is acceptable. Do not delete the file — keeping it preserves known historical counterexamples.) - -- [ ] **Step 3: Run the proptests** - -Run: `cargo test -p stim-parser-2 --test proptest_roundtrip --test proptest_ast --test proptest_parse` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add crates/stim-parser-2/tests -git commit -m "test(stim-parser-2): port proptest suites" -``` - ---- - -## Phase 8 — Differential parity harness - -### Task 18: Parity harness vs old `stim-parser` - -**Files:** -- Create: `crates/stim-parser-2/tests/parity.rs` -- Reference: `crates/stim-parser/tests/proptest_roundtrip.rs` (`program_source` strategy), `crates/stim-parser/tests/roundtrip.rs` (corpora) - -- [ ] **Step 1: Write the parity harness** - -```rust -// SPDX-FileCopyrightText: 2026 The PPVM Authors -// SPDX-License-Identifier: Apache-2.0 - -//! Differential parity: old `stim_parser` vs new `stim_parser_2`. -//! Asserts (1) same accept/reject and (2) byte-identical canonical print -//! output, over a hand corpus and proptest-generated programs. - -use proptest::prelude::*; - -// Same fragment strategy as tests/proptest_roundtrip.rs — keep in sync. -fn instruction_fragment() -> impl Strategy { - prop_oneof![ - Just("H 0\n".to_string()), - Just("CX 0 1\n".to_string()), - Just("S[T] 0\n".to_string()), - Just("I[R_X(theta=0.5)] 0\n".to_string()), - Just("I_ERROR[loss](0.01) 0\n".to_string()), - Just("DEPOLARIZE1(0.05) 0\n".to_string()), - Just("M(0.001) 0\n".to_string()), - Just("MPAD 0 1 0\n".to_string()), - Just("DETECTOR rec[-1]\n".to_string()), - Just("REPEAT 3 {\n H 0\n M 0\n}\n".to_string()), - Just("# leading\n".to_string()), - Just("H 0 # trail\n".to_string()), - ] -} - -fn program_source() -> impl Strategy { - prop::collection::vec(instruction_fragment(), 0..16).prop_map(|f| f.concat()) -} - -fn assert_parity(src: &str) { - let old = stim_parser::extended::parse_extended(src); - let new = stim_parser_2::prelude::parse_extended(src); - match (old, new) { - (Ok(o), Ok(n)) => { - assert_eq!(format!("{o}"), n.to_stim(), "print mismatch for:\n{src}"); - } - (Err(_), Err(_)) => {} - (o, n) => panic!( - "accept/reject mismatch for:\n{src}\n old_ok={}, new_ok={}", - o.is_ok(), - n.is_ok() - ), - } -} - -#[test] -fn parity_on_hand_corpus() { - for src in [ - "H 0\nCX 0 1\nM 0 1\n", - "S[T] 0\nI[R_X(theta=0.25)] 1\nI_ERROR[loss](0.01) 2\n", - "REPEAT 2 {\n REPEAT 3 {\n H 0\n M 0\n }\n}\n", - "MPAD 0 1 0\nMPAD(0.01) 1 1 0 0\n", - "I_ERROR[correlated_loss](0.1, 0.05, 0.05) 0 1 2 3\n", - "R 0 1\nMR 0\nDETECTOR rec[-1]\nOBSERVABLE_INCLUDE(0) rec[-1]\nTICK\n", - ] { - assert_parity(src); - } -} - -proptest! { - #[test] - fn parity_on_generated_programs(src in program_source()) { - assert_parity(&src); - } -} -``` - -- [ ] **Step 2: Run the parity harness** - -Run: `cargo test -p stim-parser-2 --test parity` -Expected: PASS. If a print mismatch appears, the new printer diverged from the old `Display`; fix `print/mod.rs` until byte-identical. If an accept/reject mismatch appears, the new validate/lower diverged; reconcile against the reference logic. - -- [ ] **Step 3: Commit** - -```bash -git add crates/stim-parser-2/tests/parity.rs -git commit -m "test(stim-parser-2): differential parity harness vs stim-parser" -``` - -### Task 19: Full-workspace gate before swap - -- [ ] **Step 1: Run the whole workspace** - -Run: `cargo test --workspace` -Expected: PASS (old crate, new crate, and every other crate still green; `ppvm-stim` still on the old `stim-parser`). - -- [ ] **Step 2: Run clippy on the new crate** - -Run: `cargo clippy -p stim-parser-2 --all-targets -- -D warnings` -Expected: no warnings. Fix any. - -- [ ] **Step 3: Commit any clippy fixes** - -```bash -git add crates/stim-parser-2 -git commit -m "chore(stim-parser-2): clippy clean" -``` - ---- - -## Phase 9 — Swap - -> Only start this phase once Task 18 and Task 19 are green. This is the point of no return for `ppvm-stim`'s dependency. - -### Task 20: Repoint `ppvm-stim` at `stim-parser-2` - -**Files:** -- Modify: `crates/ppvm-stim/Cargo.toml`, `crates/ppvm-stim/src/lib.rs`, `crates/ppvm-stim/src/executor.rs`, `crates/ppvm-stim/src/validate.rs`, `crates/ppvm-stim/benches/tableau-msd-stim.rs` -- Reference: the import/match sites found via grep (Task 0 of the review found them) - -- [ ] **Step 1: Switch the dependency** - -In `crates/ppvm-stim/Cargo.toml` change: - -```toml -stim-parser = { version = "0.1.0", path = "../stim-parser" } -``` -to: -```toml -stim-parser-2 = { version = "0.1.0", path = "../stim-parser-2" } -``` - -- [ ] **Step 2: Update imports and AST match sites** - -In each `ppvm-stim` source file, apply: -- `use stim_parser::…` → `use stim_parser_2::…` (path within is `prelude`, or `ast`/`extended` re-exports; use `stim_parser_2::prelude::{…}`). -- `RawPassthrough::Gate { name, targets, .. }` → `ExtendedInstruction::Gate(GateOp { name, targets, .. })`; same for `Noise`/`Measure`/`Annotation` (these are now top-level `ExtendedInstruction` variants wrapping the shared `*Op` structs, **not** nested under `Raw`). -- Remove `ExtendedInstruction::Raw(...)` wrapping entirely — match the family variants directly. -- `*line` field reads (e.g. in `validate.rs`'s `ExecError` construction): `ExtendedProgram` already owns `line_map` (Task 8), so resolve `op.span.line(&program.line_map)` to recover the `usize` line. `ExecError`'s `line: usize` fields and `Display` strings stay exactly as they are — only the *source* of the number changes from a stored `line` field to `span.line(&line_map)`. Thread `&program.line_map` into `validate_slice` alongside the existing `measurements` accumulator. -- `ExtendedInstruction::Mpp { products, .. }` → `ExtendedInstruction::Mpp(MppOp { products, .. })`. - -- [ ] **Step 3: Build and test ppvm-stim** - -Run: `cargo test -p ppvm-stim` -Expected: PASS. Iterate on the match sites until green. - -- [ ] **Step 4: Full workspace** - -Run: `cargo test --workspace` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add crates/ppvm-stim -git commit -m "refactor(ppvm-stim): switch to stim-parser-2" -``` - -### Task 21: Delete old crate, rename new → `stim-parser` - -**Files:** -- Delete: `crates/stim-parser/` -- Rename: `crates/stim-parser-2/` → `crates/stim-parser/` -- Modify: root `Cargo.toml`, `crates/stim-parser/Cargo.toml`, `crates/ppvm-stim/Cargo.toml`, all `ppvm-stim` imports, `crates/stim-parser-2/tests/parity.rs` (delete) - -- [ ] **Step 1: Remove the parity harness (it dev-depends on the old crate)** - -```bash -git rm crates/stim-parser-2/tests/parity.rs -``` - -- [ ] **Step 2: Delete the old crate and remove its dev-dep** - -```bash -git rm -r crates/stim-parser -``` - -In `crates/stim-parser-2/Cargo.toml`, delete the line: -```toml -stim-parser = { version = "0.1.0", path = "../stim-parser" } # parity harness only; removed at swap -``` - -- [ ] **Step 3: Rename the directory and the crate** - -```bash -git mv crates/stim-parser-2 crates/stim-parser -``` - -In `crates/stim-parser/Cargo.toml` change `name = "stim-parser-2"` → `name = "stim-parser"`. - -- [ ] **Step 4: Update workspace members and the consumer dependency** - -In root `Cargo.toml`, change the members line back to a single entry — replace `"crates/ppvm-stim", "crates/stim-parser", "crates/stim-parser-2",` with `"crates/ppvm-stim", "crates/stim-parser",`. - -In `crates/ppvm-stim/Cargo.toml` change: -```toml -stim-parser-2 = { version = "0.1.0", path = "../stim-parser-2" } -``` -to: -```toml -stim-parser = { version = "0.1.0", path = "../stim-parser" } -``` - -- [ ] **Step 5: Rename the crate references in `ppvm-stim` source** - -Replace every `stim_parser_2` with `stim_parser` across `crates/ppvm-stim/src/**` and `crates/ppvm-stim/benches/**`: - -Run: `grep -rl stim_parser_2 crates/ppvm-stim` then edit each, or use your editor's project replace. Verify none remain: -Run: `grep -rn "stim_parser_2\|stim-parser-2" crates/ Cargo.toml` -Expected: no matches. - -- [ ] **Step 6: Full workspace build + test** - -Run: `cargo test --workspace` -Expected: PASS. - -Run: `cargo clippy --workspace --all-targets -- -D warnings` -Expected: no warnings. - -- [ ] **Step 7: Commit** - -```bash -git add -A -git commit -m "refactor(stim-parser): replace with stim-parser-2 implementation" -``` - ---- - -## Self-Review (completed by plan author) - -**Spec coverage:** -- Typestate pipeline → Tasks 11–14. ✓ -- Diagnostic-sink effect model (Flow as continuation) → Tasks 2–3, threaded in 12–13. ✓ -- Two enums sharing payloads → Tasks 6–8 (shared `*Op` structs reused). ✓ -- Single-source table → Tasks 4–5 + completeness tests. ✓ -- `Span` over `line` → Task 1, carried on all ops (6–8), resolved in diagnostics. ✓ -- First-class canonical printer → Task 15 (`to_stim`/`to_stim_with`/`Display`/`StimPrint`). ✓ -- Keep chumsky + stack thread → Tasks 9–10. ✓ -- Validation-responsibility contract unchanged → validate (12) keeps dialect checks; `ppvm-stim` keeps capability checks (20). ✓ -- Parity-then-swap delivery → Tasks 18–21. ✓ -- Non-goals (no trivia, no hand-written parser, no streaming) → respected; printer is canonical-only. ✓ - -**Placeholder scan:** No "TBD"/"add error handling" left. Port tasks name the exact reference file and the exact transformation. Line-map ownership is fixed upfront — `Program` (Task 7) and `ExtendedProgram` (Task 8) own `Arc` from the start, so `ppvm-stim` resolves `op.span.line(&program.line_map)` at swap time (Task 20) with no open decision. - -**Type consistency:** `Diagnostic::error(span, code, message)` signature is used identically in Tasks 2, 12, 13. `sink.emit(..) -> Flow` consistent. `canonical_name(EntryKind)` free fn (Task 5) + per-enum `canonical_name(self)` accessors (Task 15) are consistent. `ExtendedInstruction::Measure(MeasureOp)` (not `Raw`) consistent across Tasks 8, 13, 15, 16, 20. `Program`/`ExtendedProgram` own `Arc` with hand-written `PartialEq` (instructions only) — defined in Tasks 7–8, produced in Tasks 12–13, consumed in Task 20. Pipeline state structs `Validated`/`Lowered` hold only the program (which owns the line map); `Parsed` holds `tree` + `line_map`. - -**Scope:** One cohesive crate reimplementation with a clean swap. Sequential layers, not independent subsystems — correctly a single plan. diff --git a/docs/superpowers/specs/2026-06-23-stim-parser-2-refactor-design.md b/docs/superpowers/specs/2026-06-23-stim-parser-2-refactor-design.md deleted file mode 100644 index fb2687e4c..000000000 --- a/docs/superpowers/specs/2026-06-23-stim-parser-2-refactor-design.md +++ /dev/null @@ -1,343 +0,0 @@ -# Design: `stim-parser-2` — typestate lowering pipeline with a diagnostic-sink effect model - -- **Date:** 2026-06-23 -- **Status:** Approved (design); pending implementation plan -- **Scope:** A ground-up reimplementation of the `stim-parser` crate as a new crate - `stim-parser-2`, built in parallel with the existing crate and swapped in once - behavioral feature-parity is verified. No API-compatibility constraints (the - package is pre-release), so we are free to break the API wherever it improves - user experience or architectural health. - -## 1. Motivation - -The current `stim-parser` crate works and is well-tested, but the architectural -review surfaced concentrated duplication and uneven structure: - -1. **`RawPassthrough` mirrors `RawInstruction`.** A near-clone of four - instruction variants plus a ~50-line `into_raw()` and a twin printer - (`fmt_raw` vs `fmt_raw_passthrough`), all to encode one invariant. -2. **Instruction identity is defined in three drift-prone places** — the name - enums, the `canonical_name()` match (variant → string), and `TABLE` (string → - variant + arity). Nothing checks they agree. -3. **`ast.rs` is a grab-bag** mixing AST nodes, name enums, table-rule enums, and - error types. -4. **Uneven diagnostics** — only `ParseError::Syntax` reports `line:col`; typed - variants report `line` only. Three fragmented error types - (`SyntaxError`/`ParseError`/`ExtendedParseError`). -5. **Pretty-printing is internal-only** (behind `Display`) and normalizing. - -This redesign keeps the proven pipeline shape (syntax → validate → lower) and the -chumsky syntax engine + its proptest safety net, while restructuring around four -goals the user specified: - -- **Object-oriented driver** expressed as an explicit **state machine**. -- **Effects/errors as an effect algebra** handled as **continuations**. -- **Two public AST enums** (vanilla Stim + extended dialect). -- **First-class canonical pretty-printer** enabling round-trips on both layers. - -## 2. Locked decisions - -| Decision | Choice | -|---|---| -| Delivery | New crate `stim-parser-2`, built in parallel; swap + rename after parity | -| State machine | **Typestate** — each stage a distinct type; transitions consume `self` | -| Effect model | **Diagnostic sink / handler** — the handler is the continuation | -| AST shape | **Two public enums**, sharing per-family payload structs | -| Instruction metadata | **One declarative `const TABLE`** as the single source of truth | -| Pretty-printer | **Canonical / normalizing**, promoted to first-class public API | -| Spans | Each node carries a `Span`; programs own the `LineMap` | -| Syntax engine | **Keep chumsky** + the oversized-stack thread | -| API compatibility | **None required** (pre-release); free to break | - -## 3. Architecture - -### 3.1 Pipeline (data flow) - -``` -&str - │ syntax/ chumsky combinators — PURE SYNTAX, zero semantics - ▼ -RawSyntaxTree RawSyntaxNode { name:String, tags, args, targets:RawTarget{text,span} } | Repeat - │ pipeline/validate.rs table-driven: name→variant, arity checks, target typing, MPP/rec parse - ▼ -Program two public enum: vanilla Stim AST; tags preserved verbatim - │ pipeline/lower.rs tag promotion: S[T]→T, I[R_X(..)]→Rotation, I_ERROR[loss]→Loss … - ▼ -ExtendedProgram two public enum: typed PPVM dialect -``` - -The whole drive runs on the oversized parser-stack thread (`run_on_parser_stack`), -preserved from today, with the same `wasm32` inline carve-out. - -### 3.2 Crate layout - -``` -crates/stim-parser-2/ - Cargo.toml - src/ - lib.rs public API + prelude; re-exports - syntax/ Stage 1 — pure syntax (chumsky), no semantics - mod.rs - grammar.rs combinators → RawSyntaxTree - raw.rs RawSyntaxNode / RawTarget - instructions/ single source of truth: spelling ↔ variant ↔ arity ↔ canonical - mod.rs name enums (GateName/NoiseName/MeasureName/AnnotationKind), - arity enums (ArgCount/TargetArity), const TABLE, - lookup() + canonical_name() + completeness tests - ast/ - mod.rs - shared.rs GateOp/NoiseOp/MeasureOp/AnnotationOp/MppOp, Target, PauliFactor, Axis, - Tag/TagParam (name enums are imported from instructions/) - vanilla.rs Instruction enum + Program - extended.rs ExtendedInstruction enum + ExtendedProgram - pipeline/ Stage driver — the typestate state machine - mod.rs Pipeline, transitions: parse / validate / lower / finish - validate.rs RawSyntaxTree → Program - lower.rs Program → ExtendedProgram (tag promotion) - diagnostics/ the effect algebra - mod.rs Diagnostic, Severity, Flow, DiagnosticSink trait, Diagnostics - sinks.rs FailFast, Collect (provided handlers) - span.rs Span + LineMap + line:col rendering - print/ first-class canonical printer - mod.rs Printer, PrintOptions, StimPrint trait - tests/ ported integration tests + proptests + differential parity harness -``` - -Each module has one clear job and can be understood/tested independently. - -## 4. Public API surface - -### 4.1 Two tiers - -```rust -// Tier 1 — the 99% case. Drives the pipeline internally with a FailFast sink. -pub fn parse(src: &str) -> Result; -pub fn parse_extended(src: &str) -> Result; - -// Tier 2 — explicit typestate machine + caller-supplied sink. -let prog = Pipeline::new(src) // Pipeline - .parse(&mut sink)? // Pipeline - .validate(&mut sink)? // Pipeline → .finish() → vanilla Program - .lower(&mut sink)? // Pipeline - .finish(); // ExtendedProgram -``` - -### 4.2 Typestate pipeline - -```rust -pub struct Pipeline { state: S } - -pub struct Source<'a> { src: &'a str } -pub struct Parsed { tree: RawSyntaxTree, line_map: Arc } -pub struct Validated { program: Program, line_map: Arc } -pub struct Lowered { program: ExtendedProgram, line_map: Arc } - -impl<'a> Pipeline> { - pub fn new(src: &'a str) -> Self; - pub fn parse(self, sink: &mut dyn DiagnosticSink) -> Result, Aborted>; -} -impl Pipeline { pub fn validate(self, sink: &mut dyn DiagnosticSink) -> Result, Aborted>; } -impl Pipeline { pub fn lower(self, sink: &mut dyn DiagnosticSink) -> Result, Aborted>; - pub fn finish(self) -> Program; } -impl Pipeline { pub fn finish(self) -> ExtendedProgram; } -``` - -Each transition consumes `self` and returns the next state type, so illegal -orderings do not compile. - -## 5. Effect model — the sink is the continuation - -```rust -pub enum Severity { Error, Warning } -pub enum Flow { Continue, Abort } // the handler's continuation choice - -pub struct Diagnostic { - pub severity: Severity, - pub span: Span, - pub message: String, - pub code: Option<&'static str>, // stable kind tag, e.g. "unknown-instruction" -} - -pub trait DiagnosticSink { - /// Emit a diagnostic. The returned Flow tells the current stage whether to - /// keep going (recover) or abort as soon as possible. - fn emit(&mut self, d: Diagnostic) -> Flow; -} - -pub struct FailFast(/* … */); // returns Abort on first Error → used by Tier-1 functions -pub struct Collect(/* … */); // returns Continue, accumulates every diagnostic -``` - -- The handler's `Flow` return value **is** the continuation decision — this is the - "effects handled as continuations" model encoded idiomatically in Rust. -- A transition told `Abort` returns `Err(Aborted)` (a tiny marker). The diagnostics - themselves live in the **caller-owned sink**, so the pipeline stays policy-free. -- chumsky's multi-error output is forwarded into the sink, so even syntax errors can - be collected (with `Collect`) rather than first-wins. -- `Diagnostics(Vec)` is the aggregate returned by the Tier-1 functions on - failure; its `Display` prints every diagnostic, one per line, each with `line:col`. - -## 6. AST model - -### 6.1 Shared family payloads (eliminates `RawPassthrough`) - -Both enums embed the *same* payload structs for the families that do not diverge: - -```rust -// ast/shared.rs — reused by BOTH enums; printed once. -pub struct GateOp { pub name: GateName, pub tags: Vec, pub args: Vec, pub targets: Vec, pub span: Span } -pub struct NoiseOp { pub name: NoiseName, pub tags: Vec, pub args: Vec, pub targets: Vec, pub span: Span } -pub struct MeasureOp { pub name: MeasureName, pub tags: Vec, pub args: Vec, pub targets: Vec, pub span: Span } -pub struct AnnotationOp { pub kind: AnnotationKind, pub args: Vec, pub targets: Vec, pub span: Span } -pub struct MppOp { pub tags: Vec, pub args: Vec, pub products: Vec>, pub span: Span } -``` - -### 6.2 The two enums - -```rust -// ast/vanilla.rs -pub enum Instruction { - Gate(GateOp), Noise(NoiseOp), Measure(MeasureOp), Annotation(AnnotationOp), - Mpp(MppOp), - MPad { tags: Vec, prob: Option, bits: Vec, span: Span }, // raw bits - Repeat { count: u64, body: Vec, span: Span }, -} -pub struct Program { pub instructions: Vec, /* + Arc */ } - -// ast/extended.rs -pub enum ExtendedInstruction { - Gate(GateOp), Noise(NoiseOp), Measure(MeasureOp), Annotation(AnnotationOp), // SAME structs - Mpp(MppOp), - // promoted sugar (genuinely divergent — stays distinct): - T { targets: Vec, span: Span }, - TDag { targets: Vec, span: Span }, - Rotation { axis: Axis, theta: f64, targets: Vec, span: Span }, - U3 { theta: f64, phi: f64, lambda: f64, targets: Vec, span: Span }, - Loss { p: f64, targets: Vec, span: Span }, - CorrelatedLoss { ps: [f64; 3], targets: Vec<(usize, usize)>, span: Span }, - MPad { tags: Vec, prob: Option, bits: Vec, span: Span }, // validated bits - Repeat { count: u64, body: Vec, span: Span }, -} -pub struct ExtendedProgram { pub instructions: Vec, /* + Arc */ } -``` - -The only divergences between the layers are MPad's bit type (`usize` vs `bool`) and -the promoted-sugar variants — everything else is literally the same struct. Result: -no `RawPassthrough`, no `into_raw()`, and the printer formats each family op once. - -`ExtendedProgram::measurement_count()` is preserved (pure AST property, -backend-agnostic). - -### 6.3 Spans - -- Each node carries a `Span { start: usize, end: usize }` (byte range). -- `Program` / `ExtendedProgram` own the `Arc`. -- Helper `node_span.line_col(&line_map) -> (usize, usize)` resolves on demand, - giving uniform `line:col` in every diagnostic. - -## 7. Single-source instruction table - -One declarative `const TABLE` is the only place an instruction is defined; it -encodes the three relationships the enum cannot: - -```rust -// row = (spelling, family+variant, args rule, target rule, canonical spelling) -("CNOT", Gate(CNot), NoArgs, Pairs, "CNOT"), -("CX", Gate(CX), NoArgs, Pairs, "CX"), -("ZCX", Gate(ZCX), NoArgs, Pairs, "ZCX"), -// … -``` - -- `lookup(name: &str) -> Option` — scan by spelling (for parsing). -- `canonical_name(variant) -> &'static str` — derived from the same rows (for printing). -- Arity columns (`ArgCount` / `TargetArity`) drive validation. - -**Drift-proofing tests:** every enum variant has exactly one row; every spelling is -unique; `lookup(canonical_name(v)).variant == v` for every variant. `I_ERROR` -remains `ArgCount::Deferred` (tag-specific arg rules enforced in `lower`). - -## 8. Pretty printer (first-class, canonical) - -```rust -pub struct PrintOptions { pub indent: Cow<'static, str> } // default " " -pub trait StimPrint { - fn print(&self, out: &mut impl fmt::Write, opts: &PrintOptions, depth: usize) -> fmt::Result; -} - -impl Program { pub fn to_stim(&self) -> String; pub fn to_stim_with(&self, opts: &PrintOptions) -> String; } -impl ExtendedProgram { /* same */ } -impl fmt::Display for Program; // delegates to to_stim() with defaults -impl fmt::Display for ExtendedProgram; // -``` - -`StimPrint` is implemented on the shared family payloads so `GateOp`/`NoiseOp`/… are -formatted exactly once (no `fmt_raw` / `fmt_raw_passthrough` twins). Behavior matches -today's canonical printer: 4-space indent, `FloatLit` always emits a decimal point, -`rec[-k]` and `X0*Y1` targets round-trip, comments dropped. Round-trip is a *semantic* -fixpoint (`parse → print → parse`), not byte-identical. - -## 9. Validation responsibility (unchanged contract) - -The split with `ppvm-stim` is preserved: - -- **`stim-parser-2`** checks dialect-level well-formedness: arity, target shape, - `rec[-k]` only on gates, MPP factor syntax, MPAD bit values. -- **`ppvm-stim`** checks capability/semantics: supported gates/noise/measures, - probability ranges, `rec[-k]` only on controlled Paulis, MPP distinct-qubit, - record range. - -## 10. Testing, parity & swap - -### 10.1 Build sequence (each step independently testable) - -1. `diagnostics/` — Span, LineMap, sink, `FailFast`/`Collect`. -2. `instructions/` — the table + completeness tests. -3. `ast/` — shared payloads + two enums + `measurement_count`. -4. `syntax/` — chumsky grammar → `RawSyntaxTree`. -5. `pipeline/` — typestate transitions parse/validate/lower + Tier-1 `parse`/`parse_extended`. -6. `print/` — `StimPrint` + `Display`. -7. Port `tests/*.rs` and all three proptest suites (roundtrip, ast, parse). -8. Differential parity harness. -9. Swap. - -### 10.2 Differential parity harness (the no-regression gate) - -A test target that dev-depends on **both** crates. For a corpus (the existing test -programs + proptest-generated programs) it runs old `stim_parser::parse_extended` -and new `stim_parser_2::parse_extended` and asserts two **operationally checkable** -properties (the crates have different AST types, so we compare observable behavior, -not the structs directly): - -1. **Same accept/reject decision** — both succeed or both fail on each input. -2. **Byte-identical canonical print output** — old `format!("{}", prog)` (its - `Display`) equals new `prog.to_stim()`. The new canonical format must therefore - reproduce the old printer's output exactly on the corpus. - -Structural equivalence beyond what printing surfaces (e.g. spans, internal field -shapes) is covered by the **ported unit/proptest suites**, not the differential -harness. Together these prove feature parity before any consumer is touched. - -### 10.3 Swap phase (only after parity is green) - -1. Repoint `ppvm-stim` at `stim-parser-2`. Mechanical edits: - - `RawPassthrough::{Gate,Noise,Measure,Annotation}` → the shared `*Op` structs. - - `*line` field reads → `op.span.line_col(&line_map)` (program owns the `LineMap`). - - `ParseError`/`ExtendedParseError` → `Diagnostics`. -2. `cargo test --workspace` green. -3. Delete old `stim-parser`; rename `stim-parser-2` → `stim-parser` - (crate dir, `Cargo.toml` `name`, `ppvm-stim/Cargo.toml` dependency); retire the - differential harness. - -## 11. Non-goals (YAGNI) - -- **No lossless/trivia-preserving round-trip.** Canonical normalizing printer only. - The AST/state-machine shape leaves room to add a trivia layer later, but it is out - of scope here. -- **No hand-written parser.** Keep chumsky for the syntax stage; a - dependency-free rewrite (and dropping the stack-thread workaround) is a possible - follow-up once parity is locked, not part of this work. -- **No streaming/iterator API.** Programs materialize fully, as today. -- **No new accent on runtime dispatch in the AST.** The two enums stay enums (for - exhaustive matching in `ppvm-stim`); "object-oriented" applies to the pipeline - driver and the table/printer methods, not to trait-object AST nodes. -```