Skip to main content

bloqade_lanes_bytecode_core/
atom_state.rs

1//! Atom state tracking for qubit-to-location mappings.
2//!
3//! [`AtomStateData`] is an immutable state object that tracks where qubits
4//! are located in the architecture as atoms move through transport lanes.
5//! It is the core data structure used by the IR analysis pipeline to simulate
6//! atom movement, detect collisions, and identify CZ gate pairings.
7
8use std::collections::{HashMap, HashSet};
9use std::hash::{Hash, Hasher};
10
11use thiserror::Error;
12
13use crate::arch::addr::{LaneAddr, LocationAddr, ZoneAddr};
14use crate::arch::query::LaneGroupError;
15use crate::arch::types::ArchSpec;
16
17/// A lane group that cannot execute against a given [`AtomStateData`].
18///
19/// Produced by [`AtomStateData::validate_moves`]. Static lane-group failures
20/// (address validity, duplicates, consistency, membership, AOD geometry) are
21/// delegated to [`ArchSpec::check_lanes`] and wrapped in [`Self::LaneGroup`];
22/// the occupancy rules are this module's own.
23#[derive(Debug, Clone, PartialEq, Eq, Error)]
24pub enum MoveValidationError {
25    /// A lane could not be resolved to (src, dst) endpoints.
26    #[error("lane {lane:?} cannot be resolved to endpoints")]
27    UnresolvableLane {
28        /// The unresolvable lane.
29        lane: LaneAddr,
30    },
31
32    /// Static lane-group check failure from [`ArchSpec::check_lanes`].
33    #[error("{0}")]
34    LaneGroup(LaneGroupError),
35
36    /// A lane's destination holds an atom that does not move in this group.
37    ///
38    /// The AOD trap site arrives at every lane's destination whether or not
39    /// the lane carried an atom, so this is a fault for empty-source filler
40    /// lanes just as for movers. An occupied destination is legal only when
41    /// its occupant sits at the source of another lane in the same group
42    /// (it vacates in the same simultaneous step).
43    #[error(
44        "lane {lane:?} targets {dst:?}, which is occupied by qubit {occupant} \
45         that does not move in this group"
46    )]
47    DestinationOccupiedByStationaryAtom {
48        /// The offending lane (mover or filler).
49        lane: LaneAddr,
50        /// The occupied destination.
51        dst: LocationAddr,
52        /// The stationary qubit at the destination.
53        occupant: u32,
54    },
55
56    /// A validated mover's source no longer holds the qubit it was resolved
57    /// with, i.e. the [`ValidatedMoves`] token was produced against a
58    /// different state (or against this one before it moved on).
59    #[error(
60        "stale ValidatedMoves token: lane {lane:?} expected qubit {expected} \
61         at {src:?}, which no longer holds it"
62    )]
63    StaleMoverSource {
64        /// The lane whose resolved source went stale.
65        lane: LaneAddr,
66        /// The source location the token recorded.
67        src: LocationAddr,
68        /// The qubit the token expected to find there.
69        expected: u32,
70    },
71
72    /// Two lanes in the group share a destination.
73    ///
74    /// Unreachable through a well-formed bus (destination-unique per
75    /// [`ArchSpec::validate`]); kept as a defensive check for hand-built
76    /// lane groups.
77    #[error("lanes {first:?} and {second:?} share destination {dst:?}")]
78    ContestedDestination {
79        /// The shared destination.
80        dst: LocationAddr,
81        /// The lane that claimed the destination first.
82        first: LaneAddr,
83        /// The lane that collided with it.
84        second: LaneAddr,
85    },
86}
87
88/// A lane group proven executable against the [`AtomStateData`] it was
89/// validated with.
90///
91/// Obtainable only from [`AtomStateData::validate_moves`], which makes
92/// "apply without validating" unrepresentable: [`AtomStateData::apply_validated`]
93/// takes this token and is total — no collision, no silent skip.
94///
95/// The token captures resolved mover assignments against the pre-move state,
96/// so it must be applied to the same state it was validated against.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct ValidatedMoves {
99    /// Resolved movers: `(qubit, src, dst, lane)`, one entry per lane whose
100    /// source held an atom at validation time.
101    movers: Vec<(u32, LocationAddr, LocationAddr, LaneAddr)>,
102}
103
104impl ValidatedMoves {
105    /// The resolved `(qubit, src, dst, lane)` mover assignments.
106    pub fn movers(&self) -> &[(u32, LocationAddr, LocationAddr, LaneAddr)] {
107        &self.movers
108    }
109}
110
111/// Tracks qubit-to-location mappings as atoms move through the architecture.
112///
113/// This is an immutable value type: all mutation methods (`add_atoms`,
114/// `apply_moves`) return a new instance rather than modifying in place.
115///
116/// The two primary maps (`locations_to_qubit` and `qubit_to_locations`) are
117/// kept in sync as a bidirectional index. When a move causes two atoms to
118/// occupy the same site, both are removed from the location maps and recorded
119/// in `collision`.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct AtomStateData {
122    /// Reverse index: given a physical location, which qubit (if any) is there?
123    pub locations_to_qubit: HashMap<LocationAddr, u32>,
124    /// Forward index: given a qubit id, where is it currently located?
125    pub qubit_to_locations: HashMap<u32, LocationAddr>,
126    /// Cumulative record of qubits that have collided since this state was
127    /// created (via constructors or `add_atoms`). Updated by `apply_moves` —
128    /// new collisions are added to existing entries. Key is the moving qubit,
129    /// value is the qubit it displaced. Collided qubits are removed from
130    /// both location maps.
131    pub collision: HashMap<u32, u32>,
132    /// The lane each qubit used in the most recent `apply_moves`.
133    /// Only populated for qubits that moved in the last step.
134    pub prev_lanes: HashMap<u32, LaneAddr>,
135    /// Cumulative number of moves each qubit has undergone across
136    /// all `apply_moves` calls in the state's history.
137    pub move_count: HashMap<u32, u32>,
138}
139
140impl Hash for AtomStateData {
141    fn hash<H: Hasher>(&self, state: &mut H) {
142        // Hash each field with a discriminant tag and length prefix to prevent
143        // cross-field collisions (e.g. entries from one map aliasing another).
144        fn hash_sorted_map<H: Hasher, K: Ord + Hash, V: Hash>(
145            state: &mut H,
146            tag: u8,
147            entries: &mut [(K, V)],
148        ) {
149            tag.hash(state);
150            entries.len().hash(state);
151            entries.sort_by(|a, b| a.0.cmp(&b.0));
152            for (k, v) in entries.iter() {
153                k.hash(state);
154                v.hash(state);
155            }
156        }
157
158        let mut loc_entries: Vec<_> = self
159            .locations_to_qubit
160            .iter()
161            .map(|(k, v)| (k.encode(), *v))
162            .collect();
163        hash_sorted_map(state, 0, &mut loc_entries);
164
165        let mut qubit_entries: Vec<_> = self
166            .qubit_to_locations
167            .iter()
168            .map(|(k, v)| (*k, v.encode()))
169            .collect();
170        hash_sorted_map(state, 1, &mut qubit_entries);
171
172        let mut collision_entries: Vec<_> = self.collision.iter().map(|(k, v)| (*k, *v)).collect();
173        hash_sorted_map(state, 2, &mut collision_entries);
174
175        let mut lane_entries: Vec<_> = self
176            .prev_lanes
177            .iter()
178            .map(|(k, v)| (*k, v.encode_u64()))
179            .collect();
180        hash_sorted_map(state, 3, &mut lane_entries);
181
182        let mut count_entries: Vec<_> = self.move_count.iter().map(|(k, v)| (*k, *v)).collect();
183        hash_sorted_map(state, 4, &mut count_entries);
184    }
185}
186
187impl AtomStateData {
188    /// Create an empty state with no qubits or locations.
189    pub fn new() -> Self {
190        Self {
191            locations_to_qubit: HashMap::new(),
192            qubit_to_locations: HashMap::new(),
193            collision: HashMap::new(),
194            prev_lanes: HashMap::new(),
195            move_count: HashMap::new(),
196        }
197    }
198
199    /// Create a state from a list of `(qubit_id, location)` pairs.
200    ///
201    /// Builds both the forward (qubit → location) and reverse (location → qubit)
202    /// maps. All other fields (collision, prev_lanes, move_count) are empty.
203    pub fn from_locations(locations: &[(u32, LocationAddr)]) -> Self {
204        let mut locations_to_qubit = HashMap::new();
205        let mut qubit_to_locations = HashMap::new();
206
207        for &(qubit, loc) in locations {
208            qubit_to_locations.insert(qubit, loc);
209            locations_to_qubit.insert(loc, qubit);
210        }
211
212        Self {
213            locations_to_qubit,
214            qubit_to_locations,
215            collision: HashMap::new(),
216            prev_lanes: HashMap::new(),
217            move_count: HashMap::new(),
218        }
219    }
220
221    /// Add atoms at new locations, returning a new state.
222    ///
223    /// Each `(qubit_id, location)` pair is added to the bidirectional maps.
224    /// Returns `Err` if any qubit id already exists in this state or any
225    /// location is already occupied by another qubit.
226    ///
227    /// The returned state inherits no collision, prev_lanes, or move_count
228    /// data — those fields are reset to empty.
229    pub fn add_atoms(&self, locations: &[(u32, LocationAddr)]) -> Result<Self, &'static str> {
230        let mut qubit_to_locations = self.qubit_to_locations.clone();
231        let mut locations_to_qubit = self.locations_to_qubit.clone();
232
233        for &(qubit, loc) in locations {
234            if qubit_to_locations.contains_key(&qubit) {
235                return Err("Attempted to add atom that already exists");
236            }
237            if locations_to_qubit.contains_key(&loc) {
238                return Err("Attempted to add atom to occupied location");
239            }
240            qubit_to_locations.insert(qubit, loc);
241            locations_to_qubit.insert(loc, qubit);
242        }
243
244        Ok(Self {
245            locations_to_qubit,
246            qubit_to_locations,
247            collision: HashMap::new(),
248            prev_lanes: HashMap::new(),
249            move_count: HashMap::new(),
250        })
251    }
252
253    /// Resolve each lane against the pre-move state into `(qubit, src, dst,
254    /// lane)` mover entries. Lanes whose source holds no atom contribute no
255    /// entry; a source consumed by an earlier lane is not consumed again
256    /// (first lane wins, matching first-match bus endpoint resolution).
257    ///
258    /// Returns `None` if any lane cannot be resolved to endpoints.
259    fn resolve_movers(
260        &self,
261        lanes: &[LaneAddr],
262        arch_spec: &ArchSpec,
263    ) -> Option<Vec<(u32, LocationAddr, LocationAddr, LaneAddr)>> {
264        let mut movers = Vec::with_capacity(lanes.len());
265        let mut seen_srcs: HashSet<LocationAddr> = HashSet::new();
266        for lane in lanes {
267            let (src, dst) = arch_spec.lane_endpoints(lane)?;
268            if !seen_srcs.insert(src) {
269                continue;
270            }
271            if let Some(&qubit) = self.locations_to_qubit.get(&src) {
272                movers.push((qubit, src, dst, *lane));
273            }
274        }
275        Some(movers)
276    }
277
278    /// Apply a group of lane moves simultaneously and return the resulting
279    /// state.
280    ///
281    /// All lanes execute as one AOD transport operation: every endpoint is
282    /// resolved against the pre-move state, so for any
283    /// [`check_lanes`](ArchSpec::check_lanes)-valid group the result is
284    /// independent of lane order (distinct lanes sharing a source can only
285    /// occur in invalid groups, and resolve first-in-slice-wins). A
286    /// destination counts as free when its occupant moves in the same group —
287    /// conveyor chains (`x→y, y→z`) are legal. Lanes whose source has no
288    /// qubit are skipped.
289    ///
290    /// A qubit that lands on an atom which does *not* move in this group
291    /// collides: both qubits are removed from the location maps and recorded
292    /// in `collision`. This method never fails on collisions — use
293    /// [`Self::validate_moves`] + [`Self::apply_validated`] to reject such
294    /// groups up front instead.
295    ///
296    /// Returns `None` if any lane cannot be resolved to endpoints (invalid
297    /// bus, word, or site). The `prev_lanes` field is reset to contain only
298    /// the lanes used in this call; `move_count` is accumulated.
299    pub fn apply_moves(&self, lanes: &[LaneAddr], arch_spec: &ArchSpec) -> Option<Self> {
300        let mut movers = self.resolve_movers(lanes, arch_spec)?;
301        // Deterministic landing order regardless of lane slice order; only
302        // observable through which qubit a `collision` entry is keyed on
303        // when two movers contest one destination (ill-formed bus).
304        movers.sort_unstable_by_key(|&(qubit, ..)| qubit);
305
306        let mut qubit_to_locations = self.qubit_to_locations.clone();
307        let mut locations_to_qubit = self.locations_to_qubit.clone();
308        let mut collisions = self.collision.clone();
309        let mut move_count = self.move_count.clone();
310        let mut prev_lanes: HashMap<u32, LaneAddr> = HashMap::new();
311
312        // Phase 1: every mover vacates its source.
313        for (qubit, src, _, _) in &movers {
314            locations_to_qubit.remove(src);
315            qubit_to_locations.remove(qubit);
316        }
317
318        // Phase 2: land, judging occupancy against the pre-move state.
319        let mover_srcs: HashSet<LocationAddr> = movers.iter().map(|&(_, src, ..)| src).collect();
320        for (qubit, _, dst, lane) in &movers {
321            *move_count.entry(*qubit).or_insert(0) += 1;
322            prev_lanes.insert(*qubit, *lane);
323
324            // A pre-move occupant that is not itself a mover stays put:
325            // both it and the arriving qubit are destroyed.
326            if let Some(&stationary) = self.locations_to_qubit.get(dst)
327                && !mover_srcs.contains(dst)
328            {
329                locations_to_qubit.remove(dst);
330                qubit_to_locations.remove(&stationary);
331                collisions.insert(*qubit, stationary);
332                continue;
333            }
334
335            // Another mover already landed here (two lanes sharing a
336            // destination — ill-formed bus): destroy both.
337            if let Some(&other) = locations_to_qubit.get(dst) {
338                locations_to_qubit.remove(dst);
339                qubit_to_locations.remove(&other);
340                collisions.insert(*qubit, other);
341                continue;
342            }
343
344            qubit_to_locations.insert(*qubit, *dst);
345            locations_to_qubit.insert(*dst, *qubit);
346        }
347
348        Some(Self {
349            locations_to_qubit,
350            qubit_to_locations,
351            prev_lanes,
352            collision: collisions,
353            move_count,
354        })
355    }
356
357    /// Validate that a lane group can execute against this state.
358    ///
359    /// This is the canonical executability check for a `move` group. It runs
360    /// the static lane-group checks ([`ArchSpec::check_lanes`]) and the
361    /// occupancy rules, all resolved against the pre-move state:
362    ///
363    /// - every occupied destination must be vacated by a lane in the same
364    ///   group (the occupant sits at another lane's source) — this applies
365    ///   uniformly to mover lanes *and* empty-source filler lanes, because
366    ///   the AOD trap site arrives at every destination either way;
367    /// - no two lanes may share a destination;
368    /// - empty-source lanes whose destination is also free are legal no-ops
369    ///   (AOD rectangle filler).
370    ///
371    /// Assumes `arch_spec` itself is valid (see [`ArchSpec::validate`]);
372    /// with well-formed (acyclic, endpoint-unique) buses, a valid group can
373    /// only be a set of independent transports or conveyor chains, never a
374    /// rotation.
375    ///
376    /// All errors are collected in one pass. On success the returned
377    /// [`ValidatedMoves`] token feeds [`Self::apply_validated`]; it is tied
378    /// to this state and must not be applied to any other.
379    pub fn validate_moves(
380        &self,
381        lanes: &[LaneAddr],
382        arch_spec: &ArchSpec,
383    ) -> Result<ValidatedMoves, Vec<MoveValidationError>> {
384        let mut errors: Vec<MoveValidationError> = arch_spec
385            .check_lanes(lanes)
386            .into_iter()
387            .map(MoveValidationError::LaneGroup)
388            .collect();
389
390        // `lane_endpoints` fails exactly when `check_lane` already reported
391        // `InvalidLane`, so only surface `UnresolvableLane` when it would
392        // otherwise go unreported (a true shouldn't-happen).
393        let invalid_lane_reported = errors.iter().any(|e| {
394            matches!(
395                e,
396                MoveValidationError::LaneGroup(LaneGroupError::InvalidLane { .. })
397            )
398        });
399
400        // Duplicate lane addresses are reported by `check_lanes`; dedup here
401        // so a repeated lane doesn't also self-report as a contested
402        // destination or double-report an occupied one.
403        let mut seen_lanes: HashSet<u64> = HashSet::new();
404        let mut resolved: Vec<(LaneAddr, LocationAddr, LocationAddr)> =
405            Vec::with_capacity(lanes.len());
406        for lane in lanes {
407            if !seen_lanes.insert(lane.encode_u64()) {
408                continue;
409            }
410            match arch_spec.lane_endpoints(lane) {
411                Some((src, dst)) => resolved.push((*lane, src, dst)),
412                None if !invalid_lane_reported => {
413                    errors.push(MoveValidationError::UnresolvableLane { lane: *lane })
414                }
415                None => {}
416            }
417        }
418
419        let mover_srcs: HashSet<LocationAddr> = resolved
420            .iter()
421            .filter(|(_, src, _)| self.locations_to_qubit.contains_key(src))
422            .map(|&(_, src, _)| src)
423            .collect();
424
425        let mut claimed_dsts: HashMap<LocationAddr, LaneAddr> = HashMap::new();
426        for &(lane, _, dst) in &resolved {
427            if let Some(&occupant) = self.locations_to_qubit.get(&dst)
428                && !mover_srcs.contains(&dst)
429            {
430                errors.push(MoveValidationError::DestinationOccupiedByStationaryAtom {
431                    lane,
432                    dst,
433                    occupant,
434                });
435            }
436            if let Some(&first) = claimed_dsts.get(&dst) {
437                errors.push(MoveValidationError::ContestedDestination {
438                    dst,
439                    first,
440                    second: lane,
441                });
442            } else {
443                claimed_dsts.insert(dst, lane);
444            }
445        }
446
447        if !errors.is_empty() {
448            return Err(errors);
449        }
450
451        let movers = self
452            .resolve_movers(lanes, arch_spec)
453            .expect("all lanes resolved above");
454        Ok(ValidatedMoves { movers })
455    }
456
457    /// Apply a validated lane group and return the resulting state.
458    ///
459    /// Total on a token produced by [`Self::validate_moves`] on *this* state:
460    /// validation has already ruled out every collision, so no atom is
461    /// destroyed or silently skipped. The `prev_lanes` field is reset to the
462    /// movers of this call; `move_count` is accumulated; `collision` is
463    /// carried over unchanged.
464    ///
465    /// Returns `Err` when the token is stale — produced against a different
466    /// state, or against this state before it moved on. The token records
467    /// assignments resolved at validation time, so applying it blindly would
468    /// silently desynchronize the two location maps rather than fail. This
469    /// check runs in every build (not just debug) because `apply_validated`
470    /// is public API, including through the Python bindings, where holding a
471    /// token across a state change is easy to do by accident. It costs
472    /// O(movers) lookups — far less than [`Self::validate_moves`] itself.
473    pub fn apply_validated(
474        &self,
475        moves: &ValidatedMoves,
476    ) -> Result<Self, Vec<MoveValidationError>> {
477        let mover_srcs: HashSet<LocationAddr> =
478            moves.movers.iter().map(|&(_, src, _, _)| src).collect();
479        let mut errors: Vec<MoveValidationError> = Vec::new();
480        for &(qubit, src, dst, lane) in &moves.movers {
481            if self.locations_to_qubit.get(&src) != Some(&qubit) {
482                errors.push(MoveValidationError::StaleMoverSource {
483                    lane,
484                    src,
485                    expected: qubit,
486                });
487                continue;
488            }
489            if let Some(&occupant) = self.locations_to_qubit.get(&dst)
490                && !mover_srcs.contains(&dst)
491            {
492                errors.push(MoveValidationError::DestinationOccupiedByStationaryAtom {
493                    lane,
494                    dst,
495                    occupant,
496                });
497            }
498        }
499        if !errors.is_empty() {
500            return Err(errors);
501        }
502
503        let mut qubit_to_locations = self.qubit_to_locations.clone();
504        let mut locations_to_qubit = self.locations_to_qubit.clone();
505        let mut move_count = self.move_count.clone();
506        let mut prev_lanes: HashMap<u32, LaneAddr> = HashMap::new();
507
508        for (qubit, src, _, _) in &moves.movers {
509            locations_to_qubit.remove(src);
510            qubit_to_locations.remove(qubit);
511        }
512
513        for (qubit, _, dst, lane) in &moves.movers {
514            *move_count.entry(*qubit).or_insert(0) += 1;
515            prev_lanes.insert(*qubit, *lane);
516            qubit_to_locations.insert(*qubit, *dst);
517            locations_to_qubit.insert(*dst, *qubit);
518        }
519
520        Ok(Self {
521            locations_to_qubit,
522            qubit_to_locations,
523            prev_lanes,
524            collision: self.collision.clone(),
525            move_count,
526        })
527    }
528
529    /// Look up which qubit (if any) occupies the given location.
530    pub fn get_qubit(&self, location: &LocationAddr) -> Option<u32> {
531        self.locations_to_qubit.get(location).copied()
532    }
533
534    /// Find CZ gate control/target qubit pairings within a zone.
535    ///
536    /// Iterates over all qubits whose current location is in the given zone
537    /// and checks whether the CZ pair site (via [`ArchSpec::get_blockaded_location`])
538    /// is also occupied. If both sites are occupied, the qubits form a
539    /// control/target pair. If the pair site is empty or doesn't exist, the
540    /// qubit is unpaired.
541    ///
542    /// Returns `(controls, targets, unpaired)` where `controls[i]` and
543    /// `targets[i]` are paired for CZ. Results are sorted by qubit id for
544    /// deterministic ordering. Returns `None` if the zone id is invalid.
545    pub fn get_qubit_pairing(
546        &self,
547        zone: &ZoneAddr,
548        arch_spec: &ArchSpec,
549    ) -> Option<(Vec<u32>, Vec<u32>, Vec<u32>)> {
550        // In the zone-centric model, all zones share the same words.
551        // Filter qubits by checking if their zone_id matches the requested zone.
552        let _zone_data = arch_spec.zone_by_id(zone.zone_id)?;
553        let zone_id = zone.zone_id;
554
555        let mut controls = Vec::new();
556        let mut targets = Vec::new();
557        let mut unpaired = Vec::new();
558        let mut visited = std::collections::HashSet::new();
559
560        // Sort by qubit id for deterministic iteration order
561        let mut sorted_qubits: Vec<_> = self.qubit_to_locations.iter().collect();
562        sorted_qubits.sort_by_key(|(qubit, _)| **qubit);
563
564        for (qubit, loc) in &sorted_qubits {
565            let qubit = **qubit;
566            let loc = **loc;
567            if visited.contains(&qubit) {
568                continue;
569            }
570            visited.insert(qubit);
571
572            if loc.zone_id != zone_id {
573                continue;
574            }
575
576            let blockaded = match arch_spec.get_cz_partner(&loc) {
577                Some(b) => b,
578                None => {
579                    unpaired.push(qubit);
580                    continue;
581                }
582            };
583
584            let target_qubit = match self.get_qubit(&blockaded) {
585                Some(t) => t,
586                None => {
587                    unpaired.push(qubit);
588                    continue;
589                }
590            };
591
592            controls.push(qubit);
593            targets.push(target_qubit);
594            visited.insert(target_qubit);
595        }
596
597        Some((controls, targets, unpaired))
598    }
599}
600
601impl Default for AtomStateData {
602    fn default() -> Self {
603        Self::new()
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use crate::arch::addr::{SiteRef, WordRef, ZonedWordRef};
611    use crate::arch::types::{Bus, Grid, Mode, Word, Zone};
612    use crate::version::Version;
613
614    /// Build the same two-zone spec used by the arch module tests.
615    /// Zone 0 has site bus 0 (site 0 -> site 1) and word bus 0 (word 0 -> word 1).
616    /// Entangling pair: zones [0, 1].
617    fn make_test_spec() -> crate::arch::ArchSpec {
618        let grid0 = Grid::from_positions(&[0.0, 5.0, 10.0], &[0.0, 3.0]);
619        let grid1 = Grid::from_positions(&[0.0, 7.5, 15.0], &[0.0, 4.0]);
620
621        crate::arch::ArchSpec {
622            version: Version::new(2, 0),
623            words: vec![
624                Word {
625                    sites: vec![[0, 0], [0, 1]],
626                },
627                Word {
628                    sites: vec![[1, 0], [1, 1]],
629                },
630            ],
631            zones: vec![
632                Zone {
633                    name: String::new(),
634                    grid: grid0,
635                    site_buses: vec![Bus {
636                        src: vec![SiteRef(0)],
637                        dst: vec![SiteRef(1)],
638                    }],
639                    word_buses: vec![Bus {
640                        src: vec![WordRef(0)],
641                        dst: vec![WordRef(1)],
642                    }],
643                    words_with_site_buses: vec![0, 1],
644                    sites_with_word_buses: vec![0],
645                    entangling_pairs: vec![[0, 1]],
646                },
647                Zone {
648                    name: String::new(),
649                    grid: grid1,
650                    site_buses: vec![],
651                    word_buses: vec![],
652                    words_with_site_buses: vec![],
653                    sites_with_word_buses: vec![],
654                    entangling_pairs: vec![],
655                },
656            ],
657            zone_buses: vec![Bus {
658                src: vec![ZonedWordRef {
659                    zone_id: 0,
660                    word_id: 0,
661                }],
662                dst: vec![ZonedWordRef {
663                    zone_id: 1,
664                    word_id: 0,
665                }],
666            }],
667            modes: vec![Mode {
668                name: "full".to_string(),
669                zones: vec![0, 1],
670                bitstring_order: vec![],
671            }],
672            paths: None,
673            feed_forward: false,
674            atom_reloading: false,
675            blockade_radius: None,
676        }
677    }
678
679    #[test]
680    fn new_state_is_empty() {
681        let state = AtomStateData::new();
682        assert!(state.locations_to_qubit.is_empty());
683        assert!(state.qubit_to_locations.is_empty());
684        assert!(state.collision.is_empty());
685        assert!(state.prev_lanes.is_empty());
686        assert!(state.move_count.is_empty());
687    }
688
689    #[test]
690    fn from_locations_creates_bidirectional_map() {
691        let locs = vec![
692            (
693                0,
694                LocationAddr {
695                    zone_id: 0,
696                    word_id: 0,
697                    site_id: 0,
698                },
699            ),
700            (
701                1,
702                LocationAddr {
703                    zone_id: 0,
704                    word_id: 1,
705                    site_id: 0,
706                },
707            ),
708        ];
709        let state = AtomStateData::from_locations(&locs);
710        assert_eq!(
711            state.get_qubit(&LocationAddr {
712                zone_id: 0,
713                word_id: 0,
714                site_id: 0
715            }),
716            Some(0)
717        );
718        assert_eq!(
719            state.get_qubit(&LocationAddr {
720                zone_id: 0,
721                word_id: 1,
722                site_id: 0
723            }),
724            Some(1)
725        );
726    }
727
728    #[test]
729    fn add_atoms_succeeds_and_fields_match() {
730        let state = AtomStateData::new();
731        let loc0 = LocationAddr {
732            zone_id: 0,
733            word_id: 0,
734            site_id: 0,
735        };
736        let loc1 = LocationAddr {
737            zone_id: 0,
738            word_id: 1,
739            site_id: 0,
740        };
741        let new_state = state.add_atoms(&[(0, loc0), (1, loc1)]).unwrap();
742
743        assert_eq!(new_state.qubit_to_locations.len(), 2);
744        assert_eq!(new_state.qubit_to_locations[&0], loc0);
745        assert_eq!(new_state.qubit_to_locations[&1], loc1);
746        assert_eq!(new_state.locations_to_qubit[&loc0], 0);
747        assert_eq!(new_state.locations_to_qubit[&loc1], 1);
748        assert!(new_state.collision.is_empty());
749        assert!(new_state.prev_lanes.is_empty());
750        assert!(new_state.move_count.is_empty());
751    }
752
753    #[test]
754    fn add_atoms_duplicate_qubit_fails() {
755        let state = AtomStateData::from_locations(&[(
756            0,
757            LocationAddr {
758                zone_id: 0,
759                word_id: 0,
760                site_id: 0,
761            },
762        )]);
763        let result = state.add_atoms(&[(
764            0,
765            LocationAddr {
766                zone_id: 0,
767                word_id: 1,
768                site_id: 0,
769            },
770        )]);
771        assert!(result.is_err());
772    }
773
774    #[test]
775    fn add_atoms_occupied_location_fails() {
776        let state = AtomStateData::from_locations(&[(
777            0,
778            LocationAddr {
779                zone_id: 0,
780                word_id: 0,
781                site_id: 0,
782            },
783        )]);
784        let result = state.add_atoms(&[(
785            1,
786            LocationAddr {
787                zone_id: 0,
788                word_id: 0,
789                site_id: 0,
790            },
791        )]);
792        assert!(result.is_err());
793    }
794
795    #[test]
796    fn apply_moves_basic() {
797        let spec = make_test_spec();
798        // Zone 0 site bus 0: site 0 -> site 1
799        let state = AtomStateData::from_locations(&[
800            (
801                0,
802                LocationAddr {
803                    zone_id: 0,
804                    word_id: 0,
805                    site_id: 0,
806                },
807            ),
808            (
809                1,
810                LocationAddr {
811                    zone_id: 0,
812                    word_id: 1,
813                    site_id: 0,
814                },
815            ),
816        ]);
817
818        // Site bus 0 moves site 0 -> site 1 (forward) in zone 0
819        let lane = LaneAddr {
820            direction: crate::arch::addr::Direction::Forward,
821            move_type: crate::arch::addr::MoveType::SiteBus,
822            zone_id: 0,
823            word_id: 0,
824            site_id: 0,
825            bus_id: 0,
826        };
827
828        let new_state = state.apply_moves(&[lane], &spec).unwrap();
829        assert_eq!(
830            new_state.get_qubit(&LocationAddr {
831                zone_id: 0,
832                word_id: 0,
833                site_id: 1
834            }),
835            Some(0)
836        );
837        assert_eq!(
838            new_state.get_qubit(&LocationAddr {
839                zone_id: 0,
840                word_id: 0,
841                site_id: 0
842            }),
843            None
844        );
845        assert_eq!(*new_state.move_count.get(&0).unwrap(), 1);
846    }
847
848    #[test]
849    fn apply_moves_collision() {
850        let spec = make_test_spec();
851        // Place qubit 0 at site 0 and qubit 1 at site 1 (the destination of site bus 0)
852        let state = AtomStateData::from_locations(&[
853            (
854                0,
855                LocationAddr {
856                    zone_id: 0,
857                    word_id: 0,
858                    site_id: 0,
859                },
860            ),
861            (
862                1,
863                LocationAddr {
864                    zone_id: 0,
865                    word_id: 0,
866                    site_id: 1,
867                },
868            ),
869        ]);
870
871        let lane = LaneAddr {
872            direction: crate::arch::addr::Direction::Forward,
873            move_type: crate::arch::addr::MoveType::SiteBus,
874            zone_id: 0,
875            word_id: 0,
876            site_id: 0,
877            bus_id: 0,
878        };
879
880        let new_state = state.apply_moves(&[lane], &spec).unwrap();
881        assert!(new_state.collision.contains_key(&0));
882        assert_eq!(*new_state.collision.get(&0).unwrap(), 1);
883        assert!(new_state.qubit_to_locations.is_empty());
884    }
885
886    #[test]
887    fn apply_moves_verifies_all_fields() {
888        let spec = make_test_spec();
889        let loc_0_0 = LocationAddr {
890            zone_id: 0,
891            word_id: 0,
892            site_id: 0,
893        };
894        let loc_0_1 = LocationAddr {
895            zone_id: 0,
896            word_id: 0,
897            site_id: 1,
898        };
899        let loc_1_0 = LocationAddr {
900            zone_id: 0,
901            word_id: 1,
902            site_id: 0,
903        };
904        let state = AtomStateData::from_locations(&[(0, loc_0_0), (1, loc_1_0)]);
905
906        let lane = LaneAddr {
907            direction: crate::arch::addr::Direction::Forward,
908            move_type: crate::arch::addr::MoveType::SiteBus,
909            zone_id: 0,
910            word_id: 0,
911            site_id: 0,
912            bus_id: 0,
913        };
914
915        let new_state = state.apply_moves(&[lane], &spec).unwrap();
916
917        // Qubit 0 moved from (0,0,0) to (0,0,1)
918        assert_eq!(new_state.qubit_to_locations[&0], loc_0_1);
919        assert_eq!(new_state.locations_to_qubit[&loc_0_1], 0);
920        // Qubit 1 didn't move
921        assert_eq!(new_state.qubit_to_locations[&1], loc_1_0);
922        assert_eq!(new_state.locations_to_qubit[&loc_1_0], 1);
923        // Old location is empty
924        assert!(!new_state.locations_to_qubit.contains_key(&loc_0_0));
925        // prev_lanes only has the moved qubit
926        assert_eq!(new_state.prev_lanes.len(), 1);
927        assert_eq!(new_state.prev_lanes[&0], lane);
928        // move_count incremented
929        assert_eq!(new_state.move_count[&0], 1);
930        // No collision
931        assert!(new_state.collision.is_empty());
932    }
933
934    #[test]
935    fn apply_moves_collision_verifies_all_fields() {
936        let spec = make_test_spec();
937        let state = AtomStateData::from_locations(&[
938            (
939                0,
940                LocationAddr {
941                    zone_id: 0,
942                    word_id: 0,
943                    site_id: 0,
944                },
945            ),
946            (
947                1,
948                LocationAddr {
949                    zone_id: 0,
950                    word_id: 0,
951                    site_id: 1,
952                },
953            ),
954        ]);
955
956        let lane = LaneAddr {
957            direction: crate::arch::addr::Direction::Forward,
958            move_type: crate::arch::addr::MoveType::SiteBus,
959            zone_id: 0,
960            word_id: 0,
961            site_id: 0,
962            bus_id: 0,
963        };
964
965        let new_state = state.apply_moves(&[lane], &spec).unwrap();
966
967        // Both qubits removed from location maps
968        assert!(new_state.qubit_to_locations.is_empty());
969        assert!(new_state.locations_to_qubit.is_empty());
970        // Collision recorded
971        assert_eq!(new_state.collision[&0], 1);
972        // prev_lanes has the moving qubit's lane
973        assert_eq!(new_state.prev_lanes[&0], lane);
974        // move_count incremented for moving qubit
975        assert_eq!(new_state.move_count[&0], 1);
976    }
977
978    #[test]
979    fn apply_moves_skips_empty_source() {
980        let spec = make_test_spec();
981        // Only qubit at (0,1,0), no qubit at (0,0,0)
982        let state = AtomStateData::from_locations(&[(
983            1,
984            LocationAddr {
985                zone_id: 0,
986                word_id: 1,
987                site_id: 0,
988            },
989        )]);
990
991        let lane = LaneAddr {
992            direction: crate::arch::addr::Direction::Forward,
993            move_type: crate::arch::addr::MoveType::SiteBus,
994            zone_id: 0,
995            word_id: 0,
996            site_id: 0,
997            bus_id: 0,
998        };
999
1000        let new_state = state.apply_moves(&[lane], &spec).unwrap();
1001        // Nothing changed — lane source had no qubit
1002        assert_eq!(new_state.qubit_to_locations.len(), 1);
1003        assert!(new_state.prev_lanes.is_empty());
1004        assert!(new_state.move_count.is_empty());
1005    }
1006
1007    #[test]
1008    fn apply_moves_invalid_lane_returns_none() {
1009        let spec = make_test_spec();
1010        let state = AtomStateData::from_locations(&[(
1011            0,
1012            LocationAddr {
1013                zone_id: 0,
1014                word_id: 0,
1015                site_id: 0,
1016            },
1017        )]);
1018
1019        let bad_lane = LaneAddr {
1020            direction: crate::arch::addr::Direction::Forward,
1021            move_type: crate::arch::addr::MoveType::SiteBus,
1022            zone_id: 0,
1023            word_id: 0,
1024            site_id: 0,
1025            bus_id: 99, // invalid bus
1026        };
1027
1028        assert!(state.apply_moves(&[bad_lane], &spec).is_none());
1029    }
1030
1031    #[test]
1032    fn apply_moves_accumulates_move_count() {
1033        let spec = make_test_spec();
1034        let state = AtomStateData::from_locations(&[(
1035            0,
1036            LocationAddr {
1037                zone_id: 0,
1038                word_id: 0,
1039                site_id: 0,
1040            },
1041        )]);
1042
1043        // Move forward: site 0 -> site 1
1044        let lane_fwd = LaneAddr {
1045            direction: crate::arch::addr::Direction::Forward,
1046            move_type: crate::arch::addr::MoveType::SiteBus,
1047            zone_id: 0,
1048            word_id: 0,
1049            site_id: 0,
1050            bus_id: 0,
1051        };
1052        let state2 = state.apply_moves(&[lane_fwd], &spec).unwrap();
1053        assert_eq!(state2.move_count[&0], 1);
1054
1055        // Move backward: site 1 -> site 0
1056        // site_id is always the forward source (0), direction flips endpoints
1057        let lane_bwd = LaneAddr {
1058            direction: crate::arch::addr::Direction::Backward,
1059            move_type: crate::arch::addr::MoveType::SiteBus,
1060            zone_id: 0,
1061            word_id: 0,
1062            site_id: 0,
1063            bus_id: 0,
1064        };
1065        let state3 = state2.apply_moves(&[lane_bwd], &spec).unwrap();
1066        assert_eq!(state3.move_count[&0], 2);
1067    }
1068
1069    #[test]
1070    fn get_qubit_empty_location() {
1071        let state = AtomStateData::from_locations(&[(
1072            0,
1073            LocationAddr {
1074                zone_id: 0,
1075                word_id: 0,
1076                site_id: 0,
1077            },
1078        )]);
1079        assert_eq!(
1080            state.get_qubit(&LocationAddr {
1081                zone_id: 0,
1082                word_id: 1,
1083                site_id: 0
1084            }),
1085            None
1086        );
1087    }
1088
1089    #[test]
1090    fn get_qubit_pairing_all_unpaired() {
1091        let spec = make_test_spec();
1092        // Zone 0 entangling_pairs: [[0, 1]] — word 0 paired with word 1.
1093        // Place both qubits in word 0 only — no qubit in word 1, so all unpaired.
1094        let state = AtomStateData::from_locations(&[
1095            (
1096                0,
1097                LocationAddr {
1098                    zone_id: 0,
1099                    word_id: 0,
1100                    site_id: 0,
1101                },
1102            ),
1103            (
1104                1,
1105                LocationAddr {
1106                    zone_id: 0,
1107                    word_id: 0,
1108                    site_id: 1,
1109                },
1110            ),
1111        ]);
1112
1113        let zone = ZoneAddr { zone_id: 0 };
1114        let (controls, targets, unpaired) = state.get_qubit_pairing(&zone, &spec).unwrap();
1115
1116        assert!(controls.is_empty());
1117        assert!(targets.is_empty());
1118        assert_eq!(unpaired.len(), 2);
1119    }
1120
1121    #[test]
1122    fn get_qubit_pairing_with_pairs() {
1123        let spec = make_test_spec();
1124        // Zone 0 entangling_pairs: [[0, 1]] — word 0 paired with word 1.
1125        // Place qubit 0 at (zone 0, word 0, site 0) and qubit 1 at (zone 0, word 1, site 0)
1126        // -> paired (same zone, partner words, same site).
1127        // Place qubit 2 at (zone 0, word 0, site 1) without partner at (zone 0, word 1, site 1)
1128        // -> unpaired.
1129        let state = AtomStateData::from_locations(&[
1130            (
1131                0,
1132                LocationAddr {
1133                    zone_id: 0,
1134                    word_id: 0,
1135                    site_id: 0,
1136                },
1137            ),
1138            (
1139                1,
1140                LocationAddr {
1141                    zone_id: 0,
1142                    word_id: 1,
1143                    site_id: 0,
1144                },
1145            ),
1146            (
1147                2,
1148                LocationAddr {
1149                    zone_id: 0,
1150                    word_id: 0,
1151                    site_id: 1,
1152                },
1153            ),
1154        ]);
1155
1156        let zone = ZoneAddr { zone_id: 0 };
1157        let (controls, targets, unpaired) = state.get_qubit_pairing(&zone, &spec).unwrap();
1158
1159        // Qubits 0 and 1 should be paired (word 0 and word 1 at site 0 in zone 0)
1160        assert_eq!(controls.len(), 1);
1161        assert_eq!(targets.len(), 1);
1162        use std::collections::HashSet;
1163        let control_set: HashSet<u32> = controls.iter().copied().collect();
1164        let target_set: HashSet<u32> = targets.iter().copied().collect();
1165        assert_eq!(control_set, HashSet::from([0]));
1166        assert_eq!(target_set, HashSet::from([1]));
1167        // Qubit 2 is unpaired (zone 0 word 0 site 1, partner word 1 site 1 is empty)
1168        assert_eq!(unpaired, vec![2]);
1169    }
1170
1171    #[test]
1172    fn get_qubit_pairing_invalid_zone() {
1173        let spec = make_test_spec();
1174        let state = AtomStateData::new();
1175        let zone = ZoneAddr { zone_id: 99 };
1176        assert!(state.get_qubit_pairing(&zone, &spec).is_none());
1177    }
1178
1179    #[test]
1180    fn get_qubit_pairing_skips_qubits_outside_zone() {
1181        let spec = make_test_spec();
1182        // Zone 0 entangling_pairs: [[0, 1]] — word 0 paired with word 1.
1183        // Place a qubit only at word 0 — partner word 1 has no qubit.
1184        let state = AtomStateData::from_locations(&[(
1185            0,
1186            LocationAddr {
1187                zone_id: 0,
1188                word_id: 0,
1189                site_id: 0,
1190            },
1191        )]);
1192
1193        // Use zone 0 — qubit at (0,0,0), partner at (0,1,0) is empty
1194        let zone = ZoneAddr { zone_id: 0 };
1195        let (controls, targets, unpaired) = state.get_qubit_pairing(&zone, &spec).unwrap();
1196
1197        assert!(controls.is_empty());
1198        assert!(targets.is_empty());
1199        assert_eq!(unpaired, vec![0]);
1200    }
1201
1202    #[test]
1203    fn default_is_empty() {
1204        let state = AtomStateData::default();
1205        assert!(state.locations_to_qubit.is_empty());
1206        assert!(state.qubit_to_locations.is_empty());
1207    }
1208
1209    #[test]
1210    fn clone_produces_equal_state() {
1211        let state = AtomStateData::from_locations(&[
1212            (
1213                0,
1214                LocationAddr {
1215                    zone_id: 0,
1216                    word_id: 0,
1217                    site_id: 0,
1218                },
1219            ),
1220            (
1221                1,
1222                LocationAddr {
1223                    zone_id: 0,
1224                    word_id: 1,
1225                    site_id: 0,
1226                },
1227            ),
1228        ]);
1229        let cloned = state.clone();
1230        assert_eq!(state, cloned);
1231    }
1232
1233    #[test]
1234    fn hash_is_deterministic() {
1235        use std::collections::hash_map::DefaultHasher;
1236
1237        let state1 = AtomStateData::from_locations(&[
1238            (
1239                0,
1240                LocationAddr {
1241                    zone_id: 0,
1242                    word_id: 0,
1243                    site_id: 0,
1244                },
1245            ),
1246            (
1247                1,
1248                LocationAddr {
1249                    zone_id: 0,
1250                    word_id: 1,
1251                    site_id: 0,
1252                },
1253            ),
1254        ]);
1255        let state2 = AtomStateData::from_locations(&[
1256            (
1257                1,
1258                LocationAddr {
1259                    zone_id: 0,
1260                    word_id: 1,
1261                    site_id: 0,
1262                },
1263            ),
1264            (
1265                0,
1266                LocationAddr {
1267                    zone_id: 0,
1268                    word_id: 0,
1269                    site_id: 0,
1270                },
1271            ),
1272        ]);
1273
1274        let mut h1 = DefaultHasher::new();
1275        let mut h2 = DefaultHasher::new();
1276        state1.hash(&mut h1);
1277        state2.hash(&mut h2);
1278        assert_eq!(h1.finish(), h2.finish());
1279    }
1280
1281    // --- Simultaneous semantics: chains, order-independence, validate/apply ---
1282
1283    /// A `validate()`-clean spec with an overlapping (acyclic) word bus:
1284    /// bus 0 maps words 0→1, 1→2, 2→3 — a conveyor chain.
1285    fn make_chain_spec() -> crate::arch::ArchSpec {
1286        let grid0 = Grid::from_positions(&[0.0, 5.0, 10.0, 15.0], &[0.0, 3.0]);
1287        let grid1 = Grid::from_positions(&[30.0, 35.0, 40.0, 45.0], &[0.0, 4.0]);
1288
1289        let spec = crate::arch::ArchSpec {
1290            version: Version::new(2, 0),
1291            words: (0..4u32)
1292                .map(|w| Word {
1293                    sites: vec![[w, 0], [w, 1]],
1294                })
1295                .collect(),
1296            zones: vec![
1297                Zone {
1298                    name: String::new(),
1299                    grid: grid0,
1300                    site_buses: vec![],
1301                    word_buses: vec![Bus {
1302                        src: vec![WordRef(0), WordRef(1), WordRef(2)],
1303                        dst: vec![WordRef(1), WordRef(2), WordRef(3)],
1304                    }],
1305                    words_with_site_buses: vec![],
1306                    sites_with_word_buses: vec![0],
1307                    entangling_pairs: vec![[0, 1]],
1308                },
1309                Zone {
1310                    name: String::new(),
1311                    grid: grid1,
1312                    site_buses: vec![],
1313                    word_buses: vec![],
1314                    words_with_site_buses: vec![],
1315                    sites_with_word_buses: vec![],
1316                    entangling_pairs: vec![],
1317                },
1318            ],
1319            zone_buses: vec![],
1320            modes: vec![Mode {
1321                name: "full".to_string(),
1322                zones: vec![0, 1],
1323                bitstring_order: vec![],
1324            }],
1325            paths: None,
1326            feed_forward: false,
1327            atom_reloading: false,
1328            blockade_radius: None,
1329        };
1330        assert!(
1331            spec.validate().is_ok(),
1332            "overlapping acyclic buses must be legal: {:?}",
1333            spec.validate()
1334        );
1335        spec
1336    }
1337
1338    /// Forward lane on chain-spec word bus 0, sourced at `word_id`.
1339    fn chain_lane(word_id: u32) -> LaneAddr {
1340        LaneAddr {
1341            direction: crate::arch::addr::Direction::Forward,
1342            move_type: crate::arch::addr::MoveType::WordBus,
1343            zone_id: 0,
1344            word_id,
1345            site_id: 0,
1346            bus_id: 0,
1347        }
1348    }
1349
1350    fn word_loc(word_id: u32) -> LocationAddr {
1351        LocationAddr {
1352            zone_id: 0,
1353            word_id,
1354            site_id: 0,
1355        }
1356    }
1357
1358    #[test]
1359    fn apply_moves_chain_succeeds_in_any_lane_order() {
1360        let spec = make_chain_spec();
1361        // Atoms at words 0 and 1; word 2 empty: conveyor shift 0→1→2.
1362        let state = AtomStateData::from_locations(&[(0, word_loc(0)), (1, word_loc(1))]);
1363        let lanes = [chain_lane(0), chain_lane(1)];
1364
1365        for order in [[0usize, 1], [1, 0]] {
1366            let slice = [lanes[order[0]], lanes[order[1]]];
1367            let result = state.apply_moves(&slice, &spec).unwrap();
1368            assert!(
1369                result.collision.is_empty(),
1370                "chain must not collide (order {order:?})"
1371            );
1372            assert_eq!(result.qubit_to_locations[&0], word_loc(1));
1373            assert_eq!(result.qubit_to_locations[&1], word_loc(2));
1374            assert_eq!(result.locations_to_qubit.len(), 2);
1375            assert_eq!(result.move_count[&0], 1);
1376            assert_eq!(result.move_count[&1], 1);
1377        }
1378    }
1379
1380    #[test]
1381    fn apply_moves_is_invariant_under_lane_permutation() {
1382        let spec = make_chain_spec();
1383        // Full chain: atoms at words 0, 1, 2 shift to 1, 2, 3.
1384        let state =
1385            AtomStateData::from_locations(&[(0, word_loc(0)), (1, word_loc(1)), (2, word_loc(2))]);
1386        let lanes = [chain_lane(0), chain_lane(1), chain_lane(2)];
1387
1388        let reference = state.apply_moves(&lanes, &spec).unwrap();
1389        assert!(reference.collision.is_empty());
1390        assert_eq!(reference.qubit_to_locations[&2], word_loc(3));
1391
1392        for perm in [
1393            [0usize, 1, 2],
1394            [0, 2, 1],
1395            [1, 0, 2],
1396            [1, 2, 0],
1397            [2, 0, 1],
1398            [2, 1, 0],
1399        ] {
1400            let slice = [lanes[perm[0]], lanes[perm[1]], lanes[perm[2]]];
1401            let permuted = state.apply_moves(&slice, &spec).unwrap();
1402            assert_eq!(
1403                permuted, reference,
1404                "lane order {perm:?} changed the result"
1405            );
1406        }
1407    }
1408
1409    #[test]
1410    fn apply_moves_stationary_collision_is_order_independent() {
1411        let spec = make_chain_spec();
1412        // Atom at word 3 has no outgoing lane in the group: qubit 1 lands on
1413        // a stationary atom regardless of lane order.
1414        let state =
1415            AtomStateData::from_locations(&[(0, word_loc(1)), (1, word_loc(2)), (2, word_loc(3))]);
1416        let lanes = [chain_lane(1), chain_lane(2)];
1417
1418        let reference = state.apply_moves(&lanes, &spec).unwrap();
1419        assert_eq!(reference.collision, HashMap::from([(1, 2)]));
1420        // Qubit 0 still completes its move; the collided pair is destroyed.
1421        assert_eq!(reference.qubit_to_locations[&0], word_loc(2));
1422        assert_eq!(reference.qubit_to_locations.len(), 1);
1423
1424        let reversed = state.apply_moves(&[lanes[1], lanes[0]], &spec).unwrap();
1425        assert_eq!(reversed, reference);
1426    }
1427
1428    #[test]
1429    fn validate_moves_accepts_chain_and_apply_validated_matches() {
1430        let spec = make_chain_spec();
1431        let state = AtomStateData::from_locations(&[(0, word_loc(0)), (1, word_loc(1))]);
1432        let lanes = [chain_lane(0), chain_lane(1)];
1433
1434        let validated = state.validate_moves(&lanes, &spec).expect("chain is valid");
1435        let via_token = state.apply_validated(&validated).expect("token is fresh");
1436        let via_legacy = state.apply_moves(&lanes, &spec).unwrap();
1437        assert_eq!(via_token, via_legacy);
1438        assert!(via_token.collision.is_empty());
1439    }
1440
1441    #[test]
1442    fn validate_moves_rejects_mover_onto_stationary_atom() {
1443        let spec = make_chain_spec();
1444        // Word 3 is occupied but has no lane in the group.
1445        let state = AtomStateData::from_locations(&[(0, word_loc(2)), (1, word_loc(3))]);
1446        let lanes = [chain_lane(2)];
1447
1448        let errors = state.validate_moves(&lanes, &spec).unwrap_err();
1449        assert!(errors.iter().any(|e| matches!(
1450            e,
1451            MoveValidationError::DestinationOccupiedByStationaryAtom { occupant: 1, .. }
1452        )));
1453    }
1454
1455    #[test]
1456    fn validate_moves_rejects_filler_onto_stationary_atom() {
1457        let spec = make_chain_spec();
1458        // Word 1 is empty (filler lane) but its destination word 2 holds a
1459        // stationary atom: the trap site still arrives there.
1460        let state = AtomStateData::from_locations(&[(0, word_loc(2))]);
1461        let lanes = [chain_lane(1)];
1462
1463        let errors = state.validate_moves(&lanes, &spec).unwrap_err();
1464        assert!(errors.iter().any(|e| matches!(
1465            e,
1466            MoveValidationError::DestinationOccupiedByStationaryAtom { occupant: 0, .. }
1467        )));
1468        // Legacy apply skips the filler silently — pinned so the contrast
1469        // between the two APIs stays intentional.
1470        let legacy = state.apply_moves(&lanes, &spec).unwrap();
1471        assert_eq!(legacy.qubit_to_locations[&0], word_loc(2));
1472        assert!(legacy.collision.is_empty());
1473    }
1474
1475    #[test]
1476    fn validate_moves_duplicate_lane_reports_duplicate_only() {
1477        let spec = make_chain_spec();
1478        let state = AtomStateData::from_locations(&[(0, word_loc(0))]);
1479        let lanes = [chain_lane(0), chain_lane(0)];
1480
1481        let errors = state.validate_moves(&lanes, &spec).unwrap_err();
1482        assert!(errors.iter().any(|e| matches!(
1483            e,
1484            MoveValidationError::LaneGroup(LaneGroupError::DuplicateAddress { .. })
1485        )));
1486        // The repeated lane must not also self-report as a contested
1487        // destination or an unresolvable lane.
1488        assert!(
1489            !errors
1490                .iter()
1491                .any(|e| matches!(e, MoveValidationError::ContestedDestination { .. }))
1492        );
1493        assert!(
1494            !errors
1495                .iter()
1496                .any(|e| matches!(e, MoveValidationError::UnresolvableLane { .. }))
1497        );
1498    }
1499
1500    #[test]
1501    fn validate_moves_reports_contested_destination() {
1502        // An ill-formed bus with a duplicated destination: 0→1 and 2→1.
1503        // `validate_moves` never sees `ArchSpec::validate()`, so it must
1504        // catch the contested landing itself.
1505        let mut spec = make_chain_spec();
1506        spec.zones[0].word_buses[0] = Bus {
1507            src: vec![WordRef(0), WordRef(2)],
1508            dst: vec![WordRef(1), WordRef(1)],
1509        };
1510        let state = AtomStateData::from_locations(&[(0, word_loc(0)), (1, word_loc(2))]);
1511        let lanes = [chain_lane(0), chain_lane(2)];
1512
1513        let errors = state.validate_moves(&lanes, &spec).unwrap_err();
1514        assert!(errors.iter().any(|e| matches!(
1515            e,
1516            MoveValidationError::ContestedDestination { first, second, .. }
1517                if first != second
1518        )));
1519    }
1520
1521    #[test]
1522    fn validate_moves_invalid_lane_reports_single_error() {
1523        let spec = make_chain_spec();
1524        let state = AtomStateData::from_locations(&[(0, word_loc(0))]);
1525        // bus_id 7 does not exist: `check_lanes` reports InvalidLane, and
1526        // the redundant UnresolvableLane must be suppressed.
1527        let mut bad = chain_lane(0);
1528        bad.bus_id = 7;
1529
1530        let errors = state.validate_moves(&[bad], &spec).unwrap_err();
1531        assert_eq!(errors.len(), 1, "expected exactly one error: {errors:?}");
1532        assert!(matches!(
1533            errors[0],
1534            MoveValidationError::LaneGroup(LaneGroupError::InvalidLane { .. })
1535        ));
1536    }
1537
1538    #[test]
1539    fn apply_validated_rejects_token_whose_destination_became_occupied() {
1540        let spec = make_chain_spec();
1541        let state = AtomStateData::from_locations(&[(0, word_loc(0))]);
1542        let validated = state
1543            .validate_moves(&[chain_lane(0)], &spec)
1544            .expect("valid against the original state");
1545
1546        // The destination becomes occupied by a stationary atom after
1547        // validation; applying the stale token must fail rather than
1548        // overwrite the occupant's reverse-map entry.
1549        let later = state.add_atoms(&[(5, word_loc(1))]).unwrap();
1550        let errors = later.apply_validated(&validated).unwrap_err();
1551        assert!(errors.iter().any(|e| matches!(
1552            e,
1553            MoveValidationError::DestinationOccupiedByStationaryAtom { occupant: 5, .. }
1554        )));
1555    }
1556
1557    #[test]
1558    fn apply_validated_rejects_token_whose_source_moved_on() {
1559        let spec = make_chain_spec();
1560        let state = AtomStateData::from_locations(&[(0, word_loc(0))]);
1561        let validated = state
1562            .validate_moves(&[chain_lane(0)], &spec)
1563            .expect("valid against the original state");
1564
1565        // Applying the token twice: after the first apply the qubit has left
1566        // word 0, so the second application is stale.
1567        let after = state.apply_validated(&validated).expect("token is fresh");
1568        let errors = after.apply_validated(&validated).unwrap_err();
1569        assert!(
1570            errors
1571                .iter()
1572                .any(|e| matches!(e, MoveValidationError::StaleMoverSource { expected: 0, .. }))
1573        );
1574        // The state that produced the error is untouched.
1575        assert_eq!(after.qubit_to_locations[&0], word_loc(1));
1576    }
1577
1578    #[test]
1579    fn validate_moves_accepts_filler_onto_vacated_site() {
1580        let spec = make_chain_spec();
1581        // Word 1 empty, word 2 occupied by a mover: the filler lane 1→2
1582        // points at a site vacated in the same step.
1583        let state = AtomStateData::from_locations(&[(0, word_loc(2))]);
1584        let lanes = [chain_lane(1), chain_lane(2)];
1585
1586        let validated = state
1587            .validate_moves(&lanes, &spec)
1588            .expect("filler is valid");
1589        let result = state.apply_validated(&validated).expect("token is fresh");
1590        assert_eq!(result.qubit_to_locations[&0], word_loc(3));
1591        assert_eq!(result.locations_to_qubit.len(), 1);
1592    }
1593}