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::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    /// Grid invariant error (bus src/dst not rectangular).
31    #[error("{0}")]
32    GridInvariant(String),
33
34    /// Entangling zone pair validation error.
35    #[error("{0}")]
36    EntanglingPair(String),
37
38    /// Mode validation error.
39    #[error("{0}")]
40    Mode(String),
41
42    /// Transport path validation error.
43    #[error("{0}")]
44    Path(String),
45}
46
47impl ArchSpec {
48    /// Validate the arch spec against all structural rules.
49    /// Collects all errors in one pass (not fail-fast).
50    pub fn validate(&self) -> Result<(), Vec<ArchSpecError>> {
51        let mut errors = Vec::new();
52
53        let num_words = self.words.len();
54        let num_zones = self.zones.len();
55        let sites_per_word = self.sites_per_word();
56
57        // Structural invariants
58        check_minimum_counts(self, &mut errors);
59        check_non_negative_grid_spacings(self, &mut errors);
60        check_uniform_grid_dimensions(self, &mut errors);
61        check_uniform_word_site_counts(self, &mut errors);
62        check_word_site_indices(self, &mut errors);
63
64        // Per-zone bus and entangling pair validation
65        for (zone_idx, zone) in self.zones.iter().enumerate() {
66            check_zone_words_with_site_buses(zone_idx, zone, num_words, &mut errors);
67            check_zone_sites_with_word_buses(zone_idx, zone, sites_per_word, &mut errors);
68            check_zone_site_buses(zone_idx, zone, sites_per_word, &mut errors);
69            check_zone_word_buses(zone_idx, zone, num_words, &mut errors);
70            check_zone_entangling_pairs(zone_idx, zone, num_words, &mut errors);
71        }
72
73        // Inter-zone bus validation
74        check_zone_buses(self, num_zones, num_words, &mut errors);
75
76        // Mode validation
77        check_modes(self, num_zones, num_words, sites_per_word, &mut errors);
78
79        // Path validation
80        check_paths(self, num_zones, &mut errors);
81
82        // Zone physical-space overlap
83        check_zone_overlap(self, &mut errors);
84
85        if errors.is_empty() {
86            Ok(())
87        } else {
88            Err(errors)
89        }
90    }
91}
92
93/// At least one zone and one word must exist.
94fn check_minimum_counts(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
95    if spec.zones.is_empty() {
96        errors.push(ArchSpecError::Structure(
97            "at least one zone must exist".into(),
98        ));
99    }
100    if spec.words.is_empty() {
101        errors.push(ArchSpecError::Structure(
102            "at least one word must exist".into(),
103        ));
104    }
105}
106
107/// All grid spacings must be non-negative. Negative spacings would produce
108/// non-monotonic positions, breaking the `Grid::bounding_box()` invariant
109/// and making position lookups ambiguous.
110fn check_non_negative_grid_spacings(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
111    for (idx, zone) in spec.zones.iter().enumerate() {
112        if let Some(pos) = zone.grid.x_spacing.iter().position(|&v| v < 0.0) {
113            errors.push(ArchSpecError::Structure(format!(
114                "zone {} grid x_spacing[{}] is negative ({:.6})",
115                idx, pos, zone.grid.x_spacing[pos]
116            )));
117        }
118        if let Some(pos) = zone.grid.y_spacing.iter().position(|&v| v < 0.0) {
119            errors.push(ArchSpecError::Structure(format!(
120                "zone {} grid y_spacing[{}] is negative ({:.6})",
121                idx, pos, zone.grid.y_spacing[pos]
122            )));
123        }
124    }
125}
126
127/// All zones must have the same grid dimensions (same num_x and num_y).
128fn check_uniform_grid_dimensions(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
129    if let Some(first_zone) = spec.zones.first() {
130        let ref_x = first_zone.grid.num_x();
131        let ref_y = first_zone.grid.num_y();
132        for (idx, zone) in spec.zones.iter().enumerate().skip(1) {
133            let zx = zone.grid.num_x();
134            let zy = zone.grid.num_y();
135            if zx != ref_x || zy != ref_y {
136                errors.push(ArchSpecError::Structure(format!(
137                    "zone {} grid dimensions ({}x{}) differ from zone 0 ({}x{})",
138                    idx, zx, zy, ref_x, ref_y
139                )));
140            }
141        }
142    }
143}
144
145/// All words must have the same number of sites.
146fn check_uniform_word_site_counts(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
147    if let Some(first_word) = spec.words.first() {
148        let ref_count = first_word.sites.len();
149        for (idx, word) in spec.words.iter().enumerate().skip(1) {
150            if word.sites.len() != ref_count {
151                errors.push(ArchSpecError::Structure(format!(
152                    "word {} has {} sites, expected {} (same as word 0)",
153                    idx,
154                    word.sites.len(),
155                    ref_count
156                )));
157            }
158        }
159    }
160}
161
162/// Word site indices must be within the grid dimensions of every zone.
163/// For each site [x, y]: x < grid.num_x() and y < grid.num_y().
164fn check_word_site_indices(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
165    // Use zone 0's grid as the reference (uniform dimensions already checked).
166    let (grid_x, grid_y) = match spec.zones.first() {
167        Some(z) => (z.grid.num_x(), z.grid.num_y()),
168        None => return, // no zones → nothing to check
169    };
170
171    for (word_idx, word) in spec.words.iter().enumerate() {
172        for (site_idx, site) in word.sites.iter().enumerate() {
173            let x = site[0] as usize;
174            let y = site[1] as usize;
175            if x >= grid_x {
176                errors.push(ArchSpecError::Structure(format!(
177                    "word {}, site {}: x index {} out of range (grid has {} x-positions)",
178                    word_idx, site_idx, site[0], grid_x
179                )));
180            }
181            if y >= grid_y {
182                errors.push(ArchSpecError::Structure(format!(
183                    "word {}, site {}: y index {} out of range (grid has {} y-positions)",
184                    word_idx, site_idx, site[1], grid_y
185                )));
186            }
187        }
188    }
189}
190
191// --- Per-zone bus validation ---
192
193use super::types::Zone;
194
195/// `words_with_site_buses` entries must be < number of words.
196fn check_zone_words_with_site_buses(
197    zone_idx: usize,
198    zone: &Zone,
199    num_words: usize,
200    errors: &mut Vec<ArchSpecError>,
201) {
202    for &wid in &zone.words_with_site_buses {
203        if wid as usize >= num_words {
204            errors.push(ArchSpecError::ZoneBus(format!(
205                "zone {}: words_with_site_buses contains invalid word ID {}",
206                zone_idx, wid
207            )));
208        }
209    }
210}
211
212/// `sites_with_word_buses` entries must be valid site indices.
213fn check_zone_sites_with_word_buses(
214    zone_idx: usize,
215    zone: &Zone,
216    sites_per_word: usize,
217    errors: &mut Vec<ArchSpecError>,
218) {
219    for &sid in &zone.sites_with_word_buses {
220        if sid as usize >= sites_per_word {
221            errors.push(ArchSpecError::ZoneBus(format!(
222                "zone {}: sites_with_word_buses contains invalid site index {} (sites_per_word={})",
223                zone_idx, sid, sites_per_word
224            )));
225        }
226    }
227}
228
229/// Site bus src/dst must have same length and SiteRef values < sites_per_word.
230fn check_zone_site_buses(
231    zone_idx: usize,
232    zone: &Zone,
233    sites_per_word: usize,
234    errors: &mut Vec<ArchSpecError>,
235) {
236    for (bus_idx, bus) in zone.site_buses.iter().enumerate() {
237        if bus.src.len() != bus.dst.len() {
238            errors.push(ArchSpecError::ZoneBus(format!(
239                "zone {}, site_bus {}: src length ({}) != dst length ({})",
240                zone_idx,
241                bus_idx,
242                bus.src.len(),
243                bus.dst.len()
244            )));
245        }
246        for (i, sref) in bus.src.iter().enumerate() {
247            if sref.0 as usize >= sites_per_word {
248                errors.push(ArchSpecError::ZoneBus(format!(
249                    "zone {}, site_bus {}: src[{}] SiteRef({}) >= sites_per_word ({})",
250                    zone_idx, bus_idx, i, sref.0, sites_per_word
251                )));
252            }
253        }
254        for (i, sref) in bus.dst.iter().enumerate() {
255            if sref.0 as usize >= sites_per_word {
256                errors.push(ArchSpecError::ZoneBus(format!(
257                    "zone {}, site_bus {}: dst[{}] SiteRef({}) >= sites_per_word ({})",
258                    zone_idx, bus_idx, i, sref.0, sites_per_word
259                )));
260            }
261        }
262    }
263}
264
265/// Word bus src/dst must have same length and WordRef values < number of words.
266fn check_zone_word_buses(
267    zone_idx: usize,
268    zone: &Zone,
269    num_words: usize,
270    errors: &mut Vec<ArchSpecError>,
271) {
272    for (bus_idx, bus) in zone.word_buses.iter().enumerate() {
273        if bus.src.len() != bus.dst.len() {
274            errors.push(ArchSpecError::ZoneBus(format!(
275                "zone {}, word_bus {}: src length ({}) != dst length ({})",
276                zone_idx,
277                bus_idx,
278                bus.src.len(),
279                bus.dst.len()
280            )));
281        }
282        for (i, wref) in bus.src.iter().enumerate() {
283            if wref.0 as usize >= num_words {
284                errors.push(ArchSpecError::ZoneBus(format!(
285                    "zone {}, word_bus {}: src[{}] WordRef({}) >= num_words ({})",
286                    zone_idx, bus_idx, i, wref.0, num_words
287                )));
288            }
289        }
290        for (i, wref) in bus.dst.iter().enumerate() {
291            if wref.0 as usize >= num_words {
292                errors.push(ArchSpecError::ZoneBus(format!(
293                    "zone {}, word_bus {}: dst[{}] WordRef({}) >= num_words ({})",
294                    zone_idx, bus_idx, i, wref.0, num_words
295                )));
296            }
297        }
298    }
299}
300
301// --- Inter-zone bus validation ---
302
303/// Zone bus entries must have valid zone_id and word_id, src/dst same length,
304/// and every pair must cross a zone boundary.
305fn check_zone_buses(
306    spec: &ArchSpec,
307    num_zones: usize,
308    num_words: usize,
309    errors: &mut Vec<ArchSpecError>,
310) {
311    for (bus_idx, bus) in spec.zone_buses.iter().enumerate() {
312        if bus.src.len() != bus.dst.len() {
313            errors.push(ArchSpecError::InterZoneBus(format!(
314                "zone_bus {}: src length ({}) != dst length ({})",
315                bus_idx,
316                bus.src.len(),
317                bus.dst.len()
318            )));
319        }
320
321        // Validate all ZonedWordRef entries
322        for (i, zwr) in bus.src.iter().enumerate() {
323            if zwr.zone_id as usize >= num_zones {
324                errors.push(ArchSpecError::InterZoneBus(format!(
325                    "zone_bus {}: src[{}] zone_id {} >= num_zones ({})",
326                    bus_idx, i, zwr.zone_id, num_zones
327                )));
328            }
329            if zwr.word_id as usize >= num_words {
330                errors.push(ArchSpecError::InterZoneBus(format!(
331                    "zone_bus {}: src[{}] word_id {} >= num_words ({})",
332                    bus_idx, i, zwr.word_id, num_words
333                )));
334            }
335        }
336        for (i, zwr) in bus.dst.iter().enumerate() {
337            if zwr.zone_id as usize >= num_zones {
338                errors.push(ArchSpecError::InterZoneBus(format!(
339                    "zone_bus {}: dst[{}] zone_id {} >= num_zones ({})",
340                    bus_idx, i, zwr.zone_id, num_zones
341                )));
342            }
343            if zwr.word_id as usize >= num_words {
344                errors.push(ArchSpecError::InterZoneBus(format!(
345                    "zone_bus {}: dst[{}] word_id {} >= num_words ({})",
346                    bus_idx, i, zwr.word_id, num_words
347                )));
348            }
349        }
350
351        // Every (src[i], dst[i]) pair must cross a zone boundary
352        let pair_count = bus.src.len().min(bus.dst.len());
353        for i in 0..pair_count {
354            if bus.src[i].zone_id == bus.dst[i].zone_id {
355                errors.push(ArchSpecError::InterZoneBus(format!(
356                    "zone_bus {}: pair {} does not cross a zone boundary \
357                     (src zone_id={}, dst zone_id={})",
358                    bus_idx, i, bus.src[i].zone_id, bus.dst[i].zone_id
359                )));
360            }
361        }
362    }
363}
364
365// --- Per-zone entangling pair validation ---
366
367/// Word indices in entangling_pairs must be valid, distinct, and no duplicate pairs.
368fn check_zone_entangling_pairs(
369    zone_idx: usize,
370    zone: &super::types::Zone,
371    num_words: usize,
372    errors: &mut Vec<ArchSpecError>,
373) {
374    let mut seen: HashSet<[u32; 2]> = HashSet::new();
375    for (idx, pair) in zone.entangling_pairs.iter().enumerate() {
376        let [a, b] = *pair;
377        if a as usize >= num_words {
378            errors.push(ArchSpecError::EntanglingPair(format!(
379                "zone[{}].entangling_pairs[{}]: word ID {} >= num_words ({})",
380                zone_idx, idx, a, num_words
381            )));
382        }
383        if b as usize >= num_words {
384            errors.push(ArchSpecError::EntanglingPair(format!(
385                "zone[{}].entangling_pairs[{}]: word ID {} >= num_words ({})",
386                zone_idx, idx, b, num_words
387            )));
388        }
389        if a == b {
390            errors.push(ArchSpecError::EntanglingPair(format!(
391                "zone[{}].entangling_pairs[{}]: word paired with itself ({})",
392                zone_idx, idx, a
393            )));
394        }
395        let normalized = if a <= b { [a, b] } else { [b, a] };
396        if !seen.insert(normalized) {
397            errors.push(ArchSpecError::EntanglingPair(format!(
398                "zone[{}].entangling_pairs[{}]: duplicate pair [{}, {}]",
399                zone_idx, idx, a, b
400            )));
401        }
402    }
403}
404
405// --- Mode validation ---
406
407/// Zone indices and bitstring_order entries must be valid.
408fn check_modes(
409    spec: &ArchSpec,
410    num_zones: usize,
411    num_words: usize,
412    sites_per_word: usize,
413    errors: &mut Vec<ArchSpecError>,
414) {
415    for (mode_idx, mode) in spec.modes.iter().enumerate() {
416        for &zone_id in &mode.zones {
417            if zone_id as usize >= num_zones {
418                errors.push(ArchSpecError::Mode(format!(
419                    "mode '{}' (index {}): zone ID {} >= num_zones ({})",
420                    mode.name, mode_idx, zone_id, num_zones
421                )));
422            }
423        }
424        for (loc_idx, loc) in mode.bitstring_order.iter().enumerate() {
425            if loc.zone_id as usize >= num_zones {
426                errors.push(ArchSpecError::Mode(format!(
427                    "mode '{}' (index {}): bitstring_order[{}] zone_id {} >= num_zones ({})",
428                    mode.name, mode_idx, loc_idx, loc.zone_id, num_zones
429                )));
430            }
431            if loc.word_id as usize >= num_words {
432                errors.push(ArchSpecError::Mode(format!(
433                    "mode '{}' (index {}): bitstring_order[{}] word_id {} >= num_words ({})",
434                    mode.name, mode_idx, loc_idx, loc.word_id, num_words
435                )));
436            }
437            if loc.site_id as usize >= sites_per_word {
438                errors.push(ArchSpecError::Mode(format!(
439                    "mode '{}' (index {}): bitstring_order[{}] site_id {} >= sites_per_word ({})",
440                    mode.name, mode_idx, loc_idx, loc.site_id, sites_per_word
441                )));
442            }
443        }
444    }
445}
446
447// --- Path validation ---
448
449/// If paths is Some, validate each path's waypoints and lane address.
450fn check_paths(spec: &ArchSpec, num_zones: usize, errors: &mut Vec<ArchSpecError>) {
451    if let Some(paths) = &spec.paths {
452        for (idx, path) in paths.iter().enumerate() {
453            if !path.check_finite() {
454                errors.push(ArchSpecError::Path(format!(
455                    "paths[{}]: waypoint contains non-finite coordinate",
456                    idx
457                )));
458            }
459
460            // Validate the lane address fields
461            let lane = super::addr::LaneAddr::decode_u64(path.lane);
462            if lane.zone_id as usize >= num_zones {
463                errors.push(ArchSpecError::Path(format!(
464                    "paths[{}]: lane 0x{:016X} has invalid zone_id {} (num_zones={})",
465                    idx, path.lane, lane.zone_id, num_zones
466                )));
467            }
468
469            if path.waypoints.len() < 2 {
470                errors.push(ArchSpecError::Path(format!(
471                    "paths[{}]: lane 0x{:016X} has {} waypoint(s), minimum is 2",
472                    idx,
473                    path.lane,
474                    path.waypoints.len()
475                )));
476            }
477        }
478    }
479}
480
481/// Verify that no two zones have overlapping bounding boxes in physical
482/// (x, y) space. Overlap would produce ambiguous site positions and make
483/// inter-zone path generation impossible (#463).
484fn check_zone_overlap(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
485    let boxes: Vec<(usize, (f64, f64, f64, f64))> = spec
486        .zones
487        .iter()
488        .enumerate()
489        .map(|(i, z)| (i, z.grid.bounding_box()))
490        .collect();
491
492    for i in 0..boxes.len() {
493        for j in (i + 1)..boxes.len() {
494            let (zi, (ax_min, ax_max, ay_min, ay_max)) = boxes[i];
495            let (zj, (bx_min, bx_max, by_min, by_max)) = boxes[j];
496
497            // Two axis-aligned rectangles overlap iff they overlap on
498            // both axes independently.
499            let x_overlap = ax_min < bx_max && bx_min < ax_max;
500            let y_overlap = ay_min < by_max && by_min < ay_max;
501
502            if x_overlap && y_overlap {
503                errors.push(ArchSpecError::Structure(format!(
504                    "zone {} and zone {} have overlapping bounding boxes: \
505                     zone {} spans x=[{:.6}, {:.6}] y=[{:.6}, {:.6}], \
506                     zone {} spans x=[{:.6}, {:.6}] y=[{:.6}, {:.6}]",
507                    zi, zj, zi, ax_min, ax_max, ay_min, ay_max, zj, bx_min, bx_max, by_min, by_max,
508                )));
509            }
510        }
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use crate::arch::addr::{SiteRef, WordRef, ZonedWordRef};
518    use crate::arch::types::{Bus, Grid, Mode, Word, Zone};
519    use crate::version::Version;
520
521    /// Create a valid two-zone arch spec for testing.
522    fn make_valid_two_zone_spec() -> ArchSpec {
523        let grid0 = Grid::from_positions(&[0.0, 5.0, 10.0], &[0.0, 3.0]);
524        // Zone 1 grid must not overlap zone 0 (x=[0,10], y=[0,3]).
525        let grid1 = Grid::from_positions(&[20.0, 27.5, 35.0], &[0.0, 4.0]);
526
527        ArchSpec {
528            version: Version::new(2, 0),
529            words: vec![
530                Word {
531                    sites: vec![[0, 0], [0, 1]],
532                },
533                Word {
534                    sites: vec![[1, 0], [1, 1]],
535                },
536            ],
537            zones: vec![
538                Zone {
539                    name: String::new(),
540                    grid: grid0,
541                    site_buses: vec![Bus {
542                        src: vec![SiteRef(0)],
543                        dst: vec![SiteRef(1)],
544                    }],
545                    word_buses: vec![Bus {
546                        src: vec![WordRef(0)],
547                        dst: vec![WordRef(1)],
548                    }],
549                    words_with_site_buses: vec![0, 1],
550                    sites_with_word_buses: vec![0],
551                    entangling_pairs: vec![[0, 1]],
552                },
553                Zone {
554                    name: String::new(),
555                    grid: grid1,
556                    site_buses: vec![],
557                    word_buses: vec![],
558                    words_with_site_buses: vec![],
559                    sites_with_word_buses: vec![],
560                    entangling_pairs: vec![],
561                },
562            ],
563            zone_buses: vec![Bus {
564                src: vec![ZonedWordRef {
565                    zone_id: 0,
566                    word_id: 0,
567                }],
568                dst: vec![ZonedWordRef {
569                    zone_id: 1,
570                    word_id: 0,
571                }],
572            }],
573            modes: vec![Mode {
574                name: "full".to_string(),
575                zones: vec![0, 1],
576                bitstring_order: vec![],
577            }],
578            paths: None,
579            feed_forward: false,
580            atom_reloading: false,
581            blockade_radius: None,
582        }
583    }
584
585    #[test]
586    fn test_valid_two_zone_spec() {
587        let spec = make_valid_two_zone_spec();
588        assert!(spec.validate().is_ok());
589    }
590
591    #[test]
592    fn test_validate_zones_must_have_same_grid_dimensions() {
593        let mut spec = make_valid_two_zone_spec();
594        // 4 x-points vs 3
595        spec.zones[1].grid = Grid::from_positions(&[0.0, 1.0, 2.0, 3.0], &[0.0, 1.0]);
596        assert!(matches!(
597            spec.validate(),
598            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(_)))
599        ));
600    }
601
602    #[test]
603    fn test_validate_site_bus_ref_out_of_range() {
604        let mut spec = make_valid_two_zone_spec();
605        spec.zones[0].site_buses = vec![Bus {
606            src: vec![SiteRef(0)],
607            dst: vec![SiteRef(999)],
608        }];
609        assert!(matches!(
610            spec.validate(),
611            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::ZoneBus(_)))
612        ));
613    }
614
615    #[test]
616    fn test_validate_zone_bus_must_cross_zones() {
617        let mut spec = make_valid_two_zone_spec();
618        spec.zone_buses = vec![Bus {
619            src: vec![ZonedWordRef {
620                zone_id: 0,
621                word_id: 0,
622            }],
623            dst: vec![ZonedWordRef {
624                zone_id: 0,
625                word_id: 1,
626            }], // same zone!
627        }];
628        assert!(matches!(
629            spec.validate(),
630            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::InterZoneBus(_)))
631        ));
632    }
633
634    #[test]
635    fn test_validate_entangling_pair_invalid_word() {
636        let mut spec = make_valid_two_zone_spec();
637        spec.zones[0].entangling_pairs = vec![[0, 99]];
638        assert!(matches!(
639            spec.validate(),
640            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::EntanglingPair(_)))
641        ));
642    }
643
644    #[test]
645    fn test_validate_mode_invalid_zone() {
646        let mut spec = make_valid_two_zone_spec();
647        spec.modes = vec![Mode {
648            name: "bad".to_string(),
649            zones: vec![99],
650            bitstring_order: vec![],
651        }];
652        assert!(matches!(
653            spec.validate(),
654            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Mode(_)))
655        ));
656    }
657
658    #[test]
659    fn test_validate_no_zones() {
660        let mut spec = make_valid_two_zone_spec();
661        spec.zones = vec![];
662        assert!(matches!(
663            spec.validate(),
664            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(msg) if msg.contains("zone")))
665        ));
666    }
667
668    #[test]
669    fn test_validate_no_words() {
670        let mut spec = make_valid_two_zone_spec();
671        spec.words = vec![];
672        assert!(matches!(
673            spec.validate(),
674            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(msg) if msg.contains("word")))
675        ));
676    }
677
678    #[test]
679    fn test_validate_word_site_count_mismatch() {
680        let mut spec = make_valid_two_zone_spec();
681        spec.words[1].sites = vec![[0, 0]]; // 1 site vs 2
682        assert!(matches!(
683            spec.validate(),
684            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(msg) if msg.contains("sites")))
685        ));
686    }
687
688    #[test]
689    fn test_validate_word_site_x_out_of_range() {
690        let mut spec = make_valid_two_zone_spec();
691        spec.words[0].sites[0] = [99, 0]; // x=99 but grid has 3 x-positions
692        assert!(matches!(
693            spec.validate(),
694            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(msg) if msg.contains("x index")))
695        ));
696    }
697
698    #[test]
699    fn test_validate_word_site_y_out_of_range() {
700        let mut spec = make_valid_two_zone_spec();
701        spec.words[0].sites[0] = [0, 99]; // y=99 but grid has 2 y-positions
702        assert!(matches!(
703            spec.validate(),
704            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(msg) if msg.contains("y index")))
705        ));
706    }
707
708    #[test]
709    fn test_validate_zone_words_with_site_buses_invalid() {
710        let mut spec = make_valid_two_zone_spec();
711        spec.zones[0].words_with_site_buses = vec![0, 99];
712        assert!(matches!(
713            spec.validate(),
714            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::ZoneBus(msg) if msg.contains("words_with_site_buses")))
715        ));
716    }
717
718    #[test]
719    fn test_validate_zone_sites_with_word_buses_invalid() {
720        let mut spec = make_valid_two_zone_spec();
721        spec.zones[0].sites_with_word_buses = vec![99];
722        assert!(matches!(
723            spec.validate(),
724            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::ZoneBus(msg) if msg.contains("sites_with_word_buses")))
725        ));
726    }
727
728    #[test]
729    fn test_validate_site_bus_length_mismatch() {
730        let mut spec = make_valid_two_zone_spec();
731        spec.zones[0].site_buses = vec![Bus {
732            src: vec![SiteRef(0), SiteRef(1)],
733            dst: vec![SiteRef(0)],
734        }];
735        assert!(matches!(
736            spec.validate(),
737            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::ZoneBus(msg) if msg.contains("src length")))
738        ));
739    }
740
741    #[test]
742    fn test_validate_word_bus_invalid_word_ref() {
743        let mut spec = make_valid_two_zone_spec();
744        spec.zones[0].word_buses = vec![Bus {
745            src: vec![WordRef(0)],
746            dst: vec![WordRef(99)],
747        }];
748        assert!(matches!(
749            spec.validate(),
750            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::ZoneBus(msg) if msg.contains("WordRef(99)")))
751        ));
752    }
753
754    #[test]
755    fn test_validate_zone_bus_invalid_zone_id() {
756        let mut spec = make_valid_two_zone_spec();
757        spec.zone_buses = vec![Bus {
758            src: vec![ZonedWordRef {
759                zone_id: 99,
760                word_id: 0,
761            }],
762            dst: vec![ZonedWordRef {
763                zone_id: 1,
764                word_id: 0,
765            }],
766        }];
767        assert!(matches!(
768            spec.validate(),
769            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::InterZoneBus(msg) if msg.contains("zone_id 99")))
770        ));
771    }
772
773    #[test]
774    fn test_validate_zone_bus_invalid_word_id() {
775        let mut spec = make_valid_two_zone_spec();
776        spec.zone_buses = vec![Bus {
777            src: vec![ZonedWordRef {
778                zone_id: 0,
779                word_id: 99,
780            }],
781            dst: vec![ZonedWordRef {
782                zone_id: 1,
783                word_id: 0,
784            }],
785        }];
786        assert!(matches!(
787            spec.validate(),
788            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::InterZoneBus(msg) if msg.contains("word_id 99")))
789        ));
790    }
791
792    #[test]
793    fn test_validate_zone_bus_length_mismatch() {
794        let mut spec = make_valid_two_zone_spec();
795        spec.zone_buses = vec![Bus {
796            src: vec![
797                ZonedWordRef {
798                    zone_id: 0,
799                    word_id: 0,
800                },
801                ZonedWordRef {
802                    zone_id: 0,
803                    word_id: 1,
804                },
805            ],
806            dst: vec![ZonedWordRef {
807                zone_id: 1,
808                word_id: 0,
809            }],
810        }];
811        assert!(matches!(
812            spec.validate(),
813            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::InterZoneBus(msg) if msg.contains("src length")))
814        ));
815    }
816
817    #[test]
818    fn test_validate_entangling_pair_duplicate() {
819        let mut spec = make_valid_two_zone_spec();
820        spec.zones[0].entangling_pairs = vec![[0, 1], [1, 0]]; // same pair reversed
821        assert!(matches!(
822            spec.validate(),
823            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::EntanglingPair(msg) if msg.contains("duplicate")))
824        ));
825    }
826
827    #[test]
828    fn test_validate_mode_bitstring_order_invalid() {
829        use crate::arch::addr::LocationAddr;
830        let mut spec = make_valid_two_zone_spec();
831        spec.modes = vec![Mode {
832            name: "bad_loc".to_string(),
833            zones: vec![0],
834            bitstring_order: vec![LocationAddr {
835                zone_id: 99,
836                word_id: 0,
837                site_id: 0,
838            }],
839        }];
840        assert!(matches!(
841            spec.validate(),
842            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Mode(msg) if msg.contains("zone_id 99")))
843        ));
844    }
845
846    #[test]
847    fn test_validate_multiple_errors_collected() {
848        let mut spec = make_valid_two_zone_spec();
849        // Break multiple things
850        spec.zones[0].entangling_pairs = vec![[0, 99]]; // bad word
851        spec.zones[0].words_with_site_buses = vec![99]; // bad word
852        let errors = spec.validate().unwrap_err();
853        assert!(
854            errors.len() >= 2,
855            "expected at least 2 errors, got {}",
856            errors.len()
857        );
858    }
859
860    #[test]
861    fn test_validate_path_non_finite_waypoint() {
862        let mut spec = make_valid_two_zone_spec();
863        let lane = crate::arch::addr::LaneAddr {
864            direction: crate::arch::addr::Direction::Forward,
865            move_type: crate::arch::addr::MoveType::SiteBus,
866            zone_id: 0,
867            word_id: 0,
868            site_id: 0,
869            bus_id: 0,
870        };
871        spec.paths = Some(vec![crate::arch::types::TransportPath {
872            lane: lane.encode_u64(),
873            waypoints: vec![[f64::NAN, 0.0], [1.0, 2.0]],
874        }]);
875        assert!(matches!(
876            spec.validate(),
877            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Path(msg) if msg.contains("non-finite")))
878        ));
879    }
880
881    #[test]
882    fn test_validate_path_too_few_waypoints() {
883        let mut spec = make_valid_two_zone_spec();
884        let lane = crate::arch::addr::LaneAddr {
885            direction: crate::arch::addr::Direction::Forward,
886            move_type: crate::arch::addr::MoveType::SiteBus,
887            zone_id: 0,
888            word_id: 0,
889            site_id: 0,
890            bus_id: 0,
891        };
892        spec.paths = Some(vec![crate::arch::types::TransportPath {
893            lane: lane.encode_u64(),
894            waypoints: vec![[1.0, 2.0]],
895        }]);
896        assert!(matches!(
897            spec.validate(),
898            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Path(msg) if msg.contains("minimum is 2")))
899        ));
900    }
901
902    #[test]
903    fn test_validate_path_invalid_zone_id() {
904        let mut spec = make_valid_two_zone_spec();
905        let lane = crate::arch::addr::LaneAddr {
906            direction: crate::arch::addr::Direction::Forward,
907            move_type: crate::arch::addr::MoveType::SiteBus,
908            zone_id: 99,
909            word_id: 0,
910            site_id: 0,
911            bus_id: 0,
912        };
913        spec.paths = Some(vec![crate::arch::types::TransportPath {
914            lane: lane.encode_u64(),
915            waypoints: vec![[0.0, 0.0], [1.0, 2.0]],
916        }]);
917        assert!(matches!(
918            spec.validate(),
919            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Path(msg) if msg.contains("zone_id")))
920        ));
921    }
922
923    // ── Zone overlap tests (#463) ──
924
925    #[test]
926    fn test_non_overlapping_zones_pass() {
927        // The default fixture has non-overlapping zones (zone 0 at x=[0,10],
928        // zone 1 at x=[20,35]).
929        let spec = make_valid_two_zone_spec();
930        assert!(spec.validate().is_ok());
931    }
932
933    #[test]
934    fn test_overlapping_zones_rejected() {
935        let mut spec = make_valid_two_zone_spec();
936        // Move zone 1's grid to overlap zone 0 (x=[0,10], y=[0,3]).
937        spec.zones[1].grid = Grid::from_positions(&[5.0, 12.5, 20.0], &[0.0, 4.0]);
938        assert!(matches!(
939            spec.validate(),
940            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(msg) if msg.contains("overlapping bounding boxes")))
941        ));
942    }
943
944    #[test]
945    fn test_adjacent_zones_not_overlapping() {
946        let mut spec = make_valid_two_zone_spec();
947        // Zone 1 starts exactly where zone 0 ends (touching but not overlapping).
948        spec.zones[1].grid = Grid::from_positions(&[10.0, 17.5, 25.0], &[0.0, 4.0]);
949        assert!(spec.validate().is_ok());
950    }
951
952    #[test]
953    fn test_shared_x_range_with_y_overlap_rejected() {
954        let mut spec = make_valid_two_zone_spec();
955        // Zones share an x range and have overlapping y ranges.
956        spec.zones[1].grid = Grid::from_positions(&[0.0, 7.5, 15.0], &[1.0, 5.0]);
957        // Zone 0: x=[0,10], y=[0,3]; Zone 1: x=[0,15], y=[1,5] → both axes overlap.
958        assert!(matches!(
959            spec.validate(),
960            Err(ref errs) if errs.iter().any(|e| matches!(e, ArchSpecError::Structure(msg) if msg.contains("overlapping")))
961        ));
962    }
963
964    #[test]
965    fn test_single_zone_no_overlap_check() {
966        // With a single zone, there's nothing to compare.
967        let mut spec = make_valid_two_zone_spec();
968        spec.zones.pop();
969        spec.zone_buses.clear();
970        spec.modes[0].zones = vec![0];
971        assert!(spec.validate().is_ok());
972    }
973}