Skip to main content

bloqade_lanes_bytecode_core/arch/
validate.rs

1//! Structural validation for [`ArchSpec`].
2//!
3//! Validates all structural rules in a single pass, collecting every error
4//! rather than failing fast. See [`ArchSpec::validate`].
5
6use std::collections::{HashMap, HashSet};
7
8use thiserror::Error;
9
10use super::types::ArchSpec;
11
12/// Error categories for arch spec structural validation.
13///
14/// Each variant groups related validation checks. Multiple errors
15/// can be collected in a single validation pass.
16#[derive(Debug, Clone, PartialEq, Error)]
17pub enum ArchSpecError {
18    /// Structural error (grid dimensions, word consistency, minimum counts).
19    #[error("{0}")]
20    Structure(String),
21
22    /// Per-zone bus validation error (site/word buses, membership lists).
23    #[error("{0}")]
24    ZoneBus(String),
25
26    /// Inter-zone bus validation error (zone bus entries).
27    #[error("{0}")]
28    InterZoneBus(String),
29
30    /// Bus src→dst relation contains a cycle.
31    ///
32    /// A bus is by definition a set of explicit edge transports executed
33    /// simultaneously as one AOD operation — never a permutation. A chain
34    /// (`x→y, y→z` with the final destination outside the source set) shifts
35    /// atoms like a conveyor and is physically valid; a cycle
36    /// (`x→y, y→z, z→x`, including the self-loop `x→x`) would rotate a
37    /// fully-occupied set of atoms with no empty site, which AOD hardware
38    /// cannot do (tones in a group share a direction and cannot cross).
39    /// Cyclic buses are therefore rejected outright, while
40    /// overlapping-but-acyclic buses remain legal.
41    #[error("{0}")]
42    CyclicBus(String),
43
44    /// Entangling zone pair validation error.
45    #[error("{0}")]
46    EntanglingPair(String),
47
48    /// Mode validation error.
49    #[error("{0}")]
50    Mode(String),
51
52    /// Transport path validation error.
53    #[error("{0}")]
54    Path(String),
55}
56
57impl ArchSpec {
58    /// Validate the arch spec against all structural rules.
59    /// Collects all errors in one pass (not fail-fast).
60    pub fn validate(&self) -> Result<(), Vec<ArchSpecError>> {
61        let mut errors = Vec::new();
62
63        let num_words = self.words.len();
64        let num_zones = self.zones.len();
65        let sites_per_word = self.sites_per_word();
66
67        // Structural invariants
68        check_minimum_counts(self, &mut errors);
69        check_non_negative_grid_spacings(self, &mut errors);
70        check_uniform_grid_dimensions(self, &mut errors);
71        check_uniform_word_site_counts(self, &mut errors);
72        check_word_site_indices(self, &mut errors);
73
74        // Per-zone bus and entangling pair validation
75        for (zone_idx, zone) in self.zones.iter().enumerate() {
76            check_zone_words_with_site_buses(zone_idx, zone, num_words, &mut errors);
77            check_zone_sites_with_word_buses(zone_idx, zone, sites_per_word, &mut errors);
78            check_zone_site_buses(zone_idx, zone, sites_per_word, &mut errors);
79            check_zone_word_buses(zone_idx, zone, num_words, &mut errors);
80            check_zone_entangling_pairs(zone_idx, zone, num_words, &mut errors);
81        }
82
83        // Inter-zone bus validation
84        check_zone_buses(self, num_zones, num_words, &mut errors);
85
86        // Mode validation
87        check_modes(self, num_zones, num_words, sites_per_word, &mut errors);
88
89        // Path validation
90        check_paths(self, num_zones, &mut errors);
91
92        // Zone physical-space overlap
93        check_zone_overlap(self, &mut errors);
94
95        if errors.is_empty() {
96            Ok(())
97        } else {
98            Err(errors)
99        }
100    }
101}
102
103/// At least one zone and one word must exist.
104fn check_minimum_counts(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
105    if spec.zones.is_empty() {
106        errors.push(ArchSpecError::Structure(
107            "at least one zone must exist".into(),
108        ));
109    }
110    if spec.words.is_empty() {
111        errors.push(ArchSpecError::Structure(
112            "at least one word must exist".into(),
113        ));
114    }
115}
116
117/// All grid spacings must be non-negative. Negative spacings would produce
118/// non-monotonic positions, breaking the `Grid::bounding_box()` invariant
119/// and making position lookups ambiguous.
120fn check_non_negative_grid_spacings(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
121    for (idx, zone) in spec.zones.iter().enumerate() {
122        if let Some(pos) = zone.grid.x_spacing.iter().position(|&v| v < 0.0) {
123            errors.push(ArchSpecError::Structure(format!(
124                "zone {} grid x_spacing[{}] is negative ({:.6})",
125                idx, pos, zone.grid.x_spacing[pos]
126            )));
127        }
128        if let Some(pos) = zone.grid.y_spacing.iter().position(|&v| v < 0.0) {
129            errors.push(ArchSpecError::Structure(format!(
130                "zone {} grid y_spacing[{}] is negative ({:.6})",
131                idx, pos, zone.grid.y_spacing[pos]
132            )));
133        }
134    }
135}
136
137/// All zones must have the same grid dimensions (same num_x and num_y).
138fn check_uniform_grid_dimensions(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
139    if let Some(first_zone) = spec.zones.first() {
140        let ref_x = first_zone.grid.num_x();
141        let ref_y = first_zone.grid.num_y();
142        for (idx, zone) in spec.zones.iter().enumerate().skip(1) {
143            let zx = zone.grid.num_x();
144            let zy = zone.grid.num_y();
145            if zx != ref_x || zy != ref_y {
146                errors.push(ArchSpecError::Structure(format!(
147                    "zone {} grid dimensions ({}x{}) differ from zone 0 ({}x{})",
148                    idx, zx, zy, ref_x, ref_y
149                )));
150            }
151        }
152    }
153}
154
155/// All words must have the same number of sites.
156fn check_uniform_word_site_counts(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
157    if let Some(first_word) = spec.words.first() {
158        let ref_count = first_word.sites.len();
159        for (idx, word) in spec.words.iter().enumerate().skip(1) {
160            if word.sites.len() != ref_count {
161                errors.push(ArchSpecError::Structure(format!(
162                    "word {} has {} sites, expected {} (same as word 0)",
163                    idx,
164                    word.sites.len(),
165                    ref_count
166                )));
167            }
168        }
169    }
170}
171
172/// Word site indices must be within the grid dimensions of every zone.
173/// For each site [x, y]: x < grid.num_x() and y < grid.num_y().
174fn check_word_site_indices(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
175    // Use zone 0's grid as the reference (uniform dimensions already checked).
176    let (grid_x, grid_y) = match spec.zones.first() {
177        Some(z) => (z.grid.num_x(), z.grid.num_y()),
178        None => return, // no zones → nothing to check
179    };
180
181    for (word_idx, word) in spec.words.iter().enumerate() {
182        for (site_idx, site) in word.sites.iter().enumerate() {
183            let x = site[0] as usize;
184            let y = site[1] as usize;
185            if x >= grid_x {
186                errors.push(ArchSpecError::Structure(format!(
187                    "word {}, site {}: x index {} out of range (grid has {} x-positions)",
188                    word_idx, site_idx, site[0], grid_x
189                )));
190            }
191            if y >= grid_y {
192                errors.push(ArchSpecError::Structure(format!(
193                    "word {}, site {}: y index {} out of range (grid has {} y-positions)",
194                    word_idx, site_idx, site[1], grid_y
195                )));
196            }
197        }
198    }
199}
200
201// --- Per-zone bus validation ---
202
203use super::types::Zone;
204
205/// `words_with_site_buses` entries must be < number of words.
206fn check_zone_words_with_site_buses(
207    zone_idx: usize,
208    zone: &Zone,
209    num_words: usize,
210    errors: &mut Vec<ArchSpecError>,
211) {
212    for &wid in &zone.words_with_site_buses {
213        if wid as usize >= num_words {
214            errors.push(ArchSpecError::ZoneBus(format!(
215                "zone {}: words_with_site_buses contains invalid word ID {}",
216                zone_idx, wid
217            )));
218        }
219    }
220}
221
222/// `sites_with_word_buses` entries must be valid site indices.
223fn check_zone_sites_with_word_buses(
224    zone_idx: usize,
225    zone: &Zone,
226    sites_per_word: usize,
227    errors: &mut Vec<ArchSpecError>,
228) {
229    for &sid in &zone.sites_with_word_buses {
230        if sid as usize >= sites_per_word {
231            errors.push(ArchSpecError::ZoneBus(format!(
232                "zone {}: sites_with_word_buses contains invalid site index {} (sites_per_word={})",
233                zone_idx, sid, sites_per_word
234            )));
235        }
236    }
237}
238
239/// Site bus src/dst must have same length and SiteRef values < sites_per_word.
240fn check_zone_site_buses(
241    zone_idx: usize,
242    zone: &Zone,
243    sites_per_word: usize,
244    errors: &mut Vec<ArchSpecError>,
245) {
246    for (bus_idx, bus) in zone.site_buses.iter().enumerate() {
247        if bus.src.len() != bus.dst.len() {
248            errors.push(ArchSpecError::ZoneBus(format!(
249                "zone {}, site_bus {}: src length ({}) != dst length ({})",
250                zone_idx,
251                bus_idx,
252                bus.src.len(),
253                bus.dst.len()
254            )));
255        }
256        for (i, sref) in bus.src.iter().enumerate() {
257            if sref.0 as usize >= sites_per_word {
258                errors.push(ArchSpecError::ZoneBus(format!(
259                    "zone {}, site_bus {}: src[{}] SiteRef({}) >= sites_per_word ({})",
260                    zone_idx, bus_idx, i, sref.0, sites_per_word
261                )));
262            }
263        }
264        for (i, sref) in bus.dst.iter().enumerate() {
265            if sref.0 as usize >= sites_per_word {
266                errors.push(ArchSpecError::ZoneBus(format!(
267                    "zone {}, site_bus {}: dst[{}] SiteRef({}) >= sites_per_word ({})",
268                    zone_idx, bus_idx, i, sref.0, sites_per_word
269                )));
270            }
271        }
272        check_bus_relation(
273            &bus.src,
274            &bus.dst,
275            &format!("zone {zone_idx}, site_bus {bus_idx}"),
276            |s| s.0.to_string(),
277            ArchSpecError::ZoneBus,
278            errors,
279        );
280    }
281}
282
283/// Word bus src/dst must have same length and WordRef values < number of words.
284fn check_zone_word_buses(
285    zone_idx: usize,
286    zone: &Zone,
287    num_words: usize,
288    errors: &mut Vec<ArchSpecError>,
289) {
290    for (bus_idx, bus) in zone.word_buses.iter().enumerate() {
291        if bus.src.len() != bus.dst.len() {
292            errors.push(ArchSpecError::ZoneBus(format!(
293                "zone {}, word_bus {}: src length ({}) != dst length ({})",
294                zone_idx,
295                bus_idx,
296                bus.src.len(),
297                bus.dst.len()
298            )));
299        }
300        for (i, wref) in bus.src.iter().enumerate() {
301            if wref.0 as usize >= num_words {
302                errors.push(ArchSpecError::ZoneBus(format!(
303                    "zone {}, word_bus {}: src[{}] WordRef({}) >= num_words ({})",
304                    zone_idx, bus_idx, i, wref.0, num_words
305                )));
306            }
307        }
308        for (i, wref) in bus.dst.iter().enumerate() {
309            if wref.0 as usize >= num_words {
310                errors.push(ArchSpecError::ZoneBus(format!(
311                    "zone {}, word_bus {}: dst[{}] WordRef({}) >= num_words ({})",
312                    zone_idx, bus_idx, i, wref.0, num_words
313                )));
314            }
315        }
316        check_bus_relation(
317            &bus.src,
318            &bus.dst,
319            &format!("zone {zone_idx}, word_bus {bus_idx}"),
320            |w| w.0.to_string(),
321            ArchSpecError::ZoneBus,
322            errors,
323        );
324    }
325}
326
327// --- Inter-zone bus validation ---
328
329/// Zone bus entries must have valid zone_id and word_id, src/dst same length,
330/// and every pair must cross a zone boundary.
331fn check_zone_buses(
332    spec: &ArchSpec,
333    num_zones: usize,
334    num_words: usize,
335    errors: &mut Vec<ArchSpecError>,
336) {
337    for (bus_idx, bus) in spec.zone_buses.iter().enumerate() {
338        if bus.src.len() != bus.dst.len() {
339            errors.push(ArchSpecError::InterZoneBus(format!(
340                "zone_bus {}: src length ({}) != dst length ({})",
341                bus_idx,
342                bus.src.len(),
343                bus.dst.len()
344            )));
345        }
346
347        // Validate all ZonedWordRef entries
348        for (i, zwr) in bus.src.iter().enumerate() {
349            if zwr.zone_id as usize >= num_zones {
350                errors.push(ArchSpecError::InterZoneBus(format!(
351                    "zone_bus {}: src[{}] zone_id {} >= num_zones ({})",
352                    bus_idx, i, zwr.zone_id, num_zones
353                )));
354            }
355            if zwr.word_id as usize >= num_words {
356                errors.push(ArchSpecError::InterZoneBus(format!(
357                    "zone_bus {}: src[{}] word_id {} >= num_words ({})",
358                    bus_idx, i, zwr.word_id, num_words
359                )));
360            }
361        }
362        for (i, zwr) in bus.dst.iter().enumerate() {
363            if zwr.zone_id as usize >= num_zones {
364                errors.push(ArchSpecError::InterZoneBus(format!(
365                    "zone_bus {}: dst[{}] zone_id {} >= num_zones ({})",
366                    bus_idx, i, zwr.zone_id, num_zones
367                )));
368            }
369            if zwr.word_id as usize >= num_words {
370                errors.push(ArchSpecError::InterZoneBus(format!(
371                    "zone_bus {}: dst[{}] word_id {} >= num_words ({})",
372                    bus_idx, i, zwr.word_id, num_words
373                )));
374            }
375        }
376
377        // Every (src[i], dst[i]) pair must cross a zone boundary
378        let pair_count = bus.src.len().min(bus.dst.len());
379        for i in 0..pair_count {
380            if bus.src[i].zone_id == bus.dst[i].zone_id {
381                errors.push(ArchSpecError::InterZoneBus(format!(
382                    "zone_bus {}: pair {} does not cross a zone boundary \
383                     (src zone_id={}, dst zone_id={})",
384                    bus_idx, i, bus.src[i].zone_id, bus.dst[i].zone_id
385                )));
386            }
387        }
388
389        check_bus_relation(
390            &bus.src,
391            &bus.dst,
392            &format!("zone_bus {bus_idx}"),
393            |z| format!("z{}w{}", z.zone_id, z.word_id),
394            ArchSpecError::InterZoneBus,
395            errors,
396        );
397    }
398}
399
400// --- Bus src→dst relation well-formedness ---
401
402/// Check that a bus's src→dst relation is well-formed: unique sources,
403/// unique destinations, and no cycles.
404///
405/// Duplicate sources are rejected because endpoint resolution is positional
406/// first-match ([`super::types::Bus::resolve_forward`]), so a duplicated
407/// source silently shadows every later pair. Duplicate destinations are
408/// rejected because two simultaneous transports into one site cannot both
409/// complete. Cycles (including self-loops) are rejected because a bus is a
410/// set of explicit transports, never a permutation — see
411/// [`ArchSpecError::CyclicBus`]. Overlapping-but-acyclic relations
412/// (conveyor chains such as `0→1, 1→2`) are legal.
413///
414/// `dup_err` selects the error category for duplicate-endpoint errors
415/// (`ZoneBus` for per-zone buses, `InterZoneBus` for zone buses); cycles
416/// always report as [`ArchSpecError::CyclicBus`]. Pairs are zipped, so a
417/// src/dst length mismatch (reported separately) truncates to the shorter
418/// side here.
419fn check_bus_relation<T>(
420    src: &[T],
421    dst: &[T],
422    label: &str,
423    fmt: impl Fn(&T) -> String,
424    dup_err: impl Fn(String) -> ArchSpecError,
425    errors: &mut Vec<ArchSpecError>,
426) where
427    T: Copy + Eq + std::hash::Hash,
428{
429    let mut first_src: HashMap<T, usize> = HashMap::new();
430    for (i, s) in src.iter().enumerate() {
431        if let Some(&j) = first_src.get(s) {
432            errors.push(dup_err(format!(
433                "{label}: duplicate src {} at indices {j} and {i} \
434                 (first-match resolution silently shadows the later pair)",
435                fmt(s)
436            )));
437        } else {
438            first_src.insert(*s, i);
439        }
440    }
441
442    let mut first_dst: HashMap<T, usize> = HashMap::new();
443    for (i, d) in dst.iter().enumerate() {
444        if let Some(&j) = first_dst.get(d) {
445            errors.push(dup_err(format!(
446                "{label}: duplicate dst {} at indices {j} and {i} \
447                 (two simultaneous transports into one site cannot both complete)",
448                fmt(d)
449            )));
450        } else {
451            first_dst.insert(*d, i);
452        }
453    }
454
455    // Cycle detection on the src→dst functional graph. With unique sources
456    // each node has at most one successor; with duplicates the first pair
457    // wins, matching first-match endpoint resolution.
458    let mut next: HashMap<T, T> = HashMap::new();
459    for (s, d) in src.iter().zip(dst.iter()) {
460        next.entry(*s).or_insert(*d);
461    }
462
463    // 0 = unvisited (absent), 1 = on the current walk, 2 = finished.
464    let mut state: HashMap<T, u8> = HashMap::new();
465    for start in src {
466        if state.get(start).copied() == Some(2) {
467            continue;
468        }
469        let mut walk: Vec<T> = Vec::new();
470        let mut cur = *start;
471        loop {
472            match state.get(&cur).copied() {
473                Some(2) => break,
474                Some(1) => {
475                    let pos = walk
476                        .iter()
477                        .position(|n| *n == cur)
478                        .expect("a node in state 1 is always on the current walk stack");
479                    let cycle: Vec<String> = walk[pos..]
480                        .iter()
481                        .chain(std::iter::once(&cur))
482                        .map(&fmt)
483                        .collect();
484                    errors.push(ArchSpecError::CyclicBus(format!(
485                        "{label}: src→dst relation contains a cycle: {} \
486                         (a bus is a set of explicit transports, never a rotation)",
487                        cycle.join(" -> ")
488                    )));
489                    break;
490                }
491                _ => {
492                    state.insert(cur, 1);
493                    walk.push(cur);
494                    match next.get(&cur) {
495                        Some(n) => cur = *n,
496                        None => break,
497                    }
498                }
499            }
500        }
501        for n in walk {
502            state.insert(n, 2);
503        }
504    }
505}
506
507// --- Per-zone entangling pair validation ---
508
509/// Word indices in entangling_pairs must be valid, distinct, and no duplicate pairs.
510fn check_zone_entangling_pairs(
511    zone_idx: usize,
512    zone: &super::types::Zone,
513    num_words: usize,
514    errors: &mut Vec<ArchSpecError>,
515) {
516    let mut seen: HashSet<[u32; 2]> = HashSet::new();
517    for (idx, pair) in zone.entangling_pairs.iter().enumerate() {
518        let [a, b] = *pair;
519        if a as usize >= num_words {
520            errors.push(ArchSpecError::EntanglingPair(format!(
521                "zone[{}].entangling_pairs[{}]: word ID {} >= num_words ({})",
522                zone_idx, idx, a, num_words
523            )));
524        }
525        if b as usize >= num_words {
526            errors.push(ArchSpecError::EntanglingPair(format!(
527                "zone[{}].entangling_pairs[{}]: word ID {} >= num_words ({})",
528                zone_idx, idx, b, num_words
529            )));
530        }
531        if a == b {
532            errors.push(ArchSpecError::EntanglingPair(format!(
533                "zone[{}].entangling_pairs[{}]: word paired with itself ({})",
534                zone_idx, idx, a
535            )));
536        }
537        let normalized = if a <= b { [a, b] } else { [b, a] };
538        if !seen.insert(normalized) {
539            errors.push(ArchSpecError::EntanglingPair(format!(
540                "zone[{}].entangling_pairs[{}]: duplicate pair [{}, {}]",
541                zone_idx, idx, a, b
542            )));
543        }
544    }
545}
546
547// --- Mode validation ---
548
549/// Zone indices and bitstring_order entries must be valid.
550fn check_modes(
551    spec: &ArchSpec,
552    num_zones: usize,
553    num_words: usize,
554    sites_per_word: usize,
555    errors: &mut Vec<ArchSpecError>,
556) {
557    for (mode_idx, mode) in spec.modes.iter().enumerate() {
558        for &zone_id in &mode.zones {
559            if zone_id as usize >= num_zones {
560                errors.push(ArchSpecError::Mode(format!(
561                    "mode '{}' (index {}): zone ID {} >= num_zones ({})",
562                    mode.name, mode_idx, zone_id, num_zones
563                )));
564            }
565        }
566        for (loc_idx, loc) in mode.bitstring_order.iter().enumerate() {
567            if loc.zone_id as usize >= num_zones {
568                errors.push(ArchSpecError::Mode(format!(
569                    "mode '{}' (index {}): bitstring_order[{}] zone_id {} >= num_zones ({})",
570                    mode.name, mode_idx, loc_idx, loc.zone_id, num_zones
571                )));
572            }
573            if loc.word_id as usize >= num_words {
574                errors.push(ArchSpecError::Mode(format!(
575                    "mode '{}' (index {}): bitstring_order[{}] word_id {} >= num_words ({})",
576                    mode.name, mode_idx, loc_idx, loc.word_id, num_words
577                )));
578            }
579            if loc.site_id as usize >= sites_per_word {
580                errors.push(ArchSpecError::Mode(format!(
581                    "mode '{}' (index {}): bitstring_order[{}] site_id {} >= sites_per_word ({})",
582                    mode.name, mode_idx, loc_idx, loc.site_id, sites_per_word
583                )));
584            }
585        }
586    }
587}
588
589// --- Path validation ---
590
591/// If paths is Some, validate each path's waypoints and lane address.
592fn check_paths(spec: &ArchSpec, num_zones: usize, errors: &mut Vec<ArchSpecError>) {
593    if let Some(paths) = &spec.paths {
594        for (idx, path) in paths.iter().enumerate() {
595            if !path.check_finite() {
596                errors.push(ArchSpecError::Path(format!(
597                    "paths[{}]: waypoint contains non-finite coordinate",
598                    idx
599                )));
600            }
601
602            // Validate the lane address fields
603            let lane = super::addr::LaneAddr::decode_u64(path.lane);
604            if lane.zone_id as usize >= num_zones {
605                errors.push(ArchSpecError::Path(format!(
606                    "paths[{}]: lane 0x{:016X} has invalid zone_id {} (num_zones={})",
607                    idx, path.lane, lane.zone_id, num_zones
608                )));
609            }
610
611            if path.waypoints.len() < 2 {
612                errors.push(ArchSpecError::Path(format!(
613                    "paths[{}]: lane 0x{:016X} has {} waypoint(s), minimum is 2",
614                    idx,
615                    path.lane,
616                    path.waypoints.len()
617                )));
618            }
619        }
620    }
621}
622
623/// Verify that no two zones have overlapping bounding boxes in physical
624/// (x, y) space. Overlap would produce ambiguous site positions and make
625/// inter-zone path generation impossible (#463).
626fn check_zone_overlap(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
627    let boxes: Vec<(usize, (f64, f64, f64, f64))> = spec
628        .zones
629        .iter()
630        .enumerate()
631        .map(|(i, z)| (i, z.grid.bounding_box()))
632        .collect();
633
634    for i in 0..boxes.len() {
635        for j in (i + 1)..boxes.len() {
636            let (zi, (ax_min, ax_max, ay_min, ay_max)) = boxes[i];
637            let (zj, (bx_min, bx_max, by_min, by_max)) = boxes[j];
638
639            // Two axis-aligned rectangles overlap iff they overlap on
640            // both axes independently.
641            let x_overlap = ax_min < bx_max && bx_min < ax_max;
642            let y_overlap = ay_min < by_max && by_min < ay_max;
643
644            if x_overlap && y_overlap {
645                errors.push(ArchSpecError::Structure(format!(
646                    "zone {} and zone {} have overlapping bounding boxes: \
647                     zone {} spans x=[{:.6}, {:.6}] y=[{:.6}, {:.6}], \
648                     zone {} spans x=[{:.6}, {:.6}] y=[{:.6}, {:.6}]",
649                    zi, zj, zi, ax_min, ax_max, ay_min, ay_max, zj, bx_min, bx_max, by_min, by_max,
650                )));
651            }
652        }
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use crate::arch::addr::{SiteRef, WordRef, ZonedWordRef};
660    use crate::arch::types::{Bus, Grid, Mode, Word, Zone};
661    use crate::version::Version;
662
663    /// Create a valid two-zone arch spec for testing.
664    fn make_valid_two_zone_spec() -> ArchSpec {
665        let grid0 = Grid::from_positions(&[0.0, 5.0, 10.0], &[0.0, 3.0]);
666        // Zone 1 grid must not overlap zone 0 (x=[0,10], y=[0,3]).
667        let grid1 = Grid::from_positions(&[20.0, 27.5, 35.0], &[0.0, 4.0]);
668
669        ArchSpec {
670            version: Version::new(2, 0),
671            words: vec![
672                Word {
673                    sites: vec![[0, 0], [0, 1]],
674                },
675                Word {
676                    sites: vec![[1, 0], [1, 1]],
677                },
678            ],
679            zones: vec![
680                Zone {
681                    name: String::new(),
682                    grid: grid0,
683                    site_buses: vec![Bus {
684                        src: vec![SiteRef(0)],
685                        dst: vec![SiteRef(1)],
686                    }],
687                    word_buses: vec![Bus {
688                        src: vec![WordRef(0)],
689                        dst: vec![WordRef(1)],
690                    }],
691                    words_with_site_buses: vec![0, 1],
692                    sites_with_word_buses: vec![0],
693                    entangling_pairs: vec![[0, 1]],
694                },
695                Zone {
696                    name: String::new(),
697                    grid: grid1,
698                    site_buses: vec![],
699                    word_buses: vec![],
700                    words_with_site_buses: vec![],
701                    sites_with_word_buses: vec![],
702                    entangling_pairs: vec![],
703                },
704            ],
705            zone_buses: vec![Bus {
706                src: vec![ZonedWordRef {
707                    zone_id: 0,
708                    word_id: 0,
709                }],
710                dst: vec![ZonedWordRef {
711                    zone_id: 1,
712                    word_id: 0,
713                }],
714            }],
715            modes: vec![Mode {
716                name: "full".to_string(),
717                zones: vec![0, 1],
718                bitstring_order: vec![],
719            }],
720            paths: None,
721            feed_forward: false,
722            atom_reloading: false,
723            blockade_radius: None,
724        }
725    }
726
727    #[test]
728    fn test_valid_two_zone_spec() {
729        let spec = make_valid_two_zone_spec();
730        assert!(spec.validate().is_ok());
731    }
732
733    #[test]
734    fn test_validate_zones_must_have_same_grid_dimensions() {
735        let mut spec = make_valid_two_zone_spec();
736        // 4 x-points vs 3
737        spec.zones[1].grid = Grid::from_positions(&[0.0, 1.0, 2.0, 3.0], &[0.0, 1.0]);
738        assert!(matches!(
739            spec.validate(),
740            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(_)))
741        ));
742    }
743
744    #[test]
745    fn test_validate_site_bus_ref_out_of_range() {
746        let mut spec = make_valid_two_zone_spec();
747        spec.zones[0].site_buses = vec![Bus {
748            src: vec![SiteRef(0)],
749            dst: vec![SiteRef(999)],
750        }];
751        assert!(matches!(
752            spec.validate(),
753            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::ZoneBus(_)))
754        ));
755    }
756
757    #[test]
758    fn test_validate_zone_bus_must_cross_zones() {
759        let mut spec = make_valid_two_zone_spec();
760        spec.zone_buses = vec![Bus {
761            src: vec![ZonedWordRef {
762                zone_id: 0,
763                word_id: 0,
764            }],
765            dst: vec![ZonedWordRef {
766                zone_id: 0,
767                word_id: 1,
768            }], // same zone!
769        }];
770        assert!(matches!(
771            spec.validate(),
772            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::InterZoneBus(_)))
773        ));
774    }
775
776    /// Extend the fixture with a third word so chains (`0→1, 1→2`) are
777    /// expressible in word buses.
778    fn add_third_word(spec: &mut ArchSpec) {
779        spec.words.push(Word {
780            sites: vec![[2, 0], [2, 1]],
781        });
782        spec.zones[0].words_with_site_buses.push(2);
783    }
784
785    #[test]
786    fn test_validate_overlapping_acyclic_bus_accepted() {
787        let mut spec = make_valid_two_zone_spec();
788        add_third_word(&mut spec);
789        // Conveyor chain 0→1, 1→2: dst set overlaps src set, no cycle.
790        spec.zones[0].word_buses = vec![Bus {
791            src: vec![WordRef(0), WordRef(1)],
792            dst: vec![WordRef(1), WordRef(2)],
793        }];
794        assert!(spec.validate().is_ok());
795    }
796
797    #[test]
798    fn test_validate_cyclic_site_bus_rejected() {
799        let mut spec = make_valid_two_zone_spec();
800        // 0→1, 1→0: a rotation.
801        spec.zones[0].site_buses = vec![Bus {
802            src: vec![SiteRef(0), SiteRef(1)],
803            dst: vec![SiteRef(1), SiteRef(0)],
804        }];
805        assert!(matches!(
806            spec.validate(),
807            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::CyclicBus(_)))
808        ));
809    }
810
811    #[test]
812    fn test_validate_self_loop_site_bus_rejected() {
813        let mut spec = make_valid_two_zone_spec();
814        spec.zones[0].site_buses = vec![Bus {
815            src: vec![SiteRef(0)],
816            dst: vec![SiteRef(0)],
817        }];
818        assert!(matches!(
819            spec.validate(),
820            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::CyclicBus(_)))
821        ));
822    }
823
824    #[test]
825    fn test_validate_duplicate_bus_src_rejected() {
826        let mut spec = make_valid_two_zone_spec();
827        add_third_word(&mut spec);
828        // Duplicate src 0; acyclic and dst-unique so only the src rule fires.
829        spec.zones[0].word_buses = vec![Bus {
830            src: vec![WordRef(0), WordRef(0)],
831            dst: vec![WordRef(1), WordRef(2)],
832        }];
833        assert!(matches!(
834            spec.validate(),
835            Err(ref errs) if errs.iter().any(
836                |e| matches!(e, ArchSpecError::ZoneBus(msg) if msg.contains("duplicate src"))
837            )
838        ));
839    }
840
841    #[test]
842    fn test_validate_duplicate_bus_dst_rejected() {
843        let mut spec = make_valid_two_zone_spec();
844        add_third_word(&mut spec);
845        // Duplicate dst 1; acyclic and src-unique so only the dst rule fires.
846        spec.zones[0].word_buses = vec![Bus {
847            src: vec![WordRef(0), WordRef(2)],
848            dst: vec![WordRef(1), WordRef(1)],
849        }];
850        assert!(matches!(
851            spec.validate(),
852            Err(ref errs) if errs.iter().any(
853                |e| matches!(e, ArchSpecError::ZoneBus(msg) if msg.contains("duplicate dst"))
854            )
855        ));
856    }
857
858    #[test]
859    fn test_validate_cyclic_zone_bus_rejected() {
860        let mut spec = make_valid_two_zone_spec();
861        // A 2-cycle across zones: every pair crosses a zone boundary, so the
862        // inter-zone rule passes; only the acyclicity rule can catch it.
863        let z0 = ZonedWordRef {
864            zone_id: 0,
865            word_id: 0,
866        };
867        let z1 = ZonedWordRef {
868            zone_id: 1,
869            word_id: 0,
870        };
871        spec.zone_buses = vec![Bus {
872            src: vec![z0, z1],
873            dst: vec![z1, z0],
874        }];
875        assert!(matches!(
876            spec.validate(),
877            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::CyclicBus(_)))
878        ));
879    }
880
881    #[test]
882    fn test_validate_entangling_pair_invalid_word() {
883        let mut spec = make_valid_two_zone_spec();
884        spec.zones[0].entangling_pairs = vec![[0, 99]];
885        assert!(matches!(
886            spec.validate(),
887            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::EntanglingPair(_)))
888        ));
889    }
890
891    #[test]
892    fn test_validate_mode_invalid_zone() {
893        let mut spec = make_valid_two_zone_spec();
894        spec.modes = vec![Mode {
895            name: "bad".to_string(),
896            zones: vec![99],
897            bitstring_order: vec![],
898        }];
899        assert!(matches!(
900            spec.validate(),
901            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Mode(_)))
902        ));
903    }
904
905    #[test]
906    fn test_validate_no_zones() {
907        let mut spec = make_valid_two_zone_spec();
908        spec.zones = vec![];
909        assert!(matches!(
910            spec.validate(),
911            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(msg) if msg.contains("zone")))
912        ));
913    }
914
915    #[test]
916    fn test_validate_no_words() {
917        let mut spec = make_valid_two_zone_spec();
918        spec.words = vec![];
919        assert!(matches!(
920            spec.validate(),
921            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(msg) if msg.contains("word")))
922        ));
923    }
924
925    #[test]
926    fn test_validate_word_site_count_mismatch() {
927        let mut spec = make_valid_two_zone_spec();
928        spec.words[1].sites = vec![[0, 0]]; // 1 site vs 2
929        assert!(matches!(
930            spec.validate(),
931            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(msg) if msg.contains("sites")))
932        ));
933    }
934
935    #[test]
936    fn test_validate_word_site_x_out_of_range() {
937        let mut spec = make_valid_two_zone_spec();
938        spec.words[0].sites[0] = [99, 0]; // x=99 but grid has 3 x-positions
939        assert!(matches!(
940            spec.validate(),
941            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(msg) if msg.contains("x index")))
942        ));
943    }
944
945    #[test]
946    fn test_validate_word_site_y_out_of_range() {
947        let mut spec = make_valid_two_zone_spec();
948        spec.words[0].sites[0] = [0, 99]; // y=99 but grid has 2 y-positions
949        assert!(matches!(
950            spec.validate(),
951            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(msg) if msg.contains("y index")))
952        ));
953    }
954
955    #[test]
956    fn test_validate_zone_words_with_site_buses_invalid() {
957        let mut spec = make_valid_two_zone_spec();
958        spec.zones[0].words_with_site_buses = vec![0, 99];
959        assert!(matches!(
960            spec.validate(),
961            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::ZoneBus(msg) if msg.contains("words_with_site_buses")))
962        ));
963    }
964
965    #[test]
966    fn test_validate_zone_sites_with_word_buses_invalid() {
967        let mut spec = make_valid_two_zone_spec();
968        spec.zones[0].sites_with_word_buses = vec![99];
969        assert!(matches!(
970            spec.validate(),
971            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::ZoneBus(msg) if msg.contains("sites_with_word_buses")))
972        ));
973    }
974
975    #[test]
976    fn test_validate_site_bus_length_mismatch() {
977        let mut spec = make_valid_two_zone_spec();
978        spec.zones[0].site_buses = vec![Bus {
979            src: vec![SiteRef(0), SiteRef(1)],
980            dst: vec![SiteRef(0)],
981        }];
982        assert!(matches!(
983            spec.validate(),
984            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::ZoneBus(msg) if msg.contains("src length")))
985        ));
986    }
987
988    #[test]
989    fn test_validate_word_bus_invalid_word_ref() {
990        let mut spec = make_valid_two_zone_spec();
991        spec.zones[0].word_buses = vec![Bus {
992            src: vec![WordRef(0)],
993            dst: vec![WordRef(99)],
994        }];
995        assert!(matches!(
996            spec.validate(),
997            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::ZoneBus(msg) if msg.contains("WordRef(99)")))
998        ));
999    }
1000
1001    #[test]
1002    fn test_validate_zone_bus_invalid_zone_id() {
1003        let mut spec = make_valid_two_zone_spec();
1004        spec.zone_buses = vec![Bus {
1005            src: vec![ZonedWordRef {
1006                zone_id: 99,
1007                word_id: 0,
1008            }],
1009            dst: vec![ZonedWordRef {
1010                zone_id: 1,
1011                word_id: 0,
1012            }],
1013        }];
1014        assert!(matches!(
1015            spec.validate(),
1016            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::InterZoneBus(msg) if msg.contains("zone_id 99")))
1017        ));
1018    }
1019
1020    #[test]
1021    fn test_validate_zone_bus_invalid_word_id() {
1022        let mut spec = make_valid_two_zone_spec();
1023        spec.zone_buses = vec![Bus {
1024            src: vec![ZonedWordRef {
1025                zone_id: 0,
1026                word_id: 99,
1027            }],
1028            dst: vec![ZonedWordRef {
1029                zone_id: 1,
1030                word_id: 0,
1031            }],
1032        }];
1033        assert!(matches!(
1034            spec.validate(),
1035            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::InterZoneBus(msg) if msg.contains("word_id 99")))
1036        ));
1037    }
1038
1039    #[test]
1040    fn test_validate_zone_bus_length_mismatch() {
1041        let mut spec = make_valid_two_zone_spec();
1042        spec.zone_buses = vec![Bus {
1043            src: vec![
1044                ZonedWordRef {
1045                    zone_id: 0,
1046                    word_id: 0,
1047                },
1048                ZonedWordRef {
1049                    zone_id: 0,
1050                    word_id: 1,
1051                },
1052            ],
1053            dst: vec![ZonedWordRef {
1054                zone_id: 1,
1055                word_id: 0,
1056            }],
1057        }];
1058        assert!(matches!(
1059            spec.validate(),
1060            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::InterZoneBus(msg) if msg.contains("src length")))
1061        ));
1062    }
1063
1064    #[test]
1065    fn test_validate_entangling_pair_duplicate() {
1066        let mut spec = make_valid_two_zone_spec();
1067        spec.zones[0].entangling_pairs = vec![[0, 1], [1, 0]]; // same pair reversed
1068        assert!(matches!(
1069            spec.validate(),
1070            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::EntanglingPair(msg) if msg.contains("duplicate")))
1071        ));
1072    }
1073
1074    #[test]
1075    fn test_validate_mode_bitstring_order_invalid() {
1076        use crate::arch::addr::LocationAddr;
1077        let mut spec = make_valid_two_zone_spec();
1078        spec.modes = vec![Mode {
1079            name: "bad_loc".to_string(),
1080            zones: vec![0],
1081            bitstring_order: vec![LocationAddr {
1082                zone_id: 99,
1083                word_id: 0,
1084                site_id: 0,
1085            }],
1086        }];
1087        assert!(matches!(
1088            spec.validate(),
1089            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Mode(msg) if msg.contains("zone_id 99")))
1090        ));
1091    }
1092
1093    #[test]
1094    fn test_validate_multiple_errors_collected() {
1095        let mut spec = make_valid_two_zone_spec();
1096        // Break multiple things
1097        spec.zones[0].entangling_pairs = vec![[0, 99]]; // bad word
1098        spec.zones[0].words_with_site_buses = vec![99]; // bad word
1099        let errors = spec.validate().unwrap_err();
1100        assert!(
1101            errors.len() >= 2,
1102            "expected at least 2 errors, got {}",
1103            errors.len()
1104        );
1105    }
1106
1107    #[test]
1108    fn test_validate_path_non_finite_waypoint() {
1109        let mut spec = make_valid_two_zone_spec();
1110        let lane = crate::arch::addr::LaneAddr {
1111            direction: crate::arch::addr::Direction::Forward,
1112            move_type: crate::arch::addr::MoveType::SiteBus,
1113            zone_id: 0,
1114            word_id: 0,
1115            site_id: 0,
1116            bus_id: 0,
1117        };
1118        spec.paths = Some(vec![crate::arch::types::TransportPath {
1119            lane: lane.encode_u64(),
1120            waypoints: vec![[f64::NAN, 0.0], [1.0, 2.0]],
1121        }]);
1122        assert!(matches!(
1123            spec.validate(),
1124            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Path(msg) if msg.contains("non-finite")))
1125        ));
1126    }
1127
1128    #[test]
1129    fn test_validate_path_too_few_waypoints() {
1130        let mut spec = make_valid_two_zone_spec();
1131        let lane = crate::arch::addr::LaneAddr {
1132            direction: crate::arch::addr::Direction::Forward,
1133            move_type: crate::arch::addr::MoveType::SiteBus,
1134            zone_id: 0,
1135            word_id: 0,
1136            site_id: 0,
1137            bus_id: 0,
1138        };
1139        spec.paths = Some(vec![crate::arch::types::TransportPath {
1140            lane: lane.encode_u64(),
1141            waypoints: vec![[1.0, 2.0]],
1142        }]);
1143        assert!(matches!(
1144            spec.validate(),
1145            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Path(msg) if msg.contains("minimum is 2")))
1146        ));
1147    }
1148
1149    #[test]
1150    fn test_validate_path_invalid_zone_id() {
1151        let mut spec = make_valid_two_zone_spec();
1152        let lane = crate::arch::addr::LaneAddr {
1153            direction: crate::arch::addr::Direction::Forward,
1154            move_type: crate::arch::addr::MoveType::SiteBus,
1155            zone_id: 99,
1156            word_id: 0,
1157            site_id: 0,
1158            bus_id: 0,
1159        };
1160        spec.paths = Some(vec![crate::arch::types::TransportPath {
1161            lane: lane.encode_u64(),
1162            waypoints: vec![[0.0, 0.0], [1.0, 2.0]],
1163        }]);
1164        assert!(matches!(
1165            spec.validate(),
1166            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Path(msg) if msg.contains("zone_id")))
1167        ));
1168    }
1169
1170    // ── Zone overlap tests (#463) ──
1171
1172    #[test]
1173    fn test_non_overlapping_zones_pass() {
1174        // The default fixture has non-overlapping zones (zone 0 at x=[0,10],
1175        // zone 1 at x=[20,35]).
1176        let spec = make_valid_two_zone_spec();
1177        assert!(spec.validate().is_ok());
1178    }
1179
1180    #[test]
1181    fn test_overlapping_zones_rejected() {
1182        let mut spec = make_valid_two_zone_spec();
1183        // Move zone 1's grid to overlap zone 0 (x=[0,10], y=[0,3]).
1184        spec.zones[1].grid = Grid::from_positions(&[5.0, 12.5, 20.0], &[0.0, 4.0]);
1185        assert!(matches!(
1186            spec.validate(),
1187            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(msg) if msg.contains("overlapping bounding boxes")))
1188        ));
1189    }
1190
1191    #[test]
1192    fn test_adjacent_zones_not_overlapping() {
1193        let mut spec = make_valid_two_zone_spec();
1194        // Zone 1 starts exactly where zone 0 ends (touching but not overlapping).
1195        spec.zones[1].grid = Grid::from_positions(&[10.0, 17.5, 25.0], &[0.0, 4.0]);
1196        assert!(spec.validate().is_ok());
1197    }
1198
1199    #[test]
1200    fn test_shared_x_range_with_y_overlap_rejected() {
1201        let mut spec = make_valid_two_zone_spec();
1202        // Zones share an x range and have overlapping y ranges.
1203        spec.zones[1].grid = Grid::from_positions(&[0.0, 7.5, 15.0], &[1.0, 5.0]);
1204        // Zone 0: x=[0,10], y=[0,3]; Zone 1: x=[0,15], y=[1,5] → both axes overlap.
1205        assert!(matches!(
1206            spec.validate(),
1207            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(msg) if msg.contains("overlapping")))
1208        ));
1209    }
1210
1211    #[test]
1212    fn test_single_zone_no_overlap_check() {
1213        // With a single zone, there's nothing to compare.
1214        let mut spec = make_valid_two_zone_spec();
1215        spec.zones.pop();
1216        spec.zone_buses.clear();
1217        spec.modes[0].zones = vec![0];
1218        assert!(spec.validate().is_ok());
1219    }
1220}