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    /// Zone configuration error (zone 0 coverage, measurement/entangling zone IDs).
19    #[error("{message}")]
20    Zone { message: String },
21
22    /// Word geometry error (site counts, grid indices, grid shape, non-finite values).
23    #[error("{message}")]
24    Geometry { message: String },
25
26    /// Bus topology error (site/word bus structure, membership lists).
27    #[error("{message}")]
28    Bus { message: String },
29
30    /// Transport path error (invalid lanes, waypoint counts, endpoint mismatches).
31    #[error("{message}")]
32    Path { message: String },
33}
34
35impl ArchSpec {
36    /// Validate the arch spec against all structural rules.
37    /// Collects all errors in one pass (not fail-fast).
38    pub fn validate(&self) -> Result<(), Vec<ArchSpecError>> {
39        let mut errors = Vec::new();
40
41        let num_words = self.geometry.words.len() as u32;
42        let num_zones = self.zones.len() as u32;
43        let sites_per_word = self.geometry.sites_per_word;
44
45        // Rule 1: Zone 0 must include all words
46        check_zone0_includes_all_words(self, num_words, &mut errors);
47
48        // Rule 2: measurement_mode_zones[0] must be zone 0
49        check_measurement_mode_first_is_zone0(self, &mut errors);
50
51        // Rule 3a: All entangling_zones IDs must be valid zone indices
52        check_entangling_zones_valid(self, num_zones, &mut errors);
53
54        // Rule 3b: All measurement_mode_zones IDs must be valid zone indices
55        check_measurement_mode_zones_valid(self, num_zones, &mut errors);
56
57        // Rules 4, 5a, 5b: Word site counts and grid index bounds
58        check_word_sites(self, &mut errors);
59
60        // Rules 6, 7, 8: Site bus validation
61        check_site_buses(self, sites_per_word, &mut errors);
62
63        // Rules 9, 10: Word bus validation
64        check_word_buses(self, num_words, &mut errors);
65
66        // Rule: All words must have the same grid shape
67        check_consistent_grid_shape(self, &mut errors);
68
69        // Rule 11: words_with_site_buses must be valid word indices
70        check_words_with_site_buses(self, num_words, &mut errors);
71
72        // Rule 12: sites_with_word_buses must be valid site indices
73        check_sites_with_word_buses(self, sites_per_word, &mut errors);
74
75        // Rule: path lane addresses must be valid
76        check_path_lanes(self, &mut errors);
77
78        if errors.is_empty() {
79            Ok(())
80        } else {
81            Err(errors)
82        }
83    }
84}
85
86fn check_zone0_includes_all_words(
87    spec: &ArchSpec,
88    num_words: u32,
89    errors: &mut Vec<ArchSpecError>,
90) {
91    if let Some(zone0) = spec.zones.first() {
92        let zone0_words: HashSet<u32> = zone0.words.iter().copied().collect();
93        let all_word_ids: HashSet<u32> = (0..num_words).collect();
94        let mut missing: Vec<u32> = all_word_ids.difference(&zone0_words).copied().collect();
95        missing.sort_unstable();
96        if !missing.is_empty() {
97            errors.push(ArchSpecError::Zone {
98                message: format!(
99                    "zone 0 must include all words: missing word IDs {:?}",
100                    missing
101                ),
102            });
103        }
104    }
105}
106
107fn check_measurement_mode_first_is_zone0(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
108    if spec.measurement_mode_zones.is_empty() {
109        errors.push(ArchSpecError::Zone {
110            message: "measurement_mode_zones must not be empty".into(),
111        });
112        return;
113    }
114    if spec.measurement_mode_zones[0] != 0 {
115        errors.push(ArchSpecError::Zone {
116            message: format!(
117                "measurement_mode_zones[0] must be zone 0, got {}",
118                spec.measurement_mode_zones[0]
119            ),
120        });
121    }
122}
123
124fn check_entangling_zones_valid(spec: &ArchSpec, num_zones: u32, errors: &mut Vec<ArchSpecError>) {
125    for &id in &spec.entangling_zones {
126        if id >= num_zones {
127            errors.push(ArchSpecError::Zone {
128                message: format!("entangling_zones contains invalid zone ID {}", id),
129            });
130        }
131    }
132}
133
134fn check_measurement_mode_zones_valid(
135    spec: &ArchSpec,
136    num_zones: u32,
137    errors: &mut Vec<ArchSpecError>,
138) {
139    for &id in &spec.measurement_mode_zones {
140        if id >= num_zones {
141            errors.push(ArchSpecError::Zone {
142                message: format!("measurement_mode_zones contains invalid zone ID {}", id),
143            });
144        }
145    }
146}
147
148fn check_word_sites(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
149    let sites_per_word = spec.geometry.sites_per_word;
150    for (word_id, word) in spec.geometry.words.iter().enumerate() {
151        let word_id = word_id as u32;
152        if let Err(field) = word.positions.check_finite() {
153            errors.push(ArchSpecError::Geometry {
154                message: format!(
155                    "word {} grid contains non-finite value in {}",
156                    word_id, field
157                ),
158            });
159        }
160        if word.site_indices.len() != sites_per_word as usize {
161            errors.push(ArchSpecError::Geometry {
162                message: format!(
163                    "word {} has {} sites, expected {} (sites_per_word)",
164                    word_id,
165                    word.site_indices.len(),
166                    sites_per_word
167                ),
168            });
169        }
170        if let Some(cz) = &word.has_cz
171            && cz.len() != sites_per_word as usize
172        {
173            errors.push(ArchSpecError::Geometry {
174                message: format!(
175                    "word {} has {} cz_pairs, expected {} (sites_per_word)",
176                    word_id,
177                    cz.len(),
178                    sites_per_word
179                ),
180            });
181        }
182        let x_len = word.positions.num_x();
183        let y_len = word.positions.num_y();
184        for (site_idx, site) in word.site_indices.iter().enumerate() {
185            let x_idx = site[0];
186            let y_idx = site[1];
187            if x_idx as usize >= x_len {
188                errors.push(ArchSpecError::Geometry {
189                    message: format!(
190                        "word {}, site {}: x_idx {} out of range (grid has num_x={})",
191                        word_id, site_idx, x_idx, x_len
192                    ),
193                });
194            }
195            if y_idx as usize >= y_len {
196                errors.push(ArchSpecError::Geometry {
197                    message: format!(
198                        "word {}, site {}: y_idx {} out of range (grid has num_y={})",
199                        word_id, site_idx, y_idx, y_len
200                    ),
201                });
202            }
203        }
204    }
205}
206
207fn check_site_buses(spec: &ArchSpec, sites_per_word: u32, errors: &mut Vec<ArchSpecError>) {
208    for (bus_id, bus) in spec.buses.site_buses.iter().enumerate() {
209        let bus_id = bus_id as u32;
210        if bus.src.len() != bus.dst.len() {
211            errors.push(ArchSpecError::Bus {
212                message: format!(
213                    "site_bus {}: src length ({}) != dst length ({})",
214                    bus_id,
215                    bus.src.len(),
216                    bus.dst.len()
217                ),
218            });
219        }
220        for &idx in bus.src.iter().chain(bus.dst.iter()) {
221            if idx >= sites_per_word {
222                errors.push(ArchSpecError::Bus {
223                    message: format!(
224                        "site_bus {}: site index {} >= sites_per_word ({})",
225                        bus_id, idx, sites_per_word
226                    ),
227                });
228            }
229        }
230        let src_set: HashSet<u32> = bus.src.iter().copied().collect();
231        for &idx in &bus.dst {
232            if src_set.contains(&idx) {
233                errors.push(ArchSpecError::Bus {
234                    message: format!(
235                        "site_bus {}: src and dst overlap at site index {}",
236                        bus_id, idx
237                    ),
238                });
239            }
240        }
241    }
242}
243
244fn check_word_buses(spec: &ArchSpec, num_words: u32, errors: &mut Vec<ArchSpecError>) {
245    for (bus_id, bus) in spec.buses.word_buses.iter().enumerate() {
246        let bus_id = bus_id as u32;
247        if bus.src.len() != bus.dst.len() {
248            errors.push(ArchSpecError::Bus {
249                message: format!(
250                    "word_bus {}: src length ({}) != dst length ({})",
251                    bus_id,
252                    bus.src.len(),
253                    bus.dst.len()
254                ),
255            });
256        }
257        for &wid in bus.src.iter().chain(bus.dst.iter()) {
258            if wid >= num_words {
259                errors.push(ArchSpecError::Bus {
260                    message: format!("word_bus {}: invalid word ID {}", bus_id, wid),
261                });
262            }
263        }
264    }
265}
266
267fn check_words_with_site_buses(spec: &ArchSpec, num_words: u32, errors: &mut Vec<ArchSpecError>) {
268    for &wid in &spec.words_with_site_buses {
269        if wid >= num_words {
270            errors.push(ArchSpecError::Bus {
271                message: format!("words_with_site_buses: invalid word ID {}", wid),
272            });
273        }
274    }
275}
276
277fn check_consistent_grid_shape(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
278    if let Some(first) = spec.geometry.words.first() {
279        let ref_x_len = first.positions.num_x();
280        let ref_y_len = first.positions.num_y();
281        for (idx, word) in spec.geometry.words.iter().enumerate().skip(1) {
282            let x_len = word.positions.num_x();
283            let y_len = word.positions.num_y();
284            if x_len != ref_x_len || y_len != ref_y_len {
285                errors.push(ArchSpecError::Geometry {
286                    message: format!(
287                        "word {} grid shape ({}x{}) differs from word 0 ({}x{})",
288                        idx, x_len, y_len, ref_x_len, ref_y_len
289                    ),
290                });
291            }
292        }
293    }
294}
295
296fn check_path_lanes(spec: &ArchSpec, errors: &mut Vec<ArchSpecError>) {
297    if let Some(paths) = &spec.paths {
298        for (index, path) in paths.iter().enumerate() {
299            if !path.check_finite() {
300                errors.push(ArchSpecError::Path {
301                    message: format!("paths[{}]: waypoint contains non-finite coordinate", index),
302                });
303            }
304            let lane = crate::arch::addr::LaneAddr::decode_u64(path.lane);
305            let lane_errors = spec.check_lane(&lane);
306            for message in lane_errors {
307                errors.push(ArchSpecError::Path {
308                    message: format!(
309                        "paths[{}]: lane 0x{:016X} is invalid: {}",
310                        index, path.lane, message
311                    ),
312                });
313            }
314
315            // Check minimum waypoint count
316            if path.waypoints.len() < 2 {
317                errors.push(ArchSpecError::Path {
318                    message: format!(
319                        "paths[{}]: lane 0x{:016X} has {} waypoint(s), minimum is 2",
320                        index,
321                        path.lane,
322                        path.waypoints.len()
323                    ),
324                });
325                continue; // can't check endpoints with < 2 waypoints
326            }
327
328            // Check that first/last waypoints match the lane's physical endpoints
329            if let Some((src_loc, dst_loc)) = spec.lane_endpoints(&lane) {
330                if let Some(src_pos) = spec.location_position(&src_loc) {
331                    let first = path.waypoints.first().unwrap();
332                    if first[0] != src_pos.0 || first[1] != src_pos.1 {
333                        errors.push(ArchSpecError::Path {
334                            message: format!(
335                                "paths[{}]: lane 0x{:016X} first waypoint ({}, {}) does not match expected position ({}, {})",
336                                index, path.lane, first[0], first[1], src_pos.0, src_pos.1
337                            ),
338                        });
339                    }
340                }
341                if let Some(dst_pos) = spec.location_position(&dst_loc) {
342                    let last = path.waypoints.last().unwrap();
343                    if last[0] != dst_pos.0 || last[1] != dst_pos.1 {
344                        errors.push(ArchSpecError::Path {
345                            message: format!(
346                                "paths[{}]: lane 0x{:016X} last waypoint ({}, {}) does not match expected position ({}, {})",
347                                index, path.lane, last[0], last[1], dst_pos.0, dst_pos.1
348                            ),
349                        });
350                    }
351                }
352            }
353        }
354    }
355}
356
357fn check_sites_with_word_buses(
358    spec: &ArchSpec,
359    sites_per_word: u32,
360    errors: &mut Vec<ArchSpecError>,
361) {
362    for &idx in &spec.sites_with_word_buses {
363        if idx >= sites_per_word {
364            errors.push(ArchSpecError::Bus {
365                message: format!(
366                    "sites_with_word_buses: site index {} >= sites_per_word ({})",
367                    idx, sites_per_word
368                ),
369            });
370        }
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use crate::arch::example_arch_spec;
377
378    use super::*;
379
380    fn has_error<F: Fn(&ArchSpecError) -> bool>(errors: &[ArchSpecError], predicate: F) -> bool {
381        errors.iter().any(predicate)
382    }
383
384    #[test]
385    fn valid_spec_passes() {
386        let spec = example_arch_spec();
387        assert!(spec.validate().is_ok());
388    }
389
390    #[test]
391    fn test_zone0_missing_words() {
392        let mut spec = example_arch_spec();
393        spec.zones[0].words = vec![0];
394        let errors = spec.validate().unwrap_err();
395        assert!(has_error(
396            &errors,
397            |e| matches!(e, ArchSpecError::Zone { message } if message.contains("missing word IDs"))
398        ));
399    }
400
401    #[test]
402    fn test_measurement_mode_first_not_zone0() {
403        let mut spec = example_arch_spec();
404        spec.measurement_mode_zones = vec![1];
405        let errors = spec.validate().unwrap_err();
406        assert!(has_error(
407            &errors,
408            |e| matches!(e, ArchSpecError::Zone { message } if message.contains("must be zone 0"))
409        ));
410    }
411
412    #[test]
413    fn test_invalid_entangling_zone() {
414        let mut spec = example_arch_spec();
415        spec.entangling_zones.push(99);
416        let errors = spec.validate().unwrap_err();
417        assert!(has_error(
418            &errors,
419            |e| matches!(e, ArchSpecError::Zone { message } if message.contains("invalid zone ID 99"))
420        ));
421    }
422
423    #[test]
424    fn test_invalid_measurement_mode_zone() {
425        let mut spec = example_arch_spec();
426        spec.measurement_mode_zones.push(99);
427        let errors = spec.validate().unwrap_err();
428        assert!(has_error(
429            &errors,
430            |e| matches!(e, ArchSpecError::Zone { message } if message.contains("invalid zone ID 99"))
431        ));
432    }
433
434    #[test]
435    fn test_wrong_site_count() {
436        let mut spec = example_arch_spec();
437        spec.geometry.words[0].site_indices.pop();
438        let errors = spec.validate().unwrap_err();
439        assert!(has_error(
440            &errors,
441            |e| matches!(e, ArchSpecError::Geometry { message } if message.contains("9 sites, expected 10"))
442        ));
443    }
444
445    #[test]
446    fn test_wrong_cz_pairs_count() {
447        let mut spec = example_arch_spec();
448        spec.geometry.words[0].has_cz.as_mut().unwrap().pop();
449        let errors = spec.validate().unwrap_err();
450        assert!(has_error(
451            &errors,
452            |e| matches!(e, ArchSpecError::Geometry { message } if message.contains("9 cz_pairs, expected 10"))
453        ));
454    }
455
456    #[test]
457    fn test_site_x_index_out_of_range() {
458        let mut spec = example_arch_spec();
459        spec.geometry.words[0].site_indices[0] = [99, 0];
460        let errors = spec.validate().unwrap_err();
461        assert!(has_error(
462            &errors,
463            |e| matches!(e, ArchSpecError::Geometry { message } if message.contains("x_idx 99 out of range"))
464        ));
465    }
466
467    #[test]
468    fn test_site_y_index_out_of_range() {
469        let mut spec = example_arch_spec();
470        spec.geometry.words[0].site_indices[0] = [0, 99];
471        let errors = spec.validate().unwrap_err();
472        assert!(has_error(
473            &errors,
474            |e| matches!(e, ArchSpecError::Geometry { message } if message.contains("y_idx 99 out of range"))
475        ));
476    }
477
478    #[test]
479    fn test_site_bus_length_mismatch() {
480        let mut spec = example_arch_spec();
481        spec.buses.site_buses[0].dst.pop();
482        let errors = spec.validate().unwrap_err();
483        assert!(has_error(
484            &errors,
485            |e| matches!(e, ArchSpecError::Bus { message } if message.contains("site_bus 0: src length"))
486        ));
487    }
488
489    #[test]
490    fn test_site_bus_overlap() {
491        let mut spec = example_arch_spec();
492        spec.buses.site_buses[0].src = vec![0, 1];
493        spec.buses.site_buses[0].dst = vec![0, 2];
494        let errors = spec.validate().unwrap_err();
495        assert!(has_error(
496            &errors,
497            |e| matches!(e, ArchSpecError::Bus { message } if message.contains("overlap at site index 0"))
498        ));
499    }
500
501    #[test]
502    fn test_site_bus_index_out_of_range() {
503        let mut spec = example_arch_spec();
504        spec.buses.site_buses[0].src[0] = 99;
505        let errors = spec.validate().unwrap_err();
506        assert!(has_error(
507            &errors,
508            |e| matches!(e, ArchSpecError::Bus { message } if message.contains("site index 99"))
509        ));
510    }
511
512    #[test]
513    fn test_word_bus_length_mismatch() {
514        let mut spec = example_arch_spec();
515        spec.buses.word_buses[0].dst.pop();
516        let errors = spec.validate().unwrap_err();
517        assert!(has_error(
518            &errors,
519            |e| matches!(e, ArchSpecError::Bus { message } if message.contains("word_bus 0: src length"))
520        ));
521    }
522
523    #[test]
524    fn test_word_bus_invalid_word_id() {
525        let mut spec = example_arch_spec();
526        spec.buses.word_buses[0].src = vec![99];
527        let errors = spec.validate().unwrap_err();
528        assert!(has_error(
529            &errors,
530            |e| matches!(e, ArchSpecError::Bus { message } if message.contains("invalid word ID 99"))
531        ));
532    }
533
534    #[test]
535    fn test_invalid_word_with_site_bus() {
536        let mut spec = example_arch_spec();
537        spec.words_with_site_buses.push(99);
538        let errors = spec.validate().unwrap_err();
539        assert!(has_error(
540            &errors,
541            |e| matches!(e, ArchSpecError::Bus { message } if message.contains("words_with_site_buses: invalid word ID 99"))
542        ));
543    }
544
545    #[test]
546    fn test_invalid_site_with_word_bus() {
547        let mut spec = example_arch_spec();
548        spec.sites_with_word_buses.push(99);
549        let errors = spec.validate().unwrap_err();
550        assert!(has_error(
551            &errors,
552            |e| matches!(e, ArchSpecError::Bus { message } if message.contains("site index 99 >= sites_per_word"))
553        ));
554    }
555
556    #[test]
557    fn test_invalid_path_lane() {
558        let mut spec = example_arch_spec();
559        let bad_lane = crate::arch::addr::LaneAddr {
560            direction: crate::arch::addr::Direction::Forward,
561            move_type: crate::arch::addr::MoveType::SiteBus,
562            word_id: 0,
563            site_id: 0,
564            bus_id: 99,
565        };
566        spec.paths = Some(vec![crate::arch::types::TransportPath {
567            lane: {
568                let (d0, d1) = bad_lane.encode();
569                (d0 as u64) | ((d1 as u64) << 32)
570            },
571            waypoints: vec![[1.0, 2.0]],
572        }]);
573        let errors = spec.validate().unwrap_err();
574        assert!(has_error(
575            &errors,
576            |e| matches!(e, ArchSpecError::Path { message } if message.contains("is invalid"))
577        ));
578    }
579
580    #[test]
581    fn test_valid_path_lane() {
582        let mut spec = example_arch_spec();
583        let good_lane = crate::arch::addr::LaneAddr {
584            direction: crate::arch::addr::Direction::Forward,
585            move_type: crate::arch::addr::MoveType::SiteBus,
586            word_id: 0,
587            site_id: 0,
588            bus_id: 0,
589        };
590        spec.paths = Some(vec![crate::arch::types::TransportPath {
591            lane: {
592                let (d0, d1) = good_lane.encode();
593                (d0 as u64) | ((d1 as u64) << 32)
594            },
595            waypoints: vec![[1.0, 2.5], [1.0, 5.0]],
596        }]);
597        assert!(spec.validate().is_ok());
598    }
599
600    #[test]
601    fn test_path_too_few_waypoints() {
602        let mut spec = example_arch_spec();
603        let lane = crate::arch::addr::LaneAddr {
604            direction: crate::arch::addr::Direction::Forward,
605            move_type: crate::arch::addr::MoveType::SiteBus,
606            word_id: 0,
607            site_id: 0,
608            bus_id: 0,
609        };
610        spec.paths = Some(vec![crate::arch::types::TransportPath {
611            lane: {
612                let (d0, d1) = lane.encode();
613                (d0 as u64) | ((d1 as u64) << 32)
614            },
615            waypoints: vec![[1.0, 2.5]],
616        }]);
617        let errors = spec.validate().unwrap_err();
618        assert!(has_error(
619            &errors,
620            |e| matches!(e, ArchSpecError::Path { message } if message.contains("1 waypoint(s), minimum is 2"))
621        ));
622    }
623
624    #[test]
625    fn test_path_endpoint_mismatch_first() {
626        let mut spec = example_arch_spec();
627        let lane = crate::arch::addr::LaneAddr {
628            direction: crate::arch::addr::Direction::Forward,
629            move_type: crate::arch::addr::MoveType::SiteBus,
630            word_id: 0,
631            site_id: 0,
632            bus_id: 0,
633        };
634        spec.paths = Some(vec![crate::arch::types::TransportPath {
635            lane: {
636                let (d0, d1) = lane.encode();
637                (d0 as u64) | ((d1 as u64) << 32)
638            },
639            waypoints: vec![[99.0, 99.0], [1.0, 5.0]],
640        }]);
641        let errors = spec.validate().unwrap_err();
642        assert!(has_error(
643            &errors,
644            |e| matches!(e, ArchSpecError::Path { message } if message.contains("first waypoint"))
645        ));
646    }
647
648    #[test]
649    fn test_path_endpoint_mismatch_last() {
650        let mut spec = example_arch_spec();
651        let lane = crate::arch::addr::LaneAddr {
652            direction: crate::arch::addr::Direction::Forward,
653            move_type: crate::arch::addr::MoveType::SiteBus,
654            word_id: 0,
655            site_id: 0,
656            bus_id: 0,
657        };
658        spec.paths = Some(vec![crate::arch::types::TransportPath {
659            lane: {
660                let (d0, d1) = lane.encode();
661                (d0 as u64) | ((d1 as u64) << 32)
662            },
663            waypoints: vec![[1.0, 2.5], [99.0, 99.0]],
664        }]);
665        let errors = spec.validate().unwrap_err();
666        assert!(has_error(
667            &errors,
668            |e| matches!(e, ArchSpecError::Path { message } if message.contains("last waypoint"))
669        ));
670    }
671
672    #[test]
673    fn test_inconsistent_grid_shape() {
674        let mut spec = example_arch_spec();
675        spec.geometry.words[1].positions.x_spacing = vec![2.0, 2.0];
676        let errors = spec.validate().unwrap_err();
677        assert!(has_error(
678            &errors,
679            |e| matches!(e, ArchSpecError::Geometry { message } if message.contains("grid shape"))
680        ));
681    }
682
683    #[test]
684    fn multiple_errors_collected() {
685        let mut spec = example_arch_spec();
686        spec.zones[0].words = vec![0];
687        spec.measurement_mode_zones = vec![1];
688        spec.sites_with_word_buses.push(99);
689        let errors = spec.validate().unwrap_err();
690        assert!(
691            errors.len() >= 3,
692            "expected at least 3 errors, got {}",
693            errors.len()
694        );
695    }
696}