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    // -- Address validation --
519
520    /// Check whether a location address (zone_id, word_id, site_id) is valid.
521    pub fn check_location(&self, loc: &LocationAddr) -> Option<String> {
522        let num_zones = self.zones.len() as u32;
523        let num_words = self.words.len() as u32;
524        let sites_per_word = self.sites_per_word() as u32;
525
526        if loc.zone_id >= num_zones {
527            return Some(format!(
528                "invalid location zone_id={} (num_zones={})",
529                loc.zone_id, num_zones
530            ));
531        }
532        if loc.word_id >= num_words {
533            return Some(format!(
534                "invalid location word_id={} (num_words={})",
535                loc.word_id, num_words
536            ));
537        }
538        if loc.site_id >= sites_per_word {
539            return Some(format!(
540                "invalid location site_id={} (sites_per_word={})",
541                loc.site_id, sites_per_word
542            ));
543        }
544        None
545    }
546
547    /// Check whether a lane address is valid.
548    ///
549    /// Validates that the zone and bus exist, word/site are in range, and the
550    /// site/word is a valid forward source for the bus. For SiteBus/WordBus,
551    /// buses are looked up from the zone. For ZoneBus, buses are looked up
552    /// from `self.zone_buses`.
553    pub fn check_lane(&self, addr: &LaneAddr) -> Vec<String> {
554        let num_zones = self.zones.len() as u32;
555        let num_words = self.words.len() as u32;
556        let sites_per_word = self.sites_per_word() as u32;
557        let mut errors = Vec::new();
558
559        // Validate zone_id first since other checks depend on it
560        if addr.zone_id >= num_zones {
561            errors.push(format!(
562                "zone_id {} out of range (num_zones={})",
563                addr.zone_id, num_zones
564            ));
565            return errors;
566        }
567
568        let zone = &self.zones[addr.zone_id as usize];
569
570        match addr.move_type {
571            MoveType::SiteBus => {
572                if addr.word_id >= num_words {
573                    errors.push(format!("word_id {} out of range", addr.word_id));
574                }
575                if addr.site_id >= sites_per_word {
576                    errors.push(format!("site_id {} out of range", addr.site_id));
577                }
578                if let Some(bus) = zone.site_buses.get(addr.bus_id as usize) {
579                    if addr.word_id < num_words
580                        && !zone.words_with_site_buses.contains(&addr.word_id)
581                    {
582                        errors.push(format!(
583                            "word_id {} not in zone {} words_with_site_buses",
584                            addr.word_id, addr.zone_id
585                        ));
586                    }
587                    if errors.is_empty() && bus.resolve_forward(addr.site_id as u16).is_none() {
588                        errors.push(format!(
589                            "site_id {} is not a valid source for zone {} site_bus {}",
590                            addr.site_id, addr.zone_id, addr.bus_id
591                        ));
592                    }
593                } else {
594                    errors.push(format!(
595                        "unknown site_bus id {} in zone {}",
596                        addr.bus_id, addr.zone_id
597                    ));
598                }
599            }
600            MoveType::WordBus => {
601                if addr.word_id >= num_words {
602                    errors.push(format!("word_id {} out of range", addr.word_id));
603                }
604                if addr.site_id >= sites_per_word {
605                    errors.push(format!("site_id {} out of range", addr.site_id));
606                } else if !zone.sites_with_word_buses.contains(&addr.site_id) {
607                    errors.push(format!(
608                        "site_id {} not in zone {} sites_with_word_buses",
609                        addr.site_id, addr.zone_id
610                    ));
611                }
612                if let Some(bus) = zone.word_buses.get(addr.bus_id as usize) {
613                    if errors.is_empty() && bus.resolve_forward(addr.word_id as u16).is_none() {
614                        errors.push(format!(
615                            "word_id {} is not a valid source for zone {} word_bus {}",
616                            addr.word_id, addr.zone_id, addr.bus_id
617                        ));
618                    }
619                } else {
620                    errors.push(format!(
621                        "unknown word_bus id {} in zone {}",
622                        addr.bus_id, addr.zone_id
623                    ));
624                }
625            }
626            MoveType::ZoneBus => {
627                if addr.word_id >= num_words {
628                    errors.push(format!("word_id {} out of range", addr.word_id));
629                }
630                if addr.site_id >= sites_per_word {
631                    errors.push(format!("site_id {} out of range", addr.site_id));
632                }
633                if let Some(bus) = self.zone_buses.get(addr.bus_id as usize) {
634                    let src_ref = ZonedWordRef {
635                        zone_id: addr.zone_id as u8,
636                        word_id: addr.word_id as u16,
637                    };
638                    if errors.is_empty() && bus.resolve_forward(&src_ref).is_none() {
639                        errors.push(format!(
640                            "zone_id={}, word_id={} is not a valid source for zone_bus {}",
641                            addr.zone_id, addr.word_id, addr.bus_id
642                        ));
643                    }
644                } else {
645                    errors.push(format!("unknown zone_bus id {}", addr.bus_id));
646                }
647            }
648        }
649        errors
650    }
651
652    /// Check whether a zone address is valid.
653    pub fn check_zone(&self, zone: &super::addr::ZoneAddr) -> Option<String> {
654        if self.zone_by_id(zone.zone_id).is_none() {
655            Some(format!("invalid zone_id={}", zone.zone_id))
656        } else {
657            None
658        }
659    }
660
661    // -- Group validation --
662
663    /// Check that a group of lanes share consistent bus_id, move_type, direction, and zone_id.
664    pub fn check_lane_group_consistency(&self, lanes: &[LaneAddr]) -> Vec<String> {
665        if lanes.is_empty() {
666            return vec![];
667        }
668        let first = &lanes[0];
669        let mut errors = Vec::new();
670
671        for lane in &lanes[1..] {
672            if lane.zone_id != first.zone_id {
673                errors.push(format!(
674                    "zone_id mismatch: expected {}, got {}",
675                    first.zone_id, lane.zone_id
676                ));
677            }
678            if lane.bus_id != first.bus_id {
679                errors.push(format!(
680                    "bus_id mismatch: expected {}, got {}",
681                    first.bus_id, lane.bus_id
682                ));
683            }
684            if lane.move_type != first.move_type {
685                errors.push(format!(
686                    "move_type mismatch: expected {:?}, got {:?}",
687                    first.move_type, lane.move_type
688                ));
689            }
690            if lane.direction != first.direction {
691                errors.push(format!(
692                    "direction mismatch: expected {:?}, got {:?}",
693                    first.direction, lane.direction
694                ));
695            }
696        }
697
698        errors
699    }
700
701    /// Check that each lane's word/site belongs to the correct zone's bus membership list.
702    ///
703    /// For SiteBus, checks zone's `words_with_site_buses`.
704    /// For WordBus, checks zone's `sites_with_word_buses`.
705    /// ZoneBus has no membership list (zone buses are global).
706    ///
707    /// Returns unique `(word_ids_not_in_site_bus_list, site_ids_not_in_word_bus_list)`.
708    pub fn check_lane_group_membership(&self, lanes: &[LaneAddr]) -> (Vec<u32>, Vec<u32>) {
709        use std::collections::BTreeSet;
710
711        let mut bad_words = BTreeSet::new();
712        let mut bad_sites = BTreeSet::new();
713
714        for lane in lanes {
715            let zone = match self.zones.get(lane.zone_id as usize) {
716                Some(z) => z,
717                None => continue, // zone validation handled elsewhere
718            };
719
720            match lane.move_type {
721                MoveType::SiteBus => {
722                    if !zone.words_with_site_buses.contains(&lane.word_id) {
723                        bad_words.insert(lane.word_id);
724                    }
725                }
726                MoveType::WordBus => {
727                    if !zone.sites_with_word_buses.contains(&lane.site_id) {
728                        bad_sites.insert(lane.site_id);
729                    }
730                }
731                MoveType::ZoneBus => {
732                    // Zone buses are global; no per-zone membership list.
733                }
734            }
735        }
736
737        (
738            bad_words.into_iter().collect(),
739            bad_sites.into_iter().collect(),
740        )
741    }
742
743    /// Validate a group of location addresses: checks each address against the
744    /// arch spec and checks for duplicates within the group.
745    pub fn check_locations(&self, locations: &[LocationAddr]) -> Vec<LocationGroupError> {
746        let mut errors = Vec::new();
747
748        // Check each unique address is valid (report once per unique address)
749        let mut checked = HashSet::new();
750        for loc in locations {
751            let bits = loc.encode();
752            if checked.insert(bits) && self.check_location(loc).is_some() {
753                errors.push(LocationGroupError::InvalidAddress {
754                    zone_id: loc.zone_id,
755                    word_id: loc.word_id,
756                    site_id: loc.site_id,
757                });
758            }
759        }
760
761        // Check for duplicates (report once per unique duplicated address)
762        let mut seen = HashSet::new();
763        let mut reported = HashSet::new();
764        for loc in locations {
765            let bits = loc.encode();
766            if !seen.insert(bits) && reported.insert(bits) {
767                errors.push(LocationGroupError::DuplicateAddress { address: bits });
768            }
769        }
770
771        errors
772    }
773
774    /// Validate a group of lane addresses: checks each address against the
775    /// arch spec, checks for duplicates, and (when more than one lane)
776    /// validates consistency, bus membership, and AOD constraints.
777    pub fn check_lanes(&self, lanes: &[LaneAddr]) -> Vec<LaneGroupError> {
778        let mut errors = Vec::new();
779
780        // Check each unique address is valid (report once per unique address)
781        let mut checked = HashSet::new();
782        for lane in lanes {
783            let bits = lane.encode();
784            if checked.insert(bits) {
785                for msg in self.check_lane(lane) {
786                    errors.push(LaneGroupError::InvalidLane { message: msg });
787                }
788            }
789        }
790
791        // Check for duplicates (report once per unique duplicated address)
792        let mut seen = HashSet::new();
793        let mut reported = HashSet::new();
794        for lane in lanes {
795            let pair = lane.encode();
796            if !seen.insert(pair) && reported.insert(pair) {
797                errors.push(LaneGroupError::DuplicateAddress { address: pair });
798            }
799        }
800
801        // Group-level checks (only meaningful with >1 lane)
802        if lanes.len() > 1 {
803            for msg in self.check_lane_group_consistency(lanes) {
804                errors.push(LaneGroupError::Inconsistent { message: msg });
805            }
806            let (bad_words, bad_sites) = self.check_lane_group_membership(lanes);
807            // Use the first lane's zone_id for error context (consistency already checked)
808            let zone_id = lanes[0].zone_id;
809            for word_id in bad_words {
810                errors.push(LaneGroupError::WordNotInSiteBusList { zone_id, word_id });
811            }
812            for site_id in bad_sites {
813                errors.push(LaneGroupError::SiteNotInWordBusList { zone_id, site_id });
814            }
815            for msg in self.check_lane_group_geometry(lanes) {
816                errors.push(LaneGroupError::AODConstraintViolation { message: msg });
817            }
818        }
819
820        errors
821    }
822
823    /// Check AOD grid constraint: lane positions must form a complete grid
824    /// (Cartesian product of unique X and Y values).
825    pub fn check_lane_group_geometry(&self, lanes: &[LaneAddr]) -> Vec<String> {
826        use std::collections::BTreeSet;
827
828        let positions: Vec<(f64, f64)> = lanes
829            .iter()
830            .filter_map(|lane| {
831                let loc = LocationAddr {
832                    zone_id: lane.zone_id,
833                    word_id: lane.word_id,
834                    site_id: lane.site_id,
835                };
836                self.location_position(&loc)
837            })
838            .collect();
839
840        if positions.len() != lanes.len() {
841            return vec!["some lane positions could not be resolved".to_string()];
842        }
843
844        let unique_x: BTreeSet<u64> = positions.iter().map(|(x, _)| x.to_bits()).collect();
845        let unique_y: BTreeSet<u64> = positions.iter().map(|(_, y)| y.to_bits()).collect();
846
847        let expected: BTreeSet<(u64, u64)> = unique_x
848            .iter()
849            .flat_map(|x| unique_y.iter().map(move |y| (*x, *y)))
850            .collect();
851
852        let actual: BTreeSet<(u64, u64)> = positions
853            .iter()
854            .map(|(x, y)| (x.to_bits(), y.to_bits()))
855            .collect();
856
857        if actual != expected {
858            vec![format!(
859                "lanes do not form a complete grid: expected {} positions ({}x * {}y), got {} unique positions",
860                expected.len(),
861                unique_x.len(),
862                unique_y.len(),
863                actual.len()
864            )]
865        } else {
866            vec![]
867        }
868    }
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874    use crate::arch::addr::{
875        Direction, LaneAddr, LocationAddr, MoveType, SiteRef, WordRef, ZoneAddr, ZonedWordRef,
876    };
877    use crate::arch::types::{Grid, Mode};
878    use crate::version::Version;
879
880    /// Create a valid two-zone arch spec for testing.
881    /// Mirrors the helper in validate.rs tests.
882    fn make_valid_two_zone_spec() -> ArchSpec {
883        let grid0 = Grid::from_positions(&[0.0, 5.0, 10.0], &[0.0, 3.0]);
884        // Zone 1 grid must not overlap zone 0 (x=[0,10], y=[0,3]).
885        let grid1 = Grid::from_positions(&[20.0, 27.5, 35.0], &[0.0, 4.0]);
886
887        ArchSpec {
888            version: Version::new(2, 0),
889            words: vec![
890                Word {
891                    sites: vec![[0, 0], [0, 1]],
892                },
893                Word {
894                    sites: vec![[1, 0], [1, 1]],
895                },
896            ],
897            zones: vec![
898                Zone {
899                    name: String::new(),
900                    grid: grid0,
901                    site_buses: vec![Bus {
902                        src: vec![SiteRef(0)],
903                        dst: vec![SiteRef(1)],
904                    }],
905                    word_buses: vec![Bus {
906                        src: vec![WordRef(0)],
907                        dst: vec![WordRef(1)],
908                    }],
909                    words_with_site_buses: vec![0, 1],
910                    sites_with_word_buses: vec![0],
911                    entangling_pairs: vec![[0, 1]],
912                },
913                Zone {
914                    name: String::new(),
915                    grid: grid1,
916                    site_buses: vec![],
917                    word_buses: vec![],
918                    words_with_site_buses: vec![],
919                    sites_with_word_buses: vec![],
920                    entangling_pairs: vec![],
921                },
922            ],
923            zone_buses: vec![Bus {
924                src: vec![ZonedWordRef {
925                    zone_id: 0,
926                    word_id: 0,
927                }],
928                dst: vec![ZonedWordRef {
929                    zone_id: 1,
930                    word_id: 0,
931                }],
932            }],
933            modes: vec![Mode {
934                name: "full".to_string(),
935                zones: vec![0, 1],
936                bitstring_order: vec![],
937            }],
938            paths: None,
939            feed_forward: false,
940            atom_reloading: false,
941            blockade_radius: None,
942        }
943    }
944
945    // ── location_position tests ──
946
947    #[test]
948    fn test_location_position_zone0() {
949        let spec = make_valid_two_zone_spec();
950        // Zone 0 grid: x=[0.0, 5.0, 10.0] y=[0.0, 3.0]
951        // Word 0: sites=[(0,0), (0,1)] -> site 0 at grid[0][0] = (0.0, 0.0)
952        let pos = spec.location_position(&LocationAddr {
953            zone_id: 0,
954            word_id: 0,
955            site_id: 0,
956        });
957        assert_eq!(pos, Some((0.0, 0.0)));
958    }
959
960    #[test]
961    fn test_location_position_zone0_site1() {
962        let spec = make_valid_two_zone_spec();
963        // Word 0: sites=[(0,0), (0,1)] -> site 1 at grid x[0]=0.0, y[1]=3.0
964        let pos = spec.location_position(&LocationAddr {
965            zone_id: 0,
966            word_id: 0,
967            site_id: 1,
968        });
969        assert_eq!(pos, Some((0.0, 3.0)));
970    }
971
972    #[test]
973    fn test_location_position_zone1() {
974        let spec = make_valid_two_zone_spec();
975        // Zone 1 grid: x=[20.0, 27.5, 35.0] y=[0.0, 4.0]
976        // Word 1: sites=[(1,0), (1,1)] -> site 0 at grid x[1]=27.5, y[0]=0.0
977        let pos = spec.location_position(&LocationAddr {
978            zone_id: 1,
979            word_id: 1,
980            site_id: 0,
981        });
982        assert_eq!(pos, Some((27.5, 0.0)));
983    }
984
985    #[test]
986    fn test_location_position_invalid_zone() {
987        let spec = make_valid_two_zone_spec();
988        let pos = spec.location_position(&LocationAddr {
989            zone_id: 99,
990            word_id: 0,
991            site_id: 0,
992        });
993        assert!(pos.is_none());
994    }
995
996    #[test]
997    fn test_location_position_invalid_word() {
998        let spec = make_valid_two_zone_spec();
999        let pos = spec.location_position(&LocationAddr {
1000            zone_id: 0,
1001            word_id: 99,
1002            site_id: 0,
1003        });
1004        assert!(pos.is_none());
1005    }
1006
1007    #[test]
1008    fn test_location_position_invalid_site() {
1009        let spec = make_valid_two_zone_spec();
1010        let pos = spec.location_position(&LocationAddr {
1011            zone_id: 0,
1012            word_id: 0,
1013            site_id: 99,
1014        });
1015        assert!(pos.is_none());
1016    }
1017
1018    // ── get_cz_partner tests ──
1019
1020    #[test]
1021    fn test_get_cz_partner() {
1022        let spec = make_valid_two_zone_spec();
1023        // Zone 0 has entangling_pairs: [[0, 1]] — word 0 paired with word 1
1024        let partner = spec.get_cz_partner(&LocationAddr {
1025            zone_id: 0,
1026            word_id: 0,
1027            site_id: 0,
1028        });
1029        assert_eq!(
1030            partner,
1031            Some(LocationAddr {
1032                zone_id: 0, // same zone
1033                word_id: 1, // partner word
1034                site_id: 0,
1035            })
1036        );
1037    }
1038
1039    #[test]
1040    fn test_get_cz_partner_reverse() {
1041        let spec = make_valid_two_zone_spec();
1042        // word 1 → word 0 (reverse direction within same zone)
1043        let partner = spec.get_cz_partner(&LocationAddr {
1044            zone_id: 0,
1045            word_id: 1,
1046            site_id: 1,
1047        });
1048        assert_eq!(
1049            partner,
1050            Some(LocationAddr {
1051                zone_id: 0,
1052                word_id: 0,
1053                site_id: 1,
1054            })
1055        );
1056    }
1057
1058    #[test]
1059    fn test_get_cz_partner_no_pair() {
1060        let spec = make_valid_two_zone_spec();
1061        // Zone 1 has no entangling pairs
1062        let partner = spec.get_cz_partner(&LocationAddr {
1063            zone_id: 1,
1064            word_id: 0,
1065            site_id: 0,
1066        });
1067        assert!(partner.is_none());
1068    }
1069
1070    // ── lane_endpoints tests ──
1071
1072    #[test]
1073    fn test_lane_endpoints_site_bus() {
1074        let spec = make_valid_two_zone_spec();
1075        // Zone 0 has site_bus: src=[SiteRef(0)] dst=[SiteRef(1)]
1076        let lane = LaneAddr {
1077            direction: Direction::Forward,
1078            move_type: MoveType::SiteBus,
1079            zone_id: 0,
1080            word_id: 0,
1081            site_id: 0,
1082            bus_id: 0,
1083        };
1084        let (src, dst) = spec.lane_endpoints(&lane).unwrap();
1085        assert_eq!(
1086            src,
1087            LocationAddr {
1088                zone_id: 0,
1089                word_id: 0,
1090                site_id: 0,
1091            }
1092        );
1093        assert_eq!(
1094            dst,
1095            LocationAddr {
1096                zone_id: 0,
1097                word_id: 0,
1098                site_id: 1,
1099            }
1100        );
1101    }
1102
1103    #[test]
1104    fn test_lane_endpoints_site_bus_backward() {
1105        let spec = make_valid_two_zone_spec();
1106        let lane = LaneAddr {
1107            direction: Direction::Backward,
1108            move_type: MoveType::SiteBus,
1109            zone_id: 0,
1110            word_id: 0,
1111            site_id: 0,
1112            bus_id: 0,
1113        };
1114        let (src, dst) = spec.lane_endpoints(&lane).unwrap();
1115        // Backward swaps: src is forward dst, dst is forward src
1116        assert_eq!(
1117            src,
1118            LocationAddr {
1119                zone_id: 0,
1120                word_id: 0,
1121                site_id: 1,
1122            }
1123        );
1124        assert_eq!(
1125            dst,
1126            LocationAddr {
1127                zone_id: 0,
1128                word_id: 0,
1129                site_id: 0,
1130            }
1131        );
1132    }
1133
1134    #[test]
1135    fn test_lane_endpoints_word_bus() {
1136        let spec = make_valid_two_zone_spec();
1137        // Zone 0 has word_bus: src=[WordRef(0)] dst=[WordRef(1)]
1138        let lane = LaneAddr {
1139            direction: Direction::Forward,
1140            move_type: MoveType::WordBus,
1141            zone_id: 0,
1142            word_id: 0,
1143            site_id: 0,
1144            bus_id: 0,
1145        };
1146        let (src, dst) = spec.lane_endpoints(&lane).unwrap();
1147        assert_eq!(
1148            src,
1149            LocationAddr {
1150                zone_id: 0,
1151                word_id: 0,
1152                site_id: 0,
1153            }
1154        );
1155        assert_eq!(
1156            dst,
1157            LocationAddr {
1158                zone_id: 0,
1159                word_id: 1,
1160                site_id: 0,
1161            }
1162        );
1163    }
1164
1165    #[test]
1166    fn test_lane_endpoints_zone_bus() {
1167        let spec = make_valid_two_zone_spec();
1168        // zone_bus: src=[ZWR(0,0)] dst=[ZWR(1,0)]
1169        let lane = LaneAddr {
1170            direction: Direction::Forward,
1171            move_type: MoveType::ZoneBus,
1172            zone_id: 0,
1173            word_id: 0,
1174            site_id: 0,
1175            bus_id: 0,
1176        };
1177        let (src, dst) = spec.lane_endpoints(&lane).unwrap();
1178        assert_eq!(
1179            src,
1180            LocationAddr {
1181                zone_id: 0,
1182                word_id: 0,
1183                site_id: 0,
1184            }
1185        );
1186        assert_eq!(
1187            dst,
1188            LocationAddr {
1189                zone_id: 1,
1190                word_id: 0,
1191                site_id: 0,
1192            }
1193        );
1194    }
1195
1196    #[test]
1197    fn test_lane_endpoints_invalid_bus_returns_none() {
1198        let spec = make_valid_two_zone_spec();
1199        let lane = LaneAddr {
1200            direction: Direction::Forward,
1201            move_type: MoveType::SiteBus,
1202            zone_id: 0,
1203            word_id: 0,
1204            site_id: 0,
1205            bus_id: 99,
1206        };
1207        assert!(spec.lane_endpoints(&lane).is_none());
1208    }
1209
1210    // ── JSON round-trip tests ──
1211
1212    #[test]
1213    fn test_json_round_trip() {
1214        let spec = make_valid_two_zone_spec();
1215        let json = serde_json::to_string_pretty(&spec).unwrap();
1216        let deserialized = ArchSpec::from_json(&json).unwrap();
1217        assert_eq!(spec, deserialized);
1218    }
1219
1220    #[test]
1221    fn test_from_json_validated() {
1222        let spec = make_valid_two_zone_spec();
1223        let json = serde_json::to_string(&spec).unwrap();
1224        let validated = ArchSpec::from_json_validated(&json).unwrap();
1225        assert_eq!(spec, validated);
1226    }
1227
1228    #[test]
1229    fn test_from_json_validated_invalid() {
1230        let json = r#"{"version": "1.0"}"#;
1231        let result = ArchSpec::from_json_validated(json);
1232        assert!(result.is_err());
1233    }
1234
1235    // ── word/zone lookup tests ──
1236
1237    #[test]
1238    fn test_word_by_id_found() {
1239        let spec = make_valid_two_zone_spec();
1240        let word = spec.word_by_id(0).unwrap();
1241        assert_eq!(word.sites.len(), 2);
1242    }
1243
1244    #[test]
1245    fn test_word_by_id_not_found() {
1246        let spec = make_valid_two_zone_spec();
1247        assert!(spec.word_by_id(99).is_none());
1248    }
1249
1250    #[test]
1251    fn test_zone_by_id_found() {
1252        let spec = make_valid_two_zone_spec();
1253        let zone = spec.zone_by_id(0).unwrap();
1254        assert_eq!(zone.site_buses.len(), 1);
1255    }
1256
1257    #[test]
1258    fn test_zone_by_id_not_found() {
1259        let spec = make_valid_two_zone_spec();
1260        assert!(spec.zone_by_id(99).is_none());
1261    }
1262
1263    // ── Bus resolve tests ──
1264
1265    #[test]
1266    fn test_site_bus_resolve_forward() {
1267        let spec = make_valid_two_zone_spec();
1268        let bus = &spec.zones[0].site_buses[0];
1269        assert_eq!(bus.resolve_forward(0), Some(1));
1270        assert_eq!(bus.resolve_forward(99), None);
1271    }
1272
1273    #[test]
1274    fn test_site_bus_resolve_backward() {
1275        let spec = make_valid_two_zone_spec();
1276        let bus = &spec.zones[0].site_buses[0];
1277        assert_eq!(bus.resolve_backward(1), Some(0));
1278        assert_eq!(bus.resolve_backward(99), None);
1279    }
1280
1281    #[test]
1282    fn test_word_bus_resolve_forward() {
1283        let spec = make_valid_two_zone_spec();
1284        let bus = &spec.zones[0].word_buses[0];
1285        assert_eq!(bus.resolve_forward(0), Some(1));
1286        assert_eq!(bus.resolve_forward(99), None);
1287    }
1288
1289    #[test]
1290    fn test_word_bus_resolve_backward() {
1291        let spec = make_valid_two_zone_spec();
1292        let bus = &spec.zones[0].word_buses[0];
1293        assert_eq!(bus.resolve_backward(1), Some(0));
1294        assert_eq!(bus.resolve_backward(99), None);
1295    }
1296
1297    #[test]
1298    fn test_zone_bus_resolve_forward() {
1299        let spec = make_valid_two_zone_spec();
1300        let bus = &spec.zone_buses[0];
1301        let src = ZonedWordRef {
1302            zone_id: 0,
1303            word_id: 0,
1304        };
1305        let dst = bus.resolve_forward(&src).unwrap();
1306        assert_eq!(dst.zone_id, 1);
1307        assert_eq!(dst.word_id, 0);
1308    }
1309
1310    #[test]
1311    fn test_zone_bus_resolve_backward() {
1312        let spec = make_valid_two_zone_spec();
1313        let bus = &spec.zone_buses[0];
1314        let dst = ZonedWordRef {
1315            zone_id: 1,
1316            word_id: 0,
1317        };
1318        let src = bus.resolve_backward(&dst).unwrap();
1319        assert_eq!(src.zone_id, 0);
1320        assert_eq!(src.word_id, 0);
1321    }
1322
1323    // ── check_location tests ──
1324
1325    #[test]
1326    fn test_check_location_valid() {
1327        let spec = make_valid_two_zone_spec();
1328        assert!(
1329            spec.check_location(&LocationAddr {
1330                zone_id: 0,
1331                word_id: 0,
1332                site_id: 0,
1333            })
1334            .is_none()
1335        );
1336    }
1337
1338    #[test]
1339    fn test_check_location_invalid_zone() {
1340        let spec = make_valid_two_zone_spec();
1341        let err = spec
1342            .check_location(&LocationAddr {
1343                zone_id: 99,
1344                word_id: 0,
1345                site_id: 0,
1346            })
1347            .unwrap();
1348        assert!(err.contains("zone_id"));
1349    }
1350
1351    // ── check_lane tests ──
1352
1353    #[test]
1354    fn test_check_lane_valid_site_bus() {
1355        let spec = make_valid_two_zone_spec();
1356        let lane = LaneAddr {
1357            direction: Direction::Forward,
1358            move_type: MoveType::SiteBus,
1359            zone_id: 0,
1360            word_id: 0,
1361            site_id: 0,
1362            bus_id: 0,
1363        };
1364        assert!(spec.check_lane(&lane).is_empty());
1365    }
1366
1367    #[test]
1368    fn test_check_lane_invalid_zone() {
1369        let spec = make_valid_two_zone_spec();
1370        let lane = LaneAddr {
1371            direction: Direction::Forward,
1372            move_type: MoveType::SiteBus,
1373            zone_id: 99,
1374            word_id: 0,
1375            site_id: 0,
1376            bus_id: 0,
1377        };
1378        let errors = spec.check_lane(&lane);
1379        assert!(!errors.is_empty());
1380        assert!(errors[0].contains("zone_id"));
1381    }
1382
1383    #[test]
1384    fn test_check_lane_invalid_bus() {
1385        let spec = make_valid_two_zone_spec();
1386        let lane = LaneAddr {
1387            direction: Direction::Forward,
1388            move_type: MoveType::SiteBus,
1389            zone_id: 0,
1390            word_id: 0,
1391            site_id: 0,
1392            bus_id: 99,
1393        };
1394        let errors = spec.check_lane(&lane);
1395        assert!(!errors.is_empty());
1396    }
1397
1398    #[test]
1399    fn test_check_lane_zone_bus_valid() {
1400        let spec = make_valid_two_zone_spec();
1401        let lane = LaneAddr {
1402            direction: Direction::Forward,
1403            move_type: MoveType::ZoneBus,
1404            zone_id: 0,
1405            word_id: 0,
1406            site_id: 0,
1407            bus_id: 0,
1408        };
1409        assert!(spec.check_lane(&lane).is_empty());
1410    }
1411
1412    #[test]
1413    fn test_check_lane_zone_bus_invalid_bus() {
1414        let spec = make_valid_two_zone_spec();
1415        let lane = LaneAddr {
1416            direction: Direction::Forward,
1417            move_type: MoveType::ZoneBus,
1418            zone_id: 0,
1419            word_id: 0,
1420            site_id: 0,
1421            bus_id: 99,
1422        };
1423        let errors = spec.check_lane(&lane);
1424        assert!(!errors.is_empty());
1425        assert!(errors[0].contains("zone_bus"));
1426    }
1427
1428    // ── check_zone tests ──
1429
1430    #[test]
1431    fn test_check_zone_valid() {
1432        let spec = make_valid_two_zone_spec();
1433        assert!(spec.check_zone(&ZoneAddr { zone_id: 0 }).is_none());
1434    }
1435
1436    #[test]
1437    fn test_check_zone_invalid() {
1438        let spec = make_valid_two_zone_spec();
1439        assert!(spec.check_zone(&ZoneAddr { zone_id: 99 }).is_some());
1440    }
1441
1442    // ── check_lane_group_consistency tests ──
1443
1444    #[test]
1445    fn test_check_lane_group_consistency_empty() {
1446        let spec = make_valid_two_zone_spec();
1447        assert!(spec.check_lane_group_consistency(&[]).is_empty());
1448    }
1449
1450    #[test]
1451    fn test_check_lane_group_consistency_zone_mismatch() {
1452        let spec = make_valid_two_zone_spec();
1453        let lanes = vec![
1454            LaneAddr {
1455                direction: Direction::Forward,
1456                move_type: MoveType::SiteBus,
1457                zone_id: 0,
1458                word_id: 0,
1459                site_id: 0,
1460                bus_id: 0,
1461            },
1462            LaneAddr {
1463                direction: Direction::Forward,
1464                move_type: MoveType::SiteBus,
1465                zone_id: 1,
1466                word_id: 0,
1467                site_id: 0,
1468                bus_id: 0,
1469            },
1470        ];
1471        let errors = spec.check_lane_group_consistency(&lanes);
1472        assert!(!errors.is_empty());
1473        assert!(errors[0].contains("zone_id mismatch"));
1474    }
1475
1476    // ── check_locations tests ──
1477
1478    #[test]
1479    fn test_check_locations_valid() {
1480        let spec = make_valid_two_zone_spec();
1481        let locs = vec![
1482            LocationAddr {
1483                zone_id: 0,
1484                word_id: 0,
1485                site_id: 0,
1486            },
1487            LocationAddr {
1488                zone_id: 0,
1489                word_id: 0,
1490                site_id: 1,
1491            },
1492        ];
1493        assert!(spec.check_locations(&locs).is_empty());
1494    }
1495
1496    #[test]
1497    fn test_check_locations_duplicate() {
1498        let spec = make_valid_two_zone_spec();
1499        let locs = vec![
1500            LocationAddr {
1501                zone_id: 0,
1502                word_id: 0,
1503                site_id: 0,
1504            },
1505            LocationAddr {
1506                zone_id: 0,
1507                word_id: 0,
1508                site_id: 0,
1509            },
1510        ];
1511        let errors = spec.check_locations(&locs);
1512        assert!(
1513            errors
1514                .iter()
1515                .any(|e| matches!(e, LocationGroupError::DuplicateAddress { .. }))
1516        );
1517    }
1518
1519    #[test]
1520    fn test_check_locations_invalid() {
1521        let spec = make_valid_two_zone_spec();
1522        let locs = vec![LocationAddr {
1523            zone_id: 99,
1524            word_id: 0,
1525            site_id: 0,
1526        }];
1527        let errors = spec.check_locations(&locs);
1528        assert!(
1529            errors
1530                .iter()
1531                .any(|e| matches!(e, LocationGroupError::InvalidAddress { .. }))
1532        );
1533    }
1534
1535    // ── Derived topology query tests (#464 phase 2) ──
1536
1537    #[test]
1538    fn test_word_partner_map() {
1539        let spec = make_valid_two_zone_spec();
1540        let map = spec.word_partner_map();
1541        // Zone 0 has entangling_pairs=[[0, 1]], zone 1 has none.
1542        assert_eq!(map.get(&0), Some(&1));
1543        assert_eq!(map.get(&1), Some(&0));
1544        assert_eq!(map.len(), 2);
1545    }
1546
1547    #[test]
1548    fn test_word_zone_map() {
1549        let spec = make_valid_two_zone_spec();
1550        let map = spec.word_zone_map();
1551        // Words 0 and 1 are referenced by zone 0 (entangling_pairs + buses).
1552        assert_eq!(map.get(&0), Some(&0));
1553        assert_eq!(map.get(&1), Some(&0));
1554        assert_eq!(map.len(), 2); // exactly 2 words
1555    }
1556
1557    #[test]
1558    fn test_left_cz_word_ids() {
1559        let spec = make_valid_two_zone_spec();
1560        let home = spec.left_cz_word_ids();
1561        // Pair [0, 1] -> home word is 0. Word 1 is the staging word.
1562        // But there are only 2 words and they're all paired, so home = [0].
1563        assert_eq!(home, vec![0]);
1564    }
1565
1566    #[test]
1567    fn test_is_home_position() {
1568        let spec = make_valid_two_zone_spec();
1569        // Per `test_left_cz_word_ids`, only word_id 0 is a home word.
1570        let home = LocationAddr {
1571            zone_id: 0,
1572            word_id: 0,
1573            site_id: 0,
1574        };
1575        let staging = LocationAddr {
1576            zone_id: 0,
1577            word_id: 1,
1578            site_id: 0,
1579        };
1580        assert!(spec.is_home_position(&home));
1581        assert!(!spec.is_home_position(&staging));
1582    }
1583
1584    #[test]
1585    fn test_lane_for_endpoints_site_bus() {
1586        let spec = make_valid_two_zone_spec();
1587        // Zone 0 has site_bus: src=[SiteRef(0)] dst=[SiteRef(1)], words_with_site_buses=[0,1].
1588        // For word 0, site bus maps site 0 -> site 1.
1589        let src = LocationAddr {
1590            zone_id: 0,
1591            word_id: 0,
1592            site_id: 0,
1593        };
1594        let dst = LocationAddr {
1595            zone_id: 0,
1596            word_id: 0,
1597            site_id: 1,
1598        };
1599        let lane = spec.lane_for_endpoints(&src, &dst);
1600        assert!(lane.is_some(), "should find a lane for (src, dst)");
1601        let l = lane.unwrap();
1602        assert_eq!(l.move_type, MoveType::SiteBus);
1603        assert_eq!(l.direction, Direction::Forward);
1604    }
1605
1606    #[test]
1607    fn test_lane_for_endpoints_word_bus() {
1608        let spec = make_valid_two_zone_spec();
1609        // Zone 0 word_bus: src=[WordRef(0)] dst=[WordRef(1)], sites_with_word_buses=[0].
1610        let src = LocationAddr {
1611            zone_id: 0,
1612            word_id: 0,
1613            site_id: 0,
1614        };
1615        let dst = LocationAddr {
1616            zone_id: 0,
1617            word_id: 1,
1618            site_id: 0,
1619        };
1620        let lane = spec.lane_for_endpoints(&src, &dst);
1621        assert!(lane.is_some(), "should find a word-bus lane");
1622        let l = lane.unwrap();
1623        assert_eq!(l.move_type, MoveType::WordBus);
1624        assert_eq!(l.direction, Direction::Forward);
1625    }
1626
1627    #[test]
1628    fn test_lane_for_endpoints_not_found() {
1629        let spec = make_valid_two_zone_spec();
1630        // No lane connects word 0 site 0 to word 1 site 1 (different site ids
1631        // across a word bus move).
1632        let src = LocationAddr {
1633            zone_id: 0,
1634            word_id: 0,
1635            site_id: 0,
1636        };
1637        let dst = LocationAddr {
1638            zone_id: 0,
1639            word_id: 1,
1640            site_id: 1,
1641        };
1642        assert!(spec.lane_for_endpoints(&src, &dst).is_none());
1643    }
1644}