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