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