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