Skip to main content

formualizer_eval/engine/graph/
mod.rs

1use crate::SheetId;
2use crate::engine::TombstoneRegistry;
3use crate::engine::named_range::{NameScope, NamedDefinition, NamedRange};
4use crate::engine::sheet_registry::SheetRegistry;
5use formualizer_common::{
6    CoordBuildHasher, ExcelError, ExcelErrorKind, LiteralValue, PackedSheetCell,
7};
8use formualizer_parse::parser::{ASTNode, ASTNodeType, ReferenceType};
9use rustc_hash::{FxHashMap, FxHashSet};
10
11#[cfg(debug_assertions)]
12use std::sync::atomic::{AtomicU64, Ordering};
13
14#[cfg(test)]
15#[derive(Debug, Default, Clone)]
16pub struct GraphInstrumentation {
17    pub edges_added: u64,
18    pub stripe_inserts: u64,
19    pub stripe_removes: u64,
20    pub dependents_scan_fallback_calls: u64,
21    pub dependents_scan_vertices_scanned: u64,
22}
23
24mod ast_utils;
25pub(crate) mod authority_host;
26pub mod editor;
27mod extent_record;
28mod formula_analysis;
29#[cfg(test)]
30mod formula_analysis_legacy_tests;
31mod formula_dirty;
32mod names;
33pub(crate) mod prepared_legacy_graph;
34mod range_deps;
35pub(crate) use range_deps::{StructuralEdit, StructuralOccupancy};
36
37mod sheets;
38pub mod snapshot;
39mod sources;
40mod structural_runs;
41mod tables;
42pub(crate) mod virtual_members;
43
44pub(crate) use structural_runs::ShiftedRun;
45pub(crate) use tables::TableEntry;
46
47use super::addr::{GridAddr, SymbolAddr, VertexAddr};
48use super::arena::{AstNodeId, DataStore, ValueRef};
49#[cfg(any(test, feature = "legacy_oracle"))]
50use super::delta_edges::CsrMutableEdges;
51use super::ingest_pipeline::{DependencyPlanRow, FormulaAstInput};
52use super::sheet_index::SheetIndex;
53use super::vertex::{VertexId, VertexKind};
54use super::vertex_store::{FIRST_NORMAL_VERTEX, VertexStore};
55#[cfg(any(test, feature = "legacy_oracle"))]
56use crate::engine::topo::{
57    GraphAdapter,
58    pk::{DynamicTopo, PkConfig},
59};
60use crate::reference::{CellRef, Coord, SharedRangeRef, SharedRef, SharedSheetLocator};
61use formualizer_common::Coord as AbsCoord;
62use formula_dirty::FormulaDirtyState;
63// topo::pk wiring will be integrated behind config.use_dynamic_topo in a follow-up step
64
65struct RegistryFunctionProvider;
66
67impl crate::traits::FunctionProvider for RegistryFunctionProvider {
68    fn planning_semantic_revision(&self) -> Option<u64> {
69        Some(0)
70    }
71
72    fn get_function(
73        &self,
74        ns: &str,
75        name: &str,
76    ) -> Option<std::sync::Arc<dyn crate::function::Function>> {
77        crate::function_registry::get(ns, name)
78    }
79
80    fn get_function_for_planning(
81        &self,
82        ns: &str,
83        name: &str,
84    ) -> Option<std::sync::Arc<dyn crate::function::Function>> {
85        crate::function_registry::get_for_planning(ns, name)
86    }
87}
88
89#[inline]
90fn normalize_stored_literal(value: LiteralValue) -> LiteralValue {
91    match value {
92        // Public contract: store numerics as Number(f64).
93        LiteralValue::Int(i) => LiteralValue::Number(i as f64),
94        other => other,
95    }
96}
97
98pub use editor::change_log::{ChangeEvent, ChangeLog};
99
100// ChangeEvent is now imported from change_log module
101
102/// 🔮 Scalability Hook: Dependency reference types for range compression
103#[derive(Debug, Clone, PartialEq, Eq, Hash)]
104pub enum DependencyRef {
105    /// A specific cell dependency
106    Cell(VertexId),
107    /// A dependency on a finite, rectangular range
108    Range {
109        sheet: String,
110        start_row: u32,
111        start_col: u32,
112        end_row: u32, // Inclusive
113        end_col: u32, // Inclusive
114    },
115    /// A whole column dependency (A:A) - future range compression
116    WholeColumn { sheet: String, col: u32 },
117    /// A whole row dependency (1:1) - future range compression  
118    WholeRow { sheet: String, row: u32 },
119}
120
121/// A key representing a coarse-grained section of a sheet
122#[derive(Debug, Clone, Hash, PartialEq, Eq)]
123pub struct StripeKey {
124    pub sheet_id: SheetId,
125    pub stripe_type: StripeType,
126    pub index: u32, // The index of the row, column, or block stripe
127}
128
129#[derive(Debug, Clone, Hash, PartialEq, Eq)]
130pub enum StripeType {
131    Row,
132    Column,
133    Block, // For dense, square-like ranges
134}
135
136/// Block stripe indexing mathematics
137const BLOCK_H: u32 = 256;
138const BLOCK_W: u32 = 256;
139
140pub fn block_index(row: u32, col: u32) -> u32 {
141    (row / BLOCK_H) << 16 | (col / BLOCK_W)
142}
143
144/// A summary of the results of a mutating operation on the graph.
145/// This serves as a "changelog" to the application layer.
146#[derive(Debug, Clone)]
147pub struct OperationSummary {
148    /// Vertices whose values have been directly or indirectly affected.
149    pub affected_vertices: Vec<VertexId>,
150    /// Placeholder cells that were newly created to satisfy dependencies.
151    pub created_placeholders: Vec<CellRef>,
152}
153
154/// Read-only dependency graph counters used by benchmark/instrumentation tooling.
155///
156/// These counters are deliberately observational: collecting them must not mutate graph state or
157/// alter formula evaluation semantics.
158#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
159pub struct GraphBaselineStats {
160    pub graph_vertex_count: usize,
161    pub graph_formula_vertex_count: usize,
162    pub graph_edge_count: usize,
163    pub dirty_vertex_count: usize,
164    pub evaluation_vertex_count: usize,
165    pub formula_ast_root_count: usize,
166    pub formula_ast_node_count: usize,
167}
168
169/// How a formula vertex stores its formula (Program 2 compression).
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub(crate) enum FormulaRef {
172    /// The vertex's own arena AST, valid at its cell.
173    Own(AstNodeId),
174    /// A family member: its formula is `template` (valid at `anchor`,
175    /// 0-based) relocated to the member's cell, with the same literals and
176    /// reference texts (checked when the member was compressed).
177    Member {
178        template: AstNodeId,
179        anchor: (u32, u32),
180    },
181}
182
183impl FormulaRef {
184    /// The arena root the formula is read from (its own AST, or the shared
185    /// template).
186    #[inline]
187    pub(crate) fn root(self) -> AstNodeId {
188        match self {
189            FormulaRef::Own(id) | FormulaRef::Member { template: id, .. } => id,
190        }
191    }
192
193    /// An ingest pipeline result: own AST, or a load-time family member.
194    #[inline]
195    pub(crate) fn of_ingested(ast_id: AstNodeId, member_anchor: Option<(u32, u32)>) -> Self {
196        match member_anchor {
197            None => FormulaRef::Own(ast_id),
198            Some(anchor) => FormulaRef::Member {
199                template: ast_id,
200                anchor,
201            },
202        }
203    }
204
205    /// The vertex's own AST, if it has one.
206    #[inline]
207    pub(crate) fn own(self) -> Option<AstNodeId> {
208        match self {
209            FormulaRef::Own(id) => Some(id),
210            FormulaRef::Member { .. } => None,
211        }
212    }
213}
214
215/// The formula of every formula vertex: a per-vertex map, plus the
216/// virtual family members (`virtual_members`), which are in no per-cell
217/// map. Writes go through `insert`/`remove`, which also record the touched
218/// vertex so the authority can follow formula edits (Program 1 M1a).
219/// Compressing a vertex to a family member, or moving a member between the
220/// map and a virtual run, is not a formula change and is not recorded.
221#[derive(Debug, Default)]
222pub(crate) struct FormulaMap {
223    map: FxHashMap<VertexId, FormulaRef>,
224    virt: virtual_members::VirtualMembers,
225    touched: Vec<VertexId>,
226}
227
228impl FormulaMap {
229    /// The vertex's formula (own AST or family member).
230    #[inline]
231    pub(crate) fn get(&self, vertex: &VertexId) -> Option<FormulaRef> {
232        match self.map.get(vertex) {
233            Some(&f) => Some(f),
234            None => self.virt.by_vertex(*vertex).map(|m| m.formula),
235        }
236    }
237
238    #[inline]
239    pub(crate) fn contains_key(&self, vertex: &VertexId) -> bool {
240        self.map.contains_key(vertex) || self.virt.contains_vertex(*vertex)
241    }
242
243    /// Formula vertices (map and virtual).
244    #[inline]
245    pub(crate) fn len(&self) -> usize {
246        self.map.len() + self.virt.len()
247    }
248
249    /// Every formula vertex with its formula: the map (unordered), then the
250    /// virtual members (by id).
251    pub(crate) fn iter(&self) -> impl Iterator<Item = (VertexId, FormulaRef)> + '_ {
252        self.map
253            .iter()
254            .map(|(&v, &f)| (v, f))
255            .chain(self.virt.iter().map(|m| (m.vertex, m.formula)))
256    }
257
258    pub(crate) fn keys(&self) -> impl Iterator<Item = VertexId> + '_ {
259        self.iter().map(|(v, _)| v)
260    }
261
262    /// Formulas held by the per-vertex map only (not virtual members).
263    /// Every stored arena root: the map's, and one template per virtual
264    /// run.
265    pub(crate) fn roots(&self) -> impl Iterator<Item = AstNodeId> + '_ {
266        self.map
267            .values()
268            .map(|f| f.root())
269            .chain(self.virt.runs().map(|r| r.template))
270    }
271
272    pub(crate) fn map_iter(&self) -> impl Iterator<Item = (VertexId, FormulaRef)> + '_ {
273        self.map.iter().map(|(&v, &f)| (v, f))
274    }
275
276    #[inline]
277    pub(crate) fn virtual_members(&self) -> &virtual_members::VirtualMembers {
278        &self.virt
279    }
280
281    #[inline]
282    pub(crate) fn virtual_members_mut(&mut self) -> &mut virtual_members::VirtualMembers {
283        &mut self.virt
284    }
285
286    /// Give a virtual member its map entry back (same formula; not
287    /// touched). Returns the member (the caller restores its cell maps).
288    #[inline]
289    pub(crate) fn materialize(
290        &mut self,
291        vertex: VertexId,
292    ) -> Option<virtual_members::VirtualMember> {
293        let m = self.virt.take(vertex)?;
294        self.map.insert(vertex, m.formula);
295        Some(m)
296    }
297
298    /// Drop the map entry of a just-materialized load member that is about
299    /// to be assigned (not touched).
300    pub(crate) fn forget_materialized(&mut self, vertex: &VertexId) {
301        self.map.remove(vertex);
302    }
303
304    /// Re-insert a drained virtual member's formula (not touched).
305    #[inline]
306    pub(crate) fn restore(&mut self, vertex: VertexId, formula: FormulaRef) {
307        self.map.insert(vertex, formula);
308    }
309
310    #[inline]
311    pub(crate) fn insert(&mut self, vertex: VertexId, ast: AstNodeId) -> Option<FormulaRef> {
312        self.insert_ref(vertex, FormulaRef::Own(ast))
313    }
314
315    /// Set a vertex's formula (own AST or family member).
316    #[inline]
317    pub(crate) fn insert_ref(
318        &mut self,
319        vertex: VertexId,
320        formula: FormulaRef,
321    ) -> Option<FormulaRef> {
322        debug_assert!(
323            !self.virt.contains_vertex(vertex),
324            "formula write to a virtual family member (materialize it first)"
325        );
326        self.touched.push(vertex);
327        self.map.insert(vertex, formula)
328    }
329
330    /// Replace a vertex's own AST with a family reference (same formula).
331    #[inline]
332    pub(crate) fn compress(&mut self, vertex: VertexId, template: AstNodeId, anchor: (u32, u32)) {
333        if let Some(slot) = self.map.get_mut(&vertex) {
334            *slot = FormulaRef::Member { template, anchor };
335        }
336    }
337
338    /// Replace a member reference with the member's own (instantiated) AST
339    /// (same formula; not recorded as touched).
340    #[inline]
341    pub(crate) fn decompress(&mut self, vertex: VertexId, own: AstNodeId) {
342        debug_assert!(!self.virt.contains_vertex(vertex));
343        if let Some(slot) = self.map.get_mut(&vertex) {
344            *slot = FormulaRef::Own(own);
345        }
346    }
347
348    /// Remap every stored arena id (after an arena compaction).
349    pub(crate) fn remap(&mut self, map: &impl Fn(AstNodeId) -> AstNodeId) {
350        for slot in self.map.values_mut() {
351            *slot = match *slot {
352                FormulaRef::Own(id) => FormulaRef::Own(map(id)),
353                FormulaRef::Member { template, anchor } => FormulaRef::Member {
354                    template: map(template),
355                    anchor,
356                },
357            };
358        }
359        self.virt.remap(map);
360    }
361
362    #[inline]
363    pub(crate) fn remove(&mut self, vertex: &VertexId) -> Option<FormulaRef> {
364        debug_assert!(
365            !self.virt.contains_vertex(*vertex),
366            "formula removal of a virtual family member (materialize it first)"
367        );
368        let old = self.map.remove(vertex);
369        if old.is_some() {
370            self.touched.push(*vertex);
371        }
372        old
373    }
374
375    #[inline]
376    pub(crate) fn reserve(&mut self, additional: usize) {
377        self.map.reserve(additional);
378    }
379
380    /// Drop the map entries of vertices that are now virtual members
381    /// (`is_virtual`: the store's flag; one pass) and give back the
382    /// capacity.
383    pub(crate) fn drop_virtual_from_map(&mut self, is_virtual: impl Fn(VertexId) -> bool) {
384        debug_assert!(
385            self.map
386                .keys()
387                .all(|&v| is_virtual(v) == self.virt.contains_vertex(v))
388        );
389        self.map.retain(|&v, _| !is_virtual(v));
390        self.map.shrink_to_fit();
391    }
392
393    pub(crate) fn map_capacity(&self) -> usize {
394        self.map.capacity()
395    }
396
397    /// Vertices whose formula changed since the last call.
398    pub(crate) fn take_touched(&mut self) -> Vec<VertexId> {
399        std::mem::take(&mut self.touched)
400    }
401
402    /// Record a vertex whose dependencies were re-derived without a formula
403    /// change (a pending symbol became bound).
404    pub(crate) fn touch(&mut self, vertex: VertexId) {
405        self.touched.push(vertex);
406    }
407
408    /// Vertices whose formula was written since the last
409    /// [`Self::take_touched`].
410    pub(crate) fn touched(&self) -> &[VertexId] {
411        &self.touched
412    }
413
414    pub(crate) fn has_touched(&self) -> bool {
415        !self.touched.is_empty()
416    }
417}
418
419/// A formula cell's formula as a template plus offset (design §11).
420///
421/// Evaluating or rendering `template` with the interpreter's reference
422/// offset `(row_delta, col_delta)` yields exactly this cell's formula.
423/// `template` alone is the formula of the family's anchor cell and is
424/// shared by every member; for a formula stored on its own cell the deltas
425/// are zero.
426#[derive(Clone, Copy, Debug, PartialEq, Eq)]
427#[non_exhaustive]
428pub struct FormulaView {
429    pub template: AstNodeId,
430    pub row_delta: i64,
431    pub col_delta: i64,
432}
433
434/// SoA-based dependency graph implementation
435#[derive(Debug)]
436pub struct DependencyGraph {
437    // Core columnar storage
438    store: VertexStore,
439
440    // Edge storage with delta slab
441    #[cfg(any(test, feature = "legacy_oracle"))]
442    edges: CsrMutableEdges,
443    /// Direct dependency edges legacy would hold (the sum of the per-vertex
444    /// counts kept in the vertex store's `edge_offset` column): admission's
445    /// `GraphEdges` measure, maintained without the CSR.
446    dep_edge_total: usize,
447    /// Formulas with `VertexStore::reads_range` set.
448    range_reader_count: usize,
449    /// Old (lower-cased) name -> sheet, for sheets renamed away from it:
450    /// name formulas that still spell it keep their edges (see
451    /// `rename_sheet`).
452    renamed_sheet_aliases: FxHashMap<String, SheetId>,
453
454    // Arena-based value and formula storage
455    data_store: DataStore,
456    vertex_values: FxHashMap<VertexId, ValueRef>,
457    vertex_formulas: FormulaMap,
458
459    /// Gate for storing grid-backed (cell/formula) LiteralValue payloads inside the dependency graph.
460    ///
461    /// When `false` (Arrow-canonical mode), the graph does not store values for cell/formula
462    /// vertices. Arrow (base + overlays) is the sole value store for sheet cells.
463    value_cache_enabled: bool,
464
465    /// Debug-only instrumentation: count attempts to read *cell/formula* graph values while
466    /// caching is disabled (canonical mode guard).
467    #[cfg(debug_assertions)]
468    graph_value_read_attempts: AtomicU64,
469
470    // Address mappings using a hasher tuned for packed Coord / PackedSheetCell
471    // keys. FxHasher's weak avalanche produces O(N^2) collision cascades on
472    // row-major bulk ingest; CoordBuildHasher keeps these strictly O(N).
473    cell_to_vertex: std::collections::HashMap<CellRef, VertexId, CoordBuildHasher>,
474    load_packed_to_vertex: std::collections::HashMap<PackedSheetCell, VertexId, CoordBuildHasher>,
475
476    /// Vertices removed per cell, revived when replay re-creates the cell
477    /// (decision 9 as amended in Program 2: undo restores the id).
478    vertex_journal: crate::engine::authority::history::IdJournal,
479
480    // Graph-owned formula dirtiness. Legacy vertices retain their sparse bits
481    // and set representation behind this single authority.
482    formula_dirty: FormulaDirtyState,
483    volatile_vertices: FxHashSet<VertexId>,
484
485    /// Monotonic count of vertices processed by dirty-propagation BFS loops
486    /// (`mark_dirty_many` / `mark_dirty_many_value_cells`). Cheap plain
487    /// counter used by perf-shape tests to assert propagation work is
488    /// O(component), not O(sources × component).
489    dirty_propagation_visits: u64,
490
491    /// Nesting depth of active deferred-dirty scopes (`begin_deferred_dirty`
492    /// / `end_deferred_dirty`). While > 0, dirty-propagation entry points
493    /// queue their sources in `deferred_dirty_pending` instead of running a
494    /// BFS per call; the outermost `end_deferred_dirty` flushes the union in
495    /// ONE multi-source `mark_dirty_many`.
496    deferred_dirty_depth: u32,
497    /// Sources queued while a deferred-dirty scope is active.
498    deferred_dirty_pending: Vec<VertexId>,
499    /// Decision 27 (option B): the retired id of each cell whose formula
500    /// was replaced by a value, keyed `(sheet, row0, col0)`. Value ->
501    /// formula at the cell takes it back; structural edits shift it; a
502    /// delete drops the band's entries into `vertex_journal`.
503    retired_ids: std::collections::BTreeMap<(SheetId, u32, u32), VertexId>,
504    /// The ids in `retired_ids` (their tombstones stay out of grid scans:
505    /// structural edits must not move or log them).
506    retired_id_set: FxHashSet<VertexId>,
507    /// Entries each journaled structural delete dropped, most recent last
508    /// (history is LIFO: an undone delete takes its batch back). A delete
509    /// without a change logger cannot be undone and pushes nothing, so the
510    /// stack only grows with the history that can pop it.
511    retired_dropped: Vec<RetiredBatch>,
512    /// Entries each undone insert dropped from its band (a redo of the
513    /// insert takes them back).
514    retired_dropped_by_undo: Vec<RetiredBatch>,
515    /// Cells the legacy graph gave a vertex without a formula (placeholders,
516    /// value cells, spill children): the graph's used extent counts them.
517    extent_record: extent_record::ExtentRecord,
518    /// The extent cells each journaled structural delete dropped, most
519    /// recent last: undo of the delete shifts the record back and restores
520    /// them (delta-sized: an insert or a delete of an unrecorded band keeps
521    /// no cells). Like `retired_dropped`, only journaled deletes push.
522    extent_dropped: Vec<Vec<extent_record::ExtentRun>>,
523    /// Structural edits whose extent the backward replay already put back
524    /// at the edit's end marker (`undo_structural_extent`), innermost last:
525    /// their start marker must not shift it again.
526    extent_undone: Vec<(u8, SheetId, u32, u32)>,
527    /// The same for cell/rectangle seeds (value edits: no vertex, decision 27).
528    deferred_dirty_pending_rects: Vec<(u16, crate::engine::authority::geom::Rect)>,
529
530    /// Vertices explicitly marked as #REF! by structural operations.
531    ///
532    /// In Arrow-truth mode, the dependency graph does not cache cell/formula values.
533    /// We still need a place to record deterministic #REF! invalidations for editor
534    /// operations and structural transforms.
535    ref_error_vertices: FxHashSet<VertexId>,
536
537    // NEW: Specialized managers for range dependencies (Hybrid Model)
538    /// Maps a formula vertex to the ranges it depends on.
539    #[cfg(any(test, feature = "legacy_oracle"))]
540    formula_to_range_deps: FxHashMap<VertexId, Vec<SharedRangeRef<'static>>>,
541
542    /// Maps a stripe to formulas that depend on it via a compressed range.
543    /// CRITICAL: VertexIds are deduplicated within each stripe to avoid quadratic blow-ups.
544    #[cfg(any(test, feature = "legacy_oracle"))]
545    stripe_to_dependents: FxHashMap<StripeKey, FxHashSet<VertexId>>,
546
547    // Sheet-level sparse indexes for O(log n + k) range queries
548    /// Maps sheet_id to its interval tree index for efficient row/column operations
549    sheet_indexes: FxHashMap<SheetId, SheetIndex>,
550
551    // Sheet name/ID mapping
552    sheet_reg: SheetRegistry,
553    default_sheet_id: SheetId,
554
555    // Named ranges support
556    /// Workbook-scoped named ranges
557    named_ranges: FxHashMap<String, NamedRange>,
558
559    /// Normalized-key lookup for workbook-scoped names.
560    ///
561    /// When `config.case_sensitive_names == false`, keys are ASCII-lowercased.
562    /// Values are the canonical (original-cased) name stored in `named_ranges`.
563    named_ranges_lookup: FxHashMap<String, String>,
564
565    /// Sheet-scoped named ranges  
566    sheet_named_ranges: FxHashMap<(SheetId, String), NamedRange>,
567
568    /// Normalized-key lookup for sheet-scoped names.
569    ///
570    /// Key is (SheetId, normalized_name_key). Value is the canonical (original-cased)
571    /// name stored in `sheet_named_ranges`.
572    sheet_named_ranges_lookup: FxHashMap<(SheetId, String), String>,
573
574    /// Reverse mapping: vertex -> names it uses (by vertex id)
575    #[cfg(any(test, feature = "legacy_oracle"))]
576    vertex_to_names: FxHashMap<VertexId, Vec<VertexId>>,
577
578    /// Lookup for name vertex -> (scope, name) to avoid map scans
579    name_vertex_lookup: FxHashMap<VertexId, (NameScope, String)>,
580
581    /// Pending formula vertices referencing unresolved bare symbolic names.
582    ///
583    /// Keys are normalized through `name_lookup_key(...)` so workbook names and
584    /// source scalars can both wake the same waiting formulas when a symbol appears.
585    pending_name_links: FxHashMap<String, FxHashSet<(SheetId, VertexId)>>,
586
587    /// Reverse mapping used to clear stale pending-name registrations when a
588    /// formula is edited, overwritten with a value, or otherwise rebuilt.
589    vertex_to_pending_names: FxHashMap<VertexId, FxHashSet<String>>,
590
591    // Native workbook tables (ListObjects)
592    tables: FxHashMap<String, tables::TableEntry>,
593    /// Normalized-key lookup for tables.
594    tables_lookup: FxHashMap<String, String>,
595    table_vertex_lookup: FxHashMap<VertexId, String>,
596
597    // External sources (SourceVertex)
598    source_scalars: FxHashMap<String, sources::SourceScalarEntry>,
599    source_tables: FxHashMap<String, sources::SourceTableEntry>,
600    source_vertex_lookup: FxHashMap<VertexId, String>,
601
602    /// Monotonic allocator for the symbol address space.
603    ///
604    /// Names, tables and external sources are identified by name and have no position, so
605    /// they are addressed by a dense index here rather than by fabricated grid coordinates
606    /// on a real sheet (#302, #304).
607    symbol_vertex_seq: u32,
608
609    /// Mapping from cell vertices to named range vertices that depend on them
610    #[cfg(any(test, feature = "legacy_oracle"))]
611    cell_to_name_dependents: FxHashMap<VertexId, FxHashSet<VertexId>>,
612    /// Cached list of cell dependencies per named range vertex (for teardown)
613    #[cfg(any(test, feature = "legacy_oracle"))]
614    name_to_cell_dependencies: FxHashMap<VertexId, Vec<VertexId>>,
615    /// Oracle edges to cells without a vertex (decision 27): readers per
616    /// cell, and cells per reader. A vertex created at such a cell gets its
617    /// readers' oracle edges then, as a placeholder used to have them.
618    #[cfg(any(test, feature = "legacy_oracle"))]
619    oracle_vertexless_readers: FxHashMap<(SheetId, u32, u32), Vec<VertexId>>,
620    #[cfg(any(test, feature = "legacy_oracle"))]
621    oracle_vertexless_of: FxHashMap<VertexId, Vec<(SheetId, u32, u32)>>,
622
623    // Evaluation configuration
624    config: super::EvalConfig,
625    /// Low-level monotonic dependency-topology revision used by engine caches.
626    topology_revision: u64,
627    /// Monotonic name, table, and external-source binding revision.
628    symbol_revision: u64,
629
630    /// Program 1 unified authority, maintained beside the legacy graph
631    /// while the feature is in development (never default).
632    authority: crate::engine::authority::host::AuthorityHost,
633
634    // Dynamic topology orderer (Pearce–Kelly) maintained alongside edges when enabled
635    #[cfg(any(test, feature = "legacy_oracle"))]
636    pk_order: Option<DynamicTopo<VertexId>>,
637
638    // Spill registry: anchor -> cells, and reverse mapping for blockers.
639    // `spill_cell_to_anchor` is keyed by `CellRef` and uses the tuned hasher
640    // for the same reason as `cell_to_vertex`.
641    spill_anchor_to_cells: FxHashMap<VertexId, Vec<CellRef>>,
642    /// Single-cell fixed arrays have no spill role or registry entry.
643    pub(crate) fixed_single_arrays: FxHashSet<VertexId>,
644    pub(crate) fixed_array_shapes: FxHashMap<VertexId, (u32, u32)>,
645    spill_cell_to_anchor: std::collections::HashMap<CellRef, VertexId, CoordBuildHasher>,
646    spill_cells_by_sheet: FxHashMap<SheetId, std::collections::BTreeMap<(u32, u32), VertexId>>,
647    /// Anchors whose committed spill may hold a formula placed after the
648    /// spill was committed (or moved there by a structural edit). A spill
649    /// child is a value cell, so only these anchors probe their own cells
650    /// for formulas when they re-plan, commit or clear; every other anchor
651    /// re-spills over its cells without a per-cell lookup (see
652    /// [`Self::spill_may_be_intruded`]). Filled when the authority syncs
653    /// the formula map's touched journal, which every formula write reaches
654    /// whatever its route ([`Self::note_spill_intrusions_of_touched`]), and
655    /// by structural edits. An anchor leaves it when it commits a spill
656    /// free of foreign formulas or its spill is cleared.
657    spill_intruded_anchors: FxHashSet<VertexId>,
658
659    /// Declared dynamic-array anchors (imported source spill identity, for
660    /// example XLSX XLDAPR cell metadata): formula vertex -> the cell it was
661    /// declared at. Empty unless a caller declares one, so workbooks without
662    /// declarations pay only an `is_empty` check on the forget paths. See
663    /// [`Self::is_current_declared_dynamic_anchor`].
664    declared_dynamic_anchors: FxHashMap<VertexId, CellRef>,
665
666    /// Request-scoped admission budgets used by graph-owned mutation paths.
667    admission_budget_override: Option<crate::engine::EvaluationBudgets>,
668
669    // Hint: during initial bulk load, many cells are guaranteed new; allow skipping existence checks per-sheet
670    first_load_assume_new: bool,
671    ensure_touched_sheets: FxHashSet<SheetId>,
672
673    // handled deleted references, in case they are reintroduced.
674    pub tombstone_registry: TombstoneRegistry,
675
676    #[cfg(test)]
677    instr: std::sync::Mutex<GraphInstrumentation>,
678    #[cfg(test)]
679    prepared_legacy_graph_failure_for_test: bool,
680}
681
682impl Default for DependencyGraph {
683    fn default() -> Self {
684        Self::new()
685    }
686}
687
688impl DependencyGraph {
689    /// Expose range expansion limit for planners
690    pub fn range_expansion_limit(&self) -> usize {
691        self.config.range_expansion_limit
692    }
693
694    pub fn get_config(&self) -> &super::EvalConfig {
695        &self.config
696    }
697
698    /// Formula vertices, virtual members included.
699    pub(crate) fn formula_vertex_count(&self) -> usize {
700        self.vertex_formulas.len()
701    }
702
703    pub(crate) fn clear_formula_vertex_dirty(&mut self, vertex_id: VertexId) {
704        self.store.set_dirty(vertex_id, false);
705        self.formula_dirty.legacy_remove(&vertex_id);
706    }
707
708    /// Return read-only baseline counters for FormulaPlane/dispatch benchmarking.
709    pub fn baseline_stats(&self) -> GraphBaselineStats {
710        let data_stats = self.data_store.memory_usage();
711        GraphBaselineStats {
712            graph_vertex_count: self.store.len(),
713            graph_formula_vertex_count: self.vertex_formulas.len(),
714            graph_edge_count: self.dep_edge_total,
715            dirty_vertex_count: self.formula_dirty.legacy_len(),
716            evaluation_vertex_count: self.get_evaluation_vertices().len(),
717            formula_ast_root_count: self.vertex_formulas.len(),
718            formula_ast_node_count: data_stats.total_ast_nodes,
719        }
720    }
721
722    #[inline]
723    pub(crate) fn value_cache_enabled(&self) -> bool {
724        self.value_cache_enabled
725    }
726
727    /// Debug-only: how many times `get_value`/`get_cell_value` were called while caching is disabled.
728    ///
729    /// In Arrow-canonical mode this should remain 0 for engine/interpreter reads.
730    #[cfg(test)]
731    pub fn debug_graph_value_read_attempts(&self) -> u64 {
732        #[cfg(debug_assertions)]
733        {
734            self.graph_value_read_attempts.load(Ordering::Relaxed)
735        }
736        #[cfg(not(debug_assertions))]
737        {
738            0
739        }
740    }
741
742    /// Build a dependency plan for a set of formulas on sheets
743    pub fn plan_dependencies<'a, I>(
744        &mut self,
745        items: I,
746        policy: &formualizer_parse::parser::CollectPolicy,
747        volatile: Option<&[bool]>,
748    ) -> Result<crate::engine::plan::DependencyPlan, formualizer_common::ExcelError>
749    where
750        I: IntoIterator<Item = (&'a str, u32, u32, &'a formualizer_parse::parser::ASTNode)>,
751    {
752        crate::engine::plan::build_dependency_plan(
753            &mut self.sheet_reg,
754            items.into_iter(),
755            policy,
756            volatile,
757        )
758    }
759
760    pub fn plan_dependencies_mixed<'a, I>(
761        &mut self,
762        items: I,
763        policy: &formualizer_parse::parser::CollectPolicy,
764        volatile: Option<&[bool]>,
765    ) -> Result<crate::engine::plan::DependencyPlan, formualizer_common::ExcelError>
766    where
767        I: IntoIterator<
768            Item = (
769                &'a str,
770                u32,
771                u32,
772                crate::engine::plan::DependencyPlanAst<'a>,
773            ),
774        >,
775    {
776        crate::engine::plan::build_dependency_plan_mixed(
777            &mut self.sheet_reg,
778            &self.data_store,
779            items.into_iter(),
780            policy,
781            volatile,
782        )
783    }
784
785    /// Ensure vertices exist for given coords; allocate missing in contiguous batches and add to edges/index.
786    /// Returns a list suitable for edges.add_vertices_batch.
787    pub fn ensure_vertices_batch(
788        &mut self,
789        coords: &[(SheetId, AbsCoord)],
790    ) -> Vec<(VertexAddr, u32)> {
791        self.ensure_vertices_batch_ordered(coords).1
792    }
793
794    /// Ensure vertices exist for given packed absolute cells and return vertex ids aligned to the
795    /// input order, plus the newly allocated `(coord, raw_vid)` items suitable for edge/index
796    /// population.
797    pub fn ensure_vertices_batch_packed_ordered(
798        &mut self,
799        packed_cells: &[PackedSheetCell],
800    ) -> (Vec<VertexId>, Vec<(VertexAddr, u32)>) {
801        let mut unmapped = vec![false; packed_cells.len()];
802        self.ensure_vertices_batch_packed_ordered_unmapped(packed_cells, &mut unmapped)
803    }
804
805    /// [`Self::ensure_vertices_batch_packed_ordered`] where `unmapped[i]`
806    /// asks that cell `i`, if it needs a new vertex, get none of the cell
807    /// map / sheet index entries (the caller installs it as a virtual
808    /// family member, or maps it with [`Self::map_load_unmapped`]). On
809    /// return `unmapped[i]` holds exactly for the new vertices left
810    /// unmapped.
811    pub(crate) fn ensure_vertices_batch_packed_ordered_unmapped(
812        &mut self,
813        packed_cells: &[PackedSheetCell],
814        unmapped: &mut [bool],
815    ) -> (Vec<VertexId>, Vec<(VertexAddr, u32)>) {
816        debug_assert_eq!(unmapped.len(), packed_cells.len());
817        #[cfg(feature = "perf_instrumentation")]
818        use crate::instant::FzInstant as PerfInstant;
819        use rustc_hash::FxHashMap;
820
821        #[cfg(feature = "perf_instrumentation")]
822        let debug = std::env::var("FZ_DEBUG_LOAD")
823            .ok()
824            .is_some_and(|v| v != "0");
825        #[cfg(feature = "perf_instrumentation")]
826        let t0 = PerfInstant::now();
827
828        let mut ordered: Vec<Option<VertexId>> = vec![None; packed_cells.len()];
829        if packed_cells.is_empty() {
830            return (Vec::new(), Vec::new());
831        }
832
833        let first_sid = packed_cells[0].sheet_id();
834        let single_sheet = packed_cells.iter().all(|cell| cell.sheet_id() == first_sid);
835        let mut add_batch: Vec<(VertexAddr, u32)> = Vec::new();
836
837        #[cfg(feature = "perf_instrumentation")]
838        let mut packed_hits = 0usize;
839        #[cfg(feature = "perf_instrumentation")]
840        let mut generic_hits = 0usize;
841        #[cfg(feature = "perf_instrumentation")]
842        let mut missing = 0usize;
843        #[cfg(feature = "perf_instrumentation")]
844        let mut t_packed_lookup_us = 0u128;
845        #[cfg(feature = "perf_instrumentation")]
846        let mut t_generic_lookup_us = 0u128;
847        #[cfg(feature = "perf_instrumentation")]
848        let mut t_alloc_us = 0u128;
849        #[cfg(feature = "perf_instrumentation")]
850        let mut t_map_insert_us = 0u128;
851        #[cfg(feature = "perf_instrumentation")]
852        let mut t_index_insert_us = 0u128;
853        #[cfg(feature = "perf_instrumentation")]
854        let mut t_edge_register_us = 0u128;
855
856        if single_sheet {
857            let sid = first_sid;
858            let mut missing_items: Vec<(usize, PackedSheetCell)> =
859                Vec::with_capacity(packed_cells.len());
860
861            for (idx, packed) in packed_cells.iter().copied().enumerate() {
862                #[cfg(feature = "perf_instrumentation")]
863                let tl0 = PerfInstant::now();
864                if self.first_load_assume_new
865                    && let Some(&existing) = self.load_packed_to_vertex.get(&packed)
866                {
867                    ordered[idx] = Some(existing);
868                    unmapped[idx] = false;
869                    #[cfg(feature = "perf_instrumentation")]
870                    {
871                        packed_hits += 1;
872                        t_packed_lookup_us += tl0.elapsed().as_micros();
873                    }
874                    continue;
875                }
876                #[cfg(feature = "perf_instrumentation")]
877                {
878                    t_packed_lookup_us += tl0.elapsed().as_micros();
879                }
880
881                let pc = AbsCoord::new(packed.row0(), packed.col0());
882                let addr = CellRef::new(sid, Coord::new(pc.row(), pc.col(), true, true));
883                #[cfg(feature = "perf_instrumentation")]
884                let tg0 = PerfInstant::now();
885                if let Some(existing) = self.cell_vertex(&addr) {
886                    ordered[idx] = Some(existing);
887                    unmapped[idx] = false;
888                    // A virtual member stays out of the cell maps.
889                    if self.first_load_assume_new && !self.is_virtual_member(existing) {
890                        self.load_packed_to_vertex.insert(packed, existing);
891                    }
892                    #[cfg(feature = "perf_instrumentation")]
893                    {
894                        generic_hits += 1;
895                    }
896                } else {
897                    missing_items.push((idx, packed));
898                    #[cfg(feature = "perf_instrumentation")]
899                    {
900                        missing += 1;
901                    }
902                }
903                #[cfg(feature = "perf_instrumentation")]
904                {
905                    t_generic_lookup_us += tg0.elapsed().as_micros();
906                }
907            }
908
909            if !missing_items.is_empty() {
910                self.ensure_touched_sheets.insert(sid);
911
912                let mut pcs: Vec<VertexAddr> = Vec::with_capacity(missing_items.len());
913                for (_, packed) in &missing_items {
914                    pcs.push(GridAddr::new(packed.row0(), packed.col0()).into());
915                }
916
917                #[cfg(feature = "perf_instrumentation")]
918                let ta0 = PerfInstant::now();
919                let vids = self.store.allocate_contiguous(sid, &pcs, 0x00);
920                #[cfg(feature = "perf_instrumentation")]
921                {
922                    t_alloc_us += ta0.elapsed().as_micros();
923                }
924                add_batch.reserve(missing_items.len());
925
926                match self.config.sheet_index_mode {
927                    crate::engine::SheetIndexMode::Eager
928                    | crate::engine::SheetIndexMode::FastBatch => {
929                        for ((input_idx, packed), vid) in missing_items.into_iter().zip(vids) {
930                            let pc = AbsCoord::new(packed.row0(), packed.col0());
931                            ordered[input_idx] = Some(vid);
932                            add_batch.push((VertexAddr::grid(GridAddr::from_coord(pc)), vid.0));
933                            if unmapped[input_idx] {
934                                continue;
935                            }
936
937                            #[cfg(feature = "perf_instrumentation")]
938                            let tm0 = PerfInstant::now();
939                            if self.first_load_assume_new {
940                                self.load_packed_to_vertex.insert(packed, vid);
941                            } else {
942                                let addr =
943                                    CellRef::new(sid, Coord::new(pc.row(), pc.col(), true, true));
944                                self.cell_to_vertex.insert(addr, vid);
945                            }
946                            #[cfg(feature = "perf_instrumentation")]
947                            {
948                                t_map_insert_us += tm0.elapsed().as_micros();
949                            }
950
951                            #[cfg(feature = "perf_instrumentation")]
952                            let ti0 = PerfInstant::now();
953                            self.sheet_index_mut(sid)
954                                .add_vertex(GridAddr::from_coord(pc), vid);
955                            #[cfg(feature = "perf_instrumentation")]
956                            {
957                                t_index_insert_us += ti0.elapsed().as_micros();
958                            }
959                        }
960                    }
961                    crate::engine::SheetIndexMode::Lazy => {
962                        for ((input_idx, packed), vid) in missing_items.into_iter().zip(vids) {
963                            let pc = AbsCoord::new(packed.row0(), packed.col0());
964                            ordered[input_idx] = Some(vid);
965                            add_batch.push((VertexAddr::grid(GridAddr::from_coord(pc)), vid.0));
966                            if unmapped[input_idx] {
967                                continue;
968                            }
969
970                            #[cfg(feature = "perf_instrumentation")]
971                            let tm0 = PerfInstant::now();
972                            if self.first_load_assume_new {
973                                self.load_packed_to_vertex.insert(packed, vid);
974                            } else {
975                                let addr =
976                                    CellRef::new(sid, Coord::new(pc.row(), pc.col(), true, true));
977                                self.cell_to_vertex.insert(addr, vid);
978                            }
979                            #[cfg(feature = "perf_instrumentation")]
980                            {
981                                t_map_insert_us += tm0.elapsed().as_micros();
982                            }
983                        }
984                    }
985                }
986            }
987        } else {
988            let mut grouped: FxHashMap<SheetId, Vec<(usize, PackedSheetCell)>> =
989                FxHashMap::default();
990
991            for (idx, packed) in packed_cells.iter().copied().enumerate() {
992                #[cfg(feature = "perf_instrumentation")]
993                let tl0 = PerfInstant::now();
994                if self.first_load_assume_new
995                    && let Some(&existing) = self.load_packed_to_vertex.get(&packed)
996                {
997                    ordered[idx] = Some(existing);
998                    unmapped[idx] = false;
999                    #[cfg(feature = "perf_instrumentation")]
1000                    {
1001                        packed_hits += 1;
1002                        t_packed_lookup_us += tl0.elapsed().as_micros();
1003                    }
1004                    continue;
1005                }
1006                #[cfg(feature = "perf_instrumentation")]
1007                {
1008                    t_packed_lookup_us += tl0.elapsed().as_micros();
1009                }
1010
1011                let sid = packed.sheet_id();
1012                let pc = AbsCoord::new(packed.row0(), packed.col0());
1013                let addr = CellRef::new(sid, Coord::new(pc.row(), pc.col(), true, true));
1014                #[cfg(feature = "perf_instrumentation")]
1015                let tg0 = PerfInstant::now();
1016                if let Some(existing) = self.cell_vertex(&addr) {
1017                    ordered[idx] = Some(existing);
1018                    unmapped[idx] = false;
1019                    // A virtual member stays out of the cell maps.
1020                    if self.first_load_assume_new && !self.is_virtual_member(existing) {
1021                        self.load_packed_to_vertex.insert(packed, existing);
1022                    }
1023                    #[cfg(feature = "perf_instrumentation")]
1024                    {
1025                        generic_hits += 1;
1026                    }
1027                } else {
1028                    grouped.entry(sid).or_default().push((idx, packed));
1029                    #[cfg(feature = "perf_instrumentation")]
1030                    {
1031                        missing += 1;
1032                    }
1033                }
1034                #[cfg(feature = "perf_instrumentation")]
1035                {
1036                    t_generic_lookup_us += tg0.elapsed().as_micros();
1037                }
1038            }
1039
1040            for (sid, items) in grouped {
1041                if items.is_empty() {
1042                    continue;
1043                }
1044                self.ensure_touched_sheets.insert(sid);
1045
1046                let mut pcs: Vec<VertexAddr> = Vec::with_capacity(items.len());
1047                for (_, packed) in &items {
1048                    pcs.push(GridAddr::new(packed.row0(), packed.col0()).into());
1049                }
1050
1051                #[cfg(feature = "perf_instrumentation")]
1052                let ta0 = PerfInstant::now();
1053                let vids = self.store.allocate_contiguous(sid, &pcs, 0x00);
1054                #[cfg(feature = "perf_instrumentation")]
1055                {
1056                    t_alloc_us += ta0.elapsed().as_micros();
1057                }
1058
1059                for ((input_idx, packed), vid) in items.into_iter().zip(vids) {
1060                    let pc = AbsCoord::new(packed.row0(), packed.col0());
1061                    ordered[input_idx] = Some(vid);
1062                    add_batch.push((VertexAddr::grid(GridAddr::from_coord(pc)), vid.0));
1063                    if unmapped[input_idx] {
1064                        continue;
1065                    }
1066
1067                    #[cfg(feature = "perf_instrumentation")]
1068                    let tm0 = PerfInstant::now();
1069                    if self.first_load_assume_new {
1070                        self.load_packed_to_vertex.insert(packed, vid);
1071                    } else {
1072                        let addr = CellRef::new(sid, Coord::new(pc.row(), pc.col(), true, true));
1073                        self.cell_to_vertex.insert(addr, vid);
1074                    }
1075                    #[cfg(feature = "perf_instrumentation")]
1076                    {
1077                        t_map_insert_us += tm0.elapsed().as_micros();
1078                    }
1079
1080                    match self.config.sheet_index_mode {
1081                        crate::engine::SheetIndexMode::Eager
1082                        | crate::engine::SheetIndexMode::FastBatch => {
1083                            #[cfg(feature = "perf_instrumentation")]
1084                            let ti0 = PerfInstant::now();
1085                            self.sheet_index_mut(sid)
1086                                .add_vertex(GridAddr::from_coord(pc), vid);
1087                            #[cfg(feature = "perf_instrumentation")]
1088                            {
1089                                t_index_insert_us += ti0.elapsed().as_micros();
1090                            }
1091                        }
1092                        crate::engine::SheetIndexMode::Lazy => {
1093                            // defer index build
1094                        }
1095                    }
1096                }
1097            }
1098        }
1099
1100        if !add_batch.is_empty() {
1101            #[cfg(feature = "perf_instrumentation")]
1102            let te0 = PerfInstant::now();
1103            #[cfg(any(test, feature = "legacy_oracle"))]
1104            {
1105                self.edges.add_vertices_batch(&add_batch);
1106                let created: FxHashSet<u32> = if self.oracle_vertexless_readers.is_empty() {
1107                    FxHashSet::default()
1108                } else {
1109                    add_batch.iter().map(|&(_, raw)| raw).collect()
1110                };
1111                for (i, packed) in packed_cells.iter().enumerate() {
1112                    if let Some(v) = ordered[i]
1113                        && created.contains(&v.0)
1114                    {
1115                        self.oracle_cell_vertex_created(
1116                            (packed.sheet_id(), packed.row0(), packed.col0()),
1117                            v,
1118                        );
1119                    }
1120                }
1121            }
1122            #[cfg(feature = "perf_instrumentation")]
1123            {
1124                t_edge_register_us += te0.elapsed().as_micros();
1125            }
1126        }
1127
1128        #[cfg(feature = "perf_instrumentation")]
1129        if debug {
1130            eprintln!(
1131                "[fz][ensure] cells={} single_sheet={} packed_hits={} generic_hits={} missing={} packed_lookup={}us generic_lookup={}us alloc={}us map_insert={}us index_insert={}us edge_register={}us total={}ms",
1132                packed_cells.len(),
1133                single_sheet,
1134                packed_hits,
1135                generic_hits,
1136                missing,
1137                t_packed_lookup_us,
1138                t_generic_lookup_us,
1139                t_alloc_us,
1140                t_map_insert_us,
1141                t_index_insert_us,
1142                t_edge_register_us,
1143                t0.elapsed().as_millis(),
1144            );
1145        }
1146
1147        let ordered = ordered
1148            .into_iter()
1149            .map(|vid| vid.expect("ensure_vertices_batch_packed_ordered must resolve every coord"))
1150            .collect();
1151        (ordered, add_batch)
1152    }
1153
1154    /// Ensure vertices exist for given coords and return vertex ids aligned to the input order,
1155    /// plus the newly allocated `(coord, raw_vid)` items suitable for edge/index population.
1156    pub fn ensure_vertices_batch_ordered(
1157        &mut self,
1158        coords: &[(SheetId, AbsCoord)],
1159    ) -> (Vec<VertexId>, Vec<(VertexAddr, u32)>) {
1160        let mut packed: Vec<PackedSheetCell> = Vec::with_capacity(coords.len());
1161        for &(sid, coord) in coords {
1162            packed.push(Self::packed_cell_key(sid, coord));
1163        }
1164        self.ensure_vertices_batch_packed_ordered(&packed)
1165    }
1166
1167    #[inline]
1168    fn packed_cell_key(sheet_id: SheetId, coord: AbsCoord) -> PackedSheetCell {
1169        PackedSheetCell::try_new(sheet_id, coord.row(), coord.col())
1170            .expect("graph coordinate must fit PackedSheetCell")
1171    }
1172
1173    fn flush_load_packed_mappings(&mut self) {
1174        if self.load_packed_to_vertex.is_empty() {
1175            return;
1176        }
1177        let debug = std::env::var("FZ_DEBUG_LOAD")
1178            .ok()
1179            .is_some_and(|v| v != "0");
1180        let t0 = crate::instant::FzInstant::now();
1181        let count = self.load_packed_to_vertex.len();
1182        self.cell_to_vertex.reserve(count);
1183        // Take the map so its allocation is released after the flush: it is
1184        // only used during a first load and would otherwise keep its capacity
1185        // (about 60 B per loaded formula) for the life of the graph.
1186        let packed_mappings = std::mem::replace(
1187            &mut self.load_packed_to_vertex,
1188            std::collections::HashMap::with_hasher(CoordBuildHasher),
1189        );
1190        for (packed, vid) in packed_mappings {
1191            let coord = AbsCoord::new(packed.row0(), packed.col0());
1192            let addr = CellRef::new(
1193                packed.sheet_id(),
1194                Coord::new(coord.row(), coord.col(), true, true),
1195            );
1196            self.cell_to_vertex.insert(addr, vid);
1197        }
1198        if debug {
1199            eprintln!(
1200                "[fz][load] flush_load_packed_mappings: {} entries in {:.1} ms",
1201                count,
1202                t0.elapsed().as_secs_f64() * 1000.0,
1203            );
1204        }
1205    }
1206
1207    /// Enable/disable the first-load fast path for value inserts.
1208    ///
1209    /// Leaving the load scope builds the dependency authority once (it was
1210    /// not synced during the load; see `authority_load_skips_closures`).
1211    pub fn set_first_load_assume_new(&mut self, enabled: bool) {
1212        let leaving = self.first_load_assume_new && !enabled;
1213        if leaving {
1214            self.flush_load_packed_mappings();
1215            // Loads grow the vertex columns by doubling: drop the slack
1216            // (up to half of every column) once the load is complete.
1217            self.store.shrink_to_fit();
1218            // The load's extent notes (value cells, referenced cells) are
1219            // folded here, not by whichever edit next crosses the fold
1220            // threshold (up to one pending cell per run: tens of µs).
1221            self.extent_record.fold_pending();
1222        } else if enabled {
1223            self.load_packed_to_vertex.clear();
1224        }
1225        self.first_load_assume_new = enabled;
1226        if leaving {
1227            self.authority_sync();
1228        }
1229    }
1230
1231    /// Program 2 compression (`EvalConfig::formula_compression`).
1232    pub(crate) fn formula_compression_enabled(&self) -> bool {
1233        self.config.formula_compression
1234    }
1235
1236    #[doc(hidden)]
1237    pub fn first_load_assume_new(&self) -> bool {
1238        self.first_load_assume_new
1239    }
1240
1241    /// Reset the per-sheet ensure touch tracking.
1242    pub fn reset_ensure_touched(&mut self) {
1243        self.ensure_touched_sheets.clear();
1244    }
1245
1246    /// Store an AST and return its arena id.
1247    pub fn store_ast(&mut self, ast: &formualizer_parse::parser::ASTNode) -> AstNodeId {
1248        self.data_store.store_ast(ast, &self.sheet_reg)
1249    }
1250
1251    /// Store ASTs in batch and return their arena ids
1252    pub fn store_asts_batch<'a, I>(&mut self, asts: I) -> Vec<AstNodeId>
1253    where
1254        I: IntoIterator<Item = &'a formualizer_parse::parser::ASTNode>,
1255    {
1256        self.data_store.store_asts_batch(asts, &self.sheet_reg)
1257    }
1258
1259    /// Reserve metadata structures for upcoming formula assignments during bulk load.
1260    pub fn reserve_formula_metadata(&mut self, additional: usize) {
1261        self.vertex_formulas.reserve(additional);
1262        self.formula_dirty.legacy_reserve(additional);
1263        self.volatile_vertices.reserve(additional);
1264    }
1265
1266    /// Lookup VertexId for a (SheetId, AbsCoord)
1267    pub fn vid_for_sid_pc(&self, sid: SheetId, pc: AbsCoord) -> Option<VertexId> {
1268        let addr = CellRef::new(sid, Coord::new(pc.row(), pc.col(), true, true));
1269        self.cell_vertex(&addr)
1270    }
1271
1272    /// Helper to map a global cell index in a plan to a VertexId
1273    pub fn vid_for_plan_idx(
1274        &self,
1275        plan: &crate::engine::plan::DependencyPlan,
1276        idx: u32,
1277    ) -> Option<VertexId> {
1278        let (sid, pc) = plan.global_cells.get(idx as usize).copied()?;
1279        self.vid_for_sid_pc(sid, pc)
1280    }
1281    /// Assign a formula to an existing vertex, removing prior edges and setting flags
1282    pub fn assign_formula_vertex(
1283        &mut self,
1284        vid: VertexId,
1285        ast_id: AstNodeId,
1286        volatile: bool,
1287        dynamic: bool,
1288    ) {
1289        self.assign_formula_ref(vid, FormulaRef::Own(ast_id), volatile, dynamic);
1290    }
1291
1292    /// [`Self::assign_formula_vertex`] for an own AST or a family member.
1293    pub(crate) fn assign_formula_ref(
1294        &mut self,
1295        vid: VertexId,
1296        formula: FormulaRef,
1297        volatile: bool,
1298        dynamic: bool,
1299    ) {
1300        self.materialize_vertex(vid);
1301        self.forget_declared_dynamic_anchor(vid);
1302        if self.vertex_formulas.contains_key(&vid) {
1303            self.remove_dependent_edges(vid);
1304        }
1305        self.store
1306            .set_kind(vid, crate::engine::vertex::VertexKind::FormulaScalar);
1307        self.vertex_values.remove(&vid);
1308        self.vertex_formulas.insert_ref(vid, formula);
1309        self.mark_volatile(vid, volatile);
1310        self.store.set_dynamic(vid, dynamic);
1311
1312        // schedule evaluation
1313        self.mark_vertex_dirty(vid);
1314    }
1315
1316    /// Fast path for initial workbook load: assign a formula to a vertex that is known not to
1317    /// already own dependency edges in the graph. Dirtiness is batched separately.
1318    pub fn assign_formula_vertex_load_fast(
1319        &mut self,
1320        vid: VertexId,
1321        ast_id: AstNodeId,
1322        volatile: bool,
1323        dynamic: bool,
1324    ) {
1325        self.assign_formula_ref_load_fast(vid, FormulaRef::Own(ast_id), volatile, dynamic);
1326    }
1327
1328    /// [`Self::assign_formula_vertex_load_fast`] for an own AST or a family
1329    /// member.
1330    pub(crate) fn assign_formula_ref_load_fast(
1331        &mut self,
1332        vid: VertexId,
1333        formula: FormulaRef,
1334        volatile: bool,
1335        dynamic: bool,
1336    ) {
1337        self.materialize_vertex(vid);
1338        debug_assert!(
1339            !self.vertex_formulas.contains_key(&vid),
1340            "load-fast formula assignment expects fresh/non-formula vertices"
1341        );
1342        self.forget_declared_dynamic_anchor(vid);
1343        self.store
1344            .set_kind(vid, crate::engine::vertex::VertexKind::FormulaScalar);
1345        self.vertex_values.remove(&vid);
1346        self.vertex_formulas.insert_ref(vid, formula);
1347        self.mark_volatile(vid, volatile);
1348        self.store.set_dynamic(vid, dynamic);
1349    }
1350
1351    /// Load-time assignment of a family member whose new vertex was left
1352    /// unmapped (`ensure_vertices_batch_packed_ordered_unmapped`): kind and
1353    /// flags as [`Self::assign_formula_ref_load_fast`], the formula held
1354    /// back for [`Self::install_load_members`].
1355    pub(crate) fn assign_unmapped_member_load_fast(&mut self, vid: VertexId) {
1356        self.store
1357            .set_kind(vid, crate::engine::vertex::VertexKind::FormulaScalar);
1358        self.store.set_dynamic(vid, false);
1359        self.vertex_formulas.touch(vid);
1360    }
1361
1362    /// First load: give every formula target of `sheet` (1-based row,
1363    /// col; the load-time family formula when known) its vertex now, in
1364    /// (col, row) order, so a column's new formula vertices are one id run.
1365    /// Family members (compression on) become virtual runs with their
1366    /// formula already set; other targets are mapped as empty vertices and
1367    /// get their formula when their chunk is planned.
1368    /// Returns the number of vertices created.
1369    pub(crate) fn preallocate_load_targets(
1370        &mut self,
1371        sheet: SheetId,
1372        mut targets: Vec<(u32, u32, Option<FormulaRef>)>,
1373    ) -> usize {
1374        if targets.is_empty() {
1375            return 0;
1376        }
1377        // (col, row); the last staging of a cell wins.
1378        targets.sort_by_key(|&(r, c, _)| (c, r));
1379        let mut dedup: Vec<(u32, u32, Option<FormulaRef>)> = Vec::with_capacity(targets.len());
1380        for t in targets {
1381            match dedup.last_mut() {
1382                Some(last) if (last.0, last.1) == (t.0, t.1) => *last = t,
1383                _ => dedup.push(t),
1384            }
1385        }
1386        let compress = self.config.formula_compression;
1387        let mut packed = Vec::with_capacity(dedup.len());
1388        let mut unmapped = Vec::with_capacity(dedup.len());
1389        for &(r, c, f) in &dedup {
1390            let Some(p) = PackedSheetCell::try_from_excel_1based(sheet, r, c) else {
1391                // Out of range: leave the cell to the chunk (it errors there).
1392                continue;
1393            };
1394            packed.push(p);
1395            unmapped.push(compress && matches!(f, Some(FormulaRef::Member { .. })));
1396        }
1397        let formulas: Vec<Option<FormulaRef>> = dedup
1398            .iter()
1399            .filter(|&&(r, c, _)| PackedSheetCell::try_from_excel_1based(sheet, r, c).is_some())
1400            .map(|&(_, _, f)| f)
1401            .collect();
1402        let (vids, created) =
1403            self.ensure_vertices_batch_packed_ordered_unmapped(&packed, &mut unmapped);
1404        let mut members = Vec::new();
1405        for (i, &v) in vids.iter().enumerate() {
1406            if unmapped[i]
1407                && let Some(f) = formulas[i]
1408            {
1409                members.push((v, sheet, packed[i].row0(), packed[i].col0(), f));
1410            }
1411        }
1412        self.install_preallocated_members(members);
1413        created.len()
1414    }
1415
1416    /// [`Self::install_load_members`] for pre-allocated targets: runs get
1417    /// their formula and become virtual; the others are mapped without a
1418    /// formula (their chunk assigns it).
1419    fn install_preallocated_members(
1420        &mut self,
1421        members: Vec<(VertexId, SheetId, u32, u32, FormulaRef)>,
1422    ) {
1423        let mut i = 0;
1424        while i < members.len() {
1425            let (v, sheet, row, col, f) = members[i];
1426            let mut len = 1usize;
1427            while let Some(&(v2, s2, r2, c2, f2)) = members.get(i + len)
1428                && v2.0 == v.0 + len as u32
1429                && s2 == sheet
1430                && c2 == col
1431                && r2 == row + len as u32
1432                && f2 == f
1433            {
1434                len += 1;
1435            }
1436            match f {
1437                FormulaRef::Member { template, anchor } if len >= 2 => {
1438                    let run = virtual_members::MemberRun {
1439                        sheet,
1440                        col,
1441                        row0: row,
1442                        len: len as u32,
1443                        first: v.0,
1444                        template,
1445                        anchor,
1446                    };
1447                    for (m, _) in run.members() {
1448                        self.store
1449                            .set_kind(m, crate::engine::vertex::VertexKind::FormulaScalar);
1450                        self.store.set_virtual(m, true);
1451                        self.vertex_formulas.touch(m);
1452                    }
1453                    self.vertex_formulas.virtual_members_mut().insert(run);
1454                }
1455                _ => {
1456                    for &(m, s, r, c, _) in &members[i..i + len] {
1457                        self.map_load_vertex(m, s, r, c);
1458                    }
1459                }
1460            }
1461            i += len;
1462        }
1463    }
1464
1465    /// Map a load vertex at its cell as the load's ensure step would.
1466    fn map_load_vertex(&mut self, v: VertexId, sheet: SheetId, row: u32, col: u32) {
1467        if self.first_load_assume_new {
1468            let packed = Self::packed_cell_key(sheet, AbsCoord::new(row, col));
1469            self.load_packed_to_vertex.insert(packed, v);
1470        } else {
1471            self.cell_to_vertex
1472                .insert(CellRef::new(sheet, Coord::new(row, col, true, true)), v);
1473        }
1474        if self.config.sheet_index_mode != crate::engine::SheetIndexMode::Lazy {
1475            self.sheet_index_mut(sheet)
1476                .add_vertex(GridAddr::new(row, col), v);
1477        }
1478    }
1479
1480    /// A chunk reaches pre-allocated virtual member `vid` with `formula`: it
1481    /// stays virtual when that is its run's formula and it needs no
1482    /// per-vertex state; otherwise it is materialized and assigned as any
1483    /// loaded formula.
1484    pub(crate) fn assign_preallocated_member(
1485        &mut self,
1486        vid: VertexId,
1487        formula: FormulaRef,
1488        volatile: bool,
1489        dynamic: bool,
1490    ) {
1491        if !volatile && !dynamic && self.vertex_formulas.get(&vid) == Some(formula) {
1492            return;
1493        }
1494        self.materialize_vertex(vid);
1495        self.vertex_formulas.forget_materialized(&vid);
1496        self.assign_formula_ref_load_fast(vid, formula, volatile, dynamic);
1497    }
1498
1499    /// Install the unmapped members of one load chunk: `(vertex, sheet,
1500    /// row, col, formula)`, 0-based. Runs of consecutive ids down a column
1501    /// with one template become virtual; any other member is mapped like
1502    /// every loaded formula (cell map, sheet index, formula map).
1503    pub(crate) fn install_load_members(
1504        &mut self,
1505        mut members: Vec<(VertexId, SheetId, u32, u32, FormulaRef)>,
1506    ) {
1507        if members.is_empty() {
1508            return;
1509        }
1510        // The last assignment of a vertex wins (a cell staged twice).
1511        members.sort_by_key(|m| m.0);
1512        members.reverse();
1513        members.dedup_by_key(|m| m.0);
1514        members.reverse();
1515        let mut runs: Vec<virtual_members::MemberRun> = Vec::new();
1516        let mut singles: Vec<usize> = Vec::new();
1517        let mut i = 0;
1518        while i < members.len() {
1519            let (v, sheet, row, col, f) = members[i];
1520            let mut len = 1usize;
1521            if let FormulaRef::Member { template, anchor } = f {
1522                while let Some(&(v2, s2, r2, c2, f2)) = members.get(i + len)
1523                    && v2.0 == v.0 + len as u32
1524                    && s2 == sheet
1525                    && c2 == col
1526                    && r2 == row + len as u32
1527                    && f2 == f
1528                {
1529                    len += 1;
1530                }
1531                if len >= 2 {
1532                    runs.push(virtual_members::MemberRun {
1533                        sheet,
1534                        col,
1535                        row0: row,
1536                        len: len as u32,
1537                        first: v.0,
1538                        template,
1539                        anchor,
1540                    });
1541                    i += len;
1542                    continue;
1543                }
1544            }
1545            singles.push(i);
1546            i += 1;
1547        }
1548        for i in singles {
1549            let (v, sheet, row, col, f) = members[i];
1550            self.vertex_formulas.restore(v, f);
1551            if self.first_load_assume_new {
1552                let packed = Self::packed_cell_key(sheet, AbsCoord::new(row, col));
1553                self.load_packed_to_vertex.insert(packed, v);
1554            } else {
1555                self.cell_to_vertex
1556                    .insert(CellRef::new(sheet, Coord::new(row, col, true, true)), v);
1557            }
1558            if self.config.sheet_index_mode != crate::engine::SheetIndexMode::Lazy {
1559                self.sheet_index_mut(sheet)
1560                    .add_vertex(GridAddr::new(row, col), v);
1561            }
1562        }
1563        for r in runs {
1564            for (v, _) in r.members() {
1565                self.store.set_virtual(v, true);
1566            }
1567            self.vertex_formulas.virtual_members_mut().insert(r);
1568        }
1569    }
1570
1571    #[cfg(any(test, feature = "legacy_oracle"))]
1572    /// Public wrapper for adding edges without beginning a batch (caller manages batch)
1573    pub fn add_edges_nobatch(&mut self, dependent: VertexId, dependencies: &[VertexId]) {
1574        self.add_dependent_edges_nobatch(dependent, dependencies);
1575    }
1576
1577    /// Iterate all normal vertex ids
1578    pub fn iter_vertex_ids(&self) -> impl Iterator<Item = VertexId> + '_ {
1579        self.store.all_vertices()
1580    }
1581
1582    /// Get the current address of a vertex: a grid position, or a symbol identity for
1583    /// names, tables and external sources.
1584    pub fn vertex_addr(&self, vid: VertexId) -> VertexAddr {
1585        self.store.addr(vid)
1586    }
1587
1588    /// Get the current grid position of a vertex, or `None` when it is a symbol.
1589    pub fn vertex_grid_addr(&self, vid: VertexId) -> Option<GridAddr> {
1590        self.store.grid_addr(vid)
1591    }
1592
1593    /// Total number of allocated vertices (including deleted)
1594    pub fn vertex_count(&self) -> usize {
1595        self.store.len()
1596    }
1597
1598    #[cfg(any(test, feature = "legacy_oracle"))]
1599    /// Replace CSR edges in one shot from adjacency and coords
1600    pub fn build_edges_from_adjacency(
1601        &mut self,
1602        adjacency: Vec<(u32, Vec<u32>)>,
1603        coords: Vec<VertexAddr>,
1604        vertex_ids: Vec<u32>,
1605    ) {
1606        #[cfg(not(any(test, feature = "legacy_oracle")))]
1607        let _ = (&adjacency, &coords, &vertex_ids);
1608        #[cfg(any(test, feature = "legacy_oracle"))]
1609        {
1610            // Merge in base/delta out-edges for vertices the formula-target
1611            // adjacency doesn't cover (e.g. named-range pass-through vertices)
1612            // before handing the final adjacency to the pure builder.
1613            let adjacency = self.edges.adjacency_with_carried_forward_edges(adjacency);
1614            self.edges
1615                .build_from_adjacency(adjacency, coords, vertex_ids);
1616        }
1617    }
1618    /// Compute min/max used row among vertices within [start_col..=end_col] on a sheet.
1619    pub fn used_row_bounds_for_columns(
1620        &self,
1621        sheet_id: SheetId,
1622        start_col: u32,
1623        end_col: u32,
1624    ) -> Option<(u32, u32)> {
1625        // Prefer sheet index when available
1626        if let Some(index) = self.sheet_indexes.get(&sheet_id)
1627            && !index.is_empty()
1628        {
1629            let mut min_r: Option<u32> = None;
1630            let mut max_r: Option<u32> = None;
1631            for vid in index.vertices_in_col_range(start_col, end_col) {
1632                let Some(r) = self.store.grid_addr(vid).map(|addr| addr.row()) else {
1633                    continue;
1634                };
1635                min_r = Some(min_r.map(|m| m.min(r)).unwrap_or(r));
1636                max_r = Some(max_r.map(|m| m.max(r)).unwrap_or(r));
1637            }
1638            self.virtual_row_bounds(sheet_id, start_col, end_col, &mut min_r, &mut max_r);
1639            self.extent_row_bounds(sheet_id, start_col, end_col, &mut min_r, &mut max_r);
1640            return match (min_r, max_r) {
1641                (Some(a), Some(b)) => Some((a, b)),
1642                _ => None,
1643            };
1644        }
1645        // Fallback: scan cell maps on the fly
1646        let mut min_r: Option<u32> = None;
1647        let mut max_r: Option<u32> = None;
1648        for cref in self.cell_to_vertex.keys() {
1649            if cref.sheet_id == sheet_id {
1650                let c = cref.coord.col();
1651                if c >= start_col && c <= end_col {
1652                    let r = cref.coord.row();
1653                    min_r = Some(min_r.map(|m| m.min(r)).unwrap_or(r));
1654                    max_r = Some(max_r.map(|m| m.max(r)).unwrap_or(r));
1655                }
1656            }
1657        }
1658        for packed in self.load_packed_to_vertex.keys() {
1659            if packed.sheet_id() == sheet_id {
1660                let c = packed.col0();
1661                if c >= start_col && c <= end_col {
1662                    let r = packed.row0();
1663                    min_r = Some(min_r.map(|m| m.min(r)).unwrap_or(r));
1664                    max_r = Some(max_r.map(|m| m.max(r)).unwrap_or(r));
1665                }
1666            }
1667        }
1668        self.virtual_row_bounds(sheet_id, start_col, end_col, &mut min_r, &mut max_r);
1669        self.extent_row_bounds(sheet_id, start_col, end_col, &mut min_r, &mut max_r);
1670        match (min_r, max_r) {
1671            (Some(a), Some(b)) => Some((a, b)),
1672            _ => None,
1673        }
1674    }
1675
1676    /// Build (or rebuild) the sheet index for a given sheet.
1677    pub fn finalize_sheet_index(&mut self, sheet: &str) {
1678        let Some(sheet_id) = self.sheet_reg.get_id(sheet) else {
1679            return;
1680        };
1681        self.rebuild_sheet_index(sheet_id);
1682    }
1683
1684    fn rebuild_sheet_index(&mut self, sheet_id: SheetId) {
1685        let mut idx = SheetIndex::new();
1686        let mut batch: Vec<(GridAddr, VertexId)> =
1687            Vec::with_capacity(self.cell_to_vertex.len() + self.load_packed_to_vertex.len());
1688        for (cref, vid) in &self.cell_to_vertex {
1689            if cref.sheet_id == sheet_id {
1690                batch.push((GridAddr::new(cref.coord.row(), cref.coord.col()), *vid));
1691            }
1692        }
1693        for (&packed, &vid) in &self.load_packed_to_vertex {
1694            if packed.sheet_id() != sheet_id {
1695                continue;
1696            }
1697            let coord = GridAddr::new(packed.row0(), packed.col0());
1698            let addr = CellRef::new(sheet_id, Coord::new(coord.row(), coord.col(), true, true));
1699            if self.cell_to_vertex.contains_key(&addr) {
1700                continue;
1701            }
1702            batch.push((coord, vid));
1703        }
1704        idx.add_vertices_batch(&batch);
1705        self.sheet_indexes.insert(sheet_id, idx);
1706    }
1707
1708    /// Finalize the queried sheet on demand in Lazy mode. A non-empty Lazy
1709    /// index can still be partial because incremental edit paths may populate
1710    /// it after deferred bulk load, so queries rebuild it unconditionally.
1711    pub(crate) fn prepare_sheet_index_for_query(&mut self, sheet_id: SheetId) {
1712        if self.config.sheet_index_mode == crate::engine::SheetIndexMode::Lazy {
1713            self.rebuild_sheet_index(sheet_id);
1714        }
1715    }
1716
1717    pub fn set_sheet_index_mode(&mut self, mode: crate::engine::SheetIndexMode) {
1718        self.config.sheet_index_mode = mode;
1719    }
1720
1721    pub(crate) fn set_evaluation_budgets(&mut self, budgets: crate::engine::EvaluationBudgets) {
1722        self.config.evaluation_budgets = budgets;
1723    }
1724
1725    /// Compute min/max used column among vertices within [start_row..=end_row] on a sheet.
1726    pub fn used_col_bounds_for_rows(
1727        &self,
1728        sheet_id: SheetId,
1729        start_row: u32,
1730        end_row: u32,
1731    ) -> Option<(u32, u32)> {
1732        if let Some(index) = self.sheet_indexes.get(&sheet_id)
1733            && !index.is_empty()
1734        {
1735            let mut min_c: Option<u32> = None;
1736            let mut max_c: Option<u32> = None;
1737            for vid in index.vertices_in_row_range(start_row, end_row) {
1738                let Some(c) = self.store.grid_addr(vid).map(|addr| addr.col()) else {
1739                    continue;
1740                };
1741                min_c = Some(min_c.map(|m| m.min(c)).unwrap_or(c));
1742                max_c = Some(max_c.map(|m| m.max(c)).unwrap_or(c));
1743            }
1744            self.virtual_col_bounds(sheet_id, start_row, end_row, &mut min_c, &mut max_c);
1745            self.extent_col_bounds(sheet_id, start_row, end_row, &mut min_c, &mut max_c);
1746            return match (min_c, max_c) {
1747                (Some(a), Some(b)) => Some((a, b)),
1748                _ => None,
1749            };
1750        }
1751        // Fallback: scan cell maps on the fly
1752        let mut min_c: Option<u32> = None;
1753        let mut max_c: Option<u32> = None;
1754        for cref in self.cell_to_vertex.keys() {
1755            if cref.sheet_id == sheet_id {
1756                let r = cref.coord.row();
1757                if r >= start_row && r <= end_row {
1758                    let c = cref.coord.col();
1759                    min_c = Some(min_c.map(|m| m.min(c)).unwrap_or(c));
1760                    max_c = Some(max_c.map(|m| m.max(c)).unwrap_or(c));
1761                }
1762            }
1763        }
1764        for packed in self.load_packed_to_vertex.keys() {
1765            if packed.sheet_id() == sheet_id {
1766                let r = packed.row0();
1767                if r >= start_row && r <= end_row {
1768                    let c = packed.col0();
1769                    min_c = Some(min_c.map(|m| m.min(c)).unwrap_or(c));
1770                    max_c = Some(max_c.map(|m| m.max(c)).unwrap_or(c));
1771                }
1772            }
1773        }
1774        self.virtual_col_bounds(sheet_id, start_row, end_row, &mut min_c, &mut max_c);
1775        self.extent_col_bounds(sheet_id, start_row, end_row, &mut min_c, &mut max_c);
1776        match (min_c, max_c) {
1777            (Some(a), Some(b)) => Some((a, b)),
1778            _ => None,
1779        }
1780    }
1781
1782    /// Widen `min..max` rows by the extent record in columns `c0..=c1`.
1783    fn extent_row_bounds(
1784        &self,
1785        sheet: SheetId,
1786        c0: u32,
1787        c1: u32,
1788        min: &mut Option<u32>,
1789        max: &mut Option<u32>,
1790    ) {
1791        if let Some((a, b)) = self.extent_record.row_bounds_for_cols(sheet, c0, c1) {
1792            *min = Some(min.map_or(a, |m| m.min(a)));
1793            *max = Some(max.map_or(b, |m| m.max(b)));
1794        }
1795    }
1796
1797    /// Widen `min..max` columns by the extent record in rows `r0..=r1`.
1798    fn extent_col_bounds(
1799        &self,
1800        sheet: SheetId,
1801        r0: u32,
1802        r1: u32,
1803        min: &mut Option<u32>,
1804        max: &mut Option<u32>,
1805    ) {
1806        if let Some((a, b)) = self.extent_record.col_bounds_for_rows(sheet, r0, r1) {
1807            *min = Some(min.map_or(a, |m| m.min(a)));
1808            *max = Some(max.map_or(b, |m| m.max(b)));
1809        }
1810    }
1811
1812    /// Widen `min..max` rows by the virtual members in columns `c0..=c1`.
1813    fn virtual_row_bounds(
1814        &self,
1815        sheet: SheetId,
1816        c0: u32,
1817        c1: u32,
1818        min: &mut Option<u32>,
1819        max: &mut Option<u32>,
1820    ) {
1821        for r in self
1822            .vertex_formulas
1823            .virtual_members()
1824            .runs_in_cols(sheet, c0, c1)
1825        {
1826            let (a, b) = (r.row0, r.row0 + r.len - 1);
1827            *min = Some(min.map_or(a, |m| m.min(a)));
1828            *max = Some(max.map_or(b, |m| m.max(b)));
1829        }
1830    }
1831
1832    /// Widen `min..max` columns by the virtual members in rows `r0..=r1`.
1833    fn virtual_col_bounds(
1834        &self,
1835        sheet: SheetId,
1836        r0: u32,
1837        r1: u32,
1838        min: &mut Option<u32>,
1839        max: &mut Option<u32>,
1840    ) {
1841        for r in self.vertex_formulas.virtual_members().runs_in_sheet(sheet) {
1842            if r.row0 <= r1 && r.row0 + r.len > r0 {
1843                *min = Some(min.map_or(r.col, |m| m.min(r.col)));
1844                *max = Some(max.map_or(r.col, |m| m.max(r.col)));
1845            }
1846        }
1847    }
1848
1849    /// Returns true if the given sheet currently contains any formula vertices.
1850    pub fn sheet_has_formulas(&self, sheet_id: SheetId) -> bool {
1851        // Check vertex_formulas keys; they represent formula vertices
1852        for vid in self.vertex_formulas.keys() {
1853            if self.store.sheet_id(vid) == sheet_id {
1854                return true;
1855            }
1856        }
1857        false
1858    }
1859    pub fn new() -> Self {
1860        Self::new_with_config(super::EvalConfig::default())
1861    }
1862
1863    pub fn new_with_config(config: super::EvalConfig) -> Self {
1864        let mut sheet_reg = SheetRegistry::new();
1865        let default_sheet_id = sheet_reg.id_for(&config.default_sheet_name);
1866
1867        #[cfg_attr(not(any(test, feature = "legacy_oracle")), allow(unused_mut))]
1868        let mut g = Self {
1869            store: VertexStore::new(),
1870            #[cfg(any(test, feature = "legacy_oracle"))]
1871            edges: CsrMutableEdges::new(),
1872            dep_edge_total: 0,
1873            range_reader_count: 0,
1874            renamed_sheet_aliases: FxHashMap::default(),
1875            data_store: DataStore::new(),
1876            vertex_values: FxHashMap::default(),
1877            vertex_formulas: FormulaMap::default(),
1878            // Phase 1 (ticket 610): Arrow-truth is the only supported mode.
1879            // The dependency graph does not cache cell/formula literal payloads.
1880            value_cache_enabled: false,
1881            #[cfg(debug_assertions)]
1882            graph_value_read_attempts: AtomicU64::new(0),
1883            cell_to_vertex: std::collections::HashMap::with_hasher(CoordBuildHasher),
1884            vertex_journal: Default::default(),
1885            load_packed_to_vertex: std::collections::HashMap::with_hasher(CoordBuildHasher),
1886            formula_dirty: FormulaDirtyState::default(),
1887            dirty_propagation_visits: 0,
1888            deferred_dirty_depth: 0,
1889            deferred_dirty_pending: Vec::new(),
1890            deferred_dirty_pending_rects: Vec::new(),
1891            retired_ids: std::collections::BTreeMap::new(),
1892            retired_id_set: FxHashSet::default(),
1893            retired_dropped: Vec::new(),
1894            retired_dropped_by_undo: Vec::new(),
1895            extent_record: Default::default(),
1896            extent_dropped: Vec::new(),
1897            extent_undone: Vec::new(),
1898            volatile_vertices: FxHashSet::default(),
1899            ref_error_vertices: FxHashSet::default(),
1900            #[cfg(any(test, feature = "legacy_oracle"))]
1901            formula_to_range_deps: FxHashMap::default(),
1902            #[cfg(any(test, feature = "legacy_oracle"))]
1903            stripe_to_dependents: FxHashMap::default(),
1904            sheet_indexes: FxHashMap::default(),
1905            sheet_reg,
1906            default_sheet_id,
1907            named_ranges: FxHashMap::default(),
1908            named_ranges_lookup: FxHashMap::default(),
1909            sheet_named_ranges: FxHashMap::default(),
1910            sheet_named_ranges_lookup: FxHashMap::default(),
1911            #[cfg(any(test, feature = "legacy_oracle"))]
1912            vertex_to_names: FxHashMap::default(),
1913            name_vertex_lookup: FxHashMap::default(),
1914            pending_name_links: FxHashMap::default(),
1915            vertex_to_pending_names: FxHashMap::default(),
1916            tables: FxHashMap::default(),
1917            tables_lookup: FxHashMap::default(),
1918            table_vertex_lookup: FxHashMap::default(),
1919            source_scalars: FxHashMap::default(),
1920            source_tables: FxHashMap::default(),
1921            source_vertex_lookup: FxHashMap::default(),
1922            symbol_vertex_seq: 0,
1923            #[cfg(any(test, feature = "legacy_oracle"))]
1924            cell_to_name_dependents: FxHashMap::default(),
1925            #[cfg(any(test, feature = "legacy_oracle"))]
1926            name_to_cell_dependencies: FxHashMap::default(),
1927            #[cfg(any(test, feature = "legacy_oracle"))]
1928            oracle_vertexless_readers: FxHashMap::default(),
1929            #[cfg(any(test, feature = "legacy_oracle"))]
1930            oracle_vertexless_of: FxHashMap::default(),
1931            config: config.clone(),
1932            topology_revision: 0,
1933            symbol_revision: 0,
1934            authority: Default::default(),
1935            #[cfg(any(test, feature = "legacy_oracle"))]
1936            pk_order: None,
1937            spill_anchor_to_cells: FxHashMap::default(),
1938            fixed_single_arrays: FxHashSet::default(),
1939            fixed_array_shapes: FxHashMap::default(),
1940            spill_cell_to_anchor: std::collections::HashMap::with_hasher(CoordBuildHasher),
1941            spill_cells_by_sheet: FxHashMap::default(),
1942            spill_intruded_anchors: FxHashSet::default(),
1943            declared_dynamic_anchors: FxHashMap::default(),
1944            admission_budget_override: None,
1945            first_load_assume_new: false,
1946            ensure_touched_sheets: FxHashSet::default(),
1947            tombstone_registry: TombstoneRegistry::default(),
1948            #[cfg(test)]
1949            instr: std::sync::Mutex::new(GraphInstrumentation::default()),
1950            #[cfg(test)]
1951            prepared_legacy_graph_failure_for_test: false,
1952        };
1953
1954        #[cfg(any(test, feature = "legacy_oracle"))]
1955        if config.use_dynamic_topo {
1956            // Seed with currently active vertices (likely empty at startup)
1957            let nodes = g
1958                .store
1959                .all_vertices()
1960                .filter(|&id| g.store.vertex_exists_active(id));
1961            let mut pk = DynamicTopo::new(
1962                nodes,
1963                PkConfig {
1964                    visit_budget: config.pk_visit_budget,
1965                    compaction_interval_ops: config.pk_compaction_interval_ops,
1966                },
1967            );
1968            // Build an initial order using current graph
1969            let adapter = GraphAdapter { g: &g };
1970            pk.rebuild_full(&adapter);
1971            g.pk_order = Some(pk);
1972        }
1973
1974        g
1975    }
1976
1977    #[cfg(any(test, feature = "legacy_oracle"))]
1978    /// When dynamic topology is enabled, compute layers for a subset using PK ordering.
1979    pub(crate) fn pk_layers_for(&self, subset: &[VertexId]) -> Option<Vec<crate::engine::Layer>> {
1980        let pk = self.pk_order.as_ref()?;
1981        let adapter = crate::engine::topo::GraphAdapter { g: self };
1982        let layers = pk.layers_for(&adapter, subset, self.config.max_layer_width);
1983        Some(layers.into_iter().map(crate::engine::Layer::new).collect())
1984    }
1985
1986    #[cfg(any(test, feature = "legacy_oracle"))]
1987    #[inline]
1988    pub(crate) fn dynamic_topo_enabled(&self) -> bool {
1989        self.pk_order.is_some()
1990    }
1991
1992    #[cfg(test)]
1993    pub fn reset_instr(&mut self) {
1994        if let Ok(mut g) = self.instr.lock() {
1995            *g = GraphInstrumentation::default();
1996        }
1997    }
1998
1999    #[cfg(test)]
2000    pub fn instr(&self) -> GraphInstrumentation {
2001        self.instr.lock().map(|g| g.clone()).unwrap_or_default()
2002    }
2003
2004    /// Whether legacy's Pearce-Kelly order is maintained (oracle builds with
2005    /// `use_dynamic_topo`); never at runtime.
2006    pub(crate) fn pk_active(&self) -> bool {
2007        #[cfg(any(test, feature = "legacy_oracle"))]
2008        {
2009            self.pk_order.is_some()
2010        }
2011        #[cfg(not(any(test, feature = "legacy_oracle")))]
2012        {
2013            false
2014        }
2015    }
2016
2017    /// Begin batch operations - defer CSR rebuilds until end_batch() is called
2018    pub fn begin_batch(&mut self) {
2019        #[cfg(any(test, feature = "legacy_oracle"))]
2020        self.edges.begin_batch();
2021    }
2022
2023    /// End batch operations and trigger CSR rebuild if needed
2024    pub fn end_batch(&mut self) {
2025        #[cfg(any(test, feature = "legacy_oracle"))]
2026        self.edges.end_batch();
2027    }
2028
2029    pub fn default_sheet_id(&self) -> SheetId {
2030        self.default_sheet_id
2031    }
2032
2033    pub fn default_sheet_name(&self) -> &str {
2034        self.sheet_reg.name(self.default_sheet_id)
2035    }
2036
2037    pub fn set_default_sheet_by_name(&mut self, name: &str) {
2038        self.default_sheet_id = self.sheet_id_mut(name);
2039    }
2040
2041    pub fn set_default_sheet_by_id(&mut self, id: SheetId) {
2042        self.default_sheet_id = id;
2043    }
2044
2045    /// Returns the ID for a sheet name, creating one if it doesn't exist.
2046    pub fn sheet_id_mut(&mut self, name: &str) -> SheetId {
2047        if let Some(id) = self.sheet_reg.get_id(name) {
2048            return id;
2049        }
2050        let id = self.sheet_reg.id_for(name);
2051        self.resolve_pending_symbol("sheet", name);
2052        id
2053    }
2054
2055    pub fn sheet_id(&self, name: &str) -> Option<SheetId> {
2056        self.sheet_reg.get_id(name)
2057    }
2058
2059    /// Resolve a sheet name to an existing ID or return a #REF! error.
2060    fn resolve_existing_sheet_id(&self, name: &str) -> Result<SheetId, ExcelError> {
2061        self.sheet_id(name).ok_or_else(|| {
2062            ExcelError::new(ExcelErrorKind::Ref).with_message(format!("Sheet not found: {name}"))
2063        })
2064    }
2065
2066    /// Returns the name of a sheet given its ID.
2067    pub fn sheet_name(&self, id: SheetId) -> &str {
2068        self.sheet_reg.name(id)
2069    }
2070
2071    /// Access the sheet registry (read-only) for external bindings
2072    pub fn sheet_reg(&self) -> &SheetRegistry {
2073        &self.sheet_reg
2074    }
2075
2076    pub(crate) fn data_store(&self) -> &DataStore {
2077        &self.data_store
2078    }
2079
2080    pub(crate) fn make_ingest_pipeline<'a>(
2081        &'a mut self,
2082        function_provider: &'a dyn crate::traits::FunctionProvider,
2083        policy: formualizer_parse::parser::CollectPolicy,
2084    ) -> crate::engine::ingest_pipeline::IngestPipeline<'a> {
2085        use crate::engine::ingest_pipeline::{
2086            NameRegistryView, NamedEntryRef, NamedTarget, SourceEntryRef, SourceRegistryView,
2087            TableEntrySnapshot, TableRegistryView,
2088        };
2089
2090        let DependencyGraph {
2091            data_store,
2092            sheet_reg,
2093            named_ranges,
2094            named_ranges_lookup,
2095            sheet_named_ranges,
2096            sheet_named_ranges_lookup,
2097            tables,
2098            tables_lookup,
2099            source_scalars,
2100            source_tables,
2101            config,
2102            ..
2103        } = self;
2104
2105        let unbound_pending =
2106            config.preparation_policy == crate::engine::PreparationPolicy::BestEffort;
2107        let case_sensitive_names = config.case_sensitive_names;
2108        let names = NameRegistryView::new(move |name, current_sheet| {
2109            let found = if case_sensitive_names {
2110                sheet_named_ranges
2111                    .get(&(current_sheet, name.to_string()))
2112                    .or_else(|| named_ranges.get(name))
2113            } else {
2114                let key = name.to_lowercase();
2115                sheet_named_ranges_lookup
2116                    .get(&(current_sheet, key.clone()))
2117                    .and_then(|canon| sheet_named_ranges.get(&(current_sheet, canon.clone())))
2118                    .or_else(|| {
2119                        named_ranges_lookup
2120                            .get(&key)
2121                            .and_then(|canon| named_ranges.get(canon))
2122                    })
2123            };
2124            found.map(|entry| NamedEntryRef {
2125                vertex: entry.vertex,
2126                target: match &entry.definition {
2127                    crate::engine::named_range::NamedDefinition::Cell(cell) => {
2128                        NamedTarget::Cell(*cell)
2129                    }
2130                    crate::engine::named_range::NamedDefinition::Range(range) => {
2131                        NamedTarget::Range(*range)
2132                    }
2133                    crate::engine::named_range::NamedDefinition::Literal(_)
2134                    | crate::engine::named_range::NamedDefinition::Formula { .. } => {
2135                        NamedTarget::Other
2136                    }
2137                },
2138            })
2139        });
2140
2141        let case_sensitive_tables = config.case_sensitive_tables;
2142        let tables_ref = &*tables;
2143        let tables_lookup_ref = &*tables_lookup;
2144        let snapshot_table = |entry: &tables::TableEntry| TableEntrySnapshot {
2145            name: entry.name.clone(),
2146            range: entry.range,
2147            header_row: entry.header_row,
2148            headers: entry.headers.clone(),
2149            vertex: entry.vertex,
2150        };
2151        let tables_view = TableRegistryView::new(
2152            move |name| {
2153                if case_sensitive_tables {
2154                    tables_ref.get(name).map(snapshot_table)
2155                } else {
2156                    let key = name.to_lowercase();
2157                    tables_lookup_ref
2158                        .get(&key)
2159                        .and_then(|canon| tables_ref.get(canon))
2160                        .map(snapshot_table)
2161                }
2162            },
2163            move |cell| {
2164                let row0 = cell.coord.row();
2165                let col0 = cell.coord.col();
2166                let mut best: Option<&tables::TableEntry> = None;
2167                let mut best_area = u64::MAX;
2168                let mut best_name = "";
2169                for table in tables_ref.values() {
2170                    if table.sheet_id() != cell.sheet_id {
2171                        continue;
2172                    }
2173                    let sr0 = table.range.start.coord.row();
2174                    let sc0 = table.range.start.coord.col();
2175                    let er0 = table.range.end.coord.row();
2176                    let ec0 = table.range.end.coord.col();
2177                    if row0 < sr0 || row0 > er0 || col0 < sc0 || col0 > ec0 {
2178                        continue;
2179                    }
2180                    let area = ((er0 - sr0 + 1) as u64).saturating_mul((ec0 - sc0 + 1) as u64);
2181                    let name = table.name.as_str();
2182                    if best.is_none() || area < best_area || (area == best_area && name < best_name)
2183                    {
2184                        best = Some(table);
2185                        best_area = area;
2186                        best_name = name;
2187                    }
2188                }
2189                best.map(snapshot_table)
2190            },
2191        );
2192
2193        let sources = SourceRegistryView::new(
2194            move |name| {
2195                source_scalars.get(name).map(|entry| SourceEntryRef {
2196                    vertex: entry.vertex,
2197                })
2198            },
2199            move |name| {
2200                source_tables.get(name).map(|entry| SourceEntryRef {
2201                    vertex: entry.vertex,
2202                })
2203            },
2204        );
2205
2206        crate::engine::ingest_pipeline::IngestPipeline::new(
2207            data_store,
2208            sheet_reg,
2209            names,
2210            tables_view,
2211            sources,
2212            function_provider,
2213            policy,
2214        )
2215        .with_unbound_pending(unbound_pending)
2216    }
2217
2218    /// Converts a `CellRef` to a fully qualified A1-style string (e.g., "SheetName!A1").
2219    pub fn to_a1(&self, cell_ref: CellRef) -> String {
2220        format!("{}!{}", self.sheet_name(cell_ref.sheet_id), cell_ref.coord)
2221    }
2222
2223    pub(crate) fn vertex_len(&self) -> usize {
2224        self.store.len()
2225    }
2226
2227    /// The id the next new vertex gets (tests: fresh ids).
2228    #[cfg(test)]
2229    pub(crate) fn next_vertex_id_for_test(&self) -> u32 {
2230        crate::engine::vertex_store::FIRST_NORMAL_VERTEX + self.store.len() as u32
2231    }
2232
2233    pub(crate) fn topology_revision(&self) -> u64 {
2234        self.topology_revision
2235    }
2236
2237    pub(crate) fn bump_topology_revision(&mut self) {
2238        self.topology_revision = self.topology_revision.wrapping_add(1);
2239    }
2240
2241    pub(crate) fn symbol_revision(&self) -> u64 {
2242        self.symbol_revision
2243    }
2244
2245    pub(crate) fn bump_symbol_revision(&mut self) {
2246        self.symbol_revision = self.symbol_revision.wrapping_add(1);
2247        // Keep a built authority current, so read-only plans (`&self`) see
2248        // the new binding; during a load or a structural edit it waits.
2249        self.authority_sync_if_ready();
2250    }
2251
2252    #[cfg(any(test, feature = "legacy_oracle"))]
2253    pub(crate) fn formula_range_dependencies(
2254        &self,
2255        vertex: VertexId,
2256    ) -> Option<&[SharedRangeRef<'static>]> {
2257        self.formula_to_range_deps.get(&vertex).map(Vec::as_slice)
2258    }
2259
2260    pub(crate) fn spill_anchors_in_region(
2261        &self,
2262        sheet_id: SheetId,
2263        start_row0: u32,
2264        start_col0: u32,
2265        end_row0: u32,
2266        end_col0: u32,
2267    ) -> Vec<VertexId> {
2268        let mut anchors = self
2269            .spill_cells_by_sheet
2270            .get(&sheet_id)
2271            .into_iter()
2272            .flat_map(|cells| cells.range((start_row0, 0)..=(end_row0, u32::MAX)))
2273            .filter_map(|(&(row, col), anchor)| {
2274                (row <= end_row0 && col >= start_col0 && col <= end_col0).then_some(*anchor)
2275            })
2276            .collect::<Vec<_>>();
2277        anchors.sort_unstable();
2278        anchors.dedup();
2279        anchors
2280    }
2281
2282    /// Get mutable access to a sheet's index, creating it if it doesn't exist
2283    /// This is the primary way VertexEditor and internal operations access the index
2284    pub fn sheet_index_mut(&mut self, sheet_id: SheetId) -> &mut SheetIndex {
2285        self.sheet_indexes.entry(sheet_id).or_default()
2286    }
2287
2288    /// Get immutable access to a sheet's index, returns None if not initialized
2289    pub fn sheet_index(&self, sheet_id: SheetId) -> Option<&SheetIndex> {
2290        self.sheet_indexes.get(&sheet_id)
2291    }
2292
2293    pub(crate) fn sheet_index_vertex_count(&self, sheet_id: SheetId) -> usize {
2294        self.sheet_indexes.get(&sheet_id).map_or(0, SheetIndex::len)
2295            + self
2296                .vertex_formulas
2297                .virtual_members()
2298                .runs_in_sheet(sheet_id)
2299                .map(|r| r.len as usize)
2300                .sum::<usize>()
2301    }
2302
2303    pub(crate) fn set_admission_budget_override(
2304        &mut self,
2305        budgets: Option<crate::engine::EvaluationBudgets>,
2306    ) -> Option<crate::engine::EvaluationBudgets> {
2307        std::mem::replace(&mut self.admission_budget_override, budgets)
2308    }
2309
2310    fn self_admission_budgets(&self) -> crate::engine::EvaluationBudgets {
2311        self.admission_budget_override
2312            .clone()
2313            .unwrap_or_else(|| self.config.resolved_evaluation_budgets())
2314    }
2315
2316    fn preview_spill_materialization(
2317        &self,
2318        target_cells: &[CellRef],
2319    ) -> Result<crate::engine::resource_ledger::GraphAdmission, ExcelError> {
2320        let unique = target_cells.iter().copied().collect::<FxHashSet<_>>();
2321        let added_vertices = unique
2322            .iter()
2323            .filter(|cell| self.cell_vertex(cell).is_none())
2324            .count();
2325        let stats = self.baseline_stats();
2326        Ok(crate::engine::resource_ledger::GraphAdmission {
2327            final_vertices: stats
2328                .graph_vertex_count
2329                .checked_add(added_vertices)
2330                .ok_or_else(|| {
2331                    ExcelError::new(ExcelErrorKind::NImpl)
2332                        .with_message("spill vertex count overflow")
2333                })?,
2334            final_edges: stats.graph_edge_count,
2335            materialization_cells: unique.len() as u64,
2336            added_vertices,
2337            added_edges: 0,
2338        })
2339    }
2340
2341    pub(crate) fn preview_value_mutation(
2342        &self,
2343        sheet_id: SheetId,
2344        row: u32,
2345        col: u32,
2346    ) -> Result<crate::engine::resource_ledger::GraphAdmission, ExcelError> {
2347        let cell = CellRef::new(sheet_id, Coord::from_excel(row, col, true, true));
2348        let existing = self.cell_vertex(&cell);
2349        let stats = self.baseline_stats();
2350        let removed_edges = existing.map_or(0, |vertex| self.store.edge_offset(vertex) as usize);
2351        // A value cell gets no vertex (decision 27).
2352        Ok(crate::engine::resource_ledger::GraphAdmission {
2353            final_vertices: stats.graph_vertex_count,
2354            final_edges: stats
2355                .graph_edge_count
2356                .checked_sub(removed_edges)
2357                .ok_or_else(|| {
2358                    ExcelError::new(ExcelErrorKind::NImpl)
2359                        .with_message("graph edge count underflow")
2360                })?,
2361            materialization_cells: 0,
2362            added_vertices: 0,
2363            added_edges: 0,
2364        })
2365    }
2366
2367    pub(crate) fn preview_value_mutations(
2368        &self,
2369        sheet_id: SheetId,
2370        cells: &[(u32, u32)],
2371    ) -> Result<crate::engine::resource_ledger::GraphAdmission, ExcelError> {
2372        let mut targets = std::collections::BTreeSet::new();
2373        let added_vertices = 0usize;
2374        let mut removed_edges = 0usize;
2375        for (row, col) in cells {
2376            let packed = PackedSheetCell::try_from_excel_1based(sheet_id, *row, *col)
2377                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
2378            if !targets.insert(packed) {
2379                continue;
2380            }
2381            let reference = CellRef::new(sheet_id, Coord::from_excel(*row, *col, true, true));
2382            // A value cell gets no vertex (decision 27).
2383            if let Some(vertex) = self.cell_vertex(&reference) {
2384                removed_edges = removed_edges
2385                    .checked_add(self.store.edge_offset(vertex) as usize)
2386                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::NImpl))?;
2387            }
2388        }
2389        let stats = self.baseline_stats();
2390        Ok(crate::engine::resource_ledger::GraphAdmission {
2391            final_vertices: stats
2392                .graph_vertex_count
2393                .checked_add(added_vertices)
2394                .ok_or_else(|| ExcelError::new(ExcelErrorKind::NImpl))?,
2395            final_edges: stats
2396                .graph_edge_count
2397                .checked_sub(removed_edges)
2398                .ok_or_else(|| ExcelError::new(ExcelErrorKind::NImpl))?,
2399            materialization_cells: 0,
2400            added_vertices,
2401            added_edges: 0,
2402        })
2403    }
2404
2405    pub(crate) fn preview_formula_mutations(
2406        &self,
2407        plans: &[(SheetId, u32, u32, DependencyPlanRow)],
2408    ) -> Result<crate::engine::resource_ledger::GraphAdmission, ExcelError> {
2409        let mut new_cells = std::collections::BTreeSet::new();
2410        let mut removed_edges = 0usize;
2411        let mut added_edges = 0usize;
2412        for (sheet_id, row, col, plan) in plans {
2413            let target = PackedSheetCell::try_from_excel_1based(*sheet_id, *row, *col)
2414                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
2415            let target_ref = CellRef::new(*sheet_id, Coord::from_excel(*row, *col, true, true));
2416            if let Some(vertex) = self.cell_vertex(&target_ref) {
2417                removed_edges = removed_edges
2418                    .checked_add(self.store.edge_offset(vertex) as usize)
2419                    .ok_or_else(|| {
2420                        ExcelError::new(ExcelErrorKind::NImpl)
2421                            .with_message("graph edge count overflow")
2422                    })?;
2423            } else {
2424                new_cells.insert(target);
2425            }
2426
2427            let mut dependencies = std::collections::BTreeSet::new();
2428            for dependency in &plan.direct_cell_deps {
2429                let packed = PackedSheetCell::try_new(
2430                    dependency.sheet_id,
2431                    dependency.coord.row(),
2432                    dependency.coord.col(),
2433                )
2434                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
2435                let reference = CellRef::new(dependency.sheet_id, dependency.coord);
2436                if let Some(vertex) = self.cell_vertex(&reference) {
2437                    dependencies.insert((0u8, u64::from(vertex.0)));
2438                } else {
2439                    new_cells.insert(packed);
2440                    dependencies.insert((1u8, packed.as_u64()));
2441                }
2442            }
2443            for name in plan.resolved_named_refs.iter().chain(&plan.named_refs) {
2444                if let Some(entry) = self.resolve_name_entry(name, *sheet_id) {
2445                    dependencies.insert((0, u64::from(entry.vertex.0)));
2446                } else if let Some(entry) = self.resolve_source_scalar_entry(name) {
2447                    dependencies.insert((0, u64::from(entry.vertex.0)));
2448                }
2449            }
2450            for name in &plan.source_refs {
2451                if let Some(vertex) = self
2452                    .resolve_source_scalar_entry(name)
2453                    .map(|entry| entry.vertex)
2454                    .or_else(|| {
2455                        self.resolve_source_table_entry(name)
2456                            .map(|entry| entry.vertex)
2457                    })
2458                {
2459                    dependencies.insert((0, u64::from(vertex.0)));
2460                }
2461            }
2462            for name in &plan.table_refs {
2463                if let Some(vertex) = self
2464                    .resolve_table_entry(name)
2465                    .map(|entry| entry.vertex)
2466                    .or_else(|| {
2467                        self.resolve_source_table_entry(name)
2468                            .map(|entry| entry.vertex)
2469                    })
2470                {
2471                    dependencies.insert((0, u64::from(vertex.0)));
2472                }
2473            }
2474            let target_row = target.row0();
2475            let target_col = target.col0();
2476            if plan.range_deps.iter().any(|range| {
2477                // `Current` is the formula's own sheet.
2478                let range_sheet = self
2479                    .sheet_reg
2480                    .resolve_locator(&range.sheet, *sheet_id)
2481                    .unwrap_or(*sheet_id);
2482                range_sheet == *sheet_id
2483                    && range
2484                        .start_row
2485                        .is_none_or(|bound| target_row >= bound.index)
2486                    && range.end_row.is_none_or(|bound| target_row <= bound.index)
2487                    && range
2488                        .start_col
2489                        .is_none_or(|bound| target_col >= bound.index)
2490                    && range.end_col.is_none_or(|bound| target_col <= bound.index)
2491            }) {
2492                dependencies.insert((1, target.as_u64()));
2493            }
2494            added_edges = added_edges.checked_add(dependencies.len()).ok_or_else(|| {
2495                ExcelError::new(ExcelErrorKind::NImpl).with_message("graph edge count overflow")
2496            })?;
2497        }
2498        let stats = self.baseline_stats();
2499        Ok(crate::engine::resource_ledger::GraphAdmission {
2500            final_vertices: stats
2501                .graph_vertex_count
2502                .checked_add(new_cells.len())
2503                .ok_or_else(|| {
2504                    ExcelError::new(ExcelErrorKind::NImpl)
2505                        .with_message("graph vertex count overflow")
2506                })?,
2507            final_edges: stats
2508                .graph_edge_count
2509                .checked_sub(removed_edges)
2510                .and_then(|count| count.checked_add(added_edges))
2511                .ok_or_else(|| {
2512                    ExcelError::new(ExcelErrorKind::NImpl).with_message("graph edge count overflow")
2513                })?,
2514            materialization_cells: plans.len() as u64,
2515            added_vertices: new_cells.len(),
2516            added_edges,
2517        })
2518    }
2519
2520    /// Formula cells of `sheet_id` in the rectangle whose formula template
2521    /// satisfies `matches`, as `(col, first_row, last_row)` intervals
2522    /// (0-based, inclusive). A virtual member run is tested once by its
2523    /// shared template and yields at most one clipped interval, so a long
2524    /// filled-down family costs one check instead of one per member;
2525    /// materialized vertices come from the sheet index's smaller axis,
2526    /// filtered by template and then by position, one single-row interval
2527    /// each.
2528    pub(crate) fn formula_intervals_in_region(
2529        &self,
2530        sheet_id: SheetId,
2531        start_row0: u32,
2532        end_row0: u32,
2533        start_col0: u32,
2534        end_col0: u32,
2535        mut matches: impl FnMut(AstNodeId) -> bool,
2536    ) -> Vec<(u32, u32, u32)> {
2537        let mut out = Vec::new();
2538        if let Some(index) = self.sheet_indexes.get(&sheet_id) {
2539            for v in index.rect_candidates(start_row0, end_row0, start_col0, end_col0) {
2540                if !self.formula_view(v).is_some_and(|f| matches(f.template)) {
2541                    continue;
2542                }
2543                if let Some(cell) = self.get_cell_ref(v) {
2544                    let (row, col) = (cell.coord.row(), cell.coord.col());
2545                    if (start_row0..=end_row0).contains(&row)
2546                        && (start_col0..=end_col0).contains(&col)
2547                    {
2548                        out.push((col, row, row));
2549                    }
2550                }
2551            }
2552        }
2553        for r in self
2554            .vertex_formulas
2555            .virtual_members()
2556            .runs_in_cols(sheet_id, start_col0, end_col0)
2557        {
2558            let lo = r.row0.max(start_row0);
2559            let hi = (r.row0 + r.len - 1).min(end_row0);
2560            if lo <= hi && matches(r.template) {
2561                out.push((r.col, lo, hi));
2562            }
2563        }
2564        out
2565    }
2566
2567    pub(crate) fn vertices_in_region(
2568        &self,
2569        sheet_id: SheetId,
2570        start_row0: u32,
2571        end_row0: u32,
2572        start_col0: u32,
2573        end_col0: u32,
2574    ) -> Vec<VertexId> {
2575        let Some(index) = self.sheet_indexes.get(&sheet_id) else {
2576            return Vec::new();
2577        };
2578        let mut out = index.vertices_in_rect(start_row0, end_row0, start_col0, end_col0);
2579        // The index holds materialized vertices; virtual family members
2580        // are indexed by their runs.
2581        for r in self
2582            .vertex_formulas
2583            .virtual_members()
2584            .runs_in_cols(sheet_id, start_col0, end_col0)
2585        {
2586            let lo = r.row0.max(start_row0);
2587            let hi = (r.row0 + r.len - 1).min(end_row0);
2588            if lo <= hi {
2589                out.extend((lo..=hi).map(|row| VertexId(r.first + (row - r.row0))));
2590            }
2591        }
2592        out
2593    }
2594
2595    #[cfg(test)]
2596    pub(crate) fn reset_sheet_index_query_stats(&self) {
2597        for index in self.sheet_indexes.values() {
2598            index.reset_query_stats();
2599        }
2600    }
2601
2602    #[cfg(test)]
2603    pub(crate) fn sheet_index_query_stats(
2604        &self,
2605    ) -> crate::engine::sheet_index::SheetIndexQueryStats {
2606        self.sheet_indexes.values().fold(
2607            crate::engine::sheet_index::SheetIndexQueryStats::default(),
2608            |mut total, index| {
2609                let stats = index.query_stats();
2610                total.coordinate_nodes_visited = total
2611                    .coordinate_nodes_visited
2612                    .saturating_add(stats.coordinate_nodes_visited);
2613                total.values_visited = total.values_visited.saturating_add(stats.values_visited);
2614                total
2615            },
2616        )
2617    }
2618
2619    /// Set a value in a cell, returns affected vertex IDs.
2620    ///
2621    /// Decision 27: a value cell has no vertex. A formula it replaces
2622    /// retires its id (value -> formula at the cell takes it back); the
2623    /// cell's dependents are dirtied by position. Arrow holds the value.
2624    pub fn set_cell_value(
2625        &mut self,
2626        sheet: &str,
2627        row: u32,
2628        col: u32,
2629        value: LiteralValue,
2630    ) -> Result<OperationSummary, ExcelError> {
2631        let _ = normalize_stored_literal(value);
2632        let sheet_id = self.sheet_id_mut(sheet);
2633        let budgets = self.self_admission_budgets();
2634        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
2635            let usage = self.preview_value_mutation(sheet_id, row, col)?;
2636            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
2637                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
2638        }
2639        // External API is 1-based; store 0-based coords internally.
2640        let coord = Coord::from_excel(row, col, true, true);
2641        let addr = CellRef::new(sheet_id, coord);
2642        self.vacate_cell(&addr);
2643        Ok(OperationSummary {
2644            affected_vertices: self.mark_dirty_cells(&[(sheet_id, coord.row(), coord.col())]),
2645            created_placeholders: Vec::new(),
2646        })
2647    }
2648
2649    /// Reserve capacity hints for upcoming bulk cell inserts (values only for now).
2650    pub fn reserve_cells(&mut self, additional: usize) {
2651        self.store.reserve(additional);
2652        if self.value_cache_enabled {
2653            self.vertex_values.reserve(additional);
2654        }
2655        self.cell_to_vertex.reserve(additional);
2656        // sheet_indexes: cannot easily reserve per-sheet without distribution; skip.
2657    }
2658
2659    /// Fast path for initial bulk load of value cells: avoids dirty propagation & dependency work.
2660    /// A value cell gets no vertex (decision 27); a formula there retires.
2661    pub fn set_cell_value_bulk_untracked(
2662        &mut self,
2663        sheet: &str,
2664        row: u32,
2665        col: u32,
2666        value: LiteralValue,
2667    ) -> Result<(), ExcelError> {
2668        let _ = normalize_stored_literal(value);
2669        let sheet_id = self.sheet_id_mut(sheet);
2670        let budgets = self.self_admission_budgets();
2671        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
2672            let usage = self.preview_value_mutation(sheet_id, row, col)?;
2673            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
2674                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
2675        }
2676        let coord = Coord::from_excel(row, col, true, true);
2677        self.vacate_cell(&CellRef::new(sheet_id, coord));
2678        Ok(())
2679    }
2680
2681    /// Bulk insert a collection of plain value cells (no formulas): value
2682    /// cells get no vertex (decision 27); a formula at such a cell retires.
2683    /// No dirty propagation (load paths).
2684    pub fn bulk_insert_values<I>(&mut self, sheet: &str, cells: I) -> Result<(), ExcelError>
2685    where
2686        I: IntoIterator<Item = (u32, u32, LiteralValue)>,
2687    {
2688        let collected: Vec<(u32, u32, LiteralValue)> = cells.into_iter().collect();
2689        if collected.is_empty() {
2690            return Ok(());
2691        }
2692        let sheet_id = self.sheet_id_mut(sheet);
2693        let budgets = self.self_admission_budgets();
2694        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
2695            let coordinates = collected
2696                .iter()
2697                .map(|(row, col, _)| (*row, *col))
2698                .collect::<Vec<_>>();
2699            let usage = self.preview_value_mutations(sheet_id, &coordinates)?;
2700            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
2701                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
2702        }
2703        // During initial ingest the caller may guarantee the cells are new.
2704        let assume_new = self.first_load_assume_new
2705            && self
2706                .sheet_id(sheet)
2707                .map(|sid| !self.ensure_touched_sheets.contains(&sid))
2708                .unwrap_or(false);
2709        if assume_new {
2710            for (row, col, _) in collected {
2711                self.extent_record
2712                    .note(sheet_id, row.saturating_sub(1), col.saturating_sub(1));
2713            }
2714            return Ok(());
2715        }
2716        for (row, col, _) in collected {
2717            let coord = Coord::from_excel(row, col, true, true);
2718            self.vacate_cell(&CellRef::new(sheet_id, coord));
2719        }
2720        Ok(())
2721    }
2722
2723    /// Set a formula in a cell, returns affected vertex IDs
2724    pub fn set_cell_formula(
2725        &mut self,
2726        sheet: &str,
2727        row: u32,
2728        col: u32,
2729        ast: ASTNode,
2730    ) -> Result<OperationSummary, ExcelError> {
2731        self.set_cell_formula_with_volatility(sheet, row, col, ast, false)
2732    }
2733
2734    /// Set a formula in a cell. The volatility argument is retained for API compatibility;
2735    /// dependency flags now come from `IngestPipeline`.
2736    pub fn set_cell_formula_with_volatility(
2737        &mut self,
2738        sheet: &str,
2739        row: u32,
2740        col: u32,
2741        ast: ASTNode,
2742        _volatile: bool,
2743    ) -> Result<OperationSummary, ExcelError> {
2744        let sheet_id = self.sheet_id_mut(sheet);
2745        let placement = CellRef::new(sheet_id, Coord::from_excel(row, col, true, true));
2746        let provider = RegistryFunctionProvider;
2747        let ingested = {
2748            let mut pipeline = self.ingest_pipeline(&provider);
2749            pipeline.ingest_formula(FormulaAstInput::Tree(ast), placement, None)?
2750        };
2751        self.set_cell_formula_with_plan(
2752            sheet,
2753            row,
2754            col,
2755            ingested.ast_id,
2756            &ingested.dep_plan,
2757            ingested.dep_plan.volatile,
2758            ingested.dep_plan.dynamic,
2759        )
2760    }
2761
2762    pub(crate) fn set_cell_formula_with_plan(
2763        &mut self,
2764        sheet: &str,
2765        row: u32,
2766        col: u32,
2767        ast_id: AstNodeId,
2768        plan: &DependencyPlanRow,
2769        volatile: bool,
2770        dynamic: bool,
2771    ) -> Result<OperationSummary, ExcelError> {
2772        let dbg = std::env::var("FZ_DEBUG_LOAD")
2773            .ok()
2774            .is_some_and(|v| v != "0");
2775        let dep_ms_thresh: u128 = std::env::var("FZ_DEBUG_DEP_MS")
2776            .ok()
2777            .and_then(|s| s.parse().ok())
2778            .unwrap_or(0);
2779        let sample_n: usize = std::env::var("FZ_DEBUG_SAMPLE_N")
2780            .ok()
2781            .and_then(|s| s.parse().ok())
2782            .unwrap_or(0);
2783        let t0 = if dbg {
2784            Some(crate::instant::FzInstant::now())
2785        } else {
2786            None
2787        };
2788        let sheet_id = self.sheet_id_mut(sheet);
2789        let budgets = self.self_admission_budgets();
2790        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
2791            let usage = self.preview_formula_mutations(&[(sheet_id, row, col, plan.clone())])?;
2792            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
2793                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
2794        }
2795        let coord = Coord::from_excel(row, col, true, true);
2796        let addr = CellRef::new(sheet_id, coord);
2797
2798        let t_dep0 = if dbg {
2799            Some(crate::instant::FzInstant::now())
2800        } else {
2801            None
2802        };
2803        let mut created_placeholders = Vec::new();
2804        let (mut new_dependencies, vertexless_deps) =
2805            self.resolve_direct_deps(&plan.direct_cell_deps);
2806        let mut named_dependencies = Vec::new();
2807        let mut unresolved_names = Vec::new();
2808        for name in plan
2809            .resolved_named_refs
2810            .iter()
2811            .chain(plan.named_refs.iter())
2812        {
2813            if let Some(named) = self.resolve_name_entry(name, sheet_id) {
2814                if !new_dependencies.contains(&named.vertex) {
2815                    new_dependencies.push(named.vertex);
2816                }
2817                if !named_dependencies.contains(&named.vertex) {
2818                    named_dependencies.push(named.vertex);
2819                }
2820            } else if let Some(source) = self.resolve_source_scalar_entry(name) {
2821                if !new_dependencies.contains(&source.vertex) {
2822                    new_dependencies.push(source.vertex);
2823                }
2824            } else {
2825                unresolved_names.push(name.clone());
2826            }
2827        }
2828        for source_name in &plan.source_refs {
2829            if let Some(source) = self.resolve_source_scalar_entry(source_name) {
2830                if !new_dependencies.contains(&source.vertex) {
2831                    new_dependencies.push(source.vertex);
2832                }
2833            } else if let Some(source) = self.resolve_source_table_entry(source_name)
2834                && !new_dependencies.contains(&source.vertex)
2835            {
2836                new_dependencies.push(source.vertex);
2837            }
2838        }
2839        for table_name in &plan.table_refs {
2840            if let Some(table) = self.resolve_table_entry(table_name) {
2841                if !new_dependencies.contains(&table.vertex) {
2842                    new_dependencies.push(table.vertex);
2843                }
2844            } else if let Some(source) = self.resolve_source_table_entry(table_name)
2845                && !new_dependencies.contains(&source.vertex)
2846            {
2847                new_dependencies.push(source.vertex);
2848            }
2849        }
2850        if let (true, Some(t)) = (dbg, t_dep0) {
2851            let elapsed = t.elapsed().as_millis();
2852            let do_log = (dep_ms_thresh > 0 && elapsed >= dep_ms_thresh)
2853                || (sample_n > 0 && (row as usize).is_multiple_of(sample_n));
2854            if (dep_ms_thresh == 0 && sample_n == 0 && row.is_multiple_of(1000)) || do_log {
2855                eprintln!(
2856                    "[fz][dep] {}!{} planned: deps={}, ranges={}, placeholders={}, names={} in {} ms",
2857                    self.sheet_name(sheet_id),
2858                    crate::reference::Coord::from_excel(row, col, true, true),
2859                    new_dependencies.len(),
2860                    plan.range_deps.len(),
2861                    created_placeholders.len(),
2862                    named_dependencies.len(),
2863                    elapsed
2864                );
2865            }
2866        }
2867
2868        // Check for self-reference (immediate cycle detection)
2869        self.replay_formula_vertex(&addr);
2870        let addr_vertex_id = self.get_or_create_vertex(&addr, &mut created_placeholders);
2871        self.materialize_vertex(addr_vertex_id);
2872
2873        // Editing a formula clears any prior structural #REF! marking for this vertex.
2874        self.ref_error_vertices.remove(&addr_vertex_id);
2875
2876        // Under `CyclePolicy::Iterate` (Runtime detection) self-dependencies
2877        // are accepted, mirroring Excel with iterative calculation enabled:
2878        // the self-edge forms a single-vertex SCC that the scheduler emits as
2879        // a Cycle unit and `evaluate_scc_unit` iterates (RFC #113, spec §7.1/
2880        // §7.6/§7.8). Everywhere else the edit-time rejection stands.
2881        //
2882        // Scope note (persistence contract, pinned by
2883        // `formualizer-workbook/tests/cycle_persistence.rs`): this rejection
2884        // is an INTERACTIVE-EDIT nicety only. Bulk load paths
2885        // (`ingest_formula_batches` → `BulkIngestBuilder`, incl. staged
2886        // `build_graph_all`) intentionally do not perform it, so workbooks
2887        // saved with self-references under an Iterate config always reload —
2888        // under any cycle config — and resolve to `#CIRC!`/iteration at
2889        // evaluation time per the loaded policy.
2890        let self_reference = new_dependencies.contains(&addr_vertex_id)
2891            || vertexless_deps.iter().any(|c| same_cell(c, &addr));
2892        if self_reference && !self.config.cycle.allows_self_dependency() {
2893            return Err(ExcelError::new(ExcelErrorKind::Circ)
2894                .with_message("Self-reference detected".to_string()));
2895        }
2896
2897        for &name_vertex in &named_dependencies {
2898            let mut visited = FxHashSet::default();
2899            if self.name_depends_on_vertex(name_vertex, addr_vertex_id, &mut visited) {
2900                return Err(ExcelError::new(ExcelErrorKind::Circ)
2901                    .with_message("Circular reference through named range".to_string()));
2902            }
2903        }
2904
2905        // Remove old dependencies first
2906        self.forget_declared_dynamic_anchor(addr_vertex_id);
2907        self.remove_dependent_edges(addr_vertex_id);
2908        self.detach_vertex_from_names(addr_vertex_id);
2909        self.clear_pending_name_references(addr_vertex_id);
2910
2911        // Update vertex properties
2912        self.store
2913            .set_kind(addr_vertex_id, VertexKind::FormulaScalar);
2914        self.vertex_formulas.insert(addr_vertex_id, ast_id);
2915        self.store.set_dirty(addr_vertex_id, true);
2916
2917        // Clear any cached value since this is now a formula
2918        self.vertex_values.remove(&addr_vertex_id);
2919
2920        self.mark_volatile(addr_vertex_id, volatile);
2921        self.store.set_dynamic(addr_vertex_id, dynamic);
2922
2923        if !named_dependencies.is_empty() {
2924            self.attach_vertex_to_names(addr_vertex_id, &named_dependencies);
2925        }
2926        for unresolved_name in &unresolved_names {
2927            self.record_pending_name_reference(sheet_id, unresolved_name, addr_vertex_id);
2928        }
2929
2930        if let (true, Some(t)) = (dbg, t0) {
2931            let elapsed = t.elapsed().as_millis();
2932            let log_set = dep_ms_thresh > 0 && elapsed >= dep_ms_thresh;
2933            if log_set {
2934                eprintln!(
2935                    "[fz][set] {}!{} total {} ms",
2936                    self.sheet_name(sheet_id),
2937                    crate::reference::Coord::from_excel(row, col, true, true),
2938                    elapsed
2939                );
2940            }
2941        }
2942
2943        // Add new dependency edges (a self-reference whose cell had no
2944        // vertex before is an edge to the formula's own vertex).
2945        let vertexless_deps: Vec<CellRef> = vertexless_deps
2946            .into_iter()
2947            .filter(|c| {
2948                if same_cell(c, &addr) {
2949                    if !new_dependencies.contains(&addr_vertex_id) {
2950                        new_dependencies.push(addr_vertex_id);
2951                    }
2952                    false
2953                } else {
2954                    true
2955                }
2956            })
2957            .collect();
2958        self.add_dependent_edges(addr_vertex_id, &new_dependencies);
2959        self.note_vertexless_deps(
2960            addr_vertex_id,
2961            vertexless_deps
2962                .iter()
2963                .map(|c| (c.sheet_id, c.coord.row(), c.coord.col())),
2964        );
2965        self.add_range_dependent_edges(addr_vertex_id, &plan.range_deps, sheet_id);
2966
2967        Ok(OperationSummary {
2968            affected_vertices: self.mark_dirty(addr_vertex_id),
2969            created_placeholders,
2970        })
2971    }
2972
2973    pub(crate) fn rewrite_structured_references_for_cell(
2974        &self,
2975        ast: &mut ASTNode,
2976        cell: CellRef,
2977    ) -> Result<bool, ExcelError> {
2978        self.rewrite_structured_references_node(ast, cell)
2979    }
2980
2981    fn rewrite_structured_references_node(
2982        &self,
2983        node: &mut ASTNode,
2984        cell: CellRef,
2985    ) -> Result<bool, ExcelError> {
2986        match &mut node.node_type {
2987            ASTNodeType::Reference { reference, .. } => {
2988                self.rewrite_structured_reference(reference, cell)
2989            }
2990            ASTNodeType::UnaryOp { expr, .. } => {
2991                self.rewrite_structured_references_node(expr, cell)
2992            }
2993            ASTNodeType::BinaryOp { left, right, .. } => {
2994                let left_rewritten = self.rewrite_structured_references_node(left, cell)?;
2995                let right_rewritten = self.rewrite_structured_references_node(right, cell)?;
2996                Ok(left_rewritten || right_rewritten)
2997            }
2998            ASTNodeType::Function { args, .. } => {
2999                let mut rewritten = false;
3000                for a in args.iter_mut() {
3001                    rewritten |= self.rewrite_structured_references_node(a, cell)?;
3002                }
3003                Ok(rewritten)
3004            }
3005            ASTNodeType::Call { callee, args } => {
3006                let mut rewritten = self.rewrite_structured_references_node(callee, cell)?;
3007                for a in args.iter_mut() {
3008                    rewritten |= self.rewrite_structured_references_node(a, cell)?;
3009                }
3010                Ok(rewritten)
3011            }
3012            ASTNodeType::Array(rows) => {
3013                let mut rewritten = false;
3014                for r in rows.iter_mut() {
3015                    for item in r.iter_mut() {
3016                        rewritten |= self.rewrite_structured_references_node(item, cell)?;
3017                    }
3018                }
3019                Ok(rewritten)
3020            }
3021            ASTNodeType::Literal(_) | ASTNodeType::Omitted => Ok(false),
3022        }
3023    }
3024
3025    fn rewrite_structured_reference(
3026        &self,
3027        reference: &mut ReferenceType,
3028        cell: CellRef,
3029    ) -> Result<bool, ExcelError> {
3030        use formualizer_parse::parser::{SpecialItem, TableSpecifier};
3031
3032        let ReferenceType::Table(tref) = reference else {
3033            return Ok(false);
3034        };
3035
3036        // This-row shorthand: parsed as an unnamed table reference with a Combination specifier.
3037        if !tref.name.is_empty() {
3038            return Ok(false);
3039        }
3040
3041        let col_name = match &tref.specifier {
3042            Some(TableSpecifier::Combination(parts)) => {
3043                let mut saw_this_row = false;
3044                let mut col: Option<&str> = None;
3045                for p in parts {
3046                    match p.as_ref() {
3047                        TableSpecifier::SpecialItem(SpecialItem::ThisRow) => {
3048                            saw_this_row = true;
3049                        }
3050                        TableSpecifier::Column(c) => {
3051                            if col.is_some() {
3052                                return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
3053                                    "This-row structured reference with multiple columns is not supported"
3054                                        .to_string(),
3055                                ));
3056                            }
3057                            col = Some(c.as_str());
3058                        }
3059                        other => {
3060                            return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
3061                                format!(
3062                                    "Unsupported this-row structured reference component: {other}"
3063                                ),
3064                            ));
3065                        }
3066                    }
3067                }
3068                if !saw_this_row {
3069                    return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
3070                        "Unnamed structured reference requires a this-row selector".to_string(),
3071                    ));
3072                }
3073                col.ok_or_else(|| {
3074                    ExcelError::new(ExcelErrorKind::NImpl).with_message(
3075                        "This-row structured reference missing column selector".to_string(),
3076                    )
3077                })?
3078            }
3079            _ => {
3080                return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
3081                    "Unnamed structured reference form is not supported".to_string(),
3082                ));
3083            }
3084        };
3085
3086        let Some(table) = self.find_table_containing_cell(cell) else {
3087            return Err(ExcelError::new(ExcelErrorKind::Name)
3088                .with_message("This-row structured reference used outside a table".to_string()));
3089        };
3090
3091        let row0 = cell.coord.row();
3092        let col0 = cell.coord.col();
3093        let sr0 = table.range.start.coord.row();
3094        let sc0 = table.range.start.coord.col();
3095        let er0 = table.range.end.coord.row();
3096        let ec0 = table.range.end.coord.col();
3097
3098        if row0 < sr0 || row0 > er0 || col0 < sc0 || col0 > ec0 {
3099            return Err(ExcelError::new(ExcelErrorKind::Name)
3100                .with_message("This-row structured reference used outside a table".to_string()));
3101        }
3102
3103        if table.header_row && row0 == sr0 {
3104            return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(
3105                "This-row structured references are not valid in the table header row".to_string(),
3106            ));
3107        }
3108
3109        let data_start = if table.header_row { sr0 + 1 } else { sr0 };
3110        if row0 < data_start {
3111            return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(
3112                "This-row structured references require a data/totals row context".to_string(),
3113            ));
3114        }
3115
3116        let Some(idx) = table.col_index(col_name) else {
3117            return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(format!(
3118                "Unknown table column in this-row reference: {col_name}"
3119            )));
3120        };
3121        let target_col0 = sc0 + (idx as u32);
3122        let target_row = row0 + 1;
3123        let target_col = target_col0 + 1;
3124
3125        *reference = ReferenceType::Cell {
3126            sheet: None,
3127            row: target_row,
3128            col: target_col,
3129            row_abs: true,
3130            col_abs: true,
3131        };
3132
3133        Ok(true)
3134    }
3135
3136    fn find_table_containing_cell(&self, cell: CellRef) -> Option<&tables::TableEntry> {
3137        let row0 = cell.coord.row();
3138        let col0 = cell.coord.col();
3139
3140        let mut best: Option<&tables::TableEntry> = None;
3141        let mut best_area: u64 = u64::MAX;
3142        let mut best_name: &str = "";
3143
3144        for t in self.tables.values() {
3145            if t.sheet_id() != cell.sheet_id {
3146                continue;
3147            }
3148            let sr0 = t.range.start.coord.row();
3149            let sc0 = t.range.start.coord.col();
3150            let er0 = t.range.end.coord.row();
3151            let ec0 = t.range.end.coord.col();
3152            if row0 < sr0 || row0 > er0 || col0 < sc0 || col0 > ec0 {
3153                continue;
3154            }
3155
3156            let h = (er0 - sr0 + 1) as u64;
3157            let w = (ec0 - sc0 + 1) as u64;
3158            let area = h.saturating_mul(w);
3159            let name = t.name.as_str();
3160            let better = match best {
3161                None => true,
3162                Some(_) => area < best_area || (area == best_area && name < best_name),
3163            };
3164            if better {
3165                best = Some(t);
3166                best_area = area;
3167                best_name = name;
3168            }
3169        }
3170
3171        best
3172    }
3173
3174    #[allow(clippy::type_complexity)]
3175    pub(crate) fn fp8_parity_extract_dependencies_with_pending_names(
3176        &mut self,
3177        ast: &ASTNode,
3178        current_sheet_id: SheetId,
3179    ) -> Result<
3180        (
3181            Vec<VertexId>,
3182            Vec<SharedRangeRef<'static>>,
3183            Vec<CellRef>,
3184            Vec<VertexId>,
3185            Vec<String>,
3186        ),
3187        ExcelError,
3188    > {
3189        self.extract_dependencies_with_pending_names(ast, current_sheet_id)
3190    }
3191
3192    pub(crate) fn fp8_parity_is_ast_volatile(&self, ast: &ASTNode) -> bool {
3193        self.is_ast_volatile(ast)
3194    }
3195
3196    pub fn set_cell_value_ref(
3197        &mut self,
3198        cell: formualizer_common::SheetCellRef<'_>,
3199        value: LiteralValue,
3200    ) -> Result<OperationSummary, ExcelError> {
3201        let owned = cell.into_owned();
3202        let sheet_id = match owned.sheet {
3203            formualizer_common::SheetLocator::Id(id) => id,
3204            formualizer_common::SheetLocator::Name(name) => self.sheet_id_mut(name.as_ref()),
3205            formualizer_common::SheetLocator::Current => self.default_sheet_id,
3206        };
3207        let sheet_name = self.sheet_name(sheet_id).to_string();
3208        self.set_cell_value(
3209            &sheet_name,
3210            owned.coord.row() + 1,
3211            owned.coord.col() + 1,
3212            value,
3213        )
3214    }
3215
3216    pub fn set_cell_formula_ref(
3217        &mut self,
3218        cell: formualizer_common::SheetCellRef<'_>,
3219        ast: ASTNode,
3220    ) -> Result<OperationSummary, ExcelError> {
3221        let owned = cell.into_owned();
3222        let sheet_id = match owned.sheet {
3223            formualizer_common::SheetLocator::Id(id) => id,
3224            formualizer_common::SheetLocator::Name(name) => self.sheet_id_mut(name.as_ref()),
3225            formualizer_common::SheetLocator::Current => self.default_sheet_id,
3226        };
3227        let sheet_name = self.sheet_name(sheet_id).to_string();
3228        self.set_cell_formula(
3229            &sheet_name,
3230            owned.coord.row() + 1,
3231            owned.coord.col() + 1,
3232            ast,
3233        )
3234    }
3235
3236    pub fn get_cell_value_ref(
3237        &self,
3238        cell: formualizer_common::SheetCellRef<'_>,
3239    ) -> Option<LiteralValue> {
3240        let owned = cell.into_owned();
3241        let sheet_id = match owned.sheet {
3242            formualizer_common::SheetLocator::Id(id) => id,
3243            formualizer_common::SheetLocator::Name(name) => self.sheet_id(name.as_ref())?,
3244            formualizer_common::SheetLocator::Current => self.default_sheet_id,
3245        };
3246        let sheet_name = self.sheet_name(sheet_id);
3247        self.get_cell_value(sheet_name, owned.coord.row() + 1, owned.coord.col() + 1)
3248    }
3249
3250    /// Get current value from a cell
3251    pub fn get_cell_value(&self, sheet: &str, row: u32, col: u32) -> Option<LiteralValue> {
3252        if !self.value_cache_enabled {
3253            #[cfg(debug_assertions)]
3254            {
3255                self.graph_value_read_attempts
3256                    .fetch_add(1, Ordering::Relaxed);
3257            }
3258            return None;
3259        }
3260        let sheet_id = self.sheet_reg.get_id(sheet)?;
3261        let coord = Coord::from_excel(row, col, true, true);
3262        let addr = CellRef::new(sheet_id, coord);
3263
3264        self.get_vertex_id_for_address(&addr).and_then(|vertex_id| {
3265            // Check values hashmap (stores both cell values and formula results)
3266            self.vertex_values
3267                .get(&vertex_id)
3268                .map(|&value_ref| self.data_store.retrieve_value(value_ref))
3269        })
3270    }
3271
3272    /// Mark vertex dirty and propagate to dependents
3273    fn mark_dirty(&mut self, vertex_id: VertexId) -> Vec<VertexId> {
3274        self.mark_dirty_many(&[vertex_id])
3275    }
3276
3277    /// Multi-source `mark_dirty`: one BFS with a shared seen-set across all
3278    /// sources, marking exactly the union of per-source `mark_dirty` calls
3279    /// but visiting every vertex at most once per call.
3280    ///
3281    /// Loop-of-`mark_dirty` callers (volatile redirty, iterative-SCC redirty)
3282    /// pay O(sources × component) without this — measured quadratic by the
3283    /// iterate edge corpus. A BFS that early-stops at already-`is_dirty`
3284    /// vertices would also fix that, but it is NOT safe in general: several
3285    /// call sites set the dirty flag WITHOUT propagating to dependents
3286    /// (`DependencyGraph::set_dirty`, `mark_dependents_dirty`, names.rs
3287    /// binding invalidation, eval.rs demand-driven re-marks), so "dirty"
3288    /// does not imply "my dependents are already dirty". The per-call shared
3289    /// seen-set needs no such invariant.
3290    ///
3291    /// While a deferred-dirty scope is active (`begin_deferred_dirty`), the
3292    /// call queues its sources for the end-of-scope flush and returns ONLY
3293    /// the sources as the "affected" set (the full transitive set is
3294    /// produced once by the flush). Loop-of-edits callers must not rely on
3295    /// per-edit transitive affected sets inside such a scope.
3296    pub(crate) fn mark_dirty_many(&mut self, vertex_ids: &[VertexId]) -> Vec<VertexId> {
3297        if self.deferred_dirty_depth > 0 {
3298            self.deferred_dirty_pending.extend_from_slice(vertex_ids);
3299            return vertex_ids.to_vec();
3300        }
3301        self.authority_mark_dirty(vertex_ids)
3302    }
3303
3304    /// Total vertices processed by dirty-propagation BFS loops since graph
3305    /// creation (perf-shape observability; see `dirty_propagation_visits`).
3306    pub(crate) fn dirty_propagation_visits(&self) -> u64 {
3307        self.dirty_propagation_visits
3308    }
3309
3310    /// Begin a deferred-dirty scope for a multi-edit batch.
3311    ///
3312    /// While active, `mark_dirty` / `mark_dirty_many` /
3313    /// `mark_dirty_many_value_cells` queue their sources instead of running a
3314    /// BFS per call; the outermost `end_deferred_dirty` flushes the queued
3315    /// union with ONE multi-source `mark_dirty_many`. Union semantics equal
3316    /// the sequential per-edit calls (pinned by
3317    /// `mark_dirty_many_equals_sequential_single_source_marks` plus the
3318    /// deferred-scope tests): any dependent edge removed mid-batch belongs to
3319    /// a vertex that was itself edited mid-batch, and edited vertices are
3320    /// themselves pending sources, so the flush covers everything a per-edit
3321    /// propagation would have reached.
3322    ///
3323    /// Nesting is depth-counted. The scope also enters the CSR edge batch
3324    /// (`begin_batch`) so edge-heavy batches amortize delta rebuilds (#127).
3325    ///
3326    /// Callers MUST guarantee `end_deferred_dirty` runs on every exit path
3327    /// (including `?` early returns): a leaked scope would silently swallow
3328    /// future propagations. Evaluation entry points `debug_assert` that no
3329    /// scope is active.
3330    pub fn begin_deferred_dirty(&mut self) {
3331        #[cfg(any(test, feature = "legacy_oracle"))]
3332        self.edges.begin_batch();
3333        self.deferred_dirty_depth += 1;
3334    }
3335
3336    /// End a deferred-dirty scope. When the outermost scope ends, runs ONE
3337    /// multi-source propagation over every source queued while deferred and
3338    /// returns its full affected set (sources pointing at vertices deleted
3339    /// mid-batch are skipped). Inner (nested) ends return an empty set.
3340    pub fn end_deferred_dirty(&mut self) -> Vec<VertexId> {
3341        debug_assert!(
3342            self.deferred_dirty_depth > 0,
3343            "end_deferred_dirty without matching begin_deferred_dirty"
3344        );
3345        #[cfg(any(test, feature = "legacy_oracle"))]
3346        self.edges.end_batch();
3347        self.deferred_dirty_depth = self.deferred_dirty_depth.saturating_sub(1);
3348        if self.deferred_dirty_depth > 0 {
3349            return Vec::new();
3350        }
3351        let pending = std::mem::take(&mut self.deferred_dirty_pending);
3352        let rects = std::mem::take(&mut self.deferred_dirty_pending_rects);
3353        let mut affected = self.authority_mark_dirty_rects(&rects);
3354        if pending.is_empty() {
3355            return affected;
3356        }
3357        let live: Vec<VertexId> = pending
3358            .into_iter()
3359            .filter(|&id| self.vertex_exists(id))
3360            .collect();
3361        affected.extend(self.mark_dirty_many(&live));
3362        affected
3363    }
3364
3365    /// Dirty the transitive dependents of `cells` (0-based `(sheet, row,
3366    /// col)`): a value edit, whose cell has no vertex (decision 27).
3367    pub(crate) fn mark_dirty_cells(
3368        &mut self,
3369        cells: &[crate::engine::authority::geom::Cell],
3370    ) -> Vec<VertexId> {
3371        let rects: Vec<(SheetId, u32, u32, u32, u32)> =
3372            cells.iter().map(|&(s, r, c)| (s, r, r, c, c)).collect();
3373        self.mark_dirty_rects(&rects)
3374    }
3375
3376    /// Dirty the transitive dependents of rectangles `(sheet, r0, r1, c0,
3377    /// c1)` (0-based, inclusive).
3378    pub(crate) fn mark_dirty_rects(
3379        &mut self,
3380        rects: &[(SheetId, u32, u32, u32, u32)],
3381    ) -> Vec<VertexId> {
3382        let rects: Vec<(u16, crate::engine::authority::geom::Rect)> = rects
3383            .iter()
3384            .map(|&(s, r0, r1, c0, c1)| {
3385                (s, crate::engine::authority::geom::Rect::new(r0, c0, r1, c1))
3386            })
3387            .collect();
3388        if self.deferred_dirty_depth > 0 {
3389            self.deferred_dirty_pending_rects.extend_from_slice(&rects);
3390            return Vec::new();
3391        }
3392        self.authority_mark_dirty_rects(&rects)
3393    }
3394
3395    /// True while a deferred-dirty scope is active (see
3396    /// `begin_deferred_dirty`). Evaluation must never start in this state.
3397    pub fn deferred_dirty_active(&self) -> bool {
3398        self.deferred_dirty_depth > 0
3399    }
3400
3401    /// Get all vertices that need evaluation
3402    pub fn get_evaluation_vertices(&self) -> Vec<VertexId> {
3403        // Both sources are sets; sort + dedup instead of a merged hash set
3404        // (a full recalc lists every formula here).
3405        let mut result: Vec<VertexId> = self
3406            .formula_dirty
3407            .legacy_iter()
3408            .chain(self.volatile_vertices.iter().copied())
3409            .filter(|&id| {
3410                // Only include active formula/name vertices; tombstoned vertices can retain stable
3411                // IDs in the store, but must never be scheduled for evaluation.
3412                self.store.vertex_exists_active(id)
3413                    && matches!(
3414                        self.store.kind(id),
3415                        VertexKind::FormulaScalar
3416                            | VertexKind::FormulaArray
3417                            | VertexKind::NamedScalar
3418                            | VertexKind::NamedArray
3419                    )
3420            })
3421            .collect();
3422        result.sort_unstable();
3423        result.dedup();
3424        result
3425    }
3426
3427    /// Whether a dirty (not merely volatile) vertex would be scheduled by
3428    /// [`Self::get_evaluation_vertices`]: the freshness replan condition.
3429    pub(crate) fn has_dirty_evaluation_vertices(&self) -> bool {
3430        self.formula_dirty.legacy_iter().any(|id| {
3431            self.store.vertex_exists_active(id)
3432                && matches!(
3433                    self.store.kind(id),
3434                    VertexKind::FormulaScalar
3435                        | VertexKind::FormulaArray
3436                        | VertexKind::NamedScalar
3437                        | VertexKind::NamedArray
3438                )
3439        })
3440    }
3441
3442    /// Clear dirty flags after successful evaluation
3443    pub fn clear_dirty_flags(&mut self, vertices: &[VertexId]) {
3444        for &vertex_id in vertices {
3445            self.store.set_dirty(vertex_id, false);
3446            self.formula_dirty.legacy_remove(&vertex_id);
3447        }
3448        self.formula_dirty.legacy_shrink_if_sparse();
3449        self.authority_observe_clean(vertices);
3450    }
3451
3452    /// 🔮 Scalability Hook: Clear volatile vertices after evaluation cycle
3453    pub fn clear_volatile_flags(&mut self) {
3454        self.volatile_vertices.clear();
3455    }
3456
3457    /// Re-marks all volatile vertices as dirty for the next evaluation cycle.
3458    /// One multi-source propagation: many volatiles feeding one dependent
3459    /// component used to pay O(volatiles × component) (a full `mark_dirty`
3460    /// BFS per volatile); `mark_dirty_many` visits the component once.
3461    pub(crate) fn redirty_volatiles(&mut self) {
3462        let volatile_ids: Vec<VertexId> = self.volatile_vertices.iter().copied().collect();
3463        let _ = self.mark_dirty_many(&volatile_ids);
3464    }
3465
3466    /// Re-marks members of iterating SCCs (and, via propagation, their
3467    /// dependents) dirty for the next evaluation cycle — the volatile-like
3468    /// redirty that keeps `CyclePolicy::Iterate` cells re-evaluating every
3469    /// recalc (RFC #113; spec §4/§7.6). Vertices deleted since the recalc
3470    /// are skipped.
3471    ///
3472    /// One multi-source propagation: the old per-member `mark_dirty` loop was
3473    /// O(|SCC|²) per recalc for a large SCC (a converged 1000-member ring
3474    /// cost ~42 ms per no-op recalc, release); an interim `!is_dirty` skip
3475    /// fixed that but leaned on dirty-flag semantics that non-propagating
3476    /// `set_dirty` callers do not uphold. The shared seen-set in
3477    /// `mark_dirty_many` is O(component) without any such invariant.
3478    pub(crate) fn redirty_iterative_members(&mut self, members: &[VertexId]) {
3479        let live: Vec<VertexId> = members
3480            .iter()
3481            .copied()
3482            .filter(|&id| self.vertex_exists(id))
3483            .collect();
3484        let _ = self.mark_dirty_many(&live);
3485    }
3486
3487    /// The vertex of a referenced cell, if it has one (Program 3, decision
3488    /// 27): a value cell or an empty cell has none, and a reference never
3489    /// creates one. The authority tracks references by position.
3490    pub(crate) fn dep_vertex(&mut self, addr: &CellRef) -> Option<VertexId> {
3491        if let Some(vertex_id) = self.cell_vertex(addr) {
3492            return Some(vertex_id);
3493        }
3494        if self.first_load_assume_new {
3495            let packed = Self::packed_cell_key(
3496                addr.sheet_id,
3497                AbsCoord::new(addr.coord.row(), addr.coord.col()),
3498            );
3499            if let Some(&existing) = self.load_packed_to_vertex.get(&packed) {
3500                self.cell_to_vertex.insert(*addr, existing);
3501                return Some(existing);
3502            }
3503        }
3504        None
3505    }
3506
3507    /// Decision 27: a value (or nothing) now fills `addr`, which keeps no
3508    /// vertex. A formula's id retires into the side table (value -> formula
3509    /// at the cell takes it back); any other vertex (a value vertex of an
3510    /// older state, a revived placeholder) is tombstoned. Returns the vertex
3511    /// that left and whether it held a formula.
3512    pub(crate) fn vacate_cell(&mut self, addr: &CellRef) -> Option<(VertexId, bool)> {
3513        // The legacy graph kept (or created) a value vertex here: it counts
3514        // toward the used extent.
3515        self.extent_record
3516            .note(addr.sheet_id, addr.coord.row(), addr.coord.col());
3517        let v = self.cell_vertex_mut(addr)?;
3518        let was_formula = matches!(
3519            self.store.kind(v),
3520            VertexKind::FormulaScalar | VertexKind::FormulaArray
3521        ) || self.vertex_formulas.contains_key(&v);
3522        self.remove_dependent_edges(v);
3523        self.detach_vertex_from_names(v);
3524        self.clear_pending_name_references(v);
3525        self.forget_declared_dynamic_anchor(v);
3526        self.vertex_formulas.remove(&v);
3527        self.vertex_values.remove(&v);
3528        self.ref_error_vertices.remove(&v);
3529        self.clear_formula_vertex_dirty(v);
3530        self.mark_volatile(v, false);
3531        self.store.set_dynamic(v, false);
3532        self.store.set_kind(v, VertexKind::Empty);
3533        let key = (addr.sheet_id, addr.coord.row(), addr.coord.col());
3534        // Oracle builds: readers' edges to the vertex become reads of a
3535        // cell without one.
3536        #[cfg(any(test, feature = "legacy_oracle"))]
3537        {
3538            let readers = self.get_dependents(v);
3539            self.remove_all_edges(v);
3540            for r in readers {
3541                if !self.store.is_deleted(r) {
3542                    self.oracle_vertexless_readers
3543                        .entry(key)
3544                        .or_default()
3545                        .push(r);
3546                    self.oracle_vertexless_of.entry(r).or_default().push(key);
3547                }
3548            }
3549        }
3550        self.cell_to_vertex.remove(addr);
3551        if let Some(index) = self.sheet_indexes.get_mut(&addr.sheet_id) {
3552            index.remove_vertex(GridAddr::new(key.1, key.2), v);
3553        }
3554        self.store.mark_deleted(v, true);
3555        if was_formula {
3556            self.retired_ids.insert(key, v);
3557            self.retired_id_set.insert(v);
3558        }
3559        Some((v, was_formula))
3560    }
3561
3562    /// An empty vertex at 0-based `(row, col)` of `sheet` (the cell's
3563    /// vertex when it has one): the low-level editor's explicit vertex
3564    /// creation. References and value edits never create one (decision 27).
3565    pub(crate) fn add_empty_vertex(
3566        &mut self,
3567        sheet: SheetId,
3568        row: u32,
3569        col: u32,
3570    ) -> Result<VertexId, ExcelError> {
3571        let budgets = self.self_admission_budgets();
3572        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
3573            let mut usage = self.preview_value_mutation(sheet, row + 1, col + 1)?;
3574            if self
3575                .cell_vertex(&CellRef::new(sheet, Coord::new(row, col, true, true)))
3576                .is_none()
3577            {
3578                usage.final_vertices = usage.final_vertices.saturating_add(1);
3579                usage.added_vertices = 1;
3580            }
3581            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
3582                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
3583        }
3584        let addr = CellRef::new(sheet, Coord::new(row, col, true, true));
3585        let mut created = Vec::new();
3586        let id = self.get_or_create_vertex(&addr, &mut created);
3587        self.materialize_vertex(id);
3588        let _ = self.mark_dirty(id);
3589        Ok(id)
3590    }
3591
3592    /// Replay of a cell edit whose prior state was not a formula (undo of a
3593    /// formula typed into a value or empty cell): the cell loses its vertex
3594    /// as a value edit does, dependents dirtied by position.
3595    pub(crate) fn retire_cell_for_replay(&mut self, addr: CellRef) {
3596        // The cell's value changes either way (Arrow restores it): its
3597        // readers are dirty even when it had no vertex.
3598        self.vacate_cell(&addr);
3599        // Legacy removed the cell's vertex: it leaves the used extent.
3600        self.forget_extent_cells(
3601            addr.sheet_id,
3602            (addr.coord.row(), addr.coord.row()),
3603            (addr.coord.col(), addr.coord.col()),
3604        );
3605        let _ = self.mark_dirty_cells(&[(addr.sheet_id, addr.coord.row(), addr.coord.col())]);
3606    }
3607
3608    /// A cell the legacy graph gave a placeholder (a reference to a cell
3609    /// without a vertex) counts toward the used extent.
3610    pub(crate) fn note_extent_cell(&mut self, sheet: SheetId, row0: u32, col0: u32) {
3611        self.extent_record.note(sheet, row0, col0);
3612    }
3613
3614    /// Cells of `rows x cols` (0-based, inclusive) leave the extent record:
3615    /// the legacy graph removed their vertices. Returns the forgotten cells
3616    /// as `(col, r0, r1)` runs.
3617    pub(crate) fn forget_extent_cells(
3618        &mut self,
3619        sheet: SheetId,
3620        rows: (u32, u32),
3621        cols: (u32, u32),
3622    ) -> Vec<(u32, u32, u32)> {
3623        self.extent_record.forget_rect(sheet, rows, cols)
3624    }
3625
3626    /// Whether the legacy graph held a vertex without a formula at `cell`
3627    /// (a referenced, value or spill-child cell): the extent record.
3628    pub(crate) fn had_legacy_cell_vertex(&self, cell: &CellRef) -> bool {
3629        self.extent_record
3630            .contains(cell.sheet_id, cell.coord.row(), cell.coord.col())
3631    }
3632
3633    /// Columns of `sheet` with a cell in the extent record.
3634    pub(crate) fn extent_record_columns(&self, sheet: SheetId) -> Vec<u32> {
3635        self.extent_record.columns(sheet)
3636    }
3637
3638    /// Runs in the extent record (tests).
3639    #[cfg(test)]
3640    pub(crate) fn extent_record_runs(&self) -> usize {
3641        self.extent_record.run_count()
3642    }
3643
3644    /// The retired id at `addr` comes back (value -> formula, decision 27)
3645    /// when the cell has no vertex; returns it.
3646    pub(crate) fn revive_retired_id(&mut self, addr: &CellRef) -> Option<VertexId> {
3647        if self.retired_ids.is_empty() {
3648            return None;
3649        }
3650        let key = (addr.sheet_id, addr.coord.row(), addr.coord.col());
3651        let id = *self.retired_ids.get(&key)?;
3652        let coord = GridAddr::new(key.1, key.2);
3653        // A live vertex at the cell keeps it (a stale cell-map entry left
3654        // by legacy move replay does not).
3655        if let Some(x) = self.cell_vertex(addr)
3656            && !self.store.is_deleted(x)
3657            && self.store.grid_addr(x) == Some(coord)
3658        {
3659            return None;
3660        }
3661        self.retired_ids.remove(&key);
3662        self.revive_vertex(id, addr.sheet_id, coord).then_some(id)
3663    }
3664
3665    /// Retired ids waiting in the side table (tests, accounting).
3666    pub(crate) fn retired_id_count(&self) -> usize {
3667        self.retired_ids.len()
3668    }
3669
3670    /// Shift the retired-id side table for a structural edit (called with
3671    /// the pre-edit frame). Entries in a deleted band go to the journal, so
3672    /// history that brings the cell back revives the id.
3673    /// `journal`: the edit is logged, so history may undo it (the band's
3674    /// entries are kept for that); an unlogged delete drops them for good.
3675    pub(crate) fn shift_retired_ids(
3676        &mut self,
3677        op: &crate::engine::graph::editor::reference_adjuster::ShiftOperation,
3678        journal: bool,
3679    ) {
3680        use crate::engine::graph::editor::reference_adjuster::ShiftOperation as Op;
3681        let dropped = self.extent_record.shift(op);
3682        if journal && matches!(*op, Op::DeleteRows { .. } | Op::DeleteColumns { .. }) {
3683            self.extent_dropped.push(dropped);
3684        }
3685        self.shift_retired_id_table(op, journal);
3686    }
3687
3688    /// The retired-id side table part of [`Self::shift_retired_ids`].
3689    fn shift_retired_id_table(
3690        &mut self,
3691        op: &crate::engine::graph::editor::reference_adjuster::ShiftOperation,
3692        journal: bool,
3693    ) {
3694        use crate::engine::graph::editor::reference_adjuster::ShiftOperation as Op;
3695        let deleting = matches!(*op, Op::DeleteRows { .. } | Op::DeleteColumns { .. });
3696        if self.retired_ids.is_empty() {
3697            if deleting && journal {
3698                self.retired_dropped.push(Vec::new());
3699            }
3700            return;
3701        }
3702        let (sheet, rows, start, count, insert) = match *op {
3703            Op::InsertRows {
3704                sheet_id,
3705                before,
3706                count,
3707            } => (sheet_id, true, before, count, true),
3708            Op::DeleteRows {
3709                sheet_id,
3710                start,
3711                count,
3712            } => (sheet_id, true, start, count, false),
3713            Op::InsertColumns {
3714                sheet_id,
3715                before,
3716                count,
3717            } => (sheet_id, false, before, count, true),
3718            Op::DeleteColumns {
3719                sheet_id,
3720                start,
3721                count,
3722            } => (sheet_id, false, start, count, false),
3723        };
3724        let entries: Vec<((SheetId, u32, u32), VertexId)> = self
3725            .retired_ids
3726            .range((sheet, 0, 0)..=(sheet, u32::MAX, u32::MAX))
3727            .map(|(k, v)| (*k, *v))
3728            .collect();
3729        for (key, _) in &entries {
3730            self.retired_ids.remove(key);
3731        }
3732        let mut dropped = Vec::new();
3733        for ((s, r, c), id) in entries {
3734            let pos = if rows { r } else { c };
3735            let moved = if pos < start {
3736                Some(pos)
3737            } else if insert {
3738                pos.checked_add(count)
3739            } else if pos < start.saturating_add(count) {
3740                None
3741            } else {
3742                Some(pos - count)
3743            };
3744            match moved {
3745                Some(p) => {
3746                    let key = if rows { (s, p, c) } else { (s, r, p) };
3747                    self.retired_ids.insert(key, id);
3748                }
3749                None => dropped.push(((s, r, c), id)),
3750            }
3751        }
3752        if !insert && journal {
3753            // Undo of the delete restores them (`replay_structural_marker`).
3754            self.retired_dropped.push(dropped);
3755        }
3756    }
3757
3758    /// Restore the retired ids a delete dropped (its band is back).
3759    fn restore_retired_batch(&mut self, batch: RetiredBatch) {
3760        for (key, id) in batch {
3761            self.retired_ids.insert(key, id);
3762        }
3763    }
3764
3765    /// Replay of a structural edit's compound marker (its description, as
3766    /// the editor logs it): history replays the edit per vertex, so the
3767    /// retired-id side table is shifted here. `forward` for redo, else undo.
3768    /// Undo of a delete brings the band's dropped entries back (and dirties
3769    /// the band's readers: its cells reappear, value cells have no vertex
3770    /// whose revival would dirty them).
3771    pub(crate) fn replay_structural_marker(&mut self, description: &str, forward: bool) {
3772        use crate::engine::graph::editor::reference_adjuster::ShiftOperation as Op;
3773        let Some(op) = parse_structural_description(description) else {
3774            return;
3775        };
3776        if forward {
3777            self.shift_retired_ids(&op, true);
3778            if matches!(op, Op::InsertRows { .. } | Op::InsertColumns { .. })
3779                && let Some(batch) = self.retired_dropped_by_undo.pop()
3780            {
3781                // Entries the undo of this insert dropped from its band.
3782                self.restore_retired_batch(batch);
3783            }
3784            return;
3785        }
3786        let (inverse, band) = match op {
3787            Op::InsertRows {
3788                sheet_id,
3789                before,
3790                count,
3791            } => (
3792                Op::DeleteRows {
3793                    sheet_id,
3794                    start: before,
3795                    count,
3796                },
3797                None,
3798            ),
3799            Op::InsertColumns {
3800                sheet_id,
3801                before,
3802                count,
3803            } => (
3804                Op::DeleteColumns {
3805                    sheet_id,
3806                    start: before,
3807                    count,
3808                },
3809                None,
3810            ),
3811            Op::DeleteRows {
3812                sheet_id,
3813                start,
3814                count,
3815            } => (
3816                Op::InsertRows {
3817                    sheet_id,
3818                    before: start,
3819                    count,
3820                },
3821                Some((sheet_id, true, start, count)),
3822            ),
3823            Op::DeleteColumns {
3824                sheet_id,
3825                start,
3826                count,
3827            } => (
3828                Op::InsertColumns {
3829                    sheet_id,
3830                    before: start,
3831                    count,
3832                },
3833                Some((sheet_id, false, start, count)),
3834            ),
3835        };
3836        // The extent record goes back to the frame before the edit, unless
3837        // the replay did that at the edit's end marker.
3838        if self.extent_undone.last() == Some(&shift_key(&op)) {
3839            self.extent_undone.pop();
3840        } else {
3841            self.shift_extent_back(&op, &inverse);
3842        }
3843        // Undo of an insert drops the band's entries (retired there after the
3844        // insert); redo of the insert takes them back.
3845        self.shift_retired_id_table(&inverse, true);
3846        if band.is_none() {
3847            if let Some(batch) = self.retired_dropped.pop() {
3848                self.retired_dropped_by_undo.push(batch);
3849            }
3850            return;
3851        }
3852        // Undo of a delete: `inverse` is an insert and recorded nothing.
3853        if let Some((sheet, rows, start, count)) = band {
3854            if count == 0 {
3855                return;
3856            }
3857            // Entries the delete dropped come back (LIFO with history).
3858            if let Some(batch) = self.retired_dropped.pop() {
3859                self.restore_retired_batch(batch);
3860            }
3861            let end = start.saturating_add(count - 1);
3862            // Excel's grid: 1,048,576 rows by 16,384 columns.
3863            let rect = if rows {
3864                (sheet, start, end, 0, 16_383)
3865            } else {
3866                (sheet, 0, 1_048_575, start, end)
3867            };
3868            let _ = self.mark_dirty_rects(&[rect]);
3869        }
3870    }
3871
3872    /// Backward replay reached the end marker of a compound whose start
3873    /// marker is `description`: for a structural edit, the extent record
3874    /// goes back to the frame before the edit now, so the replay of the
3875    /// edit's events (formulas and vertices restored in that frame) notes
3876    /// cells in the frame they belong to. The start marker then leaves the
3877    /// record alone.
3878    pub(crate) fn undo_structural_extent(&mut self, description: &str) {
3879        use crate::engine::graph::editor::reference_adjuster::ShiftOperation as Op;
3880        let Some(op) = parse_structural_description(description) else {
3881            return;
3882        };
3883        let inverse = match op {
3884            Op::InsertRows {
3885                sheet_id,
3886                before,
3887                count,
3888            } => Op::DeleteRows {
3889                sheet_id,
3890                start: before,
3891                count,
3892            },
3893            Op::InsertColumns {
3894                sheet_id,
3895                before,
3896                count,
3897            } => Op::DeleteColumns {
3898                sheet_id,
3899                start: before,
3900                count,
3901            },
3902            Op::DeleteRows {
3903                sheet_id,
3904                start,
3905                count,
3906            } => Op::InsertRows {
3907                sheet_id,
3908                before: start,
3909                count,
3910            },
3911            Op::DeleteColumns {
3912                sheet_id,
3913                start,
3914                count,
3915            } => Op::InsertColumns {
3916                sheet_id,
3917                before: start,
3918                count,
3919            },
3920        };
3921        self.shift_extent_back(&op, &inverse);
3922        self.extent_undone.push(shift_key(&op));
3923    }
3924
3925    /// End of a replay: see `authority_set_replay`.
3926    pub(crate) fn clear_extent_undone(&mut self) {
3927        self.extent_undone.clear();
3928    }
3929
3930    /// Undo of structural edit `op` for the extent record: the inverse
3931    /// shift, and for a delete the cells it dropped.
3932    fn shift_extent_back(
3933        &mut self,
3934        op: &crate::engine::graph::editor::reference_adjuster::ShiftOperation,
3935        inverse: &crate::engine::graph::editor::reference_adjuster::ShiftOperation,
3936    ) {
3937        use crate::engine::graph::editor::reference_adjuster::ShiftOperation as Op;
3938        let _ = self.extent_record.shift(inverse);
3939        if matches!(*op, Op::DeleteRows { .. } | Op::DeleteColumns { .. })
3940            && let Some(dropped) = self.extent_dropped.pop()
3941        {
3942            self.extent_record.restore(dropped);
3943        }
3944    }
3945
3946    /// Extent cells noted but not folded into runs yet (tests).
3947    #[cfg(test)]
3948    pub(crate) fn extent_record_pending(&self) -> usize {
3949        self.extent_record.pending_len()
3950    }
3951
3952    /// Extent cells retained for undo of structural deletes: `(entries,
3953    /// runs)` (tests: history stays delta-sized).
3954    #[cfg(test)]
3955    pub(crate) fn extent_history_counts(&self) -> (usize, usize) {
3956        (
3957            self.extent_dropped.len(),
3958            self.extent_dropped.iter().map(Vec::len).sum(),
3959        )
3960    }
3961
3962    /// A sheet is removed: its retired ids go to the journal.
3963    pub(crate) fn drop_retired_ids_of_sheet(&mut self, sheet: SheetId) {
3964        self.extent_record.drop_sheet(sheet);
3965        let keys: Vec<(SheetId, u32, u32)> = self
3966            .retired_ids
3967            .range((sheet, 0, 0)..=(sheet, u32::MAX, u32::MAX))
3968            .map(|(k, _)| *k)
3969            .collect();
3970        for key in keys {
3971            if let Some(id) = self.retired_ids.remove(&key) {
3972                self.vertex_journal.retired(key, id.0);
3973            }
3974        }
3975    }
3976
3977    /// Resolve direct cell dependencies to vertices: `(vertices, cells
3978    /// without a vertex)`, both deduplicated.
3979    pub(crate) fn resolve_direct_deps(
3980        &mut self,
3981        cells: &[CellRef],
3982    ) -> (Vec<VertexId>, Vec<CellRef>) {
3983        let mut vertices: Vec<VertexId> = Vec::with_capacity(cells.len());
3984        let mut vertexless: Vec<CellRef> = Vec::new();
3985        for cell in cells {
3986            match self.dep_vertex(cell) {
3987                Some(v) => {
3988                    if !vertices.contains(&v) {
3989                        vertices.push(v);
3990                    }
3991                }
3992                None => {
3993                    if !vertexless.iter().any(|c| same_cell(c, cell)) {
3994                        vertexless.push(*cell);
3995                    }
3996                }
3997            }
3998        }
3999        (vertices, vertexless)
4000    }
4001
4002    fn get_or_create_vertex(
4003        &mut self,
4004        addr: &CellRef,
4005        created_placeholders: &mut Vec<CellRef>,
4006    ) -> VertexId {
4007        if let Some(vertex_id) = self.cell_vertex(addr) {
4008            return vertex_id;
4009        }
4010        // A formula replaced by a value retired its id here: take it back.
4011        if let Some(vertex_id) = self.revive_retired_id(addr) {
4012            return vertex_id;
4013        }
4014
4015        // During first-load bulk ingest the fast path populates
4016        // ``load_packed_to_vertex`` but skips ``cell_to_vertex``. Promote
4017        // the entry into ``cell_to_vertex`` so subsequent lookups are O(1)
4018        // and consistent across the two maps.
4019        if self.first_load_assume_new {
4020            let packed = Self::packed_cell_key(
4021                addr.sheet_id,
4022                AbsCoord::new(addr.coord.row(), addr.coord.col()),
4023            );
4024            if let Some(&existing) = self.load_packed_to_vertex.get(&packed) {
4025                self.cell_to_vertex.insert(*addr, existing);
4026                return existing;
4027            }
4028        }
4029
4030        created_placeholders.push(*addr);
4031        let position = GridAddr::new(addr.coord.row(), addr.coord.col());
4032        let vertex_id = self
4033            .store
4034            .allocate(VertexAddr::grid(position), addr.sheet_id, 0x00);
4035
4036        #[cfg(any(test, feature = "legacy_oracle"))]
4037        {
4038            self.edges
4039                .add_vertex(VertexAddr::grid(position), vertex_id.0);
4040            self.oracle_cell_vertex_created(
4041                (addr.sheet_id, position.row(), position.col()),
4042                vertex_id,
4043            );
4044        }
4045
4046        // Add to sheet index for O(log n + k) range queries
4047        self.sheet_index_mut(addr.sheet_id)
4048            .add_vertex(position, vertex_id);
4049
4050        self.store.set_kind(vertex_id, VertexKind::Empty);
4051        self.cell_to_vertex.insert(*addr, vertex_id);
4052        vertex_id
4053    }
4054
4055    /// Direct dependencies of `dependent` on cells without a vertex: counted
4056    /// like edges (the count per distinct cell is what a placeholder vertex
4057    /// per cell gave); oracle builds remember them for the vertex a cell may
4058    /// get later.
4059    pub(crate) fn note_vertexless_deps(
4060        &mut self,
4061        dependent: VertexId,
4062        cells: impl IntoIterator<Item = (SheetId, u32, u32)>,
4063    ) {
4064        let mut n = 0usize;
4065        #[cfg(any(test, feature = "legacy_oracle"))]
4066        let mut keys = Vec::new();
4067        for cell in cells {
4068            n += 1;
4069            // The legacy placeholder counted toward the used extent.
4070            self.extent_record.note(cell.0, cell.1, cell.2);
4071            #[cfg(any(test, feature = "legacy_oracle"))]
4072            {
4073                self.oracle_vertexless_readers
4074                    .entry(cell)
4075                    .or_default()
4076                    .push(dependent);
4077                keys.push(cell);
4078            }
4079            #[cfg(not(any(test, feature = "legacy_oracle")))]
4080            let _ = cell;
4081        }
4082        #[cfg(any(test, feature = "legacy_oracle"))]
4083        if !keys.is_empty() {
4084            self.oracle_vertexless_of
4085                .entry(dependent)
4086                .or_default()
4087                .extend(keys);
4088        }
4089        self.note_dep_edges(dependent, n);
4090    }
4091
4092    /// Oracle builds: a vertex `v` now exists at `cell`; readers that
4093    /// referenced the cell while it had none get their oracle edge.
4094    #[cfg(any(test, feature = "legacy_oracle"))]
4095    pub(crate) fn oracle_cell_vertex_created(&mut self, cell: (SheetId, u32, u32), v: VertexId) {
4096        if self.oracle_vertexless_readers.is_empty() {
4097            return;
4098        }
4099        let Some(readers) = self.oracle_vertexless_readers.remove(&cell) else {
4100            return;
4101        };
4102        for reader in readers {
4103            if let Some(cells) = self.oracle_vertexless_of.get_mut(&reader) {
4104                cells.retain(|c| *c != cell);
4105                if cells.is_empty() {
4106                    self.oracle_vertexless_of.remove(&reader);
4107                }
4108            }
4109            self.oracle_add_dependent_edges(reader, &[v]);
4110        }
4111    }
4112
4113    /// Oracle builds: formulas and names reading `cell`, which has no
4114    /// vertex.
4115    #[cfg(any(test, feature = "legacy_oracle"))]
4116    pub(crate) fn oracle_vertexless_readers_of(
4117        &self,
4118        cell: crate::engine::authority::geom::Cell,
4119    ) -> Vec<VertexId> {
4120        self.oracle_vertexless_readers
4121            .get(&(cell.0 as SheetId, cell.1, cell.2))
4122            .cloned()
4123            .unwrap_or_default()
4124    }
4125
4126    /// Oracle builds: the cells without a vertex that `dependent` reads
4127    /// directly (its other direct dependencies are oracle edges).
4128    #[cfg(any(test, feature = "legacy_oracle"))]
4129    pub(crate) fn oracle_vertexless_cells(&self, dependent: VertexId) -> Vec<CellRef> {
4130        self.oracle_vertexless_of
4131            .get(&dependent)
4132            .map(|cells| {
4133                cells
4134                    .iter()
4135                    .map(|&(s, r, c)| CellRef::new(s, Coord::new(r, c, true, true)))
4136                    .collect()
4137            })
4138            .unwrap_or_default()
4139    }
4140
4141    #[cfg(any(test, feature = "legacy_oracle"))]
4142    fn oracle_forget_vertexless(&mut self, dependent: VertexId) {
4143        if let Some(cells) = self.oracle_vertexless_of.remove(&dependent) {
4144            for cell in cells {
4145                if let Some(readers) = self.oracle_vertexless_readers.get_mut(&cell) {
4146                    readers.retain(|r| *r != dependent);
4147                    if readers.is_empty() {
4148                        self.oracle_vertexless_readers.remove(&cell);
4149                    }
4150                }
4151            }
4152        }
4153    }
4154
4155    /// Record `n` direct dependency edges of `dependent` (admission count).
4156    pub(crate) fn note_dep_edges(&mut self, dependent: VertexId, n: usize) {
4157        let now = self.store.edge_offset(dependent) as usize + n;
4158        self.store
4159            .set_edge_offset(dependent, u32::try_from(now).unwrap_or(u32::MAX));
4160        self.dep_edge_total += n;
4161    }
4162
4163    /// The sheet a renamed sheet's old name still denotes for name
4164    /// formulas, unless a live sheet has that name.
4165    pub(crate) fn renamed_sheet_alias(&self, name: &str) -> Option<SheetId> {
4166        self.renamed_sheet_aliases
4167            .get(&name.to_ascii_lowercase())
4168            .copied()
4169            .filter(|&id| self.sheet_reg.name(id) != name)
4170    }
4171
4172    /// Whether `vertex`'s formula reads a compressed range.
4173    pub(crate) fn reads_compressed_range(&self, vertex: VertexId) -> bool {
4174        self.store.reads_range(vertex)
4175    }
4176
4177    /// `vertex` reads a compressed range (see `VertexStore::reads_range`).
4178    pub(crate) fn note_reads_range(&mut self, vertex: VertexId) {
4179        if !self.store.reads_range(vertex) {
4180            self.store.set_reads_range(vertex, true);
4181            self.range_reader_count += 1;
4182        }
4183    }
4184
4185    /// Whether any formula reads a compressed range.
4186    pub(crate) fn has_compressed_range_readers(&self) -> bool {
4187        self.range_reader_count > 0
4188    }
4189
4190    /// Drop `vertex`'s direct dependency edges from the admission count.
4191    pub(crate) fn forget_dep_edges(&mut self, vertex: VertexId) {
4192        let n = self.store.edge_offset(vertex) as usize;
4193        if n > 0 {
4194            self.store.set_edge_offset(vertex, 0);
4195            self.dep_edge_total = self.dep_edge_total.saturating_sub(n);
4196        }
4197    }
4198
4199    fn add_dependent_edges(&mut self, dependent: VertexId, dependencies: &[VertexId]) {
4200        self.note_dep_edges(dependent, dependencies.len());
4201        #[cfg(any(test, feature = "legacy_oracle"))]
4202        self.oracle_add_dependent_edges(dependent, dependencies);
4203    }
4204
4205    /// The oracle-only half (test/oracle builds) of the function above.
4206    #[cfg(any(test, feature = "legacy_oracle"))]
4207    fn oracle_add_dependent_edges(&mut self, dependent: VertexId, dependencies: &[VertexId]) {
4208        // Batch to avoid repeated CSR rebuilds and keep reverse edges current
4209        #[cfg(any(test, feature = "legacy_oracle"))]
4210        self.edges.begin_batch();
4211
4212        // If PK enabled, update order using a short-lived adapter without holding &mut self
4213        // Track dependencies that should be skipped if rejecting cycle-creating edges
4214        let mut skip_deps: rustc_hash::FxHashSet<VertexId> = rustc_hash::FxHashSet::default();
4215        if self.pk_order.is_some()
4216            && let Some(mut pk) = self.pk_order.take()
4217        {
4218            pk.ensure_nodes(std::iter::once(dependent));
4219            pk.ensure_nodes(dependencies.iter().copied());
4220            {
4221                let adapter = GraphAdapter { g: self };
4222                for &dep_id in dependencies {
4223                    match pk.try_add_edge(&adapter, dep_id, dependent) {
4224                        Ok(_) => {}
4225                        Err(_cycle) => {
4226                            if self.config.pk_reject_cycle_edges {
4227                                skip_deps.insert(dep_id);
4228                            } else {
4229                                pk.rebuild_full(&adapter);
4230                            }
4231                        }
4232                    }
4233                }
4234            } // drop adapter
4235            self.pk_order = Some(pk);
4236        }
4237
4238        // Now mutate engine edges; if rejecting cycles, re-check and skip those that would create cycles
4239        for &dep_id in dependencies {
4240            if self.config.pk_reject_cycle_edges && skip_deps.contains(&dep_id) {
4241                continue;
4242            }
4243            self.edges.add_edge(dependent, dep_id);
4244            #[cfg(test)]
4245            {
4246                if let Ok(mut g) = self.instr.lock() {
4247                    g.edges_added += 1;
4248                }
4249            }
4250        }
4251
4252        #[cfg(any(test, feature = "legacy_oracle"))]
4253        self.edges.end_batch();
4254    }
4255
4256    /// Like add_dependent_edges, but assumes caller is managing edges.begin_batch/end_batch
4257    fn add_dependent_edges_nobatch(&mut self, dependent: VertexId, dependencies: &[VertexId]) {
4258        self.note_dep_edges(dependent, dependencies.len());
4259        #[cfg(any(test, feature = "legacy_oracle"))]
4260        {
4261            // If PK enabled, update order using a short-lived adapter without holding &mut self
4262            let mut skip_deps: rustc_hash::FxHashSet<VertexId> = rustc_hash::FxHashSet::default();
4263            if self.pk_order.is_some()
4264                && let Some(mut pk) = self.pk_order.take()
4265            {
4266                pk.ensure_nodes(std::iter::once(dependent));
4267                pk.ensure_nodes(dependencies.iter().copied());
4268                {
4269                    let adapter = GraphAdapter { g: self };
4270                    for &dep_id in dependencies {
4271                        match pk.try_add_edge(&adapter, dep_id, dependent) {
4272                            Ok(_) => {}
4273                            Err(_cycle) => {
4274                                if self.config.pk_reject_cycle_edges {
4275                                    skip_deps.insert(dep_id);
4276                                } else {
4277                                    pk.rebuild_full(&adapter);
4278                                }
4279                            }
4280                        }
4281                    }
4282                }
4283                self.pk_order = Some(pk);
4284            }
4285
4286            for &dep_id in dependencies {
4287                if self.config.pk_reject_cycle_edges && skip_deps.contains(&dep_id) {
4288                    continue;
4289                }
4290                self.edges.add_edge(dependent, dep_id);
4291                #[cfg(test)]
4292                {
4293                    if let Ok(mut g) = self.instr.lock() {
4294                        g.edges_added += 1;
4295                    }
4296                }
4297            }
4298        }
4299    }
4300
4301    /// Bulk set formulas on a sheet using a single dependency plan and batched edge updates.
4302    pub fn bulk_set_formulas<I>(&mut self, sheet: &str, items: I) -> Result<usize, ExcelError>
4303    where
4304        I: IntoIterator<Item = (u32, u32, ASTNode)>,
4305    {
4306        let collected: Vec<(u32, u32, ASTNode)> = items.into_iter().collect();
4307        if collected.is_empty() {
4308            return Ok(0);
4309        }
4310        let vol_flags: Vec<bool> = collected
4311            .iter()
4312            .map(|(_, _, ast)| self.is_ast_volatile(ast))
4313            .collect();
4314        self.bulk_set_formulas_with_volatility(sheet, collected, vol_flags)
4315    }
4316
4317    pub fn bulk_set_formulas_with_volatility(
4318        &mut self,
4319        sheet: &str,
4320        collected: Vec<(u32, u32, ASTNode)>,
4321        _vol_flags: Vec<bool>,
4322    ) -> Result<usize, ExcelError> {
4323        let sheet_id = self.sheet_id_mut(sheet);
4324        if collected.is_empty() {
4325            return Ok(0);
4326        }
4327        let provider = RegistryFunctionProvider;
4328        let ingested = {
4329            let mut pipeline = self.ingest_pipeline(&provider);
4330            let inputs = collected.into_iter().map(|(row, col, ast)| {
4331                let placement = CellRef::new(sheet_id, Coord::from_excel(row, col, true, true));
4332                (FormulaAstInput::Tree(ast), placement, None)
4333            });
4334            pipeline.ingest_batch(inputs)?
4335        };
4336        let planned = ingested
4337            .into_iter()
4338            .map(|formula| {
4339                (
4340                    formula.placement.coord.row() + 1,
4341                    formula.placement.coord.col() + 1,
4342                    formula.ast_id,
4343                    formula.dep_plan,
4344                )
4345            })
4346            .collect();
4347        self.bulk_set_formulas_with_plans(sheet, planned)
4348    }
4349
4350    pub(crate) fn bulk_set_formulas_with_plans(
4351        &mut self,
4352        sheet: &str,
4353        planned: Vec<(u32, u32, AstNodeId, DependencyPlanRow)>,
4354    ) -> Result<usize, ExcelError> {
4355        let sheet_id = self.sheet_id_mut(sheet);
4356        if planned.is_empty() {
4357            return Ok(0);
4358        }
4359        let budgets = self.self_admission_budgets();
4360        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
4361            let admission_plans = planned
4362                .iter()
4363                .map(|(row, col, _, plan)| (sheet_id, *row, *col, plan.clone()))
4364                .collect::<Vec<_>>();
4365            let usage = self.preview_formula_mutations(&admission_plans)?;
4366            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
4367                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
4368        }
4369        let mut created_placeholders: Vec<CellRef> = Vec::new();
4370        let mut target_vids: Vec<VertexId> = Vec::with_capacity(planned.len());
4371        for (row, col, _, _) in &planned {
4372            let addr = CellRef::new(sheet_id, Coord::from_excel(*row, *col, true, true));
4373            let target = self.get_or_create_vertex(&addr, &mut created_placeholders);
4374            self.materialize_vertex(target);
4375            target_vids.push(target);
4376        }
4377
4378        for (i, &tvid) in target_vids.iter().enumerate() {
4379            self.forget_declared_dynamic_anchor(tvid);
4380            if self.vertex_formulas.contains_key(&tvid) {
4381                self.remove_dependent_edges(tvid);
4382            }
4383            self.detach_vertex_from_names(tvid);
4384            self.clear_pending_name_references(tvid);
4385            self.store.set_kind(tvid, VertexKind::FormulaScalar);
4386            self.store.set_dirty(tvid, true);
4387            self.vertex_values.remove(&tvid);
4388            self.vertex_formulas.insert(tvid, planned[i].2);
4389            self.mark_volatile(tvid, planned[i].3.volatile);
4390            self.store.set_dynamic(tvid, planned[i].3.dynamic);
4391        }
4392        self.formula_dirty
4393            .legacy_extend(target_vids.iter().copied());
4394
4395        #[cfg(any(test, feature = "legacy_oracle"))]
4396        self.edges.begin_batch();
4397        for (i, tvid) in target_vids.iter().copied().enumerate() {
4398            let plan = &planned[i].3;
4399            let (mut deps, vertexless) = self.resolve_direct_deps(&plan.direct_cell_deps);
4400            self.note_vertexless_deps(
4401                tvid,
4402                vertexless
4403                    .iter()
4404                    .map(|c| (c.sheet_id, c.coord.row(), c.coord.col())),
4405            );
4406
4407            let mut name_vertices = Vec::new();
4408            for name in plan
4409                .resolved_named_refs
4410                .iter()
4411                .chain(plan.named_refs.iter())
4412            {
4413                if let Some(named) = self.resolve_name_entry(name, sheet_id) {
4414                    if !deps.contains(&named.vertex) {
4415                        deps.push(named.vertex);
4416                    }
4417                    if !name_vertices.contains(&named.vertex) {
4418                        name_vertices.push(named.vertex);
4419                    }
4420                } else if let Some(source) = self.resolve_source_scalar_entry(name) {
4421                    if !deps.contains(&source.vertex) {
4422                        deps.push(source.vertex);
4423                    }
4424                } else {
4425                    self.record_pending_name_reference(sheet_id, name, tvid);
4426                }
4427            }
4428            for source_name in &plan.source_refs {
4429                if let Some(source) = self.resolve_source_scalar_entry(source_name) {
4430                    if !deps.contains(&source.vertex) {
4431                        deps.push(source.vertex);
4432                    }
4433                } else if let Some(source) = self.resolve_source_table_entry(source_name)
4434                    && !deps.contains(&source.vertex)
4435                {
4436                    deps.push(source.vertex);
4437                }
4438            }
4439            for table_name in &plan.table_refs {
4440                if let Some(table) = self.resolve_table_entry(table_name) {
4441                    if !deps.contains(&table.vertex) {
4442                        deps.push(table.vertex);
4443                    }
4444                } else if let Some(source) = self.resolve_source_table_entry(table_name)
4445                    && !deps.contains(&source.vertex)
4446                {
4447                    deps.push(source.vertex);
4448                }
4449            }
4450            if !name_vertices.is_empty() {
4451                self.attach_vertex_to_names(tvid, &name_vertices);
4452            }
4453            if !deps.is_empty() {
4454                self.add_dependent_edges_nobatch(tvid, &deps);
4455            }
4456            self.add_range_dependent_edges(tvid, &plan.range_deps, sheet_id);
4457        }
4458        #[cfg(any(test, feature = "legacy_oracle"))]
4459        self.edges.end_batch();
4460
4461        // Readers of the written cells hold values computed from the old
4462        // contents. The targets are already dirty; dirty their transitive
4463        // readers once, as `set_cell_formula` does per cell. The cells are
4464        // coalesced into vertical runs so a copied-down batch is a handful of
4465        // rectangles for one closure query (or one queued flush inside a
4466        // deferred-dirty scope), not a query per target. The plans are
4467        // dropped first: the closure query syncs the dependency authority,
4468        // and holding every plan across that sync raises peak memory.
4469        let written = planned.len();
4470        let mut cells: Vec<(u32, u32)> = planned
4471            .iter()
4472            .map(|(row, col, _, _)| (col.saturating_sub(1), row.saturating_sub(1)))
4473            .collect();
4474        drop(planned);
4475        cells.sort_unstable();
4476        cells.dedup();
4477        let mut runs: Vec<(SheetId, u32, u32, u32, u32)> = Vec::new();
4478        for (col, row) in cells {
4479            match runs.last_mut() {
4480                Some((_, _, r1, c0, _)) if *c0 == col && *r1 + 1 == row => *r1 = row,
4481                _ => runs.push((sheet_id, row, row, col, col)),
4482            }
4483        }
4484        self.mark_dirty_rects(&runs);
4485
4486        Ok(written)
4487    }
4488
4489    #[cfg(any(test, feature = "legacy_oracle"))]
4490    /// Public (crate) helper to add a single dependency edge (dependent -> dependency) used for restoration/undo.
4491    pub fn add_dependency_edge(
4492        &mut self,
4493        dependent: VertexId,
4494        dependency: VertexId,
4495    ) -> Result<(), ExcelError> {
4496        if dependent == dependency {
4497            return Ok(());
4498        }
4499        let budgets = self.self_admission_budgets();
4500        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
4501            let stats = self.baseline_stats();
4502            let added = usize::from(!self.get_dependencies(dependent).contains(&dependency));
4503            crate::engine::resource_ledger::preflight_graph_admission(
4504                &budgets,
4505                crate::engine::resource_ledger::GraphAdmission {
4506                    final_vertices: stats.graph_vertex_count,
4507                    final_edges: stats.graph_edge_count.checked_add(added).ok_or_else(|| {
4508                        ExcelError::new(ExcelErrorKind::NImpl)
4509                            .with_message("graph edge count overflow")
4510                    })?,
4511                    materialization_cells: 0,
4512                    added_vertices: 0,
4513                    added_edges: added,
4514                },
4515                None,
4516            )
4517            .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
4518        }
4519        // If PK enabled attempt to add maintaining ordering; fallback to rebuild if cycle
4520        if self.pk_order.is_some()
4521            && let Some(mut pk) = self.pk_order.take()
4522        {
4523            pk.ensure_nodes(std::iter::once(dependent));
4524            pk.ensure_nodes(std::iter::once(dependency));
4525            let adapter = GraphAdapter { g: self };
4526            if pk.try_add_edge(&adapter, dependency, dependent).is_err() {
4527                // Cycle: rebuild full (conservative)
4528                pk.rebuild_full(&adapter);
4529            }
4530            self.pk_order = Some(pk);
4531        }
4532        self.edges.add_edge(dependent, dependency);
4533        self.store.set_dirty(dependent, true);
4534        self.formula_dirty.legacy_insert(dependent);
4535        Ok(())
4536    }
4537
4538    fn remove_dependent_edges(&mut self, vertex: VertexId) {
4539        self.forget_dep_edges(vertex);
4540        if self.store.reads_range(vertex) {
4541            self.store.set_reads_range(vertex, false);
4542            self.range_reader_count = self.range_reader_count.saturating_sub(1);
4543        }
4544        #[cfg(any(test, feature = "legacy_oracle"))]
4545        self.oracle_remove_dependent_edges(vertex);
4546    }
4547
4548    /// The oracle-only half (test/oracle builds) of the function above.
4549    #[cfg(any(test, feature = "legacy_oracle"))]
4550    fn oracle_remove_dependent_edges(&mut self, vertex: VertexId) {
4551        self.oracle_forget_vertexless(vertex);
4552        // Remove all outgoing edges from this vertex (its dependencies)
4553        let dependencies = self.edges.out_edges(vertex);
4554
4555        #[cfg(any(test, feature = "legacy_oracle"))]
4556        self.edges.begin_batch();
4557        if self.pk_order.is_some()
4558            && let Some(mut pk) = self.pk_order.take()
4559        {
4560            for dep in &dependencies {
4561                pk.remove_edge(*dep, vertex);
4562            }
4563            self.pk_order = Some(pk);
4564        }
4565        for dep in dependencies {
4566            self.edges.remove_edge(vertex, dep);
4567        }
4568        #[cfg(any(test, feature = "legacy_oracle"))]
4569        self.edges.end_batch();
4570
4571        // Remove range dependencies and clean up stripes
4572        if let Some(old_ranges) = self.formula_to_range_deps.remove(&vertex) {
4573            let old_sheet_id = self.store.sheet_id(vertex);
4574
4575            for range in &old_ranges {
4576                // `Current` is the sheet the moved formula used to live on.
4577                let sheet_id = self
4578                    .sheet_reg
4579                    .resolve_locator(&range.sheet, old_sheet_id)
4580                    .unwrap_or(old_sheet_id);
4581                let s_row = range.start_row.map(|b| b.index);
4582                let e_row = range.end_row.map(|b| b.index);
4583                let s_col = range.start_col.map(|b| b.index);
4584                let e_col = range.end_col.map(|b| b.index);
4585
4586                let mut keys_to_clean = FxHashSet::default();
4587
4588                let col_stripes = (s_row.is_none() && e_row.is_none())
4589                    || (s_col.is_some() && e_col.is_some() && (s_row.is_none() || e_row.is_none()));
4590                let row_stripes = (s_col.is_none() && e_col.is_none())
4591                    || (s_row.is_some() && e_row.is_some() && (s_col.is_none() || e_col.is_none()));
4592
4593                if col_stripes && !row_stripes {
4594                    let sc = s_col.unwrap_or(0);
4595                    let ec = e_col.unwrap_or(sc);
4596                    for col in sc..=ec {
4597                        keys_to_clean.insert(StripeKey {
4598                            sheet_id,
4599                            stripe_type: StripeType::Column,
4600                            index: col,
4601                        });
4602                    }
4603                } else if row_stripes && !col_stripes {
4604                    let sr = s_row.unwrap_or(0);
4605                    let er = e_row.unwrap_or(sr);
4606                    for row in sr..=er {
4607                        keys_to_clean.insert(StripeKey {
4608                            sheet_id,
4609                            stripe_type: StripeType::Row,
4610                            index: row,
4611                        });
4612                    }
4613                } else {
4614                    let start_row = s_row.unwrap_or(0);
4615                    let start_col = s_col.unwrap_or(0);
4616                    let end_row = e_row.unwrap_or(start_row);
4617                    let end_col = e_col.unwrap_or(start_col);
4618
4619                    let height = end_row.saturating_sub(start_row) + 1;
4620                    let width = end_col.saturating_sub(start_col) + 1;
4621
4622                    if self.config.enable_block_stripes && height > 1 && width > 1 {
4623                        let start_block_row = start_row / BLOCK_H;
4624                        let end_block_row = end_row / BLOCK_H;
4625                        let start_block_col = start_col / BLOCK_W;
4626                        let end_block_col = end_col / BLOCK_W;
4627
4628                        for block_row in start_block_row..=end_block_row {
4629                            for block_col in start_block_col..=end_block_col {
4630                                keys_to_clean.insert(StripeKey {
4631                                    sheet_id,
4632                                    stripe_type: StripeType::Block,
4633                                    index: block_index(block_row * BLOCK_H, block_col * BLOCK_W),
4634                                });
4635                            }
4636                        }
4637                    } else if height > width {
4638                        for col in start_col..=end_col {
4639                            keys_to_clean.insert(StripeKey {
4640                                sheet_id,
4641                                stripe_type: StripeType::Column,
4642                                index: col,
4643                            });
4644                        }
4645                    } else {
4646                        for row in start_row..=end_row {
4647                            keys_to_clean.insert(StripeKey {
4648                                sheet_id,
4649                                stripe_type: StripeType::Row,
4650                                index: row,
4651                            });
4652                        }
4653                    }
4654                }
4655
4656                for key in keys_to_clean {
4657                    if let Some(dependents) = self.stripe_to_dependents.get_mut(&key) {
4658                        dependents.remove(&vertex);
4659                        if dependents.is_empty() {
4660                            self.stripe_to_dependents.remove(&key);
4661                            #[cfg(test)]
4662                            {
4663                                if let Ok(mut g) = self.instr.lock() {
4664                                    g.stripe_removes += 1;
4665                                }
4666                            }
4667                        }
4668                    }
4669                }
4670            }
4671        }
4672    }
4673
4674    // Removed: vertices() and get_vertex() methods - no longer needed with SoA
4675    // The old AoS Vertex struct has been eliminated in favor of direct
4676    // access to columnar data through the VertexStore
4677
4678    /// Updates the cached value of a formula vertex.
4679    /// Whether any spill anchor is registered.
4680    pub(crate) fn has_spill_anchors(&self) -> bool {
4681        !self.spill_anchor_to_cells.is_empty()
4682    }
4683
4684    /// Whether `vertex` anchors a spill.
4685    pub(crate) fn is_spill_anchor(&self, vertex: VertexId) -> bool {
4686        self.spill_anchor_to_cells.contains_key(&vertex)
4687    }
4688
4689    /// [`Self::update_vertex_value`] that clones the value only when the
4690    /// graph keeps it (canonical mode keeps none for grid cells).
4691    pub(crate) fn update_vertex_value_ref(&mut self, vertex_id: VertexId, value: &LiteralValue) {
4692        if !self.value_cache_enabled && self.is_grid_backed(vertex_id) {
4693            if !self.vertex_values.is_empty() {
4694                self.vertex_values.remove(&vertex_id);
4695            }
4696            return;
4697        }
4698        self.update_vertex_value(vertex_id, value.clone());
4699    }
4700
4701    pub(crate) fn update_vertex_value(&mut self, vertex_id: VertexId, value: LiteralValue) {
4702        if !self.value_cache_enabled && self.is_grid_backed(vertex_id) {
4703            // Canonical mode: grid-backed vertices must not store values in the graph.
4704            // Symbols (e.g. named-range formulas) may still cache theirs.
4705            self.vertex_values.remove(&vertex_id);
4706            return;
4707        }
4708        // A cached value is per-vertex state a virtual member cannot hold.
4709        self.materialize_vertex(vertex_id);
4710        let value_ref = self.data_store.store_value(normalize_stored_literal(value));
4711        self.vertex_values.insert(vertex_id, value_ref);
4712    }
4713
4714    /// Plan a spill region for an anchor; returns #SPILL! if blocked.
4715    ///
4716    /// A target cell blocks the spill when it is owned by another anchor's
4717    /// spill, holds another formula (scalar or array), or holds a non-empty
4718    /// value. Evaluation turns a blocked plan into a `#SPILL!` anchor value.
4719    pub fn plan_spill_region(
4720        &self,
4721        anchor: VertexId,
4722        target_cells: &[CellRef],
4723    ) -> Result<(), ExcelError> {
4724        self.plan_spill_region_yielding(anchor, target_cells, &[])
4725            .map_err(|(error, _)| error)
4726    }
4727
4728    /// [`Self::plan_spill_region`], also returning the first blocking cell.
4729    pub(crate) fn plan_spill_region_with_blocker(
4730        &self,
4731        anchor: VertexId,
4732        target_cells: &[CellRef],
4733    ) -> Result<(), (ExcelError, CellRef)> {
4734        self.plan_spill_region_yielding(anchor, target_cells, &[])
4735    }
4736
4737    /// [`Self::plan_spill_region`] with the spills of `yielding` anchors
4738    /// treated as already cleared.
4739    fn plan_spill_region_yielding(
4740        &self,
4741        anchor: VertexId,
4742        target_cells: &[CellRef],
4743        yielding: &[VertexId],
4744    ) -> Result<(), (ExcelError, CellRef)> {
4745        use formualizer_common::{ExcelErrorExtra, ExcelErrorKind};
4746        // Compute expected spill shape from the target rectangle for better diagnostics
4747        let (expected_rows, expected_cols) = if target_cells.is_empty() {
4748            (0u32, 0u32)
4749        } else {
4750            let mut min_r = u32::MAX;
4751            let mut max_r = 0u32;
4752            let mut min_c = u32::MAX;
4753            let mut max_c = 0u32;
4754            for cell in target_cells {
4755                let r = cell.coord.row();
4756                let c = cell.coord.col();
4757                if r < min_r {
4758                    min_r = r;
4759                }
4760                if r > max_r {
4761                    max_r = r;
4762                }
4763                if c < min_c {
4764                    min_c = c;
4765                }
4766                if c > max_c {
4767                    max_c = c;
4768                }
4769            }
4770            (
4771                max_r.saturating_sub(min_r).saturating_add(1),
4772                max_c.saturating_sub(min_c).saturating_add(1),
4773            )
4774        };
4775        // Only an anchor a formula may have entered probes its own cells.
4776        let probe_owned = self.spill_may_be_intruded(anchor);
4777        // Allow overlapping with previously owned spill cells by this anchor
4778        for cell in target_cells {
4779            // If cell is already owned by this anchor's previous spill, it's allowed.
4780            let owned_by_anchor = match self.spill_cell_to_anchor.get(cell) {
4781                Some(&existing_anchor) if existing_anchor == anchor => true,
4782                Some(other) if yielding.contains(other) => false,
4783                Some(_other) => {
4784                    return Err((
4785                        ExcelError::new(ExcelErrorKind::Spill)
4786                            .with_message("BlockedBySpill")
4787                            .with_extra(ExcelErrorExtra::Spill {
4788                                expected_rows,
4789                                expected_cols,
4790                            }),
4791                        *cell,
4792                    ));
4793                }
4794                None => false,
4795            };
4796
4797            // A spill child is a value cell, so a formula there was entered
4798            // after this anchor last spilled: it blocks the next spill like
4799            // any other formula. (Values keep the previous behavior.) Such
4800            // an anchor was recorded as intruded; no other anchor probes.
4801            if owned_by_anchor && !(probe_owned && self.is_foreign_formula_cell(cell, anchor)) {
4802                continue;
4803            }
4804
4805            // If cell is occupied by another formula, block.
4806            if let Some(vid) = self.cell_vertex(cell)
4807                && vid != anchor
4808            {
4809                // Prevent clobbering formulas (array or scalar) in the target area
4810                match self.store.kind(vid) {
4811                    VertexKind::FormulaScalar | VertexKind::FormulaArray => {
4812                        return Err((
4813                            ExcelError::new(ExcelErrorKind::Spill)
4814                                .with_message("BlockedByFormula")
4815                                .with_extra(ExcelErrorExtra::Spill {
4816                                    expected_rows,
4817                                    expected_cols,
4818                                }),
4819                            *cell,
4820                        ));
4821                    }
4822                    _ => {
4823                        // If a non-empty value exists (and not this anchor), block
4824                        if let Some(vref) = self.vertex_values.get(&vid) {
4825                            let v = self.data_store.retrieve_value(*vref);
4826                            if !matches!(v, LiteralValue::Empty) {
4827                                return Err((
4828                                    ExcelError::new(ExcelErrorKind::Spill)
4829                                        .with_message("BlockedByValue")
4830                                        .with_extra(ExcelErrorExtra::Spill {
4831                                            expected_rows,
4832                                            expected_cols,
4833                                        }),
4834                                    *cell,
4835                                ));
4836                            }
4837                        }
4838                    }
4839                }
4840            }
4841        }
4842        Ok(())
4843    }
4844
4845    // Note: non-atomic commit_spill_region has been removed. All callers must use
4846    // commit_spill_region_atomic_with_fault for atomicity and rollback on failure.
4847
4848    /// Commit a spill atomically with an internal shadow buffer and optional fault injection.
4849    /// If a fault is injected partway through, all changes are rolled back to the pre-commit state.
4850    /// This does not change behavior under normal operation; it's primarily for Phase 3 guarantees and tests.
4851    ///
4852    /// Precondition: `target_cells` is the full spill rectangle in row-major
4853    /// order, starting at the anchor cell. The registry stores the vector
4854    /// verbatim, and [`Self::spill_extent_for_anchor`] reads the rectangle
4855    /// from its first and last cells in O(1) (returning `None` for a vector
4856    /// that fails its shape check, such as a truncated snapshot).
4857    pub fn commit_spill_region_atomic_with_fault(
4858        &mut self,
4859        anchor: VertexId,
4860        target_cells: Vec<CellRef>,
4861        values: Vec<Vec<LiteralValue>>,
4862        fault_after_ops: Option<usize>,
4863    ) -> Result<(), ExcelError> {
4864        self.materialize_vertex(anchor);
4865        let budgets = self.self_admission_budgets();
4866        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
4867            let admission = self.preview_spill_materialization(&target_cells)?;
4868            crate::engine::resource_ledger::preflight_graph_admission(&budgets, admission, None)
4869                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
4870        }
4871
4872        // Anchor cell coordinates (0-based) for special-casing writes.
4873        // We must never overwrite the anchor via set_cell_value(), because that would
4874        // strip the formula and break incremental recalculation.
4875        let anchor_cell = self
4876            .get_cell_ref(anchor)
4877            .expect("anchor cell ref for spill commit");
4878        let anchor_sheet_name = self.sheet_name(anchor_cell.sheet_id).to_string();
4879        let anchor_row = anchor_cell.coord.row();
4880        let anchor_col = anchor_cell.coord.col();
4881
4882        // Capture previous owned cells for this anchor
4883        let prev_cells = self
4884            .spill_anchor_to_cells
4885            .get(&anchor)
4886            .cloned()
4887            .unwrap_or_default();
4888        // Use CoordBuildHasher on CellRef keys to avoid FxHasher clustering on
4889        // packed Coord values.
4890        let new_set: std::collections::HashSet<CellRef, CoordBuildHasher> =
4891            target_cells.iter().copied().collect();
4892        let prev_set: std::collections::HashSet<CellRef, CoordBuildHasher> =
4893            prev_cells.iter().copied().collect();
4894
4895        // Compose operation list: clears first (prev - new), then writes for new rectangle
4896        #[derive(Clone)]
4897        struct Op {
4898            sheet: String,
4899            row: u32,
4900            col: u32,
4901            new_value: LiteralValue,
4902        }
4903        let mut ops: Vec<Op> = Vec::new();
4904
4905        // Clears for cells no longer used; a formula entered into the spill
4906        // stays.
4907        let probe_owned = self.spill_may_be_intruded(anchor);
4908        let entered = |cell: &CellRef| probe_owned && self.is_foreign_formula_cell(cell, anchor);
4909        for cell in prev_cells.iter() {
4910            if !new_set.contains(cell) && !entered(cell) {
4911                let sheet = self.sheet_name(cell.sheet_id).to_string();
4912                ops.push(Op {
4913                    sheet,
4914                    row: cell.coord.row(),
4915                    col: cell.coord.col(),
4916                    new_value: LiteralValue::Empty,
4917                });
4918            }
4919        }
4920
4921        // Writes for new values (row-major to match target rectangle)
4922        if !target_cells.is_empty() {
4923            let first = target_cells.first().copied().unwrap();
4924            let row0 = first.coord.row();
4925            let col0 = first.coord.col();
4926            let sheet = self.sheet_name(first.sheet_id).to_string();
4927            for (r_off, row_vals) in values.iter().enumerate() {
4928                for (c_off, v) in row_vals.iter().enumerate() {
4929                    ops.push(Op {
4930                        sheet: sheet.clone(),
4931                        row: row0 + r_off as u32,
4932                        col: col0 + c_off as u32,
4933                        new_value: v.clone(),
4934                    });
4935                }
4936            }
4937        }
4938
4939        // Shadow buffer of old values for rollback
4940        #[derive(Clone)]
4941        struct OldVal {
4942            present: bool,
4943            value: LiteralValue,
4944        }
4945        let mut old_values: Vec<((String, u32, u32), OldVal)> = Vec::with_capacity(ops.len());
4946
4947        // Capture old values before applying
4948        for op in &ops {
4949            // op.row/op.col are internal 0-based; get_cell_value is a public 1-based API.
4950            let old = self
4951                .get_cell_value(&op.sheet, op.row + 1, op.col + 1)
4952                .unwrap_or(LiteralValue::Empty);
4953            let present = true; // unified model: we always treat as present
4954            old_values.push((
4955                (op.sheet.clone(), op.row, op.col),
4956                OldVal {
4957                    present,
4958                    value: old,
4959                },
4960            ));
4961        }
4962
4963        // Apply with optional injected fault
4964        for (applied, op) in ops.iter().enumerate() {
4965            if let Some(n) = fault_after_ops
4966                && applied == n
4967            {
4968                for idx in (0..applied).rev() {
4969                    let ((ref sheet, row, col), ref old) = old_values[idx];
4970                    if sheet == &anchor_sheet_name && row == anchor_row && col == anchor_col {
4971                        self.update_vertex_value_ref(anchor, &old.value);
4972                    } else {
4973                        let _ = self.set_cell_value(sheet, row + 1, col + 1, old.value.clone());
4974                    }
4975                }
4976                return Err(ExcelError::new(ExcelErrorKind::Error)
4977                    .with_message("Injected persistence fault during spill commit"));
4978            }
4979            if op.sheet == anchor_sheet_name && op.row == anchor_row && op.col == anchor_col {
4980                self.update_vertex_value_ref(anchor, &op.new_value);
4981            } else {
4982                let _ =
4983                    self.set_cell_value(&op.sheet, op.row + 1, op.col + 1, op.new_value.clone());
4984            }
4985        }
4986
4987        // Update spill ownership maps only on success
4988        // Clear previous ownership not reused
4989        for cell in prev_cells.iter() {
4990            if !new_set.contains(cell) {
4991                self.spill_cell_to_anchor.remove(cell);
4992                let remove_sheet = self
4993                    .spill_cells_by_sheet
4994                    .get_mut(&cell.sheet_id)
4995                    .is_some_and(|sheet| {
4996                        sheet.remove(&(cell.coord.row(), cell.coord.col()));
4997                        sheet.is_empty()
4998                    });
4999                if remove_sheet {
5000                    self.spill_cells_by_sheet.remove(&cell.sheet_id);
5001                }
5002            }
5003        }
5004        // Mark ownership for new rectangle using the declared target cells only
5005        for cell in &target_cells {
5006            self.spill_cell_to_anchor.insert(*cell, anchor);
5007            self.spill_cells_by_sheet
5008                .entry(cell.sheet_id)
5009                .or_default()
5010                .insert((cell.coord.row(), cell.coord.col()), anchor);
5011        }
5012        // The committed spill holds no foreign formula (the planner refused
5013        // one) unless a caller committed without planning: stay recorded
5014        // then.
5015        if !self.spill_intruded_anchors.is_empty()
5016            && self.spill_intruded_anchors.contains(&anchor)
5017            && !target_cells
5018                .iter()
5019                .any(|cell| self.is_foreign_formula_cell(cell, anchor))
5020        {
5021            self.spill_intruded_anchors.remove(&anchor);
5022        }
5023        self.spill_anchor_to_cells.insert(anchor, target_cells);
5024        Ok(())
5025    }
5026
5027    pub(crate) fn spill_cells_for_anchor(&self, anchor: VertexId) -> Option<&[CellRef]> {
5028        self.spill_anchor_to_cells
5029            .get(&anchor)
5030            .map(|v| v.as_slice())
5031    }
5032
5033    /// The committed spill rectangle of `anchor` as its (top-left,
5034    /// bottom-right) corners, in O(1).
5035    ///
5036    /// Spill targets are registered row-major over a rectangle whose first
5037    /// cell is the anchor, so the first and last targets are its corners;
5038    /// no member cell is scanned. A registry entry that is not such a
5039    /// rectangle (for example a truncated snapshot restored by undo or
5040    /// replay) is detected by the O(1) shape check and yields `None`, so a
5041    /// spill reference to it is `#REF!` rather than a partial range.
5042    pub(crate) fn spill_extent_for_anchor(&self, anchor: VertexId) -> Option<(CellRef, CellRef)> {
5043        let cells = self.spill_anchor_to_cells.get(&anchor)?;
5044        let first = *cells.first()?;
5045        let last = *cells.last()?;
5046        if last.sheet_id != first.sheet_id
5047            || last.coord.row() < first.coord.row()
5048            || last.coord.col() < first.coord.col()
5049        {
5050            return None;
5051        }
5052        let rows = (last.coord.row() - first.coord.row()) as usize + 1;
5053        let cols = (last.coord.col() - first.coord.col()) as usize + 1;
5054        if rows.checked_mul(cols) != Some(cells.len()) {
5055            return None;
5056        }
5057        Some((first, last))
5058    }
5059
5060    pub(crate) fn spill_registry_has_anchor(&self, anchor: VertexId) -> bool {
5061        self.spill_anchor_to_cells.contains_key(&anchor)
5062    }
5063
5064    pub(crate) fn spill_registry_anchor_for_cell(&self, cell: CellRef) -> Option<VertexId> {
5065        self.spill_cell_to_anchor.get(&cell).copied()
5066    }
5067
5068    pub(crate) fn spill_registry_counts(&self) -> (usize, usize) {
5069        (
5070            self.spill_anchor_to_cells.len(),
5071            self.spill_cell_to_anchor.len(),
5072        )
5073    }
5074
5075    /// Record `anchor`, a formula vertex at `cell`, as a declared dynamic
5076    /// array anchor. Returns whether it was newly declared.
5077    pub(crate) fn declare_dynamic_anchor(&mut self, anchor: VertexId, cell: CellRef) -> bool {
5078        let cell = CellRef::new(
5079            cell.sheet_id,
5080            Coord::new(cell.coord.row(), cell.coord.col(), true, true),
5081        );
5082        self.declared_dynamic_anchors.insert(anchor, cell) != Some(cell)
5083    }
5084
5085    /// Drop a declaration because its formula vertex was replaced, removed or
5086    /// moved. Free when nothing is declared.
5087    #[inline]
5088    pub(crate) fn forget_declared_dynamic_anchor(&mut self, vertex: VertexId) {
5089        if !self.fixed_single_arrays.is_empty() {
5090            self.fixed_single_arrays.remove(&vertex);
5091        }
5092        if !self.fixed_array_shapes.is_empty() {
5093            self.fixed_array_shapes.remove(&vertex);
5094        }
5095        if !self.declared_dynamic_anchors.is_empty() {
5096            self.declared_dynamic_anchors.remove(&vertex);
5097        }
5098    }
5099
5100    /// Whether `vertex` is a declared dynamic-array anchor that still holds
5101    /// a formula at the cell it was declared at. The position check is a
5102    /// backstop for edit paths that move a vertex without passing through
5103    /// [`Self::set_grid_addr`]; such a declaration is ignored, not revived.
5104    pub(crate) fn is_current_declared_dynamic_anchor(&self, vertex: VertexId) -> bool {
5105        if self.declared_dynamic_anchors.is_empty() {
5106            return false;
5107        }
5108        self.declared_dynamic_anchors
5109            .get(&vertex)
5110            .is_some_and(|declared| {
5111                self.vertex_has_formula(vertex)
5112                    && self.get_cell_ref(vertex).is_some_and(|at| {
5113                        at.sheet_id == declared.sheet_id
5114                            && at.coord.row() == declared.coord.row()
5115                            && at.coord.col() == declared.coord.col()
5116                    })
5117            })
5118    }
5119
5120    #[cfg(test)]
5121    pub(crate) fn declared_dynamic_anchor_count(&self) -> usize {
5122        self.declared_dynamic_anchors.len()
5123    }
5124
5125    /// Spills that must yield to `anchor` when its planned rectangle
5126    /// `target_cells` (row-major) collides with them; `None` when `anchor`
5127    /// is blocked instead.
5128    ///
5129    /// Engine policy for colliding spill extents, independent of evaluation
5130    /// and edit order: when neither anchor lies inside the other's rectangle,
5131    /// the anchor first in (sheet, column, row) order spills and the other
5132    /// gets `#SPILL!`. That is the order in which a schedule layer commits
5133    /// its cells, so a fresh evaluation already resolves this way; a later
5134    /// edit or a different layer order reaches the same result by making the
5135    /// later anchor yield. An anchor inside the other's rectangle is a formula
5136    /// blocker and is handled by the formula rule, not here. Every blocker
5137    /// must yield for the anchor to spill.
5138    pub(crate) fn spills_yielding_to(
5139        &self,
5140        anchor: VertexId,
5141        target_cells: &[CellRef],
5142    ) -> Option<Vec<VertexId>> {
5143        let anchor_cell = self.get_cell_ref(anchor)?;
5144        let (first, last) = (*target_cells.first()?, *target_cells.last()?);
5145        let inside = |cell: CellRef, first: CellRef, last: CellRef| {
5146            cell.sheet_id == first.sheet_id
5147                && (first.coord.row()..=last.coord.row()).contains(&cell.coord.row())
5148                && (first.coord.col()..=last.coord.col()).contains(&cell.coord.col())
5149        };
5150        let key = |cell: CellRef| (cell.sheet_id, cell.coord.col(), cell.coord.row());
5151        let mut yielding: Vec<VertexId> = Vec::new();
5152        for cell in target_cells {
5153            let Some(&owner) = self.spill_cell_to_anchor.get(cell) else {
5154                continue;
5155            };
5156            if owner == anchor || yielding.contains(&owner) {
5157                continue;
5158            }
5159            let owner_cell = self.get_cell_ref(owner)?;
5160            let (owner_first, owner_last) = self.spill_extent_for_anchor(owner)?;
5161            if inside(owner_cell, first, last)
5162                || inside(anchor_cell, owner_first, owner_last)
5163                || key(owner_cell) <= key(anchor_cell)
5164            {
5165                return None;
5166            }
5167            yielding.push(owner);
5168        }
5169        if yielding.is_empty() {
5170            return None;
5171        }
5172        self.plan_spill_region_yielding(anchor, target_cells, &yielding)
5173            .ok()
5174            .map(|()| yielding)
5175    }
5176
5177    /// The anchor's registered spill cells that clearing the spill empties:
5178    /// every cell except a formula entered into the spill after it was
5179    /// committed, which keeps its own value.
5180    pub(crate) fn spill_cells_to_clear(&self, anchor: VertexId) -> Vec<CellRef> {
5181        let Some(cells) = self.spill_cells_for_anchor(anchor) else {
5182            return Vec::new();
5183        };
5184        if !self.spill_may_be_intruded(anchor) {
5185            return cells.to_vec();
5186        }
5187        cells
5188            .iter()
5189            .copied()
5190            .filter(|cell| !self.is_foreign_formula_cell(cell, anchor))
5191            .collect()
5192    }
5193
5194    /// Whether `anchor`'s committed spill may hold a foreign formula: it is
5195    /// recorded in `spill_intruded_anchors`, or a formula write has not
5196    /// been synced into that record yet (inside a deferred-dirty batch,
5197    /// say). O(1).
5198    #[inline]
5199    pub(crate) fn spill_may_be_intruded(&self, anchor: VertexId) -> bool {
5200        self.vertex_formulas.has_touched()
5201            || (!self.spill_intruded_anchors.is_empty()
5202                && self.spill_intruded_anchors.contains(&anchor))
5203    }
5204
5205    /// Record the anchors whose committed spill holds a formula written
5206    /// since the last authority sync (the formula map's touched journal),
5207    /// and return those not recorded before: the caller wakes them so their
5208    /// next recalc re-plans and reports `#SPILL!`. O(touched) lookups, and
5209    /// nothing at all without a committed spill.
5210    pub(crate) fn note_spill_intrusions_of_touched(&mut self) -> Vec<VertexId> {
5211        if self.spill_cell_to_anchor.is_empty() {
5212            return Vec::new();
5213        }
5214        let owners: Vec<VertexId> = self
5215            .vertex_formulas
5216            .touched()
5217            .iter()
5218            .filter_map(|&vertex| {
5219                let cell = self.get_cell_ref(vertex)?;
5220                let &owner = self.spill_cell_to_anchor.get(&cell)?;
5221                (owner != vertex && self.is_foreign_formula_cell(&cell, owner)).then_some(owner)
5222            })
5223            .collect();
5224        owners
5225            .into_iter()
5226            .filter(|&owner| self.spill_intruded_anchors.insert(owner))
5227            .collect()
5228    }
5229
5230    /// A structural edit is about to move cells: any committed spill may
5231    /// end up holding a moved formula, so every anchor probes its cells
5232    /// once more. O(anchors).
5233    pub(crate) fn note_structural_spill_intrusions(&mut self) {
5234        if self.spill_anchor_to_cells.is_empty() {
5235            return;
5236        }
5237        let anchors: Vec<VertexId> = self.spill_anchor_to_cells.keys().copied().collect();
5238        self.spill_intruded_anchors.extend(anchors);
5239    }
5240
5241    #[cfg(test)]
5242    pub(crate) fn spill_intruded_anchor_count(&self) -> usize {
5243        self.spill_intruded_anchors.len()
5244    }
5245
5246    /// Whether `cell` holds a formula vertex other than `anchor`.
5247    pub(crate) fn is_foreign_formula_cell(&self, cell: &CellRef, anchor: VertexId) -> bool {
5248        self.cell_vertex(cell).is_some_and(|vid| {
5249            vid != anchor
5250                && matches!(
5251                    self.store.kind(vid),
5252                    VertexKind::FormulaScalar | VertexKind::FormulaArray
5253                )
5254        })
5255    }
5256
5257    /// Clear an existing spill region for an anchor (set cells to Empty and forget ownership)
5258    pub fn clear_spill_region(&mut self, anchor: VertexId) {
5259        let _ = self.clear_spill_region_bulk(anchor);
5260    }
5261
5262    /// Bulk clear an existing spill region for an anchor.
5263    ///
5264    /// This avoids calling `set_cell_value()` per spill child (which can trigger O(N*V)
5265    /// dependent scans when `edges.delta_size() > 0`). Instead, it clears values directly and
5266    /// performs a single dirty propagation over the affected spill children.
5267    ///
5268    /// Returns the previously registered spill cells (including the anchor cell) for callers that
5269    /// want to mirror/record deltas.
5270    pub fn clear_spill_region_bulk(&mut self, anchor: VertexId) -> Vec<CellRef> {
5271        let anchor_cell = self.get_cell_ref(anchor);
5272        let Some(cells) = self.spill_anchor_to_cells.remove(&anchor) else {
5273            return Vec::new();
5274        };
5275        let probe_owned = self.spill_may_be_intruded(anchor);
5276        if !self.spill_intruded_anchors.is_empty() {
5277            self.spill_intruded_anchors.remove(&anchor);
5278        }
5279
5280        // Remove ownership for all cells first.
5281        for cell in cells.iter() {
5282            self.spill_cell_to_anchor.remove(cell);
5283            let remove_sheet = self
5284                .spill_cells_by_sheet
5285                .get_mut(&cell.sheet_id)
5286                .is_some_and(|sheet| {
5287                    sheet.remove(&(cell.coord.row(), cell.coord.col()));
5288                    sheet.is_empty()
5289                });
5290            if remove_sheet {
5291                self.spill_cells_by_sheet.remove(&cell.sheet_id);
5292            }
5293        }
5294
5295        // Clear all spill children (excluding the anchor cell). A child is a
5296        // value cell: no vertex (decision 27); one it still has leaves.
5297        let mut changed: Vec<crate::engine::authority::geom::Cell> = Vec::new();
5298        for cell in cells.iter().copied() {
5299            let is_anchor = anchor_cell.map(|a| a == cell).unwrap_or(false);
5300            // A formula entered into the spill after it was committed is the
5301            // user's cell, not a child: it stays.
5302            if is_anchor || (probe_owned && self.is_foreign_formula_cell(&cell, anchor)) {
5303                continue;
5304            }
5305            self.vacate_cell(&cell);
5306            changed.push((cell.sheet_id, cell.coord.row(), cell.coord.col()));
5307        }
5308
5309        // Single dirty propagation for all changed spill children.
5310        if !changed.is_empty() {
5311            let _ = self.mark_dirty_cells(&changed);
5312        }
5313
5314        cells
5315    }
5316
5317    #[cfg(any(test, feature = "legacy_oracle"))]
5318    fn collect_range_dependents_for_vertex(&self, vertex_id: VertexId) -> Vec<VertexId> {
5319        // Only a vertex with a position can sit inside a range. A symbol has none.
5320        let Some(position) = self.store.grid_addr(vertex_id) else {
5321            return Vec::new();
5322        };
5323        self.collect_range_dependents_for_rect(
5324            self.store.sheet_id(vertex_id),
5325            position.row(),
5326            position.col(),
5327            position.row(),
5328            position.col(),
5329        )
5330    }
5331
5332    #[cfg(any(test, feature = "legacy_oracle"))]
5333    fn collect_range_dependents_for_rect(
5334        &self,
5335        sheet_id: SheetId,
5336        start_row: u32,
5337        start_col: u32,
5338        end_row: u32,
5339        end_col: u32,
5340    ) -> Vec<VertexId> {
5341        if self.stripe_to_dependents.is_empty() {
5342            return Vec::new();
5343        }
5344        let mut candidates: FxHashSet<VertexId> = FxHashSet::default();
5345
5346        for col in start_col..=end_col {
5347            let key = StripeKey {
5348                sheet_id,
5349                stripe_type: StripeType::Column,
5350                index: col,
5351            };
5352            if let Some(deps) = self.stripe_to_dependents.get(&key) {
5353                candidates.extend(deps);
5354            }
5355        }
5356        for row in start_row..=end_row {
5357            let key = StripeKey {
5358                sheet_id,
5359                stripe_type: StripeType::Row,
5360                index: row,
5361            };
5362            if let Some(deps) = self.stripe_to_dependents.get(&key) {
5363                candidates.extend(deps);
5364            }
5365        }
5366        if self.config.enable_block_stripes {
5367            let br0 = start_row / BLOCK_H;
5368            let br1 = end_row / BLOCK_H;
5369            let bc0 = start_col / BLOCK_W;
5370            let bc1 = end_col / BLOCK_W;
5371            for br in br0..=br1 {
5372                for bc in bc0..=bc1 {
5373                    let key = StripeKey {
5374                        sheet_id,
5375                        stripe_type: StripeType::Block,
5376                        index: block_index(br * BLOCK_H, bc * BLOCK_W),
5377                    };
5378                    if let Some(deps) = self.stripe_to_dependents.get(&key) {
5379                        candidates.extend(deps);
5380                    }
5381                }
5382            }
5383        }
5384
5385        // Precision check: the dirty rect must overlap at least one of the formula's registered ranges.
5386        let mut out: Vec<VertexId> = Vec::new();
5387        for dep_id in candidates {
5388            let Some(ranges) = self.formula_to_range_deps.get(&dep_id) else {
5389                continue;
5390            };
5391            let mut hit = false;
5392            for range in ranges {
5393                // `Current` is the dependent formula's own sheet; an
5394                // unresolvable name keeps the dependent in the candidate set.
5395                let range_sheet_id = self
5396                    .sheet_reg
5397                    .resolve_locator(&range.sheet, self.get_vertex_sheet_id(dep_id))
5398                    .unwrap_or(sheet_id);
5399                if range_sheet_id != sheet_id {
5400                    continue;
5401                }
5402                let sr0 = range.start_row.map(|b| b.index).unwrap_or(0);
5403                let er0 = range.end_row.map(|b| b.index).unwrap_or(u32::MAX);
5404                let sc0 = range.start_col.map(|b| b.index).unwrap_or(0);
5405                let ec0 = range.end_col.map(|b| b.index).unwrap_or(u32::MAX);
5406                let overlap =
5407                    sr0 <= end_row && er0 >= start_row && sc0 <= end_col && ec0 >= start_col;
5408                if overlap {
5409                    hit = true;
5410                    break;
5411                }
5412            }
5413            if hit {
5414                out.push(dep_id);
5415            }
5416        }
5417        out
5418    }
5419
5420    /// Whether `vertex_id` is an existing, non-deleted vertex that still
5421    /// holds a formula (a cell overwritten with a literal keeps its vertex
5422    /// but drops its formula).
5423    pub(crate) fn is_live_formula_vertex(&self, vertex_id: VertexId) -> bool {
5424        self.store.vertex_exists_active(vertex_id) && self.has_formula(vertex_id)
5425    }
5426
5427    /// Check if a vertex exists
5428    pub(crate) fn vertex_exists(&self, vertex_id: VertexId) -> bool {
5429        if vertex_id.0 < FIRST_NORMAL_VERTEX {
5430            return false;
5431        }
5432        let index = (vertex_id.0 - FIRST_NORMAL_VERTEX) as usize;
5433        index < self.store.len()
5434    }
5435
5436    /// Get the kind of a vertex
5437    pub(crate) fn get_vertex_kind(&self, vertex_id: VertexId) -> VertexKind {
5438        self.store.kind(vertex_id)
5439    }
5440
5441    /// Get the sheet ID of a vertex
5442    pub(crate) fn get_vertex_sheet_id(&self, vertex_id: VertexId) -> SheetId {
5443        self.store.sheet_id(vertex_id)
5444    }
5445
5446    /// The vertex's own arena formula root. `None` for a compressed family
5447    /// member (its formula is a shared template at an offset; use
5448    /// [`Self::formula_view`] or [`Self::get_formula`]).
5449    pub fn get_formula_id(&self, vertex_id: VertexId) -> Option<AstNodeId> {
5450        self.vertex_formulas.get(&vertex_id).and_then(|f| f.own())
5451    }
5452
5453    /// The formula of a formula vertex as a template plus offset.
5454    pub fn formula_view(&self, vertex_id: VertexId) -> Option<FormulaView> {
5455        let f = self.vertex_formulas.get(&vertex_id)?;
5456        Some(match f {
5457            FormulaRef::Own(template) => FormulaView {
5458                template,
5459                row_delta: 0,
5460                col_delta: 0,
5461            },
5462            FormulaRef::Member { template, anchor } => {
5463                let addr = self.store.grid_addr(vertex_id)?;
5464                FormulaView {
5465                    template,
5466                    row_delta: i64::from(addr.row()) - i64::from(anchor.0),
5467                    col_delta: i64::from(addr.col()) - i64::from(anchor.1),
5468                }
5469            }
5470        })
5471    }
5472
5473    /// Whether the vertex holds a formula (own or compressed).
5474    pub(crate) fn has_formula(&self, vertex_id: VertexId) -> bool {
5475        self.vertex_formulas.contains_key(&vertex_id)
5476    }
5477
5478    /// Ensure the vertex stores its own AST (instantiating a compressed
5479    /// member), and return it. Paths that rewrite one cell's formula use
5480    /// this before editing.
5481    pub(crate) fn own_formula_id(&mut self, vertex_id: VertexId) -> Option<AstNodeId> {
5482        self.materialize_vertex(vertex_id);
5483        match self.vertex_formulas.get(&vertex_id)? {
5484            FormulaRef::Own(id) => Some(id),
5485            FormulaRef::Member { .. } => {
5486                let ast = self.get_formula(vertex_id)?;
5487                let id = self.data_store.store_ast(&ast, &self.sheet_reg);
5488                self.vertex_formulas.decompress(vertex_id, id);
5489                Some(id)
5490            }
5491        }
5492    }
5493
5494    /// Formula vertices held by the per-vertex formula map (not virtual
5495    /// family members), by id.
5496    pub(crate) fn materialized_formula_vertices_sorted(&self) -> Vec<VertexId> {
5497        let mut vertices: Vec<VertexId> = self.vertex_formulas.map_iter().map(|(v, _)| v).collect();
5498        vertices.sort_unstable();
5499        vertices
5500    }
5501
5502    pub(crate) fn formula_vertices(&self) -> Vec<VertexId> {
5503        let mut vertices = self.vertex_formulas.keys().collect::<Vec<_>>();
5504        vertices.sort_unstable();
5505        vertices
5506    }
5507
5508    pub fn get_formula_id_and_volatile(&self, vertex_id: VertexId) -> Option<(AstNodeId, bool)> {
5509        let ast_id = self.get_formula_id(vertex_id)?;
5510        Some((ast_id, self.is_volatile(vertex_id)))
5511    }
5512
5513    pub fn get_formula_node(&self, vertex_id: VertexId) -> Option<&super::arena::AstNodeData> {
5514        let ast_id = self.get_formula_id(vertex_id)?;
5515        self.data_store.get_node(ast_id)
5516    }
5517
5518    pub fn get_formula_node_and_volatile(
5519        &self,
5520        vertex_id: VertexId,
5521    ) -> Option<(&super::arena::AstNodeData, bool)> {
5522        let (ast_id, vol) = self.get_formula_id_and_volatile(vertex_id)?;
5523        let node = self.data_store.get_node(ast_id)?;
5524        Some((node, vol))
5525    }
5526
5527    /// Get the formula AST for a vertex.
5528    ///
5529    /// Not used in hot paths; reconstructs from arena.
5530    pub fn get_formula(&self, vertex_id: VertexId) -> Option<ASTNode> {
5531        let view = self.formula_view(vertex_id)?;
5532        let ast = self
5533            .data_store
5534            .retrieve_ast(view.template, &self.sheet_reg)?;
5535        if view.row_delta == 0 && view.col_delta == 0 {
5536            return Some(ast);
5537        }
5538        crate::engine::template::relocate::instantiate_member_ast(
5539            &ast,
5540            view.row_delta,
5541            view.col_delta,
5542        )
5543        .ok()
5544    }
5545
5546    /// Get the value stored for a vertex
5547    pub fn get_value(&self, vertex_id: VertexId) -> Option<LiteralValue> {
5548        if !self.value_cache_enabled && self.is_grid_backed(vertex_id) {
5549            // In canonical mode, grid-backed values must not be read from the graph.
5550            // Symbols (named ranges, tables, external sources) may still use graph storage.
5551            #[cfg(debug_assertions)]
5552            {
5553                self.graph_value_read_attempts
5554                    .fetch_add(1, Ordering::Relaxed);
5555            }
5556            return None;
5557        }
5558        self.vertex_values
5559            .get(&vertex_id)
5560            .map(|&value_ref| self.data_store.retrieve_value(value_ref))
5561    }
5562
5563    /// True when the vertex occupies the grid, i.e. it is a cell, formula or empty
5564    /// placeholder rather than a symbol.
5565    ///
5566    /// This replaces the `VertexKind` enumerations that used to spell out the grid-backed
5567    /// kinds. "Has a position" is now a structural property of the address, so it cannot
5568    /// drift out of step with the set of kinds.
5569    #[inline]
5570    fn is_grid_backed(&self, vertex_id: VertexId) -> bool {
5571        self.store.grid_addr(vertex_id).is_some()
5572    }
5573
5574    /// Get the cell reference for a vertex.
5575    ///
5576    /// Returns `None` for symbol vertices (names, tables, external sources): they are
5577    /// identified by name and have no position, so there is no address to return.
5578    pub(crate) fn get_cell_ref(&self, vertex_id: VertexId) -> Option<CellRef> {
5579        let grid = self.store.grid_addr(vertex_id)?;
5580        let sheet_id = self.store.sheet_id(vertex_id);
5581        let coord = Coord::new(grid.row(), grid.col(), true, true);
5582        Some(CellRef::new(sheet_id, coord))
5583    }
5584
5585    /// Create a cell reference (helper for internal use)
5586    pub(crate) fn make_cell_ref_internal(&self, sheet_id: SheetId, row: u32, col: u32) -> CellRef {
5587        let coord = Coord::new(row, col, true, true);
5588        CellRef::new(sheet_id, coord)
5589    }
5590
5591    /// Create a cell reference from sheet name and Excel 1-based coordinates.
5592    pub fn make_cell_ref(&self, sheet_name: &str, row: u32, col: u32) -> CellRef {
5593        let sheet_id = self.sheet_reg.get_id(sheet_name).unwrap_or(0);
5594        let coord = Coord::from_excel(row, col, true, true);
5595        CellRef::new(sheet_id, coord)
5596    }
5597
5598    /// Check if a vertex is dirty
5599    pub(crate) fn is_dirty(&self, vertex_id: VertexId) -> bool {
5600        self.store.is_dirty(vertex_id)
5601    }
5602
5603    /// Check if a vertex is volatile
5604    pub(crate) fn is_volatile(&self, vertex_id: VertexId) -> bool {
5605        self.store.is_volatile(vertex_id)
5606    }
5607
5608    pub(crate) fn is_dynamic(&self, vertex_id: VertexId) -> bool {
5609        self.store.is_dynamic(vertex_id)
5610    }
5611
5612    /// Get vertex ID for a cell address.
5613    ///
5614    /// Returns the id by value (0.10: it returned `Option<&VertexId>`, a
5615    /// borrow of the cell map, which no longer holds compressed family
5616    /// members).
5617    pub fn get_vertex_id_for_address(&self, addr: &CellRef) -> Option<VertexId> {
5618        self.cell_vertex(addr)
5619    }
5620
5621    #[cfg(test)]
5622    pub fn cell_to_vertex(
5623        &self,
5624    ) -> &std::collections::HashMap<CellRef, VertexId, CoordBuildHasher> {
5625        &self.cell_to_vertex
5626    }
5627
5628    #[cfg(any(test, feature = "legacy_oracle"))]
5629    /// Borrow dependencies of a vertex when no pending edge delta exists.
5630    ///
5631    /// This enables zero-allocation traversal in hot scheduler paths.
5632    #[inline]
5633    pub(crate) fn dependencies_slice(&self, vertex_id: VertexId) -> Option<&[VertexId]> {
5634        self.edges.out_edges_ref(vertex_id)
5635    }
5636
5637    #[cfg(any(test, feature = "legacy_oracle"))]
5638    /// Get the dependencies of a vertex (for scheduler)
5639    pub(crate) fn get_dependencies(&self, vertex_id: VertexId) -> Vec<VertexId> {
5640        self.edges.out_edges(vertex_id)
5641    }
5642
5643    #[cfg(any(test, feature = "legacy_oracle"))]
5644    /// Check if a vertex has a self-loop
5645    pub(crate) fn has_self_loop(&self, vertex_id: VertexId) -> bool {
5646        if let Some(deps) = self.dependencies_slice(vertex_id) {
5647            deps.contains(&vertex_id)
5648        } else {
5649            self.edges.out_edges(vertex_id).contains(&vertex_id)
5650        }
5651    }
5652
5653    #[cfg(any(test, feature = "legacy_oracle"))]
5654    /// Borrow dependents of a vertex when no pending edge delta exists.
5655    ///
5656    /// This enables zero-allocation traversal in hot scheduler paths.
5657    #[inline]
5658    pub(crate) fn dependents_slice(&self, vertex_id: VertexId) -> Option<&[VertexId]> {
5659        self.edges.in_edges_ref(vertex_id)
5660    }
5661
5662    #[cfg(any(test, feature = "legacy_oracle"))]
5663    /// Get dependents of a vertex (vertices that depend on this vertex)
5664    ///
5665    /// Delta-aware: pending edge mutations that have not been folded into the
5666    /// CSR base yet are merged in via the delta slab's reverse index, so this
5667    /// is O(in-degree) even mid-edit (no O(V) scan, no forced rebuild; #125).
5668    pub(crate) fn get_dependents(&self, vertex_id: VertexId) -> Vec<VertexId> {
5669        self.edges.in_edges_merged(vertex_id)
5670    }
5671
5672    #[cfg(any(test, feature = "legacy_oracle"))]
5673    /// Bounded, delta-aware incoming-edge visitor used by read-only
5674    /// introspection. Unlike `get_dependents`, this never constructs the full
5675    /// in-degree before the caller's work limit can stop discovery.
5676    pub(crate) fn visit_direct_dependents_bounded(
5677        &self,
5678        vertex_id: VertexId,
5679        remaining_work: &mut u64,
5680        visitor: &mut dyn FnMut(VertexId) -> bool,
5681    ) -> bool {
5682        self.edges
5683            .visit_in_edges_bounded(vertex_id, remaining_work, visitor)
5684    }
5685
5686    // Internal helper methods for Milestone 0.4
5687
5688    /// Internal: Create a snapshot of vertex state for rollback
5689    #[doc(hidden)]
5690    pub fn snapshot_vertex(&self, id: VertexId) -> crate::engine::VertexSnapshot {
5691        let coord = self.store.grid_addr(id).unwrap_or_default();
5692        let sheet_id = self.store.sheet_id(id);
5693        let kind = self.store.kind(id);
5694        let flags = self.store.flags(id) & !crate::engine::vertex_store::VIRTUAL_FLAG;
5695
5696        // Get value and formula references
5697        let value_ref = self.vertex_values.get(&id).copied();
5698        let formula_ref = self.vertex_formulas.get(&id).map(|f| f.root());
5699
5700        // Outgoing edges (dependencies): legacy's, in oracle builds only.
5701        #[cfg(any(test, feature = "legacy_oracle"))]
5702        let out_edges = self.get_dependencies(id);
5703        #[cfg(not(any(test, feature = "legacy_oracle")))]
5704        let out_edges = Vec::new();
5705
5706        crate::engine::VertexSnapshot {
5707            coord,
5708            sheet_id,
5709            kind,
5710            flags,
5711            value_ref,
5712            formula_ref,
5713            out_edges,
5714        }
5715    }
5716
5717    /// Internal: Remove all edges for a vertex
5718    #[doc(hidden)]
5719    pub(crate) fn remove_all_edges(&mut self, id: VertexId) {
5720        self.materialize_vertex(id);
5721        #[cfg(not(any(test, feature = "legacy_oracle")))]
5722        self.remove_dependent_edges(id);
5723        #[cfg(any(test, feature = "legacy_oracle"))]
5724        {
5725            // Enter batch mode to avoid intermediate rebuilds
5726            #[cfg(any(test, feature = "legacy_oracle"))]
5727            self.edges.begin_batch();
5728
5729            // Remove outgoing edges (this vertex's dependencies)
5730            self.remove_dependent_edges(id);
5731
5732            // Remove incoming edges (vertices that depend on this vertex).
5733            // get_dependents is delta-aware, so no rebuild is needed here (#125).
5734            let dependents = self.get_dependents(id);
5735            if self.pk_order.is_some()
5736                && let Some(mut pk) = self.pk_order.take()
5737            {
5738                for dependent in &dependents {
5739                    pk.remove_edge(id, *dependent);
5740                }
5741                self.pk_order = Some(pk);
5742            }
5743            for dependent in dependents {
5744                self.edges.remove_edge(dependent, id);
5745            }
5746
5747            // Exit batch mode and rebuild once with all changes
5748            #[cfg(any(test, feature = "legacy_oracle"))]
5749            self.edges.end_batch();
5750        }
5751    }
5752
5753    /// Internal: Mark vertex as having #REF! error
5754    #[doc(hidden)]
5755    pub fn mark_as_ref_error(&mut self, id: VertexId) {
5756        self.materialize_vertex(id);
5757        if !self.value_cache_enabled && self.is_grid_backed(id) {
5758            self.ref_error_vertices.insert(id);
5759            // Canonical-only: graph does not cache grid-backed values.
5760            // Ensure the dependent subgraph is dirtied so evaluation updates Arrow truth.
5761            self.vertex_values.remove(&id);
5762            let _ = self.mark_dirty(id);
5763            return;
5764        }
5765        let error = LiteralValue::Error(ExcelError::new(ExcelErrorKind::Ref));
5766        let value_ref = self.data_store.store_value(error);
5767        self.vertex_values.insert(id, value_ref);
5768        let _ = self.mark_dirty(id);
5769    }
5770
5771    /// Check if a vertex has a #REF! error
5772    pub fn is_ref_error(&self, id: VertexId) -> bool {
5773        if !self.value_cache_enabled && self.is_grid_backed(id) {
5774            return self.ref_error_vertices.contains(&id);
5775        }
5776        if let Some(value_ref) = self.vertex_values.get(&id) {
5777            let value = self.data_store.retrieve_value(*value_ref);
5778            if let LiteralValue::Error(err) = value {
5779                return err.kind == ExcelErrorKind::Ref;
5780            }
5781        }
5782        false
5783    }
5784
5785    /// Internal: Mark all direct dependents as dirty
5786    #[doc(hidden)]
5787    pub fn mark_dependents_dirty(&mut self, id: VertexId) {
5788        // Legacy flagged its CSR in-edge readers (cell references, ranges
5789        // within the expansion limit, names), without propagation. Mid
5790        // structural edit (or load) the store lags: queue it for the resync.
5791        if self.authority_defers_marks() {
5792            self.authority_queue_direct_dirty(id);
5793            return;
5794        }
5795        for dep_id in self.authority_in_edge_readers(id) {
5796            self.store.set_dirty(dep_id, true);
5797            self.formula_dirty.legacy_insert(dep_id);
5798        }
5799    }
5800
5801    /// Internal: Mark a vertex as volatile
5802    #[doc(hidden)]
5803    pub fn mark_volatile(&mut self, id: VertexId, volatile: bool) {
5804        if volatile {
5805            self.materialize_vertex(id);
5806        }
5807        self.store.set_volatile(id, volatile);
5808        if volatile {
5809            self.volatile_vertices.insert(id);
5810        } else {
5811            self.volatile_vertices.remove(&id);
5812        }
5813    }
5814
5815    /// Move a vertex to a new grid position.
5816    ///
5817    /// Takes a `GridAddr`, so a symbol vertex cannot be shifted onto the grid by a
5818    /// structural edit (#304).
5819    #[doc(hidden)]
5820    pub fn set_grid_addr(&mut self, id: VertexId, coord: GridAddr) {
5821        self.materialize_vertex(id);
5822        // A declared dynamic-array anchor's identity does not follow a moved
5823        // vertex (FORM211): the declaration is cleared.
5824        if (!self.declared_dynamic_anchors.is_empty()
5825            || !self.fixed_single_arrays.is_empty()
5826            || !self.fixed_array_shapes.is_empty())
5827            && self.store.grid_addr(id) != Some(coord)
5828        {
5829            self.forget_declared_dynamic_anchor(id);
5830        }
5831        self.store.set_addr(id, VertexAddr::grid(coord));
5832    }
5833
5834    /// Update edge cache coordinate
5835    #[doc(hidden)]
5836    pub(crate) fn update_edge_grid_addr(&mut self, id: VertexId, coord: GridAddr) {
5837        self.materialize_vertex(id);
5838        #[cfg(not(any(test, feature = "legacy_oracle")))]
5839        let _ = (&id, &coord);
5840        #[cfg(any(test, feature = "legacy_oracle"))]
5841        {
5842            self.edges.update_addr(id, VertexAddr::grid(coord));
5843        }
5844    }
5845
5846    /// Mark vertex as deleted (tombstone)
5847    #[doc(hidden)]
5848    pub fn mark_deleted(&mut self, id: VertexId, deleted: bool) {
5849        self.materialize_vertex(id);
5850        self.store.mark_deleted(id, deleted);
5851    }
5852
5853    /// Set vertex kind
5854    #[doc(hidden)]
5855    pub fn set_kind(&mut self, id: VertexId, kind: VertexKind) {
5856        self.materialize_vertex(id);
5857        self.store.set_kind(id, kind);
5858    }
5859
5860    /// Set vertex dirty flag
5861    #[doc(hidden)]
5862    pub fn set_dirty(&mut self, id: VertexId, dirty: bool) {
5863        self.store.set_dirty(id, dirty);
5864        if dirty {
5865            self.formula_dirty.legacy_insert(id);
5866        } else {
5867            self.formula_dirty.legacy_remove(&id);
5868        }
5869    }
5870
5871    /// Get vertex kind (for testing)
5872    #[cfg(test)]
5873    pub(crate) fn get_kind(&self, id: VertexId) -> VertexKind {
5874        self.store.kind(id)
5875    }
5876
5877    /// Get vertex flags (for testing)
5878    #[cfg(test)]
5879    pub(crate) fn get_flags(&self, id: VertexId) -> u8 {
5880        self.store.flags(id) & !crate::engine::vertex_store::VIRTUAL_FLAG
5881    }
5882
5883    /// The virtual family member at `addr`, if any.
5884    #[inline]
5885    pub(crate) fn virtual_member_at(&self, addr: &CellRef) -> Option<VertexId> {
5886        self.vertex_formulas
5887            .virtual_members()
5888            .by_cell(addr.sheet_id, addr.coord.row(), addr.coord.col())
5889            .map(|m| m.vertex)
5890    }
5891
5892    /// Check if vertex is deleted (for testing)
5893    #[cfg(test)]
5894    pub(crate) fn is_deleted(&self, id: VertexId) -> bool {
5895        self.store.is_deleted(id)
5896    }
5897
5898    #[cfg(any(test, feature = "legacy_oracle"))]
5899    /// Force edge rebuild (internal use)
5900    #[doc(hidden)]
5901    pub fn rebuild_edges(&mut self) {
5902        self.edges.rebuild();
5903    }
5904
5905    #[cfg(any(test, feature = "legacy_oracle"))]
5906    /// Fold pending edge deltas into the CSR base ahead of a read-heavy phase
5907    /// (scheduling/evaluation), restoring the zero-allocation slice fast
5908    /// paths. No-op when no deltas are pending. This is the read-side half of
5909    /// the #125 amortization: writes defer rebuilds, read bursts pay for at
5910    /// most one.
5911    pub fn flush_pending_edge_deltas(&mut self) {
5912        self.edges.rebuild();
5913    }
5914
5915    #[cfg(any(test, feature = "legacy_oracle"))]
5916    /// Get delta size (internal use)
5917    #[doc(hidden)]
5918    pub fn edges_delta_size(&self) -> usize {
5919        self.edges.delta_size()
5920    }
5921
5922    #[cfg(any(test, feature = "legacy_oracle"))]
5923    /// Number of full CSR rebuilds performed so far (observability; used by
5924    /// the #125 rebuild-amortization regression tests).
5925    #[doc(hidden)]
5926    pub fn edges_rebuild_count(&self) -> u64 {
5927        self.edges.rebuild_count()
5928    }
5929
5930    /// Get vertex ID for specific cell address
5931    pub fn get_vertex_for_cell(&self, addr: &CellRef) -> Option<VertexId> {
5932        self.cell_vertex(addr)
5933    }
5934
5935    // ---------------------------------------------------------------
5936    // Virtual family members (Program 2 compression, P2-M2).
5937
5938    /// The vertex at `addr`: the cell map, else a virtual family member.
5939    #[inline]
5940    pub(crate) fn cell_vertex(&self, addr: &CellRef) -> Option<VertexId> {
5941        if let Some(&v) = self.cell_to_vertex.get(addr) {
5942            return Some(v);
5943        }
5944        self.vertex_formulas
5945            .virtual_members()
5946            .by_cell(addr.sheet_id, addr.coord.row(), addr.coord.col())
5947            .map(|m| m.vertex)
5948    }
5949
5950    /// [`Self::cell_vertex`] for a caller about to mutate the cell or its
5951    /// vertex: a virtual member is materialized first.
5952    #[inline]
5953    pub(crate) fn cell_vertex_mut(&mut self, addr: &CellRef) -> Option<VertexId> {
5954        if let Some(&v) = self.cell_to_vertex.get(addr) {
5955            return Some(v);
5956        }
5957        let m = self.vertex_formulas.virtual_members().by_cell(
5958            addr.sheet_id,
5959            addr.coord.row(),
5960            addr.coord.col(),
5961        )?;
5962        self.materialize_vertex(m.vertex);
5963        Some(m.vertex)
5964    }
5965
5966    /// Whether `v` is a virtual family member.
5967    #[inline]
5968    pub(crate) fn is_virtual_member(&self, v: VertexId) -> bool {
5969        !self.vertex_formulas.virtual_members().is_empty() && self.store.is_virtual(v)
5970    }
5971
5972    /// Give virtual member `v` its per-cell map entries back (cell map,
5973    /// formula map, sheet index). Same formula, same vertex; no-op for any
5974    /// other vertex. O(log runs).
5975    pub(crate) fn materialize_vertex(&mut self, v: VertexId) -> bool {
5976        if !self.is_virtual_member(v) {
5977            return false;
5978        }
5979        let Some(m) = self.vertex_formulas.materialize(v) else {
5980            return false;
5981        };
5982        self.store.ensure_dense(v, 1);
5983        self.store.set_virtual(v, false);
5984        let addr = CellRef::new(m.sheet, Coord::new(m.row, m.col, true, true));
5985        self.cell_to_vertex.insert(addr, v);
5986        self.sheet_index_mut(m.sheet)
5987            .add_vertex(GridAddr::new(m.row, m.col), v);
5988        true
5989    }
5990
5991    /// Materialize every virtual member of `sheet`.
5992    pub(crate) fn materialize_sheet(&mut self, sheet: SheetId) {
5993        let runs = self
5994            .vertex_formulas
5995            .virtual_members_mut()
5996            .drain_sheet(sheet);
5997        self.restore_runs(runs);
5998    }
5999
6000    /// Materialize every virtual member (structural and sheet-wide
6001    /// operations, and anything that walks per-cell maps). O(members).
6002    pub(crate) fn materialize_all(&mut self) {
6003        if self.vertex_formulas.virtual_members().is_empty() {
6004            return;
6005        }
6006        let runs = self.vertex_formulas.virtual_members_mut().drain();
6007        self.restore_runs(runs);
6008    }
6009
6010    fn restore_runs(&mut self, runs: Vec<virtual_members::MemberRun>) {
6011        if runs.is_empty() {
6012            return;
6013        }
6014        let n: usize = runs.iter().map(|r| r.len as usize).sum();
6015        self.cell_to_vertex.reserve(n);
6016        self.vertex_formulas.reserve(n);
6017        let mut by_sheet: FxHashMap<SheetId, Vec<(GridAddr, VertexId)>> = FxHashMap::default();
6018        for r in &runs {
6019            self.store.ensure_dense(VertexId(r.first), r.len);
6020            let f = r.formula();
6021            let batch = by_sheet.entry(r.sheet).or_default();
6022            for (v, row) in r.members() {
6023                self.store.set_virtual(v, false);
6024                self.vertex_formulas.restore(v, f);
6025                self.cell_to_vertex
6026                    .insert(CellRef::new(r.sheet, Coord::new(row, r.col, true, true)), v);
6027                batch.push((GridAddr::new(row, r.col), v));
6028            }
6029        }
6030        for (sheet, batch) in by_sheet {
6031            self.sheet_index_mut(sheet).add_vertices_batch(&batch);
6032        }
6033    }
6034
6035    /// Formula vertices in columns `c0..=c1` of `sheet` that are virtual
6036    /// family members, with their rows (sheet-index queries add these: the
6037    /// index holds only materialized vertices).
6038    pub(crate) fn virtual_members_in_cols(
6039        &self,
6040        sheet: SheetId,
6041        c0: u32,
6042        c1: u32,
6043    ) -> impl Iterator<Item = (VertexId, GridAddr)> + '_ {
6044        self.vertex_formulas
6045            .virtual_members()
6046            .runs_in_cols(sheet, c0, c1)
6047            .flat_map(|r| {
6048                r.members()
6049                    .map(move |(v, row)| (v, GridAddr::new(row, r.col)))
6050            })
6051    }
6052
6053    /// Vertices of `sheet` whose column is in `c0..=c1`: the sheet index
6054    /// plus virtual members, or (no index) a scan of the vertex rows, which
6055    /// virtual members keep.
6056    pub(crate) fn vertices_in_cols(&self, sheet: SheetId, c0: u32, c1: u32) -> Vec<VertexId> {
6057        match self.sheet_indexes.get(&sheet) {
6058            Some(index) => {
6059                let mut out = index.vertices_in_col_range(c0, c1);
6060                out.extend(self.virtual_members_in_cols(sheet, c0, c1).map(|(v, _)| v));
6061                out
6062            }
6063            None => self
6064                .grid_vertices_in_sheet(sheet)
6065                .filter(|(_, a)| a.col() >= c0 && a.col() <= c1)
6066                .map(|(v, _)| v)
6067                .collect(),
6068        }
6069    }
6070
6071    /// Vertices of `sheet` whose row is in `r0..=r1` (see
6072    /// [`Self::vertices_in_cols`]).
6073    pub(crate) fn vertices_in_rows(&self, sheet: SheetId, r0: u32, r1: u32) -> Vec<VertexId> {
6074        match self.sheet_indexes.get(&sheet) {
6075            Some(index) => {
6076                let mut out = index.vertices_in_row_range(r0, r1);
6077                for r in self.vertex_formulas.virtual_members().runs_in_sheet(sheet) {
6078                    let lo = r.row0.max(r0);
6079                    let hi = (r.row0 + r.len - 1).min(r1);
6080                    if lo <= hi {
6081                        out.extend((lo..=hi).map(|row| VertexId(r.first + (row - r.row0))));
6082                    }
6083                }
6084                out
6085            }
6086            None => self
6087                .grid_vertices_in_sheet(sheet)
6088                .filter(|(_, a)| a.row() >= r0 && a.row() <= r1)
6089                .map(|(v, _)| v)
6090                .collect(),
6091        }
6092    }
6093
6094    /// Turn compressed family members into virtual runs: members whose
6095    /// formula is exactly `Member { template, anchor }` of one run of
6096    /// consecutive rows and consecutive vertex ids down a column, and that
6097    /// carry no per-vertex state beyond their row (not volatile, dynamic,
6098    /// a spill anchor, a #REF! mark, a cached value, pending names or name
6099    /// links). Returns the number of members made virtual.
6100    ///
6101    /// Cost: one scan of the formula map, a sort of the member candidates
6102    /// by vertex id (so the vertex columns are read in order), one `retain`
6103    /// of the two maps checked against the new runs, and a rebuild of the
6104    /// affected sheet indexes.
6105    pub(crate) fn virtualize_family_members(&mut self) -> usize {
6106        let mut cand: Vec<(u32, AstNodeId, (u32, u32))> = self
6107            .vertex_formulas
6108            .map_iter()
6109            .filter_map(|(v, f)| match f {
6110                FormulaRef::Member { template, anchor } => Some((v.0, template, anchor)),
6111                FormulaRef::Own(_) => None,
6112            })
6113            .collect();
6114        if cand.len() < 2 {
6115            return 0;
6116        }
6117        cand.sort_unstable_by_key(|c| c.0);
6118        let runs = self.member_runs_in_id_order(&cand);
6119        drop(cand);
6120        self.install_virtual_runs(runs)
6121    }
6122
6123    /// Maximal runs among `cand` (member vertices sorted by id, with their
6124    /// template and anchor): consecutive ids, one column, consecutive rows,
6125    /// one template, each member virtualizable. Runs of one are dropped.
6126    fn member_runs_in_id_order(
6127        &self,
6128        cand: &[(u32, AstNodeId, (u32, u32))],
6129    ) -> Vec<virtual_members::MemberRun> {
6130        let mut runs: Vec<virtual_members::MemberRun> = Vec::new();
6131        let mut cur: Option<virtual_members::MemberRun> = None;
6132        let close = |cur: &mut Option<virtual_members::MemberRun>,
6133                     runs: &mut Vec<virtual_members::MemberRun>| {
6134            if let Some(r) = cur.take()
6135                && r.len >= 2
6136            {
6137                runs.push(r);
6138            }
6139        };
6140        for &(v, template, anchor) in cand {
6141            let vid = VertexId(v);
6142            if !self.virtualizable(vid) {
6143                close(&mut cur, &mut runs);
6144                continue;
6145            }
6146            let Some(addr) = self.store.grid_addr(vid) else {
6147                close(&mut cur, &mut runs);
6148                continue;
6149            };
6150            let sheet = self.store.sheet_id(vid);
6151            if let Some(r) = cur.as_mut()
6152                && r.sheet == sheet
6153                && r.col == addr.col()
6154                && r.first + r.len == v
6155                && r.row0 + r.len == addr.row()
6156                && r.template == template
6157                && r.anchor == anchor
6158            {
6159                r.len += 1;
6160                continue;
6161            }
6162            close(&mut cur, &mut runs);
6163            cur = Some(virtual_members::MemberRun {
6164                sheet,
6165                col: addr.col(),
6166                row0: addr.row(),
6167                len: 1,
6168                first: v,
6169                template,
6170                anchor,
6171            });
6172        }
6173        close(&mut cur, &mut runs);
6174        runs
6175    }
6176
6177    /// Move the members of `runs` (materialized, disjoint) out of the
6178    /// per-cell maps into virtual runs. Returns the number of members.
6179    fn install_virtual_runs(&mut self, runs: Vec<virtual_members::MemberRun>) -> usize {
6180        if runs.is_empty() {
6181            return 0;
6182        }
6183        let mut made = 0usize;
6184        let mut sheets: Vec<SheetId> = Vec::new();
6185        for &r in &runs {
6186            for (v, _) in r.members() {
6187                self.store.set_virtual(v, true);
6188            }
6189            sheets.push(r.sheet);
6190            made += r.len as usize;
6191            self.vertex_formulas.virtual_members_mut().insert(r);
6192        }
6193        // Drop the members' map entries: one pass over each map, checked
6194        // against the runs (small and cache-resident) rather than the
6195        // vertex columns.
6196        // An entry is a member's own mapping when its vertex is virtual now
6197        // (flag, one byte per vertex) and sits at that cell (its row).
6198        let store = &self.store;
6199        let mut removed: Vec<u32> = Vec::with_capacity(made);
6200        self.cell_to_vertex.retain(|c, v| {
6201            let mine = store.is_virtual(*v)
6202                && store.sheet_id(*v) == c.sheet_id
6203                && store.grid_addr(*v) == Some(GridAddr::new(c.coord.row(), c.coord.col()));
6204            if mine {
6205                removed.push(v.0);
6206            }
6207            !mine
6208        });
6209        self.cell_to_vertex.shrink_to_fit();
6210        let store = &self.store;
6211        self.vertex_formulas
6212            .drop_virtual_from_map(|v| store.is_virtual(v));
6213        if removed.len() != made {
6214            // Some member was not the vertex mapped at its cell (legacy
6215            // leaves such formula vertices after some undo/redo replays):
6216            // it stays materialized, cell map untouched.
6217            removed.sort_unstable();
6218            let orphans: Vec<VertexId> = runs
6219                .iter()
6220                .flat_map(|r| r.members().map(|(v, _)| v))
6221                .filter(|v| removed.binary_search(&v.0).is_err())
6222                .collect();
6223            for v in orphans {
6224                if self.vertex_formulas.materialize(v).is_some() {
6225                    self.store.set_virtual(v, false);
6226                    made -= 1;
6227                }
6228            }
6229        }
6230        sheets.sort_unstable();
6231        sheets.dedup();
6232        self.rebuild_sheet_indexes(&sheets);
6233        self.virtualize_member_pages();
6234        made
6235    }
6236
6237    /// Drop the vertex rows of every page filled by virtual members (see
6238    /// `VertexStore::virtualize_member_span`). Returns the pages dropped.
6239    pub(crate) fn virtualize_member_pages(&mut self) -> usize {
6240        let runs: Vec<virtual_members::MemberRun> = self
6241            .vertex_formulas
6242            .virtual_members()
6243            .runs()
6244            .filter(|r| r.len as usize >= 1024)
6245            .copied()
6246            .collect();
6247        runs.iter()
6248            .map(|r| {
6249                self.store
6250                    .virtualize_member_span(VertexId(r.first), r.len, r.sheet, r.col, r.row0)
6251            })
6252            .sum()
6253    }
6254
6255    /// Whether member vertex `v` may leave the per-cell maps.
6256    fn virtualizable(&self, v: VertexId) -> bool {
6257        let flags = self.store.flags(v);
6258        // deleted | volatile | dynamic | already virtual
6259        if flags & (0x02 | 0x04 | 0x08 | crate::engine::vertex_store::VIRTUAL_FLAG) != 0
6260            || self.store.kind(v) != VertexKind::FormulaScalar
6261        {
6262            return false;
6263        }
6264        let absent = |empty: bool, contains: &dyn Fn() -> bool| empty || !contains();
6265        absent(self.ref_error_vertices.is_empty(), &|| {
6266            self.ref_error_vertices.contains(&v)
6267        }) && absent(self.vertex_values.is_empty(), &|| {
6268            self.vertex_values.contains_key(&v)
6269        }) && absent(self.vertex_to_pending_names.is_empty(), &|| {
6270            self.vertex_to_pending_names.contains_key(&v)
6271        }) && absent(self.spill_anchor_to_cells.is_empty(), &|| {
6272            self.spill_anchor_to_cells.contains_key(&v)
6273        }) && absent(self.name_vertex_lookup.is_empty(), &|| {
6274            self.name_vertex_lookup.contains_key(&v)
6275        }) && absent(self.volatile_vertices.is_empty(), &|| {
6276            self.volatile_vertices.contains(&v)
6277        })
6278    }
6279
6280    /// Drop the virtual members from the (existing) sheet indexes of
6281    /// `sheets` (the store's virtual flag); every other entry keeps its
6282    /// indexed position.
6283    fn rebuild_sheet_indexes(&mut self, sheets: &[SheetId]) {
6284        let store = &self.store;
6285        for sheet in sheets {
6286            if let Some(index) = self.sheet_indexes.get_mut(sheet) {
6287                index.retain_vertices(|v| !store.is_virtual(v));
6288            }
6289        }
6290    }
6291
6292    /// Virtual family members and runs (tests, memory probes).
6293    /// Vertex pages without rows (tests, memory probes).
6294    pub(crate) fn virtual_vertex_pages(&self) -> usize {
6295        self.store.virtual_pages()
6296    }
6297
6298    pub(crate) fn virtual_member_counts(&self) -> (usize, usize) {
6299        let m = self.vertex_formulas.virtual_members();
6300        (m.len(), m.run_count())
6301    }
6302
6303    /// Get the grid position of a vertex (public for VertexEditor).
6304    ///
6305    /// `None` for symbol vertices, which have no position. Structural operations iterate
6306    /// grid positions, so this is what keeps them away from names, tables and sources.
6307    pub fn get_grid_addr(&self, id: VertexId) -> Option<GridAddr> {
6308        self.store.grid_addr(id)
6309    }
6310
6311    /// Get sheet_id for a vertex (public for VertexEditor)
6312    pub fn get_sheet_id(&self, id: VertexId) -> SheetId {
6313        self.store.sheet_id(id)
6314    }
6315
6316    /// Get every grid-resident vertex on a sheet, paired with its position.
6317    ///
6318    /// Symbol vertices (names, tables, external sources) are structurally absent: they have
6319    /// no grid position, so they cannot be produced here. Structural edits drive off this
6320    /// iterator, which is why a row or column operation can no longer delete or shift a
6321    /// name vertex (#302, #304).
6322    pub fn grid_vertices_in_sheet(
6323        &self,
6324        sheet_id: SheetId,
6325    ) -> impl Iterator<Item = (VertexId, GridAddr)> + '_ {
6326        self.store.all_vertices().filter_map(move |id| {
6327            if !self.vertex_exists(id) || self.store.sheet_id(id) != sheet_id {
6328                return None;
6329            }
6330            if !self.retired_id_set.is_empty()
6331                && self.retired_id_set.contains(&id)
6332                && self.store.is_deleted(id)
6333            {
6334                return None;
6335            }
6336            self.store.grid_addr(id).map(|addr| (id, addr))
6337        })
6338    }
6339
6340    /// Does a vertex have a formula associated
6341    pub fn vertex_has_formula(&self, id: VertexId) -> bool {
6342        self.vertex_formulas.contains_key(&id)
6343    }
6344
6345    /// Get all vertices with formulas
6346    pub fn vertices_with_formulas(&self) -> impl Iterator<Item = VertexId> + '_ {
6347        self.vertex_formulas.keys()
6348    }
6349
6350    /// Update a vertex's formula
6351    pub fn update_vertex_formula(&mut self, id: VertexId, ast: ASTNode) -> Result<(), ExcelError> {
6352        self.materialize_vertex(id);
6353        // Get the sheet_id for this vertex
6354        let sheet_id = self.store.sheet_id(id);
6355
6356        // Extract dependencies from AST, retaining unresolved names for later linking.
6357        let (
6358            new_dependencies,
6359            new_range_dependencies,
6360            vertexless,
6361            named_dependencies,
6362            unresolved_names,
6363        ) = self.extract_dependencies_with_pending_names(&ast, sheet_id)?;
6364
6365        let old_kind = self.store.kind(id);
6366
6367        // Remove all links owned by the previous formula.
6368        self.remove_dependent_edges(id);
6369        self.detach_vertex_from_names(id);
6370        self.clear_pending_name_references(id);
6371
6372        // Store the new formula
6373        let ast_id = self.data_store.store_ast(&ast, &self.sheet_reg);
6374        self.vertex_formulas.insert(id, ast_id);
6375
6376        // Add new dependency edges
6377        self.add_dependent_edges(id, &new_dependencies);
6378        self.note_vertexless_deps(
6379            id,
6380            vertexless
6381                .iter()
6382                .map(|c| (c.sheet_id, c.coord.row(), c.coord.col())),
6383        );
6384        self.add_range_dependent_edges(id, &new_range_dependencies, sheet_id);
6385
6386        if !named_dependencies.is_empty() {
6387            self.attach_vertex_to_names(id, &named_dependencies);
6388        }
6389        for unresolved_name in &unresolved_names {
6390            self.record_pending_name_reference(sheet_id, unresolved_name, id);
6391        }
6392
6393        // Formula replacement supersedes any structural error/cache state left when a
6394        // deleted dependency marked this vertex before its AST was rewritten.
6395        self.ref_error_vertices.remove(&id);
6396        self.vertex_values.remove(&id);
6397
6398        // A structural rewrite must not collapse an existing array formula kind.
6399        self.store.set_kind(
6400            id,
6401            if old_kind == VertexKind::FormulaArray {
6402                VertexKind::FormulaArray
6403            } else {
6404                VertexKind::FormulaScalar
6405            },
6406        );
6407
6408        Ok(())
6409    }
6410
6411    /// Mark a vertex as dirty without propagation (for VertexEditor)
6412    pub fn mark_vertex_dirty(&mut self, vertex_id: VertexId) {
6413        self.store.set_dirty(vertex_id, true);
6414        self.formula_dirty.legacy_insert(vertex_id);
6415    }
6416
6417    /// Batch-mark vertices dirty without propagation.
6418    pub fn mark_vertices_dirty_batch(&mut self, vertices: &[VertexId]) {
6419        self.formula_dirty.legacy_reserve(vertices.len());
6420        for &vertex_id in vertices {
6421            self.store.set_dirty(vertex_id, true);
6422        }
6423        self.formula_dirty.legacy_extend(vertices.iter().copied());
6424    }
6425
6426    /// Update cell mapping for a vertex (for VertexEditor)
6427    pub fn update_cell_mapping(
6428        &mut self,
6429        id: VertexId,
6430        old_addr: Option<CellRef>,
6431        new_addr: CellRef,
6432    ) {
6433        self.materialize_vertex(id);
6434        if let Some(old) = old_addr {
6435            self.cell_vertex_mut(&old);
6436        }
6437        self.cell_vertex_mut(&new_addr);
6438        // Remove old mapping if it exists
6439        if let Some(old) = old_addr {
6440            self.cell_to_vertex.remove(&old);
6441        }
6442        // Add new mapping
6443        self.cell_to_vertex.insert(new_addr, id);
6444    }
6445
6446    /// Remove cell mapping (for VertexEditor)
6447    pub fn remove_cell_mapping(&mut self, addr: &CellRef) {
6448        self.cell_vertex_mut(addr);
6449        self.cell_to_vertex.remove(addr);
6450    }
6451
6452    /// Bring back removed vertex `id` at `coord` of `sheet` as an empty
6453    /// cell (undo of a removal: decision 9, the cell keeps its id). Only a
6454    /// tombstoned vertex, and only onto a cell without a vertex; returns
6455    /// whether it did.
6456    pub(crate) fn revive_vertex(&mut self, id: VertexId, sheet: SheetId, coord: GridAddr) -> bool {
6457        if !self.store.vertex_exists(id)
6458            || !self.store.is_deleted(id)
6459            || self.store.grid_addr(id).is_none()
6460            || self.store.sheet_id(id) != sheet
6461        {
6462            return false;
6463        }
6464        let cell = CellRef::new(sheet, Coord::new(coord.row(), coord.col(), true, true));
6465        // Occupied only by a live vertex that sits at the cell (legacy's
6466        // move replay can leave stale cell-map entries behind). An empty
6467        // placeholder (created for a reference while replay restored a
6468        // reader first) gives way.
6469        let mut placeholder = None;
6470        if let Some(x) = self.cell_vertex(&cell)
6471            && !self.store.is_deleted(x)
6472            && self.store.grid_addr(x) == Some(coord)
6473        {
6474            if !self.is_pure_placeholder(x) {
6475                return false;
6476            }
6477            placeholder = Some(x);
6478        }
6479        #[cfg(any(test, feature = "legacy_oracle"))]
6480        let readers = placeholder
6481            .map(|x| self.get_dependents(x))
6482            .unwrap_or_default();
6483        if let Some(x) = placeholder {
6484            self.cell_to_vertex.remove(&cell);
6485            if let Some(index) = self.sheet_indexes.get_mut(&sheet) {
6486                index.remove_vertex(coord, x);
6487            }
6488            self.remove_all_edges(x);
6489            self.store.mark_deleted(x, true);
6490        }
6491        self.retired_id_set.remove(&id);
6492        self.store.mark_deleted(id, false);
6493        self.store.set_addr(id, VertexAddr::grid(coord));
6494        #[cfg(any(test, feature = "legacy_oracle"))]
6495        self.edges.update_addr(id, VertexAddr::grid(coord));
6496        self.store.set_kind(id, VertexKind::Empty);
6497        self.store.set_dynamic(id, false);
6498        self.store.set_volatile(id, false);
6499        self.cell_to_vertex.insert(cell, id);
6500        self.sheet_index_mut(sheet).add_vertex(coord, id);
6501        self.ref_error_vertices.remove(&id);
6502        // Legacy's edges from the placeholder's readers now name the
6503        // revived vertex (oracle builds keep them).
6504        #[cfg(any(test, feature = "legacy_oracle"))]
6505        for r in readers {
6506            if let Some(ast) = self.get_formula(r) {
6507                self.rebuild_formula_dependencies(r, &ast);
6508            }
6509        }
6510        // As legacy's re-creation (a `set_cell_value` of Empty): the cell
6511        // changed, its readers are dirty.
6512        let _ = self.mark_dirty(id);
6513        true
6514    }
6515
6516    /// An empty cell vertex with no state of its own (a reference's
6517    /// placeholder).
6518    fn is_pure_placeholder(&self, x: VertexId) -> bool {
6519        self.store.kind(x) == VertexKind::Empty
6520            && !self.vertex_formulas.contains_key(&x)
6521            && !self.vertex_values.contains_key(&x)
6522            && !self.ref_error_vertices.contains(&x)
6523            && !self.spill_anchor_to_cells.contains_key(&x)
6524            && !self.vertex_to_pending_names.contains_key(&x)
6525            && !self.name_vertex_lookup.contains_key(&x)
6526    }
6527
6528    /// The formula of vertex `v` is leaving its cell (formula -> value,
6529    /// removal): journaled so replay that brings the formula back revives
6530    /// `v` if the cell lost its vertex meanwhile (see `IdJournal`).
6531    pub(crate) fn journal_formula_left(&mut self, v: VertexId) {
6532        if !self.vertex_formulas.contains_key(&v) {
6533            return;
6534        }
6535        if let Some(cell) = self.get_cell_ref(v) {
6536            self.vertex_journal
6537                .retired((cell.sheet_id, cell.coord.row(), cell.coord.col()), v.0);
6538        }
6539    }
6540
6541    /// A formula is about to be set at `addr`. During undo/redo, the vertex
6542    /// whose formula left this cell comes back if the cell has no vertex
6543    /// now (legacy replay removed it: e.g. undoing a formula typed over a
6544    /// value, whose value Arrow restores). Outside replay this starts a new
6545    /// timeline for the cell.
6546    fn replay_formula_vertex(&mut self, addr: &CellRef) {
6547        if self.revive_retired_id(addr).is_some() {
6548            return;
6549        }
6550        let cell = (addr.sheet_id, addr.coord.row(), addr.coord.col());
6551        let Some(id) = self.vertex_journal.created(cell) else {
6552            return;
6553        };
6554        if self.cell_vertex(addr).is_none() {
6555            let coord = GridAddr::new(addr.coord.row(), addr.coord.col());
6556            self.revive_vertex(VertexId(id), addr.sheet_id, coord);
6557        }
6558    }
6559
6560    pub(crate) fn set_replay_mode(&mut self, mode: crate::engine::authority::history::Replay) {
6561        self.vertex_journal.set_mode(mode);
6562    }
6563
6564    /// Get the cell reference for a vertex
6565    pub fn get_cell_ref_for_vertex(&self, id: VertexId) -> Option<CellRef> {
6566        let coord = self.store.grid_addr(id)?;
6567        let sheet_id = self.store.sheet_id(id);
6568        // Find the cell reference in the mapping
6569        let cell_ref = CellRef::new(sheet_id, Coord::new(coord.row(), coord.col(), true, true));
6570        // Verify it actually maps to this vertex
6571        if self.cell_vertex(&cell_ref) == Some(id) {
6572            Some(cell_ref)
6573        } else {
6574            None
6575        }
6576    }
6577
6578    /// Rebuild dependency edges/range links for an existing formula vertex after AST changes.
6579    ///
6580    /// This intentionally reuses the same extraction and edge wiring machinery as
6581    /// `set_cell_formula[_with_volatility]` to preserve edge orientation, placeholder
6582    /// behavior, and name/range dependency semantics.
6583    pub(crate) fn rebuild_formula_dependencies(&mut self, vertex_id: VertexId, ast: &ASTNode) {
6584        self.materialize_vertex(vertex_id);
6585        let sheet_id = self.store.sheet_id(vertex_id);
6586
6587        // Remove old dependency, name, and pending-name links first.
6588        self.remove_dependent_edges(vertex_id);
6589        self.detach_vertex_from_names(vertex_id);
6590        self.clear_pending_name_references(vertex_id);
6591
6592        let (
6593            new_dependencies,
6594            new_range_dependencies,
6595            vertexless,
6596            named_dependencies,
6597            unresolved_names,
6598        ) = match self.extract_dependencies_with_pending_names(ast, sheet_id) {
6599            Ok(v) => v,
6600            Err(_) => {
6601                self.mark_as_ref_error(vertex_id);
6602                return;
6603            }
6604        };
6605
6606        // Self-reference / name-cycle safety parity with set_cell_formula
6607        // (including the `CyclePolicy::Iterate` self-dependency relaxation).
6608        if new_dependencies.contains(&vertex_id) && !self.config.cycle.allows_self_dependency() {
6609            self.mark_as_ref_error(vertex_id);
6610            return;
6611        }
6612
6613        for &name_vertex in &named_dependencies {
6614            let mut visited = FxHashSet::default();
6615            if self.name_depends_on_vertex(name_vertex, vertex_id, &mut visited) {
6616                self.mark_as_ref_error(vertex_id);
6617                return;
6618            }
6619        }
6620
6621        // Formula is now recoverable again.
6622        self.ref_error_vertices.remove(&vertex_id);
6623        self.vertex_values.remove(&vertex_id);
6624
6625        if !named_dependencies.is_empty() {
6626            self.attach_vertex_to_names(vertex_id, &named_dependencies);
6627        }
6628        for unresolved_name in &unresolved_names {
6629            self.record_pending_name_reference(sheet_id, unresolved_name, vertex_id);
6630        }
6631
6632        self.add_dependent_edges(vertex_id, &new_dependencies);
6633        self.note_vertexless_deps(
6634            vertex_id,
6635            vertexless
6636                .iter()
6637                .map(|c| (c.sheet_id, c.coord.row(), c.coord.col())),
6638        );
6639        self.add_range_dependent_edges(vertex_id, &new_range_dependencies, sheet_id);
6640        self.vertex_formulas.touch(vertex_id);
6641        let _ = self.mark_dirty(vertex_id);
6642    }
6643}
6644
6645// ========== Sheet Management Operations ==========
6646
6647/// Retired ids a structural edit dropped from the side table.
6648/// Retired ids a structural delete dropped from the side table.
6649type RetiredBatch = Vec<((SheetId, u32, u32), VertexId)>;
6650
6651/// Same sheet and position (reference flags aside).
6652pub(crate) fn same_cell(a: &CellRef, b: &CellRef) -> bool {
6653    a.sheet_id == b.sheet_id && a.coord.row() == b.coord.row() && a.coord.col() == b.coord.col()
6654}
6655
6656/// The shift operation of a structural edit's compound description, as
6657/// `VertexEditor` logs it (`InsertRows sheet=S before=B count=N`, ...).
6658/// The sheet a structural edit applies to.
6659/// A structural edit as a comparable key (kind, sheet, position, count).
6660fn shift_key(
6661    op: &crate::engine::graph::editor::reference_adjuster::ShiftOperation,
6662) -> (u8, SheetId, u32, u32) {
6663    use crate::engine::graph::editor::reference_adjuster::ShiftOperation as Op;
6664    match *op {
6665        Op::InsertRows {
6666            sheet_id,
6667            before,
6668            count,
6669        } => (0, sheet_id, before, count),
6670        Op::DeleteRows {
6671            sheet_id,
6672            start,
6673            count,
6674        } => (1, sheet_id, start, count),
6675        Op::InsertColumns {
6676            sheet_id,
6677            before,
6678            count,
6679        } => (2, sheet_id, before, count),
6680        Op::DeleteColumns {
6681            sheet_id,
6682            start,
6683            count,
6684        } => (3, sheet_id, start, count),
6685    }
6686}
6687
6688pub(crate) fn parse_structural_description(
6689    description: &str,
6690) -> Option<crate::engine::graph::editor::reference_adjuster::ShiftOperation> {
6691    use crate::engine::graph::editor::reference_adjuster::ShiftOperation as Op;
6692    let mut parts = description.split_whitespace();
6693    let kind = parts.next()?;
6694    let mut field = |name: &str| -> Option<u32> {
6695        parts
6696            .next()?
6697            .strip_prefix(name)?
6698            .strip_prefix('=')?
6699            .parse()
6700            .ok()
6701    };
6702    let sheet_id = u16::try_from(field("sheet")?).ok()?;
6703    Some(match kind {
6704        "InsertRows" => {
6705            let before = field("before")?;
6706            Op::InsertRows {
6707                sheet_id,
6708                before,
6709                count: field("count")?,
6710            }
6711        }
6712        "DeleteRows" => {
6713            let start = field("start")?;
6714            Op::DeleteRows {
6715                sheet_id,
6716                start,
6717                count: field("count")?,
6718            }
6719        }
6720        "InsertColumns" => {
6721            let before = field("before")?;
6722            Op::InsertColumns {
6723                sheet_id,
6724                before,
6725                count: field("count")?,
6726            }
6727        }
6728        "DeleteColumns" => {
6729            let start = field("start")?;
6730            Op::DeleteColumns {
6731                sheet_id,
6732                start,
6733                count: field("count")?,
6734            }
6735        }
6736        _ => return None,
6737    })
6738}