Skip to main content

bloqade_lanes_bytecode_core/arch/
query.rs

1//! Arch spec queries: JSON loading, position lookup, lane resolution,
2//! and group-level address validation.
3
4use std::collections::{HashMap, HashSet};
5use std::fmt;
6
7use thiserror::Error;
8
9use super::addr::{Direction, LaneAddr, LocationAddr, MoveType, SiteRef, WordRef, ZonedWordRef};
10use super::types::{ArchSpec, Bus, Word, Zone};
11use super::validate::ArchSpecError;
12
13/// Error returned when loading an arch spec from JSON fails.
14#[derive(Debug, Error)]
15pub enum ArchSpecLoadError {
16    #[error("JSON parse error: {0}")]
17    Json(#[from] serde_json::Error),
18
19    #[error("validation errors: {0:?}")]
20    Validation(Vec<ArchSpecError>),
21}
22
23impl From<Vec<ArchSpecError>> for ArchSpecLoadError {
24    fn from(errors: Vec<ArchSpecError>) -> Self {
25        ArchSpecLoadError::Validation(errors)
26    }
27}
28
29// --- Group-level error types ---
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum LocationGroupError {
33    /// A location address appears more than once in the group.
34    DuplicateAddress { address: u64 },
35    /// A location address is invalid per the arch spec.
36    InvalidAddress {
37        zone_id: u32,
38        word_id: u32,
39        site_id: u32,
40    },
41}
42
43impl fmt::Display for LocationGroupError {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            LocationGroupError::DuplicateAddress { address } => {
47                let addr = LocationAddr::decode(*address);
48                write!(
49                    f,
50                    "duplicate location address zone_id={}, word_id={}, site_id={}",
51                    addr.zone_id, addr.word_id, addr.site_id
52                )
53            }
54            LocationGroupError::InvalidAddress {
55                zone_id,
56                word_id,
57                site_id,
58            } => {
59                write!(
60                    f,
61                    "invalid location zone_id={}, word_id={}, site_id={}",
62                    zone_id, word_id, site_id
63                )
64            }
65        }
66    }
67}
68
69impl std::error::Error for LocationGroupError {}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum LaneGroupError {
73    /// A lane address appears more than once in the group.
74    DuplicateAddress { address: (u32, u32) },
75    /// A lane address is invalid per the arch spec.
76    InvalidLane { message: String },
77    /// Lanes have inconsistent bus_id, move_type, direction, or zone_id.
78    Inconsistent { message: String },
79    /// Lane word_id not in zone's words_with_site_buses.
80    WordNotInSiteBusList { zone_id: u32, word_id: u32 },
81    /// Lane site_id not in zone's sites_with_word_buses.
82    SiteNotInWordBusList { zone_id: u32, site_id: u32 },
83    /// Lane group violates AOD grid constraint (e.g. not a complete grid).
84    AODConstraintViolation { message: String },
85}
86
87impl fmt::Display for LaneGroupError {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            LaneGroupError::DuplicateAddress { address } => {
91                let combined = (address.0 as u64) | ((address.1 as u64) << 32);
92                write!(f, "duplicate lane address 0x{:016x}", combined)
93            }
94            LaneGroupError::InvalidLane { message } => {
95                write!(f, "invalid lane: {}", message)
96            }
97            LaneGroupError::Inconsistent { message } => {
98                write!(f, "lane group inconsistent: {}", message)
99            }
100            LaneGroupError::WordNotInSiteBusList { zone_id, word_id } => {
101                write!(
102                    f,
103                    "zone {}: word_id {} not in words_with_site_buses",
104                    zone_id, word_id
105                )
106            }
107            LaneGroupError::SiteNotInWordBusList { zone_id, site_id } => {
108                write!(
109                    f,
110                    "zone {}: site_id {} not in sites_with_word_buses",
111                    zone_id, site_id
112                )
113            }
114            LaneGroupError::AODConstraintViolation { message } => {
115                write!(f, "AOD constraint violation: {}", message)
116            }
117        }
118    }
119}
120
121impl std::error::Error for LaneGroupError {}
122
123// --- Bus resolve methods ---
124
125impl Bus<SiteRef> {
126    /// Given a source site, return the destination site (forward move).
127    pub fn resolve_forward(&self, src: u16) -> Option<u16> {
128        self.src
129            .iter()
130            .position(|s| s.0 == src)
131            .and_then(|i| self.dst.get(i).map(|d| d.0))
132    }
133
134    /// Given a destination site, return the source site (backward move).
135    pub fn resolve_backward(&self, dst: u16) -> Option<u16> {
136        self.dst
137            .iter()
138            .position(|d| d.0 == dst)
139            .and_then(|i| self.src.get(i).map(|s| s.0))
140    }
141}
142
143impl Bus<WordRef> {
144    /// Given a source word, return the destination word (forward move).
145    pub fn resolve_forward(&self, src: u16) -> Option<u16> {
146        self.src
147            .iter()
148            .position(|s| s.0 == src)
149            .and_then(|i| self.dst.get(i).map(|d| d.0))
150    }
151
152    /// Given a destination word, return the source word (backward move).
153    pub fn resolve_backward(&self, dst: u16) -> Option<u16> {
154        self.dst
155            .iter()
156            .position(|d| d.0 == dst)
157            .and_then(|i| self.src.get(i).map(|s| s.0))
158    }
159}
160
161impl Bus<ZonedWordRef> {
162    /// Given a source ZonedWordRef, return the destination (forward move).
163    pub fn resolve_forward(&self, src: &ZonedWordRef) -> Option<&ZonedWordRef> {
164        self.src
165            .iter()
166            .position(|s| s == src)
167            .and_then(|i| self.dst.get(i))
168    }
169
170    /// Given a destination ZonedWordRef, return the source (backward move).
171    pub fn resolve_backward(&self, dst: &ZonedWordRef) -> Option<&ZonedWordRef> {
172        self.dst
173            .iter()
174            .position(|d| d == dst)
175            .and_then(|i| self.src.get(i))
176    }
177}
178
179// --- ArchSpec methods ---
180
181impl ArchSpec {
182    // -- Deserialization --
183
184    /// Deserialize from a JSON string.
185    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
186        serde_json::from_str(json)
187    }
188
189    /// Deserialize from JSON and validate.
190    pub fn from_json_validated(json: &str) -> Result<Self, ArchSpecLoadError> {
191        let spec = Self::from_json(json)?;
192        spec.validate()?;
193        Ok(spec)
194    }
195
196    // -- Lookup helpers --
197
198    /// Look up a word by its index.
199    pub fn word_by_id(&self, id: u32) -> Option<&Word> {
200        self.words.get(id as usize)
201    }
202
203    /// Look up a zone by its index.
204    pub fn zone_by_id(&self, id: u32) -> Option<&Zone> {
205        self.zones.get(id as usize)
206    }
207
208    // -- Derived topology queries --
209
210    /// Build a bidirectional word-partner map from all zones' entangling pairs.
211    ///
212    /// For each `[w_a, w_b]` pair in any zone, the map contains both
213    /// `w_a -> w_b` and `w_b -> w_a`. Words not appearing in any pair
214    /// are absent from the map.
215    pub fn word_partner_map(&self) -> HashMap<u32, u32> {
216        let mut map = HashMap::new();
217        for zone in &self.zones {
218            for &[w_a, w_b] in &zone.entangling_pairs {
219                map.insert(w_a, w_b);
220                map.insert(w_b, w_a);
221            }
222        }
223        map
224    }
225
226    /// Map each word to the zone that owns it.
227    ///
228    /// Derived from each zone's `entangling_pairs`, `word_buses`, and
229    /// `words_with_site_buses`. First match wins. Words not referenced
230    /// by any zone default to zone 0.
231    pub fn word_zone_map(&self) -> HashMap<u32, u32> {
232        let mut map = HashMap::new();
233        for (zone_id, zone) in self.zones.iter().enumerate() {
234            let zid = zone_id as u32;
235            for &[w_a, w_b] in &zone.entangling_pairs {
236                map.entry(w_a).or_insert(zid);
237                map.entry(w_b).or_insert(zid);
238            }
239            for bus in &zone.word_buses {
240                for wref in &bus.src {
241                    map.entry(wref.0 as u32).or_insert(zid);
242                }
243                for wref in &bus.dst {
244                    map.entry(wref.0 as u32).or_insert(zid);
245                }
246            }
247            for &wid in &zone.words_with_site_buses {
248                map.entry(wid).or_insert(zid);
249            }
250        }
251        for wid in 0..self.words.len() as u32 {
252            map.entry(wid).or_insert(0);
253        }
254        map
255    }
256
257    /// Return the set of "home" word IDs — the lower word in each entangling
258    /// pair, plus any word not appearing in any pair.
259    pub fn left_cz_word_ids(&self) -> Vec<u32> {
260        let partner = self.word_partner_map();
261        let mut paired: HashSet<u32> = HashSet::new();
262        let mut home: HashSet<u32> = HashSet::new();
263        for (&w_a, &w_b) in &partner {
264            paired.insert(w_a);
265            paired.insert(w_b);
266            home.insert(w_a.min(w_b));
267        }
268        for wid in 0..self.words.len() as u32 {
269            if !paired.contains(&wid) {
270                home.insert(wid);
271            }
272        }
273        let mut result: Vec<u32> = home.into_iter().collect();
274        result.sort();
275        result
276    }
277
278    /// Reverse-lookup: given (src, dst) location pair, find the LaneAddr
279    /// that connects them (if any).
280    ///
281    /// Searches SiteBus, WordBus, and ZoneBus lanes. The search is
282    /// narrowed by exploiting the LaneAddr encoding: the
283    /// `(zone_id, word_id, site_id)` in a lane address correspond to the
284    /// move's source location (Forward) or destination location (Backward).
285    /// So given `(src, dst)` we only iterate over `bus_id × move_type`
286    /// for each direction — typically <20 candidates total — rather than
287    /// enumerating every lane in the architecture.
288    ///
289    /// Membership lists (`words_with_site_buses`, `sites_with_word_buses`)
290    /// further prune: if the candidate word/site isn't in the relevant
291    /// list, that move type is skipped entirely.
292    pub fn lane_for_endpoints(&self, src: &LocationAddr, dst: &LocationAddr) -> Option<LaneAddr> {
293        // Try Forward: lane address fields come from src.
294        if let Some(lane) = self.try_lane_from_location(src, dst, Direction::Forward) {
295            return Some(lane);
296        }
297        // Try Backward: lane address fields come from dst.
298        self.try_lane_from_location(dst, src, Direction::Backward)
299    }
300
301    /// Helper for `lane_for_endpoints`: given the location that defines
302    /// the lane address fields (`origin`) and the expected other endpoint
303    /// (`target`), try each bus_id × move_type combination.
304    fn try_lane_from_location(
305        &self,
306        origin: &LocationAddr,
307        target: &LocationAddr,
308        direction: Direction,
309    ) -> Option<LaneAddr> {
310        let zone = self.zones.get(origin.zone_id as usize)?;
311
312        // SiteBus: only if origin's word is in words_with_site_buses.
313        if zone.words_with_site_buses.contains(&origin.word_id) {
314            for bus_id in 0..zone.site_buses.len() {
315                if let Some(lane) = self.check_lane_candidate(
316                    MoveType::SiteBus,
317                    origin,
318                    target,
319                    bus_id as u32,
320                    direction,
321                ) {
322                    return Some(lane);
323                }
324            }
325        }
326
327        // WordBus: only if origin's site is in sites_with_word_buses.
328        if zone.sites_with_word_buses.contains(&origin.site_id) {
329            for bus_id in 0..zone.word_buses.len() {
330                if let Some(lane) = self.check_lane_candidate(
331                    MoveType::WordBus,
332                    origin,
333                    target,
334                    bus_id as u32,
335                    direction,
336                ) {
337                    return Some(lane);
338                }
339            }
340        }
341
342        // ZoneBus: buses live on self (not per-zone).
343        for bus_id in 0..self.zone_buses.len() {
344            if let Some(lane) = self.check_lane_candidate(
345                MoveType::ZoneBus,
346                origin,
347                target,
348                bus_id as u32,
349                direction,
350            ) {
351                return Some(lane);
352            }
353        }
354
355        None
356    }
357
358    /// Get the flat index of a location within a zone — O(1).
359    ///
360    /// The index is `word_id * sites_per_word + site_id`. This relies on
361    /// the validated invariant that all words have the same number of sites
362    /// (`check_uniform_word_site_counts`).
363    ///
364    /// Returns `None` if `loc.zone_id != zone_id` or if word/site is out
365    /// of range.
366    pub fn zone_location_index(&self, loc: &LocationAddr, zone_id: u32) -> Option<usize> {
367        if loc.zone_id != zone_id {
368            return None;
369        }
370        let spw = self.sites_per_word();
371        let wid = loc.word_id as usize;
372        let sid = loc.site_id as usize;
373        if wid >= self.words.len() || sid >= spw {
374            return None;
375        }
376        Some(wid * spw + sid)
377    }
378
379    /// Construct a candidate `LaneAddr` from the origin location and
380    /// check whether its resolved endpoints match `(origin, target)`.
381    fn check_lane_candidate(
382        &self,
383        move_type: MoveType,
384        origin: &LocationAddr,
385        target: &LocationAddr,
386        bus_id: u32,
387        direction: Direction,
388    ) -> Option<LaneAddr> {
389        let lane = LaneAddr {
390            move_type,
391            zone_id: origin.zone_id,
392            word_id: origin.word_id,
393            site_id: origin.site_id,
394            bus_id,
395            direction,
396        };
397        let (s, d) = self.lane_endpoints(&lane)?;
398        let (expected_src, expected_dst) = match direction {
399            Direction::Forward => (s, d),
400            Direction::Backward => (d, s),
401        };
402        if expected_src == *origin && expected_dst == *target {
403            Some(lane)
404        } else {
405            None
406        }
407    }
408
409    // -- Position resolution --
410
411    /// Resolve a LocationAddr to physical (x, y) coordinates.
412    ///
413    /// Uses the zone's grid and the word's site index pair to compute
414    /// the physical position.
415    pub fn location_position(&self, loc: &LocationAddr) -> Option<(f64, f64)> {
416        let zone = self.zones.get(loc.zone_id as usize)?;
417        let word = self.words.get(loc.word_id as usize)?;
418        let site = word.sites.get(loc.site_id as usize)?;
419        let x = zone.grid.x_position(site[0] as usize)?;
420        let y = zone.grid.y_position(site[1] as usize)?;
421        Some((x, y))
422    }
423
424    /// Resolve a `LaneAddr` to its source and destination `LocationAddr` pair.
425    ///
426    /// Returns `Some((src, dst))` if the lane can be resolved through the bus,
427    /// or `None` if the lane references invalid zones, words, sites, or buses.
428    pub fn lane_endpoints(&self, lane: &LaneAddr) -> Option<(LocationAddr, LocationAddr)> {
429        // Validate the lane address up front so callers always get None
430        // for invalid lanes (e.g. out-of-range zone_id, word_id, or site_id).
431        if !self.check_lane(lane).is_empty() {
432            return None;
433        }
434
435        let zone = self.zone_by_id(lane.zone_id)?;
436
437        // In the lane address convention, site_id and word_id always encode
438        // the forward-direction source. The direction field only controls
439        // which endpoint is returned as src vs dst.
440        let fwd_src = LocationAddr {
441            zone_id: lane.zone_id,
442            word_id: lane.word_id,
443            site_id: lane.site_id,
444        };
445
446        let fwd_dst = match lane.move_type {
447            MoveType::SiteBus => {
448                let bus = zone.site_buses.get(lane.bus_id as usize)?;
449                let dst_site = bus.resolve_forward(lane.site_id as u16)?;
450                LocationAddr {
451                    zone_id: lane.zone_id,
452                    word_id: lane.word_id,
453                    site_id: dst_site as u32,
454                }
455            }
456            MoveType::WordBus => {
457                let bus = zone.word_buses.get(lane.bus_id as usize)?;
458                let dst_word = bus.resolve_forward(lane.word_id as u16)?;
459                LocationAddr {
460                    zone_id: lane.zone_id,
461                    word_id: dst_word as u32,
462                    site_id: lane.site_id,
463                }
464            }
465            MoveType::ZoneBus => {
466                let bus = self.zone_buses.get(lane.bus_id as usize)?;
467                let src_ref = ZonedWordRef {
468                    zone_id: lane.zone_id as u8,
469                    word_id: lane.word_id as u16,
470                };
471                let dst_ref = bus.resolve_forward(&src_ref)?;
472                LocationAddr {
473                    zone_id: dst_ref.zone_id as u32,
474                    word_id: dst_ref.word_id as u32,
475                    site_id: lane.site_id,
476                }
477            }
478        };
479
480        match lane.direction {
481            Direction::Forward => Some((fwd_src, fwd_dst)),
482            Direction::Backward => Some((fwd_dst, fwd_src)),
483        }
484    }
485
486    /// Whether a location sits in a "home" word — i.e. its `word_id` is in
487    /// [`Self::left_cz_word_ids`]. Used by the no-home placement strategy
488    /// to identify atoms still at their original home positions vs.
489    /// returners that need re-assigning.
490    pub fn is_home_position(&self, loc: &LocationAddr) -> bool {
491        self.left_cz_word_ids().contains(&loc.word_id)
492    }
493
494    /// Get the CZ partner for a given location.
495    ///
496    /// Searches `zones[loc.zone_id].entangling_pairs` for a pair containing
497    /// `loc.word_id`. Returns the partner in the **same zone** with the paired
498    /// word_id and same site_id. Returns `None` if the word is not in any
499    /// entangling pair within its zone.
500    pub fn get_cz_partner(&self, loc: &LocationAddr) -> Option<LocationAddr> {
501        let zone = self.zones.get(loc.zone_id as usize)?;
502        let partner_word = zone.entangling_pairs.iter().find_map(|pair| {
503            if pair[0] == loc.word_id {
504                Some(pair[1])
505            } else if pair[1] == loc.word_id {
506                Some(pair[0])
507            } else {
508                None
509            }
510        })?;
511        Some(LocationAddr {
512            zone_id: loc.zone_id,
513            word_id: partner_word,
514            site_id: loc.site_id,
515        })
516    }
517
518    /// Resolve a `(zone_id, row, col)` grid coordinate to a `LocationAddr`.
519    ///
520    /// `col` is the grid x-index and `row` the grid y-index within `zone_id`.
521    /// Returns the location whose word site sits at that grid position — a
522    /// unique `(word_id, site_id)` within the zone — or `None` if no atom
523    /// occupies it (or the zone doesn't exist). This is the authoritative
524    /// `(row, col) -> (word_id, site_id)` mapping for the architecture's
525    /// addressing scheme; callers depend only on this, not on the word/site
526    /// layout.
527    pub fn location_at(&self, zone_id: u32, row: u32, col: u32) -> Option<LocationAddr> {
528        // TODO: this does an O(words * sites) linear scan and rebuilds
529        // `word_zone_map()` on every call. Replace with a lazily-evaluated,
530        // cached `HashMap` keyed by `(zone_id, row, col) -> LocationAddr` so
531        // repeated lookups during compilation are O(1).
532        self.zones.get(zone_id as usize)?;
533        let word_zone = self.word_zone_map();
534        for (word_id, word) in self.words.iter().enumerate() {
535            let wid = word_id as u32;
536            if word_zone.get(&wid).copied().unwrap_or(0) != zone_id {
537                continue;
538            }
539            for (site_id, site) in word.sites.iter().enumerate() {
540                if site[0] == col && site[1] == row {
541                    return Some(LocationAddr {
542                        zone_id,
543                        word_id: wid,
544                        site_id: site_id as u32,
545                    });
546                }
547            }
548        }
549        None
550    }
551
552    // -- Address validation --
553
554    /// Check whether a location address (zone_id, word_id, site_id) is valid.
555    pub fn check_location(&self, loc: &LocationAddr) -> Option<String> {
556        let num_zones = self.zones.len() as u32;
557        let num_words = self.words.len() as u32;
558        let sites_per_word = self.sites_per_word() as u32;
559
560        if loc.zone_id >= num_zones {
561            return Some(format!(
562                "invalid location zone_id={} (num_zones={})",
563                loc.zone_id, num_zones
564            ));
565        }
566        if loc.word_id >= num_words {
567            return Some(format!(
568                "invalid location word_id={} (num_words={})",
569                loc.word_id, num_words
570            ));
571        }
572        if loc.site_id >= sites_per_word {
573            return Some(format!(
574                "invalid location site_id={} (sites_per_word={})",
575                loc.site_id, sites_per_word
576            ));
577        }
578        None
579    }
580
581    /// Check whether a lane address is valid.
582    ///
583    /// Validates that the zone and bus exist, word/site are in range, and the
584    /// site/word is a valid forward source for the bus. For SiteBus/WordBus,
585    /// buses are looked up from the zone. For ZoneBus, buses are looked up
586    /// from `self.zone_buses`.
587    pub fn check_lane(&self, addr: &LaneAddr) -> Vec<String> {
588        let num_zones = self.zones.len() as u32;
589        let num_words = self.words.len() as u32;
590        let sites_per_word = self.sites_per_word() as u32;
591        let mut errors = Vec::new();
592
593        // Validate zone_id first since other checks depend on it
594        if addr.zone_id >= num_zones {
595            errors.push(format!(
596                "zone_id {} out of range (num_zones={})",
597                addr.zone_id, num_zones
598            ));
599            return errors;
600        }
601
602        let zone = &self.zones[addr.zone_id as usize];
603
604        match addr.move_type {
605            MoveType::SiteBus => {
606                if addr.word_id >= num_words {
607                    errors.push(format!("word_id {} out of range", addr.word_id));
608                }
609                if addr.site_id >= sites_per_word {
610                    errors.push(format!("site_id {} out of range", addr.site_id));
611                }
612                if let Some(bus) = zone.site_buses.get(addr.bus_id as usize) {
613                    if addr.word_id < num_words
614                        && !zone.words_with_site_buses.contains(&addr.word_id)
615                    {
616                        errors.push(format!(
617                            "word_id {} not in zone {} words_with_site_buses",
618                            addr.word_id, addr.zone_id
619                        ));
620                    }
621                    if errors.is_empty() && bus.resolve_forward(addr.site_id as u16).is_none() {
622                        errors.push(format!(
623                            "site_id {} is not a valid source for zone {} site_bus {}",
624                            addr.site_id, addr.zone_id, addr.bus_id
625                        ));
626                    }
627                } else {
628                    errors.push(format!(
629                        "unknown site_bus id {} in zone {}",
630                        addr.bus_id, addr.zone_id
631                    ));
632                }
633            }
634            MoveType::WordBus => {
635                if addr.word_id >= num_words {
636                    errors.push(format!("word_id {} out of range", addr.word_id));
637                }
638                if addr.site_id >= sites_per_word {
639                    errors.push(format!("site_id {} out of range", addr.site_id));
640                } else if !zone.sites_with_word_buses.contains(&addr.site_id) {
641                    errors.push(format!(
642                        "site_id {} not in zone {} sites_with_word_buses",
643                        addr.site_id, addr.zone_id
644                    ));
645                }
646                if let Some(bus) = zone.word_buses.get(addr.bus_id as usize) {
647                    if errors.is_empty() && bus.resolve_forward(addr.word_id as u16).is_none() {
648                        errors.push(format!(
649                            "word_id {} is not a valid source for zone {} word_bus {}",
650                            addr.word_id, addr.zone_id, addr.bus_id
651                        ));
652                    }
653                } else {
654                    errors.push(format!(
655                        "unknown word_bus id {} in zone {}",
656                        addr.bus_id, addr.zone_id
657                    ));
658                }
659            }
660            MoveType::ZoneBus => {
661                if addr.word_id >= num_words {
662                    errors.push(format!("word_id {} out of range", addr.word_id));
663                }
664                if addr.site_id >= sites_per_word {
665                    errors.push(format!("site_id {} out of range", addr.site_id));
666                }
667                if let Some(bus) = self.zone_buses.get(addr.bus_id as usize) {
668                    let src_ref = ZonedWordRef {
669                        zone_id: addr.zone_id as u8,
670                        word_id: addr.word_id as u16,
671                    };
672                    if errors.is_empty() && bus.resolve_forward(&src_ref).is_none() {
673                        errors.push(format!(
674                            "zone_id={}, word_id={} is not a valid source for zone_bus {}",
675                            addr.zone_id, addr.word_id, addr.bus_id
676                        ));
677                    }
678                } else {
679                    errors.push(format!("unknown zone_bus id {}", addr.bus_id));
680                }
681            }
682        }
683        errors
684    }
685
686    /// Check whether a zone address is valid.
687    pub fn check_zone(&self, zone: &super::addr::ZoneAddr) -> Option<String> {
688        if self.zone_by_id(zone.zone_id).is_none() {
689            Some(format!("invalid zone_id={}", zone.zone_id))
690        } else {
691            None
692        }
693    }
694
695    // -- Group validation --
696
697    /// Check that a group of lanes share consistent bus_id, move_type, direction, and zone_id.
698    pub fn check_lane_group_consistency(&self, lanes: &[LaneAddr]) -> Vec<String> {
699        if lanes.is_empty() {
700            return vec![];
701        }
702        let first = &lanes[0];
703        let mut errors = Vec::new();
704
705        for lane in &lanes[1..] {
706            if lane.zone_id != first.zone_id {
707                errors.push(format!(
708                    "zone_id mismatch: expected {}, got {}",
709                    first.zone_id, lane.zone_id
710                ));
711            }
712            if lane.bus_id != first.bus_id {
713                errors.push(format!(
714                    "bus_id mismatch: expected {}, got {}",
715                    first.bus_id, lane.bus_id
716                ));
717            }
718            if lane.move_type != first.move_type {
719                errors.push(format!(
720                    "move_type mismatch: expected {:?}, got {:?}",
721                    first.move_type, lane.move_type
722                ));
723            }
724            if lane.direction != first.direction {
725                errors.push(format!(
726                    "direction mismatch: expected {:?}, got {:?}",
727                    first.direction, lane.direction
728                ));
729            }
730        }
731
732        errors
733    }
734
735    /// Check that each lane's word/site belongs to the correct zone's bus membership list.
736    ///
737    /// For SiteBus, checks zone's `words_with_site_buses`.
738    /// For WordBus, checks zone's `sites_with_word_buses`.
739    /// ZoneBus has no membership list (zone buses are global).
740    ///
741    /// Returns unique `(word_ids_not_in_site_bus_list, site_ids_not_in_word_bus_list)`.
742    pub fn check_lane_group_membership(&self, lanes: &[LaneAddr]) -> (Vec<u32>, Vec<u32>) {
743        use std::collections::BTreeSet;
744
745        let mut bad_words = BTreeSet::new();
746        let mut bad_sites = BTreeSet::new();
747
748        for lane in lanes {
749            let zone = match self.zones.get(lane.zone_id as usize) {
750                Some(z) => z,
751                None => continue, // zone validation handled elsewhere
752            };
753
754            match lane.move_type {
755                MoveType::SiteBus => {
756                    if !zone.words_with_site_buses.contains(&lane.word_id) {
757                        bad_words.insert(lane.word_id);
758                    }
759                }
760                MoveType::WordBus => {
761                    if !zone.sites_with_word_buses.contains(&lane.site_id) {
762                        bad_sites.insert(lane.site_id);
763                    }
764                }
765                MoveType::ZoneBus => {
766                    // Zone buses are global; no per-zone membership list.
767                }
768            }
769        }
770
771        (
772            bad_words.into_iter().collect(),
773            bad_sites.into_iter().collect(),
774        )
775    }
776
777    /// Validate a group of location addresses: checks each address against the
778    /// arch spec and checks for duplicates within the group.
779    pub fn check_locations(&self, locations: &[LocationAddr]) -> Vec<LocationGroupError> {
780        let mut errors = Vec::new();
781
782        // Check each unique address is valid (report once per unique address)
783        let mut checked = HashSet::new();
784        for loc in locations {
785            let bits = loc.encode();
786            if checked.insert(bits) && self.check_location(loc).is_some() {
787                errors.push(LocationGroupError::InvalidAddress {
788                    zone_id: loc.zone_id,
789                    word_id: loc.word_id,
790                    site_id: loc.site_id,
791                });
792            }
793        }
794
795        // Check for duplicates (report once per unique duplicated address)
796        let mut seen = HashSet::new();
797        let mut reported = HashSet::new();
798        for loc in locations {
799            let bits = loc.encode();
800            if !seen.insert(bits) && reported.insert(bits) {
801                errors.push(LocationGroupError::DuplicateAddress { address: bits });
802            }
803        }
804
805        errors
806    }
807
808    /// Validate a group of lane addresses: checks each address against the
809    /// arch spec, checks for duplicates, and (when more than one lane)
810    /// validates consistency, bus membership, and AOD constraints.
811    pub fn check_lanes(&self, lanes: &[LaneAddr]) -> Vec<LaneGroupError> {
812        let mut errors = Vec::new();
813
814        // Check each unique address is valid (report once per unique address)
815        let mut checked = HashSet::new();
816        for lane in lanes {
817            let bits = lane.encode();
818            if checked.insert(bits) {
819                for msg in self.check_lane(lane) {
820                    errors.push(LaneGroupError::InvalidLane { message: msg });
821                }
822            }
823        }
824
825        // Check for duplicates (report once per unique duplicated address)
826        let mut seen = HashSet::new();
827        let mut reported = HashSet::new();
828        for lane in lanes {
829            let pair = lane.encode();
830            if !seen.insert(pair) && reported.insert(pair) {
831                errors.push(LaneGroupError::DuplicateAddress { address: pair });
832            }
833        }
834
835        // Group-level checks (only meaningful with >1 lane)
836        if lanes.len() > 1 {
837            for msg in self.check_lane_group_consistency(lanes) {
838                errors.push(LaneGroupError::Inconsistent { message: msg });
839            }
840            let (bad_words, bad_sites) = self.check_lane_group_membership(lanes);
841            // Use the first lane's zone_id for error context (consistency already checked)
842            let zone_id = lanes[0].zone_id;
843            for word_id in bad_words {
844                errors.push(LaneGroupError::WordNotInSiteBusList { zone_id, word_id });
845            }
846            for site_id in bad_sites {
847                errors.push(LaneGroupError::SiteNotInWordBusList { zone_id, site_id });
848            }
849            for msg in self.check_lane_group_geometry(lanes) {
850                errors.push(LaneGroupError::AODConstraintViolation { message: msg });
851            }
852        }
853
854        errors
855    }
856
857    /// Check AOD grid constraint: lane positions must form a complete grid
858    /// (Cartesian product of unique X and Y values).
859    pub fn check_lane_group_geometry(&self, lanes: &[LaneAddr]) -> Vec<String> {
860        use std::collections::BTreeSet;
861
862        let positions: Vec<(f64, f64)> = lanes
863            .iter()
864            .filter_map(|lane| {
865                let loc = LocationAddr {
866                    zone_id: lane.zone_id,
867                    word_id: lane.word_id,
868                    site_id: lane.site_id,
869                };
870                self.location_position(&loc)
871            })
872            .collect();
873
874        if positions.len() != lanes.len() {
875            return vec!["some lane positions could not be resolved".to_string()];
876        }
877
878        let unique_x: BTreeSet<u64> = positions.iter().map(|(x, _)| x.to_bits()).collect();
879        let unique_y: BTreeSet<u64> = positions.iter().map(|(_, y)| y.to_bits()).collect();
880
881        let expected: BTreeSet<(u64, u64)> = unique_x
882            .iter()
883            .flat_map(|x| unique_y.iter().map(move |y| (*x, *y)))
884            .collect();
885
886        let actual: BTreeSet<(u64, u64)> = positions
887            .iter()
888            .map(|(x, y)| (x.to_bits(), y.to_bits()))
889            .collect();
890
891        if actual != expected {
892            vec![format!(
893                "lanes do not form a complete grid: expected {} positions ({}x * {}y), got {} unique positions",
894                expected.len(),
895                unique_x.len(),
896                unique_y.len(),
897                actual.len()
898            )]
899        } else {
900            vec![]
901        }
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908    use crate::arch::addr::{
909        Direction, LaneAddr, LocationAddr, MoveType, SiteRef, WordRef, ZoneAddr, ZonedWordRef,
910    };
911    use crate::arch::types::{Grid, Mode};
912    use crate::version::Version;
913
914    /// Create a valid two-zone arch spec for testing.
915    /// Mirrors the helper in validate.rs tests.
916    fn make_valid_two_zone_spec() -> ArchSpec {
917        let grid0 = Grid::from_positions(&[0.0, 5.0, 10.0], &[0.0, 3.0]);
918        // Zone 1 grid must not overlap zone 0 (x=[0,10], y=[0,3]).
919        let grid1 = Grid::from_positions(&[20.0, 27.5, 35.0], &[0.0, 4.0]);
920
921        ArchSpec {
922            version: Version::new(2, 0),
923            words: vec![
924                Word {
925                    sites: vec![[0, 0], [0, 1]],
926                },
927                Word {
928                    sites: vec![[1, 0], [1, 1]],
929                },
930            ],
931            zones: vec![
932                Zone {
933                    name: String::new(),
934                    grid: grid0,
935                    site_buses: vec![Bus {
936                        src: vec![SiteRef(0)],
937                        dst: vec![SiteRef(1)],
938                    }],
939                    word_buses: vec![Bus {
940                        src: vec![WordRef(0)],
941                        dst: vec![WordRef(1)],
942                    }],
943                    words_with_site_buses: vec![0, 1],
944                    sites_with_word_buses: vec![0],
945                    entangling_pairs: vec![[0, 1]],
946                },
947                Zone {
948                    name: String::new(),
949                    grid: grid1,
950                    site_buses: vec![],
951                    word_buses: vec![],
952                    words_with_site_buses: vec![],
953                    sites_with_word_buses: vec![],
954                    entangling_pairs: vec![],
955                },
956            ],
957            zone_buses: vec![Bus {
958                src: vec![ZonedWordRef {
959                    zone_id: 0,
960                    word_id: 0,
961                }],
962                dst: vec![ZonedWordRef {
963                    zone_id: 1,
964                    word_id: 0,
965                }],
966            }],
967            modes: vec![Mode {
968                name: "full".to_string(),
969                zones: vec![0, 1],
970                bitstring_order: vec![],
971            }],
972            paths: None,
973            feed_forward: false,
974            atom_reloading: false,
975            blockade_radius: None,
976        }
977    }
978
979    // ── location_position tests ──
980
981    #[test]
982    fn test_location_position_zone0() {
983        let spec = make_valid_two_zone_spec();
984        // Zone 0 grid: x=[0.0, 5.0, 10.0] y=[0.0, 3.0]
985        // Word 0: sites=[(0,0), (0,1)] -> site 0 at grid[0][0] = (0.0, 0.0)
986        let pos = spec.location_position(&LocationAddr {
987            zone_id: 0,
988            word_id: 0,
989            site_id: 0,
990        });
991        assert_eq!(pos, Some((0.0, 0.0)));
992    }
993
994    #[test]
995    fn test_location_position_zone0_site1() {
996        let spec = make_valid_two_zone_spec();
997        // Word 0: sites=[(0,0), (0,1)] -> site 1 at grid x[0]=0.0, y[1]=3.0
998        let pos = spec.location_position(&LocationAddr {
999            zone_id: 0,
1000            word_id: 0,
1001            site_id: 1,
1002        });
1003        assert_eq!(pos, Some((0.0, 3.0)));
1004    }
1005
1006    #[test]
1007    fn test_location_position_zone1() {
1008        let spec = make_valid_two_zone_spec();
1009        // Zone 1 grid: x=[20.0, 27.5, 35.0] y=[0.0, 4.0]
1010        // Word 1: sites=[(1,0), (1,1)] -> site 0 at grid x[1]=27.5, y[0]=0.0
1011        let pos = spec.location_position(&LocationAddr {
1012            zone_id: 1,
1013            word_id: 1,
1014            site_id: 0,
1015        });
1016        assert_eq!(pos, Some((27.5, 0.0)));
1017    }
1018
1019    #[test]
1020    fn test_location_position_invalid_zone() {
1021        let spec = make_valid_two_zone_spec();
1022        let pos = spec.location_position(&LocationAddr {
1023            zone_id: 99,
1024            word_id: 0,
1025            site_id: 0,
1026        });
1027        assert!(pos.is_none());
1028    }
1029
1030    #[test]
1031    fn test_location_position_invalid_word() {
1032        let spec = make_valid_two_zone_spec();
1033        let pos = spec.location_position(&LocationAddr {
1034            zone_id: 0,
1035            word_id: 99,
1036            site_id: 0,
1037        });
1038        assert!(pos.is_none());
1039    }
1040
1041    #[test]
1042    fn test_location_position_invalid_site() {
1043        let spec = make_valid_two_zone_spec();
1044        let pos = spec.location_position(&LocationAddr {
1045            zone_id: 0,
1046            word_id: 0,
1047            site_id: 99,
1048        });
1049        assert!(pos.is_none());
1050    }
1051
1052    // ── get_cz_partner tests ──
1053
1054    #[test]
1055    fn test_get_cz_partner() {
1056        let spec = make_valid_two_zone_spec();
1057        // Zone 0 has entangling_pairs: [[0, 1]] — word 0 paired with word 1
1058        let partner = spec.get_cz_partner(&LocationAddr {
1059            zone_id: 0,
1060            word_id: 0,
1061            site_id: 0,
1062        });
1063        assert_eq!(
1064            partner,
1065            Some(LocationAddr {
1066                zone_id: 0, // same zone
1067                word_id: 1, // partner word
1068                site_id: 0,
1069            })
1070        );
1071    }
1072
1073    #[test]
1074    fn test_get_cz_partner_reverse() {
1075        let spec = make_valid_two_zone_spec();
1076        // word 1 → word 0 (reverse direction within same zone)
1077        let partner = spec.get_cz_partner(&LocationAddr {
1078            zone_id: 0,
1079            word_id: 1,
1080            site_id: 1,
1081        });
1082        assert_eq!(
1083            partner,
1084            Some(LocationAddr {
1085                zone_id: 0,
1086                word_id: 0,
1087                site_id: 1,
1088            })
1089        );
1090    }
1091
1092    #[test]
1093    fn test_get_cz_partner_no_pair() {
1094        let spec = make_valid_two_zone_spec();
1095        // Zone 1 has no entangling pairs
1096        let partner = spec.get_cz_partner(&LocationAddr {
1097            zone_id: 1,
1098            word_id: 0,
1099            site_id: 0,
1100        });
1101        assert!(partner.is_none());
1102    }
1103
1104    // ── lane_endpoints tests ──
1105
1106    #[test]
1107    fn test_lane_endpoints_site_bus() {
1108        let spec = make_valid_two_zone_spec();
1109        // Zone 0 has site_bus: src=[SiteRef(0)] dst=[SiteRef(1)]
1110        let lane = LaneAddr {
1111            direction: Direction::Forward,
1112            move_type: MoveType::SiteBus,
1113            zone_id: 0,
1114            word_id: 0,
1115            site_id: 0,
1116            bus_id: 0,
1117        };
1118        let (src, dst) = spec.lane_endpoints(&lane).unwrap();
1119        assert_eq!(
1120            src,
1121            LocationAddr {
1122                zone_id: 0,
1123                word_id: 0,
1124                site_id: 0,
1125            }
1126        );
1127        assert_eq!(
1128            dst,
1129            LocationAddr {
1130                zone_id: 0,
1131                word_id: 0,
1132                site_id: 1,
1133            }
1134        );
1135    }
1136
1137    #[test]
1138    fn test_lane_endpoints_site_bus_backward() {
1139        let spec = make_valid_two_zone_spec();
1140        let lane = LaneAddr {
1141            direction: Direction::Backward,
1142            move_type: MoveType::SiteBus,
1143            zone_id: 0,
1144            word_id: 0,
1145            site_id: 0,
1146            bus_id: 0,
1147        };
1148        let (src, dst) = spec.lane_endpoints(&lane).unwrap();
1149        // Backward swaps: src is forward dst, dst is forward src
1150        assert_eq!(
1151            src,
1152            LocationAddr {
1153                zone_id: 0,
1154                word_id: 0,
1155                site_id: 1,
1156            }
1157        );
1158        assert_eq!(
1159            dst,
1160            LocationAddr {
1161                zone_id: 0,
1162                word_id: 0,
1163                site_id: 0,
1164            }
1165        );
1166    }
1167
1168    #[test]
1169    fn test_lane_endpoints_word_bus() {
1170        let spec = make_valid_two_zone_spec();
1171        // Zone 0 has word_bus: src=[WordRef(0)] dst=[WordRef(1)]
1172        let lane = LaneAddr {
1173            direction: Direction::Forward,
1174            move_type: MoveType::WordBus,
1175            zone_id: 0,
1176            word_id: 0,
1177            site_id: 0,
1178            bus_id: 0,
1179        };
1180        let (src, dst) = spec.lane_endpoints(&lane).unwrap();
1181        assert_eq!(
1182            src,
1183            LocationAddr {
1184                zone_id: 0,
1185                word_id: 0,
1186                site_id: 0,
1187            }
1188        );
1189        assert_eq!(
1190            dst,
1191            LocationAddr {
1192                zone_id: 0,
1193                word_id: 1,
1194                site_id: 0,
1195            }
1196        );
1197    }
1198
1199    #[test]
1200    fn test_lane_endpoints_zone_bus() {
1201        let spec = make_valid_two_zone_spec();
1202        // zone_bus: src=[ZWR(0,0)] dst=[ZWR(1,0)]
1203        let lane = LaneAddr {
1204            direction: Direction::Forward,
1205            move_type: MoveType::ZoneBus,
1206            zone_id: 0,
1207            word_id: 0,
1208            site_id: 0,
1209            bus_id: 0,
1210        };
1211        let (src, dst) = spec.lane_endpoints(&lane).unwrap();
1212        assert_eq!(
1213            src,
1214            LocationAddr {
1215                zone_id: 0,
1216                word_id: 0,
1217                site_id: 0,
1218            }
1219        );
1220        assert_eq!(
1221            dst,
1222            LocationAddr {
1223                zone_id: 1,
1224                word_id: 0,
1225                site_id: 0,
1226            }
1227        );
1228    }
1229
1230    #[test]
1231    fn test_lane_endpoints_invalid_bus_returns_none() {
1232        let spec = make_valid_two_zone_spec();
1233        let lane = LaneAddr {
1234            direction: Direction::Forward,
1235            move_type: MoveType::SiteBus,
1236            zone_id: 0,
1237            word_id: 0,
1238            site_id: 0,
1239            bus_id: 99,
1240        };
1241        assert!(spec.lane_endpoints(&lane).is_none());
1242    }
1243
1244    // ── JSON round-trip tests ──
1245
1246    #[test]
1247    fn test_json_round_trip() {
1248        let spec = make_valid_two_zone_spec();
1249        let json = serde_json::to_string_pretty(&spec).unwrap();
1250        let deserialized = ArchSpec::from_json(&json).unwrap();
1251        assert_eq!(spec, deserialized);
1252    }
1253
1254    #[test]
1255    fn test_from_json_validated() {
1256        let spec = make_valid_two_zone_spec();
1257        let json = serde_json::to_string(&spec).unwrap();
1258        let validated = ArchSpec::from_json_validated(&json).unwrap();
1259        assert_eq!(spec, validated);
1260    }
1261
1262    #[test]
1263    fn test_from_json_validated_invalid() {
1264        let json = r#"{"version": "1.0"}"#;
1265        let result = ArchSpec::from_json_validated(json);
1266        assert!(result.is_err());
1267    }
1268
1269    // ── word/zone lookup tests ──
1270
1271    #[test]
1272    fn test_word_by_id_found() {
1273        let spec = make_valid_two_zone_spec();
1274        let word = spec.word_by_id(0).unwrap();
1275        assert_eq!(word.sites.len(), 2);
1276    }
1277
1278    #[test]
1279    fn test_word_by_id_not_found() {
1280        let spec = make_valid_two_zone_spec();
1281        assert!(spec.word_by_id(99).is_none());
1282    }
1283
1284    #[test]
1285    fn test_zone_by_id_found() {
1286        let spec = make_valid_two_zone_spec();
1287        let zone = spec.zone_by_id(0).unwrap();
1288        assert_eq!(zone.site_buses.len(), 1);
1289    }
1290
1291    #[test]
1292    fn test_zone_by_id_not_found() {
1293        let spec = make_valid_two_zone_spec();
1294        assert!(spec.zone_by_id(99).is_none());
1295    }
1296
1297    // ── Bus resolve tests ──
1298
1299    #[test]
1300    fn test_site_bus_resolve_forward() {
1301        let spec = make_valid_two_zone_spec();
1302        let bus = &spec.zones[0].site_buses[0];
1303        assert_eq!(bus.resolve_forward(0), Some(1));
1304        assert_eq!(bus.resolve_forward(99), None);
1305    }
1306
1307    #[test]
1308    fn test_site_bus_resolve_backward() {
1309        let spec = make_valid_two_zone_spec();
1310        let bus = &spec.zones[0].site_buses[0];
1311        assert_eq!(bus.resolve_backward(1), Some(0));
1312        assert_eq!(bus.resolve_backward(99), None);
1313    }
1314
1315    #[test]
1316    fn test_word_bus_resolve_forward() {
1317        let spec = make_valid_two_zone_spec();
1318        let bus = &spec.zones[0].word_buses[0];
1319        assert_eq!(bus.resolve_forward(0), Some(1));
1320        assert_eq!(bus.resolve_forward(99), None);
1321    }
1322
1323    #[test]
1324    fn test_word_bus_resolve_backward() {
1325        let spec = make_valid_two_zone_spec();
1326        let bus = &spec.zones[0].word_buses[0];
1327        assert_eq!(bus.resolve_backward(1), Some(0));
1328        assert_eq!(bus.resolve_backward(99), None);
1329    }
1330
1331    #[test]
1332    fn test_zone_bus_resolve_forward() {
1333        let spec = make_valid_two_zone_spec();
1334        let bus = &spec.zone_buses[0];
1335        let src = ZonedWordRef {
1336            zone_id: 0,
1337            word_id: 0,
1338        };
1339        let dst = bus.resolve_forward(&src).unwrap();
1340        assert_eq!(dst.zone_id, 1);
1341        assert_eq!(dst.word_id, 0);
1342    }
1343
1344    #[test]
1345    fn test_zone_bus_resolve_backward() {
1346        let spec = make_valid_two_zone_spec();
1347        let bus = &spec.zone_buses[0];
1348        let dst = ZonedWordRef {
1349            zone_id: 1,
1350            word_id: 0,
1351        };
1352        let src = bus.resolve_backward(&dst).unwrap();
1353        assert_eq!(src.zone_id, 0);
1354        assert_eq!(src.word_id, 0);
1355    }
1356
1357    // ── check_location tests ──
1358
1359    #[test]
1360    fn test_check_location_valid() {
1361        let spec = make_valid_two_zone_spec();
1362        assert!(
1363            spec.check_location(&LocationAddr {
1364                zone_id: 0,
1365                word_id: 0,
1366                site_id: 0,
1367            })
1368            .is_none()
1369        );
1370    }
1371
1372    #[test]
1373    fn test_check_location_invalid_zone() {
1374        let spec = make_valid_two_zone_spec();
1375        let err = spec
1376            .check_location(&LocationAddr {
1377                zone_id: 99,
1378                word_id: 0,
1379                site_id: 0,
1380            })
1381            .unwrap();
1382        assert!(err.contains("zone_id"));
1383    }
1384
1385    // ── check_lane tests ──
1386
1387    #[test]
1388    fn test_check_lane_valid_site_bus() {
1389        let spec = make_valid_two_zone_spec();
1390        let lane = LaneAddr {
1391            direction: Direction::Forward,
1392            move_type: MoveType::SiteBus,
1393            zone_id: 0,
1394            word_id: 0,
1395            site_id: 0,
1396            bus_id: 0,
1397        };
1398        assert!(spec.check_lane(&lane).is_empty());
1399    }
1400
1401    #[test]
1402    fn test_check_lane_invalid_zone() {
1403        let spec = make_valid_two_zone_spec();
1404        let lane = LaneAddr {
1405            direction: Direction::Forward,
1406            move_type: MoveType::SiteBus,
1407            zone_id: 99,
1408            word_id: 0,
1409            site_id: 0,
1410            bus_id: 0,
1411        };
1412        let errors = spec.check_lane(&lane);
1413        assert!(!errors.is_empty());
1414        assert!(errors[0].contains("zone_id"));
1415    }
1416
1417    #[test]
1418    fn test_check_lane_invalid_bus() {
1419        let spec = make_valid_two_zone_spec();
1420        let lane = LaneAddr {
1421            direction: Direction::Forward,
1422            move_type: MoveType::SiteBus,
1423            zone_id: 0,
1424            word_id: 0,
1425            site_id: 0,
1426            bus_id: 99,
1427        };
1428        let errors = spec.check_lane(&lane);
1429        assert!(!errors.is_empty());
1430    }
1431
1432    #[test]
1433    fn test_check_lane_zone_bus_valid() {
1434        let spec = make_valid_two_zone_spec();
1435        let lane = LaneAddr {
1436            direction: Direction::Forward,
1437            move_type: MoveType::ZoneBus,
1438            zone_id: 0,
1439            word_id: 0,
1440            site_id: 0,
1441            bus_id: 0,
1442        };
1443        assert!(spec.check_lane(&lane).is_empty());
1444    }
1445
1446    #[test]
1447    fn test_check_lane_zone_bus_invalid_bus() {
1448        let spec = make_valid_two_zone_spec();
1449        let lane = LaneAddr {
1450            direction: Direction::Forward,
1451            move_type: MoveType::ZoneBus,
1452            zone_id: 0,
1453            word_id: 0,
1454            site_id: 0,
1455            bus_id: 99,
1456        };
1457        let errors = spec.check_lane(&lane);
1458        assert!(!errors.is_empty());
1459        assert!(errors[0].contains("zone_bus"));
1460    }
1461
1462    // ── check_zone tests ──
1463
1464    #[test]
1465    fn test_check_zone_valid() {
1466        let spec = make_valid_two_zone_spec();
1467        assert!(spec.check_zone(&ZoneAddr { zone_id: 0 }).is_none());
1468    }
1469
1470    #[test]
1471    fn test_check_zone_invalid() {
1472        let spec = make_valid_two_zone_spec();
1473        assert!(spec.check_zone(&ZoneAddr { zone_id: 99 }).is_some());
1474    }
1475
1476    // ── check_lane_group_consistency tests ──
1477
1478    #[test]
1479    fn test_check_lane_group_consistency_empty() {
1480        let spec = make_valid_two_zone_spec();
1481        assert!(spec.check_lane_group_consistency(&[]).is_empty());
1482    }
1483
1484    #[test]
1485    fn test_check_lane_group_consistency_zone_mismatch() {
1486        let spec = make_valid_two_zone_spec();
1487        let lanes = vec![
1488            LaneAddr {
1489                direction: Direction::Forward,
1490                move_type: MoveType::SiteBus,
1491                zone_id: 0,
1492                word_id: 0,
1493                site_id: 0,
1494                bus_id: 0,
1495            },
1496            LaneAddr {
1497                direction: Direction::Forward,
1498                move_type: MoveType::SiteBus,
1499                zone_id: 1,
1500                word_id: 0,
1501                site_id: 0,
1502                bus_id: 0,
1503            },
1504        ];
1505        let errors = spec.check_lane_group_consistency(&lanes);
1506        assert!(!errors.is_empty());
1507        assert!(errors[0].contains("zone_id mismatch"));
1508    }
1509
1510    // ── check_locations tests ──
1511
1512    #[test]
1513    fn test_check_locations_valid() {
1514        let spec = make_valid_two_zone_spec();
1515        let locs = vec![
1516            LocationAddr {
1517                zone_id: 0,
1518                word_id: 0,
1519                site_id: 0,
1520            },
1521            LocationAddr {
1522                zone_id: 0,
1523                word_id: 0,
1524                site_id: 1,
1525            },
1526        ];
1527        assert!(spec.check_locations(&locs).is_empty());
1528    }
1529
1530    #[test]
1531    fn test_check_locations_duplicate() {
1532        let spec = make_valid_two_zone_spec();
1533        let locs = vec![
1534            LocationAddr {
1535                zone_id: 0,
1536                word_id: 0,
1537                site_id: 0,
1538            },
1539            LocationAddr {
1540                zone_id: 0,
1541                word_id: 0,
1542                site_id: 0,
1543            },
1544        ];
1545        let errors = spec.check_locations(&locs);
1546        assert!(
1547            errors
1548                .iter()
1549                .any(|e| matches!(e, LocationGroupError::DuplicateAddress { .. }))
1550        );
1551    }
1552
1553    #[test]
1554    fn test_check_locations_invalid() {
1555        let spec = make_valid_two_zone_spec();
1556        let locs = vec![LocationAddr {
1557            zone_id: 99,
1558            word_id: 0,
1559            site_id: 0,
1560        }];
1561        let errors = spec.check_locations(&locs);
1562        assert!(
1563            errors
1564                .iter()
1565                .any(|e| matches!(e, LocationGroupError::InvalidAddress { .. }))
1566        );
1567    }
1568
1569    // ── Derived topology query tests (#464 phase 2) ──
1570
1571    #[test]
1572    fn test_word_partner_map() {
1573        let spec = make_valid_two_zone_spec();
1574        let map = spec.word_partner_map();
1575        // Zone 0 has entangling_pairs=[[0, 1]], zone 1 has none.
1576        assert_eq!(map.get(&0), Some(&1));
1577        assert_eq!(map.get(&1), Some(&0));
1578        assert_eq!(map.len(), 2);
1579    }
1580
1581    #[test]
1582    fn test_word_zone_map() {
1583        let spec = make_valid_two_zone_spec();
1584        let map = spec.word_zone_map();
1585        // Words 0 and 1 are referenced by zone 0 (entangling_pairs + buses).
1586        assert_eq!(map.get(&0), Some(&0));
1587        assert_eq!(map.get(&1), Some(&0));
1588        assert_eq!(map.len(), 2); // exactly 2 words
1589    }
1590
1591    #[test]
1592    fn test_left_cz_word_ids() {
1593        let spec = make_valid_two_zone_spec();
1594        let home = spec.left_cz_word_ids();
1595        // Pair [0, 1] -> home word is 0. Word 1 is the staging word.
1596        // But there are only 2 words and they're all paired, so home = [0].
1597        assert_eq!(home, vec![0]);
1598    }
1599
1600    #[test]
1601    fn test_is_home_position() {
1602        let spec = make_valid_two_zone_spec();
1603        // Per `test_left_cz_word_ids`, only word_id 0 is a home word.
1604        let home = LocationAddr {
1605            zone_id: 0,
1606            word_id: 0,
1607            site_id: 0,
1608        };
1609        let staging = LocationAddr {
1610            zone_id: 0,
1611            word_id: 1,
1612            site_id: 0,
1613        };
1614        assert!(spec.is_home_position(&home));
1615        assert!(!spec.is_home_position(&staging));
1616    }
1617
1618    #[test]
1619    fn test_lane_for_endpoints_site_bus() {
1620        let spec = make_valid_two_zone_spec();
1621        // Zone 0 has site_bus: src=[SiteRef(0)] dst=[SiteRef(1)], words_with_site_buses=[0,1].
1622        // For word 0, site bus maps site 0 -> site 1.
1623        let src = LocationAddr {
1624            zone_id: 0,
1625            word_id: 0,
1626            site_id: 0,
1627        };
1628        let dst = LocationAddr {
1629            zone_id: 0,
1630            word_id: 0,
1631            site_id: 1,
1632        };
1633        let lane = spec.lane_for_endpoints(&src, &dst);
1634        assert!(lane.is_some(), "should find a lane for (src, dst)");
1635        let l = lane.unwrap();
1636        assert_eq!(l.move_type, MoveType::SiteBus);
1637        assert_eq!(l.direction, Direction::Forward);
1638    }
1639
1640    #[test]
1641    fn test_lane_for_endpoints_word_bus() {
1642        let spec = make_valid_two_zone_spec();
1643        // Zone 0 word_bus: src=[WordRef(0)] dst=[WordRef(1)], sites_with_word_buses=[0].
1644        let src = LocationAddr {
1645            zone_id: 0,
1646            word_id: 0,
1647            site_id: 0,
1648        };
1649        let dst = LocationAddr {
1650            zone_id: 0,
1651            word_id: 1,
1652            site_id: 0,
1653        };
1654        let lane = spec.lane_for_endpoints(&src, &dst);
1655        assert!(lane.is_some(), "should find a word-bus lane");
1656        let l = lane.unwrap();
1657        assert_eq!(l.move_type, MoveType::WordBus);
1658        assert_eq!(l.direction, Direction::Forward);
1659    }
1660
1661    #[test]
1662    fn test_lane_for_endpoints_not_found() {
1663        let spec = make_valid_two_zone_spec();
1664        // No lane connects word 0 site 0 to word 1 site 1 (different site ids
1665        // across a word bus move).
1666        let src = LocationAddr {
1667            zone_id: 0,
1668            word_id: 0,
1669            site_id: 0,
1670        };
1671        let dst = LocationAddr {
1672            zone_id: 0,
1673            word_id: 1,
1674            site_id: 1,
1675        };
1676        assert!(spec.lane_for_endpoints(&src, &dst).is_none());
1677    }
1678}