Skip to main content

bloqade_lanes_bytecode_core/isa/
validate.rs

1//! Architecture-dependent validation for the vihaco-backed ISA.
2//!
3//! These checks gate a [`Program`] against an [`ArchSpec`]. They are
4//! **arch-dependent by design**: when no arch spec is supplied ([`validate`]
5//! called with `None`), every check here is skipped and an empty error list is
6//! returned — an arch-free program is considered fine at this layer.
7//!
8//! [`validate`] runs the arch-dependent capability + address checks below.
9//! [`validate_structure`] adds arch-independent structural checks, and
10//! [`simulate_stack`] adds optional stack-type simulation (underflow, type
11//! mismatches, and lane/location group validation).
12//!
13//! ## Capability checks
14//!
15//! - **`feed_forward` → CPU control flow.** Without mid-circuit classical
16//!   feedback the hardware can only run straight-line code, so any nested
17//!   [`vihaco_cpu`] branch/call (`br`, `cond_br`, `call`, `call_indirect`) is
18//!   rejected. (This rule exists because the [`Cpu`](super::Instruction::Cpu)
19//!   variant makes those opcodes representable in a lanes program.)
20//! - **`feed_forward` → multiple measurements.** Without feed-forward at most
21//!   one `measure` may appear.
22//! - **`atom_reloading` → `fill`.** Without atom reloading, refilling atoms
23//!   after the initial fill is unsupported.
24//!
25//! ## Address checks
26//!
27//! Every `const_loc` / `const_lane` / `const_zone` operand is decoded and
28//! checked against the architecture's topology via
29//! [`ArchSpec::check_location`] / [`check_lane`](ArchSpec::check_lane) /
30//! [`check_zone`](ArchSpec::check_zone) — invalid zones, words, sites, lanes,
31//! and AOD constraints are reported with the arch layer's own message.
32
33use std::collections::HashSet;
34use std::fmt;
35
36use vihaco::value::Value;
37use vihaco_cpu::Instruction as Cpu;
38
39use super::{Instruction, Program};
40use crate::arch::addr::{LaneAddr, LocationAddr, ZoneAddr};
41use crate::arch::query::{LaneGroupError, LocationGroupError};
42use crate::arch::types::ArchSpec;
43
44/// Value type tags tracked by the [`simulate_stack`] type simulator. These
45/// mirror the stack value kinds the runtime distinguishes.
46pub mod tag {
47    pub const FLOAT: u8 = 0x0;
48    pub const INT: u8 = 0x1;
49    pub const ARRAY_REF: u8 = 0x2;
50    pub const LOCATION: u8 = 0x3;
51    pub const LANE: u8 = 0x4;
52    pub const ZONE: u8 = 0x5;
53    pub const MEASURE_FUTURE: u8 = 0x6;
54    pub const DETECTOR_REF: u8 = 0x7;
55    pub const OBSERVABLE_REF: u8 = 0x8;
56    pub const MEASUREMENT_RESULT: u8 = 0x9;
57}
58
59/// An arch-dependent validation failure, tagged with the offending
60/// instruction's program counter.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum ValidationError {
63    /// A CPU control-flow instruction appears but `feed_forward` is disabled.
64    ControlFlowRequiresFeedForward {
65        pc: usize,
66        /// The offending mnemonic (e.g. `"cond_br"`).
67        mnemonic: &'static str,
68    },
69    /// More than one `measure` appears but `feed_forward` is disabled.
70    MultipleMeasuresRequireFeedForward { pc: usize },
71    /// A `fill` appears but `atom_reloading` is disabled.
72    FillRequiresAtomReloading { pc: usize },
73    /// A `const_loc` operand does not name a valid location in the arch spec.
74    InvalidLocation { pc: usize, message: String },
75    /// A `const_lane` operand does not name a valid lane in the arch spec.
76    InvalidLane { pc: usize, message: String },
77    /// A `const_zone` operand does not name a valid zone in the arch spec.
78    InvalidZone { pc: usize, message: String },
79
80    // ---- structural (arch-independent) ----
81    /// `new_array` dim0 must be greater than zero.
82    NewArrayZeroDim0 { pc: usize },
83    /// `new_array` type_tag exceeds the maximum value tag.
84    NewArrayInvalidTypeTag { pc: usize, type_tag: u32 },
85    /// `initial_fill` is not the first non-constant instruction.
86    InitialFillNotFirst { pc: usize },
87    /// The program has no instructions (and therefore no terminator).
88    EmptyProgram,
89    /// The final instruction is neither `return` nor `halt`.
90    MissingTerminator { pc: usize },
91    /// An instruction follows a `return`/`halt` and is unreachable.
92    UnreachableInstruction { pc: usize },
93
94    // ---- stack-type simulation (only via `simulate_stack`) ----
95    /// An instruction popped from an empty stack.
96    StackUnderflow { pc: usize },
97    /// A popped value had the wrong type tag (see [`tag`]).
98    TypeMismatch { pc: usize, expected: u8, got: u8 },
99    /// A `local_r`/`local_rz`/`fill`/`initial_fill` location group is invalid.
100    LocationGroupValidation {
101        pc: usize,
102        error: LocationGroupError,
103    },
104    /// A `move` lane group is invalid (duplicate, inconsistent, AOD, …).
105    LaneGroupValidation { pc: usize, error: LaneGroupError },
106}
107
108/// Maximum valid `new_array` element type tag (`TAG_OBSERVABLE_REF = 0x8`).
109const MAX_TYPE_TAG: u32 = 0x8;
110
111impl fmt::Display for ValidationError {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self {
114            ValidationError::ControlFlowRequiresFeedForward { pc, mnemonic } => write!(
115                f,
116                "pc {pc}: control-flow instruction '{mnemonic}' requires feed_forward capability"
117            ),
118            ValidationError::MultipleMeasuresRequireFeedForward { pc } => write!(
119                f,
120                "pc {pc}: multiple measure instructions require feed_forward capability"
121            ),
122            ValidationError::FillRequiresAtomReloading { pc } => {
123                write!(
124                    f,
125                    "pc {pc}: fill instruction requires atom_reloading capability"
126                )
127            }
128            ValidationError::InvalidLocation { pc, message } => {
129                write!(f, "pc {pc}: invalid location: {message}")
130            }
131            ValidationError::InvalidLane { pc, message } => {
132                write!(f, "pc {pc}: invalid lane: {message}")
133            }
134            ValidationError::InvalidZone { pc, message } => {
135                write!(f, "pc {pc}: invalid zone: {message}")
136            }
137            ValidationError::NewArrayZeroDim0 { pc } => {
138                write!(f, "pc {pc}: new_array dim0 must be > 0")
139            }
140            ValidationError::NewArrayInvalidTypeTag { pc, type_tag } => {
141                write!(f, "pc {pc}: invalid new_array type tag {type_tag}")
142            }
143            ValidationError::InitialFillNotFirst { pc } => write!(
144                f,
145                "pc {pc}: initial_fill must be the first non-constant instruction"
146            ),
147            ValidationError::EmptyProgram => {
148                write!(
149                    f,
150                    "program has no instructions: missing return or halt terminator"
151                )
152            }
153            ValidationError::MissingTerminator { pc } => {
154                write!(f, "pc {pc}: program must end with return or halt")
155            }
156            ValidationError::UnreachableInstruction { pc } => {
157                write!(f, "pc {pc}: unreachable instruction after return or halt")
158            }
159            ValidationError::StackUnderflow { pc } => write!(f, "pc {pc}: stack underflow"),
160            ValidationError::TypeMismatch { pc, expected, got } => write!(
161                f,
162                "pc {pc}: type mismatch: expected tag 0x{expected:x}, got 0x{got:x}"
163            ),
164            ValidationError::LocationGroupValidation { pc, error } => {
165                write!(f, "pc {pc}: {error}")
166            }
167            ValidationError::LaneGroupValidation { pc, error } => write!(f, "pc {pc}: {error}"),
168        }
169    }
170}
171
172impl std::error::Error for ValidationError {}
173
174/// If `cpu` is a control-flow instruction, return its canonical mnemonic.
175///
176/// `Return` is deliberately excluded: it is a terminator, not a feed-forward
177/// branch (and lanes uses its own [`Return`](super::Instruction::Return)
178/// rather than the nested CPU one).
179fn control_flow_mnemonic(cpu: &Cpu) -> Option<&'static str> {
180    match cpu {
181        Cpu::Branch(_) => Some("br"),
182        Cpu::ConditionalBranch(_, _) => Some("cond_br"),
183        Cpu::Call(_, _) => Some("call"),
184        Cpu::IndirectCall => Some("call_indirect"),
185        _ => None,
186    }
187}
188
189/// Validate a program's arch-dependent constraints (capabilities + addresses).
190///
191/// When `arch` is `None`, all checks are skipped and an empty list is returned.
192/// Otherwise every violation is collected in program order (the validator does
193/// not stop at the first error), so callers can report them all at once.
194pub fn validate(program: &Program, arch: Option<&ArchSpec>) -> Vec<ValidationError> {
195    let Some(arch) = arch else {
196        return Vec::new();
197    };
198
199    let mut errors = Vec::new();
200    let mut measure_count = 0u32;
201
202    for (pc, inst) in program.code.iter().enumerate() {
203        match inst {
204            // ---- capability checks ----
205            Instruction::Cpu(cpu) if !arch.feed_forward => {
206                if let Some(mnemonic) = control_flow_mnemonic(cpu) {
207                    errors.push(ValidationError::ControlFlowRequiresFeedForward { pc, mnemonic });
208                }
209            }
210            Instruction::Measure(_) => {
211                measure_count += 1;
212                if !arch.feed_forward && measure_count > 1 {
213                    errors.push(ValidationError::MultipleMeasuresRequireFeedForward { pc });
214                }
215            }
216            Instruction::Fill(_) if !arch.atom_reloading => {
217                errors.push(ValidationError::FillRequiresAtomReloading { pc });
218            }
219
220            // ---- address checks ----
221            Instruction::ConstLoc(bits) => {
222                if let Some(message) = arch.check_location(&LocationAddr::decode(*bits)) {
223                    errors.push(ValidationError::InvalidLocation { pc, message });
224                }
225            }
226            Instruction::ConstLane(bits) => {
227                for message in arch.check_lane(&LaneAddr::decode_u64(*bits)) {
228                    errors.push(ValidationError::InvalidLane { pc, message });
229                }
230            }
231            Instruction::ConstZone(bits) => {
232                if let Some(message) = arch.check_zone(&ZoneAddr::decode(*bits)) {
233                    errors.push(ValidationError::InvalidZone { pc, message });
234                }
235            }
236
237            _ => {}
238        }
239    }
240
241    errors
242}
243
244/// True if `inst` terminates execution (`return` or `halt`).
245fn is_terminator(inst: &Instruction) -> bool {
246    matches!(inst, Instruction::Return | Instruction::Cpu(Cpu::Halt))
247}
248
249/// True if `inst` only pushes a constant (and so may precede `initial_fill`).
250fn is_constant_push(inst: &Instruction) -> bool {
251    matches!(
252        inst,
253        Instruction::ConstLoc(_)
254            | Instruction::ConstLane(_)
255            | Instruction::ConstZone(_)
256            | Instruction::Cpu(Cpu::Const(_))
257    )
258}
259
260/// Validate a program's arch-independent structural rules: `new_array` operand
261/// bounds, `initial_fill` ordering, and terminator/reachability. These never
262/// consult an arch spec, so they always run.
263pub fn validate_structure(program: &Program) -> Vec<ValidationError> {
264    let mut errors = Vec::new();
265    let mut seen_non_constant = false;
266
267    for (pc, inst) in program.code.iter().enumerate() {
268        match inst {
269            Instruction::NewArray(type_tag, dim0, _dim1) => {
270                if *dim0 == 0 {
271                    errors.push(ValidationError::NewArrayZeroDim0 { pc });
272                }
273                if *type_tag > MAX_TYPE_TAG {
274                    errors.push(ValidationError::NewArrayInvalidTypeTag {
275                        pc,
276                        type_tag: *type_tag,
277                    });
278                }
279                seen_non_constant = true;
280            }
281            Instruction::InitialFill(_) => {
282                if seen_non_constant {
283                    errors.push(ValidationError::InitialFillNotFirst { pc });
284                }
285                seen_non_constant = true;
286            }
287            inst if is_constant_push(inst) => {}
288            _ => seen_non_constant = true,
289        }
290    }
291
292    // Any instruction after the first terminator is unreachable. If there are
293    // unreachable instructions they explain a non-terminal last instruction, so
294    // `MissingTerminator` would be a redundant second error.
295    let mut found_terminator = false;
296    let mut unreachable = Vec::new();
297    for (pc, inst) in program.code.iter().enumerate() {
298        if found_terminator {
299            unreachable.push(ValidationError::UnreachableInstruction { pc });
300        }
301        if is_terminator(inst) {
302            found_terminator = true;
303        }
304    }
305
306    if unreachable.is_empty() {
307        match program.code.last() {
308            None => errors.push(ValidationError::EmptyProgram),
309            Some(last) if !is_terminator(last) => errors.push(ValidationError::MissingTerminator {
310                pc: program.code.len() - 1,
311            }),
312            Some(_) => {}
313        }
314    } else {
315        errors.extend(unreachable);
316    }
317
318    errors
319}
320
321// ── Stack-type simulation ──────────────────────────────────────────────────
322
323/// One tracked stack value: its type tag and (when known) concrete bits.
324#[derive(Debug, Clone)]
325struct SimEntry {
326    tag: u8,
327    value: Option<u64>,
328}
329
330/// Type-level stack simulator: walks the instruction stream tracking value
331/// types, reporting underflow and type mismatches, and — when given an
332/// [`ArchSpec`] — validating `move` lane groups and `fill`/`local_*` location
333/// groups (duplicates only without an arch).
334struct StackSimulator<'a> {
335    stack: Vec<SimEntry>,
336    errors: Vec<ValidationError>,
337    arch: Option<&'a ArchSpec>,
338    pc: usize,
339}
340
341impl<'a> StackSimulator<'a> {
342    fn new(arch: Option<&'a ArchSpec>) -> Self {
343        Self {
344            stack: Vec::new(),
345            errors: Vec::new(),
346            arch,
347            pc: 0,
348        }
349    }
350
351    fn pop_any(&mut self) {
352        if self.stack.pop().is_none() {
353            self.errors
354                .push(ValidationError::StackUnderflow { pc: self.pc });
355        }
356    }
357
358    fn pop_typed(&mut self, expected: u8) {
359        match self.stack.pop() {
360            Some(entry) if entry.tag != expected => {
361                self.errors.push(ValidationError::TypeMismatch {
362                    pc: self.pc,
363                    expected,
364                    got: entry.tag,
365                })
366            }
367            Some(_) => {}
368            None => self
369                .errors
370                .push(ValidationError::StackUnderflow { pc: self.pc }),
371        }
372    }
373
374    fn pop_typed_n(&mut self, expected: u8, count: u32) {
375        for _ in 0..count {
376            self.pop_typed(expected);
377        }
378    }
379
380    /// Pop one value expected to have `expected` tag, returning its concrete
381    /// bits when the type matches.
382    fn pop_addr(&mut self, expected: u8) -> Option<u64> {
383        match self.stack.pop() {
384            Some(entry) if entry.tag == expected => entry.value,
385            Some(entry) => {
386                self.errors.push(ValidationError::TypeMismatch {
387                    pc: self.pc,
388                    expected,
389                    got: entry.tag,
390                });
391                None
392            }
393            None => {
394                self.errors
395                    .push(ValidationError::StackUnderflow { pc: self.pc });
396                None
397            }
398        }
399    }
400
401    fn push(&mut self, tag: u8, value: Option<u64>) {
402        self.stack.push(SimEntry { tag, value });
403    }
404
405    fn sim_dup(&mut self) {
406        if let Some(top) = self.stack.last().cloned() {
407            self.stack.push(top);
408        } else {
409            self.errors
410                .push(ValidationError::StackUnderflow { pc: self.pc });
411        }
412    }
413
414    fn sim_swap(&mut self) {
415        let len = self.stack.len();
416        if len >= 2 {
417            self.stack.swap(len - 1, len - 2);
418        } else {
419            self.errors
420                .push(ValidationError::StackUnderflow { pc: self.pc });
421        }
422    }
423
424    /// Report each uniquely-duplicated location once (no-arch fallback).
425    fn check_duplicate_locations(&mut self, locations: &[LocationAddr]) {
426        let mut seen = HashSet::new();
427        let mut reported = HashSet::new();
428        for loc in locations {
429            let bits = loc.encode();
430            if !seen.insert(bits) && reported.insert(bits) {
431                self.errors.push(ValidationError::LocationGroupValidation {
432                    pc: self.pc,
433                    error: LocationGroupError::DuplicateAddress { address: bits },
434                });
435            }
436        }
437    }
438
439    /// Report each uniquely-duplicated lane once (no-arch fallback).
440    fn check_duplicate_lanes(&mut self, lanes: &[LaneAddr]) {
441        let mut seen = HashSet::new();
442        let mut reported = HashSet::new();
443        for lane in lanes {
444            let (d0, d1) = lane.encode();
445            let combined = (d0 as u64) | ((d1 as u64) << 32);
446            if !seen.insert(combined) && reported.insert(combined) {
447                self.errors.push(ValidationError::LaneGroupValidation {
448                    pc: self.pc,
449                    error: LaneGroupError::DuplicateAddress { address: (d0, d1) },
450                });
451            }
452        }
453    }
454
455    /// Pop `arity` locations and validate them as a group.
456    fn pop_and_validate_locations(&mut self, arity: u32) {
457        let bits: Vec<Option<u64>> = (0..arity).map(|_| self.pop_addr(tag::LOCATION)).collect();
458        let locations: Vec<LocationAddr> = bits
459            .iter()
460            .filter_map(|v| v.map(LocationAddr::decode))
461            .collect();
462        let pc = self.pc;
463        if let Some(arch) = self.arch {
464            for error in arch.check_locations(&locations) {
465                self.errors
466                    .push(ValidationError::LocationGroupValidation { pc, error });
467            }
468        } else {
469            self.check_duplicate_locations(&locations);
470        }
471    }
472
473    /// Pop `arity` lanes and validate them as a group.
474    fn sim_move(&mut self, arity: u32) {
475        let bits: Vec<Option<u64>> = (0..arity).map(|_| self.pop_addr(tag::LANE)).collect();
476        let lanes: Vec<LaneAddr> = bits
477            .iter()
478            .filter_map(|v| v.map(LaneAddr::decode_u64))
479            .collect();
480        let pc = self.pc;
481        if let Some(arch) = self.arch {
482            for error in arch.check_lanes(&lanes) {
483                self.errors
484                    .push(ValidationError::LaneGroupValidation { pc, error });
485            }
486        } else {
487            self.check_duplicate_lanes(&lanes);
488        }
489    }
490
491    fn dispatch(&mut self, inst: &Instruction) {
492        match inst {
493            // constants push a typed value
494            Instruction::Cpu(Cpu::Const(Value::F64(v))) => self.push(tag::FLOAT, Some(v.to_bits())),
495            Instruction::Cpu(Cpu::Const(Value::I64(v))) => self.push(tag::INT, Some(*v as u64)),
496            Instruction::ConstLoc(v) => self.push(tag::LOCATION, Some(*v)),
497            Instruction::ConstLane(v) => self.push(tag::LANE, Some(*v)),
498            Instruction::ConstZone(v) => self.push(tag::ZONE, Some(*v as u64)),
499
500            // stack manipulation
501            Instruction::Pop => self.pop_any(),
502            Instruction::Cpu(Cpu::Dup) => self.sim_dup(),
503            Instruction::Swap => self.sim_swap(),
504
505            // atom arrangement
506            Instruction::InitialFill(arity) | Instruction::Fill(arity) => {
507                self.pop_and_validate_locations(*arity)
508            }
509            Instruction::Move(arity) => self.sim_move(*arity),
510
511            // gates
512            Instruction::LocalR(arity) => {
513                self.pop_typed_n(tag::FLOAT, 2);
514                self.pop_and_validate_locations(*arity);
515            }
516            Instruction::LocalRz(arity) => {
517                self.pop_typed_n(tag::FLOAT, 1);
518                self.pop_and_validate_locations(*arity);
519            }
520            Instruction::GlobalR => self.pop_typed_n(tag::FLOAT, 2),
521            Instruction::GlobalRz => self.pop_typed_n(tag::FLOAT, 1),
522            Instruction::Cz => self.pop_typed(tag::ZONE),
523
524            // measurement
525            Instruction::Measure(arity) => {
526                self.pop_typed_n(tag::ZONE, *arity);
527                for _ in 0..*arity {
528                    self.push(tag::MEASURE_FUTURE, None);
529                }
530            }
531            Instruction::AwaitMeasure => {
532                self.pop_typed(tag::MEASURE_FUTURE);
533                self.push(tag::MEASUREMENT_RESULT, None);
534            }
535
536            // arrays
537            Instruction::NewArray(_type_tag, dim0, dim1) => {
538                let count = dim0 * if *dim1 == 0 { 1 } else { *dim1 };
539                for _ in 0..count {
540                    self.pop_any();
541                }
542                self.push(tag::ARRAY_REF, None);
543            }
544            Instruction::GetItem(ndims) => {
545                self.pop_typed_n(tag::INT, *ndims);
546                self.pop_typed(tag::ARRAY_REF);
547                // Element type is not tracked; assume float.
548                self.push(tag::FLOAT, None);
549            }
550
551            // detectors / observables
552            Instruction::SetDetector => {
553                self.pop_typed(tag::ARRAY_REF);
554                self.push(tag::DETECTOR_REF, None);
555            }
556            Instruction::SetObservable => {
557                self.pop_typed(tag::ARRAY_REF);
558                self.push(tag::OBSERVABLE_REF, None);
559            }
560
561            // control
562            Instruction::Return => self.pop_any(),
563            Instruction::Cpu(Cpu::Halt) => {}
564
565            // Other reused vihaco-cpu ops (arithmetic, etc.) are not emitted by
566            // the lanes pipeline and are not modeled here.
567            Instruction::Cpu(_) => {}
568        }
569    }
570
571    fn run(mut self, program: &Program) -> Vec<ValidationError> {
572        for (pc, inst) in program.code.iter().enumerate() {
573            self.pc = pc;
574            self.dispatch(inst);
575        }
576        self.errors
577    }
578}
579
580/// Run the type-level stack simulation over a program. Collects underflow and
581/// type-mismatch errors, plus lane/location group errors (validated against
582/// `arch` when provided, else duplicate-only).
583pub fn simulate_stack(program: &Program, arch: Option<&ArchSpec>) -> Vec<ValidationError> {
584    StackSimulator::new(arch).run(program)
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use crate::version::Version;
591
592    /// A small, valid arch: one word of 5 sites, one zone.
593    const SIMPLE_ARCH_JSON: &str = include_str!("../../../../examples/arch/simple.json");
594
595    fn simple_arch() -> ArchSpec {
596        ArchSpec::from_json(SIMPLE_ARCH_JSON).expect("examples/arch/simple.json should parse")
597    }
598
599    /// Minimal arch carrying only capability flags; topology is empty (the
600    /// capability checks never touch it).
601    fn caps_arch(feed_forward: bool, atom_reloading: bool) -> ArchSpec {
602        ArchSpec {
603            version: Version::new(2, 0),
604            words: vec![],
605            zones: vec![],
606            zone_buses: vec![],
607            modes: vec![],
608            paths: None,
609            feed_forward,
610            atom_reloading,
611            blockade_radius: None,
612        }
613    }
614
615    fn program(instructions: Vec<Instruction>) -> Program {
616        crate::isa::program::from_code(Version::new(1, 0), instructions)
617    }
618
619    fn loc(zone_id: u32, word_id: u32, site_id: u32) -> u64 {
620        LocationAddr {
621            zone_id,
622            word_id,
623            site_id,
624        }
625        .encode()
626    }
627
628    // ---- capability checks ----
629
630    #[test]
631    fn no_arch_skips_all_checks() {
632        let p = program(vec![
633            Instruction::Cpu(Cpu::ConditionalBranch(0, 1)),
634            Instruction::Fill(1),
635            Instruction::Measure(1),
636            Instruction::Measure(1),
637            Instruction::ConstZone(99), // also an invalid address
638        ]);
639        assert!(validate(&p, None).is_empty());
640    }
641
642    #[test]
643    fn branching_rejected_without_feed_forward() {
644        let p = program(vec![
645            Instruction::Cpu(Cpu::Branch(0)),
646            Instruction::Cpu(Cpu::ConditionalBranch(0, 1)),
647            Instruction::Cpu(Cpu::Call(1, 0)),
648            Instruction::Cpu(Cpu::IndirectCall),
649        ]);
650        let errors = validate(&p, Some(&caps_arch(false, false)));
651        assert_eq!(
652            errors,
653            vec![
654                ValidationError::ControlFlowRequiresFeedForward {
655                    pc: 0,
656                    mnemonic: "br"
657                },
658                ValidationError::ControlFlowRequiresFeedForward {
659                    pc: 1,
660                    mnemonic: "cond_br"
661                },
662                ValidationError::ControlFlowRequiresFeedForward {
663                    pc: 2,
664                    mnemonic: "call"
665                },
666                ValidationError::ControlFlowRequiresFeedForward {
667                    pc: 3,
668                    mnemonic: "call_indirect"
669                },
670            ]
671        );
672    }
673
674    #[test]
675    fn branching_allowed_with_feed_forward() {
676        let p = program(vec![
677            Instruction::Cpu(Cpu::ConditionalBranch(0, 1)),
678            Instruction::Measure(1),
679            Instruction::Measure(1),
680        ]);
681        assert!(validate(&p, Some(&caps_arch(true, false))).is_empty());
682    }
683
684    #[test]
685    fn non_control_flow_cpu_ops_are_fine() {
686        use vihaco::value::Value;
687        let p = program(vec![
688            Instruction::Cpu(Cpu::Const(Value::I64(1))),
689            Instruction::Cpu(Cpu::Dup),
690            Instruction::Cpu(Cpu::Halt),
691        ]);
692        assert!(validate(&p, Some(&caps_arch(false, false))).is_empty());
693    }
694
695    #[test]
696    fn single_measure_ok_but_second_rejected_without_feed_forward() {
697        let p = program(vec![Instruction::Measure(1), Instruction::Measure(1)]);
698        assert_eq!(
699            validate(&p, Some(&caps_arch(false, false))),
700            vec![ValidationError::MultipleMeasuresRequireFeedForward { pc: 1 }]
701        );
702    }
703
704    #[test]
705    fn fill_requires_atom_reloading() {
706        let p = program(vec![Instruction::Fill(1)]);
707        assert_eq!(
708            validate(&p, Some(&caps_arch(false, false))),
709            vec![ValidationError::FillRequiresAtomReloading { pc: 0 }]
710        );
711        assert!(validate(&p, Some(&caps_arch(false, true))).is_empty());
712    }
713
714    // ---- address checks ----
715
716    #[test]
717    fn valid_addresses_pass() {
718        let arch = simple_arch();
719        // zone 0, word 0, sites 0 and 4 are in range; zone 0 exists.
720        let p = program(vec![
721            Instruction::ConstLoc(loc(0, 0, 0)),
722            Instruction::ConstLoc(loc(0, 0, 4)),
723            Instruction::ConstZone(ZoneAddr { zone_id: 0 }.encode()),
724        ]);
725        assert!(validate(&p, Some(&arch)).is_empty(), "expected no errors");
726    }
727
728    #[test]
729    fn invalid_location_rejected() {
730        let arch = simple_arch();
731        // site 99 is out of range for a 5-site word.
732        let p = program(vec![Instruction::ConstLoc(loc(0, 0, 99))]);
733        let errors = validate(&p, Some(&arch));
734        assert!(
735            matches!(
736                errors.as_slice(),
737                [ValidationError::InvalidLocation { pc: 0, .. }]
738            ),
739            "got {errors:?}"
740        );
741    }
742
743    #[test]
744    fn invalid_zone_rejected() {
745        let arch = simple_arch();
746        // zone 5 does not exist (only zone 0).
747        let p = program(vec![Instruction::ConstZone(
748            ZoneAddr { zone_id: 5 }.encode(),
749        )]);
750        let errors = validate(&p, Some(&arch));
751        assert!(
752            matches!(
753                errors.as_slice(),
754                [ValidationError::InvalidZone { pc: 0, .. }]
755            ),
756            "got {errors:?}"
757        );
758    }
759
760    #[test]
761    fn invalid_lane_rejected() {
762        let arch = simple_arch();
763        // A lane in a nonexistent zone is invalid.
764        let bad = LaneAddr {
765            direction: crate::arch::addr::Direction::Forward,
766            move_type: crate::arch::addr::MoveType::SiteBus,
767            zone_id: 9,
768            word_id: 0,
769            site_id: 0,
770            bus_id: 0,
771        };
772        let p = program(vec![Instruction::ConstLane(bad.encode_u64())]);
773        let errors = validate(&p, Some(&arch));
774        assert!(
775            errors
776                .iter()
777                .any(|e| matches!(e, ValidationError::InvalidLane { pc: 0, .. })),
778            "got {errors:?}"
779        );
780    }
781
782    // ---- structural checks ----
783
784    #[test]
785    fn well_formed_program_has_no_structural_errors() {
786        let p = program(vec![
787            Instruction::ConstLoc(loc(0, 0, 0)),
788            Instruction::InitialFill(1),
789            Instruction::Return,
790        ]);
791        assert!(validate_structure(&p).is_empty());
792    }
793
794    #[test]
795    fn empty_program_is_rejected() {
796        assert_eq!(
797            validate_structure(&program(vec![])),
798            vec![ValidationError::EmptyProgram]
799        );
800    }
801
802    #[test]
803    fn missing_terminator_rejected() {
804        let p = program(vec![
805            Instruction::ConstLoc(loc(0, 0, 0)),
806            Instruction::InitialFill(1),
807        ]);
808        assert_eq!(
809            validate_structure(&p),
810            vec![ValidationError::MissingTerminator { pc: 1 }]
811        );
812    }
813
814    #[test]
815    fn halt_is_a_valid_terminator() {
816        let p = program(vec![Instruction::Cpu(Cpu::Halt)]);
817        assert!(validate_structure(&p).is_empty());
818    }
819
820    #[test]
821    fn unreachable_after_terminator_rejected() {
822        let p = program(vec![Instruction::Return, Instruction::Cpu(Cpu::Halt)]);
823        assert_eq!(
824            validate_structure(&p),
825            vec![ValidationError::UnreachableInstruction { pc: 1 }]
826        );
827    }
828
829    #[test]
830    fn initial_fill_must_be_first_non_constant() {
831        // A const push before initial_fill is fine; a gate before it is not.
832        let ok = program(vec![
833            Instruction::ConstLoc(loc(0, 0, 0)),
834            Instruction::InitialFill(1),
835            Instruction::Return,
836        ]);
837        assert!(validate_structure(&ok).is_empty());
838
839        let bad = program(vec![
840            Instruction::GlobalR,
841            Instruction::InitialFill(1),
842            Instruction::Return,
843        ]);
844        assert!(validate_structure(&bad).contains(&ValidationError::InitialFillNotFirst { pc: 1 }));
845    }
846
847    #[test]
848    fn new_array_bounds_checked() {
849        let p = program(vec![Instruction::NewArray(99, 0, 0), Instruction::Return]);
850        let errors = validate_structure(&p);
851        assert!(errors.contains(&ValidationError::NewArrayZeroDim0 { pc: 0 }));
852        assert!(errors.contains(&ValidationError::NewArrayInvalidTypeTag {
853            pc: 0,
854            type_tag: 99
855        }));
856    }
857
858    #[test]
859    fn structure_and_arch_checks_compose() {
860        // The composition consumers run: structural + arch-dependent checks
861        // both fire and collect. Missing terminator (structural) + bad zone (arch).
862        let arch = simple_arch();
863        let p = program(vec![Instruction::ConstZone(
864            ZoneAddr { zone_id: 5 }.encode(),
865        )]);
866        let errors: Vec<_> = validate_structure(&p)
867            .into_iter()
868            .chain(validate(&p, Some(&arch)))
869            .collect();
870        assert!(errors.contains(&ValidationError::MissingTerminator { pc: 0 }));
871        assert!(
872            errors
873                .iter()
874                .any(|e| matches!(e, ValidationError::InvalidZone { .. }))
875        );
876    }
877
878    #[test]
879    fn capability_and_address_errors_collected_together() {
880        let arch = simple_arch(); // feed_forward = false, atom_reloading = false
881        let p = program(vec![
882            Instruction::Cpu(Cpu::Branch(0)), // pc 0: control flow
883            Instruction::Fill(1),             // pc 1: reloading
884            Instruction::ConstZone(ZoneAddr { zone_id: 5 }.encode()), // pc 2: bad zone
885        ]);
886        let errors = validate(&p, Some(&arch));
887        assert_eq!(
888            errors.len(),
889            3,
890            "one error per violation, in pc order: {errors:?}"
891        );
892        assert!(matches!(
893            errors[0],
894            ValidationError::ControlFlowRequiresFeedForward { pc: 0, .. }
895        ));
896        assert!(matches!(
897            errors[1],
898            ValidationError::FillRequiresAtomReloading { pc: 1 }
899        ));
900        assert!(matches!(
901            errors[2],
902            ValidationError::InvalidZone { pc: 2, .. }
903        ));
904    }
905
906    // ---- stack simulation ----
907
908    fn cpu_float(v: f64) -> Instruction {
909        Instruction::Cpu(Cpu::Const(Value::F64(v)))
910    }
911
912    #[test]
913    fn stack_well_typed_program_has_no_errors() {
914        // const_loc, const_loc, initial_fill 2, const_zone, measure 1,
915        // await_measure, return — all types line up.
916        let p = program(vec![
917            Instruction::ConstLoc(loc(0, 0, 0)),
918            Instruction::ConstLoc(loc(0, 0, 1)),
919            Instruction::InitialFill(2),
920            Instruction::ConstZone(0),
921            Instruction::Measure(1),
922            Instruction::AwaitMeasure,
923            Instruction::Return,
924        ]);
925        assert!(
926            simulate_stack(&p, None).is_empty(),
927            "{:?}",
928            simulate_stack(&p, None)
929        );
930    }
931
932    #[test]
933    fn stack_underflow_detected() {
934        let p = program(vec![Instruction::Pop]);
935        assert_eq!(
936            simulate_stack(&p, None),
937            vec![ValidationError::StackUnderflow { pc: 0 }]
938        );
939    }
940
941    #[test]
942    fn type_mismatch_detected() {
943        // initial_fill expects locations; a float is on the stack instead.
944        let p = program(vec![cpu_float(1.0), Instruction::InitialFill(1)]);
945        let errors = simulate_stack(&p, None);
946        assert!(
947            errors.iter().any(|e| matches!(
948                e,
949                ValidationError::TypeMismatch {
950                    pc: 1,
951                    expected,
952                    got
953                } if *expected == tag::LOCATION && *got == tag::FLOAT
954            )),
955            "got {errors:?}"
956        );
957    }
958
959    #[test]
960    fn measure_pushes_future_consumed_by_await() {
961        // A measure future left dangling is fine; awaiting a non-future is not.
962        let p = program(vec![cpu_float(1.0), Instruction::AwaitMeasure]);
963        let errors = simulate_stack(&p, None);
964        assert!(
965            errors.iter().any(|e| matches!(
966                e,
967                ValidationError::TypeMismatch { pc: 1, expected, .. } if *expected == tag::MEASURE_FUTURE
968            )),
969            "got {errors:?}"
970        );
971    }
972
973    #[test]
974    fn local_r_pops_two_floats_then_locations() {
975        // const_loc, const_float, const_float, local_r 1 — well typed.
976        let p = program(vec![
977            Instruction::ConstLoc(loc(0, 0, 0)),
978            cpu_float(1.5),
979            cpu_float(0.5),
980            Instruction::LocalR(1),
981        ]);
982        assert!(simulate_stack(&p, None).is_empty());
983    }
984
985    #[test]
986    fn duplicate_locations_flagged_without_arch() {
987        // Two identical locations into a single fill group.
988        let p = program(vec![
989            Instruction::ConstLoc(loc(0, 0, 0)),
990            Instruction::ConstLoc(loc(0, 0, 0)),
991            Instruction::InitialFill(2),
992        ]);
993        let errors = simulate_stack(&p, None);
994        assert!(
995            errors
996                .iter()
997                .any(|e| matches!(e, ValidationError::LocationGroupValidation { .. })),
998            "got {errors:?}"
999        );
1000    }
1001
1002    #[test]
1003    fn invalid_lane_group_flagged_with_arch() {
1004        let arch = simple_arch();
1005        // A lane in a nonexistent zone forms an invalid move group.
1006        let bad = crate::arch::addr::LaneAddr {
1007            direction: crate::arch::addr::Direction::Forward,
1008            move_type: crate::arch::addr::MoveType::SiteBus,
1009            zone_id: 9,
1010            word_id: 0,
1011            site_id: 0,
1012            bus_id: 0,
1013        };
1014        let p = program(vec![
1015            Instruction::ConstLane(bad.encode_u64()),
1016            Instruction::Move(1),
1017        ]);
1018        let errors = simulate_stack(&p, Some(&arch));
1019        assert!(
1020            errors
1021                .iter()
1022                .any(|e| matches!(e, ValidationError::LaneGroupValidation { .. })),
1023            "got {errors:?}"
1024        );
1025    }
1026
1027    #[test]
1028    fn await_measure_pushes_measurement_result() {
1029        // const_zone, measure 1, await_measure — the awaited value carries the
1030        // measurement-result tag, so a following set_detector (wants ARRAY_REF)
1031        // now type-mismatches on MEASUREMENT_RESULT rather than silently matching.
1032        let p = program(vec![
1033            Instruction::ConstZone(0),
1034            Instruction::Measure(1),
1035            Instruction::AwaitMeasure,
1036        ]);
1037        // await_measure must push the measurement-result tag.
1038        assert_eq!(tag::MEASUREMENT_RESULT, 0x9);
1039        // Sanity: simulate cleanly (no underflow/mismatch) for the measure→await chain.
1040        assert!(simulate_stack(&p, None).iter().all(|e| !matches!(
1041            e,
1042            ValidationError::StackUnderflow { .. } | ValidationError::TypeMismatch { .. }
1043        )));
1044    }
1045
1046    // ---- Display ----
1047
1048    #[test]
1049    fn validation_error_display_strings() {
1050        use crate::arch::query::{LaneGroupError, LocationGroupError};
1051
1052        let cases: Vec<(ValidationError, String)> = vec![
1053            (
1054                ValidationError::ControlFlowRequiresFeedForward {
1055                    pc: 3,
1056                    mnemonic: "cond_br",
1057                },
1058                "pc 3: control-flow instruction 'cond_br' requires feed_forward capability".into(),
1059            ),
1060            (
1061                ValidationError::MultipleMeasuresRequireFeedForward { pc: 5 },
1062                "pc 5: multiple measure instructions require feed_forward capability".into(),
1063            ),
1064            (
1065                ValidationError::FillRequiresAtomReloading { pc: 1 },
1066                "pc 1: fill instruction requires atom_reloading capability".into(),
1067            ),
1068            (
1069                ValidationError::InvalidLocation {
1070                    pc: 2,
1071                    message: "bad".into(),
1072                },
1073                "pc 2: invalid location: bad".into(),
1074            ),
1075            (
1076                ValidationError::InvalidLane {
1077                    pc: 2,
1078                    message: "bad".into(),
1079                },
1080                "pc 2: invalid lane: bad".into(),
1081            ),
1082            (
1083                ValidationError::InvalidZone {
1084                    pc: 2,
1085                    message: "bad".into(),
1086                },
1087                "pc 2: invalid zone: bad".into(),
1088            ),
1089            (
1090                ValidationError::NewArrayZeroDim0 { pc: 0 },
1091                "pc 0: new_array dim0 must be > 0".into(),
1092            ),
1093            (
1094                ValidationError::NewArrayInvalidTypeTag {
1095                    pc: 0,
1096                    type_tag: 99,
1097                },
1098                "pc 0: invalid new_array type tag 99".into(),
1099            ),
1100            (
1101                ValidationError::InitialFillNotFirst { pc: 4 },
1102                "pc 4: initial_fill must be the first non-constant instruction".into(),
1103            ),
1104            (
1105                ValidationError::EmptyProgram,
1106                "program has no instructions: missing return or halt terminator".into(),
1107            ),
1108            (
1109                ValidationError::MissingTerminator { pc: 7 },
1110                "pc 7: program must end with return or halt".into(),
1111            ),
1112            (
1113                ValidationError::UnreachableInstruction { pc: 8 },
1114                "pc 8: unreachable instruction after return or halt".into(),
1115            ),
1116            (
1117                ValidationError::StackUnderflow { pc: 1 },
1118                "pc 1: stack underflow".into(),
1119            ),
1120            (
1121                ValidationError::TypeMismatch {
1122                    pc: 1,
1123                    expected: tag::LOCATION,
1124                    got: tag::FLOAT,
1125                },
1126                "pc 1: type mismatch: expected tag 0x3, got 0x0".into(),
1127            ),
1128        ];
1129        for (err, expected) in cases {
1130            assert_eq!(err.to_string(), expected);
1131        }
1132
1133        // The two group-validation variants prefix `pc N:` onto the wrapped
1134        // arch-layer error's own Display.
1135        let loc_err = LocationGroupError::DuplicateAddress { address: 0x10 };
1136        assert_eq!(
1137            ValidationError::LocationGroupValidation {
1138                pc: 2,
1139                error: loc_err.clone(),
1140            }
1141            .to_string(),
1142            format!("pc 2: {loc_err}")
1143        );
1144        let lane_err = LaneGroupError::DuplicateAddress { address: (1, 2) };
1145        assert_eq!(
1146            ValidationError::LaneGroupValidation {
1147                pc: 3,
1148                error: lane_err.clone(),
1149            }
1150            .to_string(),
1151            format!("pc 3: {lane_err}")
1152        );
1153    }
1154
1155    // ---- stack simulation: dispatch coverage ----
1156
1157    #[test]
1158    fn stack_sim_int_const_and_pop() {
1159        // `const.i64` pushes an INT; `pop` discards it. Well typed.
1160        let p = program(vec![
1161            Instruction::Cpu(Cpu::Const(Value::I64(7))),
1162            Instruction::Pop,
1163        ]);
1164        assert!(simulate_stack(&p, None).is_empty());
1165    }
1166
1167    #[test]
1168    fn stack_sim_swap_needs_two_entries() {
1169        let ok = program(vec![
1170            Instruction::ConstLoc(loc(0, 0, 0)),
1171            Instruction::ConstLoc(loc(0, 0, 1)),
1172            Instruction::Swap,
1173        ]);
1174        assert!(simulate_stack(&ok, None).is_empty());
1175
1176        // Only one entry: swap underflows.
1177        let bad = program(vec![Instruction::ConstLoc(loc(0, 0, 0)), Instruction::Swap]);
1178        assert_eq!(
1179            simulate_stack(&bad, None),
1180            vec![ValidationError::StackUnderflow { pc: 1 }]
1181        );
1182    }
1183
1184    #[test]
1185    fn stack_sim_dup_underflow_on_empty() {
1186        let p = program(vec![Instruction::Cpu(Cpu::Dup)]);
1187        assert_eq!(
1188            simulate_stack(&p, None),
1189            vec![ValidationError::StackUnderflow { pc: 0 }]
1190        );
1191    }
1192
1193    #[test]
1194    fn stack_sim_dup_copies_top_of_stack() {
1195        // `dup` on a non-empty stack pushes a copy of the top entry; popping
1196        // both leaves an empty, well-typed stack.
1197        let p = program(vec![
1198            Instruction::ConstLoc(loc(0, 0, 0)),
1199            Instruction::Cpu(Cpu::Dup),
1200            Instruction::Pop,
1201            Instruction::Pop,
1202        ]);
1203        assert!(
1204            simulate_stack(&p, None).is_empty(),
1205            "{:?}",
1206            simulate_stack(&p, None)
1207        );
1208    }
1209
1210    #[test]
1211    fn stack_sim_gate_ops_are_well_typed() {
1212        // Exercises the LocalRz / GlobalR / GlobalRz / Cz dispatch arms.
1213        let p = program(vec![
1214            Instruction::ConstLoc(loc(0, 0, 0)),
1215            cpu_float(0.5),
1216            Instruction::LocalRz(1),
1217            cpu_float(0.5),
1218            Instruction::GlobalRz,
1219            cpu_float(0.5),
1220            cpu_float(0.25),
1221            Instruction::GlobalR,
1222            Instruction::ConstZone(0),
1223            Instruction::Cz,
1224        ]);
1225        assert!(
1226            simulate_stack(&p, None).is_empty(),
1227            "{:?}",
1228            simulate_stack(&p, None)
1229        );
1230    }
1231
1232    #[test]
1233    fn stack_sim_gate_underflow_on_empty_stack() {
1234        // GlobalRz pops one float via `pop_typed`; empty stack -> underflow.
1235        assert_eq!(
1236            simulate_stack(&program(vec![Instruction::GlobalRz]), None),
1237            vec![ValidationError::StackUnderflow { pc: 0 }]
1238        );
1239        // Move pops a lane via `pop_addr`; empty stack -> underflow.
1240        assert_eq!(
1241            simulate_stack(&program(vec![Instruction::Move(1)]), None),
1242            vec![ValidationError::StackUnderflow { pc: 0 }]
1243        );
1244    }
1245
1246    #[test]
1247    fn stack_sim_new_array_and_get_item() {
1248        // new_array pops `dim0` elements and pushes an ARRAY_REF; get_item pops
1249        // `ndims` INT indices plus the ARRAY_REF and pushes the element.
1250        let p = program(vec![
1251            Instruction::Cpu(Cpu::Const(Value::I64(1))),
1252            Instruction::Cpu(Cpu::Const(Value::I64(2))),
1253            Instruction::NewArray(tag::INT as u32, 2, 0),
1254            Instruction::Cpu(Cpu::Const(Value::I64(0))),
1255            Instruction::GetItem(1),
1256        ]);
1257        assert!(
1258            simulate_stack(&p, None).is_empty(),
1259            "{:?}",
1260            simulate_stack(&p, None)
1261        );
1262    }
1263
1264    #[test]
1265    fn stack_sim_set_detector_and_observable() {
1266        // Each consumes an ARRAY_REF (produced by a 1-element new_array).
1267        let det = program(vec![
1268            Instruction::Cpu(Cpu::Const(Value::I64(0))),
1269            Instruction::NewArray(tag::INT as u32, 1, 0),
1270            Instruction::SetDetector,
1271        ]);
1272        assert!(
1273            simulate_stack(&det, None).is_empty(),
1274            "{:?}",
1275            simulate_stack(&det, None)
1276        );
1277
1278        let obs = program(vec![
1279            Instruction::Cpu(Cpu::Const(Value::I64(0))),
1280            Instruction::NewArray(tag::INT as u32, 1, 0),
1281            Instruction::SetObservable,
1282        ]);
1283        assert!(
1284            simulate_stack(&obs, None).is_empty(),
1285            "{:?}",
1286            simulate_stack(&obs, None)
1287        );
1288    }
1289
1290    #[test]
1291    fn stack_sim_halt_and_other_cpu_ops_are_noops() {
1292        // `halt` and any other reused vihaco-cpu op (here `print`, and a
1293        // non-int/float `const`) are untracked no-ops in the type simulator.
1294        let p = program(vec![
1295            Instruction::Cpu(Cpu::Print),
1296            Instruction::Cpu(Cpu::Const(Value::Bool(true))),
1297            Instruction::Cpu(Cpu::Halt),
1298        ]);
1299        assert!(simulate_stack(&p, None).is_empty());
1300    }
1301
1302    #[test]
1303    fn location_group_validated_against_arch() {
1304        // With an arch, `initial_fill` runs the arch location-group check
1305        // (not just the no-arch duplicate fallback). Two valid distinct
1306        // locations produce no group error.
1307        let arch = simple_arch();
1308        let p = program(vec![
1309            Instruction::ConstLoc(loc(0, 0, 0)),
1310            Instruction::ConstLoc(loc(0, 0, 1)),
1311            Instruction::InitialFill(2),
1312        ]);
1313        let errors = simulate_stack(&p, Some(&arch));
1314        assert!(
1315            !errors
1316                .iter()
1317                .any(|e| matches!(e, ValidationError::LocationGroupValidation { .. })),
1318            "got {errors:?}"
1319        );
1320    }
1321
1322    #[test]
1323    fn location_group_arch_errors_are_reported() {
1324        // With an arch, a bad fill group surfaces the arch layer's own
1325        // location-group error (exercising the arch branch's error path, not
1326        // the no-arch duplicate fallback). A location repeated within the
1327        // group is invalid.
1328        let arch = simple_arch();
1329        let p = program(vec![
1330            Instruction::ConstLoc(loc(0, 0, 0)),
1331            Instruction::ConstLoc(loc(0, 0, 0)),
1332            Instruction::InitialFill(2),
1333        ]);
1334        let errors = simulate_stack(&p, Some(&arch));
1335        assert!(
1336            errors
1337                .iter()
1338                .any(|e| matches!(e, ValidationError::LocationGroupValidation { .. })),
1339            "got {errors:?}"
1340        );
1341    }
1342
1343    #[test]
1344    fn duplicate_lanes_flagged_without_arch() {
1345        // The no-arch `move` fallback reports repeated lanes as duplicates.
1346        let lane = LaneAddr {
1347            direction: crate::arch::addr::Direction::Forward,
1348            move_type: crate::arch::addr::MoveType::SiteBus,
1349            zone_id: 0,
1350            word_id: 0,
1351            site_id: 0,
1352            bus_id: 0,
1353        };
1354        let p = program(vec![
1355            Instruction::ConstLane(lane.encode_u64()),
1356            Instruction::ConstLane(lane.encode_u64()),
1357            Instruction::Move(2),
1358        ]);
1359        let errors = simulate_stack(&p, None);
1360        assert!(
1361            errors
1362                .iter()
1363                .any(|e| matches!(e, ValidationError::LaneGroupValidation { .. })),
1364            "got {errors:?}"
1365        );
1366    }
1367}