Skip to main content

bloq_graph/
graph.rs

1//! The editable block graph and its topology-preserving operations.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
4use std::ops::RangeInclusive;
5use std::str::FromStr;
6use std::sync::Arc;
7
8use bloq_utils::{Basis, PauliBasis, UDirection};
9use glam::IVec3;
10use petgraph::stable_graph::{EdgeIndex, NodeIndex, StableUnGraph};
11use petgraph::visit::EdgeRef;
12use rand::{Rng, RngExt, SeedableRng, rngs::StdRng};
13use rustc_hash::FxBuildHasher;
14use smallvec::SmallVec;
15
16use crate::ZXGraph;
17#[cfg(feature = "gltf")]
18use crate::gltf::{
19    GltfData, GltfFaceSelector, POPPED_X_FACE_COLOR, POPPED_Y_FACE_COLOR, POPPED_Z_FACE_COLOR,
20    block_graph_as_gltf_data_with_modules, block_graph_as_gltf_data_with_popped_faces,
21    module_html_geometry, stabilizer_as_gltf_data,
22};
23use crate::validate::InvalidActionError;
24use crate::zx::{
25    Stabilizer, StabilizerGenerator, StabilizerGenerators, StabilizerRowKind,
26    fill_ports_auto as fill_ports_auto_impl,
27};
28use crate::{
29    Action, ActionDag, Block, BlockGraphError, BlockKind, BranchProjection, CubeHeight, CubeKind,
30    Direction, MeasureTarget, MeasurementObservable, PatchRotationKind, Pipe, WalkingBoundaryKind,
31    WalkingKind,
32};
33
34#[cfg(feature = "gltf")]
35const OUTER_FACE_OPACITY_SCALE: f32 = 0.55;
36#[cfg(feature = "gltf")]
37fn emphasize_popped_faces(mut data: GltfData) -> GltfData {
38    // Scoped here rather than at the top of the file: this is the crate's only
39    // use of either, and a featureless build would warn on a top-level import.
40    use bloq_utils::RGBA;
41
42    let mut triangles: BTreeMap<RGBA, Vec<[glam::Vec3; 3]>> = BTreeMap::new();
43    for (color, faces) in std::mem::take(&mut data.triangles) {
44        let color = match color {
45            RGBA::X_RED => POPPED_X_FACE_COLOR,
46            RGBA::Y_GREEN => POPPED_Y_FACE_COLOR,
47            RGBA::Z_BLUE => POPPED_Z_FACE_COLOR,
48            _ => RGBA { a: 255, ..color },
49        };
50        triangles.entry(color).or_default().extend(faces);
51    }
52    data.triangles = triangles;
53    data
54}
55
56/// A read-only view of a single time (`z`) layer of a [`BlockGraph`].
57///
58/// Obtained via [`BlockGraph::layer`]; use [`into_graph`](Self::into_graph) to
59/// materialize the layer as a standalone graph.
60#[derive(Debug, Clone, Copy)]
61pub struct BlockLayerView<'a> {
62    graph: &'a BlockGraph,
63    z: i32,
64}
65
66/// Stabilizer tables for the jointly reachable static projections of a
67/// conditional block graph.
68pub type AnalyzedBranchProjections = Vec<(BranchProjection, StabilizerGenerators)>;
69
70/// Branch-arm footprints retained only while a module linker bulk-adds child
71/// blocks. The editable graph continues to use its ordinary collision path.
72#[derive(Default)]
73pub(crate) struct LinkBranchOccupancy {
74    by_position: HashMap<IVec3, Vec<(IVec3, BlockKind)>>,
75}
76
77impl LinkBranchOccupancy {
78    pub(crate) fn add_region(
79        &mut self,
80        region: &crate::BranchRegion,
81    ) -> Result<(), BlockGraphError> {
82        for block in region.on_false().blocks().chain(region.on_true().blocks()) {
83            for position in block.checked_reserved_positions()? {
84                self.by_position
85                    .entry(position)
86                    .or_default()
87                    .push((block.pos(), block.kind()));
88            }
89        }
90        Ok(())
91    }
92}
93
94/// A graph of surface code blocks connected by lattice-surgery pipes.
95///
96/// This is the source-level, authoring representation: blocks sit at integer 3D
97/// lattice positions (the `z` axis is time), pipes connect adjacent endpoints,
98/// and classical control flow lives in a cached [`ActionDag`]. Structural edits
99/// refresh that DAG; metadata-only tag edits leave it intact.
100/// [`validate`](Self::validate) rebuilds and checks both structure and action
101/// semantics.
102#[derive(Debug, Clone)]
103pub struct BlockGraph {
104    inner: StableUnGraph<Block, Pipe>,
105    block_id: HashMap<IVec3, u32, FxBuildHasher>,
106    reserved_by_position: HashMap<IVec3, SmallVec<[IVec3; 1]>, FxBuildHasher>,
107    pub(crate) branches: Vec<crate::BranchRegion>,
108    action_graph: ActionDag,
109    action_inputs: BTreeSet<String>,
110    /// Error from the last post-mutation DAG rebuild, if it failed. While set,
111    /// [`action_graph`](Self::action_graph) holds an unvalidated best-effort DAG.
112    action_graph_error: Option<BlockGraphError>,
113    /// Definition name, `main` for the executable root.
114    pub name: String,
115    /// Public quantum and classical interface.
116    pub interface: crate::ModuleInterface,
117    /// Rotated and translated references to other graph definitions.
118    pub instances: Vec<crate::ModuleInstance>,
119    /// Authored quantum seams between local blocks and child instances.
120    pub quantum_connections: Vec<crate::QuantumConnection>,
121    /// Authored bindings to child classical inputs.
122    pub bit_bindings: Vec<crate::BitBinding>,
123    pub(crate) definitions: Vec<BlockGraph>,
124    pub(crate) interface_declared: bool,
125}
126
127impl Default for BlockGraph {
128    fn default() -> Self {
129        Self {
130            inner: StableUnGraph::default(),
131            block_id: HashMap::default(),
132            reserved_by_position: HashMap::default(),
133            branches: Vec::new(),
134            action_graph: ActionDag::default(),
135            action_inputs: BTreeSet::new(),
136            action_graph_error: None,
137            name: Self::ENTRY_MODULE.to_string(),
138            interface: crate::ModuleInterface::default(),
139            instances: Vec::new(),
140            quantum_connections: Vec::new(),
141            bit_bindings: Vec::new(),
142            definitions: Vec::new(),
143            interface_declared: false,
144        }
145    }
146}
147
148impl BlockGraph {
149    /// Replaces local geometry and actions while retaining interfaces and definitions.
150    pub fn replace_local_body(&mut self, body: Self) {
151        self.inner = body.inner;
152        self.block_id = body.block_id;
153        self.reserved_by_position = body.reserved_by_position;
154        self.branches = body.branches;
155        self.action_graph = body.action_graph;
156        self.action_inputs = body.action_inputs;
157        self.action_graph_error = body.action_graph_error;
158    }
159
160    pub(crate) fn copy_local_geometry(&self) -> Self {
161        Self {
162            inner: self.inner.clone(),
163            block_id: self.block_id.clone(),
164            reserved_by_position: self.reserved_by_position.clone(),
165            branches: self.branches.clone(),
166            action_graph: self.action_graph.clone(),
167            action_inputs: self.action_inputs.clone(),
168            action_graph_error: self.action_graph_error.clone(),
169            ..Self::default()
170        }
171    }
172}
173
174impl<'a> BlockLayerView<'a> {
175    /// Returns the time layer this view is fixed to.
176    pub fn z(&self) -> i32 {
177        self.z
178    }
179
180    /// Returns the underlying graph.
181    pub fn graph(&self) -> &BlockGraph {
182        self.graph
183    }
184
185    /// Iterates over the blocks occupying this layer.
186    pub fn blocks(&self) -> impl Iterator<Item = &Block> + '_ {
187        self.graph
188            .blocks()
189            .filter(move |block| block.occupies_layer(self.z))
190    }
191
192    /// Iterates over the pipes lying within this layer, projecting spatial pipes
193    /// of multi-layer blocks onto the layer's `z`.
194    pub fn spacelike_pipes(&self) -> impl Iterator<Item = Pipe> + '_ {
195        self.graph.pipes().filter_map(move |pipe| {
196            let p1 = pipe.src;
197            let p2 = pipe.dst();
198            if p1.z == self.z && p2.z == self.z {
199                return Some(pipe.clone());
200            }
201            if !pipe.dir.is_spatial() {
202                return None;
203            }
204            let src_block = self.graph.get_endpoint_block(p1)?;
205            let dst_block = self.graph.get_endpoint_block(p2)?;
206            if src_block.occupies_layer(self.z) && dst_block.occupies_layer(self.z) {
207                let mut projected = pipe.clone();
208                projected.src.z = self.z;
209                Some(projected)
210            } else {
211                None
212            }
213        })
214    }
215
216    /// Iterates over the temporal pipes crossing into the layer above or below.
217    pub fn boundary_pipes(&self) -> impl Iterator<Item = &Pipe> + '_ {
218        self.graph.pipes().filter(move |pipe| {
219            let p1 = pipe.src;
220            let p2 = pipe.dst();
221            let (p1, p2) = if p1.z <= p2.z { (p1, p2) } else { (p2, p1) };
222            (p1.z == self.z && p2.z > self.z) || (p1.z < self.z && p2.z == self.z)
223        })
224    }
225
226    /// Materializes the layer as a standalone [`BlockGraph`], replacing temporal
227    /// pipes into adjacent layers with connections to virtual
228    /// [`BlockKind::Port`] blocks. Actions are dropped.
229    ///
230    /// # Panics
231    ///
232    /// Panics if the source graph's internal block or pipe indexes are inconsistent.
233    pub fn into_graph(self) -> BlockGraph {
234        let mut layer = BlockGraph::new();
235        for block in self.blocks() {
236            let mut layer_block = block.clone();
237            if layer_block.kind.is_cube() && layer_block.height_cells() != 1 {
238                layer_block = Block::new(IVec3::new(block.pos.x, block.pos.y, self.z), block.kind);
239                if let Some(tag) = block.tag() {
240                    layer_block = layer_block
241                        .with_tag(tag)
242                        .expect("existing block tag is valid");
243                }
244            }
245            layer.add_block(layer_block);
246        }
247        for p in self.spacelike_pipes() {
248            layer.add_pipe(p);
249        }
250        for p in self.boundary_pipes() {
251            let p1 = p.src;
252            let p2 = p.dst();
253            let (p1, p2) = if p1.z <= p2.z { (p1, p2) } else { (p2, p1) };
254            if p1.z == self.z && p2.z > self.z {
255                layer.add_block(Block::new(p2, BlockKind::Port));
256                layer.add_pipe(p.clone());
257            } else if p1.z < self.z && p2.z == self.z {
258                layer.add_block(Block::new(p1, BlockKind::Port));
259                layer.add_pipe(p.clone());
260            }
261        }
262        layer.clear_actions();
263        layer
264    }
265}
266
267impl BlockGraph {
268    /// Creates an empty block graph.
269    pub fn new() -> Self {
270        Self::default()
271    }
272
273    pub(crate) fn require_flat_hierarchy(
274        &self,
275        operation: &'static str,
276    ) -> Result<(), BlockGraphError> {
277        if self.instances.is_empty() {
278            Ok(())
279        } else {
280            Err(BlockGraphError::HierarchyRequiresFlatten { operation })
281        }
282    }
283
284    pub(crate) fn require_flat_source(
285        &self,
286        operation: &'static str,
287    ) -> Result<(), BlockGraphError> {
288        if self.has_module_structure() {
289            Err(BlockGraphError::HierarchyRequiresFlatten { operation })
290        } else {
291            Ok(())
292        }
293    }
294
295    /// Infers the X/Y/Z basis assignment for each face of a pipe from `pipe.src`.
296    pub fn infer_pipe_basis(&self, pipe: &Pipe) -> [Option<Basis>; 3] {
297        self.infer_pipe_basis_from_endpoint(pipe, pipe.src)
298    }
299
300    /// Infers the X/Y/Z basis assignment for each face of a pipe from one endpoint.
301    pub fn infer_pipe_basis_from_endpoint(
302        &self,
303        pipe: &Pipe,
304        endpoint: IVec3,
305    ) -> [Option<Basis>; 3] {
306        let Ok(dst) = pipe.try_dst() else {
307            return [None; 3];
308        };
309        if endpoint != pipe.src && endpoint != dst {
310            return [None; 3];
311        }
312
313        let mut canonical = if pipe.src.to_array() < dst.to_array() {
314            pipe.clone()
315        } else {
316            Pipe::new(dst, pipe.dir.negate())
317        };
318        if pipe.hadamard {
319            canonical = canonical.with_hadamard();
320        }
321        let mut bases = self.infer_pipe_basis_from_source(&canonical);
322        if endpoint != canonical.src && pipe.hadamard {
323            for basis in bases.iter_mut().flatten() {
324                *basis = basis.flip();
325            }
326        }
327        bases
328    }
329
330    /// Infers the unique non-spatial cube that continues a spatial Port into a
331    /// plain temporal pipe.
332    ///
333    /// The authored spatial pipe fixes the two bases perpendicular to its
334    /// axis. The missing basis equals the temporal basis, leaving the other
335    /// spatial axis as the cube normal.
336    pub fn infer_spatial_port_cube_kind(&self, position: IVec3) -> Option<crate::CubeKind> {
337        let block = self.get_block(position)?;
338        if !block.kind().is_port() {
339            return None;
340        }
341        let pipe = self
342            .inner
343            .edges(self.try_get_id(position).ok()?)
344            .filter(|edge| edge.weight().src() == position || edge.weight().dst() == position)
345            .min_by_key(|edge| edge.id().index())?
346            .weight();
347        if !pipe.dir().is_spatial() {
348            return None;
349        }
350        let mut bases = self.infer_pipe_basis_from_endpoint(pipe, position);
351        let temporal = bases[crate::UDirection::Z.index()]?;
352        bases[pipe.dir().as_udirection().index()] = Some(temporal);
353        let kind = crate::CubeKind::try_from([bases[0]?, bases[1]?, bases[2]?]).ok()?;
354        (!kind.is_spatial()).then_some(kind)
355    }
356
357    fn infer_pipe_basis_from_source(&self, pipe: &Pipe) -> [Option<Basis>; 3] {
358        let Ok(dst) = pipe.try_dst() else {
359            return [None; 3];
360        };
361        let mut bases = [None; 3];
362        let src_bases = self.infer_pipe_endpoint_face_bases(pipe, pipe.src);
363        let dst_bases = self.infer_pipe_endpoint_face_bases(pipe, dst);
364
365        for (i, dir) in UDirection::iter().enumerate() {
366            if dir == pipe.dir.as_udirection() {
367                continue;
368            }
369            if let Some(basis) = src_bases[i] {
370                bases[i] = Some(basis);
371            } else if let Some(b) = dst_bases[i] {
372                bases[i] = Some(if pipe.hadamard { b.flip() } else { b });
373            }
374        }
375        let pipe_dir_id = pipe.dir.as_udirection().index();
376        // If no bases could be inferred, default the two perpendicular axes to
377        // complementary X/Z bases
378        let none_count = bases.iter().filter(|b| b.is_none()).count();
379        if none_count == 3 {
380            bases[(pipe_dir_id + 1) % 3] = Some(Basis::X);
381            bases[(pipe_dir_id + 2) % 3] = Some(Basis::Z);
382        } else if none_count == 2 {
383            let Some(resolved_id) = bases.iter().position(Option::is_some) else {
384                return bases;
385            };
386            let other_id = 3 - pipe_dir_id - resolved_id;
387            if let Some(resolved) = bases[resolved_id] {
388                bases[other_id] = Some(resolved.flip());
389            }
390        }
391        bases
392    }
393
394    pub(crate) fn infer_pipe_endpoint_face_bases(
395        &self,
396        pipe: &Pipe,
397        endpoint: IVec3,
398    ) -> [Option<Basis>; 3] {
399        let Ok(pipe_dst) = pipe.try_dst() else {
400            return [None; 3];
401        };
402        if endpoint != pipe.src && endpoint != pipe_dst {
403            return [None; 3];
404        }
405        let Some(block) = self.get_endpoint_block(endpoint) else {
406            return [None; 3];
407        };
408        let endpoint_bases = match block.kind() {
409            BlockKind::PatchRotation(kind) => {
410                kind.pipe_face_bases_at_endpoint(block.pos(), endpoint)
411            }
412            _ => block.bases_at_endpoint(endpoint),
413        };
414        let Some(kind_bases) = endpoint_bases else {
415            return [None; 3];
416        };
417
418        let mut bases = [None; 3];
419        for (i, dir) in UDirection::iter().enumerate() {
420            if dir == pipe.dir.as_udirection() {
421                continue;
422            }
423            let offset = dir.to_ivec3();
424            let has_opposite_pair = crate::checked_add_position(endpoint, offset)
425                .is_ok_and(|neighbor| self.has_pipe_between(endpoint, neighbor))
426                && crate::checked_add_position(endpoint, -offset)
427                    .is_ok_and(|neighbor| self.has_pipe_between(endpoint, neighbor));
428            if !has_opposite_pair {
429                bases[i] = Some(kind_bases[i]);
430            }
431        }
432        bases
433    }
434
435    /// Returns a read-only view of the time layer at `z`.
436    pub fn layer(&self, z: i32) -> BlockLayerView<'_> {
437        BlockLayerView { graph: self, z }
438    }
439
440    /// The `+X`/`+Y` connected components of each time layer's cubes, keyed by
441    /// the layer the cube is *anchored* in, each component sorted and the
442    /// components ordered by their lowest member.
443    ///
444    /// Spatial connectivity is the unit of shared time: merged patches drive the
445    /// same data qubits, so anything schedule-like belongs to a whole component
446    /// — the height propagated here, the CX-slot depth `bloq_compile` derives.
447    /// `+Z` pipes are temporal and do not join components.
448    pub fn cube_layer_components(&self) -> BTreeMap<i32, Vec<Vec<IVec3>>> {
449        let mut layers = BTreeMap::<i32, Vec<IVec3>>::new();
450        for block in self.blocks().filter(|block| block.kind().is_cube()) {
451            layers.entry(block.pos().z).or_default().push(block.pos());
452        }
453
454        layers
455            .into_iter()
456            .map(|(z, mut positions)| {
457                positions.sort_by_key(IVec3::to_array);
458                let index: HashMap<IVec3, usize> = positions
459                    .iter()
460                    .copied()
461                    .enumerate()
462                    .map(|(i, pos)| (pos, i))
463                    .collect();
464                let mut union = petgraph::unionfind::UnionFind::new(positions.len());
465                for pipe in self.layer(z).spacelike_pipes() {
466                    let (src, dst) = pipe.endpoints();
467                    let (Some(src), Some(dst)) =
468                        (self.get_endpoint_block(src), self.get_endpoint_block(dst))
469                    else {
470                        continue;
471                    };
472                    // A cube anchored lower merely overlaps this layer; it
473                    // belongs to its own layer's partition, not this one's.
474                    let (Some(&src), Some(&dst)) = (index.get(&src.pos()), index.get(&dst.pos()))
475                    else {
476                        continue;
477                    };
478                    union.union(src, dst);
479                }
480
481                let mut groups: Vec<Vec<IVec3>> = Vec::new();
482                let mut slot_of_root = HashMap::<usize, usize>::new();
483                for (i, &pos) in positions.iter().enumerate() {
484                    let slot = *slot_of_root.entry(union.find(i)).or_insert_with(|| {
485                        groups.push(Vec::new());
486                        groups.len() - 1
487                    });
488                    groups[slot].push(pos);
489                }
490                (z, groups)
491            })
492            .collect()
493    }
494
495    /// Returns the current action DAG.
496    ///
497    /// Structural and action-affecting edits refresh this DAG; tag-only edits
498    /// leave it intact.
499    pub fn action_graph(&self) -> &ActionDag {
500        &self.action_graph
501    }
502
503    /// Returns the error from the last failed DAG rebuild; `Some` means the
504    /// cached DAG is an unvalidated best effort. Cleared by the next
505    /// successful rebuild.
506    pub fn action_graph_error(&self) -> Option<&BlockGraphError> {
507        self.action_graph_error.as_ref()
508    }
509
510    /// Derives an independent action DAG, including correlation dependencies.
511    ///
512    /// This inspection query does not change the source or compile physical IR.
513    /// Authored hierarchies use the compiler's module linker and correlation
514    /// composition, retaining definition and instance ownership in each node.
515    /// Continuing branches use the guarded causal readout planner.
516    /// Composed and continuing-branch DAGs retain dependencies without assigning
517    /// a single measurement stabilizer to each node.
518    ///
519    /// # Errors
520    ///
521    /// Returns an error for invalid sources, causal cycles, unavailable readouts,
522    /// or exhausted analysis budgets.
523    pub fn analyze_action_graph(&self) -> Result<ActionDag, BlockGraphError> {
524        self.analyze_action_graph_with_limits(crate::ModuleCertificationLimits::DEFAULT)
525    }
526
527    /// Derives an action DAG with explicit source-analysis resource limits.
528    ///
529    /// # Errors
530    ///
531    /// Returns the same typed failures as [`Self::analyze_action_graph`], including
532    /// exhaustion of the supplied budgets.
533    pub fn analyze_action_graph_with_limits(
534        &self,
535        limits: crate::ModuleCertificationLimits,
536    ) -> Result<ActionDag, BlockGraphError> {
537        if self.has_module_structure() {
538            return crate::action_dag::hierarchy::analyze(self, limits);
539        }
540        if !self.has_continuing_branches() {
541            let (analyzed, _) = self.clone().analyze_actions_with_limits(limits)?;
542            return Ok(analyzed.action_graph);
543        }
544        let topology = crate::GuardedTopology::new(self, limits)?;
545        let mut plan = crate::GuardedSurfaceSpace::new(topology, &Default::default(), limits)?
546            .plan_readouts()?;
547        let mut dag = self.build_action_graph(&self.actions())?;
548        dag.attach_guarded_dependencies(plan.action_dependencies()?)?;
549        Ok(dag)
550    }
551
552    /// Returns the actions in source order.
553    pub fn actions(&self) -> Vec<Action> {
554        self.action_graph
555            .ordered_nodes()
556            .map(|node| node.action.clone())
557            .collect()
558    }
559
560    /// Returns `true` if the graph has any actions.
561    pub fn has_actions(&self) -> bool {
562        self.action_graph.ordered_nodes().next().is_some()
563    }
564
565    /// Returns the internal node ID for the block at the given position.
566    pub fn get_block_id(&self, pos: impl Into<IVec3>) -> Option<u32> {
567        self.block_id.get(&pos.into()).copied()
568    }
569
570    pub(crate) fn blog_block_ids(&self) -> Vec<(IVec3, u32)> {
571        let mut blocks = self
572            .block_id
573            .iter()
574            .map(|(&position, &id)| (position, id))
575            .collect::<Vec<_>>();
576        blocks.sort_by_key(|(_, id)| *id);
577        if self.branches.is_empty() {
578            return blocks;
579        }
580        let shown = self
581            .branches
582            .iter()
583            .flat_map(|region| region.shown_arm().blocks().map(Block::pos))
584            .collect::<HashSet<_>>();
585        blocks
586            .into_iter()
587            .filter(|(position, _)| !shown.contains(position))
588            .enumerate()
589            .map(|(id, (position, _))| {
590                (
591                    position,
592                    u32::try_from(id).expect("BlockGraph ids fit in u32"),
593                )
594            })
595            .collect()
596    }
597
598    /// Returns the stored block ID for a connectable endpoint position.
599    ///
600    /// Ordinary blocks have one endpoint at their stored position. Extended
601    /// blocks also expose their shifted end position as a connectable endpoint
602    /// that resolves to the same stored graph node.
603    pub(crate) fn get_endpoint_block_id(&self, pos: impl Into<IVec3>) -> Option<u32> {
604        self.try_get_endpoint_id(pos)
605            .ok()
606            .map(|id| id.index() as u32)
607    }
608
609    /// Converts this static block graph to a ZX-calculus graph.
610    ///
611    /// A graph with structural Branch actions must first be expanded with
612    /// [`BlockGraph::branch_projections`]; optional topology has no single ZX
613    /// graph representation.
614    ///
615    /// # Errors
616    ///
617    /// Returns an error if graph validation or ZX conversion fails.
618    pub fn to_zx_graph(&self) -> Result<ZXGraph, BlockGraphError> {
619        self.require_flat_hierarchy("ZX conversion")?;
620        ZXGraph::try_from(self).map_err(Into::into)
621    }
622
623    /// Derives the stabilizer generators for this graph via the unified compute backend.
624    ///
625    /// Structural sources must first be expanded with
626    /// [`BlockGraph::branch_projections`]; one generator table cannot describe
627    /// several optional topologies.
628    ///
629    /// # Errors
630    ///
631    /// Returns an error if validation or stabilizer derivation fails.
632    pub fn stabilizers(&self) -> Result<StabilizerGenerators, BlockGraphError> {
633        self.stabilizers_with_limits(crate::ModuleCertificationLimits::DEFAULT)
634    }
635
636    /// Derive a static table with explicit construction and search limits.
637    ///
638    /// # Errors
639    ///
640    /// Returns an error if validation, resource limits, or stabilizer derivation fails.
641    pub fn stabilizers_with_limits(
642        &self,
643        limits: crate::ModuleCertificationLimits,
644    ) -> Result<StabilizerGenerators, BlockGraphError> {
645        self.require_flat_hierarchy("stabilizer analysis")?;
646        self.validate_resource_limits(limits)?;
647        if let Some(target) = self.action_graph.ordered_nodes().find_map(|node| {
648            if let Action::Branch { target, .. } = node.action {
649                Some(target)
650            } else {
651                None
652            }
653        }) {
654            return Err(crate::InvalidActionError::MissingBranchValue(target).into());
655        }
656        self.stabilizer_zx_graph(limits)?
657            .stabilizers_with_limits(limits)
658            .map_err(Into::into)
659    }
660
661    fn stabilizer_zx_graph(
662        &self,
663        limits: crate::ModuleCertificationLimits,
664    ) -> Result<ZXGraph, BlockGraphError> {
665        let columns = self
666            .pipe_count()
667            .checked_mul(2)
668            .and_then(|pipes| pipes.checked_add(self.block_count()));
669        if columns.is_none_or(|columns| columns > limits.max_local_columns) {
670            return Err(crate::StabilizerError::ResourceLimited {
671                phase: "local ZX columns",
672                observed: columns.unwrap_or(usize::MAX),
673                limit: limits.max_local_columns,
674            }
675            .into());
676        }
677        ZXGraph::from_block_graph_for_analysis(self).map_err(Into::into)
678    }
679
680    /// Derives the canonical all-true table used by the installed source DAG.
681    /// Named measurement rows must live wholly in the common prefix; otherwise
682    /// one cached Decode action could not describe every projected arm.
683    pub(crate) fn stabilizers_for_action_analysis_with_limits(
684        &self,
685        limits: crate::ModuleCertificationLimits,
686    ) -> Result<StabilizerGenerators, BlockGraphError> {
687        self.stabilizer_analysis_for_actions_impl(limits)
688            .map(|(baseline, _)| baseline)
689    }
690
691    fn stabilizer_analysis_for_actions(
692        &self,
693    ) -> Result<(StabilizerGenerators, AnalyzedBranchProjections), BlockGraphError> {
694        self.stabilizer_analysis_for_actions_impl(crate::ModuleCertificationLimits::DEFAULT)
695    }
696
697    fn stabilizer_analysis_for_actions_impl(
698        &self,
699        limits: crate::ModuleCertificationLimits,
700    ) -> Result<(StabilizerGenerators, AnalyzedBranchProjections), BlockGraphError> {
701        self.validate_resource_limits(limits)?;
702        if self.has_continuing_branches() {
703            return Err(InvalidActionError::GuardedAnalysisRequired.into());
704        }
705        let canonical = self
706            .branches
707            .iter()
708            .any(|region| !region.shown_true())
709            .then(|| self.canonical_true_branch_view())
710            .transpose()?;
711        let graph = canonical.as_ref().unwrap_or(self);
712
713        // Keep Branch actions on the all-true static topology so measurement
714        // construction can avoid self-reader cycles before the final DAG check.
715        let zx = graph.stabilizer_zx_graph(limits)?;
716        let baseline = zx.stabilizers_with_limits(limits)?;
717        if !graph
718            .action_graph
719            .ordered_nodes()
720            .any(|node| matches!(node.action, Action::Branch { .. }))
721        {
722            return Ok((baseline, Vec::new()));
723        }
724        baseline.validate_selective_decoupling()?;
725        let regions = graph.branch_regions()?;
726        for (name, row) in named_measurement_rows(&baseline) {
727            if regions
728                .iter()
729                .any(|region| positioned_stabilizer_touches_region(row, region))
730            {
731                return Err(InvalidActionError::BranchDependentMeasurementSurface {
732                    name: name.to_owned(),
733                }
734                .into());
735            }
736        }
737
738        let mut projections = Vec::new();
739        let measurements = baseline
740            .generators
741            .iter()
742            .filter(|generator| generator.is_measurement())
743            .collect::<Vec<_>>();
744        let static_projections = graph.branch_projections_for_analysis_up_to_with_limits(
745            limits.max_guarded_domain_size.saturating_add(1),
746            limits.boolean_limits(),
747        )?;
748        if static_projections.len() > limits.max_guarded_domain_size {
749            return Err(crate::StabilizerError::ResourceLimited {
750                phase: "action-analysis branch projections",
751                observed: static_projections.len(),
752                limit: limits.max_guarded_domain_size,
753            }
754            .into());
755        }
756        for projection in static_projections {
757            let zx = projection.graph().stabilizer_zx_graph(limits)?;
758            let projected = if let Some(reused) = zx
759                .stabilizers_reusing_measurements(&measurements, limits)?
760                .filter(|candidate| {
761                    candidate.validate_selective_decoupling().is_ok()
762                        && validate_shared_measurement_rows(&baseline, candidate).is_ok()
763                }) {
764                reused
765            } else {
766                let independent = zx.stabilizers_with_limits(limits)?;
767                independent.validate_selective_decoupling()?;
768                validate_shared_measurement_rows(&baseline, &independent)?;
769                independent
770            };
771            let projection = projection.with_analyzed_actions(&projected)?;
772            projections.push((projection, projected));
773        }
774
775        Ok((baseline, projections))
776    }
777
778    /// Rebuilds the action DAG with stabilizer-derived dependencies and surfaces.
779    ///
780    /// `stabilizers` must come from this graph. The returned graph stores only
781    /// derived metadata; BLOG serialization remains unchanged.
782    ///
783    /// # Errors
784    ///
785    /// Returns an error if action validation or readout dependency attachment fails.
786    pub fn with_analyzed_action_graph(
787        mut self,
788        stabilizers: &StabilizerGenerators,
789    ) -> Result<Self, BlockGraphError> {
790        let actions = self.actions();
791        let mut dag = self.build_action_graph(&actions)?;
792        stabilizers.validate_selective_decoupling()?;
793        dag.attach_readout_dependencies(&stabilizers.generators, Some(&stabilizers.zx_graph))?;
794        self.install_action_graph(dag, None);
795        Ok(self)
796    }
797
798    /// Computes stabilizers once and installs their derived action analysis.
799    ///
800    /// For a graph with structural branches, the returned table is only the
801    /// canonical all-true, common-prefix analysis table used by the compiler;
802    /// it is not the stabilizer family of the conditional graph. Use
803    /// [`BlockGraph::branch_projections`] and derive each projection's
804    /// stabilizers for semantic inspection.
805    ///
806    /// # Errors
807    ///
808    /// Returns an error if stabilizer derivation or action analysis fails.
809    pub fn analyze_actions(self) -> Result<(Self, StabilizerGenerators), BlockGraphError> {
810        self.analyze_actions_with_limits(crate::ModuleCertificationLimits::DEFAULT)
811    }
812
813    /// Analyze source actions without replacing explicit resource limits with defaults.
814    ///
815    /// # Errors
816    ///
817    /// Returns an error if resource-limited stabilizer derivation or action analysis fails.
818    pub fn analyze_actions_with_limits(
819        self,
820        limits: crate::ModuleCertificationLimits,
821    ) -> Result<(Self, StabilizerGenerators), BlockGraphError> {
822        self.require_flat_hierarchy("action analysis")?;
823        let (stabilizers, _) = self.stabilizer_analysis_for_actions_impl(limits)?;
824        let graph = self.with_analyzed_action_graph(&stabilizers)?;
825        Ok((graph, stabilizers))
826    }
827
828    #[doc(hidden)]
829    pub fn analyze_actions_with_projections(
830        self,
831    ) -> Result<(Self, StabilizerGenerators, AnalyzedBranchProjections), BlockGraphError> {
832        self.require_flat_hierarchy("action analysis")?;
833        let (stabilizers, projections) = self.stabilizer_analysis_for_actions()?;
834        let graph = self.with_analyzed_action_graph(&stabilizers)?;
835        Ok((graph, stabilizers, projections))
836    }
837
838    /// Certifies the complete family without constructing static joint choices.
839    pub(crate) fn validate_guarded_actions_with_limits(
840        &self,
841        limits: crate::ModuleCertificationLimits,
842    ) -> Result<(), BlockGraphError> {
843        let topology = crate::GuardedTopology::new(self, limits)?;
844        crate::GuardedSurfaceSpace::new(topology, &Default::default(), limits)?
845            .plan_readouts()
846            .map(drop)
847    }
848
849    /// Attaches stabilizer-derived dependencies to the already-installed DAG.
850    fn analyze_installed_actions(
851        &mut self,
852        limits: crate::ModuleCertificationLimits,
853    ) -> Result<(), BlockGraphError> {
854        if self.has_continuing_branches() {
855            return self.validate_guarded_actions_with_limits(limits);
856        }
857        let (stabilizers, _) = self.stabilizer_analysis_for_actions_impl(limits)?;
858        stabilizers.validate_selective_decoupling()?;
859        self.action_graph
860            .attach_readout_dependencies(&stabilizers.generators, Some(&stabilizers.zx_graph))
861    }
862
863    /// Fills port blocks using compatible external stabilizer support and
864    /// returns each closed graph with the generators that selected its fill.
865    /// Static variants retain selected measurement records and their aliases,
866    /// removing feedback and resolves for filled caps. Returns an error if
867    /// postselection or a structural branch needs an unselected record.
868    ///
869    /// # Errors
870    ///
871    /// Returns an error if automatic filling or postselection validation fails.
872    pub fn fill_ports_auto(
873        &self,
874    ) -> Result<Vec<(BlockGraph, Vec<StabilizerGenerator>)>, BlockGraphError> {
875        self.fill_ports_auto_with_limits(crate::ModuleCertificationLimits::DEFAULT)
876    }
877
878    /// Fills ports with explicit limits for source and filled-graph analysis.
879    ///
880    /// # Errors
881    ///
882    /// Returns the same errors as [`fill_ports_auto`](Self::fill_ports_auto),
883    /// including exhaustion of a caller's certification budget.
884    pub fn fill_ports_auto_with_limits(
885        &self,
886        limits: crate::ModuleCertificationLimits,
887    ) -> Result<Vec<(BlockGraph, Vec<StabilizerGenerator>)>, BlockGraphError> {
888        self.require_flat_source("boundary filling")?;
889        fill_ports_auto_impl(self, limits)
890    }
891
892    /// The closed graphs [`fill_ports_auto`](Self::fill_ports_auto) produces,
893    /// without the generators that selected each fill.
894    ///
895    /// # Errors
896    ///
897    /// Returns the same automatic-filling errors as [`Self::fill_ports_auto`].
898    pub fn filled_graphs(&self) -> Result<Vec<BlockGraph>, BlockGraphError> {
899        self.filled_graphs_with_limits(crate::ModuleCertificationLimits::DEFAULT)
900    }
901
902    /// The graphs from [`fill_ports_auto_with_limits`](Self::fill_ports_auto_with_limits),
903    /// without their selecting stabilizer generators.
904    ///
905    /// # Errors
906    ///
907    /// Returns the same errors as [`Self::fill_ports_auto_with_limits`].
908    pub fn filled_graphs_with_limits(
909        &self,
910        limits: crate::ModuleCertificationLimits,
911    ) -> Result<Vec<BlockGraph>, BlockGraphError> {
912        Ok(self
913            .fill_ports_auto_with_limits(limits)?
914            .into_iter()
915            .map(|(graph, _)| graph)
916            .collect())
917    }
918
919    /// Returns the inclusive coordinate span of the graph on each axis,
920    /// including both stored arms of every branch, or `None` when empty.
921    #[must_use]
922    pub fn spans(
923        &self,
924    ) -> Option<(
925        RangeInclusive<i32>,
926        RangeInclusive<i32>,
927        RangeInclusive<i32>,
928    )> {
929        let boundary_positions = |block: &Block| {
930            // A tall cube's extrema are its two endpoint cells. Interior cells
931            // cannot enlarge the box and need not be materialized or visited.
932            let offsets = if block.kind().is_cube() {
933                block.connectable_offsets()
934            } else {
935                block.reserved_offsets()
936            };
937            let position = block.pos();
938            offsets.into_iter().map(move |offset| position + offset)
939        };
940        let visible = self.blocks().flat_map(boundary_positions);
941        let branch_arms = self
942            .branches
943            .iter()
944            .flat_map(|region| region.on_false().blocks().chain(region.on_true().blocks()))
945            .flat_map(boundary_positions);
946        let mut positions = visible.chain(branch_arms);
947        let first_pos = positions.next()?;
948
949        let (mut min_x, mut max_x) = (first_pos.x, first_pos.x);
950        let (mut min_y, mut max_y) = (first_pos.y, first_pos.y);
951        let (mut min_z, mut max_z) = (first_pos.z, first_pos.z);
952
953        for pos in positions {
954            min_x = min_x.min(pos.x);
955            max_x = max_x.max(pos.x);
956            min_y = min_y.min(pos.y);
957            max_y = max_y.max(pos.y);
958            min_z = min_z.min(pos.z);
959            max_z = max_z.max(pos.z);
960        }
961
962        Some((min_x..=max_x, min_y..=max_y, min_z..=max_z))
963    }
964
965    /// Resolve all selective blocks from one random assignment of their action
966    /// measurement variables. Resolve expressions that share a variable are
967    /// therefore sampled jointly; sites without actions retain independent
968    /// random choices. Returns the resolved graph and a map from original to
969    /// resolved blocks. Resolution is deterministic in `seed`.
970    /// Authored module sources must be explicitly flattened first.
971    ///
972    /// If at least one selective is resolved, the result is structure-only and
973    /// all actions are removed, including structural branches. Project branches
974    /// first when a concrete conditional topology is required.
975    ///
976    /// # Panics
977    ///
978    /// Panics if internally cached resolve metadata names a non-selective block.
979    ///
980    /// # Errors
981    ///
982    /// Returns a typed hierarchy error unless child instances are explicitly flattened.
983    pub fn randomly_resolve_selectives(
984        &self,
985        seed: u64,
986    ) -> Result<(BlockGraph, HashMap<Block, Block>), BlockGraphError> {
987        self.require_flat_source("selective resolution")?;
988        let mut rng = StdRng::seed_from_u64(seed);
989        let selective_blocks: Vec<Block> = self
990            .blocks()
991            .filter(|block| block.kind.is_selective())
992            .cloned()
993            .collect();
994        let mut resolved_graph = self.clone();
995        let mut replacements = HashMap::with_capacity(selective_blocks.len());
996        let has_selectives = !selective_blocks.is_empty();
997        let targets = selective_blocks
998            .iter()
999            .map(|block| block.pos)
1000            .collect::<Vec<_>>();
1001        let sampled_resolves = self.action_graph.sample_resolve_values(&targets, &mut rng);
1002
1003        for original in selective_blocks {
1004            let resolved_kind = sampled_resolves.get(&original.pos).map_or_else(
1005                || self.randomly_resolved_selective_kind(&original, &mut rng),
1006                |&value| {
1007                    let BlockKind::Selective(kind) = original.kind else {
1008                        unreachable!("selective_blocks contains only selectives")
1009                    };
1010                    let chosen = if value {
1011                        kind.pauli_if_true()
1012                    } else {
1013                        kind.pauli_if_false()
1014                    };
1015                    self.resolved_selective_kind_for_basis(chosen)
1016                },
1017            );
1018            let resolved_block = {
1019                let block = resolved_graph
1020                    .get_block_mut(original.pos)
1021                    .expect("resolved graph cloned from source must still contain selective block");
1022                // Selective and every resolved measurement kind reserve the same
1023                // single cell, so the occupancy index stays correct.
1024                block.kind = resolved_kind;
1025                block.clone()
1026            };
1027            replacements.insert(original, resolved_block);
1028        }
1029
1030        // Resolve actions and selective-measurement metadata no longer match a
1031        // statically filled graph, so return structure only.
1032        if has_selectives {
1033            resolved_graph.clear_actions();
1034        }
1035
1036        Ok((resolved_graph, replacements))
1037    }
1038
1039    /// Appends an action, revalidating and re-analyzing the whole action set
1040    /// against the graph. See [`set_actions`](Self::set_actions).
1041    ///
1042    /// # Errors
1043    ///
1044    /// Returns a [`BlockGraphError`] if the resulting action set fails
1045    /// validation or physical analysis.
1046    pub fn add_action(&mut self, action: Action) -> Result<(), BlockGraphError> {
1047        let mut ordered = self
1048            .action_graph
1049            .ordered_nodes()
1050            .map(|node| node.action.clone())
1051            .collect::<Vec<_>>();
1052        ordered.push(action);
1053        self.set_actions(ordered)
1054    }
1055
1056    pub(crate) fn resolve_measurement_observable(
1057        &self,
1058        target: &MeasureTarget,
1059    ) -> Result<MeasurementObservable, BlockGraphError> {
1060        match target {
1061            MeasureTarget::Node(pos) => match self
1062                .get_block(*pos)
1063                .ok_or(BlockGraphError::BlockNotFound(*pos))?
1064                .kind
1065            {
1066                BlockKind::Y => Ok(MeasurementObservable::Concrete(PauliBasis::Y)),
1067                BlockKind::Measurement(basis) => Ok(MeasurementObservable::Concrete(basis.into())),
1068                BlockKind::Selective(kind) => Ok(MeasurementObservable::Selective(kind)),
1069                BlockKind::Cube(kind) => Ok(MeasurementObservable::Concrete(kind.z().into())),
1070                BlockKind::Walking(kind) => {
1071                    Ok(MeasurementObservable::Concrete(kind.boundary().z().into()))
1072                }
1073                BlockKind::PatchRotation(kind) => {
1074                    Ok(MeasurementObservable::Concrete(kind.basis().into()))
1075                }
1076                BlockKind::Port | BlockKind::T => Err(BlockGraphError::InvalidAction(
1077                    InvalidActionError::InvalidMeasurementNode(*pos),
1078                )),
1079            },
1080            MeasureTarget::Edge { src, dir } => {
1081                let dst = crate::checked_add_position(*src, dir.to_ivec3())?;
1082                let pipe = self
1083                    .get_pipe(*src, dst)
1084                    .ok_or(BlockGraphError::PipeNotFound(*src, dst))?;
1085                let bases = self.infer_pipe_basis(pipe);
1086                // Must stay an `Err`, not a panic: the best-effort DAG rebuild
1087                // after mutation calls this on possibly-invalid graphs and
1088                // swallows resolution failures by design.
1089                let basis =
1090                    bases[2].ok_or(BlockGraphError::MeasurementEdgeMissingTimeBasis(*src, dst))?;
1091                Ok(MeasurementObservable::Concrete(basis.flip().into()))
1092            }
1093        }
1094    }
1095
1096    pub(crate) fn build_action_graph(
1097        &self,
1098        actions: &[Action],
1099    ) -> Result<ActionDag, BlockGraphError> {
1100        if actions.is_empty() {
1101            return Ok(self.new_action_graph(actions));
1102        }
1103        let mut dag = self.new_action_graph(actions);
1104        dag.validate(Some(self))?;
1105        self.attach_measurement_observables(&mut dag, actions.len(), true)?;
1106        Ok(dag)
1107    }
1108
1109    fn new_action_graph(&self, actions: &[Action]) -> ActionDag {
1110        ActionDag::from_actions_with_inputs(actions, self.action_inputs.iter().cloned())
1111    }
1112
1113    /// Attaches the resolved measurement observable to every `Measure` node.
1114    ///
1115    /// When `strict`, the first resolution failure aborts and is returned; this
1116    /// is what [`build_action_graph`](Self::build_action_graph) wants. When not
1117    /// strict, unresolvable nodes are simply left without metadata and the rest
1118    /// are still populated — used by the post-mutation refresh so consumers do
1119    /// not see spurious `None` for nodes that *can* resolve.
1120    fn attach_measurement_observables(
1121        &self,
1122        dag: &mut ActionDag,
1123        len: usize,
1124        strict: bool,
1125    ) -> Result<(), BlockGraphError> {
1126        for ordinal in 0..len {
1127            let target = match dag.node_by_ordinal(ordinal).map(|node| &node.action) {
1128                Some(Action::Measure { target, .. }) => *target,
1129                _ => continue,
1130            };
1131            match self.resolve_measurement_observable(&target) {
1132                Ok(observable) => dag
1133                    .set_measurement_observable(ordinal, observable)
1134                    .expect("ordinals 0..len index the DAG built from these same actions"),
1135                Err(err) if strict => return Err(err),
1136                Err(_) => {}
1137            }
1138        }
1139        Ok(())
1140    }
1141
1142    /// Single point of installation for the cached DAG, keeping
1143    /// `action_graph_error` in lockstep with `action_graph`.
1144    fn install_action_graph(&mut self, dag: ActionDag, error: Option<BlockGraphError>) {
1145        self.action_graph = dag;
1146        self.action_graph_error = error;
1147    }
1148
1149    /// Builds an unvalidated DAG, attaching whatever measurement metadata
1150    /// still resolves. Used when a strict [`build_action_graph`](Self::build_action_graph)
1151    /// has failed but the actions must be kept.
1152    fn build_action_graph_best_effort(&self, actions: &[Action]) -> ActionDag {
1153        let mut dag = self.new_action_graph(actions);
1154        let _ = self.attach_measurement_observables(&mut dag, actions.len(), false);
1155        dag
1156    }
1157
1158    /// Rebuilds and installs the DAG from `actions`; on failure installs a
1159    /// best-effort DAG and records the error in `action_graph_error`.
1160    pub(crate) fn rebuild_action_graph_lenient(&mut self, actions: Vec<Action>) {
1161        match self.build_action_graph(&actions) {
1162            Ok(dag) => self.install_action_graph(dag, None),
1163            Err(err) => {
1164                let dag = self.build_action_graph_best_effort(&actions);
1165                self.install_action_graph(dag, Some(err));
1166            }
1167        }
1168    }
1169
1170    #[cfg(test)]
1171    pub(crate) fn set_actions_unchecked(&mut self, actions: Vec<Action>) {
1172        let dag = self.new_action_graph(&actions);
1173        self.install_action_graph(dag, None);
1174    }
1175
1176    /// Replaces all actions, validating the new set against the graph and, once
1177    /// the topology is structurally complete, deriving each measurement's
1178    /// stabilizer surface and its implicit dependency edges.
1179    ///
1180    /// # Errors
1181    ///
1182    /// Returns a [`BlockGraphError`] if the new action set fails semantic or
1183    /// graph-constraint validation, a measurement surface is unavailable, or
1184    /// the derived edges close a dependency cycle; the previous actions are
1185    /// left in place.
1186    pub fn set_actions(&mut self, actions: Vec<Action>) -> Result<(), BlockGraphError> {
1187        self.set_actions_with_limits(actions, crate::ModuleCertificationLimits::DEFAULT)
1188    }
1189
1190    pub(crate) fn set_actions_with_limits(
1191        &mut self,
1192        actions: Vec<Action>,
1193        limits: crate::ModuleCertificationLimits,
1194    ) -> Result<(), BlockGraphError> {
1195        let dag = self.build_action_graph(&actions)?;
1196        if actions.is_empty() || crate::validate::validate_structure(self).is_err() {
1197            // Construction may attach actions before topology is complete.
1198            // Full validation/an editor refresh analyzes them once structure holds.
1199            self.install_action_graph(dag, None);
1200            return Ok(());
1201        }
1202
1203        // Physical analysis needs the candidate installed, so swap it in and
1204        // roll back if it turns out to be unanalyzable.
1205        let previous = std::mem::replace(&mut self.action_graph, dag);
1206        let previous_error = self.action_graph_error.take();
1207        if let Err(err) = self.analyze_installed_actions(limits) {
1208            self.install_action_graph(previous, previous_error);
1209            return Err(err);
1210        }
1211        Ok(())
1212    }
1213
1214    /// Replaces actions while declaring external Boolean inputs.
1215    ///
1216    /// Module bodies use these names for values bound by their parent. A
1217    /// standalone graph compiler has no producer for them.
1218    ///
1219    /// # Errors
1220    ///
1221    /// Returns the same errors as [`set_actions`](Self::set_actions), plus
1222    /// [`InvalidActionError::InvalidActionName`] for an invalid input name.
1223    pub fn set_actions_with_inputs(
1224        &mut self,
1225        actions: Vec<Action>,
1226        inputs: impl IntoIterator<Item = String>,
1227    ) -> Result<(), BlockGraphError> {
1228        self.set_actions_with_inputs_and_limits(
1229            actions,
1230            inputs,
1231            crate::ModuleCertificationLimits::DEFAULT,
1232        )
1233    }
1234
1235    pub(crate) fn set_actions_with_inputs_and_limits(
1236        &mut self,
1237        actions: Vec<Action>,
1238        inputs: impl IntoIterator<Item = String>,
1239        limits: crate::ModuleCertificationLimits,
1240    ) -> Result<(), BlockGraphError> {
1241        self.set_actions_with_inputs_impl(actions, inputs, false, limits)
1242    }
1243
1244    pub(crate) fn set_actions_deferred_with_inputs(
1245        &mut self,
1246        actions: Vec<Action>,
1247        inputs: impl IntoIterator<Item = String>,
1248    ) -> Result<(), BlockGraphError> {
1249        self.set_actions_with_inputs_impl(
1250            actions,
1251            inputs,
1252            true,
1253            crate::ModuleCertificationLimits::DEFAULT,
1254        )
1255    }
1256
1257    fn set_actions_with_inputs_impl(
1258        &mut self,
1259        actions: Vec<Action>,
1260        inputs: impl IntoIterator<Item = String>,
1261        deferred: bool,
1262        limits: crate::ModuleCertificationLimits,
1263    ) -> Result<(), BlockGraphError> {
1264        let inputs = inputs.into_iter().collect::<BTreeSet<_>>();
1265        if let Some(name) = inputs
1266            .iter()
1267            .find(|name| !crate::parser::is_valid_identifier(name))
1268        {
1269            return Err(InvalidActionError::InvalidActionName(name.clone()).into());
1270        }
1271        let previous = std::mem::replace(&mut self.action_inputs, inputs);
1272        let result = if deferred {
1273            self.set_actions_deferred(actions)
1274        } else {
1275            self.set_actions_with_limits(actions, limits)
1276        };
1277        if let Err(err) = result {
1278            self.action_inputs = previous;
1279            return Err(err);
1280        }
1281        Ok(())
1282    }
1283
1284    /// Replaces actions after source validation, deferring stabilizer surfaces
1285    /// and implicit dependencies to [`analyze_actions`](Self::analyze_actions).
1286    ///
1287    /// # Errors
1288    ///
1289    /// Returns a [`BlockGraphError`] for invalid source semantics without
1290    /// changing the previous actions.
1291    pub fn set_actions_deferred(&mut self, actions: Vec<Action>) -> Result<(), BlockGraphError> {
1292        let dag = self.build_action_graph(&actions)?;
1293        self.install_action_graph(dag, None);
1294        Ok(())
1295    }
1296
1297    /// Replaces all actions without failing on a graph-constraint violation,
1298    /// recording it in [`action_graph_error`](Self::action_graph_error) instead.
1299    ///
1300    /// This supports incremental editing, where an action list can be temporarily
1301    /// incomplete. Names and nonempty feedback targets remain strict so actions
1302    /// can survive a BLOG round-trip.
1303    ///
1304    /// Callers that must not install a broken action set use
1305    /// [`set_actions`](Self::set_actions).
1306    ///
1307    /// # Errors
1308    ///
1309    /// Returns [`BlockGraphError`] if a `Measure`/`Let` name is invalid or feedback
1310    /// has no targets; the previous actions are left in place.
1311    pub fn set_actions_lenient(&mut self, actions: Vec<Action>) -> Result<(), BlockGraphError> {
1312        for action in &actions {
1313            Self::validate_action_syntax(action)?;
1314        }
1315        self.rebuild_action_graph_lenient(actions);
1316        Ok(())
1317    }
1318
1319    /// Rejects action syntax that cannot survive a BLOG round-trip.
1320    ///
1321    /// `Measure`/`Let` names are emitted verbatim by the writer, so a name
1322    /// outside the BLOG identifier grammar (or a reserved keyword) would fail to
1323    /// reparse — the same round-trip hazard guarded for tags. Variable
1324    /// *references* need no check here: an undefined one is caught by the DAG
1325    /// build, and a defined one is validated at its `Measure`/`Let` site.
1326    pub(crate) fn validate_action_syntax(action: &Action) -> Result<(), BlockGraphError> {
1327        if matches!(action, Action::Feedback { targets, .. } if targets.is_empty()) {
1328            return Err(InvalidActionError::EmptyFeedbackTargets.into());
1329        }
1330        let name = match action {
1331            Action::Measure { name, .. } | Action::Let { name, .. } => name,
1332            _ => return Ok(()),
1333        };
1334        if crate::parser::is_valid_identifier(name) {
1335            Ok(())
1336        } else {
1337            Err(InvalidActionError::InvalidActionName(name.clone()).into())
1338        }
1339    }
1340
1341    /// Removes all actions.
1342    pub fn clear_actions(&mut self) {
1343        self.action_inputs.clear();
1344        self.install_action_graph(ActionDag::default(), None);
1345    }
1346
1347    fn refresh_action_graph_after_mutation(&mut self) {
1348        // A mutation can leave the actions inconsistent with the new topology.
1349        // These `()`-returning mutators cannot surface that error, so the
1350        // lenient rebuild keeps the actions and records the failure in
1351        // `action_graph_error` for callers to inspect.
1352        let actions = self.actions();
1353        self.rebuild_action_graph_lenient(actions);
1354    }
1355
1356    /// Returns a new [`BlockGraph`] shifted by the given offset.
1357    ///
1358    /// # Errors
1359    ///
1360    /// Returns [`BlockGraphError::CoordinateOverflow`] if any shifted block
1361    /// footprint, pipe endpoint, or action position exceeds the coordinate
1362    /// range.
1363    pub fn shift_positions(&self, offset: IVec3) -> Result<Self, BlockGraphError> {
1364        if offset == IVec3::ZERO {
1365            return Ok(self.clone());
1366        }
1367        self.require_flat_source("position shifting")?;
1368        let mut g = Self::new();
1369        g.action_inputs = self.action_inputs.clone();
1370        for block in self.blocks() {
1371            g.try_add_block(block.try_with_shift(offset)?)?;
1372        }
1373        for pipe in self.pipes() {
1374            g.try_add_pipe(pipe.try_with_shift(offset)?)?;
1375        }
1376        g.branches =
1377            self.transformed_branches(|position| crate::checked_add_position(position, offset))?;
1378        let shifted_actions = self
1379            .action_graph
1380            .ordered_nodes()
1381            .map(|node| node.action.try_with_shift(offset))
1382            .collect::<Result<Vec<_>, _>>()?;
1383        // A degraded source graph (see `action_graph_error`) shifts to an
1384        // equally degraded result instead of panicking.
1385        g.rebuild_action_graph_lenient(shifted_actions);
1386        Ok(g)
1387    }
1388
1389    /// Returns a new [`BlockGraph`] rotated around the origin along the given axis.
1390    ///
1391    /// Rotation preserves blocks, pipes, branches, and actions. Rotations that
1392    /// cannot represent a block's time-oriented geometry return a typed error.
1393    ///
1394    /// # Errors
1395    ///
1396    /// Returns an unsupported-orientation, coordinate, or validation error.
1397    pub fn rotate_about_origin(
1398        &self,
1399        axis: UDirection,
1400        quarter_turns: i32,
1401    ) -> Result<Self, BlockGraphError> {
1402        let rotated = self.rotate_about_origin_lenient(axis, quarter_turns)?;
1403        rotated.validate()?;
1404        Ok(rotated)
1405    }
1406
1407    /// Rotates the graph without validating the result.
1408    ///
1409    /// # Errors
1410    ///
1411    /// Returns an unsupported-orientation or coordinate-overflow error.
1412    pub fn rotate_about_origin_lenient(
1413        &self,
1414        axis: UDirection,
1415        quarter_turns: i32,
1416    ) -> Result<Self, BlockGraphError> {
1417        self.with_orientation_lenient(crate::ModuleRotation::new(axis, quarter_turns).orientation())
1418    }
1419
1420    /// Applies a composed cubic orientation without validating action semantics.
1421    #[doc(hidden)]
1422    pub fn with_orientation_lenient(
1423        &self,
1424        orientation: crate::ModuleOrientation,
1425    ) -> Result<Self, BlockGraphError> {
1426        if orientation != crate::ModuleOrientation::IDENTITY {
1427            self.require_flat_source("rotation")?;
1428        }
1429        validate_block_orientation(
1430            self.blocks().chain(
1431                self.branches
1432                    .iter()
1433                    .flat_map(|branch| branch.on_false().blocks().chain(branch.on_true().blocks())),
1434            ),
1435            orientation,
1436        )?;
1437        if orientation == crate::ModuleOrientation::IDENTITY {
1438            return Ok(self.clone());
1439        }
1440        let mut rotated = Self::new();
1441        rotated.action_inputs = self.action_inputs.clone();
1442
1443        for block in self.blocks() {
1444            rotated.try_add_block(oriented_block(block, orientation)?)?;
1445        }
1446
1447        for pipe in self.pipes() {
1448            rotated.try_add_pipe(oriented_pipe(pipe, orientation)?)?;
1449        }
1450
1451        rotated.branches = self
1452            .branches
1453            .iter()
1454            .map(|branch| branch.try_with_orientation(orientation))
1455            .collect::<Result<_, _>>()?;
1456        let actions = self
1457            .action_graph
1458            .ordered_nodes()
1459            .map(|node| node.action.try_with_orientation(orientation))
1460            .collect::<Result<Vec<_>, _>>()?;
1461        rotated.rebuild_action_graph_lenient(actions);
1462        Ok(rotated)
1463    }
1464
1465    /// Returns a new [`BlockGraph`] with X/Z bases flipped.
1466    ///
1467    /// Swaps block and feedback bases and negates selective resolve conditions
1468    /// to preserve their ordered choices in the canonical XY/XZ/YZ representation.
1469    /// Fixed T/Y resources retain their kind; each incident pipe toggles its
1470    /// Hadamard. A selective's Y arm also needs its outcome sign flipped because
1471    /// H conjugation sends Y to -Y; Pauli feedback along the other arm's axis
1472    /// supplies that sign without changing its X/Z outcome. Positions, tags,
1473    /// and structural branch conditions are retained.
1474    ///
1475    /// # Errors
1476    ///
1477    /// Returns a flatten-required error for an authored module source.
1478    pub fn flip_xz_basis_lenient(&self) -> Result<Self, BlockGraphError> {
1479        self.require_flat_source("basis flipping")?;
1480        let mut flipped = self.clone();
1481        for block in flipped.inner.node_weights_mut() {
1482            block.kind = block.kind.flip_xz_basis();
1483        }
1484        for pipe in flipped.inner.edge_weights_mut() {
1485            flip_pipe_xz_basis(pipe, |pos| {
1486                self.get_block(pos)
1487                    .is_some_and(|block| matches!(block.kind(), BlockKind::T | BlockKind::Y))
1488            });
1489        }
1490        flipped.branches = self
1491            .branches
1492            .iter()
1493            .map(|branch| branch.flip_xz_basis(self))
1494            .collect();
1495        let mut actions = self.actions();
1496        for action in &mut actions {
1497            if let Action::Resolve { condition, .. } = action {
1498                *condition = condition.clone().negated();
1499            }
1500            if let Action::Feedback { targets, .. } = action {
1501                for target in targets {
1502                    target.pauli = match target.pauli {
1503                        PauliBasis::X => PauliBasis::Z,
1504                        PauliBasis::Y => PauliBasis::Y,
1505                        PauliBasis::Z => PauliBasis::X,
1506                    };
1507                }
1508            }
1509        }
1510        let mut y_signs = flipped
1511            .blocks()
1512            .filter_map(|block| {
1513                let pauli = match block.kind() {
1514                    BlockKind::Selective(crate::SelectiveKind::XY) => PauliBasis::X,
1515                    BlockKind::Selective(crate::SelectiveKind::YZ) => PauliBasis::Z,
1516                    _ => return None,
1517                };
1518                Some(crate::FeedbackTarget {
1519                    pauli,
1520                    target: block.pos(),
1521                    direction: None,
1522                })
1523            })
1524            .collect::<Vec<_>>();
1525        y_signs.sort_unstable_by_key(|target| target.target.to_array());
1526        if !y_signs.is_empty() {
1527            let correction = Action::Feedback {
1528                targets: y_signs,
1529                condition: None,
1530            };
1531            // Adjacent identical Pauli operations cancel. In particular, a
1532            // second basis flip cancels the first flip's Y-sign correction.
1533            if actions.last() == Some(&correction) {
1534                actions.pop();
1535            } else {
1536                actions.push(correction);
1537            }
1538        }
1539        flipped.rebuild_action_graph_lenient(actions);
1540        Ok(flipped)
1541    }
1542
1543    /// Returns a validated copy with X/Z bases flipped, including the Hadamard
1544    /// frame on every fixed T/Y resource's incident pipe and selective Y signs.
1545    ///
1546    /// # Errors
1547    ///
1548    /// Returns an error if the flipped graph or its actions are invalid.
1549    pub fn flip_xz_basis(&self) -> Result<Self, BlockGraphError> {
1550        fn is_missing_resolve(err: &BlockGraphError) -> bool {
1551            matches!(
1552                err,
1553                BlockGraphError::InvalidAction(
1554                    InvalidActionError::MissingResolveForSelective { .. }
1555                )
1556            )
1557        }
1558
1559        let mut flipped = self.flip_xz_basis_lenient()?;
1560        let actions = flipped.actions();
1561        match flipped
1562            .set_actions(actions)
1563            .and_then(|()| flipped.validate())
1564        {
1565            Ok(()) => Ok(flipped),
1566            Err(err) if is_missing_resolve(&err) => {
1567                flipped.clear_actions();
1568                flipped.validate()?;
1569                Ok(flipped)
1570            }
1571            Err(err) => Err(err),
1572        }
1573    }
1574
1575    /// Returns a new [`BlockGraph`] with all blocks shifted in the z-axis
1576    /// so that the minimum z-coordinate is zero.
1577    ///
1578    /// # Errors
1579    ///
1580    /// Returns [`BlockGraphError::CoordinateNormalizationOverflow`] if the
1581    /// normalized z span cannot be represented by `i32` coordinates.
1582    pub fn with_zero_min_z(&self) -> Result<Self, BlockGraphError> {
1583        self.require_flat_source("time normalization")?;
1584        let Some((_, _, z_span)) = self.spans() else {
1585            return Ok(self.clone());
1586        };
1587        let min_z = *z_span.start();
1588        let max_z = *z_span.end();
1589        if min_z == 0 {
1590            return Ok(self.clone());
1591        }
1592        if i64::from(max_z) - i64::from(min_z) > i64::from(i32::MAX) {
1593            return Err(BlockGraphError::CoordinateNormalizationOverflow { min_z, max_z });
1594        }
1595
1596        let normalize = |position| normalize_z_position(position, min_z, max_z);
1597        let mut normalized = Self::new();
1598        normalized.action_inputs = self.action_inputs.clone();
1599        for block in self.blocks() {
1600            let mut block = block.clone();
1601            block.pos = normalize(block.pos)?;
1602            normalized.try_add_block(block)?;
1603        }
1604        for pipe in self.pipes() {
1605            let mut pipe = pipe.clone();
1606            pipe.src = normalize(pipe.src)?;
1607            normalized.try_add_pipe(pipe)?;
1608        }
1609        normalized.branches = self.transformed_branches(normalize)?;
1610        let actions = self
1611            .action_graph
1612            .ordered_nodes()
1613            .map(|node| normalize_action_z(&node.action, min_z, max_z))
1614            .collect::<Result<Vec<_>, _>>()?;
1615        normalized.rebuild_action_graph_lenient(actions);
1616        Ok(normalized)
1617    }
1618
1619    fn all_reachable_definitions(&self, predicate: impl Fn(&Self) -> bool) -> bool {
1620        let mut pending = vec![self];
1621        let mut seen = HashSet::new();
1622        while let Some(graph) = pending.pop() {
1623            if !seen.insert(graph.name.as_str()) {
1624                continue;
1625            }
1626            if !predicate(graph) {
1627                return false;
1628            }
1629            for instance in &graph.instances {
1630                let Some(child) = self.module(&instance.definition) else {
1631                    return false;
1632                };
1633                pending.push(child);
1634            }
1635        }
1636        true
1637    }
1638
1639    fn local_blocks_with_branch_arms(&self) -> impl Iterator<Item = &Block> {
1640        self.blocks().chain(
1641            self.branches
1642                .iter()
1643                .flat_map(|branch| branch.on_false().blocks().chain(branch.on_true().blocks())),
1644        )
1645    }
1646
1647    /// Returns `true` if this graph and every reachable definition contain no blocks.
1648    /// Both authored branch arms count. An unresolved definition returns `false`.
1649    pub fn is_empty(&self) -> bool {
1650        self.all_reachable_definitions(|graph| {
1651            graph.local_blocks_with_branch_arms().next().is_none()
1652        })
1653    }
1654
1655    /// Returns the number of blocks in this definition's local geometry.
1656    pub fn block_count(&self) -> usize {
1657        self.inner.node_count()
1658    }
1659
1660    /// Returns the number of pipes in this definition's local geometry.
1661    pub fn pipe_count(&self) -> usize {
1662        self.inner.edge_count()
1663    }
1664
1665    fn count_kind(&self, pred: impl Fn(&BlockKind) -> bool) -> usize {
1666        self.inner.node_weights().filter(|b| pred(&b.kind)).count()
1667    }
1668
1669    /// Returns the number of walking blocks in this definition's local geometry.
1670    pub fn walking_count(&self) -> usize {
1671        self.count_kind(BlockKind::is_walking)
1672    }
1673
1674    /// Returns the number of patch rotation blocks in this definition's local geometry.
1675    pub fn patch_rotation_count(&self) -> usize {
1676        self.count_kind(BlockKind::is_patch_rotation)
1677    }
1678
1679    /// Returns the number of Port blocks in this definition's local geometry.
1680    pub fn port_count(&self) -> usize {
1681        self.count_kind(BlockKind::is_port)
1682    }
1683
1684    /// Returns the number of Y-basis blocks in this definition's local geometry.
1685    pub fn y_count(&self) -> usize {
1686        self.count_kind(BlockKind::is_y)
1687    }
1688
1689    /// Returns the number of T blocks in this definition's local geometry.
1690    pub fn t_count(&self) -> usize {
1691        self.count_kind(BlockKind::is_t)
1692    }
1693
1694    /// Returns the number of selective blocks in this definition's local geometry.
1695    pub fn selective_count(&self) -> usize {
1696        self.count_kind(BlockKind::is_selective)
1697    }
1698
1699    /// Returns this definition's local blocks, without expanding child instances.
1700    pub fn blocks(&self) -> impl Iterator<Item = &Block> {
1701        self.inner.node_weights()
1702    }
1703
1704    /// Returns this definition's local pipes, without expanding child instances.
1705    pub fn pipes(&self) -> impl Iterator<Item = &Pipe> {
1706        self.inner.edge_weights()
1707    }
1708
1709    /// Pipes incident to the block anchored at `position`, including pipes at
1710    /// an extended block's other endpoints. A missing anchor yields no pipes.
1711    /// Visits only the block's adjacency, without scanning the graph.
1712    pub fn pipes_at(&self, position: IVec3) -> impl Iterator<Item = &Pipe> {
1713        self.block_id
1714            .get(&position)
1715            .into_iter()
1716            .flat_map(|&id| self.inner.edges(NodeIndex::new(id as usize)))
1717            .map(|edge| edge.weight())
1718    }
1719
1720    /// Returns local block positions, without expanding child instances.
1721    pub fn positions(&self) -> impl Iterator<Item = IVec3> {
1722        self.inner.node_weights().map(|b| b.pos)
1723    }
1724
1725    /// Returns `true` if every reachable definition and branch arm has no T blocks.
1726    /// An unresolved definition returns `false`.
1727    pub fn is_clifford(&self) -> bool {
1728        self.all_reachable_definitions(|graph| {
1729            graph
1730                .local_blocks_with_branch_arms()
1731                .all(|block| !block.kind.is_t())
1732        })
1733    }
1734
1735    /// Returns `true` if every reachable definition and branch arm has a
1736    /// statically known block kind. An unresolved definition returns `false`.
1737    pub fn is_rigid(&self) -> bool {
1738        self.all_reachable_definitions(|graph| {
1739            graph
1740                .local_blocks_with_branch_arms()
1741                .all(|block| !block.kind.is_dynamic())
1742        })
1743    }
1744
1745    /// Returns `true` if the root has a public quantum boundary.
1746    /// Child Ports consumed by module seams do not make the root open.
1747    pub fn is_open(&self) -> bool {
1748        !self.interface.quantum_ports.is_empty()
1749            || self
1750                .local_blocks_with_branch_arms()
1751                .any(|block| block.kind.is_port())
1752    }
1753
1754    fn try_get_id(&self, pos: impl Into<IVec3>) -> Result<NodeIndex<u32>, BlockGraphError> {
1755        let pos = pos.into();
1756        self.block_id
1757            .get(&pos)
1758            .copied()
1759            .map(NodeIndex::from)
1760            .ok_or(BlockGraphError::BlockNotFound(pos))
1761    }
1762
1763    fn try_get_endpoint_id(
1764        &self,
1765        pos: impl Into<IVec3>,
1766    ) -> Result<NodeIndex<u32>, BlockGraphError> {
1767        let pos = pos.into();
1768        if let Some(id) = self.block_id.get(&pos).copied() {
1769            return Ok(NodeIndex::from(id));
1770        }
1771
1772        self.reserved_by_position
1773            .get(&pos)
1774            .into_iter()
1775            .flatten()
1776            .find_map(|anchor| {
1777                let id = self
1778                    .block_id
1779                    .get(anchor)
1780                    .copied()
1781                    .expect("reserved-position index references an existing block");
1782                let block = self
1783                    .inner
1784                    .node_weight(NodeIndex::from(id))
1785                    .expect("block_id references an existing block");
1786                block
1787                    .connectable_offsets()
1788                    .into_iter()
1789                    .filter(|offset| *offset != IVec3::ZERO)
1790                    .any(|offset| block.pos + offset == pos)
1791                    .then_some(id)
1792            })
1793            .map(NodeIndex::from)
1794            .ok_or(BlockGraphError::BlockNotFound(pos))
1795    }
1796
1797    /// Returns a reference to the block at the given position, if it exists.
1798    pub fn get_block(&self, position: impl Into<IVec3>) -> Option<&Block> {
1799        self.try_get_id(position)
1800            .ok()
1801            .and_then(|id| self.inner.node_weight(id))
1802    }
1803
1804    /// Returns the owning block for a connectable endpoint position.
1805    ///
1806    /// For ordinary blocks this is the block at `position`; for extended
1807    /// blocks this also resolves the virtual end position back to the block
1808    /// stored at its start position.
1809    pub fn get_endpoint_block(&self, position: impl Into<IVec3>) -> Option<&Block> {
1810        self.try_get_endpoint_id(position)
1811            .ok()
1812            .and_then(|id| self.inner.node_weight(id))
1813    }
1814
1815    /// Returns `true` if a block exists at the given position.
1816    pub fn has_block_at(&self, position: impl Into<IVec3>) -> bool {
1817        self.block_id.contains_key(&position.into())
1818    }
1819
1820    /// Returns `true` if a connectable block endpoint exists at the given position.
1821    pub fn has_endpoint_at(&self, position: impl Into<IVec3>) -> bool {
1822        self.try_get_endpoint_id(position).is_ok()
1823    }
1824
1825    /// Iterates over every lattice cell reserved by some block.
1826    pub fn occupied_positions(&self) -> impl Iterator<Item = IVec3> + '_ {
1827        self.blocks().flat_map(|block| {
1828            block
1829                .reserved_offsets()
1830                .into_iter()
1831                .map(move |offset| block.pos + offset)
1832        })
1833    }
1834
1835    /// Checks whether `block` can be placed without an illegal footprint overlap.
1836    ///
1837    /// # Errors
1838    ///
1839    /// Returns [`BlockGraphError::BlockPositionOccupied`] if a reserved cell
1840    /// collides with an existing block that does not permit the overlap.
1841    pub fn can_place_block(&self, block: &Block) -> Result<(), BlockGraphError> {
1842        self.can_place_block_except(block, None)
1843    }
1844
1845    fn can_place_block_except(
1846        &self,
1847        block: &Block,
1848        ignored_anchor: Option<IVec3>,
1849    ) -> Result<(), BlockGraphError> {
1850        let edited_region = ignored_anchor.and_then(|position| {
1851            self.branches
1852                .iter()
1853                .position(|region| region.shown_arm().contains_block(position))
1854        });
1855        for occupied in block.checked_reserved_positions()? {
1856            self.check_visible_occupancy(block, occupied, ignored_anchor)?;
1857            for (index, region) in self.branches.iter().enumerate() {
1858                if Some(index) == edited_region {
1859                    continue;
1860                }
1861                for existing in region.on_false().blocks().chain(region.on_true().blocks()) {
1862                    let overlaps = existing
1863                        .reserved_offsets()
1864                        .into_iter()
1865                        .any(|offset| existing.pos() + offset == occupied);
1866                    if overlaps
1867                        && (edited_region.is_some()
1868                            || !block.kind.allows_reserved_overlap(
1869                                block.pos,
1870                                existing.kind(),
1871                                existing.pos(),
1872                                occupied,
1873                            ))
1874                    {
1875                        return Err(BlockGraphError::BlockPositionOccupied(occupied));
1876                    }
1877                }
1878            }
1879        }
1880        Ok(())
1881    }
1882
1883    fn check_visible_occupancy(
1884        &self,
1885        block: &Block,
1886        occupied: IVec3,
1887        ignored_anchor: Option<IVec3>,
1888    ) -> Result<(), BlockGraphError> {
1889        for &anchor in self
1890            .reserved_by_position
1891            .get(&occupied)
1892            .into_iter()
1893            .flatten()
1894            .filter(|anchor| Some(**anchor) != ignored_anchor)
1895        {
1896            let existing = self
1897                .get_block(anchor)
1898                .expect("reserved-position index references an existing block");
1899            if !block
1900                .kind
1901                .allows_reserved_overlap(block.pos, existing.kind, existing.pos, occupied)
1902            {
1903                return Err(BlockGraphError::BlockPositionOccupied(occupied));
1904            }
1905        }
1906        Ok(())
1907    }
1908
1909    fn index_reserved_positions(&mut self, block: &Block) {
1910        for position in block
1911            .checked_reserved_positions()
1912            .expect("placed block footprint fits the coordinate range")
1913        {
1914            self.reserved_by_position
1915                .entry(position)
1916                .or_default()
1917                .push(block.pos);
1918        }
1919    }
1920
1921    fn unindex_reserved_positions(&mut self, block: &Block) {
1922        for position in block
1923            .checked_reserved_positions()
1924            .expect("placed block footprint fits the coordinate range")
1925        {
1926            let Some(anchors) = self.reserved_by_position.get_mut(&position) else {
1927                continue;
1928            };
1929            anchors.retain(|anchor| *anchor != block.pos);
1930            if anchors.is_empty() {
1931                self.reserved_by_position.remove(&position);
1932            }
1933        }
1934    }
1935
1936    /// Sets the cube height at `position`, re-checking the resulting footprint.
1937    ///
1938    /// Height propagation can grow a cube from one cell to several, so the new
1939    /// footprint has to clear the same occupancy check a fresh placement would.
1940    ///
1941    /// # Errors
1942    ///
1943    /// [`BlockGraphError::BlockNotFound`] if nothing is placed at `position`,
1944    /// [`BlockGraphError::Block`] if the block is not a cube or the footprint
1945    /// leaves the coordinate range, or
1946    /// [`BlockGraphError::BlockPositionOccupied`] if the grown footprint
1947    /// collides.
1948    ///
1949    /// # Panics
1950    ///
1951    /// Panics if graph indexes change after the initial existence check.
1952    pub fn set_cube_height(
1953        &mut self,
1954        position: IVec3,
1955        height: CubeHeight,
1956    ) -> Result<(), BlockGraphError> {
1957        let existing = self
1958            .get_block(position)
1959            .ok_or(BlockGraphError::BlockNotFound(position))?
1960            .clone();
1961        let candidate = existing.clone().with_height(height)?;
1962        self.can_place_block_except(&candidate, Some(position))?;
1963        self.check_preserved_endpoints(&candidate)?;
1964        self.unindex_reserved_positions(&existing);
1965        self.index_reserved_positions(&candidate);
1966        *self
1967            .get_block_mut(position)
1968            .expect("block existence checked above") = candidate;
1969        self.sync_shown_branch_arm_data();
1970        self.refresh_action_graph_after_mutation();
1971        Ok(())
1972    }
1973
1974    /// Returns `true` if a pipe exists between the two positions.
1975    pub fn has_pipe_between(&self, u: impl Into<IVec3>, v: impl Into<IVec3>) -> bool {
1976        self.get_pipe(u, v).is_some()
1977    }
1978
1979    pub(crate) fn get_block_mut(&mut self, position: impl Into<IVec3>) -> Option<&mut Block> {
1980        self.try_get_id(position)
1981            .ok()
1982            .and_then(|index| self.inner.node_weight_mut(index))
1983    }
1984
1985    fn check_preserved_endpoints(&self, candidate: &Block) -> Result<(), BlockGraphError> {
1986        let position = candidate.pos();
1987        let endpoints = candidate.connectable_offsets();
1988        for pipe in self.inner.edges(self.try_get_id(position)?) {
1989            for endpoint in [pipe.weight().src(), pipe.weight().dst()] {
1990                if self
1991                    .get_endpoint_block(endpoint)
1992                    .is_some_and(|owner| owner.pos() == position)
1993                    && !endpoints
1994                        .iter()
1995                        .any(|offset| position + *offset == endpoint)
1996                {
1997                    return Err(BlockGraphError::BlockNotFound(endpoint));
1998                }
1999            }
2000        }
2001        Ok(())
2002    }
2003
2004    /// Update a block kind while preserving graph indexes and refreshing action metadata.
2005    ///
2006    /// # Errors
2007    ///
2008    /// Returns an error if the block is missing or the new kind violates geometry or branch rules.
2009    ///
2010    /// # Panics
2011    ///
2012    /// Panics if graph indexes or an existing cube height are internally invalid.
2013    pub fn set_block_kind(
2014        &mut self,
2015        position: impl Into<IVec3>,
2016        kind: BlockKind,
2017    ) -> Result<(), BlockGraphError> {
2018        let position = position.into();
2019        if self.get_block(position).is_none() {
2020            return Err(BlockGraphError::BlockNotFound(position));
2021        }
2022        if let Some(region) = self.shown_branch_at(position)
2023            && (kind.is_dynamic() || kind.is_port())
2024        {
2025            return Err(InvalidActionError::InvalidBranchInterface {
2026                name: region.name.clone(),
2027                reason: format!("arms cannot contain {kind} blocks"),
2028            }
2029            .into());
2030        }
2031        let mut candidate = Block::new(position, kind);
2032        if kind.is_cube() {
2033            let height = self
2034                .get_block(position)
2035                .expect("block existence checked")
2036                .height();
2037            candidate = candidate
2038                .with_height(height)
2039                .expect("existing cube height is valid");
2040        }
2041        self.can_place_block_except(&candidate, Some(position))?;
2042        self.check_preserved_endpoints(&candidate)?;
2043        let existing = self
2044            .get_block(position)
2045            .expect("block existence checked")
2046            .clone();
2047        self.unindex_reserved_positions(&existing);
2048        let block = self
2049            .get_block_mut(position)
2050            .ok_or(BlockGraphError::BlockNotFound(position))?;
2051        block.set_kind(kind);
2052        let block = block.clone();
2053        self.index_reserved_positions(&block);
2054        self.sync_shown_branch_arm_data();
2055        self.refresh_action_graph_after_mutation();
2056        Ok(())
2057    }
2058
2059    /// Update a block tag while preserving graph indexes and action metadata.
2060    /// An empty `tag` clears the tag.
2061    ///
2062    /// # Errors
2063    ///
2064    /// Returns [`BlockGraphError::BlockNotFound`] if no block occupies
2065    /// `position`, or [`BlockGraphError::InvalidTag`] if `tag` is malformed.
2066    pub fn set_block_tag(
2067        &mut self,
2068        position: impl Into<IVec3>,
2069        tag: impl Into<String>,
2070    ) -> Result<(), BlockGraphError> {
2071        let position = position.into();
2072        let block = self
2073            .get_block_mut(position)
2074            .ok_or(BlockGraphError::BlockNotFound(position))?;
2075        block.set_tag(tag)?;
2076        self.sync_shown_branch_arm_data();
2077        Ok(())
2078    }
2079
2080    /// Updates a Port's RGB display color. Alpha remains fixed; default gray is
2081    /// omitted from serialization.
2082    ///
2083    /// # Errors
2084    ///
2085    /// Returns [`BlockGraphError::BlockNotFound`] if no block occupies
2086    /// `position`, or [`BlockError::PortColorOnNonPort`](crate::BlockError) if
2087    /// that block is not a Port.
2088    pub fn set_port_color(
2089        &mut self,
2090        position: impl Into<IVec3>,
2091        rgb: [u8; 3],
2092    ) -> Result<(), BlockGraphError> {
2093        let position = position.into();
2094        self.get_block_mut(position)
2095            .ok_or(BlockGraphError::BlockNotFound(position))?
2096            .set_port_color_checked(rgb)?;
2097        Ok(())
2098    }
2099
2100    /// Assigns the role of a Port block.
2101    ///
2102    /// # Errors
2103    ///
2104    /// Returns an error if the block is missing or is not a Port.
2105    pub fn set_port_role(
2106        &mut self,
2107        position: impl Into<IVec3>,
2108        role: crate::PortRole,
2109    ) -> Result<(), BlockGraphError> {
2110        let position = position.into();
2111        self.get_block_mut(position)
2112            .ok_or(BlockGraphError::BlockNotFound(position))?
2113            .set_port_role_checked(role)?;
2114        Ok(())
2115    }
2116
2117    /// Add a block to the graph, returning its position.
2118    ///
2119    /// This mutates topology and refreshes cached action metadata for any
2120    /// existing actions.
2121    ///
2122    /// # Panics
2123    ///
2124    /// Panics if the block's position is already occupied. Use
2125    /// [`try_add_block`](Self::try_add_block) for a fallible alternative.
2126    pub fn add_block(&mut self, block: Block) -> IVec3 {
2127        self.try_add_block(block)
2128            .unwrap_or_else(|err| panic!("Failed to add block: {err}"))
2129    }
2130
2131    /// Adds a block to the graph, returning its position or an error if the position is occupied.
2132    ///
2133    /// This mutates topology and refreshes cached action metadata for any
2134    /// existing actions.
2135    ///
2136    /// # Errors
2137    ///
2138    /// Returns an error for an occupied or unrepresentable block footprint.
2139    pub fn try_add_block(&mut self, block: Block) -> Result<IVec3, BlockGraphError> {
2140        self.try_add_block_with_branch_occupancy(block, None)
2141    }
2142
2143    pub(crate) fn try_add_block_with_branch_occupancy(
2144        &mut self,
2145        block: Block,
2146        branch_occupancy: Option<&LinkBranchOccupancy>,
2147    ) -> Result<IVec3, BlockGraphError> {
2148        let pos = block.pos;
2149        if self.block_id.contains_key(&pos) {
2150            return Err(BlockGraphError::BlockExists(pos));
2151        }
2152        if let Some(index) = branch_occupancy {
2153            self.can_place_block_for_link(&block, index)?;
2154        } else {
2155            self.can_place_block(&block)?;
2156        }
2157        let indexed = block.clone();
2158        let index = self.inner.add_node(block);
2159        self.block_id.insert(pos, index.index() as u32);
2160        self.index_reserved_positions(&indexed);
2161        self.refresh_action_graph_after_mutation();
2162        Ok(pos)
2163    }
2164
2165    fn can_place_block_for_link(
2166        &self,
2167        block: &Block,
2168        branch_occupancy: &LinkBranchOccupancy,
2169    ) -> Result<(), BlockGraphError> {
2170        for occupied in block.checked_reserved_positions()? {
2171            self.check_visible_occupancy(block, occupied, None)?;
2172            for &(existing_pos, existing_kind) in branch_occupancy
2173                .by_position
2174                .get(&occupied)
2175                .into_iter()
2176                .flatten()
2177            {
2178                if !block.kind.allows_reserved_overlap(
2179                    block.pos,
2180                    existing_kind,
2181                    existing_pos,
2182                    occupied,
2183                ) {
2184                    return Err(BlockGraphError::BlockPositionOccupied(occupied));
2185                }
2186            }
2187        }
2188        Ok(())
2189    }
2190
2191    /// Removes and returns the block at the given position, if it exists.
2192    ///
2193    /// This mutates topology and refreshes cached action metadata for any
2194    /// existing actions.
2195    pub fn remove_block(&mut self, pos: impl Into<IVec3>) -> Option<Block> {
2196        let id = self.block_id.remove(&pos.into())?;
2197        let block = self.inner.remove_node(id.into())?;
2198        self.unindex_reserved_positions(&block);
2199        self.sync_shown_branch_arm_data();
2200        self.refresh_action_graph_after_mutation();
2201        Some(block)
2202    }
2203
2204    /// Add a pipe connecting two blocks.
2205    ///
2206    /// This mutates topology and refreshes cached action metadata for any
2207    /// existing actions.
2208    ///
2209    /// # Panics
2210    ///
2211    /// Panics if either endpoint does not exist or if the pipe already exists.
2212    /// Use [`try_add_pipe`](Self::try_add_pipe) for a fallible alternative.
2213    pub fn add_pipe(&mut self, pipe: Pipe) {
2214        self.try_add_pipe(pipe)
2215            .unwrap_or_else(|err| panic!("Failed to add pipe: {err}"));
2216    }
2217
2218    /// Adds a pipe connecting two blocks, returning an error if either endpoint is missing or the pipe already exists.
2219    ///
2220    /// This mutates topology and refreshes cached action metadata for any
2221    /// existing actions.
2222    ///
2223    /// # Errors
2224    ///
2225    /// Returns an error for invalid endpoints, missing blocks, or a duplicate pipe.
2226    pub fn try_add_pipe(&mut self, pipe: Pipe) -> Result<(), BlockGraphError> {
2227        self.try_add_pipe_with_branch_candidates(pipe, None)
2228    }
2229
2230    /// Use a linker's transient ownership index while keeping the same pipe
2231    /// validation and insertion path as ordinary edits.
2232    pub(crate) fn try_add_pipe_with_branch_candidates(
2233        &mut self,
2234        pipe: Pipe,
2235        branch_candidates: Option<&[usize]>,
2236    ) -> Result<(), BlockGraphError> {
2237        let (u, v) = pipe.try_endpoints()?;
2238        let u_index = self.try_get_endpoint_id(u)?;
2239        let v_index = self.try_get_endpoint_id(v)?;
2240        if self.has_pipe_between(u, v) {
2241            return Err(BlockGraphError::PipeExists(u, v));
2242        }
2243        if let Some(indices) = branch_candidates {
2244            self.record_added_branch_pipe_for(&pipe, indices.iter().copied());
2245        } else {
2246            self.record_added_branch_pipe(&pipe);
2247        }
2248        self.inner.add_edge(u_index, v_index, pipe);
2249        self.refresh_action_graph_after_mutation();
2250        Ok(())
2251    }
2252
2253    /// Finds the graph edge whose pipe endpoints match `u` and `v` in either order.
2254    fn find_pipe_edge(&self, u: IVec3, v: IVec3) -> Option<EdgeIndex> {
2255        let u_index = self.try_get_endpoint_id(u).ok()?;
2256        let v_index = self.try_get_endpoint_id(v).ok()?;
2257        self.inner
2258            .edges_connecting(u_index, v_index)
2259            .find_map(|edge| {
2260                let pipe = edge.weight();
2261                let endpoints_match =
2262                    (pipe.src() == u && pipe.dst() == v) || (pipe.src() == v && pipe.dst() == u);
2263                endpoints_match.then_some(edge.id())
2264            })
2265    }
2266
2267    /// Removes and returns the pipe between the two positions, if it exists.
2268    pub fn remove_pipe(&mut self, u: impl Into<IVec3>, v: impl Into<IVec3>) -> Option<Pipe> {
2269        let removed = self
2270            .find_pipe_edge(u.into(), v.into())
2271            .and_then(|edge_index| self.inner.remove_edge(edge_index));
2272        if removed.is_some() {
2273            self.sync_shown_branch_arm_data();
2274            self.refresh_action_graph_after_mutation();
2275        }
2276        removed
2277    }
2278
2279    /// Returns a reference to the pipe between the two positions, if it exists.
2280    pub fn get_pipe(&self, u: impl Into<IVec3>, v: impl Into<IVec3>) -> Option<&Pipe> {
2281        let edge_index = self.find_pipe_edge(u.into(), v.into())?;
2282        self.inner.edge_weight(edge_index)
2283    }
2284
2285    /// Returns the pipe connecting the blocks anchored at `u` and `v`.
2286    ///
2287    /// Unlike [`get_pipe`](Self::get_pipe), this accepts block positions rather
2288    /// than physical pipe endpoints. This matters for multi-cell blocks whose
2289    /// connected endpoint is offset from the stored block position.
2290    pub fn get_pipe_between_blocks(
2291        &self,
2292        u: impl Into<IVec3>,
2293        v: impl Into<IVec3>,
2294    ) -> Option<&Pipe> {
2295        let u_index = self.try_get_id(u).ok()?;
2296        let v_index = self.try_get_id(v).ok()?;
2297        self.inner
2298            .edges_connecting(u_index, v_index)
2299            .min_by_key(EdgeRef::id)
2300            .map(|edge| edge.weight())
2301    }
2302
2303    pub(crate) fn get_pipe_mut(
2304        &mut self,
2305        u: impl Into<IVec3>,
2306        v: impl Into<IVec3>,
2307    ) -> Option<&mut Pipe> {
2308        let edge_index = self.find_pipe_edge(u.into(), v.into())?;
2309        self.inner.edge_weight_mut(edge_index)
2310    }
2311
2312    /// Update a pipe tag while preserving graph topology and action metadata.
2313    /// An empty `tag` clears the tag.
2314    ///
2315    /// # Errors
2316    ///
2317    /// Returns [`BlockGraphError::PipeNotFound`] if no pipe connects `u` and
2318    /// `v`, or [`BlockGraphError::InvalidTag`] if `tag` is malformed.
2319    pub fn set_pipe_tag(
2320        &mut self,
2321        u: impl Into<IVec3>,
2322        v: impl Into<IVec3>,
2323        tag: impl Into<String>,
2324    ) -> Result<(), BlockGraphError> {
2325        let u = u.into();
2326        let v = v.into();
2327        let pipe = self
2328            .get_pipe_mut(u, v)
2329            .ok_or(BlockGraphError::PipeNotFound(u, v))?;
2330        pipe.set_tag(tag)?;
2331        self.sync_shown_branch_arm_data();
2332        Ok(())
2333    }
2334
2335    /// Update a pipe Hadamard flag and refresh action metadata.
2336    ///
2337    /// # Errors
2338    ///
2339    /// Returns [`BlockGraphError::PipeNotFound`] if no pipe connects `u` and `v`.
2340    pub fn set_pipe_hadamard(
2341        &mut self,
2342        u: impl Into<IVec3>,
2343        v: impl Into<IVec3>,
2344        hadamard: bool,
2345    ) -> Result<(), BlockGraphError> {
2346        let u = u.into();
2347        let v = v.into();
2348        let key = {
2349            let pipe = self
2350                .get_pipe_mut(u, v)
2351                .ok_or(BlockGraphError::PipeNotFound(u, v))?;
2352            pipe.hadamard = hadamard;
2353            crate::branch::pipe_key(pipe)
2354        };
2355        for region in &mut self.branches {
2356            region.set_cut_hadamard(key, hadamard);
2357        }
2358        self.sync_shown_branch_arm_data();
2359        self.refresh_action_graph_after_mutation();
2360        Ok(())
2361    }
2362
2363    /// Returns all blocks directly connected to the block owning the given endpoint.
2364    pub fn neighbors(&self, position: impl Into<IVec3>) -> Vec<&Block> {
2365        if let Ok(index) = self.try_get_endpoint_id(position.into()) {
2366            return self
2367                .inner
2368                .neighbors(index)
2369                .filter_map(|neighbor_index| self.inner.node_weight(neighbor_index))
2370                .collect();
2371        }
2372        vec![]
2373    }
2374
2375    /// Returns the physical neighbor endpoint positions for pipes attached to
2376    /// the given connectable endpoint.
2377    pub fn neighbor_positions(&self, position: impl Into<IVec3>) -> Vec<IVec3> {
2378        let position = position.into();
2379        let Ok(index) = self.try_get_endpoint_id(position) else {
2380            return Vec::new();
2381        };
2382        self.inner
2383            .edges(index)
2384            .filter_map(|edge| {
2385                let pipe = edge.weight();
2386                let dst = pipe.dst();
2387                if pipe.src == position {
2388                    Some(dst)
2389                } else if dst == position {
2390                    Some(pipe.src)
2391                } else {
2392                    None
2393                }
2394            })
2395            .collect()
2396    }
2397
2398    /// Returns the number of pipes attached to the given connectable endpoint.
2399    pub fn degree(&self, position: impl Into<IVec3>) -> usize {
2400        self.neighbor_positions(position).len()
2401    }
2402
2403    /// Returns an iterator over `(block_u, block_v, pipe)` triples for every pipe in the graph.
2404    pub fn block_pairs_with_pipe(&self) -> impl Iterator<Item = (&Block, &Block, &Pipe)> {
2405        self.inner.edge_indices().filter_map(|edge_index| {
2406            let (u_index, v_index) = self.inner.edge_endpoints(edge_index)?;
2407            let u = self.inner.node_weight(u_index)?;
2408            let v = self.inner.node_weight(v_index)?;
2409            let pipe = self.inner.edge_weight(edge_index)?;
2410            Some((u, v, pipe))
2411        })
2412    }
2413
2414    /// Returns physical endpoint positions and owner blocks for every pipe.
2415    pub fn pipe_endpoints_with_blocks(
2416        &self,
2417    ) -> impl Iterator<Item = (IVec3, IVec3, &Block, &Block, &Pipe)> {
2418        self.block_pairs_with_pipe().filter_map(|(u, v, pipe)| {
2419            let src_block = self.get_endpoint_block(pipe.src)?;
2420            let dst = pipe.dst();
2421            let dst_block = self.get_endpoint_block(dst)?;
2422
2423            if src_block.pos == u.pos && dst_block.pos == v.pos {
2424                Some((pipe.src, dst, u, v, pipe))
2425            } else if src_block.pos == v.pos && dst_block.pos == u.pos {
2426                Some((pipe.src, dst, v, u, pipe))
2427            } else {
2428                None
2429            }
2430        })
2431    }
2432
2433    /// Audits structure and source actions, including signed dependencies.
2434    /// Hierarchical graphs use composed certificates and their C0 contract;
2435    /// leaves retain the local stabilizer/action audit.
2436    ///
2437    /// # Errors
2438    ///
2439    /// Returns the first structural, action, or stabilizer-derived validation error.
2440    pub fn validate(&self) -> Result<(), BlockGraphError> {
2441        if self.has_module_structure() {
2442            self.validate_source()?;
2443            if !self.instances.is_empty() {
2444                return self
2445                    .summarize_root(crate::ModuleCertificationLimits::DEFAULT)
2446                    .map(drop)
2447                    .map_err(|source| {
2448                        BlockGraphError::ModuleSource(Arc::new(
2449                            crate::ModuleError::InvalidGeometry {
2450                                module: self.name.clone(),
2451                                source,
2452                                span: None,
2453                            },
2454                        ))
2455                    });
2456            }
2457            return crate::validate::validate(&self.copy_local_geometry().fix_shadowed_faces());
2458        }
2459        crate::validate::validate(self)
2460    }
2461
2462    /// Validates graph structural constraints without validating action semantics.
2463    ///
2464    /// # Errors
2465    ///
2466    /// Returns the first structural graph error.
2467    pub fn validate_structure(&self) -> Result<(), BlockGraphError> {
2468        crate::validate::validate_structure(self)
2469    }
2470
2471    /// Validates graph structure and action-source semantics without deriving
2472    /// stabilizer surfaces or implicit physical dependencies.
2473    ///
2474    /// # Errors
2475    ///
2476    /// Returns the first structural or source-action error.
2477    pub fn validate_source(&self) -> Result<(), BlockGraphError> {
2478        if self.has_module_structure() {
2479            self.validate_with_limits(crate::ModuleCertificationLimits::DEFAULT)
2480                .map_err(|error| BlockGraphError::ModuleSource(Arc::new(error)))?;
2481            for definition in self.modules() {
2482                let local = definition.copy_local_geometry().fix_shadowed_faces();
2483                if definition.instances.is_empty() {
2484                    crate::validate::validate_source(&local)?;
2485                } else {
2486                    // Child seams supply some local block incidence. Their
2487                    // geometry was checked above; only the owned DAG remains.
2488                    let mut dag = local.build_action_graph(&local.actions())?;
2489                    dag.validate(Some(&local))?;
2490                }
2491            }
2492            return Ok(());
2493        }
2494        crate::validate::validate_source(self).map(|_| ())
2495    }
2496
2497    /// Lists every structural violation rather than stopping at the first, for
2498    /// callers that need to tell a defect an edit introduced from one the graph
2499    /// already carried. An empty result means the same as
2500    /// [`BlockGraph::validate_structure`] returning `Ok`.
2501    ///
2502    /// Violations are complete per validation phase, not across phases: an
2503    /// overlapping footprint or an unresolvable pipe endpoint suppresses the
2504    /// later geometry checks, which assume those hold.
2505    pub fn structural_errors(&self) -> Vec<BlockGraphError> {
2506        crate::validate::structural_errors(self)
2507    }
2508}
2509
2510#[cfg(feature = "gltf")]
2511impl BlockGraph {
2512    /// Builds the export glTF scene, optionally overlaying a stabilizer generator.
2513    fn build_export_gltf(
2514        &self,
2515        pipe_length: f32,
2516        stabilizer: Option<&StabilizerGenerator>,
2517        popped_faces: &[GltfFaceSelector],
2518    ) -> Result<GltfData, BlockGraphError> {
2519        self.require_flat_hierarchy("glTF export")?;
2520        let mut data = block_graph_as_gltf_data_with_popped_faces(self, pipe_length, popped_faces);
2521        if !popped_faces.is_empty() {
2522            data = emphasize_popped_faces(data);
2523        }
2524        let editor_world = |point: glam::Vec3| glam::vec3(point.x, point.z, -point.y);
2525        data = data.map_points(editor_world);
2526        if let Some(stabilizer) = stabilizer {
2527            if popped_faces.is_empty() {
2528                data = data.scale_face_opacity(OUTER_FACE_OPACITY_SCALE);
2529            }
2530            let stabilizer_data =
2531                stabilizer_as_gltf_data(stabilizer, self, pipe_length)?.map_points(editor_world);
2532            data.extend(stabilizer_data);
2533        }
2534        Ok(data)
2535    }
2536
2537    fn build_module_export_gltf(
2538        &self,
2539        pipe_length: f32,
2540        popped_faces: &[GltfFaceSelector],
2541    ) -> Result<(GltfData, Option<crate::ModuleView>), BlockGraphError> {
2542        let flat = self
2543            .flatten()
2544            .map_err(|error| BlockGraphError::ModuleMaterialization(Arc::new(error)))?;
2545        let modules = crate::ModuleView::from_graph(self, &flat);
2546        let data = block_graph_as_gltf_data_with_modules(
2547            &flat,
2548            pipe_length,
2549            popped_faces,
2550            modules.as_ref(),
2551        )
2552        .map_points(|point| glam::vec3(point.x, point.z, -point.y));
2553        Ok((data, modules))
2554    }
2555
2556    /// Exports an independent rendering projection colored by module definition.
2557    ///
2558    /// Uses the editor's opaque ownership colors and retains ordinary colors on
2559    /// cross-definition pipes. The authored hierarchy is unchanged.
2560    ///
2561    /// # Errors
2562    ///
2563    /// Returns a materialization, serialization, or file-I/O error.
2564    pub fn write_module_gltf_file(
2565        &self,
2566        pipe_length: f32,
2567        path: impl AsRef<std::path::Path>,
2568        popped_faces: &[GltfFaceSelector],
2569    ) -> Result<(), BlockGraphError> {
2570        self.build_module_export_gltf(pipe_length, popped_faces)?
2571            .0
2572            .to_file(path)
2573    }
2574
2575    /// Exports a module ownership view with independently toggled definition highlights.
2576    ///
2577    /// Retains the authored graph and creates an independent flat rendering
2578    /// projection. The HTML embeds its model and loads model-viewer from a CDN.
2579    ///
2580    /// # Errors
2581    ///
2582    /// Returns a materialization, serialization, or file-I/O error.
2583    pub fn write_module_html_viewer(
2584        &self,
2585        pipe_length: f32,
2586        path: impl AsRef<std::path::Path>,
2587        popped_faces: &[GltfFaceSelector],
2588    ) -> Result<(), BlockGraphError> {
2589        let flat = self
2590            .flatten()
2591            .map_err(|error| BlockGraphError::ModuleMaterialization(Arc::new(error)))?;
2592        let modules = crate::ModuleView::from_graph(self, &flat);
2593        let (data, ranges) =
2594            module_html_geometry(&flat, pipe_length, popped_faces, modules.as_ref());
2595        let data = data.map_points(|point| glam::vec3(point.x, point.z, -point.y));
2596        let mut legend = String::new();
2597        if let Some(modules) = modules {
2598            legend.push_str("<details class=\"module-legend\" open><summary>Modules</summary><ul>");
2599            for (index, module) in modules.modules().iter().enumerate() {
2600                let color = crate::module_color(index);
2601                let name = module
2602                    .name
2603                    .replace('&', "&amp;")
2604                    .replace('<', "&lt;")
2605                    .replace('>', "&gt;")
2606                    .replace('"', "&quot;");
2607                let instance_suffix = if module.instance_count == 1 { "" } else { "s" };
2608                let block_suffix = if module.block_count == 1 { "" } else { "s" };
2609                legend.push_str(&format!("<li><label><input type=\"checkbox\" data-module=\"{index}\" checked disabled aria-label=\"Highlight {name}\"><i style=\"background:#{:02x}{:02x}{:02x}\"></i>{name}<br>{} instance{instance_suffix} · {} block{block_suffix}</label></li>",
2610                    color.r, color.g, color.b, module.instance_count, module.block_count));
2611            }
2612            legend.push_str("</ul></details>");
2613        }
2614        std::fs::write(&path, data.to_module_html_str(&legend, &ranges)?).map_err(|source| {
2615            BlockGraphError::Io {
2616                path: path.as_ref().to_path_buf(),
2617                source: Arc::new(source),
2618            }
2619        })
2620    }
2621
2622    /// Exports the graph as a `.gltf` file, optionally overlaying a stabilizer generator.
2623    ///
2624    /// Uses the editor's orientation and baked diffuse lighting in unlit materials.
2625    ///
2626    /// # Errors
2627    ///
2628    /// Returns a graph, serialization, or file-I/O error.
2629    pub fn write_to_gltf_file(
2630        &self,
2631        pipe_length: f32,
2632        path: impl AsRef<std::path::Path>,
2633        stabilizer: Option<&StabilizerGenerator>,
2634        popped_faces: &[GltfFaceSelector],
2635    ) -> Result<(), BlockGraphError> {
2636        self.build_export_gltf(pipe_length, stabilizer, popped_faces)?
2637            .to_file(path)
2638    }
2639
2640    /// Exports the graph as an HTML viewer with an embedded glTF scene.
2641    ///
2642    /// The model data is embedded as a base64 data URI, but the `model-viewer`
2643    /// renderer is loaded from a CDN, so the page requires network access.
2644    ///
2645    /// # Errors
2646    ///
2647    /// Returns a graph, serialization, or file-I/O error.
2648    pub fn write_to_gltf_html_viewer(
2649        &self,
2650        pipe_length: f32,
2651        path: impl AsRef<std::path::Path>,
2652        stabilizer: Option<&StabilizerGenerator>,
2653        popped_faces: &[GltfFaceSelector],
2654    ) -> Result<(), BlockGraphError> {
2655        let gltf_data = self.build_export_gltf(pipe_length, stabilizer, popped_faces)?;
2656        let html_str = gltf_data.to_html_str()?;
2657        std::fs::write(&path, html_str).map_err(|e| BlockGraphError::Io {
2658            path: path.as_ref().to_path_buf(),
2659            source: Arc::new(e),
2660        })
2661    }
2662}
2663
2664impl BlockGraph {
2665    /// Serializes the complete executable hierarchy as BLOG.
2666    ///
2667    /// Procedural graphs infer their interfaces; authored interfaces and helper
2668    /// definitions are retained exactly. Use `to_blog_body_text` explicitly for
2669    /// a local geometry/actions projection.
2670    ///
2671    /// # Errors
2672    ///
2673    /// Returns a typed error when a procedural graph's interface cannot be inferred.
2674    pub fn to_program_blog_text(&self) -> Result<String, crate::ModuleError> {
2675        if self.has_module_structure() {
2676            return Ok(self.to_blog_text());
2677        }
2678        self.with_local_export_names()
2679            .with_inferred_interface()
2680            .map(|program| program.to_blog_text())
2681    }
2682
2683    fn with_local_export_names(&self) -> Self {
2684        fn rename_expr(expr: &mut crate::Expr, names: &HashMap<String, String>) {
2685            match expr {
2686                crate::Expr::Var(name) => {
2687                    if let Some(local) = names.get(name) {
2688                        *name = local.clone();
2689                    }
2690                }
2691                crate::Expr::Not(inner) => rename_expr(inner, names),
2692                crate::Expr::Binary(_, left, right) => {
2693                    rename_expr(left, names);
2694                    rename_expr(right, names);
2695                }
2696            }
2697        }
2698
2699        let mut actions = self.actions();
2700        let names = self
2701            .action_inputs
2702            .iter()
2703            .chain(actions.iter().filter_map(|action| match action {
2704                Action::Let { name, .. } | Action::Measure { name, .. } => Some(name),
2705                _ => None,
2706            }))
2707            .chain(self.branches.iter().map(|branch| &branch.name))
2708            .cloned()
2709            .collect::<BTreeSet<_>>();
2710        let mut next = 0;
2711        let replacements = names
2712            .iter()
2713            .filter(|name| name.contains("__") || !crate::parser::is_valid_simple_identifier(name))
2714            .map(|name| {
2715                let local = loop {
2716                    let candidate = format!("flat{next}");
2717                    next += 1;
2718                    if !names.contains(&candidate) {
2719                        break candidate;
2720                    }
2721                };
2722                (name.clone(), local)
2723            })
2724            .collect::<HashMap<_, _>>();
2725        let rename = |name: &String| replacements.get(name).unwrap_or(name).clone();
2726        let mut graph = self.clone();
2727        graph.action_inputs = self.action_inputs.iter().map(rename).collect();
2728        for branch in &mut graph.branches {
2729            branch.name = rename(&branch.name);
2730        }
2731        for action in &mut actions {
2732            let expr = match action {
2733                Action::Let { name, expr } => {
2734                    *name = rename(name);
2735                    Some(expr)
2736                }
2737                Action::Measure { name, .. } => {
2738                    *name = rename(name);
2739                    None
2740                }
2741                Action::DiscardIf(expr) => Some(expr),
2742                Action::Resolve { condition, .. } | Action::Branch { condition, .. } => {
2743                    Some(condition)
2744                }
2745                Action::Feedback { condition, .. } => condition.as_mut(),
2746            };
2747            if let Some(expr) = expr {
2748                rename_expr(expr, &replacements);
2749            }
2750        }
2751        graph.rebuild_action_graph_lenient(actions);
2752        graph
2753    }
2754
2755    /// Serializes the graph to the `.blog` text format (BLOG 1.0).
2756    ///
2757    /// # Panics
2758    ///
2759    /// Panics if internal block, pipe, branch, or action indexes are inconsistent.
2760    pub fn to_blog_body_text(&self) -> String {
2761        use std::fmt::Write as _;
2762
2763        if !self.branches.is_empty() {
2764            return self.to_blog_text_with_branches();
2765        }
2766
2767        let mut blog = String::from("BLOG 1.0\n\n");
2768
2769        let sorted_id_pos = self.blog_block_ids();
2770
2771        let has_data = !sorted_id_pos.is_empty() || self.pipes().next().is_some();
2772        if has_data {
2773            for (pos, id) in sorted_id_pos {
2774                let block = self.get_block(pos).expect("block exists in block_id");
2775                writeln!(blog, "{id}: {block}").expect("writing to String cannot fail");
2776            }
2777
2778            let mut sorted_pipes: Vec<Pipe> = self.pipes().cloned().collect();
2779            sorted_pipes.sort_by_key(|pipe| {
2780                let src_id = self
2781                    .get_endpoint_block_id(pipe.src)
2782                    .expect("pipe src has block_id");
2783                let dst_pos = pipe.src + pipe.dir.to_ivec3();
2784                let dst_id = self
2785                    .get_endpoint_block_id(dst_pos)
2786                    .expect("pipe dst has block_id");
2787                (src_id, dst_id)
2788            });
2789            for pipe in sorted_pipes {
2790                writeln!(
2791                    blog,
2792                    "{}",
2793                    pipe.display_with_ids(|position| self.block_id.get(&position).copied())
2794                )
2795                .expect("writing to String cannot fail");
2796            }
2797        }
2798
2799        if self.has_actions() {
2800            if has_data {
2801                blog.push('\n');
2802            }
2803            for node in self.action_graph.ordered_nodes() {
2804                let action_str = node
2805                    .action
2806                    .display_with_ids(|position| self.block_id.get(&position).copied());
2807                writeln!(blog, "{action_str}").expect("writing to String cannot fail");
2808            }
2809        }
2810        blog
2811    }
2812
2813    fn to_blog_text_with_branches(&self) -> String {
2814        use std::fmt::Write as _;
2815
2816        let mut blog = String::from("BLOG 1.0\n\n");
2817        let shown_pipes = self
2818            .branches
2819            .iter()
2820            .flat_map(|region| region.shown_arm().pipes())
2821            .map(crate::branch::pipe_key)
2822            .collect::<HashSet<_>>();
2823        let shared_pipes = self
2824            .branches
2825            .iter()
2826            .flat_map(|region| {
2827                region
2828                    .on_false()
2829                    .pipes()
2830                    .filter(|pipe| region.on_true().pipes().any(|candidate| candidate == *pipe))
2831            })
2832            .cloned()
2833            .collect::<HashSet<_>>();
2834
2835        let common = self.blog_block_ids();
2836        let mut action_ids = HashMap::new();
2837        let mut next_id = u32::try_from(common.len()).expect("BlockGraph ids fit in u32");
2838        for (position, id) in common {
2839            let block = self
2840                .get_block(position)
2841                .expect("common block exists in block_id");
2842            writeln!(blog, "{id}: {block}").expect("writing to String cannot fail");
2843            action_ids.insert(position, id);
2844        }
2845
2846        let mut common_pipes = self
2847            .pipes()
2848            .filter(|pipe| !shown_pipes.contains(&crate::branch::pipe_key(pipe)))
2849            .cloned()
2850            .collect::<Vec<_>>();
2851        common_pipes.extend(shared_pipes.iter().cloned());
2852        common_pipes.sort_by_key(|pipe| {
2853            let (src, dst) = pipe.endpoints();
2854            (src.to_array(), dst.to_array())
2855        });
2856        for pipe in common_pipes {
2857            writeln!(
2858                blog,
2859                "{}",
2860                pipe.display_with_ids(|position| action_ids.get(&position).copied())
2861            )
2862            .expect("writing to String cannot fail");
2863        }
2864
2865        for region in &self.branches {
2866            writeln!(blog, "branch {} {{", region.name).expect("writing to String cannot fail");
2867            for (label, arm) in [("false", region.on_false()), ("true", region.on_true())] {
2868                writeln!(blog, "  {label} {{").expect("writing to String cannot fail");
2869                let mut arm_ids = HashMap::new();
2870                for block in arm.blocks() {
2871                    writeln!(blog, "    {next_id}: {block}")
2872                        .expect("writing to String cannot fail");
2873                    arm_ids.insert(block.pos(), next_id);
2874                    next_id += 1;
2875                }
2876                for pipe in arm.pipes().filter(|pipe| !shared_pipes.contains(*pipe)) {
2877                    writeln!(
2878                        blog,
2879                        "    {}",
2880                        pipe.display_with_ids(|position| {
2881                            arm_ids
2882                                .get(&position)
2883                                .or_else(|| action_ids.get(&position))
2884                                .copied()
2885                        })
2886                    )
2887                    .expect("writing to String cannot fail");
2888                }
2889                writeln!(blog, "  }}").expect("writing to String cannot fail");
2890            }
2891            writeln!(blog, "}}").expect("writing to String cannot fail");
2892        }
2893
2894        if self.has_actions() {
2895            blog.push('\n');
2896            for node in self.action_graph.ordered_nodes() {
2897                match &node.action {
2898                    Action::Branch { target, condition } => {
2899                        let name = &self
2900                            .branch_by_target(*target)
2901                            .expect("validated branch action has a named region")
2902                            .name;
2903                        writeln!(blog, "resolve {name} if {condition}")
2904                            .expect("writing to String cannot fail");
2905                    }
2906                    action => {
2907                        let action =
2908                            action.display_with_ids(|position| action_ids.get(&position).copied());
2909                        writeln!(blog, "{action}").expect("writing to String cannot fail");
2910                    }
2911                }
2912            }
2913        }
2914        blog
2915    }
2916
2917    /// Writes the graph to a file in `.blog` text format.
2918    ///
2919    /// # Errors
2920    ///
2921    /// Returns [`BlockGraphError::Io`] if the file cannot be written.
2922    pub fn to_file(&self, path: impl AsRef<std::path::Path>) -> Result<(), BlockGraphError> {
2923        std::fs::write(&path, self.to_blog_text()).map_err(|e| BlockGraphError::Io {
2924            path: path.as_ref().to_path_buf(),
2925            source: Arc::new(e),
2926        })
2927    }
2928
2929    /// Parses a block graph from `.blog` text format.
2930    ///
2931    /// BLOG is an authoring format, so the result may be structurally
2932    /// incomplete. Call [`Self::validate`] when a complete graph is required.
2933    ///
2934    /// # Errors
2935    ///
2936    /// Returns a parse or graph-construction error for invalid BLOG input.
2937    pub fn from_blog_text(blog_text: &str) -> Result<Self, BlockGraphError> {
2938        crate::parser::parse_blog_to_graph(blog_text)
2939    }
2940
2941    /// Parses executable BLOG or graph-body text, retaining module hierarchy.
2942    ///
2943    /// This is the same input model as Python's `BlockGraph.from_text` and the
2944    /// native compiler. Geometry, authored interfaces, child instances, seams,
2945    /// bindings, and helper definitions are owned by the returned graph.
2946    /// Imports require [`Self::load`].
2947    ///
2948    /// # Errors
2949    ///
2950    /// Returns typed syntax, module, graph-construction, or resource-limit errors.
2951    ///
2952    /// # Examples
2953    ///
2954    /// ```
2955    /// use bloq_graph::BlockGraph;
2956    /// let graph = BlockGraph::from_text("BLOG 1.0\nmodule main {\n0: ZXZ [0,0,0]\n}\n")?;
2957    /// assert_eq!(graph.block_count(), 1);
2958    /// # Ok::<(), bloq_graph::BlockGraphError>(())
2959    /// ```
2960    pub fn from_text(text: &str) -> Result<Self, BlockGraphError> {
2961        Self::from_text_with_limits(text, crate::ModuleCertificationLimits::DEFAULT)
2962    }
2963
2964    /// Parses executable BLOG or graph-body text with explicit source budgets.
2965    ///
2966    /// The same limits govern action analysis and hierarchy expansion.
2967    ///
2968    /// # Errors
2969    ///
2970    /// Returns typed syntax, module, graph-construction, or resource-limit errors.
2971    pub fn from_text_with_limits(
2972        text: &str,
2973        limits: crate::ModuleCertificationLimits,
2974    ) -> Result<Self, BlockGraphError> {
2975        crate::parser::parse_graph_input(text, limits)
2976    }
2977
2978    /// Loads BLOG as a block graph, resolving imports relative to each file.
2979    ///
2980    /// This matches Python's `BlockGraph.load`. Module instances and interfaces remain authored; call [`Self::flatten`]
2981    /// explicitly when a consumer needs one expanded geometry projection.
2982    ///
2983    /// # Errors
2984    ///
2985    /// Returns typed I/O, parse, import-resolution, graph, or resource-limit errors.
2986    pub fn load(path: impl AsRef<std::path::Path>) -> Result<Self, BlockGraphError> {
2987        Self::load_with_limits(path, crate::ModuleCertificationLimits::DEFAULT)
2988    }
2989
2990    /// Loads BLOG as a graph with explicit source-analysis and expansion budgets.
2991    ///
2992    /// The same limits apply to every imported hierarchy.
2993    ///
2994    /// # Errors
2995    ///
2996    /// Returns typed I/O, parse, import-resolution, graph, or resource-limit errors.
2997    pub fn load_with_limits(
2998        path: impl AsRef<std::path::Path>,
2999        limits: crate::ModuleCertificationLimits,
3000    ) -> Result<Self, BlockGraphError> {
3001        crate::parser::load_graph_input(path.as_ref(), limits)
3002    }
3003
3004    /// Returns a new graph with cube kinds adjusted so that shadowed pipe faces have consistent basis assignments.
3005    #[must_use]
3006    pub fn fix_shadowed_faces(&self) -> Self {
3007        self.fix_shadowed_faces_with(&[])
3008    }
3009
3010    pub(crate) fn fix_shadowed_faces_with(&self, extra_pipes: &[(IVec3, UDirection)]) -> Self {
3011        let mut extras = HashMap::<IVec3, [usize; 3]>::new();
3012        for &(position, direction) in extra_pipes {
3013            extras.entry(position).or_default()[direction.index()] += 1;
3014        }
3015        let mut updates: Vec<(IVec3, CubeKind)> = Vec::new();
3016        for block in self.blocks() {
3017            let BlockKind::Cube(current_kind) = block.kind else {
3018                continue;
3019            };
3020            // Count pipes per direction
3021            let mut counts = extras.get(&block.pos()).copied().unwrap_or_default();
3022            for edge in self.inner.edges(
3023                self.try_get_id(block.pos)
3024                    .expect("block from self has valid id"),
3025            ) {
3026                counts[edge.weight().dir.as_udirection().index()] += 1;
3027            }
3028
3029            let new_kind = shadowed_cube_kind(current_kind, counts);
3030            if new_kind != current_kind {
3031                updates.push((block.pos, new_kind));
3032            }
3033        }
3034
3035        let mut new_graph = self.clone();
3036        let changed = !updates.is_empty();
3037        for (pos, kind) in updates {
3038            if let Some(block) = new_graph.get_block_mut(pos) {
3039                // Cube to cube keeps the height, and so the reserved
3040                // footprint `reserved_by_position` indexes.
3041                block.kind = BlockKind::Cube(kind);
3042            }
3043        }
3044        if changed {
3045            new_graph.sync_shown_branch_arm_data();
3046            new_graph.refresh_action_graph_after_mutation();
3047        }
3048        new_graph
3049    }
3050
3051    fn randomly_resolved_selective_kind(&self, block: &Block, rng: &mut impl Rng) -> BlockKind {
3052        let BlockKind::Selective(selective_kind) = block.kind else {
3053            return block.kind;
3054        };
3055        let chosen_basis = if rng.random::<bool>() {
3056            selective_kind.pauli_if_true()
3057        } else {
3058            selective_kind.pauli_if_false()
3059        };
3060        self.resolved_selective_kind_for_basis(chosen_basis)
3061    }
3062
3063    fn resolved_selective_kind_for_basis(&self, chosen_basis: PauliBasis) -> BlockKind {
3064        match chosen_basis {
3065            PauliBasis::X => BlockKind::Measurement(Basis::X),
3066            PauliBasis::Y => BlockKind::Y,
3067            PauliBasis::Z => BlockKind::Measurement(Basis::Z),
3068        }
3069    }
3070}
3071
3072fn named_measurement_rows(stabilizers: &StabilizerGenerators) -> BTreeMap<&str, &Stabilizer> {
3073    stabilizers
3074        .generators
3075        .iter()
3076        .filter_map(|generator| match &generator.kind {
3077            StabilizerRowKind::Measurement { name } => Some((name.as_str(), &generator.stabilizer)),
3078            _ => None,
3079        })
3080        .collect()
3081}
3082
3083fn validate_shared_measurement_rows(
3084    baseline: &StabilizerGenerators,
3085    projected: &StabilizerGenerators,
3086) -> Result<(), BlockGraphError> {
3087    let baseline_fixings = crate::selective_fixings(&baseline.generators);
3088    let projected_fixings = crate::selective_fixings(&projected.generators);
3089    let projected_rows = named_measurement_rows(projected);
3090    for (name, row) in named_measurement_rows(baseline) {
3091        let shared = projected_rows.get(name).is_some_and(|projected| {
3092            positioned_stabilizers_equal(row, projected)
3093                && shared_measurement_fixings(row, &baseline_fixings, &projected_fixings)
3094        });
3095        if !shared {
3096            return Err(InvalidActionError::BranchDependentMeasurementSurface {
3097                name: name.to_owned(),
3098            }
3099            .into());
3100        }
3101    }
3102    Ok(())
3103}
3104
3105fn positioned_stabilizers_equal(left: &Stabilizer, right: &Stabilizer) -> bool {
3106    left.sign == right.sign
3107        && left.port_stabilizer == right.port_stabilizer
3108        && left.interior_nodes == right.interior_nodes
3109        && left.interior_edges == right.interior_edges
3110}
3111
3112fn shared_measurement_fixings(
3113    row: &Stabilizer,
3114    baseline: &crate::SelectiveFixings<'_>,
3115    projected: &crate::SelectiveFixings<'_>,
3116) -> bool {
3117    let mut sites = row
3118        .interior_nodes
3119        .iter()
3120        .filter_map(|(&pos, &pauli)| {
3121            (pauli != crate::Pauli::I && baseline.contains_key(&pos)).then_some(pos)
3122        })
3123        .collect::<Vec<_>>();
3124    let mut seen = sites.iter().copied().collect::<HashSet<_>>();
3125    let mut scan = 0;
3126    while scan < sites.len() {
3127        let site = sites[scan];
3128        scan += 1;
3129        let base = baseline[&site];
3130        let Some(projected) = projected.get(&site) else {
3131            return false;
3132        };
3133        if base.forbidden != projected.forbidden
3134            || !positioned_stabilizers_equal(base.row, projected.row)
3135        {
3136            return false;
3137        }
3138        for (&pos, &pauli) in &base.row.interior_nodes {
3139            if pauli != crate::Pauli::I && baseline.contains_key(&pos) && seen.insert(pos) {
3140                sites.push(pos);
3141            }
3142        }
3143    }
3144    true
3145}
3146
3147fn positioned_stabilizer_touches_region(row: &Stabilizer, region: &crate::BranchRegion) -> bool {
3148    row.interior_nodes
3149        .iter()
3150        .any(|(&position, &pauli)| pauli != crate::Pauli::I && region.contains_block(position))
3151        || row.interior_edges.iter().any(|(&(first, second), &pauli)| {
3152            pauli != crate::Pauli::I
3153                && (region.contains_block(first) || region.contains_block(second))
3154        })
3155}
3156
3157impl std::fmt::Display for BlockGraph {
3158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3159        f.write_str(&self.to_blog_text())
3160    }
3161}
3162
3163impl FromStr for BlockGraph {
3164    type Err = BlockGraphError;
3165
3166    fn from_str(s: &str) -> Result<Self, Self::Err> {
3167        Self::from_text(s)
3168    }
3169}
3170
3171/// A fixed native resource needs one Hadamard for each flipped endpoint frame.
3172pub(crate) fn flip_pipe_xz_basis(pipe: &mut Pipe, is_fixed_resource: impl Fn(IVec3) -> bool) {
3173    pipe.hadamard ^= is_fixed_resource(pipe.src()) ^ is_fixed_resource(pipe.dst());
3174}
3175
3176pub(crate) fn validate_block_orientation<'a>(
3177    blocks: impl IntoIterator<Item = &'a Block>,
3178    orientation: crate::ModuleOrientation,
3179) -> Result<(), BlockGraphError> {
3180    let (mut has_dynamic, mut has_walking_or_patch, mut has_custom_height, mut has_y) =
3181        (false, false, false, false);
3182    for block in blocks {
3183        has_dynamic |= block.kind.is_t() || block.kind.is_selective();
3184        has_walking_or_patch |= block.kind.is_walking() || block.kind.is_patch_rotation();
3185        // Any non-default height, not just a multi-cell one: height is a
3186        // property of the *time* axis, so it stops meaning anything once a
3187        // rotation swaps time with a spatial axis.
3188        has_custom_height |= !block.height().is_default();
3189        has_y |= block.kind.is_y();
3190    }
3191    if !orientation.preserves_time_direction() {
3192        if has_dynamic {
3193            return Err(BlockGraphError::RotationUnsupportedDynamicBlocks);
3194        }
3195        if has_walking_or_patch {
3196            return Err(BlockGraphError::RotationWalkingRequiresTimeAxis);
3197        }
3198        if has_custom_height {
3199            return Err(BlockGraphError::RotationCubeHeightRequiresTimeAxis);
3200        }
3201    }
3202    if has_y && !orientation.is_coordinate_half_turn() {
3203        return Err(BlockGraphError::RotationRequiresHalfTurns);
3204    }
3205    Ok(())
3206}
3207
3208/// Normalize one cube using its complete incident-axis counts. Local geometry
3209/// contexts use the same rule without normalizing their incomplete outer halo.
3210pub(crate) fn shadowed_cube_kind(current_kind: CubeKind, counts: [usize; 3]) -> CubeKind {
3211    let mut new_kind = current_kind;
3212    match counts.iter().filter(|&&c| c > 0).count() {
3213        1 if current_kind.is_spatial() => {
3214            let active_dim = counts
3215                .iter()
3216                .position(|&c| c > 0)
3217                .expect("exactly one active dimension in this match arm");
3218            // Spatial pass-through: fix the kind to make it not a spatial kind
3219            if active_dim != 2 && counts[active_dim] == 2 {
3220                let mut bases = new_kind.bases();
3221                bases[active_dim] = bases[active_dim].flip();
3222                new_kind =
3223                    CubeKind::try_from(bases).expect("flipped basis produces valid CubeKind");
3224            }
3225        }
3226        2 => {
3227            let mut active_dims = (0..3).filter(|&i| counts[i] > 0).take(2);
3228            let dim1 = active_dims
3229                .next()
3230                .expect("two active dims in this match arm");
3231            let dim2 = active_dims
3232                .next()
3233                .expect("two active dims in this match arm");
3234            // X-shape/T-shape: use normal basis to decide the block kind
3235            let mut bases = current_kind.bases();
3236            let normal_dim = counts
3237                .iter()
3238                .position(|c| *c == 0)
3239                .expect("one inactive dim in two-active match arm");
3240            let normal_basis = bases[normal_dim];
3241            let side_basis = normal_basis.flip();
3242            bases[dim1] = side_basis;
3243            bases[dim2] = side_basis;
3244            new_kind = CubeKind::try_from(bases).expect("valid bases produce valid CubeKind");
3245        }
3246        _ => {}
3247    }
3248    new_kind
3249}
3250
3251pub(crate) fn oriented_block(
3252    block: &Block,
3253    orientation: crate::ModuleOrientation,
3254) -> Result<Block, BlockGraphError> {
3255    let mut rotated = block.clone();
3256    rotated.pos = orientation.try_rotate_position(block.pos)?;
3257    rotated.kind = oriented_block_kind(block.kind, orientation);
3258    for offset in rotated.reserved_offsets() {
3259        crate::checked_add_position(rotated.pos, offset)?;
3260    }
3261    Ok(rotated)
3262}
3263
3264pub(crate) fn oriented_pipe(
3265    pipe: &Pipe,
3266    orientation: crate::ModuleOrientation,
3267) -> Result<Pipe, BlockGraphError> {
3268    let src = orientation.try_rotate_position(pipe.src)?;
3269    let dir = orientation.rotate_direction(pipe.dir);
3270    crate::checked_add_position(src, dir.to_ivec3())?;
3271    Ok(Pipe {
3272        src,
3273        dir,
3274        hadamard: pipe.hadamard,
3275        tag: pipe.tag.clone(),
3276    })
3277}
3278
3279fn oriented_block_kind(kind: BlockKind, orientation: crate::ModuleOrientation) -> BlockKind {
3280    match kind {
3281        BlockKind::Cube(kind) => BlockKind::Cube(
3282            CubeKind::try_from(orientation.rotate_axis_values(kind.bases()))
3283                .expect("a cubic orientation permutes valid cube bases"),
3284        ),
3285        BlockKind::Walking(kind) => {
3286            let movement = orientation
3287                .try_rotate_position(kind.movement_3d())
3288                .expect("small walking movement remains representable");
3289            let boundary =
3290                CubeKind::try_from(orientation.rotate_axis_values(kind.boundary().bases()))
3291                    .and_then(WalkingBoundaryKind::try_from)
3292                    .expect("a time-preserving orientation keeps walking boundaries temporal");
3293            BlockKind::Walking(
3294                WalkingKind::new(boundary, movement.truncate())
3295                    .expect("a time-preserving orientation keeps walking movement valid"),
3296            )
3297        }
3298        BlockKind::PatchRotation(kind) => {
3299            let movement = orientation
3300                .try_rotate_position(kind.movement_3d())
3301                .expect("small patch rotation movement remains representable");
3302            let basis = if orientation
3303                .rotate_direction(Direction::XPLUS)
3304                .as_udirection()
3305                == UDirection::X
3306            {
3307                kind.basis()
3308            } else {
3309                kind.basis().flip()
3310            };
3311            BlockKind::PatchRotation(
3312                PatchRotationKind::new(basis, movement.truncate())
3313                    .expect("a time-preserving orientation keeps patch movement valid"),
3314            )
3315        }
3316        _ => kind,
3317    }
3318}
3319
3320fn normalize_z_position(position: IVec3, min_z: i32, max_z: i32) -> Result<IVec3, BlockGraphError> {
3321    let z = i64::from(position.z) - i64::from(min_z);
3322    let z = i32::try_from(z)
3323        .map_err(|_| BlockGraphError::CoordinateNormalizationOverflow { min_z, max_z })?;
3324    Ok(IVec3::new(position.x, position.y, z))
3325}
3326
3327fn normalize_action_z(action: &Action, min_z: i32, max_z: i32) -> Result<Action, BlockGraphError> {
3328    let normalize = |position| normalize_z_position(position, min_z, max_z);
3329    Ok(match action {
3330        Action::Measure { target, name } => Action::Measure {
3331            target: match target {
3332                MeasureTarget::Node(position) => MeasureTarget::Node(normalize(*position)?),
3333                MeasureTarget::Edge { src, dir } => {
3334                    let src = normalize(*src)?;
3335                    crate::checked_add_position(src, dir.to_ivec3())?;
3336                    MeasureTarget::Edge { src, dir: *dir }
3337                }
3338            },
3339            name: name.clone(),
3340        },
3341        Action::Resolve { target, condition } => Action::Resolve {
3342            target: normalize(*target)?,
3343            condition: condition.clone(),
3344        },
3345        Action::Branch { target, condition } => Action::Branch {
3346            target: normalize(*target)?,
3347            condition: condition.clone(),
3348        },
3349        Action::Feedback { targets, condition } => Action::Feedback {
3350            targets: targets
3351                .iter()
3352                .map(|target| {
3353                    Ok(crate::FeedbackTarget {
3354                        pauli: target.pauli,
3355                        target: normalize(target.target)?,
3356                        direction: target.direction,
3357                    })
3358                })
3359                .collect::<Result<_, BlockGraphError>>()?,
3360            condition: condition.clone(),
3361        },
3362        Action::Let { .. } | Action::DiscardIf(..) => action.clone(),
3363    })
3364}
3365
3366#[cfg(test)]
3367mod tests {
3368    use super::*;
3369    use crate::{BranchArm, Expr, GalleryItem, SelectiveKind, StabilizerError};
3370    use bloq_utils::Pauli;
3371
3372    #[test]
3373    fn cube_height_edits_preserve_connected_endpoints() {
3374        let mut graph = BlockGraph::new();
3375        graph.add_block(
3376            Block::new(IVec3::ZERO, BlockKind::Cube(CubeKind::ZXZ))
3377                .with_height("2d".parse().unwrap())
3378                .unwrap(),
3379        );
3380        graph.add_block(Block::new(2 * IVec3::Z, BlockKind::Cube(CubeKind::ZXZ)));
3381        graph.add_pipe(Pipe::new(IVec3::Z, Direction::ZPLUS));
3382        graph.validate().unwrap();
3383        let before = graph.to_blog_text();
3384        assert!(matches!(
3385            graph.set_cube_height(IVec3::ZERO, CubeHeight::DEFAULT),
3386            Err(BlockGraphError::BlockNotFound(endpoint)) if endpoint == IVec3::Z
3387        ));
3388        assert_eq!(graph.to_blog_text(), before);
3389        graph.to_zx_graph().unwrap();
3390
3391        // Even an internally corrupted endpoint must fail validation before
3392        // a conversion indexes the missing endpoint.
3393        let existing = graph.get_block(IVec3::ZERO).unwrap().clone();
3394        graph.unindex_reserved_positions(&existing);
3395        let short = existing.with_height(CubeHeight::DEFAULT).unwrap();
3396        graph.index_reserved_positions(&short);
3397        *graph.get_block_mut(IVec3::ZERO).unwrap() = short;
3398        assert!(matches!(
3399            graph.validate_structure(),
3400            Err(BlockGraphError::BlockNotFound(endpoint)) if endpoint == IVec3::Z
3401        ));
3402        graph.to_zx_graph().unwrap_err();
3403    }
3404
3405    #[test]
3406    fn executable_flat_exports_round_trip_linked_symbols_without_collisions() {
3407        for source in [
3408            GalleryItem::PhaseGradientK4.build(),
3409            BlockGraph::from_text(bloq_test::ONE_BIT_ADDER_SOURCE).unwrap(),
3410        ] {
3411            let graph = source
3412                .materialize_root_graph()
3413                .expect("gallery flat projection");
3414            let original_actions = graph.actions();
3415            let text = graph.to_program_blog_text().unwrap();
3416            let reparsed = crate::parse_inline_graph(&text)
3417                .unwrap()
3418                .materialize_flat_graph()
3419                .unwrap();
3420            assert_eq!(reparsed.block_count(), graph.block_count());
3421            assert_eq!(reparsed.actions().len(), graph.actions().len());
3422            assert_eq!(reparsed.to_program_blog_text().unwrap(), text);
3423            assert_eq!(graph.actions(), original_actions);
3424        }
3425
3426        let mut graph = BlockGraph::new();
3427        graph
3428            .set_actions_with_inputs(
3429                vec![
3430                    Action::Let {
3431                        name: "flat0".into(),
3432                        expr: crate::Expr::Var("child__input".into()),
3433                    },
3434                    Action::Let {
3435                        name: "child__value".into(),
3436                        expr: crate::Expr::Not(Box::new(crate::Expr::Var("flat0".into()))),
3437                    },
3438                    Action::DiscardIf(crate::Expr::Var("child__value".into())),
3439                ],
3440                ["child__input".into()],
3441            )
3442            .unwrap();
3443        let text = graph.to_program_blog_text().unwrap();
3444        let program = crate::parse_inline_graph(&text).unwrap();
3445        assert!(text.contains("flat0 = flat1"), "{text}");
3446        assert!(text.contains("flat2 = !flat0"), "{text}");
3447        assert_eq!(program.root().interface.bit_inputs, ["flat1"]);
3448    }
3449
3450    #[test]
3451    fn executable_exports_keep_boolean_and_quantum_names_disjoint() {
3452        for input in ["q0", "source/value", "module"] {
3453            let mut graph = GalleryItem::CNOT
3454                .build()
3455                .materialize_root_graph()
3456                .expect("gallery flat projection");
3457            graph
3458                .set_actions_with_inputs(
3459                    vec![Action::Let {
3460                        name: "readout".into(),
3461                        expr: crate::Expr::Var(input.into()),
3462                    }],
3463                    [input.into()],
3464                )
3465                .unwrap();
3466            let exported = graph.to_program_blog_text().unwrap();
3467            let program = crate::parse_inline_graph(&exported).unwrap();
3468            let [name] = program.root().interface.bit_inputs.as_slice() else {
3469                panic!("one external bit survives export");
3470            };
3471            assert!(
3472                program
3473                    .root()
3474                    .interface
3475                    .quantum_ports
3476                    .iter()
3477                    .all(|port| port.name != *name)
3478            );
3479            assert_eq!(
3480                program.root().local_body().actions(),
3481                vec![Action::Let {
3482                    name: "readout".into(),
3483                    expr: crate::Expr::Var(name.clone()),
3484                }]
3485            );
3486            assert_eq!(graph.action_graph().inputs().collect::<Vec<_>>(), [input]);
3487        }
3488    }
3489
3490    /// A selective block needs a `resolve`, and that `resolve` needs the
3491    /// `measure` its condition names, so an interactively built action list is
3492    /// invalid at every intermediate step. The lenient install has to carry that
3493    /// violation rather than refuse the edit, and clear it once the list is
3494    /// complete.
3495    #[test]
3496    fn lenient_action_install_carries_an_incomplete_list_then_clears_it() {
3497        let mut graph = BlockGraph::default();
3498        graph.add_block(Block::new(IVec3::ZERO, BlockKind::Cube(CubeKind::ZXZ)));
3499        graph.add_block(Block::new(
3500            IVec3::X,
3501            BlockKind::Selective(SelectiveKind::XY),
3502        ));
3503        graph.add_pipe(Pipe::new(IVec3::ZERO, Direction::XPLUS));
3504        let measure = Action::Measure {
3505            target: MeasureTarget::Node(IVec3::ZERO),
3506            name: "m0".into(),
3507        };
3508        let resolve = Action::Resolve {
3509            target: IVec3::X,
3510            condition: crate::Expr::Var("m0".into()),
3511        };
3512
3513        assert!(
3514            graph.set_actions(vec![measure.clone()]).is_err(),
3515            "the strict install is what forces the lenient one to exist"
3516        );
3517
3518        graph
3519            .set_actions_lenient(vec![measure.clone()])
3520            .expect("a valid identifier installs even while the list is incomplete");
3521        assert_eq!(graph.actions(), vec![measure.clone()]);
3522        assert!(
3523            graph.action_graph_error().is_some(),
3524            "the unmet constraint is carried, not dropped"
3525        );
3526
3527        graph
3528            .set_actions_lenient(vec![measure, resolve])
3529            .expect("the completed list installs");
3530        assert!(
3531            graph.action_graph_error().is_none(),
3532            "completing the list clears the carried violation"
3533        );
3534    }
3535
3536    /// An unusable name is a typo, not a stage of construction, so it is refused
3537    /// by both installs.
3538    #[test]
3539    fn lenient_action_install_still_rejects_an_unroundtrippable_name() {
3540        let mut graph = BlockGraph::default();
3541        graph.add_block(Block::new(IVec3::ZERO, BlockKind::Cube(CubeKind::ZXZ)));
3542
3543        let rejected = graph.set_actions_lenient(vec![Action::Measure {
3544            target: MeasureTarget::Node(IVec3::ZERO),
3545            name: "not an identifier".into(),
3546        }]);
3547
3548        assert!(rejected.is_err());
3549        assert!(graph.actions().is_empty());
3550    }
3551
3552    #[cfg(feature = "gltf")]
3553    #[test]
3554    fn export_geometry_uses_editor_orientation_without_reflecting_faces() {
3555        let mut graph = BlockGraph::new();
3556        let position = glam::ivec3(1, 2, 3);
3557        graph.add_block(Block::new(position, BlockKind::Cube(CubeKind::ZXZ)));
3558        let source = block_graph_as_gltf_data_with_popped_faces(&graph, 2.0, &[]);
3559        let exported = graph.build_export_gltf(2.0, None, &[]).unwrap();
3560        let editor_world = |point: glam::Vec3| glam::vec3(point.x, point.z, -point.y);
3561        for (color, triangles) in source.triangles {
3562            let expected: Vec<_> = triangles
3563                .into_iter()
3564                .map(|triangle| triangle.map(editor_world))
3565                .collect();
3566            assert_eq!(exported.triangles[&color], expected);
3567        }
3568    }
3569
3570    #[cfg(feature = "gltf")]
3571    #[test]
3572    fn popped_stabilizer_view_emphasizes_remaining_faces() {
3573        let graph = GalleryItem::CNOT
3574            .build()
3575            .materialize_root_graph()
3576            .expect("gallery flat projection");
3577        let stabilizers = graph.stabilizers().expect("CNOT stabilizers");
3578        let stabilizer = &stabilizers.generators[0];
3579
3580        let transparent = graph
3581            .build_export_gltf(2.0, Some(stabilizer), &[])
3582            .expect("transparent stabilizer view");
3583        let popped = graph
3584            .build_export_gltf(
3585                2.0,
3586                Some(stabilizer),
3587                &[GltfFaceSelector::All(Direction::YPLUS)],
3588            )
3589            .expect("popped stabilizer view");
3590
3591        assert!(transparent.triangles.keys().any(|color| color.a < 255));
3592        assert!(popped.triangles.keys().all(|color| color.a == 255));
3593        assert!(popped.triangles.contains_key(&POPPED_X_FACE_COLOR));
3594        assert!(popped.triangles.contains_key(&POPPED_Z_FACE_COLOR));
3595    }
3596
3597    #[test]
3598    fn to_blog_text_serializes_action_dag_in_ordinal_order() {
3599        let mut graph = BlockGraph::new();
3600        graph.add_block(Block::new(
3601            glam::ivec3(0, 0, 0),
3602            BlockKind::Cube(CubeKind::ZXZ),
3603        ));
3604        graph
3605            .set_actions_lenient(vec![Action::Measure {
3606                target: MeasureTarget::Node(glam::ivec3(0, 0, 0)),
3607                name: "m0".into(),
3608            }])
3609            .unwrap();
3610
3611        let text = graph.to_blog_text();
3612        assert!(text.starts_with("BLOG 1.0\n"));
3613        assert!(text.contains("m0 = measure 0\n"));
3614        assert!(!text.contains("debug"));
3615        assert!(!text.contains("set_time"));
3616    }
3617
3618    #[test]
3619    fn branch_blog_pipes_use_common_and_arm_local_ids() {
3620        let past = IVec3::ZERO;
3621        let inside = IVec3::Z;
3622        let mut graph = BlockGraph::new();
3623        graph.add_block(Block::new(past, BlockKind::Cube(CubeKind::ZXZ)));
3624        graph.add_block(Block::new(
3625            IVec3::new(2, 0, 0),
3626            BlockKind::Cube(CubeKind::ZXZ),
3627        ));
3628        let arm = |tag| {
3629            BranchArm::new(
3630                vec![
3631                    Block::new(inside, BlockKind::Cube(CubeKind::ZXZ)),
3632                    Block::new(2 * inside, BlockKind::Cube(CubeKind::ZXZ)),
3633                ],
3634                vec![
3635                    Pipe::new(past, Direction::ZPLUS).with_tag(tag).unwrap(),
3636                    Pipe::new(inside, Direction::ZPLUS).with_tag(tag).unwrap(),
3637                ],
3638            )
3639        };
3640        let target = graph
3641            .try_add_branch_region("b", arm("false"), arm("true"))
3642            .unwrap();
3643        graph
3644            .set_actions_deferred(vec![
3645                Action::Measure {
3646                    target: MeasureTarget::Node(IVec3::new(2, 0, 0)),
3647                    name: "m".into(),
3648                },
3649                Action::Branch {
3650                    target,
3651                    condition: Expr::Var("m".into()),
3652                },
3653            ])
3654            .unwrap();
3655
3656        let text = graph.to_blog_body_text();
3657        assert!(text.contains("  false {\n    2: ZXZ [0, 0, 1]\n    3: ZXZ [0, 0, 2]\n    0 -> +Z <false>\n    2 -> +Z <false>\n  }"));
3658        assert!(text.contains("  true {\n    4: ZXZ [0, 0, 1]\n    5: ZXZ [0, 0, 2]\n    0 -> +Z <true>\n    4 -> +Z <true>\n  }"));
3659        assert_eq!(
3660            BlockGraph::from_blog_text(&text)
3661                .unwrap()
3662                .to_blog_body_text(),
3663            text
3664        );
3665        let full = graph.to_blog_text();
3666        assert!(full.contains("module main {"));
3667        assert_eq!(BlockGraph::from_text(&full).unwrap().to_blog_text(), full);
3668    }
3669
3670    #[test]
3671    fn to_blog_text_serializes_walking_blocks_with_explicit_end_position() {
3672        let mut graph = BlockGraph::new();
3673        graph.add_block(Block::new(
3674            glam::ivec3(1, 0, 3),
3675            BlockKind::Walking(
3676                WalkingKind::new(WalkingBoundaryKind::ZXZ, glam::ivec2(0, 1)).unwrap(),
3677            ),
3678        ));
3679        graph.add_block(Block::new(glam::ivec3(1, 1, 5), BlockKind::Port));
3680        graph.add_pipe(Pipe::new(glam::ivec3(1, 1, 4), Direction::ZPLUS));
3681
3682        let text = graph.to_blog_text();
3683
3684        assert!(text.contains("0: walk ZXZ [1, 0, 3] -> [1, 1, 4]"));
3685        assert!(text.contains("[1, 1, 4] -> +Z"));
3686        assert!(!text.contains("WZXZ"));
3687    }
3688
3689    #[test]
3690    fn tags_survive_blog_text_round_trip() {
3691        let source = concat!(
3692            "BLOG 1.0\n\n",
3693            "0: ZXZ [0, 0, 0] <if>\n",
3694            "1: ZXZ [1, 0, 0] <R_Z(π/8)^†>\n",
3695            "0 -> +X <(X⊗X)^(1/2)>\n",
3696        );
3697        let graph = BlockGraph::from_blog_text(source).unwrap();
3698        assert_eq!(graph.to_blog_body_text(), source);
3699        let full = graph.to_blog_text();
3700        let restored = BlockGraph::from_text(&full).unwrap();
3701        assert_eq!(restored.to_blog_body_text(), source);
3702        assert_eq!(restored.to_blog_text(), full);
3703    }
3704
3705    #[test]
3706    fn tag_edits_preserve_analyzed_actions() {
3707        let mut graph = GalleryItem::T
3708            .build()
3709            .materialize_root_graph()
3710            .expect("gallery flat projection");
3711        let snapshot = |graph: &BlockGraph| {
3712            (
3713                graph.action_graph().is_analyzed(),
3714                graph
3715                    .action_graph()
3716                    .ordered_nodes()
3717                    .cloned()
3718                    .collect::<Vec<_>>(),
3719                graph.action_graph().dependencies().collect::<Vec<_>>(),
3720            )
3721        };
3722        let actions = snapshot(&graph);
3723        let block = graph
3724            .blocks()
3725            .next()
3726            .expect("gallery graph has blocks")
3727            .pos();
3728        let (src, dst) = graph
3729            .pipes()
3730            .next()
3731            .expect("gallery graph has pipes")
3732            .endpoints();
3733
3734        graph.set_block_tag(block, "block-tag").unwrap();
3735        graph.set_pipe_tag(src, dst, "pipe-tag").unwrap();
3736
3737        assert_eq!(snapshot(&graph), actions);
3738    }
3739
3740    #[test]
3741    fn shift_positions_preserves_action_dag_contents() {
3742        let mut graph = BlockGraph::new();
3743        graph.add_block(Block::new(
3744            glam::ivec3(0, 0, 0),
3745            BlockKind::Cube(CubeKind::ZXZ),
3746        ));
3747        graph
3748            .add_action(Action::Measure {
3749                target: MeasureTarget::Node(glam::ivec3(0, 0, 0)),
3750                name: "m0".into(),
3751            })
3752            .unwrap();
3753
3754        let shifted = graph
3755            .shift_positions(glam::ivec3(3, 0, 1))
3756            .expect("shift fits");
3757        let ordered = shifted
3758            .action_graph()
3759            .ordered_nodes()
3760            .map(|node| node.action.clone())
3761            .collect::<Vec<_>>();
3762        assert_eq!(
3763            ordered,
3764            vec![Action::Measure {
3765                target: MeasureTarget::Node(glam::ivec3(3, 0, 1)),
3766                name: "m0".into(),
3767            }]
3768        );
3769    }
3770
3771    #[test]
3772    fn position_transforms_preserve_external_action_inputs() {
3773        let mut graph = BlockGraph::new();
3774        graph.add_block(Block::new(
3775            glam::ivec3(0, 0, 3),
3776            BlockKind::Cube(CubeKind::ZXZ),
3777        ));
3778        graph
3779            .set_actions_with_inputs(
3780                vec![Action::DiscardIf(crate::Expr::Var("enabled".into()))],
3781                ["enabled".to_string()],
3782            )
3783            .unwrap();
3784
3785        for transformed in [
3786            graph.shift_positions(glam::ivec3(1, 0, 0)).unwrap(),
3787            graph.with_zero_min_z().unwrap(),
3788        ] {
3789            assert!(transformed.action_graph_error().is_none());
3790            assert_eq!(
3791                transformed.action_graph().inputs().collect::<Vec<_>>(),
3792                ["enabled"]
3793            );
3794            transformed.validate().unwrap();
3795        }
3796    }
3797
3798    #[test]
3799    fn set_actions_with_inputs_replaces_declared_inputs() {
3800        let mut graph = BlockGraph::new();
3801        graph
3802            .set_actions_with_inputs(Vec::new(), ["old".to_string()])
3803            .unwrap();
3804
3805        assert!(matches!(
3806            graph.set_actions_with_inputs(
3807                vec![Action::DiscardIf(crate::Expr::Var("old".into()))],
3808                ["new".to_string()],
3809            ),
3810            Err(BlockGraphError::InvalidAction(
3811                InvalidActionError::UndefinedVariable(name)
3812            )) if name == "old"
3813        ));
3814    }
3815
3816    #[test]
3817    fn limited_action_install_restores_actions_and_inputs_after_exhaustion() {
3818        let continuing = crate::parse_inline_graph(include_str!(
3819            "../../docs/fixtures/conditional_cz_strip.blog"
3820        ))
3821        .unwrap()
3822        .flatten()
3823        .unwrap();
3824        assert!(continuing.has_continuing_branches());
3825        for mut graph in [
3826            GalleryItem::CNOT
3827                .build()
3828                .materialize_root_graph()
3829                .expect("gallery flat projection"),
3830            continuing,
3831        ] {
3832            let previous_actions = graph.actions();
3833            let previous_nodes = graph
3834                .action_graph()
3835                .ordered_nodes()
3836                .cloned()
3837                .collect::<Vec<_>>();
3838            let previous_inputs = graph.action_inputs.clone();
3839            let mut actions = previous_actions.clone();
3840            actions.push(Action::Let {
3841                name: "new_value".into(),
3842                expr: crate::Expr::Var("new_input".into()),
3843            });
3844            let mut inputs = previous_inputs.clone();
3845            inputs.insert("new_input".into());
3846            let limits = crate::ModuleCertificationLimits {
3847                max_local_columns: 0,
3848                max_boolean_steps: 0,
3849                ..crate::ModuleCertificationLimits::DEFAULT
3850            };
3851            let error = graph
3852                .set_actions_with_inputs_and_limits(actions.clone(), inputs.clone(), limits)
3853                .unwrap_err();
3854            assert!(matches!(
3855                error,
3856                BlockGraphError::Stabilizer(crate::StabilizerError::ResourceLimited { .. })
3857            ));
3858            assert_eq!(graph.actions(), previous_actions);
3859            assert_eq!(graph.action_inputs, previous_inputs);
3860            assert_eq!(
3861                graph
3862                    .action_graph()
3863                    .ordered_nodes()
3864                    .cloned()
3865                    .collect::<Vec<_>>(),
3866                previous_nodes
3867            );
3868            assert!(graph.action_graph_error().is_none());
3869            graph
3870                .set_actions_with_inputs_and_limits(
3871                    actions.clone(),
3872                    inputs.clone(),
3873                    crate::ModuleCertificationLimits::DEFAULT,
3874                )
3875                .unwrap();
3876            assert_eq!(graph.actions(), actions);
3877            assert_eq!(graph.action_inputs, inputs);
3878        }
3879    }
3880
3881    #[test]
3882    fn block_graph_set_actions_enriches_measurements_before_conversion() {
3883        use crate::{MeasureTarget, MeasurementObservable};
3884        use glam::ivec3;
3885
3886        let mut graph = BlockGraph::new();
3887        graph.add_block(Block::new(ivec3(0, 0, 0), BlockKind::Cube(CubeKind::ZXZ)));
3888        graph
3889            .set_actions(vec![Action::Measure {
3890                target: MeasureTarget::Node(ivec3(0, 0, 0)),
3891                name: "m0".into(),
3892            }])
3893            .unwrap();
3894
3895        assert_eq!(
3896            graph.action_graph().node_by_ordinal(0).unwrap().measurement,
3897            Some(MeasurementObservable::Concrete(crate::PauliBasis::Z))
3898        );
3899        assert!(graph.action_graph().is_analyzed());
3900        assert!(
3901            graph
3902                .action_graph()
3903                .node_by_ordinal(0)
3904                .unwrap()
3905                .measurement_stabilizer
3906                .is_some()
3907        );
3908    }
3909
3910    #[test]
3911    fn deferred_actions_wait_for_physical_analysis() {
3912        let mut graph = GalleryItem::T
3913            .build()
3914            .materialize_root_graph()
3915            .expect("gallery flat projection");
3916        let actions = graph.actions();
3917        graph.clear_actions();
3918
3919        graph.set_actions_deferred(actions).unwrap();
3920        graph.validate_source().unwrap();
3921        assert!(!graph.action_graph().is_analyzed());
3922
3923        let (graph, _) = graph.analyze_actions().unwrap();
3924        assert!(graph.action_graph().is_analyzed());
3925    }
3926
3927    #[test]
3928    fn projected_measurement_must_match_shared_decode() {
3929        let graph = GalleryItem::CCZGateTeleport
3930            .build()
3931            .materialize_root_graph()
3932            .expect("gallery flat projection");
3933        let (_, baseline, projections) = graph.analyze_actions_with_projections().unwrap();
3934        let mut projected = projections[0].1.clone();
3935        let row = projected
3936            .generators
3937            .iter_mut()
3938            .find(|row| row.is_measurement())
3939            .unwrap();
3940        let name = row.measurement_name().unwrap().to_owned();
3941        row.stabilizer.sign ^= true;
3942
3943        assert!(matches!(
3944            validate_shared_measurement_rows(&baseline, &projected),
3945            Err(BlockGraphError::InvalidAction(
3946                InvalidActionError::BranchDependentMeasurementSurface { name: rejected }
3947            )) if rejected == name
3948        ));
3949    }
3950
3951    #[test]
3952    fn projected_measurement_fixings_must_match_shared_decode() {
3953        let (_, baseline) = GalleryItem::PhaseGradientK4
3954            .build()
3955            .materialize_root_graph()
3956            .expect("gallery flat projection")
3957            .analyze_actions()
3958            .unwrap();
3959        let mut projected = baseline.clone();
3960        projected
3961            .generators
3962            .iter_mut()
3963            .find(|row| matches!(row.kind, StabilizerRowKind::SelectiveFixing { .. }))
3964            .unwrap()
3965            .stabilizer
3966            .sign ^= true;
3967
3968        assert!(matches!(
3969            validate_shared_measurement_rows(&baseline, &projected),
3970            Err(BlockGraphError::InvalidAction(
3971                InvalidActionError::BranchDependentMeasurementSurface { .. }
3972            ))
3973        ));
3974    }
3975
3976    #[test]
3977    fn strict_action_install_keeps_previous_list_when_surface_is_impossible() {
3978        let mut graph = GalleryItem::T
3979            .build()
3980            .materialize_root_graph()
3981            .expect("gallery flat projection");
3982        let actions = graph.actions();
3983        for pos in [glam::ivec3(0, 0, 0), glam::ivec3(0, 0, 2)] {
3984            graph.get_block_mut(pos).unwrap().kind = BlockKind::Cube(CubeKind::XZX);
3985        }
3986        graph.clear_actions();
3987
3988        let err = graph.set_actions(actions).unwrap_err();
3989
3990        assert!(matches!(
3991            err,
3992            BlockGraphError::Stabilizer(StabilizerError::MeasurementSurfaceUnavailable {
3993                ref mvar
3994            }) if mvar == "mzz"
3995        ));
3996        assert!(graph.actions().is_empty());
3997    }
3998
3999    #[test]
4000    fn complete_surface_fallback_backtracks_across_cz_records() {
4001        let mut graph = GalleryItem::CZSpatialH
4002            .build()
4003            .materialize_root_graph()
4004            .expect("gallery flat projection");
4005        graph.clear_actions();
4006        let actions = vec![
4007            Action::Measure {
4008                target: MeasureTarget::Node(glam::ivec3(0, 0, 1)),
4009                name: "a".into(),
4010            },
4011            Action::Measure {
4012                target: MeasureTarget::Node(glam::ivec3(1, 0, 1)),
4013                name: "b".into(),
4014            },
4015        ];
4016        let mut greedy_probe = graph.clone();
4017        greedy_probe.set_actions_lenient(actions.clone()).unwrap();
4018        let zx = ZXGraph::from_block_graph_for_analysis(&greedy_probe).unwrap();
4019        assert!(matches!(
4020            zx.to_stabilizer_table(),
4021            Err(StabilizerError::MeasurementSurfaceUnavailable { ref mvar }) if mvar == "b"
4022        ));
4023
4024        graph.set_actions(actions).unwrap();
4025        for (ordinal, pos) in [glam::ivec3(0, 0, 1), glam::ivec3(1, 0, 1)]
4026            .into_iter()
4027            .enumerate()
4028        {
4029            let node = graph.action_graph().node_by_ordinal(ordinal).unwrap();
4030            let Some(MeasurementObservable::Concrete(basis)) = node.measurement else {
4031                panic!("CZ node measurement has a concrete basis");
4032            };
4033            assert_eq!(
4034                node.measurement_stabilizer
4035                    .as_ref()
4036                    .unwrap()
4037                    .interior_nodes
4038                    .get(&pos),
4039                Some(&Pauli::from(basis))
4040            );
4041        }
4042    }
4043
4044    #[test]
4045    fn failed_refresh_after_mutation_records_action_graph_error() {
4046        use glam::ivec3;
4047
4048        let mut graph = BlockGraph::new();
4049        graph.add_block(Block::new(ivec3(0, 0, 0), BlockKind::Cube(CubeKind::ZXZ)));
4050        graph
4051            .set_actions(vec![Action::Measure {
4052                target: MeasureTarget::Node(ivec3(0, 0, 0)),
4053                name: "m0".into(),
4054            }])
4055            .unwrap();
4056        assert!(graph.action_graph_error().is_none());
4057
4058        // Removing the measured block invalidates the action against the new
4059        // topology; the refresh keeps a best-effort DAG and records the error.
4060        graph.remove_block(ivec3(0, 0, 0));
4061        assert!(graph.action_graph_error().is_some());
4062        assert!(graph.has_actions());
4063
4064        graph.clear_actions();
4065        assert!(graph.action_graph_error().is_none());
4066    }
4067
4068    #[test]
4069    fn shift_positions_carries_degradation_instead_of_panicking() {
4070        use glam::ivec3;
4071
4072        let mut graph = BlockGraph::new();
4073        graph.add_block(Block::new(ivec3(0, 0, 0), BlockKind::Cube(CubeKind::ZXZ)));
4074        graph.add_block(Block::new(ivec3(5, 0, 0), BlockKind::Cube(CubeKind::ZXZ)));
4075        graph
4076            .set_actions(vec![Action::Measure {
4077                target: MeasureTarget::Node(ivec3(0, 0, 0)),
4078                name: "m0".into(),
4079            }])
4080            .unwrap();
4081        graph.remove_block(ivec3(0, 0, 0));
4082        assert!(graph.action_graph_error().is_some());
4083
4084        let shifted = graph.shift_positions(ivec3(1, 0, 0)).expect("shift fits");
4085        assert!(shifted.action_graph_error().is_some());
4086        assert!(shifted.has_actions());
4087    }
4088
4089    #[test]
4090    fn shift_positions_translates_actions_in_an_empty_degraded_graph() {
4091        use glam::ivec3;
4092
4093        let mut graph = BlockGraph::new();
4094        graph.add_block(Block::new(ivec3(0, 0, 0), BlockKind::Cube(CubeKind::ZXZ)));
4095        graph
4096            .set_actions(vec![Action::Measure {
4097                target: MeasureTarget::Node(ivec3(0, 0, 0)),
4098                name: "m0".into(),
4099            }])
4100            .unwrap();
4101        graph.remove_block(ivec3(0, 0, 0));
4102        assert!(graph.is_empty());
4103
4104        let shifted = graph.shift_positions(ivec3(4, 0, 0)).expect("shift fits");
4105        assert_eq!(
4106            shifted.actions(),
4107            [Action::Measure {
4108                target: MeasureTarget::Node(ivec3(4, 0, 0)),
4109                name: "m0".into(),
4110            }]
4111        );
4112        assert!(shifted.action_graph_error().is_some());
4113    }
4114
4115    #[test]
4116    fn shift_positions_keeps_lookups_valid_after_removing_lower_block() {
4117        use glam::ivec3;
4118
4119        // Removing the lowest-indexed block leaves the source's petgraph node
4120        // ids non-contiguous; the shift must rebuild lookups from the compacted
4121        // node ids rather than reusing the stale source ids.
4122        let mut graph = BlockGraph::new();
4123        graph.add_block(Block::new(ivec3(0, 0, 0), BlockKind::Cube(CubeKind::ZXZ)));
4124        graph.add_block(Block::new(ivec3(1, 0, 0), BlockKind::Cube(CubeKind::ZXZ)));
4125        graph.add_block(Block::new(ivec3(2, 0, 0), BlockKind::Cube(CubeKind::ZXZ)));
4126        graph.remove_block(ivec3(0, 0, 0));
4127
4128        let mut shifted = graph.shift_positions(ivec3(10, 0, 0)).expect("shift fits");
4129
4130        assert_eq!(
4131            shifted.get_block(ivec3(11, 0, 0)).unwrap().pos,
4132            ivec3(11, 0, 0)
4133        );
4134        assert_eq!(
4135            shifted.get_block(ivec3(12, 0, 0)).unwrap().pos,
4136            ivec3(12, 0, 0)
4137        );
4138        assert!(shifted.get_block(ivec3(13, 0, 0)).is_none());
4139
4140        shifted
4141            .try_add_pipe(Pipe::new(ivec3(11, 0, 0), Direction::XPLUS))
4142            .unwrap();
4143        assert!(shifted.has_pipe_between(ivec3(11, 0, 0), ivec3(12, 0, 0)));
4144    }
4145
4146    #[test]
4147    fn validate_and_to_zx_graph_rebuild_measurements_after_kind_mutation() {
4148        use glam::ivec3;
4149
4150        let mut graph = BlockGraph::new();
4151        graph.add_block(Block::new(ivec3(0, 0, 0), BlockKind::Cube(CubeKind::ZXZ)));
4152        graph
4153            .set_actions(vec![Action::Measure {
4154                target: MeasureTarget::Node(ivec3(0, 0, 0)),
4155                name: "m0".into(),
4156            }])
4157            .unwrap();
4158
4159        graph
4160            .set_block_kind(ivec3(0, 0, 0), BlockKind::Cube(CubeKind::XZX))
4161            .unwrap();
4162
4163        assert_eq!(
4164            graph.action_graph().node_by_ordinal(0).unwrap().measurement,
4165            Some(MeasurementObservable::Concrete(crate::PauliBasis::X))
4166        );
4167        graph.validate().unwrap();
4168        let zx = graph.to_zx_graph().unwrap();
4169
4170        assert_eq!(
4171            zx.action_graph().node_by_ordinal(0).unwrap().measurement,
4172            Some(MeasurementObservable::Concrete(crate::PauliBasis::X))
4173        );
4174    }
4175
4176    #[test]
4177    fn validate_structure_ignores_action_semantic_errors() {
4178        use glam::ivec3;
4179
4180        let measured = ivec3(0, 0, 0);
4181        let src = ivec3(2, 0, 0);
4182        let dst = ivec3(3, 0, 0);
4183        let mut graph = BlockGraph::new();
4184        graph.add_block(Block::new(measured, BlockKind::Cube(CubeKind::ZXZ)));
4185        graph.add_block(Block::new(src, BlockKind::Cube(CubeKind::ZXZ)));
4186        graph.add_block(Block::new(dst, BlockKind::Cube(CubeKind::ZXZ)));
4187        graph.add_pipe(Pipe::new(src, Direction::XPLUS));
4188        graph
4189            .set_actions(vec![Action::Measure {
4190                target: MeasureTarget::Node(measured),
4191                name: "m0".into(),
4192            }])
4193            .unwrap();
4194
4195        graph.remove_block(measured);
4196
4197        graph.validate_structure().unwrap();
4198        assert!(graph.validate().is_err());
4199    }
4200
4201    #[test]
4202    fn set_pipe_hadamard_refreshes_cached_edge_measurement_observable() {
4203        let mut graph = BlockGraph::new();
4204        graph.add_block(Block::new(
4205            IVec3::new(0, 0, -1),
4206            BlockKind::Cube(CubeKind::ZXZ),
4207        ));
4208        graph.add_block(Block::new(
4209            IVec3::new(0, 0, 0),
4210            BlockKind::Cube(CubeKind::ZXZ),
4211        ));
4212        graph.add_block(Block::new(
4213            IVec3::new(0, 0, 1),
4214            BlockKind::Cube(CubeKind::ZXZ),
4215        ));
4216        graph.add_block(Block::new(
4217            IVec3::new(1, 0, 0),
4218            BlockKind::Cube(CubeKind::ZXZ),
4219        ));
4220        graph.add_pipe(Pipe::new(IVec3::new(0, 0, -1), Direction::ZPLUS));
4221        graph.add_pipe(Pipe::new(IVec3::new(0, 0, 0), Direction::ZPLUS));
4222        graph.add_pipe(Pipe::new(IVec3::new(0, 0, 0), Direction::XPLUS));
4223        graph
4224            .set_actions_lenient(vec![Action::Measure {
4225                target: MeasureTarget::Edge {
4226                    src: IVec3::new(0, 0, 0),
4227                    dir: Direction::XPLUS,
4228                },
4229                name: "mx".into(),
4230            }])
4231            .unwrap();
4232
4233        assert_eq!(
4234            graph.action_graph().node_by_ordinal(0).unwrap().measurement,
4235            Some(MeasurementObservable::Concrete(crate::PauliBasis::X))
4236        );
4237
4238        graph
4239            .set_pipe_hadamard(IVec3::new(0, 0, 0), IVec3::new(1, 0, 0), true)
4240            .expect("pipe exists");
4241
4242        assert_eq!(
4243            graph.action_graph().node_by_ordinal(0).unwrap().measurement,
4244            Some(MeasurementObservable::Concrete(crate::PauliBasis::Z))
4245        );
4246    }
4247
4248    #[test]
4249    fn test_randomly_resolve_selectives_returns_replacement_map() {
4250        let graph = GalleryItem::CCZFactoryWithTels
4251            .build()
4252            .materialize_root_graph()
4253            .expect("gallery flat projection");
4254        let (resolved, replacements) = graph.randomly_resolve_selectives(rand::random()).unwrap();
4255
4256        assert!(resolved.blocks().all(|block| !block.kind.is_selective()));
4257        assert!(!resolved.has_actions());
4258        assert!(!replacements.is_empty());
4259        for (original, current) in replacements {
4260            assert!(original.kind.is_selective());
4261            assert!(!current.kind.is_selective());
4262            assert_eq!(original.pos, current.pos);
4263            assert_eq!(original.tag, current.tag);
4264            assert_eq!(resolved.get_block(current.pos), Some(&current));
4265        }
4266    }
4267
4268    #[test]
4269    fn randomly_resolve_selectives_samples_joint_controls_once() {
4270        let graph = GalleryItem::ToffoliFromAndDelayedCZ
4271            .build()
4272            .materialize_root_graph()
4273            .expect("gallery flat projection");
4274        let joint = [IVec3::new(0, 1, 5), IVec3::new(1, 0, 5)];
4275        let mut seen = HashSet::new();
4276
4277        let limit = 2usize
4278            .saturating_pow(u32::try_from(joint.len()).unwrap_or(u32::MAX))
4279            .min(crate::ModuleCertificationLimits::DEFAULT.max_guarded_domain_size);
4280        let domain = graph
4281            .action_graph()
4282            .resolve_value_domain_bounded(&joint, limit)
4283            .expect("two joint sites have at most four resolve tuples");
4284        assert_eq!(domain.values(), &[vec![false, false], vec![true, true]]);
4285
4286        for seed in 0..64 {
4287            let (_, replacements) = graph.randomly_resolve_selectives(seed).unwrap();
4288            let choices = joint.map(|pos| {
4289                let original = graph.get_block(pos).expect("joint selective exists");
4290                let resolved = &replacements[original];
4291                let BlockKind::Selective(kind) = original.kind else {
4292                    panic!("joint site is selective");
4293                };
4294                resolved.kind == graph.resolved_selective_kind_for_basis(kind.pauli_if_true())
4295            });
4296            assert_eq!(
4297                choices[0], choices[1],
4298                "seed {seed} split sites controlled by the same mxy value",
4299            );
4300            seen.insert(choices[0]);
4301        }
4302
4303        assert_eq!(seen, HashSet::from([false, true]));
4304    }
4305
4306    #[test]
4307    fn layer_graph_discards_actions() {
4308        let mut graph = BlockGraph::new();
4309        graph.add_block(Block::new(
4310            IVec3::new(0, 0, 0),
4311            BlockKind::Cube(CubeKind::ZXZ),
4312        ));
4313        graph.add_block(Block::new(
4314            IVec3::new(0, 0, 1),
4315            BlockKind::Cube(CubeKind::ZXZ),
4316        ));
4317        graph.add_pipe(Pipe::new(IVec3::new(0, 0, 0), Direction::ZPLUS));
4318        graph
4319            .add_action(Action::Measure {
4320                target: MeasureTarget::Node(IVec3::new(0, 0, 1)),
4321                name: "m1".into(),
4322            })
4323            .unwrap();
4324
4325        let layer = graph.layer(0).into_graph();
4326        assert!(!layer.has_actions());
4327        assert!(layer.block_count() > 0);
4328    }
4329
4330    #[test]
4331    fn block_layer_view_materializes_boundary_ports() {
4332        let graph = hand_built_layer_graph();
4333        let view_graph = graph.layer(0).into_graph();
4334
4335        let expected_blocks = vec![
4336            (IVec3::new(0, 0, -1), BlockKind::Port),
4337            (IVec3::new(0, 0, 0), BlockKind::Cube(CubeKind::ZXZ)),
4338            (IVec3::new(1, 0, 0), BlockKind::Cube(CubeKind::ZXZ)),
4339            (IVec3::new(1, 0, 1), BlockKind::Port),
4340        ];
4341        let expected_pipes = vec![
4342            (IVec3::new(0, 0, -1), IVec3::new(0, 0, 0), false),
4343            (IVec3::new(0, 0, 0), IVec3::new(1, 0, 0), false),
4344            (IVec3::new(1, 0, 0), IVec3::new(1, 0, 1), false),
4345        ];
4346
4347        assert_eq!(view_graph.block_count(), 4);
4348        assert_eq!(view_graph.pipe_count(), 3);
4349        assert_eq!(block_signatures(&view_graph), expected_blocks);
4350        assert_eq!(pipe_signatures(&view_graph), expected_pipes);
4351    }
4352
4353    #[test]
4354    fn scaled_cube_occupies_multiple_time_cells_and_exposes_top_endpoint() {
4355        let mut graph = BlockGraph::new();
4356        let block = Block::new(IVec3::ZERO, BlockKind::Cube(CubeKind::ZXZ))
4357            .with_height("3d".parse().expect("valid height"))
4358            .unwrap();
4359        graph.add_block(block);
4360
4361        assert_eq!(
4362            graph.occupied_positions().collect::<Vec<_>>(),
4363            vec![
4364                IVec3::new(0, 0, 0),
4365                IVec3::new(0, 0, 1),
4366                IVec3::new(0, 0, 2)
4367            ]
4368        );
4369        assert!(graph.has_endpoint_at(IVec3::new(0, 0, 2)));
4370        assert_eq!(
4371            graph
4372                .get_block(IVec3::ZERO)
4373                .expect("scaled cube")
4374                .endpoint_for_direction(Direction::ZPLUS),
4375            IVec3::new(0, 0, 2)
4376        );
4377        assert_eq!(graph.spans().expect("non-empty graph has spans").2, 0..=2);
4378        assert_eq!(graph.layer(1).blocks().count(), 1);
4379    }
4380
4381    #[test]
4382    fn scaled_cube_footprint_blocks_overlapping_placement() {
4383        let mut graph = BlockGraph::new();
4384        graph.add_block(
4385            Block::new(IVec3::ZERO, BlockKind::Cube(CubeKind::ZXZ))
4386                .with_height("2d".parse().expect("valid height"))
4387                .unwrap(),
4388        );
4389
4390        assert!(matches!(
4391            graph.try_add_block(Block::new(
4392                IVec3::new(0, 0, 1),
4393                BlockKind::Cube(CubeKind::ZXZ)
4394            )),
4395            Err(BlockGraphError::BlockPositionOccupied(pos))
4396                if pos == IVec3::new(0, 0, 1)
4397        ));
4398    }
4399
4400    #[test]
4401    fn max_height_cube_placement_is_linear_in_footprint_size() {
4402        let mut graph = BlockGraph::new();
4403        graph
4404            .try_add_block(
4405                Block::new(IVec3::ZERO, BlockKind::Cube(CubeKind::ZXZ))
4406                    .with_height(CubeHeight::new(crate::MAX_CUBE_HEIGHT_CELLS, 1, 0).unwrap())
4407                    .unwrap(),
4408            )
4409            .unwrap();
4410
4411        graph
4412            .try_add_block(
4413                Block::new(IVec3::X, BlockKind::Cube(CubeKind::ZXZ))
4414                    .with_height(CubeHeight::new(crate::MAX_CUBE_HEIGHT_CELLS, 1, 0).unwrap())
4415                    .unwrap(),
4416            )
4417            .expect("disjoint maximum-height footprints place without a quadratic scan");
4418    }
4419
4420    #[test]
4421    fn graph_ingress_rejects_coordinate_overflow() {
4422        let walking = WalkingKind::new(WalkingBoundaryKind::ZXZ, glam::ivec2(1, 0)).unwrap();
4423        let mut graph = BlockGraph::new();
4424        let position = IVec3::new(0, 0, i32::MAX);
4425        assert!(matches!(
4426            graph.try_add_block(Block::new(position, BlockKind::Walking(walking))),
4427            Err(BlockGraphError::CoordinateOverflow { position: p, .. }) if p == position
4428        ));
4429
4430        let pipe = Pipe::new(IVec3::new(i32::MAX, 0, 0), Direction::XPLUS);
4431        assert!(matches!(
4432            graph.try_add_pipe(pipe),
4433            Err(BlockGraphError::CoordinateOverflow { .. })
4434        ));
4435
4436        assert!(matches!(
4437            graph.add_action(Action::Measure {
4438                target: MeasureTarget::Edge {
4439                    src: IVec3::new(i32::MAX, 0, 0),
4440                    dir: Direction::XPLUS,
4441                },
4442                name: "overflow".into(),
4443            }),
4444            Err(BlockGraphError::CoordinateOverflow { .. })
4445        ));
4446    }
4447
4448    #[test]
4449    fn graph_transforms_report_coordinate_overflow() {
4450        let mut graph = BlockGraph::new();
4451        graph
4452            .try_add_block(Block::new(IVec3::new(i32::MAX, 0, 0), BlockKind::Port))
4453            .unwrap();
4454        assert!(matches!(
4455            graph.shift_positions(IVec3::X),
4456            Err(BlockGraphError::CoordinateOverflow { .. })
4457        ));
4458
4459        let mut rotation = BlockGraph::new();
4460        rotation
4461            .try_add_block(Block::new(IVec3::new(i32::MIN, 0, 0), BlockKind::Port))
4462            .unwrap();
4463        assert!(matches!(
4464            rotation.rotate_about_origin(UDirection::Y, 1),
4465            Err(BlockGraphError::CoordinateRotationOverflow { .. })
4466        ));
4467    }
4468
4469    #[test]
4470    fn zero_min_z_normalizes_i32_min_and_preserves_graph_content() {
4471        let mut at_min = BlockGraph::new();
4472        at_min
4473            .try_add_block(Block::new(IVec3::new(0, 0, i32::MIN), BlockKind::Port))
4474            .unwrap();
4475        at_min
4476            .try_add_block(Block::new(
4477                IVec3::new(0, 0, i32::MIN + 1),
4478                BlockKind::Cube(CubeKind::ZXZ),
4479            ))
4480            .unwrap();
4481        at_min
4482            .try_add_pipe(Pipe::new(IVec3::new(0, 0, i32::MIN), Direction::ZPLUS))
4483            .unwrap();
4484        at_min
4485            .add_action(Action::Measure {
4486                target: MeasureTarget::Node(IVec3::new(0, 0, i32::MIN + 1)),
4487                name: "m".into(),
4488            })
4489            .unwrap();
4490
4491        let normalized = at_min.with_zero_min_z().expect("two-layer span fits");
4492        assert_eq!(
4493            normalized.positions().collect::<HashSet<_>>(),
4494            HashSet::from([IVec3::ZERO, IVec3::Z])
4495        );
4496        assert!(normalized.has_pipe_between(IVec3::ZERO, IVec3::Z));
4497        assert_eq!(
4498            normalized.actions(),
4499            [Action::Measure {
4500                target: MeasureTarget::Node(IVec3::Z),
4501                name: "m".into(),
4502            }]
4503        );
4504    }
4505
4506    #[test]
4507    fn zero_min_z_rejects_unrepresentable_span() {
4508        let mut too_wide = BlockGraph::new();
4509        too_wide
4510            .try_add_block(Block::new(
4511                IVec3::new(0, 0, -2_000_000_000),
4512                BlockKind::Cube(CubeKind::ZXZ),
4513            ))
4514            .unwrap();
4515        too_wide
4516            .try_add_block(Block::new(
4517                IVec3::new(0, 0, 2_000_000_000),
4518                BlockKind::Cube(CubeKind::ZXZ),
4519            ))
4520            .unwrap();
4521        assert!(matches!(
4522            too_wide.with_zero_min_z(),
4523            Err(BlockGraphError::CoordinateNormalizationOverflow {
4524                min_z: -2_000_000_000,
4525                max_z: 2_000_000_000,
4526            })
4527        ));
4528    }
4529
4530    #[test]
4531    fn three_quarter_turns_do_not_require_unrepresentable_intermediate_turns() {
4532        let rotate = |axis, position| {
4533            crate::ModuleRotation::new(axis, 3)
4534                .try_rotate_position(position)
4535                .unwrap()
4536        };
4537        assert_eq!(
4538            rotate(UDirection::X, IVec3::new(0, 0, i32::MIN)),
4539            IVec3::new(0, i32::MIN, 0)
4540        );
4541        assert_eq!(
4542            rotate(UDirection::Y, IVec3::new(i32::MIN, 0, 0)),
4543            IVec3::new(0, 0, i32::MIN)
4544        );
4545        assert_eq!(
4546            rotate(UDirection::Z, IVec3::new(0, i32::MIN, 0)),
4547            IVec3::new(i32::MIN, 0, 0)
4548        );
4549    }
4550
4551    #[test]
4552    fn layer_graph_materializes_scaled_cube_slice_on_requested_layer() {
4553        let mut graph = BlockGraph::new();
4554        graph.add_block(
4555            Block::new(IVec3::ZERO, BlockKind::Cube(CubeKind::ZXZ))
4556                .with_height("3d".parse().expect("valid height"))
4557                .unwrap(),
4558        );
4559        graph.add_block(Block::new(IVec3::new(0, 0, 3), BlockKind::Port));
4560        graph.add_pipe(Pipe::new(IVec3::new(0, 0, 2), Direction::ZPLUS));
4561
4562        let layer = graph.layer(2).into_graph();
4563        let block = layer
4564            .get_block(IVec3::new(0, 0, 2))
4565            .expect("scaled cube slice appears on occupied layer");
4566
4567        assert_eq!(block.height_cells(), 1);
4568        assert_eq!(
4569            layer.occupied_positions().collect::<Vec<_>>(),
4570            vec![IVec3::new(0, 0, 2), IVec3::new(0, 0, 3)]
4571        );
4572        assert!(layer.has_pipe_between(IVec3::new(0, 0, 2), IVec3::new(0, 0, 3)));
4573    }
4574
4575    #[test]
4576    fn extreme_layer_queries_do_not_overflow() {
4577        let mut graph = BlockGraph::new();
4578        graph.add_block(Block::new(IVec3::ZERO, BlockKind::Port));
4579        graph.add_block(Block::new(IVec3::Z, BlockKind::Port));
4580        graph.add_pipe(Pipe::new(IVec3::ZERO, Direction::ZPLUS));
4581
4582        assert_eq!(graph.layer(i32::MIN).boundary_pipes().count(), 0);
4583        assert_eq!(graph.layer(i32::MAX).boundary_pipes().count(), 0);
4584    }
4585
4586    #[test]
4587    fn boundary_pipe_basis_inference_does_not_wrap_perpendicular_neighbors() {
4588        let lower = IVec3::new(i32::MAX, 0, 0);
4589        let upper = IVec3::new(i32::MAX, 0, 1);
4590        let mut graph = BlockGraph::new();
4591        graph
4592            .try_add_block(Block::new(lower, BlockKind::Port))
4593            .unwrap();
4594        graph
4595            .try_add_block(Block::new(upper, BlockKind::Cube(CubeKind::ZXZ)))
4596            .unwrap();
4597        graph
4598            .try_add_pipe(Pipe::new(lower, Direction::ZPLUS))
4599            .unwrap();
4600
4601        graph.validate().expect("boundary pipe remains valid");
4602        let pipe = graph.pipes().next().unwrap();
4603        assert!(
4604            graph
4605                .infer_pipe_endpoint_face_bases(pipe, upper)
4606                .into_iter()
4607                .any(|basis| basis.is_some())
4608        );
4609
4610        let invalid = Pipe::new(lower, Direction::XPLUS);
4611        assert_eq!(graph.infer_pipe_basis(&invalid), [None; 3]);
4612        assert_eq!(
4613            graph.infer_pipe_basis_from_endpoint(&invalid, lower),
4614            [None; 3]
4615        );
4616    }
4617
4618    #[test]
4619    fn incident_pipes_follow_extended_block_ownership() {
4620        let mut graph = hand_built_layer_graph();
4621        let anchor = IVec3::new(4, 0, 0);
4622        graph.add_block(
4623            Block::new(anchor, BlockKind::Cube(CubeKind::ZXZ))
4624                .with_height("3d".parse().unwrap())
4625                .unwrap(),
4626        );
4627        graph.add_block(Block::new(
4628            anchor + 3 * IVec3::Z,
4629            BlockKind::Cube(CubeKind::ZXZ),
4630        ));
4631        graph.add_pipe(Pipe::new(anchor + 2 * IVec3::Z, Direction::ZPLUS));
4632        for position in graph.positions().chain([IVec3::new(99, 0, 0)]) {
4633            let mut expected: Vec<_> = graph
4634                .pipes()
4635                .filter(|pipe| {
4636                    [pipe.src(), pipe.dst()].into_iter().any(|endpoint| {
4637                        graph
4638                            .get_endpoint_block(endpoint)
4639                            .is_some_and(|block| block.pos() == position)
4640                    })
4641                })
4642                .map(|pipe| (pipe.src().to_array(), pipe.dst().to_array()))
4643                .collect();
4644            let mut actual: Vec<_> = graph
4645                .pipes_at(position)
4646                .map(|pipe| (pipe.src().to_array(), pipe.dst().to_array()))
4647                .collect();
4648            expected.sort_unstable();
4649            actual.sort_unstable();
4650            assert_eq!(actual, expected);
4651        }
4652    }
4653
4654    #[test]
4655    fn spatial_port_inference_matches_first_live_pipe_scan() {
4656        let mut graph = BlockGraph::new();
4657        let port = IVec3::ZERO;
4658        graph.add_block(Block::new(port, BlockKind::Port));
4659        for position in [IVec3::X, -IVec3::X, IVec3::Z, 10 * IVec3::X, 11 * IVec3::X] {
4660            graph.add_block(Block::new(position, BlockKind::Cube(CubeKind::ZXZ)));
4661        }
4662        graph.add_block(Block::new(IVec3::Y, BlockKind::Cube(CubeKind::XZZ)));
4663        graph.add_pipe(Pipe::new(10 * IVec3::X, Direction::XPLUS));
4664        graph.add_pipe(Pipe::new(port, Direction::XPLUS).with_hadamard());
4665        graph.add_pipe(Pipe::new(port, Direction::ZPLUS));
4666        graph.add_pipe(Pipe::new(-IVec3::X, Direction::XPLUS));
4667        graph.add_pipe(Pipe::new(port, Direction::YPLUS));
4668
4669        let check = |graph: &BlockGraph, expected| {
4670            assert_eq!(graph.infer_spatial_port_cube_kind(port), expected);
4671        };
4672
4673        check(&graph, Some(CubeKind::XZX));
4674        graph.remove_pipe(port, IVec3::X).unwrap();
4675        check(&graph, None);
4676        graph.remove_pipe(port, IVec3::Z).unwrap();
4677        check(&graph, Some(CubeKind::ZXZ));
4678        graph.remove_pipe(port, -IVec3::X).unwrap();
4679        check(&graph, Some(CubeKind::XZZ));
4680        graph.remove_pipe(port, IVec3::Y).unwrap();
4681        check(&graph, None);
4682    }
4683
4684    #[test]
4685    fn layer_graph_projects_scaled_cube_spatial_pipe_to_requested_layer() {
4686        let mut graph = BlockGraph::new();
4687        graph.add_block(
4688            Block::new(IVec3::ZERO, BlockKind::Cube(CubeKind::ZXZ))
4689                .with_height("3d".parse().expect("valid height"))
4690                .unwrap(),
4691        );
4692        graph.add_block(
4693            Block::new(IVec3::X, BlockKind::Cube(CubeKind::ZXZ))
4694                .with_height("3d".parse().expect("valid height"))
4695                .unwrap(),
4696        );
4697        graph.add_pipe(Pipe::new(IVec3::ZERO, Direction::XPLUS));
4698
4699        let layer = graph.layer(2).into_graph();
4700
4701        assert!(layer.has_pipe_between(IVec3::new(0, 0, 2), IVec3::new(1, 0, 2)));
4702    }
4703
4704    fn hand_built_layer_graph() -> BlockGraph {
4705        let mut graph = BlockGraph::new();
4706        graph.add_block(Block::new(
4707            IVec3::new(0, 0, -1),
4708            BlockKind::Cube(CubeKind::ZXZ),
4709        ));
4710        graph.add_block(Block::new(
4711            IVec3::new(0, 0, 0),
4712            BlockKind::Cube(CubeKind::ZXZ),
4713        ));
4714        graph.add_block(Block::new(
4715            IVec3::new(1, 0, 0),
4716            BlockKind::Cube(CubeKind::ZXZ),
4717        ));
4718        graph.add_block(Block::new(
4719            IVec3::new(1, 0, 1),
4720            BlockKind::Cube(CubeKind::ZXZ),
4721        ));
4722        graph.add_pipe(Pipe::new(IVec3::new(0, 0, -1), Direction::ZPLUS));
4723        graph.add_pipe(Pipe::new(IVec3::new(0, 0, 0), Direction::XPLUS));
4724        graph.add_pipe(Pipe::new(IVec3::new(1, 0, 0), Direction::ZPLUS));
4725        graph
4726    }
4727
4728    fn block_signatures(graph: &BlockGraph) -> Vec<(IVec3, BlockKind)> {
4729        let mut blocks = graph
4730            .blocks()
4731            .map(|block| (block.pos, block.kind))
4732            .collect::<Vec<_>>();
4733        blocks.sort_by_key(|(pos, kind)| (pos.to_array(), *kind));
4734        blocks
4735    }
4736
4737    fn pipe_signatures(graph: &BlockGraph) -> Vec<(IVec3, IVec3, bool)> {
4738        let mut pipes = graph
4739            .block_pairs_with_pipe()
4740            .map(|(_, _, pipe)| {
4741                let (src, dst) = pipe.endpoints();
4742                let (src, dst) = if src.to_array() <= dst.to_array() {
4743                    (src, dst)
4744                } else {
4745                    (dst, src)
4746                };
4747                (src, dst, pipe.hadamard)
4748            })
4749            .collect::<Vec<_>>();
4750        pipes.sort_by_key(|(src, dst, hadamard)| (src.to_array(), dst.to_array(), *hadamard));
4751        pipes
4752    }
4753
4754    #[test]
4755    fn test_selective_resolution_uses_selected_basis() {
4756        let graph = BlockGraph::new();
4757        for (basis, expected) in [
4758            (PauliBasis::X, BlockKind::Measurement(Basis::X)),
4759            (PauliBasis::Y, BlockKind::Y),
4760            (PauliBasis::Z, BlockKind::Measurement(Basis::Z)),
4761        ] {
4762            assert_eq!(graph.resolved_selective_kind_for_basis(basis), expected);
4763        }
4764    }
4765
4766    #[test]
4767    fn fixed_measurement_actions_use_the_declared_basis() {
4768        for basis in [Basis::X, Basis::Z] {
4769            let mut graph = BlockGraph::new();
4770            graph.add_block(Block::new(IVec3::ZERO, BlockKind::Measurement(basis)));
4771
4772            assert_eq!(
4773                graph
4774                    .resolve_measurement_observable(&MeasureTarget::Node(IVec3::ZERO))
4775                    .unwrap(),
4776                MeasurementObservable::Concrete(basis.into()),
4777            );
4778        }
4779    }
4780
4781    #[test]
4782    fn rotate_about_origin_lenient_rotates_graph_and_actions() {
4783        let mut graph = BlockGraph::from_blog_text(
4784            "BLOG 1.0\n\n  0: ZXZ [0, 0, 0]\n  1: ZXZ [0, 0, 1]\n  [0, 0, 0] -> +Z\n",
4785        )
4786        .expect("inline test graph is valid");
4787        graph
4788            .set_actions_lenient(vec![Action::Measure {
4789                target: MeasureTarget::Node(IVec3::Z),
4790                name: "m0".to_string(),
4791            }])
4792            .unwrap();
4793
4794        let rotated = graph
4795            .rotate_about_origin_lenient(UDirection::X, 1)
4796            .expect("rotation succeeds");
4797
4798        assert!(rotated.has_block_at(IVec3::new(0, 0, 0)));
4799        assert!(rotated.has_block_at(IVec3::new(0, -1, 0)));
4800        assert!(rotated.has_pipe_between(IVec3::new(0, 0, 0), IVec3::new(0, -1, 0)));
4801        assert_eq!(
4802            rotated
4803                .get_block(IVec3::new(0, 0, 0))
4804                .map(|block| block.kind),
4805            Some(BlockKind::Cube(CubeKind::ZZX))
4806        );
4807        assert_eq!(
4808            rotated
4809                .get_block(IVec3::new(0, -1, 0))
4810                .map(|block| block.kind),
4811            Some(BlockKind::Cube(CubeKind::ZZX))
4812        );
4813        assert_eq!(
4814            rotated.actions(),
4815            &[Action::Measure {
4816                target: MeasureTarget::Node(IVec3::NEG_Y),
4817                name: "m0".to_string(),
4818            }]
4819        );
4820    }
4821
4822    #[test]
4823    fn rotate_about_origin_rotates_walking_boundary_around_time_axis() {
4824        let graph = GalleryItem::GHZSlideThenGlide
4825            .build()
4826            .materialize_root_graph()
4827            .expect("gallery flat projection");
4828
4829        let rotated = graph
4830            .rotate_about_origin(UDirection::Z, 1)
4831            .expect("walking gallery should rotate around time axis");
4832
4833        assert_eq!(
4834            rotated
4835                .get_block(IVec3::new(0, 0, 1))
4836                .map(|block| block.kind),
4837            Some(BlockKind::Walking(
4838                WalkingKind::new(WalkingBoundaryKind::XZZ, glam::ivec2(0, 1)).unwrap()
4839            ))
4840        );
4841        assert!(rotated.has_pipe_between(IVec3::new(0, 0, 0), IVec3::new(0, 0, 1)));
4842    }
4843
4844    #[test]
4845    fn rotate_about_origin_rotates_patch_rotation_construction_basis() {
4846        let kind = PatchRotationKind::new(Basis::X, glam::ivec2(0, 1)).unwrap();
4847        let mut graph = BlockGraph::new();
4848        graph.add_block(Block::new(IVec3::ZERO, BlockKind::PatchRotation(kind)));
4849        graph.add_block(Block::new(IVec3::NEG_Z, BlockKind::Port));
4850        graph.add_block(Block::new(IVec3::new(0, 1, 2), BlockKind::Port));
4851        graph.add_pipe(Pipe::new(IVec3::ZERO, Direction::ZMINUS));
4852        graph.add_pipe(Pipe::new(kind.end_position(IVec3::ZERO), Direction::ZPLUS));
4853
4854        let rotated = graph
4855            .rotate_about_origin(UDirection::Z, 1)
4856            .expect("patch rotation should rotate around time axis");
4857
4858        let rotated_kind = match rotated
4859            .get_block(IVec3::ZERO)
4860            .expect("rotated patch rotation remains at origin")
4861            .kind()
4862        {
4863            BlockKind::PatchRotation(rotated_kind) => rotated_kind,
4864            other => panic!("expected patch rotation, got {other:?}"),
4865        };
4866        assert_eq!(rotated_kind.movement(), glam::ivec2(-1, 0));
4867        assert_eq!(rotated_kind.basis(), kind.basis().flip());
4868    }
4869
4870    #[test]
4871    fn rotate_about_origin_requires_half_turns_for_y_graphs() {
4872        let graph = GalleryItem::YMemory
4873            .build()
4874            .materialize_root_graph()
4875            .expect("gallery flat projection");
4876
4877        assert!(matches!(
4878            graph.rotate_about_origin(UDirection::X, 1),
4879            Err(BlockGraphError::RotationRequiresHalfTurns)
4880        ));
4881        graph.rotate_about_origin(UDirection::X, 2).unwrap();
4882    }
4883
4884    #[test]
4885    fn rotate_about_origin_only_rotates_dynamic_graphs_around_time() {
4886        let graph = GalleryItem::T
4887            .build()
4888            .materialize_root_graph()
4889            .expect("gallery flat projection");
4890
4891        graph.rotate_about_origin(UDirection::Z, 1).unwrap();
4892        assert!(matches!(
4893            graph.rotate_about_origin(UDirection::X, 1),
4894            Err(BlockGraphError::RotationUnsupportedDynamicBlocks)
4895        ));
4896    }
4897
4898    /// The input port makes both selective arms reachable before and after X/Z flip.
4899    #[test]
4900    fn flip_xz_basis_preserves_metadata_and_flips_selective_basis() {
4901        let graph = BlockGraph::from_blog_text(
4902            "BLOG 1.0\n\n  0: Port [0, 0, 0] <source>\n\
4903             1: XY [0, 0, 1] <selective>\n\
4904             2: ZXZ [2, 0, 0]\n\
4905             3: ZXZ [2, 0, 1]\n\
4906             [0, 0, 0] -H> +Z <link>\n\
4907             [2, 0, 0] -> +Z\n\n  m0 = measure 3\n  resolve 1 if m0\n",
4908        )
4909        .unwrap();
4910
4911        let flipped = graph.flip_xz_basis().expect("basis flip succeeds");
4912
4913        assert_eq!(
4914            flipped
4915                .get_block(IVec3::new(0, 0, 0))
4916                .map(|block| (&block.kind, block.tag.as_str(),)),
4917            Some((&BlockKind::Port, "source"))
4918        );
4919        assert_eq!(
4920            flipped
4921                .get_block(IVec3::new(0, 0, 1))
4922                .map(|block| (&block.kind, block.tag.as_str(),)),
4923            Some((&BlockKind::Selective(SelectiveKind::YZ), "selective",))
4924        );
4925        assert_eq!(
4926            flipped
4927                .get_pipe(IVec3::new(0, 0, 0), IVec3::new(0, 0, 1))
4928                .map(|pipe| (pipe.hadamard, pipe.tag.as_str())),
4929            Some((true, "link"))
4930        );
4931        let mut expected_actions = graph.actions();
4932        if let Action::Resolve { condition, .. } = &mut expected_actions[1] {
4933            *condition = condition.clone().negated();
4934        }
4935        expected_actions.push(Action::Feedback {
4936            targets: vec![crate::FeedbackTarget {
4937                pauli: PauliBasis::Z,
4938                target: IVec3::new(0, 0, 1),
4939                direction: None,
4940            }],
4941            condition: None,
4942        });
4943        assert_eq!(flipped.actions(), expected_actions);
4944
4945        let roundtrip = flipped.flip_xz_basis().expect("second flip succeeds");
4946        assert_eq!(roundtrip.actions(), graph.actions());
4947        assert_eq!(
4948            roundtrip
4949                .get_block(IVec3::new(0, 0, 1))
4950                .map(|block| block.kind),
4951            Some(BlockKind::Selective(SelectiveKind::XY))
4952        );
4953    }
4954
4955    #[test]
4956    fn basis_flip_swaps_both_selective_choices_but_keeps_branch_conditions() {
4957        use crate::Expr;
4958        for kind in [SelectiveKind::XY, SelectiveKind::XZ, SelectiveKind::YZ] {
4959            let mut graph = BlockGraph::new();
4960            graph.add_block(Block::new(IVec3::ZERO, BlockKind::Selective(kind)));
4961            let actions = vec![
4962                Action::Resolve {
4963                    target: IVec3::ZERO,
4964                    condition: Expr::Var("control".into()),
4965                },
4966                Action::Branch {
4967                    target: IVec3::X,
4968                    condition: Expr::Var("control".into()),
4969                },
4970            ];
4971            graph.set_actions_lenient(actions.clone()).unwrap();
4972            let flipped = graph.flip_xz_basis_lenient().unwrap();
4973            assert_eq!(flipped.actions()[1], actions[1]);
4974            assert_eq!(
4975                flipped.actions()[0],
4976                Action::Resolve {
4977                    target: IVec3::ZERO,
4978                    condition: Expr::Not(Box::new(Expr::Var("control".into())))
4979                }
4980            );
4981            let BlockKind::Selective(flipped_kind) = flipped.get_block(IVec3::ZERO).unwrap().kind
4982            else {
4983                unreachable!()
4984            };
4985            for control in [false, true] {
4986                let before = if control {
4987                    kind.pauli_if_true()
4988                } else {
4989                    kind.pauli_if_false()
4990                };
4991                let after = if !control {
4992                    flipped_kind.pauli_if_true()
4993                } else {
4994                    flipped_kind.pauli_if_false()
4995                };
4996                assert_eq!(
4997                    after,
4998                    match before {
4999                        PauliBasis::X => PauliBasis::Z,
5000                        PauliBasis::Y => PauliBasis::Y,
5001                        PauliBasis::Z => PauliBasis::X,
5002                    }
5003                );
5004            }
5005            assert_eq!(flipped.flip_xz_basis_lenient().unwrap().actions(), actions);
5006        }
5007    }
5008
5009    #[test]
5010    fn lenient_basis_flip_defers_action_analysis() {
5011        let graph = GalleryItem::T
5012            .build()
5013            .materialize_root_graph()
5014            .expect("gallery flat projection");
5015        let flipped = graph.flip_xz_basis_lenient().unwrap();
5016
5017        assert_eq!(
5018            flipped.flip_xz_basis_lenient().unwrap().actions(),
5019            graph.actions()
5020        );
5021        assert!(!flipped.action_graph().is_analyzed());
5022    }
5023
5024    #[test]
5025    fn flip_xz_basis_rebuilds_measurement_metadata_from_flipped_structure() {
5026        use glam::ivec3;
5027
5028        let graph = BlockGraph::from_blog_text(
5029            "BLOG 1.0\n\n  0: ZXZ [0, 0, 0]\n  1: ZXZ [0, 0, 1]\n  [0, 0, 0] -> +Z\n\n  m0 = measure 1\n",
5030        )
5031        .unwrap();
5032
5033        let flipped = graph.flip_xz_basis().expect("basis flip succeeds");
5034
5035        assert_eq!(
5036            flipped.get_block(ivec3(0, 0, 0)).map(|block| block.kind),
5037            Some(BlockKind::Cube(CubeKind::XZX))
5038        );
5039        assert_eq!(
5040            flipped
5041                .action_graph()
5042                .node_by_ordinal(0)
5043                .unwrap()
5044                .measurement,
5045            Some(MeasurementObservable::Concrete(crate::PauliBasis::X))
5046        );
5047    }
5048
5049    #[test]
5050    fn flip_xz_basis_handles_walking_end_pipe_without_zx_roundtrip_adjacency_error() {
5051        let graph = BlockGraph::from_blog_text(
5052            "BLOG 1.0\n\n\
5053             0: XZZ [0, 0, 0]\n\
5054             1: walk XZX [0, 0, 1] -> [1, 0, 2]\n\
5055             2: Port [1, 0, 3]\n\
5056             [0, 0, 0] -> +Z\n\
5057             [1, 0, 2] -> +Z\n",
5058        )
5059        .expect("walking graph should parse");
5060
5061        let flipped = graph.flip_xz_basis().expect("basis flip succeeds");
5062
5063        assert_eq!(
5064            flipped
5065                .get_block(IVec3::new(0, 0, 1))
5066                .map(|block| block.kind),
5067            Some(BlockKind::Walking(
5068                WalkingKind::new(WalkingBoundaryKind::ZXZ, glam::ivec2(1, 0)).unwrap()
5069            ))
5070        );
5071        assert!(flipped.has_pipe_between(IVec3::new(1, 0, 2), IVec3::new(1, 0, 3)));
5072    }
5073
5074    #[test]
5075    fn spans_cover_each_axis_inclusively() {
5076        let mut graph = BlockGraph::new();
5077        graph.add_block(Block::new(IVec3::new(-2, 3, 5), BlockKind::Port));
5078        graph.add_block(Block::new(
5079            IVec3::new(1, -1, 8),
5080            BlockKind::Cube(CubeKind::ZXZ),
5081        ));
5082        graph.add_block(Block::new(
5083            IVec3::new(0, 2, 4),
5084            BlockKind::Cube(CubeKind::XZX),
5085        ));
5086
5087        let (x_span, y_span, z_span) = graph.spans().expect("non-empty graph has spans");
5088
5089        assert_eq!(x_span, -2..=1);
5090        assert_eq!(y_span, -1..=3);
5091        assert_eq!(z_span, 4..=8);
5092
5093        graph
5094            .set_cube_height(IVec3::new(1, -1, 8), "65535d".parse().unwrap())
5095            .unwrap();
5096        let spans = graph.spans().unwrap();
5097        assert_eq!(spans, (-2..=1, -1..=3, 4..=65_542));
5098    }
5099}