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