Skip to main content

formualizer_eval/engine/graph/
mod.rs

1use crate::SheetId;
2use crate::engine::TombstoneRegistry;
3use crate::engine::named_range::{NameScope, NamedDefinition, NamedRange};
4use crate::engine::sheet_registry::SheetRegistry;
5use crate::formula_plane::authority::FormulaAuthority;
6use formualizer_common::{
7    CoordBuildHasher, ExcelError, ExcelErrorKind, LiteralValue, PackedSheetCell,
8};
9use formualizer_parse::parser::{ASTNode, ASTNodeType, ReferenceType};
10use rustc_hash::{FxHashMap, FxHashSet};
11
12#[cfg(debug_assertions)]
13use std::sync::atomic::{AtomicU64, Ordering};
14
15#[cfg(test)]
16#[derive(Debug, Default, Clone)]
17pub struct GraphInstrumentation {
18    pub edges_added: u64,
19    pub stripe_inserts: u64,
20    pub stripe_removes: u64,
21    pub dependents_scan_fallback_calls: u64,
22    pub dependents_scan_vertices_scanned: u64,
23}
24
25mod ast_utils;
26pub mod editor;
27mod formula_analysis;
28#[cfg(test)]
29mod formula_analysis_legacy_tests;
30mod formula_dirty;
31mod names;
32pub(crate) mod prepared_legacy_graph;
33mod range_deps;
34pub(crate) use range_deps::{StructuralEdit, StructuralOccupancy};
35
36mod sheets;
37pub mod snapshot;
38mod sources;
39mod tables;
40pub(crate) use tables::TableEntry;
41
42use super::addr::{GridAddr, SymbolAddr, VertexAddr};
43use super::arena::{AstNodeId, DataStore, ValueRef};
44use super::delta_edges::CsrMutableEdges;
45use super::ingest_pipeline::{DependencyPlanRow, FormulaAstInput};
46use super::sheet_index::SheetIndex;
47use super::vertex::{VertexId, VertexKind};
48use super::vertex_store::{FIRST_NORMAL_VERTEX, VertexStore};
49use crate::engine::topo::{
50    GraphAdapter,
51    pk::{DynamicTopo, PkConfig},
52};
53use crate::reference::{CellRef, Coord, SharedRangeRef, SharedRef, SharedSheetLocator};
54use formualizer_common::Coord as AbsCoord;
55use formula_dirty::FormulaDirtyState;
56pub(crate) use formula_dirty::{
57    FormulaDirtyEventSnapshot, FormulaDirtyLease, FormulaDirtyStats, FormulaDirtySublease,
58    WholeSpanDirtyReason,
59};
60// topo::pk wiring will be integrated behind config.use_dynamic_topo in a follow-up step
61
62struct RegistryFunctionProvider;
63
64impl crate::traits::FunctionProvider for RegistryFunctionProvider {
65    fn planning_semantic_revision(&self) -> Option<u64> {
66        Some(0)
67    }
68
69    fn get_function(
70        &self,
71        ns: &str,
72        name: &str,
73    ) -> Option<std::sync::Arc<dyn crate::function::Function>> {
74        crate::function_registry::get(ns, name)
75    }
76
77    fn get_function_for_planning(
78        &self,
79        ns: &str,
80        name: &str,
81    ) -> Option<std::sync::Arc<dyn crate::function::Function>> {
82        crate::function_registry::get_for_planning(ns, name)
83    }
84}
85
86#[inline]
87fn normalize_stored_literal(value: LiteralValue) -> LiteralValue {
88    match value {
89        // Public contract: store numerics as Number(f64).
90        LiteralValue::Int(i) => LiteralValue::Number(i as f64),
91        other => other,
92    }
93}
94
95pub use editor::change_log::{ChangeEvent, ChangeLog};
96
97// ChangeEvent is now imported from change_log module
98
99/// 🔮 Scalability Hook: Dependency reference types for range compression
100#[derive(Debug, Clone, PartialEq, Eq, Hash)]
101pub enum DependencyRef {
102    /// A specific cell dependency
103    Cell(VertexId),
104    /// A dependency on a finite, rectangular range
105    Range {
106        sheet: String,
107        start_row: u32,
108        start_col: u32,
109        end_row: u32, // Inclusive
110        end_col: u32, // Inclusive
111    },
112    /// A whole column dependency (A:A) - future range compression
113    WholeColumn { sheet: String, col: u32 },
114    /// A whole row dependency (1:1) - future range compression  
115    WholeRow { sheet: String, row: u32 },
116}
117
118/// A key representing a coarse-grained section of a sheet
119#[derive(Debug, Clone, Hash, PartialEq, Eq)]
120pub struct StripeKey {
121    pub sheet_id: SheetId,
122    pub stripe_type: StripeType,
123    pub index: u32, // The index of the row, column, or block stripe
124}
125
126#[derive(Debug, Clone, Hash, PartialEq, Eq)]
127pub enum StripeType {
128    Row,
129    Column,
130    Block, // For dense, square-like ranges
131}
132
133/// Block stripe indexing mathematics
134const BLOCK_H: u32 = 256;
135const BLOCK_W: u32 = 256;
136
137pub fn block_index(row: u32, col: u32) -> u32 {
138    (row / BLOCK_H) << 16 | (col / BLOCK_W)
139}
140
141/// A summary of the results of a mutating operation on the graph.
142/// This serves as a "changelog" to the application layer.
143#[derive(Debug, Clone)]
144pub struct OperationSummary {
145    /// Vertices whose values have been directly or indirectly affected.
146    pub affected_vertices: Vec<VertexId>,
147    /// Placeholder cells that were newly created to satisfy dependencies.
148    pub created_placeholders: Vec<CellRef>,
149}
150
151/// Read-only dependency graph counters used by benchmark/instrumentation tooling.
152///
153/// These counters are deliberately observational: collecting them must not mutate graph state or
154/// alter formula evaluation semantics.
155#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
156pub struct GraphBaselineStats {
157    pub graph_vertex_count: usize,
158    pub graph_formula_vertex_count: usize,
159    pub graph_edge_count: usize,
160    pub dirty_vertex_count: usize,
161    pub evaluation_vertex_count: usize,
162    pub formula_ast_root_count: usize,
163    pub formula_ast_node_count: usize,
164}
165
166/// SoA-based dependency graph implementation
167#[derive(Debug)]
168pub struct DependencyGraph {
169    // Core columnar storage
170    store: VertexStore,
171
172    // Edge storage with delta slab
173    edges: CsrMutableEdges,
174
175    // Arena-based value and formula storage
176    data_store: DataStore,
177    vertex_values: FxHashMap<VertexId, ValueRef>,
178    vertex_formulas: FxHashMap<VertexId, AstNodeId>,
179
180    /// Gate for storing grid-backed (cell/formula) LiteralValue payloads inside the dependency graph.
181    ///
182    /// When `false` (Arrow-canonical mode), the graph does not store values for cell/formula
183    /// vertices. Arrow (base + overlays) is the sole value store for sheet cells.
184    value_cache_enabled: bool,
185
186    /// Debug-only instrumentation: count attempts to read *cell/formula* graph values while
187    /// caching is disabled (canonical mode guard).
188    #[cfg(debug_assertions)]
189    graph_value_read_attempts: AtomicU64,
190
191    // Address mappings using a hasher tuned for packed Coord / PackedSheetCell
192    // keys. FxHasher's weak avalanche produces O(N^2) collision cascades on
193    // row-major bulk ingest; CoordBuildHasher keeps these strictly O(N).
194    cell_to_vertex: std::collections::HashMap<CellRef, VertexId, CoordBuildHasher>,
195    load_packed_to_vertex: std::collections::HashMap<PackedSheetCell, VertexId, CoordBuildHasher>,
196
197    // Graph-owned formula dirtiness. Legacy vertices retain their sparse bits
198    // and set representation behind this single authority.
199    formula_dirty: FormulaDirtyState,
200    volatile_vertices: FxHashSet<VertexId>,
201
202    /// Monotonic count of vertices processed by dirty-propagation BFS loops
203    /// (`mark_dirty_many` / `mark_dirty_many_value_cells`). Cheap plain
204    /// counter used by perf-shape tests to assert propagation work is
205    /// O(component), not O(sources × component).
206    dirty_propagation_visits: u64,
207
208    /// Nesting depth of active deferred-dirty scopes (`begin_deferred_dirty`
209    /// / `end_deferred_dirty`). While > 0, dirty-propagation entry points
210    /// queue their sources in `deferred_dirty_pending` instead of running a
211    /// BFS per call; the outermost `end_deferred_dirty` flushes the union in
212    /// ONE multi-source `mark_dirty_many`.
213    deferred_dirty_depth: u32,
214    /// Sources queued while a deferred-dirty scope is active.
215    deferred_dirty_pending: Vec<VertexId>,
216
217    /// Vertices explicitly marked as #REF! by structural operations.
218    ///
219    /// In Arrow-truth mode, the dependency graph does not cache cell/formula values.
220    /// We still need a place to record deterministic #REF! invalidations for editor
221    /// operations and structural transforms.
222    ref_error_vertices: FxHashSet<VertexId>,
223
224    // NEW: Specialized managers for range dependencies (Hybrid Model)
225    /// Maps a formula vertex to the ranges it depends on.
226    formula_to_range_deps: FxHashMap<VertexId, Vec<SharedRangeRef<'static>>>,
227
228    /// Maps a stripe to formulas that depend on it via a compressed range.
229    /// CRITICAL: VertexIds are deduplicated within each stripe to avoid quadratic blow-ups.
230    stripe_to_dependents: FxHashMap<StripeKey, FxHashSet<VertexId>>,
231
232    // Sheet-level sparse indexes for O(log n + k) range queries
233    /// Maps sheet_id to its interval tree index for efficient row/column operations
234    sheet_indexes: FxHashMap<SheetId, SheetIndex>,
235
236    // Sheet name/ID mapping
237    sheet_reg: SheetRegistry,
238    default_sheet_id: SheetId,
239
240    // Named ranges support
241    /// Workbook-scoped named ranges
242    named_ranges: FxHashMap<String, NamedRange>,
243
244    /// Normalized-key lookup for workbook-scoped names.
245    ///
246    /// When `config.case_sensitive_names == false`, keys are ASCII-lowercased.
247    /// Values are the canonical (original-cased) name stored in `named_ranges`.
248    named_ranges_lookup: FxHashMap<String, String>,
249
250    /// Sheet-scoped named ranges  
251    sheet_named_ranges: FxHashMap<(SheetId, String), NamedRange>,
252
253    /// Normalized-key lookup for sheet-scoped names.
254    ///
255    /// Key is (SheetId, normalized_name_key). Value is the canonical (original-cased)
256    /// name stored in `sheet_named_ranges`.
257    sheet_named_ranges_lookup: FxHashMap<(SheetId, String), String>,
258
259    /// Reverse mapping: vertex -> names it uses (by vertex id)
260    vertex_to_names: FxHashMap<VertexId, Vec<VertexId>>,
261
262    /// Lookup for name vertex -> (scope, name) to avoid map scans
263    name_vertex_lookup: FxHashMap<VertexId, (NameScope, String)>,
264
265    /// Pending formula vertices referencing unresolved bare symbolic names.
266    ///
267    /// Keys are normalized through `name_lookup_key(...)` so workbook names and
268    /// source scalars can both wake the same waiting formulas when a symbol appears.
269    pending_name_links: FxHashMap<String, FxHashSet<(SheetId, VertexId)>>,
270
271    /// Reverse mapping used to clear stale pending-name registrations when a
272    /// formula is edited, overwritten with a value, or otherwise rebuilt.
273    vertex_to_pending_names: FxHashMap<VertexId, FxHashSet<String>>,
274
275    // Native workbook tables (ListObjects)
276    tables: FxHashMap<String, tables::TableEntry>,
277    /// Normalized-key lookup for tables.
278    tables_lookup: FxHashMap<String, String>,
279    table_vertex_lookup: FxHashMap<VertexId, String>,
280
281    // External sources (SourceVertex)
282    source_scalars: FxHashMap<String, sources::SourceScalarEntry>,
283    source_tables: FxHashMap<String, sources::SourceTableEntry>,
284    source_vertex_lookup: FxHashMap<VertexId, String>,
285
286    /// Monotonic allocator for the symbol address space.
287    ///
288    /// Names, tables and external sources are identified by name and have no position, so
289    /// they are addressed by a dense index here rather than by fabricated grid coordinates
290    /// on a real sheet (#302, #304).
291    symbol_vertex_seq: u32,
292
293    /// Mapping from cell vertices to named range vertices that depend on them
294    cell_to_name_dependents: FxHashMap<VertexId, FxHashSet<VertexId>>,
295    /// Cached list of cell dependencies per named range vertex (for teardown)
296    name_to_cell_dependencies: FxHashMap<VertexId, Vec<VertexId>>,
297
298    // Evaluation configuration
299    config: super::EvalConfig,
300    /// Low-level monotonic dependency-topology revision used by engine caches.
301    topology_revision: u64,
302    /// Monotonic name, table, and external-source binding revision.
303    symbol_revision: u64,
304
305    // Graph-owned FormulaPlane authority shell. Inert until a later runtime cut-over.
306    formula_authority: FormulaAuthority,
307
308    // Dynamic topology orderer (Pearce–Kelly) maintained alongside edges when enabled
309    pk_order: Option<DynamicTopo<VertexId>>,
310
311    // Spill registry: anchor -> cells, and reverse mapping for blockers.
312    // `spill_cell_to_anchor` is keyed by `CellRef` and uses the tuned hasher
313    // for the same reason as `cell_to_vertex`.
314    spill_anchor_to_cells: FxHashMap<VertexId, Vec<CellRef>>,
315    spill_cell_to_anchor: std::collections::HashMap<CellRef, VertexId, CoordBuildHasher>,
316    spill_cells_by_sheet: FxHashMap<SheetId, std::collections::BTreeMap<(u32, u32), VertexId>>,
317
318    /// Request-scoped admission budgets used by graph-owned mutation paths.
319    admission_budget_override: Option<crate::engine::EvaluationBudgets>,
320
321    // Hint: during initial bulk load, many cells are guaranteed new; allow skipping existence checks per-sheet
322    first_load_assume_new: bool,
323    ensure_touched_sheets: FxHashSet<SheetId>,
324
325    // handled deleted references, in case they are reintroduced.
326    pub tombstone_registry: TombstoneRegistry,
327
328    #[cfg(test)]
329    instr: std::sync::Mutex<GraphInstrumentation>,
330    #[cfg(test)]
331    prepared_legacy_graph_failure_for_test: bool,
332}
333
334impl Default for DependencyGraph {
335    fn default() -> Self {
336        Self::new()
337    }
338}
339
340impl DependencyGraph {
341    /// Expose range expansion limit for planners
342    pub fn range_expansion_limit(&self) -> usize {
343        self.config.range_expansion_limit
344    }
345
346    pub fn get_config(&self) -> &super::EvalConfig {
347        &self.config
348    }
349
350    pub(crate) fn formula_authority(&self) -> &FormulaAuthority {
351        &self.formula_authority
352    }
353
354    pub(crate) fn formula_authority_mut(&mut self) -> &mut FormulaAuthority {
355        &mut self.formula_authority
356    }
357
358    pub(crate) fn mark_formula_region_dirty(
359        &mut self,
360        region: crate::formula_plane::region_index::Region,
361    ) {
362        self.formula_dirty.record_region(region);
363    }
364
365    pub(crate) fn mark_formula_span_region_dirty(
366        &mut self,
367        span_ref: crate::formula_plane::runtime::FormulaSpanRef,
368        region: crate::formula_plane::region_index::Region,
369    ) {
370        self.formula_dirty.record_span_region(span_ref, region);
371    }
372
373    pub(crate) fn mark_formula_spans_dirty(
374        &mut self,
375        spans: impl IntoIterator<Item = crate::formula_plane::runtime::FormulaSpanRef>,
376        reason: WholeSpanDirtyReason,
377    ) {
378        self.formula_dirty.record_whole_spans(spans, reason);
379    }
380
381    pub(crate) fn mark_all_formula_spans_dirty(&mut self, reason: WholeSpanDirtyReason) {
382        let spans = self.formula_authority.active_span_refs();
383        self.formula_dirty.record_whole_spans(spans, reason);
384    }
385
386    pub(crate) fn lease_formula_dirty(&mut self) -> FormulaDirtyLease {
387        self.formula_dirty.lease()
388    }
389
390    pub(crate) fn extend_formula_dirty_lease(
391        &mut self,
392        lease: FormulaDirtyLease,
393    ) -> Option<FormulaDirtyLease> {
394        self.formula_dirty.extend(lease)
395    }
396
397    pub(crate) fn ack_formula_dirty(&mut self, lease: FormulaDirtyLease) -> bool {
398        self.formula_dirty.ack(lease)
399    }
400
401    pub(crate) fn ack_formula_dirty_sublease(&mut self, sublease: FormulaDirtySublease) -> bool {
402        self.formula_dirty.ack_sublease(sublease)
403    }
404
405    pub(crate) fn release_formula_dirty_lease(&mut self, lease: FormulaDirtyLease) -> bool {
406        self.formula_dirty.release(lease)
407    }
408
409    pub(crate) fn pending_formula_dirty_regions(
410        &self,
411    ) -> impl Iterator<Item = crate::formula_plane::region_index::Region> + '_ {
412        self.formula_dirty.pending_regions()
413    }
414
415    pub(crate) fn pending_formula_dirty_span_regions(
416        &self,
417    ) -> impl Iterator<
418        Item = (
419            crate::formula_plane::runtime::FormulaSpanRef,
420            crate::formula_plane::region_index::Region,
421        ),
422    > + '_ {
423        self.formula_dirty.pending_span_regions()
424    }
425
426    pub(crate) fn pending_formula_dirty_whole_spans(
427        &self,
428    ) -> impl Iterator<Item = crate::formula_plane::runtime::FormulaSpanRef> + '_ {
429        self.formula_dirty.pending_whole_spans()
430    }
431
432    pub(crate) fn pending_formula_dirty_event_count(&self) -> usize {
433        self.formula_dirty.pending_event_count()
434    }
435
436    pub(crate) fn formula_dirty_stats(&self) -> FormulaDirtyStats {
437        self.formula_dirty.stats()
438    }
439
440    pub(crate) fn clear_formula_vertex_dirty(&mut self, vertex_id: VertexId) {
441        self.store.set_dirty(vertex_id, false);
442        self.formula_dirty.legacy_remove(&vertex_id);
443    }
444
445    /// Return read-only baseline counters for FormulaPlane/dispatch benchmarking.
446    pub fn baseline_stats(&self) -> GraphBaselineStats {
447        let data_stats = self.data_store.memory_usage();
448        GraphBaselineStats {
449            graph_vertex_count: self.store.len(),
450            graph_formula_vertex_count: self.vertex_formulas.len(),
451            graph_edge_count: self.edges.num_edges_exact(),
452            dirty_vertex_count: self.formula_dirty.legacy_len(),
453            evaluation_vertex_count: self.get_evaluation_vertices().len(),
454            formula_ast_root_count: self.vertex_formulas.len(),
455            formula_ast_node_count: data_stats.total_ast_nodes,
456        }
457    }
458
459    #[inline]
460    pub(crate) fn value_cache_enabled(&self) -> bool {
461        self.value_cache_enabled
462    }
463
464    /// Debug-only: how many times `get_value`/`get_cell_value` were called while caching is disabled.
465    ///
466    /// In Arrow-canonical mode this should remain 0 for engine/interpreter reads.
467    #[cfg(test)]
468    pub fn debug_graph_value_read_attempts(&self) -> u64 {
469        #[cfg(debug_assertions)]
470        {
471            self.graph_value_read_attempts.load(Ordering::Relaxed)
472        }
473        #[cfg(not(debug_assertions))]
474        {
475            0
476        }
477    }
478
479    /// Build a dependency plan for a set of formulas on sheets
480    pub fn plan_dependencies<'a, I>(
481        &mut self,
482        items: I,
483        policy: &formualizer_parse::parser::CollectPolicy,
484        volatile: Option<&[bool]>,
485    ) -> Result<crate::engine::plan::DependencyPlan, formualizer_common::ExcelError>
486    where
487        I: IntoIterator<Item = (&'a str, u32, u32, &'a formualizer_parse::parser::ASTNode)>,
488    {
489        crate::engine::plan::build_dependency_plan(
490            &mut self.sheet_reg,
491            items.into_iter(),
492            policy,
493            volatile,
494        )
495    }
496
497    pub fn plan_dependencies_mixed<'a, I>(
498        &mut self,
499        items: I,
500        policy: &formualizer_parse::parser::CollectPolicy,
501        volatile: Option<&[bool]>,
502    ) -> Result<crate::engine::plan::DependencyPlan, formualizer_common::ExcelError>
503    where
504        I: IntoIterator<
505            Item = (
506                &'a str,
507                u32,
508                u32,
509                crate::engine::plan::DependencyPlanAst<'a>,
510            ),
511        >,
512    {
513        crate::engine::plan::build_dependency_plan_mixed(
514            &mut self.sheet_reg,
515            &self.data_store,
516            items.into_iter(),
517            policy,
518            volatile,
519        )
520    }
521
522    /// Ensure vertices exist for given coords; allocate missing in contiguous batches and add to edges/index.
523    /// Returns a list suitable for edges.add_vertices_batch.
524    pub fn ensure_vertices_batch(
525        &mut self,
526        coords: &[(SheetId, AbsCoord)],
527    ) -> Vec<(VertexAddr, u32)> {
528        self.ensure_vertices_batch_ordered(coords).1
529    }
530
531    /// Ensure vertices exist for given packed absolute cells and return vertex ids aligned to the
532    /// input order, plus the newly allocated `(coord, raw_vid)` items suitable for edge/index
533    /// population.
534    pub fn ensure_vertices_batch_packed_ordered(
535        &mut self,
536        packed_cells: &[PackedSheetCell],
537    ) -> (Vec<VertexId>, Vec<(VertexAddr, u32)>) {
538        #[cfg(feature = "perf_instrumentation")]
539        use crate::instant::FzInstant as PerfInstant;
540        use rustc_hash::FxHashMap;
541
542        #[cfg(feature = "perf_instrumentation")]
543        let debug = std::env::var("FZ_DEBUG_LOAD")
544            .ok()
545            .is_some_and(|v| v != "0");
546        #[cfg(feature = "perf_instrumentation")]
547        let t0 = PerfInstant::now();
548
549        let mut ordered: Vec<Option<VertexId>> = vec![None; packed_cells.len()];
550        if packed_cells.is_empty() {
551            return (Vec::new(), Vec::new());
552        }
553
554        let first_sid = packed_cells[0].sheet_id();
555        let single_sheet = packed_cells.iter().all(|cell| cell.sheet_id() == first_sid);
556        let mut add_batch: Vec<(VertexAddr, u32)> = Vec::new();
557
558        #[cfg(feature = "perf_instrumentation")]
559        let mut packed_hits = 0usize;
560        #[cfg(feature = "perf_instrumentation")]
561        let mut generic_hits = 0usize;
562        #[cfg(feature = "perf_instrumentation")]
563        let mut missing = 0usize;
564        #[cfg(feature = "perf_instrumentation")]
565        let mut t_packed_lookup_us = 0u128;
566        #[cfg(feature = "perf_instrumentation")]
567        let mut t_generic_lookup_us = 0u128;
568        #[cfg(feature = "perf_instrumentation")]
569        let mut t_alloc_us = 0u128;
570        #[cfg(feature = "perf_instrumentation")]
571        let mut t_map_insert_us = 0u128;
572        #[cfg(feature = "perf_instrumentation")]
573        let mut t_index_insert_us = 0u128;
574        #[cfg(feature = "perf_instrumentation")]
575        let mut t_edge_register_us = 0u128;
576
577        if single_sheet {
578            let sid = first_sid;
579            let mut missing_items: Vec<(usize, PackedSheetCell)> =
580                Vec::with_capacity(packed_cells.len());
581
582            for (idx, packed) in packed_cells.iter().copied().enumerate() {
583                #[cfg(feature = "perf_instrumentation")]
584                let tl0 = PerfInstant::now();
585                if self.first_load_assume_new
586                    && let Some(&existing) = self.load_packed_to_vertex.get(&packed)
587                {
588                    ordered[idx] = Some(existing);
589                    #[cfg(feature = "perf_instrumentation")]
590                    {
591                        packed_hits += 1;
592                        t_packed_lookup_us += tl0.elapsed().as_micros();
593                    }
594                    continue;
595                }
596                #[cfg(feature = "perf_instrumentation")]
597                {
598                    t_packed_lookup_us += tl0.elapsed().as_micros();
599                }
600
601                let pc = AbsCoord::new(packed.row0(), packed.col0());
602                let addr = CellRef::new(sid, Coord::new(pc.row(), pc.col(), true, true));
603                #[cfg(feature = "perf_instrumentation")]
604                let tg0 = PerfInstant::now();
605                if let Some(&existing) = self.cell_to_vertex.get(&addr) {
606                    ordered[idx] = Some(existing);
607                    if self.first_load_assume_new {
608                        self.load_packed_to_vertex.insert(packed, existing);
609                    }
610                    #[cfg(feature = "perf_instrumentation")]
611                    {
612                        generic_hits += 1;
613                    }
614                } else {
615                    missing_items.push((idx, packed));
616                    #[cfg(feature = "perf_instrumentation")]
617                    {
618                        missing += 1;
619                    }
620                }
621                #[cfg(feature = "perf_instrumentation")]
622                {
623                    t_generic_lookup_us += tg0.elapsed().as_micros();
624                }
625            }
626
627            if !missing_items.is_empty() {
628                self.ensure_touched_sheets.insert(sid);
629
630                let mut pcs: Vec<VertexAddr> = Vec::with_capacity(missing_items.len());
631                for (_, packed) in &missing_items {
632                    pcs.push(GridAddr::new(packed.row0(), packed.col0()).into());
633                }
634
635                #[cfg(feature = "perf_instrumentation")]
636                let ta0 = PerfInstant::now();
637                let vids = self.store.allocate_contiguous(sid, &pcs, 0x00);
638                #[cfg(feature = "perf_instrumentation")]
639                {
640                    t_alloc_us += ta0.elapsed().as_micros();
641                }
642                add_batch.reserve(missing_items.len());
643
644                match self.config.sheet_index_mode {
645                    crate::engine::SheetIndexMode::Eager
646                    | crate::engine::SheetIndexMode::FastBatch => {
647                        for ((input_idx, packed), vid) in
648                            missing_items.into_iter().zip(vids.into_iter())
649                        {
650                            let pc = AbsCoord::new(packed.row0(), packed.col0());
651                            ordered[input_idx] = Some(vid);
652                            add_batch.push((VertexAddr::grid(GridAddr::from_coord(pc)), vid.0));
653
654                            #[cfg(feature = "perf_instrumentation")]
655                            let tm0 = PerfInstant::now();
656                            if self.first_load_assume_new {
657                                self.load_packed_to_vertex.insert(packed, vid);
658                            } else {
659                                let addr =
660                                    CellRef::new(sid, Coord::new(pc.row(), pc.col(), true, true));
661                                self.cell_to_vertex.insert(addr, vid);
662                            }
663                            #[cfg(feature = "perf_instrumentation")]
664                            {
665                                t_map_insert_us += tm0.elapsed().as_micros();
666                            }
667
668                            #[cfg(feature = "perf_instrumentation")]
669                            let ti0 = PerfInstant::now();
670                            self.sheet_index_mut(sid)
671                                .add_vertex(GridAddr::from_coord(pc), vid);
672                            #[cfg(feature = "perf_instrumentation")]
673                            {
674                                t_index_insert_us += ti0.elapsed().as_micros();
675                            }
676                        }
677                    }
678                    crate::engine::SheetIndexMode::Lazy => {
679                        for ((input_idx, packed), vid) in
680                            missing_items.into_iter().zip(vids.into_iter())
681                        {
682                            let pc = AbsCoord::new(packed.row0(), packed.col0());
683                            ordered[input_idx] = Some(vid);
684                            add_batch.push((VertexAddr::grid(GridAddr::from_coord(pc)), vid.0));
685
686                            #[cfg(feature = "perf_instrumentation")]
687                            let tm0 = PerfInstant::now();
688                            if self.first_load_assume_new {
689                                self.load_packed_to_vertex.insert(packed, vid);
690                            } else {
691                                let addr =
692                                    CellRef::new(sid, Coord::new(pc.row(), pc.col(), true, true));
693                                self.cell_to_vertex.insert(addr, vid);
694                            }
695                            #[cfg(feature = "perf_instrumentation")]
696                            {
697                                t_map_insert_us += tm0.elapsed().as_micros();
698                            }
699                        }
700                    }
701                }
702            }
703        } else {
704            let mut grouped: FxHashMap<SheetId, Vec<(usize, PackedSheetCell)>> =
705                FxHashMap::default();
706
707            for (idx, packed) in packed_cells.iter().copied().enumerate() {
708                #[cfg(feature = "perf_instrumentation")]
709                let tl0 = PerfInstant::now();
710                if self.first_load_assume_new
711                    && let Some(&existing) = self.load_packed_to_vertex.get(&packed)
712                {
713                    ordered[idx] = Some(existing);
714                    #[cfg(feature = "perf_instrumentation")]
715                    {
716                        packed_hits += 1;
717                        t_packed_lookup_us += tl0.elapsed().as_micros();
718                    }
719                    continue;
720                }
721                #[cfg(feature = "perf_instrumentation")]
722                {
723                    t_packed_lookup_us += tl0.elapsed().as_micros();
724                }
725
726                let sid = packed.sheet_id();
727                let pc = AbsCoord::new(packed.row0(), packed.col0());
728                let addr = CellRef::new(sid, Coord::new(pc.row(), pc.col(), true, true));
729                #[cfg(feature = "perf_instrumentation")]
730                let tg0 = PerfInstant::now();
731                if let Some(&existing) = self.cell_to_vertex.get(&addr) {
732                    ordered[idx] = Some(existing);
733                    if self.first_load_assume_new {
734                        self.load_packed_to_vertex.insert(packed, existing);
735                    }
736                    #[cfg(feature = "perf_instrumentation")]
737                    {
738                        generic_hits += 1;
739                    }
740                } else {
741                    grouped.entry(sid).or_default().push((idx, packed));
742                    #[cfg(feature = "perf_instrumentation")]
743                    {
744                        missing += 1;
745                    }
746                }
747                #[cfg(feature = "perf_instrumentation")]
748                {
749                    t_generic_lookup_us += tg0.elapsed().as_micros();
750                }
751            }
752
753            for (sid, items) in grouped {
754                if items.is_empty() {
755                    continue;
756                }
757                self.ensure_touched_sheets.insert(sid);
758
759                let mut pcs: Vec<VertexAddr> = Vec::with_capacity(items.len());
760                for (_, packed) in &items {
761                    pcs.push(GridAddr::new(packed.row0(), packed.col0()).into());
762                }
763
764                #[cfg(feature = "perf_instrumentation")]
765                let ta0 = PerfInstant::now();
766                let vids = self.store.allocate_contiguous(sid, &pcs, 0x00);
767                #[cfg(feature = "perf_instrumentation")]
768                {
769                    t_alloc_us += ta0.elapsed().as_micros();
770                }
771
772                for ((input_idx, packed), vid) in items.into_iter().zip(vids.into_iter()) {
773                    let pc = AbsCoord::new(packed.row0(), packed.col0());
774                    ordered[input_idx] = Some(vid);
775                    add_batch.push((VertexAddr::grid(GridAddr::from_coord(pc)), vid.0));
776
777                    #[cfg(feature = "perf_instrumentation")]
778                    let tm0 = PerfInstant::now();
779                    if self.first_load_assume_new {
780                        self.load_packed_to_vertex.insert(packed, vid);
781                    } else {
782                        let addr = CellRef::new(sid, Coord::new(pc.row(), pc.col(), true, true));
783                        self.cell_to_vertex.insert(addr, vid);
784                    }
785                    #[cfg(feature = "perf_instrumentation")]
786                    {
787                        t_map_insert_us += tm0.elapsed().as_micros();
788                    }
789
790                    match self.config.sheet_index_mode {
791                        crate::engine::SheetIndexMode::Eager
792                        | crate::engine::SheetIndexMode::FastBatch => {
793                            #[cfg(feature = "perf_instrumentation")]
794                            let ti0 = PerfInstant::now();
795                            self.sheet_index_mut(sid)
796                                .add_vertex(GridAddr::from_coord(pc), vid);
797                            #[cfg(feature = "perf_instrumentation")]
798                            {
799                                t_index_insert_us += ti0.elapsed().as_micros();
800                            }
801                        }
802                        crate::engine::SheetIndexMode::Lazy => {
803                            // defer index build
804                        }
805                    }
806                }
807            }
808        }
809
810        if !add_batch.is_empty() {
811            #[cfg(feature = "perf_instrumentation")]
812            let te0 = PerfInstant::now();
813            self.edges.add_vertices_batch(&add_batch);
814            #[cfg(feature = "perf_instrumentation")]
815            {
816                t_edge_register_us += te0.elapsed().as_micros();
817            }
818        }
819
820        #[cfg(feature = "perf_instrumentation")]
821        if debug {
822            eprintln!(
823                "[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",
824                packed_cells.len(),
825                single_sheet,
826                packed_hits,
827                generic_hits,
828                missing,
829                t_packed_lookup_us,
830                t_generic_lookup_us,
831                t_alloc_us,
832                t_map_insert_us,
833                t_index_insert_us,
834                t_edge_register_us,
835                t0.elapsed().as_millis(),
836            );
837        }
838
839        let ordered = ordered
840            .into_iter()
841            .map(|vid| vid.expect("ensure_vertices_batch_packed_ordered must resolve every coord"))
842            .collect();
843        (ordered, add_batch)
844    }
845
846    /// Ensure vertices exist for given coords and return vertex ids aligned to the input order,
847    /// plus the newly allocated `(coord, raw_vid)` items suitable for edge/index population.
848    pub fn ensure_vertices_batch_ordered(
849        &mut self,
850        coords: &[(SheetId, AbsCoord)],
851    ) -> (Vec<VertexId>, Vec<(VertexAddr, u32)>) {
852        let mut packed: Vec<PackedSheetCell> = Vec::with_capacity(coords.len());
853        for &(sid, coord) in coords {
854            packed.push(Self::packed_cell_key(sid, coord));
855        }
856        self.ensure_vertices_batch_packed_ordered(&packed)
857    }
858
859    #[inline]
860    fn packed_cell_key(sheet_id: SheetId, coord: AbsCoord) -> PackedSheetCell {
861        PackedSheetCell::try_new(sheet_id, coord.row(), coord.col())
862            .expect("graph coordinate must fit PackedSheetCell")
863    }
864
865    fn flush_load_packed_mappings(&mut self) {
866        if self.load_packed_to_vertex.is_empty() {
867            return;
868        }
869        let debug = std::env::var("FZ_DEBUG_LOAD")
870            .ok()
871            .is_some_and(|v| v != "0");
872        let t0 = crate::instant::FzInstant::now();
873        let count = self.load_packed_to_vertex.len();
874        self.cell_to_vertex.reserve(count);
875        for (&packed, &vid) in &self.load_packed_to_vertex {
876            let coord = AbsCoord::new(packed.row0(), packed.col0());
877            let addr = CellRef::new(
878                packed.sheet_id(),
879                Coord::new(coord.row(), coord.col(), true, true),
880            );
881            self.cell_to_vertex.insert(addr, vid);
882        }
883        self.load_packed_to_vertex.clear();
884        if debug {
885            eprintln!(
886                "[fz][load] flush_load_packed_mappings: {} entries in {:.1} ms",
887                count,
888                t0.elapsed().as_secs_f64() * 1000.0,
889            );
890        }
891    }
892
893    /// Enable/disable the first-load fast path for value inserts.
894    pub fn set_first_load_assume_new(&mut self, enabled: bool) {
895        if self.first_load_assume_new && !enabled {
896            self.flush_load_packed_mappings();
897        } else if enabled {
898            self.load_packed_to_vertex.clear();
899        }
900        self.first_load_assume_new = enabled;
901    }
902
903    #[doc(hidden)]
904    pub fn first_load_assume_new(&self) -> bool {
905        self.first_load_assume_new
906    }
907
908    /// Reset the per-sheet ensure touch tracking.
909    pub fn reset_ensure_touched(&mut self) {
910        self.ensure_touched_sheets.clear();
911    }
912
913    /// Store an AST and return its arena id.
914    pub fn store_ast(&mut self, ast: &formualizer_parse::parser::ASTNode) -> AstNodeId {
915        self.data_store.store_ast(ast, &self.sheet_reg)
916    }
917
918    /// Store ASTs in batch and return their arena ids
919    pub fn store_asts_batch<'a, I>(&mut self, asts: I) -> Vec<AstNodeId>
920    where
921        I: IntoIterator<Item = &'a formualizer_parse::parser::ASTNode>,
922    {
923        self.data_store.store_asts_batch(asts, &self.sheet_reg)
924    }
925
926    /// Reserve metadata structures for upcoming formula assignments during bulk load.
927    pub fn reserve_formula_metadata(&mut self, additional: usize) {
928        self.vertex_formulas.reserve(additional);
929        self.formula_dirty.legacy_reserve(additional);
930        self.volatile_vertices.reserve(additional);
931    }
932
933    /// Lookup VertexId for a (SheetId, AbsCoord)
934    pub fn vid_for_sid_pc(&self, sid: SheetId, pc: AbsCoord) -> Option<VertexId> {
935        let addr = CellRef::new(sid, Coord::new(pc.row(), pc.col(), true, true));
936        self.cell_to_vertex.get(&addr).copied()
937    }
938
939    /// Helper to map a global cell index in a plan to a VertexId
940    pub fn vid_for_plan_idx(
941        &self,
942        plan: &crate::engine::plan::DependencyPlan,
943        idx: u32,
944    ) -> Option<VertexId> {
945        let (sid, pc) = plan.global_cells.get(idx as usize).copied()?;
946        self.vid_for_sid_pc(sid, pc)
947    }
948    /// Assign a formula to an existing vertex, removing prior edges and setting flags
949    pub fn assign_formula_vertex(
950        &mut self,
951        vid: VertexId,
952        ast_id: AstNodeId,
953        volatile: bool,
954        dynamic: bool,
955    ) {
956        if self.vertex_formulas.contains_key(&vid) {
957            self.remove_dependent_edges(vid);
958        }
959        self.store
960            .set_kind(vid, crate::engine::vertex::VertexKind::FormulaScalar);
961        self.vertex_values.remove(&vid);
962        self.vertex_formulas.insert(vid, ast_id);
963        self.mark_volatile(vid, volatile);
964        self.store.set_dynamic(vid, dynamic);
965
966        // schedule evaluation
967        self.mark_vertex_dirty(vid);
968    }
969
970    /// Fast path for initial workbook load: assign a formula to a vertex that is known not to
971    /// already own dependency edges in the graph. Dirtiness is batched separately.
972    pub fn assign_formula_vertex_load_fast(
973        &mut self,
974        vid: VertexId,
975        ast_id: AstNodeId,
976        volatile: bool,
977        dynamic: bool,
978    ) {
979        debug_assert!(
980            !self.vertex_formulas.contains_key(&vid),
981            "load-fast formula assignment expects fresh/non-formula vertices"
982        );
983        self.store
984            .set_kind(vid, crate::engine::vertex::VertexKind::FormulaScalar);
985        self.vertex_values.remove(&vid);
986        self.vertex_formulas.insert(vid, ast_id);
987        self.mark_volatile(vid, volatile);
988        self.store.set_dynamic(vid, dynamic);
989    }
990
991    /// Public wrapper for adding edges without beginning a batch (caller manages batch)
992    pub fn add_edges_nobatch(&mut self, dependent: VertexId, dependencies: &[VertexId]) {
993        self.add_dependent_edges_nobatch(dependent, dependencies);
994    }
995
996    /// Iterate all normal vertex ids
997    pub fn iter_vertex_ids(&self) -> impl Iterator<Item = VertexId> + '_ {
998        self.store.all_vertices()
999    }
1000
1001    /// Get the current address of a vertex: a grid position, or a symbol identity for
1002    /// names, tables and external sources.
1003    pub fn vertex_addr(&self, vid: VertexId) -> VertexAddr {
1004        self.store.addr(vid)
1005    }
1006
1007    /// Get the current grid position of a vertex, or `None` when it is a symbol.
1008    pub fn vertex_grid_addr(&self, vid: VertexId) -> Option<GridAddr> {
1009        self.store.grid_addr(vid)
1010    }
1011
1012    /// Total number of allocated vertices (including deleted)
1013    pub fn vertex_count(&self) -> usize {
1014        self.store.len()
1015    }
1016
1017    /// Replace CSR edges in one shot from adjacency and coords
1018    pub fn build_edges_from_adjacency(
1019        &mut self,
1020        adjacency: Vec<(u32, Vec<u32>)>,
1021        coords: Vec<VertexAddr>,
1022        vertex_ids: Vec<u32>,
1023    ) {
1024        // Merge in base/delta out-edges for vertices the formula-target
1025        // adjacency doesn't cover (e.g. named-range pass-through vertices)
1026        // before handing the final adjacency to the pure builder.
1027        let adjacency = self.edges.adjacency_with_carried_forward_edges(adjacency);
1028        self.edges
1029            .build_from_adjacency(adjacency, coords, vertex_ids);
1030    }
1031    /// Compute min/max used row among vertices within [start_col..=end_col] on a sheet.
1032    pub fn used_row_bounds_for_columns(
1033        &self,
1034        sheet_id: SheetId,
1035        start_col: u32,
1036        end_col: u32,
1037    ) -> Option<(u32, u32)> {
1038        // Prefer sheet index when available
1039        if let Some(index) = self.sheet_indexes.get(&sheet_id)
1040            && !index.is_empty()
1041        {
1042            let mut min_r: Option<u32> = None;
1043            let mut max_r: Option<u32> = None;
1044            for vid in index.vertices_in_col_range(start_col, end_col) {
1045                let Some(r) = self.store.grid_addr(vid).map(|addr| addr.row()) else {
1046                    continue;
1047                };
1048                min_r = Some(min_r.map(|m| m.min(r)).unwrap_or(r));
1049                max_r = Some(max_r.map(|m| m.max(r)).unwrap_or(r));
1050            }
1051            return match (min_r, max_r) {
1052                (Some(a), Some(b)) => Some((a, b)),
1053                _ => None,
1054            };
1055        }
1056        // Fallback: scan cell maps on the fly
1057        let mut min_r: Option<u32> = None;
1058        let mut max_r: Option<u32> = None;
1059        for cref in self.cell_to_vertex.keys() {
1060            if cref.sheet_id == sheet_id {
1061                let c = cref.coord.col();
1062                if c >= start_col && c <= end_col {
1063                    let r = cref.coord.row();
1064                    min_r = Some(min_r.map(|m| m.min(r)).unwrap_or(r));
1065                    max_r = Some(max_r.map(|m| m.max(r)).unwrap_or(r));
1066                }
1067            }
1068        }
1069        for packed in self.load_packed_to_vertex.keys() {
1070            if packed.sheet_id() == sheet_id {
1071                let c = packed.col0();
1072                if c >= start_col && c <= end_col {
1073                    let r = packed.row0();
1074                    min_r = Some(min_r.map(|m| m.min(r)).unwrap_or(r));
1075                    max_r = Some(max_r.map(|m| m.max(r)).unwrap_or(r));
1076                }
1077            }
1078        }
1079        match (min_r, max_r) {
1080            (Some(a), Some(b)) => Some((a, b)),
1081            _ => None,
1082        }
1083    }
1084
1085    /// Build (or rebuild) the sheet index for a given sheet.
1086    pub fn finalize_sheet_index(&mut self, sheet: &str) {
1087        let Some(sheet_id) = self.sheet_reg.get_id(sheet) else {
1088            return;
1089        };
1090        self.rebuild_sheet_index(sheet_id);
1091    }
1092
1093    fn rebuild_sheet_index(&mut self, sheet_id: SheetId) {
1094        let mut idx = SheetIndex::new();
1095        let mut batch: Vec<(GridAddr, VertexId)> =
1096            Vec::with_capacity(self.cell_to_vertex.len() + self.load_packed_to_vertex.len());
1097        for (cref, vid) in &self.cell_to_vertex {
1098            if cref.sheet_id == sheet_id {
1099                batch.push((GridAddr::new(cref.coord.row(), cref.coord.col()), *vid));
1100            }
1101        }
1102        for (&packed, &vid) in &self.load_packed_to_vertex {
1103            if packed.sheet_id() != sheet_id {
1104                continue;
1105            }
1106            let coord = GridAddr::new(packed.row0(), packed.col0());
1107            let addr = CellRef::new(sheet_id, Coord::new(coord.row(), coord.col(), true, true));
1108            if self.cell_to_vertex.contains_key(&addr) {
1109                continue;
1110            }
1111            batch.push((coord, vid));
1112        }
1113        idx.add_vertices_batch(&batch);
1114        self.sheet_indexes.insert(sheet_id, idx);
1115    }
1116
1117    /// Finalize the queried sheet on demand in Lazy mode. A non-empty Lazy
1118    /// index can still be partial because incremental edit paths may populate
1119    /// it after deferred bulk load, so queries rebuild it unconditionally.
1120    pub(crate) fn prepare_sheet_index_for_query(&mut self, sheet_id: SheetId) {
1121        if self.config.sheet_index_mode == crate::engine::SheetIndexMode::Lazy {
1122            self.rebuild_sheet_index(sheet_id);
1123        }
1124    }
1125
1126    pub fn set_sheet_index_mode(&mut self, mode: crate::engine::SheetIndexMode) {
1127        self.config.sheet_index_mode = mode;
1128    }
1129
1130    pub(crate) fn set_evaluation_budgets(&mut self, budgets: crate::engine::EvaluationBudgets) {
1131        self.config.evaluation_budgets = budgets;
1132    }
1133
1134    /// Compute min/max used column among vertices within [start_row..=end_row] on a sheet.
1135    pub fn used_col_bounds_for_rows(
1136        &self,
1137        sheet_id: SheetId,
1138        start_row: u32,
1139        end_row: u32,
1140    ) -> Option<(u32, u32)> {
1141        if let Some(index) = self.sheet_indexes.get(&sheet_id)
1142            && !index.is_empty()
1143        {
1144            let mut min_c: Option<u32> = None;
1145            let mut max_c: Option<u32> = None;
1146            for vid in index.vertices_in_row_range(start_row, end_row) {
1147                let Some(c) = self.store.grid_addr(vid).map(|addr| addr.col()) else {
1148                    continue;
1149                };
1150                min_c = Some(min_c.map(|m| m.min(c)).unwrap_or(c));
1151                max_c = Some(max_c.map(|m| m.max(c)).unwrap_or(c));
1152            }
1153            return match (min_c, max_c) {
1154                (Some(a), Some(b)) => Some((a, b)),
1155                _ => None,
1156            };
1157        }
1158        // Fallback: scan cell maps on the fly
1159        let mut min_c: Option<u32> = None;
1160        let mut max_c: Option<u32> = None;
1161        for cref in self.cell_to_vertex.keys() {
1162            if cref.sheet_id == sheet_id {
1163                let r = cref.coord.row();
1164                if r >= start_row && r <= end_row {
1165                    let c = cref.coord.col();
1166                    min_c = Some(min_c.map(|m| m.min(c)).unwrap_or(c));
1167                    max_c = Some(max_c.map(|m| m.max(c)).unwrap_or(c));
1168                }
1169            }
1170        }
1171        for packed in self.load_packed_to_vertex.keys() {
1172            if packed.sheet_id() == sheet_id {
1173                let r = packed.row0();
1174                if r >= start_row && r <= end_row {
1175                    let c = packed.col0();
1176                    min_c = Some(min_c.map(|m| m.min(c)).unwrap_or(c));
1177                    max_c = Some(max_c.map(|m| m.max(c)).unwrap_or(c));
1178                }
1179            }
1180        }
1181        match (min_c, max_c) {
1182            (Some(a), Some(b)) => Some((a, b)),
1183            _ => None,
1184        }
1185    }
1186
1187    /// Returns true if the given sheet currently contains any formula vertices.
1188    pub fn sheet_has_formulas(&self, sheet_id: SheetId) -> bool {
1189        // Check vertex_formulas keys; they represent formula vertices
1190        for &vid in self.vertex_formulas.keys() {
1191            if self.store.sheet_id(vid) == sheet_id {
1192                return true;
1193            }
1194        }
1195        false
1196    }
1197    pub fn new() -> Self {
1198        Self::new_with_config(super::EvalConfig::default())
1199    }
1200
1201    pub fn new_with_config(config: super::EvalConfig) -> Self {
1202        let mut sheet_reg = SheetRegistry::new();
1203        let default_sheet_id = sheet_reg.id_for(&config.default_sheet_name);
1204
1205        let mut g = Self {
1206            store: VertexStore::new(),
1207            edges: CsrMutableEdges::new(),
1208            data_store: DataStore::new(),
1209            vertex_values: FxHashMap::default(),
1210            vertex_formulas: FxHashMap::default(),
1211            // Phase 1 (ticket 610): Arrow-truth is the only supported mode.
1212            // The dependency graph does not cache cell/formula literal payloads.
1213            value_cache_enabled: false,
1214            #[cfg(debug_assertions)]
1215            graph_value_read_attempts: AtomicU64::new(0),
1216            cell_to_vertex: std::collections::HashMap::with_hasher(CoordBuildHasher),
1217            load_packed_to_vertex: std::collections::HashMap::with_hasher(CoordBuildHasher),
1218            formula_dirty: FormulaDirtyState::default(),
1219            dirty_propagation_visits: 0,
1220            deferred_dirty_depth: 0,
1221            deferred_dirty_pending: Vec::new(),
1222            volatile_vertices: FxHashSet::default(),
1223            ref_error_vertices: FxHashSet::default(),
1224            formula_to_range_deps: FxHashMap::default(),
1225            stripe_to_dependents: FxHashMap::default(),
1226            sheet_indexes: FxHashMap::default(),
1227            sheet_reg,
1228            default_sheet_id,
1229            named_ranges: FxHashMap::default(),
1230            named_ranges_lookup: FxHashMap::default(),
1231            sheet_named_ranges: FxHashMap::default(),
1232            sheet_named_ranges_lookup: FxHashMap::default(),
1233            vertex_to_names: FxHashMap::default(),
1234            name_vertex_lookup: FxHashMap::default(),
1235            pending_name_links: FxHashMap::default(),
1236            vertex_to_pending_names: FxHashMap::default(),
1237            tables: FxHashMap::default(),
1238            tables_lookup: FxHashMap::default(),
1239            table_vertex_lookup: FxHashMap::default(),
1240            source_scalars: FxHashMap::default(),
1241            source_tables: FxHashMap::default(),
1242            source_vertex_lookup: FxHashMap::default(),
1243            symbol_vertex_seq: 0,
1244            cell_to_name_dependents: FxHashMap::default(),
1245            name_to_cell_dependencies: FxHashMap::default(),
1246            config: config.clone(),
1247            topology_revision: 0,
1248            symbol_revision: 0,
1249            formula_authority: FormulaAuthority::default(),
1250            pk_order: None,
1251            spill_anchor_to_cells: FxHashMap::default(),
1252            spill_cell_to_anchor: std::collections::HashMap::with_hasher(CoordBuildHasher),
1253            spill_cells_by_sheet: FxHashMap::default(),
1254            admission_budget_override: None,
1255            first_load_assume_new: false,
1256            ensure_touched_sheets: FxHashSet::default(),
1257            tombstone_registry: TombstoneRegistry::default(),
1258            #[cfg(test)]
1259            instr: std::sync::Mutex::new(GraphInstrumentation::default()),
1260            #[cfg(test)]
1261            prepared_legacy_graph_failure_for_test: false,
1262        };
1263
1264        if config.use_dynamic_topo {
1265            // Seed with currently active vertices (likely empty at startup)
1266            let nodes = g
1267                .store
1268                .all_vertices()
1269                .filter(|&id| g.store.vertex_exists_active(id));
1270            let mut pk = DynamicTopo::new(
1271                nodes,
1272                PkConfig {
1273                    visit_budget: config.pk_visit_budget,
1274                    compaction_interval_ops: config.pk_compaction_interval_ops,
1275                },
1276            );
1277            // Build an initial order using current graph
1278            let adapter = GraphAdapter { g: &g };
1279            pk.rebuild_full(&adapter);
1280            g.pk_order = Some(pk);
1281        }
1282
1283        g
1284    }
1285
1286    /// When dynamic topology is enabled, compute layers for a subset using PK ordering.
1287    pub(crate) fn pk_layers_for(&self, subset: &[VertexId]) -> Option<Vec<crate::engine::Layer>> {
1288        let pk = self.pk_order.as_ref()?;
1289        let adapter = crate::engine::topo::GraphAdapter { g: self };
1290        let layers = pk.layers_for(&adapter, subset, self.config.max_layer_width);
1291        Some(
1292            layers
1293                .into_iter()
1294                .map(|vs| crate::engine::Layer { vertices: vs })
1295                .collect(),
1296        )
1297    }
1298
1299    #[inline]
1300    pub(crate) fn dynamic_topo_enabled(&self) -> bool {
1301        self.pk_order.is_some()
1302    }
1303
1304    #[cfg(test)]
1305    pub fn reset_instr(&mut self) {
1306        if let Ok(mut g) = self.instr.lock() {
1307            *g = GraphInstrumentation::default();
1308        }
1309    }
1310
1311    #[cfg(test)]
1312    pub fn instr(&self) -> GraphInstrumentation {
1313        self.instr.lock().map(|g| g.clone()).unwrap_or_default()
1314    }
1315
1316    /// Begin batch operations - defer CSR rebuilds until end_batch() is called
1317    pub fn begin_batch(&mut self) {
1318        self.edges.begin_batch();
1319    }
1320
1321    /// End batch operations and trigger CSR rebuild if needed
1322    pub fn end_batch(&mut self) {
1323        self.edges.end_batch();
1324    }
1325
1326    pub fn default_sheet_id(&self) -> SheetId {
1327        self.default_sheet_id
1328    }
1329
1330    pub fn default_sheet_name(&self) -> &str {
1331        self.sheet_reg.name(self.default_sheet_id)
1332    }
1333
1334    pub fn set_default_sheet_by_name(&mut self, name: &str) {
1335        self.default_sheet_id = self.sheet_id_mut(name);
1336    }
1337
1338    pub fn set_default_sheet_by_id(&mut self, id: SheetId) {
1339        self.default_sheet_id = id;
1340    }
1341
1342    /// Returns the ID for a sheet name, creating one if it doesn't exist.
1343    pub fn sheet_id_mut(&mut self, name: &str) -> SheetId {
1344        self.sheet_reg.id_for(name)
1345    }
1346
1347    pub fn sheet_id(&self, name: &str) -> Option<SheetId> {
1348        self.sheet_reg.get_id(name)
1349    }
1350
1351    /// Resolve a sheet name to an existing ID or return a #REF! error.
1352    fn resolve_existing_sheet_id(&self, name: &str) -> Result<SheetId, ExcelError> {
1353        self.sheet_id(name).ok_or_else(|| {
1354            ExcelError::new(ExcelErrorKind::Ref).with_message(format!("Sheet not found: {name}"))
1355        })
1356    }
1357
1358    /// Returns the name of a sheet given its ID.
1359    pub fn sheet_name(&self, id: SheetId) -> &str {
1360        self.sheet_reg.name(id)
1361    }
1362
1363    /// Access the sheet registry (read-only) for external bindings
1364    pub fn sheet_reg(&self) -> &SheetRegistry {
1365        &self.sheet_reg
1366    }
1367
1368    pub(crate) fn data_store(&self) -> &DataStore {
1369        &self.data_store
1370    }
1371
1372    pub(crate) fn make_ingest_pipeline<'a>(
1373        &'a mut self,
1374        function_provider: &'a dyn crate::traits::FunctionProvider,
1375        policy: formualizer_parse::parser::CollectPolicy,
1376    ) -> crate::engine::ingest_pipeline::IngestPipeline<'a> {
1377        use crate::engine::ingest_pipeline::{
1378            NameRegistryView, NamedEntryRef, NamedTarget, SourceEntryRef, SourceRegistryView,
1379            TableEntrySnapshot, TableRegistryView,
1380        };
1381
1382        let DependencyGraph {
1383            data_store,
1384            sheet_reg,
1385            named_ranges,
1386            named_ranges_lookup,
1387            sheet_named_ranges,
1388            sheet_named_ranges_lookup,
1389            tables,
1390            tables_lookup,
1391            source_scalars,
1392            source_tables,
1393            config,
1394            ..
1395        } = self;
1396
1397        let case_sensitive_names = config.case_sensitive_names;
1398        let names = NameRegistryView::new(move |name, current_sheet| {
1399            let found = if case_sensitive_names {
1400                sheet_named_ranges
1401                    .get(&(current_sheet, name.to_string()))
1402                    .or_else(|| named_ranges.get(name))
1403            } else {
1404                let key = name.to_lowercase();
1405                sheet_named_ranges_lookup
1406                    .get(&(current_sheet, key.clone()))
1407                    .and_then(|canon| sheet_named_ranges.get(&(current_sheet, canon.clone())))
1408                    .or_else(|| {
1409                        named_ranges_lookup
1410                            .get(&key)
1411                            .and_then(|canon| named_ranges.get(canon))
1412                    })
1413            };
1414            found.map(|entry| NamedEntryRef {
1415                vertex: entry.vertex,
1416                target: match &entry.definition {
1417                    crate::engine::named_range::NamedDefinition::Cell(cell) => {
1418                        NamedTarget::Cell(*cell)
1419                    }
1420                    crate::engine::named_range::NamedDefinition::Range(range) => {
1421                        NamedTarget::Range(*range)
1422                    }
1423                    crate::engine::named_range::NamedDefinition::Literal(_)
1424                    | crate::engine::named_range::NamedDefinition::Formula { .. } => {
1425                        NamedTarget::Other
1426                    }
1427                },
1428            })
1429        });
1430
1431        let case_sensitive_tables = config.case_sensitive_tables;
1432        let tables_ref = &*tables;
1433        let tables_lookup_ref = &*tables_lookup;
1434        let snapshot_table = |entry: &tables::TableEntry| TableEntrySnapshot {
1435            name: entry.name.clone(),
1436            range: entry.range,
1437            header_row: entry.header_row,
1438            headers: entry.headers.clone(),
1439            vertex: entry.vertex,
1440        };
1441        let tables_view = TableRegistryView::new(
1442            move |name| {
1443                if case_sensitive_tables {
1444                    tables_ref.get(name).map(snapshot_table)
1445                } else {
1446                    let key = name.to_lowercase();
1447                    tables_lookup_ref
1448                        .get(&key)
1449                        .and_then(|canon| tables_ref.get(canon))
1450                        .map(snapshot_table)
1451                }
1452            },
1453            move |cell| {
1454                let row0 = cell.coord.row();
1455                let col0 = cell.coord.col();
1456                let mut best: Option<&tables::TableEntry> = None;
1457                let mut best_area = u64::MAX;
1458                let mut best_name = "";
1459                for table in tables_ref.values() {
1460                    if table.sheet_id() != cell.sheet_id {
1461                        continue;
1462                    }
1463                    let sr0 = table.range.start.coord.row();
1464                    let sc0 = table.range.start.coord.col();
1465                    let er0 = table.range.end.coord.row();
1466                    let ec0 = table.range.end.coord.col();
1467                    if row0 < sr0 || row0 > er0 || col0 < sc0 || col0 > ec0 {
1468                        continue;
1469                    }
1470                    let area = ((er0 - sr0 + 1) as u64).saturating_mul((ec0 - sc0 + 1) as u64);
1471                    let name = table.name.as_str();
1472                    if best.is_none() || area < best_area || (area == best_area && name < best_name)
1473                    {
1474                        best = Some(table);
1475                        best_area = area;
1476                        best_name = name;
1477                    }
1478                }
1479                best.map(snapshot_table)
1480            },
1481        );
1482
1483        let sources = SourceRegistryView::new(
1484            move |name| {
1485                source_scalars.get(name).map(|entry| SourceEntryRef {
1486                    vertex: entry.vertex,
1487                })
1488            },
1489            move |name| {
1490                source_tables.get(name).map(|entry| SourceEntryRef {
1491                    vertex: entry.vertex,
1492                })
1493            },
1494        );
1495
1496        crate::engine::ingest_pipeline::IngestPipeline::new(
1497            data_store,
1498            sheet_reg,
1499            names,
1500            tables_view,
1501            sources,
1502            function_provider,
1503            policy,
1504        )
1505    }
1506
1507    /// Converts a `CellRef` to a fully qualified A1-style string (e.g., "SheetName!A1").
1508    pub fn to_a1(&self, cell_ref: CellRef) -> String {
1509        format!("{}!{}", self.sheet_name(cell_ref.sheet_id), cell_ref.coord)
1510    }
1511
1512    pub(crate) fn vertex_len(&self) -> usize {
1513        self.store.len()
1514    }
1515
1516    pub(crate) fn topology_revision(&self) -> u64 {
1517        self.topology_revision
1518    }
1519
1520    pub(crate) fn bump_topology_revision(&mut self) {
1521        self.topology_revision = self.topology_revision.wrapping_add(1);
1522    }
1523
1524    pub(crate) fn symbol_revision(&self) -> u64 {
1525        self.symbol_revision
1526    }
1527
1528    pub(crate) fn bump_symbol_revision(&mut self) {
1529        self.symbol_revision = self.symbol_revision.wrapping_add(1);
1530    }
1531
1532    pub(crate) fn authority_revisions(&self) -> (u64, u64, u64) {
1533        (
1534            self.formula_authority.plane.epoch().0,
1535            self.formula_authority.indexes_epoch(),
1536            self.formula_authority.indexed_plane_epoch(),
1537        )
1538    }
1539
1540    pub(crate) fn formula_range_dependencies(
1541        &self,
1542        vertex: VertexId,
1543    ) -> Option<&[SharedRangeRef<'static>]> {
1544        self.formula_to_range_deps.get(&vertex).map(Vec::as_slice)
1545    }
1546
1547    pub(crate) fn spill_anchors_in_region(
1548        &self,
1549        sheet_id: SheetId,
1550        start_row0: u32,
1551        start_col0: u32,
1552        end_row0: u32,
1553        end_col0: u32,
1554    ) -> Vec<VertexId> {
1555        let mut anchors = self
1556            .spill_cells_by_sheet
1557            .get(&sheet_id)
1558            .into_iter()
1559            .flat_map(|cells| cells.range((start_row0, 0)..=(end_row0, u32::MAX)))
1560            .filter_map(|(&(row, col), anchor)| {
1561                (row <= end_row0 && col >= start_col0 && col <= end_col0).then_some(*anchor)
1562            })
1563            .collect::<Vec<_>>();
1564        anchors.sort_unstable();
1565        anchors.dedup();
1566        anchors
1567    }
1568
1569    /// Get mutable access to a sheet's index, creating it if it doesn't exist
1570    /// This is the primary way VertexEditor and internal operations access the index
1571    pub fn sheet_index_mut(&mut self, sheet_id: SheetId) -> &mut SheetIndex {
1572        self.sheet_indexes.entry(sheet_id).or_default()
1573    }
1574
1575    /// Get immutable access to a sheet's index, returns None if not initialized
1576    pub fn sheet_index(&self, sheet_id: SheetId) -> Option<&SheetIndex> {
1577        self.sheet_indexes.get(&sheet_id)
1578    }
1579
1580    pub(crate) fn sheet_index_vertex_count(&self, sheet_id: SheetId) -> usize {
1581        self.sheet_indexes.get(&sheet_id).map_or(0, SheetIndex::len)
1582    }
1583
1584    pub(crate) fn set_admission_budget_override(
1585        &mut self,
1586        budgets: Option<crate::engine::EvaluationBudgets>,
1587    ) -> Option<crate::engine::EvaluationBudgets> {
1588        std::mem::replace(&mut self.admission_budget_override, budgets)
1589    }
1590
1591    fn self_admission_budgets(&self) -> crate::engine::EvaluationBudgets {
1592        self.admission_budget_override
1593            .clone()
1594            .unwrap_or_else(|| self.config.resolved_evaluation_budgets())
1595    }
1596
1597    fn preview_spill_materialization(
1598        &self,
1599        target_cells: &[CellRef],
1600    ) -> Result<crate::engine::resource_ledger::GraphAdmission, ExcelError> {
1601        let unique = target_cells.iter().copied().collect::<FxHashSet<_>>();
1602        let added_vertices = unique
1603            .iter()
1604            .filter(|cell| !self.cell_to_vertex.contains_key(cell))
1605            .count();
1606        let stats = self.baseline_stats();
1607        Ok(crate::engine::resource_ledger::GraphAdmission {
1608            final_vertices: stats
1609                .graph_vertex_count
1610                .checked_add(added_vertices)
1611                .ok_or_else(|| {
1612                    ExcelError::new(ExcelErrorKind::NImpl)
1613                        .with_message("spill vertex count overflow")
1614                })?,
1615            final_edges: stats.graph_edge_count,
1616            materialization_cells: unique.len() as u64,
1617            added_vertices,
1618            added_edges: 0,
1619        })
1620    }
1621
1622    pub(crate) fn preview_value_mutation(
1623        &self,
1624        sheet_id: SheetId,
1625        row: u32,
1626        col: u32,
1627    ) -> Result<crate::engine::resource_ledger::GraphAdmission, ExcelError> {
1628        let cell = CellRef::new(sheet_id, Coord::from_excel(row, col, true, true));
1629        let existing = self.cell_to_vertex.get(&cell).copied();
1630        let stats = self.baseline_stats();
1631        let removed_edges = existing.map_or(0, |vertex| self.get_dependencies(vertex).len());
1632        Ok(crate::engine::resource_ledger::GraphAdmission {
1633            final_vertices: stats
1634                .graph_vertex_count
1635                .checked_add(usize::from(existing.is_none()))
1636                .ok_or_else(|| {
1637                    ExcelError::new(ExcelErrorKind::NImpl)
1638                        .with_message("graph vertex count overflow")
1639                })?,
1640            final_edges: stats
1641                .graph_edge_count
1642                .checked_sub(removed_edges)
1643                .ok_or_else(|| {
1644                    ExcelError::new(ExcelErrorKind::NImpl)
1645                        .with_message("graph edge count underflow")
1646                })?,
1647            materialization_cells: 0,
1648            added_vertices: usize::from(existing.is_none()),
1649            added_edges: 0,
1650        })
1651    }
1652
1653    pub(crate) fn preview_value_mutations(
1654        &self,
1655        sheet_id: SheetId,
1656        cells: &[(u32, u32)],
1657    ) -> Result<crate::engine::resource_ledger::GraphAdmission, ExcelError> {
1658        let mut targets = std::collections::BTreeSet::new();
1659        let mut added_vertices = 0usize;
1660        let mut removed_edges = 0usize;
1661        for (row, col) in cells {
1662            let packed = PackedSheetCell::try_from_excel_1based(sheet_id, *row, *col)
1663                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
1664            if !targets.insert(packed) {
1665                continue;
1666            }
1667            let reference = CellRef::new(sheet_id, Coord::from_excel(*row, *col, true, true));
1668            if let Some(vertex) = self.cell_to_vertex.get(&reference).copied() {
1669                removed_edges = removed_edges
1670                    .checked_add(self.get_dependencies(vertex).len())
1671                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::NImpl))?;
1672            } else {
1673                added_vertices = added_vertices
1674                    .checked_add(1)
1675                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::NImpl))?;
1676            }
1677        }
1678        let stats = self.baseline_stats();
1679        Ok(crate::engine::resource_ledger::GraphAdmission {
1680            final_vertices: stats
1681                .graph_vertex_count
1682                .checked_add(added_vertices)
1683                .ok_or_else(|| ExcelError::new(ExcelErrorKind::NImpl))?,
1684            final_edges: stats
1685                .graph_edge_count
1686                .checked_sub(removed_edges)
1687                .ok_or_else(|| ExcelError::new(ExcelErrorKind::NImpl))?,
1688            materialization_cells: 0,
1689            added_vertices,
1690            added_edges: 0,
1691        })
1692    }
1693
1694    pub(crate) fn preview_formula_mutations(
1695        &self,
1696        plans: &[(SheetId, u32, u32, DependencyPlanRow)],
1697    ) -> Result<crate::engine::resource_ledger::GraphAdmission, ExcelError> {
1698        let mut new_cells = std::collections::BTreeSet::new();
1699        let mut removed_edges = 0usize;
1700        let mut added_edges = 0usize;
1701        for (sheet_id, row, col, plan) in plans {
1702            let target = PackedSheetCell::try_from_excel_1based(*sheet_id, *row, *col)
1703                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
1704            let target_ref = CellRef::new(*sheet_id, Coord::from_excel(*row, *col, true, true));
1705            if let Some(vertex) = self.cell_to_vertex.get(&target_ref).copied() {
1706                removed_edges = removed_edges
1707                    .checked_add(self.get_dependencies(vertex).len())
1708                    .ok_or_else(|| {
1709                        ExcelError::new(ExcelErrorKind::NImpl)
1710                            .with_message("graph edge count overflow")
1711                    })?;
1712            } else {
1713                new_cells.insert(target);
1714            }
1715
1716            let mut dependencies = std::collections::BTreeSet::new();
1717            for dependency in &plan.direct_cell_deps {
1718                let packed = PackedSheetCell::try_new(
1719                    dependency.sheet_id,
1720                    dependency.coord.row(),
1721                    dependency.coord.col(),
1722                )
1723                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
1724                let reference = CellRef::new(dependency.sheet_id, dependency.coord);
1725                if let Some(vertex) = self.cell_to_vertex.get(&reference).copied() {
1726                    dependencies.insert((0u8, u64::from(vertex.0)));
1727                } else {
1728                    new_cells.insert(packed);
1729                    dependencies.insert((1u8, packed.as_u64()));
1730                }
1731            }
1732            for name in plan.resolved_named_refs.iter().chain(&plan.named_refs) {
1733                if let Some(entry) = self.resolve_name_entry(name, *sheet_id) {
1734                    dependencies.insert((0, u64::from(entry.vertex.0)));
1735                } else if let Some(entry) = self.resolve_source_scalar_entry(name) {
1736                    dependencies.insert((0, u64::from(entry.vertex.0)));
1737                }
1738            }
1739            for name in &plan.source_refs {
1740                if let Some(vertex) = self
1741                    .resolve_source_scalar_entry(name)
1742                    .map(|entry| entry.vertex)
1743                    .or_else(|| {
1744                        self.resolve_source_table_entry(name)
1745                            .map(|entry| entry.vertex)
1746                    })
1747                {
1748                    dependencies.insert((0, u64::from(vertex.0)));
1749                }
1750            }
1751            for name in &plan.table_refs {
1752                if let Some(vertex) = self
1753                    .resolve_table_entry(name)
1754                    .map(|entry| entry.vertex)
1755                    .or_else(|| {
1756                        self.resolve_source_table_entry(name)
1757                            .map(|entry| entry.vertex)
1758                    })
1759                {
1760                    dependencies.insert((0, u64::from(vertex.0)));
1761                }
1762            }
1763            let target_row = target.row0();
1764            let target_col = target.col0();
1765            if plan.range_deps.iter().any(|range| {
1766                // `Current` is the formula's own sheet.
1767                let range_sheet = self
1768                    .sheet_reg
1769                    .resolve_locator(&range.sheet, *sheet_id)
1770                    .unwrap_or(*sheet_id);
1771                range_sheet == *sheet_id
1772                    && range
1773                        .start_row
1774                        .is_none_or(|bound| target_row >= bound.index)
1775                    && range.end_row.is_none_or(|bound| target_row <= bound.index)
1776                    && range
1777                        .start_col
1778                        .is_none_or(|bound| target_col >= bound.index)
1779                    && range.end_col.is_none_or(|bound| target_col <= bound.index)
1780            }) {
1781                dependencies.insert((1, target.as_u64()));
1782            }
1783            added_edges = added_edges.checked_add(dependencies.len()).ok_or_else(|| {
1784                ExcelError::new(ExcelErrorKind::NImpl).with_message("graph edge count overflow")
1785            })?;
1786        }
1787        let stats = self.baseline_stats();
1788        Ok(crate::engine::resource_ledger::GraphAdmission {
1789            final_vertices: stats
1790                .graph_vertex_count
1791                .checked_add(new_cells.len())
1792                .ok_or_else(|| {
1793                    ExcelError::new(ExcelErrorKind::NImpl)
1794                        .with_message("graph vertex count overflow")
1795                })?,
1796            final_edges: stats
1797                .graph_edge_count
1798                .checked_sub(removed_edges)
1799                .and_then(|count| count.checked_add(added_edges))
1800                .ok_or_else(|| {
1801                    ExcelError::new(ExcelErrorKind::NImpl).with_message("graph edge count overflow")
1802                })?,
1803            materialization_cells: plans.len() as u64,
1804            added_vertices: new_cells.len(),
1805            added_edges,
1806        })
1807    }
1808
1809    pub(crate) fn vertices_in_region(
1810        &self,
1811        sheet_id: SheetId,
1812        start_row0: u32,
1813        end_row0: u32,
1814        start_col0: u32,
1815        end_col0: u32,
1816    ) -> Vec<VertexId> {
1817        self.sheet_indexes
1818            .get(&sheet_id)
1819            .map_or_else(Vec::new, |index| {
1820                index.vertices_in_rect(start_row0, end_row0, start_col0, end_col0)
1821            })
1822    }
1823
1824    #[cfg(test)]
1825    pub(crate) fn reset_sheet_index_query_stats(&self) {
1826        for index in self.sheet_indexes.values() {
1827            index.reset_query_stats();
1828        }
1829    }
1830
1831    #[cfg(test)]
1832    pub(crate) fn sheet_index_query_stats(
1833        &self,
1834    ) -> crate::engine::sheet_index::SheetIndexQueryStats {
1835        self.sheet_indexes.values().fold(
1836            crate::engine::sheet_index::SheetIndexQueryStats::default(),
1837            |mut total, index| {
1838                let stats = index.query_stats();
1839                total.coordinate_nodes_visited = total
1840                    .coordinate_nodes_visited
1841                    .saturating_add(stats.coordinate_nodes_visited);
1842                total.values_visited = total.values_visited.saturating_add(stats.values_visited);
1843                total
1844            },
1845        )
1846    }
1847
1848    /// Set a value in a cell, returns affected vertex IDs
1849    pub fn set_cell_value(
1850        &mut self,
1851        sheet: &str,
1852        row: u32,
1853        col: u32,
1854        value: LiteralValue,
1855    ) -> Result<OperationSummary, ExcelError> {
1856        let value = normalize_stored_literal(value);
1857        let sheet_id = self.sheet_id_mut(sheet);
1858        let budgets = self.self_admission_budgets();
1859        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
1860            let usage = self.preview_value_mutation(sheet_id, row, col)?;
1861            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
1862                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
1863        }
1864        // External API is 1-based; store 0-based coords internally.
1865        let coord = Coord::from_excel(row, col, true, true);
1866        let addr = CellRef::new(sheet_id, coord);
1867        let mut created_placeholders = Vec::new();
1868
1869        let vertex_id = if let Some(&existing_id) = self.cell_to_vertex.get(&addr) {
1870            // Check if it was a formula and remove dependencies
1871            let is_formula = matches!(
1872                self.store.kind(existing_id),
1873                VertexKind::FormulaScalar | VertexKind::FormulaArray
1874            );
1875
1876            if is_formula {
1877                self.remove_dependent_edges(existing_id);
1878                self.detach_vertex_from_names(existing_id);
1879                self.clear_pending_name_references(existing_id);
1880                self.vertex_formulas.remove(&existing_id);
1881            }
1882
1883            // Update to value kind
1884            self.store.set_kind(existing_id, VertexKind::Cell);
1885            if self.value_cache_enabled {
1886                let value_ref = self.data_store.store_value(value);
1887                self.vertex_values.insert(existing_id, value_ref);
1888            } else {
1889                // Ensure no stale payload remains if cache is disabled.
1890                self.vertex_values.remove(&existing_id);
1891            }
1892            existing_id
1893        } else {
1894            // Create new vertex
1895            created_placeholders.push(addr);
1896            let position = GridAddr::from_coord(AbsCoord::from_excel(row, col));
1897            let vertex_id = self
1898                .store
1899                .allocate(VertexAddr::grid(position), sheet_id, 0x01); // dirty flag
1900
1901            // Add vertex coordinate for CSR
1902            self.edges
1903                .add_vertex(VertexAddr::grid(position), vertex_id.0);
1904
1905            // Add to sheet index for O(log n + k) range queries
1906            self.sheet_index_mut(sheet_id)
1907                .add_vertex(position, vertex_id);
1908
1909            self.store.set_kind(vertex_id, VertexKind::Cell);
1910            if self.value_cache_enabled {
1911                let value_ref = self.data_store.store_value(value);
1912                self.vertex_values.insert(vertex_id, value_ref);
1913            }
1914            self.cell_to_vertex.insert(addr, vertex_id);
1915            vertex_id
1916        };
1917
1918        // Cell edits clear any structural #REF! marking for this vertex.
1919        self.ref_error_vertices.remove(&vertex_id);
1920
1921        Ok(OperationSummary {
1922            affected_vertices: self.mark_dirty(vertex_id),
1923            created_placeholders,
1924        })
1925    }
1926
1927    /// Reserve capacity hints for upcoming bulk cell inserts (values only for now).
1928    pub fn reserve_cells(&mut self, additional: usize) {
1929        self.store.reserve(additional);
1930        if self.value_cache_enabled {
1931            self.vertex_values.reserve(additional);
1932        }
1933        self.cell_to_vertex.reserve(additional);
1934        // sheet_indexes: cannot easily reserve per-sheet without distribution; skip.
1935    }
1936
1937    /// Fast path for initial bulk load of value cells: avoids dirty propagation & dependency work.
1938    pub fn set_cell_value_bulk_untracked(
1939        &mut self,
1940        sheet: &str,
1941        row: u32,
1942        col: u32,
1943        value: LiteralValue,
1944    ) -> Result<(), ExcelError> {
1945        let value = normalize_stored_literal(value);
1946        let sheet_id = self.sheet_id_mut(sheet);
1947        let budgets = self.self_admission_budgets();
1948        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
1949            let usage = self.preview_value_mutation(sheet_id, row, col)?;
1950            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
1951                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
1952        }
1953        let coord = Coord::from_excel(row, col, true, true);
1954        let addr = CellRef::new(sheet_id, coord);
1955        if let Some(&existing_id) = self.cell_to_vertex.get(&addr) {
1956            // Overwrite existing value vertex only (ignore formulas in bulk path)
1957            if matches!(
1958                self.store.kind(existing_id),
1959                VertexKind::FormulaScalar | VertexKind::FormulaArray
1960            ) {
1961                self.remove_dependent_edges(existing_id);
1962                self.detach_vertex_from_names(existing_id);
1963                self.clear_pending_name_references(existing_id);
1964                self.vertex_formulas.remove(&existing_id);
1965            }
1966            if self.value_cache_enabled {
1967                let value_ref = self.data_store.store_value(value);
1968                self.vertex_values.insert(existing_id, value_ref);
1969            } else {
1970                self.vertex_values.remove(&existing_id);
1971            }
1972            self.store.set_kind(existing_id, VertexKind::Cell);
1973            self.ref_error_vertices.remove(&existing_id);
1974            return Ok(());
1975        }
1976        let position = GridAddr::from_coord(AbsCoord::from_excel(row, col));
1977        let vertex_id = self
1978            .store
1979            .allocate(VertexAddr::grid(position), sheet_id, 0x00); // not dirty
1980        self.edges
1981            .add_vertex(VertexAddr::grid(position), vertex_id.0);
1982        self.sheet_index_mut(sheet_id)
1983            .add_vertex(position, vertex_id);
1984        self.store.set_kind(vertex_id, VertexKind::Cell);
1985        self.ref_error_vertices.remove(&vertex_id);
1986        if self.value_cache_enabled {
1987            let value_ref = self.data_store.store_value(value);
1988            self.vertex_values.insert(vertex_id, value_ref);
1989        }
1990        self.cell_to_vertex.insert(addr, vertex_id);
1991        Ok(())
1992    }
1993
1994    /// Bulk insert a collection of plain value cells (no formulas) more efficiently.
1995    pub fn bulk_insert_values<I>(&mut self, sheet: &str, cells: I) -> Result<(), ExcelError>
1996    where
1997        I: IntoIterator<Item = (u32, u32, LiteralValue)>,
1998    {
1999        use crate::instant::FzInstant as Instant;
2000        let t0 = Instant::now();
2001        // Collect first to know size
2002        let collected: Vec<(u32, u32, LiteralValue)> = cells.into_iter().collect();
2003        if collected.is_empty() {
2004            return Ok(());
2005        }
2006        let sheet_id = self.sheet_id_mut(sheet);
2007        let budgets = self.self_admission_budgets();
2008        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
2009            let coordinates = collected
2010                .iter()
2011                .map(|(row, col, _)| (*row, *col))
2012                .collect::<Vec<_>>();
2013            let usage = self.preview_value_mutations(sheet_id, &coordinates)?;
2014            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
2015                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
2016        }
2017        self.reserve_cells(collected.len());
2018        let t_reserve = Instant::now();
2019        let mut new_vertices: Vec<(VertexAddr, u32)> = Vec::with_capacity(collected.len());
2020        let mut index_items: Vec<(GridAddr, VertexId)> = Vec::with_capacity(collected.len());
2021        // For new allocations, accumulate values and assign after a single batch store
2022        let mut new_value_coords: Vec<(GridAddr, VertexId)> = Vec::with_capacity(collected.len());
2023        let mut new_value_literals: Vec<LiteralValue> = Vec::with_capacity(collected.len());
2024        // Detect fast path: during initial ingest, caller may guarantee most cells are new.
2025        let assume_new = self.first_load_assume_new
2026            && self
2027                .sheet_id(sheet)
2028                .map(|sid| !self.ensure_touched_sheets.contains(&sid))
2029                .unwrap_or(false);
2030
2031        for (row, col, value) in collected {
2032            let value = normalize_stored_literal(value);
2033            let coord = Coord::from_excel(row, col, true, true);
2034            let addr = CellRef::new(sheet_id, coord);
2035            if !assume_new && let Some(&existing_id) = self.cell_to_vertex.get(&addr) {
2036                if matches!(
2037                    self.store.kind(existing_id),
2038                    VertexKind::FormulaScalar | VertexKind::FormulaArray
2039                ) {
2040                    self.remove_dependent_edges(existing_id);
2041                    self.detach_vertex_from_names(existing_id);
2042                    self.clear_pending_name_references(existing_id);
2043                    self.vertex_formulas.remove(&existing_id);
2044                }
2045                if self.value_cache_enabled {
2046                    let value_ref = self.data_store.store_value(value);
2047                    self.vertex_values.insert(existing_id, value_ref);
2048                } else {
2049                    self.vertex_values.remove(&existing_id);
2050                }
2051                self.store.set_kind(existing_id, VertexKind::Cell);
2052                continue;
2053            }
2054            let packed = GridAddr::from_coord(AbsCoord::from_excel(row, col));
2055            let vertex_id = self
2056                .store
2057                .allocate(VertexAddr::grid(packed), sheet_id, 0x00);
2058            self.store.set_kind(vertex_id, VertexKind::Cell);
2059            // Defer value arena storage to a single batch
2060            new_value_coords.push((packed, vertex_id));
2061            new_value_literals.push(value);
2062            self.cell_to_vertex.insert(addr, vertex_id);
2063            new_vertices.push((VertexAddr::grid(packed), vertex_id.0));
2064            index_items.push((packed, vertex_id));
2065        }
2066        // Perform a single batch store for newly allocated values
2067        if self.value_cache_enabled && !new_value_literals.is_empty() {
2068            let vrefs = self.data_store.store_values_batch(new_value_literals);
2069            debug_assert_eq!(vrefs.len(), new_value_coords.len());
2070            for (i, (_pc, vid)) in new_value_coords.iter().enumerate() {
2071                self.vertex_values.insert(*vid, vrefs[i]);
2072            }
2073        }
2074        let t_after_alloc = Instant::now();
2075        if !new_vertices.is_empty() {
2076            let t_edges_start = Instant::now();
2077            self.edges.add_vertices_batch(&new_vertices);
2078            let t_edges_done = Instant::now();
2079
2080            match self.config.sheet_index_mode {
2081                crate::engine::SheetIndexMode::Eager => {
2082                    self.sheet_index_mut(sheet_id)
2083                        .add_vertices_batch(&index_items);
2084                }
2085                crate::engine::SheetIndexMode::Lazy => {
2086                    // Skip building index now; will be built on-demand
2087                }
2088                crate::engine::SheetIndexMode::FastBatch => {
2089                    // FastBatch for now delegates to same batch insert (future: build from sorted arrays)
2090                    self.sheet_index_mut(sheet_id)
2091                        .add_vertices_batch(&index_items);
2092                }
2093            }
2094            let t_index_done = Instant::now();
2095        }
2096        Ok(())
2097    }
2098
2099    /// Set a formula in a cell, returns affected vertex IDs
2100    pub fn set_cell_formula(
2101        &mut self,
2102        sheet: &str,
2103        row: u32,
2104        col: u32,
2105        ast: ASTNode,
2106    ) -> Result<OperationSummary, ExcelError> {
2107        self.set_cell_formula_with_volatility(sheet, row, col, ast, false)
2108    }
2109
2110    /// Set a formula in a cell. The volatility argument is retained for API compatibility;
2111    /// dependency flags now come from `IngestPipeline`.
2112    pub fn set_cell_formula_with_volatility(
2113        &mut self,
2114        sheet: &str,
2115        row: u32,
2116        col: u32,
2117        ast: ASTNode,
2118        _volatile: bool,
2119    ) -> Result<OperationSummary, ExcelError> {
2120        let sheet_id = self.sheet_id_mut(sheet);
2121        let placement = CellRef::new(sheet_id, Coord::from_excel(row, col, true, true));
2122        let provider = RegistryFunctionProvider;
2123        let ingested = {
2124            let mut pipeline = self.ingest_pipeline(&provider);
2125            pipeline.ingest_formula(FormulaAstInput::Tree(ast), placement, None)?
2126        };
2127        self.set_cell_formula_with_plan(
2128            sheet,
2129            row,
2130            col,
2131            ingested.ast_id,
2132            &ingested.dep_plan,
2133            ingested.dep_plan.volatile,
2134            ingested.dep_plan.dynamic,
2135        )
2136    }
2137
2138    pub(crate) fn set_cell_formula_with_plan(
2139        &mut self,
2140        sheet: &str,
2141        row: u32,
2142        col: u32,
2143        ast_id: AstNodeId,
2144        plan: &DependencyPlanRow,
2145        volatile: bool,
2146        dynamic: bool,
2147    ) -> Result<OperationSummary, ExcelError> {
2148        let dbg = std::env::var("FZ_DEBUG_LOAD")
2149            .ok()
2150            .is_some_and(|v| v != "0");
2151        let dep_ms_thresh: u128 = std::env::var("FZ_DEBUG_DEP_MS")
2152            .ok()
2153            .and_then(|s| s.parse().ok())
2154            .unwrap_or(0);
2155        let sample_n: usize = std::env::var("FZ_DEBUG_SAMPLE_N")
2156            .ok()
2157            .and_then(|s| s.parse().ok())
2158            .unwrap_or(0);
2159        let t0 = if dbg {
2160            Some(crate::instant::FzInstant::now())
2161        } else {
2162            None
2163        };
2164        let sheet_id = self.sheet_id_mut(sheet);
2165        let budgets = self.self_admission_budgets();
2166        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
2167            let usage = self.preview_formula_mutations(&[(sheet_id, row, col, plan.clone())])?;
2168            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
2169                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
2170        }
2171        let coord = Coord::from_excel(row, col, true, true);
2172        let addr = CellRef::new(sheet_id, coord);
2173
2174        let t_dep0 = if dbg {
2175            Some(crate::instant::FzInstant::now())
2176        } else {
2177            None
2178        };
2179        let mut created_placeholders = Vec::new();
2180        let mut new_dependencies = Vec::with_capacity(plan.direct_cell_deps.len());
2181        for dep in &plan.direct_cell_deps {
2182            let dep_vid = self.get_or_create_vertex(dep, &mut created_placeholders);
2183            if !new_dependencies.contains(&dep_vid) {
2184                new_dependencies.push(dep_vid);
2185            }
2186        }
2187        let mut named_dependencies = Vec::new();
2188        let mut unresolved_names = Vec::new();
2189        for name in plan
2190            .resolved_named_refs
2191            .iter()
2192            .chain(plan.named_refs.iter())
2193        {
2194            if let Some(named) = self.resolve_name_entry(name, sheet_id) {
2195                if !new_dependencies.contains(&named.vertex) {
2196                    new_dependencies.push(named.vertex);
2197                }
2198                if !named_dependencies.contains(&named.vertex) {
2199                    named_dependencies.push(named.vertex);
2200                }
2201            } else if let Some(source) = self.resolve_source_scalar_entry(name) {
2202                if !new_dependencies.contains(&source.vertex) {
2203                    new_dependencies.push(source.vertex);
2204                }
2205            } else {
2206                unresolved_names.push(name.clone());
2207            }
2208        }
2209        for source_name in &plan.source_refs {
2210            if let Some(source) = self.resolve_source_scalar_entry(source_name) {
2211                if !new_dependencies.contains(&source.vertex) {
2212                    new_dependencies.push(source.vertex);
2213                }
2214            } else if let Some(source) = self.resolve_source_table_entry(source_name)
2215                && !new_dependencies.contains(&source.vertex)
2216            {
2217                new_dependencies.push(source.vertex);
2218            }
2219        }
2220        for table_name in &plan.table_refs {
2221            if let Some(table) = self.resolve_table_entry(table_name) {
2222                if !new_dependencies.contains(&table.vertex) {
2223                    new_dependencies.push(table.vertex);
2224                }
2225            } else if let Some(source) = self.resolve_source_table_entry(table_name)
2226                && !new_dependencies.contains(&source.vertex)
2227            {
2228                new_dependencies.push(source.vertex);
2229            }
2230        }
2231        if let (true, Some(t)) = (dbg, t_dep0) {
2232            let elapsed = t.elapsed().as_millis();
2233            let do_log = (dep_ms_thresh > 0 && elapsed >= dep_ms_thresh)
2234                || (sample_n > 0 && (row as usize).is_multiple_of(sample_n));
2235            if (dep_ms_thresh == 0 && sample_n == 0 && row.is_multiple_of(1000)) || do_log {
2236                eprintln!(
2237                    "[fz][dep] {}!{} planned: deps={}, ranges={}, placeholders={}, names={} in {} ms",
2238                    self.sheet_name(sheet_id),
2239                    crate::reference::Coord::from_excel(row, col, true, true),
2240                    new_dependencies.len(),
2241                    plan.range_deps.len(),
2242                    created_placeholders.len(),
2243                    named_dependencies.len(),
2244                    elapsed
2245                );
2246            }
2247        }
2248
2249        // Check for self-reference (immediate cycle detection)
2250        let addr_vertex_id = self.get_or_create_vertex(&addr, &mut created_placeholders);
2251
2252        // Editing a formula clears any prior structural #REF! marking for this vertex.
2253        self.ref_error_vertices.remove(&addr_vertex_id);
2254
2255        // Under `CyclePolicy::Iterate` (Runtime detection) self-dependencies
2256        // are accepted, mirroring Excel with iterative calculation enabled:
2257        // the self-edge forms a single-vertex SCC that the scheduler emits as
2258        // a Cycle unit and `evaluate_scc_unit` iterates (RFC #113, spec §7.1/
2259        // §7.6/§7.8). Everywhere else the edit-time rejection stands.
2260        //
2261        // Scope note (persistence contract, pinned by
2262        // `formualizer-workbook/tests/cycle_persistence.rs`): this rejection
2263        // is an INTERACTIVE-EDIT nicety only. Bulk load paths
2264        // (`ingest_formula_batches` → `BulkIngestBuilder`, incl. staged
2265        // `build_graph_all`) intentionally do not perform it, so workbooks
2266        // saved with self-references under an Iterate config always reload —
2267        // under any cycle config — and resolve to `#CIRC!`/iteration at
2268        // evaluation time per the loaded policy.
2269        if new_dependencies.contains(&addr_vertex_id) && !self.config.cycle.allows_self_dependency()
2270        {
2271            return Err(ExcelError::new(ExcelErrorKind::Circ)
2272                .with_message("Self-reference detected".to_string()));
2273        }
2274
2275        for &name_vertex in &named_dependencies {
2276            let mut visited = FxHashSet::default();
2277            if self.name_depends_on_vertex(name_vertex, addr_vertex_id, &mut visited) {
2278                return Err(ExcelError::new(ExcelErrorKind::Circ)
2279                    .with_message("Circular reference through named range".to_string()));
2280            }
2281        }
2282
2283        // Remove old dependencies first
2284        self.remove_dependent_edges(addr_vertex_id);
2285        self.detach_vertex_from_names(addr_vertex_id);
2286        self.clear_pending_name_references(addr_vertex_id);
2287
2288        // Update vertex properties
2289        self.store
2290            .set_kind(addr_vertex_id, VertexKind::FormulaScalar);
2291        self.vertex_formulas.insert(addr_vertex_id, ast_id);
2292        self.store.set_dirty(addr_vertex_id, true);
2293
2294        // Clear any cached value since this is now a formula
2295        self.vertex_values.remove(&addr_vertex_id);
2296
2297        self.mark_volatile(addr_vertex_id, volatile);
2298        self.store.set_dynamic(addr_vertex_id, dynamic);
2299
2300        if !named_dependencies.is_empty() {
2301            self.attach_vertex_to_names(addr_vertex_id, &named_dependencies);
2302        }
2303        for unresolved_name in &unresolved_names {
2304            self.record_pending_name_reference(sheet_id, unresolved_name, addr_vertex_id);
2305        }
2306
2307        if let (true, Some(t)) = (dbg, t0) {
2308            let elapsed = t.elapsed().as_millis();
2309            let log_set = dep_ms_thresh > 0 && elapsed >= dep_ms_thresh;
2310            if log_set {
2311                eprintln!(
2312                    "[fz][set] {}!{} total {} ms",
2313                    self.sheet_name(sheet_id),
2314                    crate::reference::Coord::from_excel(row, col, true, true),
2315                    elapsed
2316                );
2317            }
2318        }
2319
2320        // Add new dependency edges
2321        self.add_dependent_edges(addr_vertex_id, &new_dependencies);
2322        self.add_range_dependent_edges(addr_vertex_id, &plan.range_deps, sheet_id);
2323
2324        Ok(OperationSummary {
2325            affected_vertices: self.mark_dirty(addr_vertex_id),
2326            created_placeholders,
2327        })
2328    }
2329
2330    pub(crate) fn rewrite_structured_references_for_cell(
2331        &self,
2332        ast: &mut ASTNode,
2333        cell: CellRef,
2334    ) -> Result<bool, ExcelError> {
2335        self.rewrite_structured_references_node(ast, cell)
2336    }
2337
2338    fn rewrite_structured_references_node(
2339        &self,
2340        node: &mut ASTNode,
2341        cell: CellRef,
2342    ) -> Result<bool, ExcelError> {
2343        match &mut node.node_type {
2344            ASTNodeType::Reference { reference, .. } => {
2345                self.rewrite_structured_reference(reference, cell)
2346            }
2347            ASTNodeType::UnaryOp { expr, .. } => {
2348                self.rewrite_structured_references_node(expr, cell)
2349            }
2350            ASTNodeType::BinaryOp { left, right, .. } => {
2351                let left_rewritten = self.rewrite_structured_references_node(left, cell)?;
2352                let right_rewritten = self.rewrite_structured_references_node(right, cell)?;
2353                Ok(left_rewritten || right_rewritten)
2354            }
2355            ASTNodeType::Function { args, .. } => {
2356                let mut rewritten = false;
2357                for a in args.iter_mut() {
2358                    rewritten |= self.rewrite_structured_references_node(a, cell)?;
2359                }
2360                Ok(rewritten)
2361            }
2362            ASTNodeType::Call { callee, args } => {
2363                let mut rewritten = self.rewrite_structured_references_node(callee, cell)?;
2364                for a in args.iter_mut() {
2365                    rewritten |= self.rewrite_structured_references_node(a, cell)?;
2366                }
2367                Ok(rewritten)
2368            }
2369            ASTNodeType::Array(rows) => {
2370                let mut rewritten = false;
2371                for r in rows.iter_mut() {
2372                    for item in r.iter_mut() {
2373                        rewritten |= self.rewrite_structured_references_node(item, cell)?;
2374                    }
2375                }
2376                Ok(rewritten)
2377            }
2378            ASTNodeType::Literal(_) | ASTNodeType::Omitted => Ok(false),
2379        }
2380    }
2381
2382    fn rewrite_structured_reference(
2383        &self,
2384        reference: &mut ReferenceType,
2385        cell: CellRef,
2386    ) -> Result<bool, ExcelError> {
2387        use formualizer_parse::parser::{SpecialItem, TableSpecifier};
2388
2389        let ReferenceType::Table(tref) = reference else {
2390            return Ok(false);
2391        };
2392
2393        // This-row shorthand: parsed as an unnamed table reference with a Combination specifier.
2394        if !tref.name.is_empty() {
2395            return Ok(false);
2396        }
2397
2398        let col_name = match &tref.specifier {
2399            Some(TableSpecifier::Combination(parts)) => {
2400                let mut saw_this_row = false;
2401                let mut col: Option<&str> = None;
2402                for p in parts {
2403                    match p.as_ref() {
2404                        TableSpecifier::SpecialItem(SpecialItem::ThisRow) => {
2405                            saw_this_row = true;
2406                        }
2407                        TableSpecifier::Column(c) => {
2408                            if col.is_some() {
2409                                return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
2410                                    "This-row structured reference with multiple columns is not supported"
2411                                        .to_string(),
2412                                ));
2413                            }
2414                            col = Some(c.as_str());
2415                        }
2416                        other => {
2417                            return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
2418                                format!(
2419                                    "Unsupported this-row structured reference component: {other}"
2420                                ),
2421                            ));
2422                        }
2423                    }
2424                }
2425                if !saw_this_row {
2426                    return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
2427                        "Unnamed structured reference requires a this-row selector".to_string(),
2428                    ));
2429                }
2430                col.ok_or_else(|| {
2431                    ExcelError::new(ExcelErrorKind::NImpl).with_message(
2432                        "This-row structured reference missing column selector".to_string(),
2433                    )
2434                })?
2435            }
2436            _ => {
2437                return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
2438                    "Unnamed structured reference form is not supported".to_string(),
2439                ));
2440            }
2441        };
2442
2443        let Some(table) = self.find_table_containing_cell(cell) else {
2444            return Err(ExcelError::new(ExcelErrorKind::Name)
2445                .with_message("This-row structured reference used outside a table".to_string()));
2446        };
2447
2448        let row0 = cell.coord.row();
2449        let col0 = cell.coord.col();
2450        let sr0 = table.range.start.coord.row();
2451        let sc0 = table.range.start.coord.col();
2452        let er0 = table.range.end.coord.row();
2453        let ec0 = table.range.end.coord.col();
2454
2455        if row0 < sr0 || row0 > er0 || col0 < sc0 || col0 > ec0 {
2456            return Err(ExcelError::new(ExcelErrorKind::Name)
2457                .with_message("This-row structured reference used outside a table".to_string()));
2458        }
2459
2460        if table.header_row && row0 == sr0 {
2461            return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(
2462                "This-row structured references are not valid in the table header row".to_string(),
2463            ));
2464        }
2465
2466        let data_start = if table.header_row { sr0 + 1 } else { sr0 };
2467        if row0 < data_start {
2468            return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(
2469                "This-row structured references require a data/totals row context".to_string(),
2470            ));
2471        }
2472
2473        let Some(idx) = table.col_index(col_name) else {
2474            return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(format!(
2475                "Unknown table column in this-row reference: {col_name}"
2476            )));
2477        };
2478        let target_col0 = sc0 + (idx as u32);
2479        let target_row = row0 + 1;
2480        let target_col = target_col0 + 1;
2481
2482        *reference = ReferenceType::Cell {
2483            sheet: None,
2484            row: target_row,
2485            col: target_col,
2486            row_abs: true,
2487            col_abs: true,
2488        };
2489
2490        Ok(true)
2491    }
2492
2493    fn find_table_containing_cell(&self, cell: CellRef) -> Option<&tables::TableEntry> {
2494        let row0 = cell.coord.row();
2495        let col0 = cell.coord.col();
2496
2497        let mut best: Option<&tables::TableEntry> = None;
2498        let mut best_area: u64 = u64::MAX;
2499        let mut best_name: &str = "";
2500
2501        for t in self.tables.values() {
2502            if t.sheet_id() != cell.sheet_id {
2503                continue;
2504            }
2505            let sr0 = t.range.start.coord.row();
2506            let sc0 = t.range.start.coord.col();
2507            let er0 = t.range.end.coord.row();
2508            let ec0 = t.range.end.coord.col();
2509            if row0 < sr0 || row0 > er0 || col0 < sc0 || col0 > ec0 {
2510                continue;
2511            }
2512
2513            let h = (er0 - sr0 + 1) as u64;
2514            let w = (ec0 - sc0 + 1) as u64;
2515            let area = h.saturating_mul(w);
2516            let name = t.name.as_str();
2517            let better = match best {
2518                None => true,
2519                Some(_) => area < best_area || (area == best_area && name < best_name),
2520            };
2521            if better {
2522                best = Some(t);
2523                best_area = area;
2524                best_name = name;
2525            }
2526        }
2527
2528        best
2529    }
2530
2531    #[allow(clippy::type_complexity)]
2532    pub(crate) fn fp8_parity_extract_dependencies_with_pending_names(
2533        &mut self,
2534        ast: &ASTNode,
2535        current_sheet_id: SheetId,
2536    ) -> Result<
2537        (
2538            Vec<VertexId>,
2539            Vec<SharedRangeRef<'static>>,
2540            Vec<CellRef>,
2541            Vec<VertexId>,
2542            Vec<String>,
2543        ),
2544        ExcelError,
2545    > {
2546        self.extract_dependencies_with_pending_names(ast, current_sheet_id)
2547    }
2548
2549    pub(crate) fn fp8_parity_is_ast_volatile(&self, ast: &ASTNode) -> bool {
2550        self.is_ast_volatile(ast)
2551    }
2552
2553    pub fn set_cell_value_ref(
2554        &mut self,
2555        cell: formualizer_common::SheetCellRef<'_>,
2556        value: LiteralValue,
2557    ) -> Result<OperationSummary, ExcelError> {
2558        let owned = cell.into_owned();
2559        let sheet_id = match owned.sheet {
2560            formualizer_common::SheetLocator::Id(id) => id,
2561            formualizer_common::SheetLocator::Name(name) => self.sheet_id_mut(name.as_ref()),
2562            formualizer_common::SheetLocator::Current => self.default_sheet_id,
2563        };
2564        let sheet_name = self.sheet_name(sheet_id).to_string();
2565        self.set_cell_value(
2566            &sheet_name,
2567            owned.coord.row() + 1,
2568            owned.coord.col() + 1,
2569            value,
2570        )
2571    }
2572
2573    pub fn set_cell_formula_ref(
2574        &mut self,
2575        cell: formualizer_common::SheetCellRef<'_>,
2576        ast: ASTNode,
2577    ) -> Result<OperationSummary, ExcelError> {
2578        let owned = cell.into_owned();
2579        let sheet_id = match owned.sheet {
2580            formualizer_common::SheetLocator::Id(id) => id,
2581            formualizer_common::SheetLocator::Name(name) => self.sheet_id_mut(name.as_ref()),
2582            formualizer_common::SheetLocator::Current => self.default_sheet_id,
2583        };
2584        let sheet_name = self.sheet_name(sheet_id).to_string();
2585        self.set_cell_formula(
2586            &sheet_name,
2587            owned.coord.row() + 1,
2588            owned.coord.col() + 1,
2589            ast,
2590        )
2591    }
2592
2593    pub fn get_cell_value_ref(
2594        &self,
2595        cell: formualizer_common::SheetCellRef<'_>,
2596    ) -> Option<LiteralValue> {
2597        let owned = cell.into_owned();
2598        let sheet_id = match owned.sheet {
2599            formualizer_common::SheetLocator::Id(id) => id,
2600            formualizer_common::SheetLocator::Name(name) => self.sheet_id(name.as_ref())?,
2601            formualizer_common::SheetLocator::Current => self.default_sheet_id,
2602        };
2603        let sheet_name = self.sheet_name(sheet_id);
2604        self.get_cell_value(sheet_name, owned.coord.row() + 1, owned.coord.col() + 1)
2605    }
2606
2607    /// Get current value from a cell
2608    pub fn get_cell_value(&self, sheet: &str, row: u32, col: u32) -> Option<LiteralValue> {
2609        if !self.value_cache_enabled {
2610            #[cfg(debug_assertions)]
2611            {
2612                self.graph_value_read_attempts
2613                    .fetch_add(1, Ordering::Relaxed);
2614            }
2615            return None;
2616        }
2617        let sheet_id = self.sheet_reg.get_id(sheet)?;
2618        let coord = Coord::from_excel(row, col, true, true);
2619        let addr = CellRef::new(sheet_id, coord);
2620
2621        self.get_vertex_id_for_address(&addr)
2622            .and_then(|&vertex_id| {
2623                // Check values hashmap (stores both cell values and formula results)
2624                self.vertex_values
2625                    .get(&vertex_id)
2626                    .map(|&value_ref| self.data_store.retrieve_value(value_ref))
2627            })
2628    }
2629
2630    /// Mark vertex dirty and propagate to dependents
2631    fn mark_dirty(&mut self, vertex_id: VertexId) -> Vec<VertexId> {
2632        self.mark_dirty_many(&[vertex_id])
2633    }
2634
2635    /// Multi-source `mark_dirty`: one BFS with a shared seen-set across all
2636    /// sources, marking exactly the union of per-source `mark_dirty` calls
2637    /// but visiting every vertex at most once per call.
2638    ///
2639    /// Loop-of-`mark_dirty` callers (volatile redirty, iterative-SCC redirty)
2640    /// pay O(sources × component) without this — measured quadratic by the
2641    /// iterate edge corpus. A BFS that early-stops at already-`is_dirty`
2642    /// vertices would also fix that, but it is NOT safe in general: several
2643    /// call sites set the dirty flag WITHOUT propagating to dependents
2644    /// (`DependencyGraph::set_dirty`, `mark_dependents_dirty`, names.rs
2645    /// binding invalidation, eval.rs demand-driven re-marks), so "dirty"
2646    /// does not imply "my dependents are already dirty". The per-call shared
2647    /// seen-set needs no such invariant.
2648    ///
2649    /// While a deferred-dirty scope is active (`begin_deferred_dirty`), the
2650    /// call queues its sources for the end-of-scope flush and returns ONLY
2651    /// the sources as the "affected" set (the full transitive set is
2652    /// produced once by the flush). Loop-of-edits callers must not rely on
2653    /// per-edit transitive affected sets inside such a scope.
2654    pub(crate) fn mark_dirty_many(&mut self, vertex_ids: &[VertexId]) -> Vec<VertexId> {
2655        if self.deferred_dirty_depth > 0 {
2656            self.deferred_dirty_pending.extend_from_slice(vertex_ids);
2657            return vertex_ids.to_vec();
2658        }
2659        let mut affected = FxHashSet::default();
2660        let mut to_visit = Vec::new();
2661        let mut visited_for_propagation = FxHashSet::default();
2662
2663        for &vertex_id in vertex_ids {
2664            // Only mark the source vertex as dirty if it's a formula.
2665            // Value cells don't get marked dirty themselves but are still
2666            // affected.
2667            let is_formula = matches!(
2668                self.store.kind(vertex_id),
2669                VertexKind::FormulaScalar
2670                    | VertexKind::FormulaArray
2671                    | VertexKind::NamedScalar
2672                    | VertexKind::NamedArray
2673            );
2674
2675            if is_formula {
2676                to_visit.push(vertex_id);
2677            } else {
2678                // Value cells are affected (for tracking) but not marked dirty
2679                affected.insert(vertex_id);
2680            }
2681
2682            // Initial propagation from direct and range dependents
2683            {
2684                // Get dependents (vertices that depend on this vertex)
2685                if let Some(dependents) = self.dependents_slice(vertex_id) {
2686                    to_visit.extend(dependents.iter().copied());
2687                } else {
2688                    let dependents = self.get_dependents(vertex_id);
2689                    to_visit.extend(dependents);
2690                }
2691
2692                if let Some(name_set) = self.cell_to_name_dependents.get(&vertex_id) {
2693                    for &name_vertex in name_set {
2694                        to_visit.push(name_vertex);
2695                    }
2696                }
2697
2698                to_visit.extend(self.collect_range_dependents_for_vertex(vertex_id));
2699            }
2700        }
2701
2702        while let Some(id) = to_visit.pop() {
2703            if !visited_for_propagation.insert(id) {
2704                continue; // Already processed
2705            }
2706            self.dirty_propagation_visits += 1;
2707            affected.insert(id);
2708
2709            // Mark vertex as dirty
2710            self.store.set_dirty(id, true);
2711
2712            // Add direct dependents to visit list
2713            if let Some(dependents) = self.dependents_slice(id) {
2714                to_visit.extend(dependents.iter().copied());
2715            } else {
2716                let dependents = self.get_dependents(id);
2717                to_visit.extend(dependents);
2718            }
2719            to_visit.extend(self.collect_range_dependents_for_vertex(id));
2720        }
2721
2722        // Add to dirty set
2723        self.formula_dirty.legacy_extend(affected.iter().copied());
2724
2725        // Return as Vec for compatibility
2726        affected.into_iter().collect()
2727    }
2728
2729    /// Total vertices processed by dirty-propagation BFS loops since graph
2730    /// creation (perf-shape observability; see `dirty_propagation_visits`).
2731    pub(crate) fn dirty_propagation_visits(&self) -> u64 {
2732        self.dirty_propagation_visits
2733    }
2734
2735    /// Begin a deferred-dirty scope for a multi-edit batch.
2736    ///
2737    /// While active, `mark_dirty` / `mark_dirty_many` /
2738    /// `mark_dirty_many_value_cells` queue their sources instead of running a
2739    /// BFS per call; the outermost `end_deferred_dirty` flushes the queued
2740    /// union with ONE multi-source `mark_dirty_many`. Union semantics equal
2741    /// the sequential per-edit calls (pinned by
2742    /// `mark_dirty_many_equals_sequential_single_source_marks` plus the
2743    /// deferred-scope tests): any dependent edge removed mid-batch belongs to
2744    /// a vertex that was itself edited mid-batch, and edited vertices are
2745    /// themselves pending sources, so the flush covers everything a per-edit
2746    /// propagation would have reached.
2747    ///
2748    /// Nesting is depth-counted. The scope also enters the CSR edge batch
2749    /// (`begin_batch`) so edge-heavy batches amortize delta rebuilds (#127).
2750    ///
2751    /// Callers MUST guarantee `end_deferred_dirty` runs on every exit path
2752    /// (including `?` early returns): a leaked scope would silently swallow
2753    /// future propagations. Evaluation entry points `debug_assert` that no
2754    /// scope is active.
2755    pub fn begin_deferred_dirty(&mut self) {
2756        self.edges.begin_batch();
2757        self.deferred_dirty_depth += 1;
2758    }
2759
2760    /// End a deferred-dirty scope. When the outermost scope ends, runs ONE
2761    /// multi-source propagation over every source queued while deferred and
2762    /// returns its full affected set (sources pointing at vertices deleted
2763    /// mid-batch are skipped). Inner (nested) ends return an empty set.
2764    pub fn end_deferred_dirty(&mut self) -> Vec<VertexId> {
2765        debug_assert!(
2766            self.deferred_dirty_depth > 0,
2767            "end_deferred_dirty without matching begin_deferred_dirty"
2768        );
2769        self.edges.end_batch();
2770        self.deferred_dirty_depth = self.deferred_dirty_depth.saturating_sub(1);
2771        if self.deferred_dirty_depth > 0 {
2772            return Vec::new();
2773        }
2774        let pending = std::mem::take(&mut self.deferred_dirty_pending);
2775        if pending.is_empty() {
2776            return Vec::new();
2777        }
2778        let live: Vec<VertexId> = pending
2779            .into_iter()
2780            .filter(|&id| self.vertex_exists(id))
2781            .collect();
2782        self.mark_dirty_many(&live)
2783    }
2784
2785    /// True while a deferred-dirty scope is active (see
2786    /// `begin_deferred_dirty`). Evaluation must never start in this state.
2787    pub fn deferred_dirty_active(&self) -> bool {
2788        self.deferred_dirty_depth > 0
2789    }
2790
2791    /// Get all vertices that need evaluation
2792    pub fn get_evaluation_vertices(&self) -> Vec<VertexId> {
2793        let mut combined = FxHashSet::default();
2794        combined.extend(self.formula_dirty.legacy_iter().copied());
2795        combined.extend(&self.volatile_vertices);
2796
2797        let mut result: Vec<VertexId> = combined
2798            .into_iter()
2799            .filter(|&id| {
2800                // Only include active formula/name vertices; tombstoned vertices can retain stable
2801                // IDs in the store, but must never be scheduled for evaluation.
2802                self.store.vertex_exists_active(id)
2803                    && matches!(
2804                        self.store.kind(id),
2805                        VertexKind::FormulaScalar
2806                            | VertexKind::FormulaArray
2807                            | VertexKind::NamedScalar
2808                            | VertexKind::NamedArray
2809                    )
2810            })
2811            .collect();
2812        result.sort_unstable();
2813        result
2814    }
2815
2816    /// Clear dirty flags after successful evaluation
2817    pub fn clear_dirty_flags(&mut self, vertices: &[VertexId]) {
2818        for &vertex_id in vertices {
2819            self.store.set_dirty(vertex_id, false);
2820            self.formula_dirty.legacy_remove(&vertex_id);
2821        }
2822    }
2823
2824    /// 🔮 Scalability Hook: Clear volatile vertices after evaluation cycle
2825    pub fn clear_volatile_flags(&mut self) {
2826        self.volatile_vertices.clear();
2827    }
2828
2829    /// Re-marks all volatile vertices as dirty for the next evaluation cycle.
2830    /// One multi-source propagation: many volatiles feeding one dependent
2831    /// component used to pay O(volatiles × component) (a full `mark_dirty`
2832    /// BFS per volatile); `mark_dirty_many` visits the component once.
2833    pub(crate) fn redirty_volatiles(&mut self) {
2834        let volatile_ids: Vec<VertexId> = self.volatile_vertices.iter().copied().collect();
2835        let _ = self.mark_dirty_many(&volatile_ids);
2836    }
2837
2838    /// Re-marks members of iterating SCCs (and, via propagation, their
2839    /// dependents) dirty for the next evaluation cycle — the volatile-like
2840    /// redirty that keeps `CyclePolicy::Iterate` cells re-evaluating every
2841    /// recalc (RFC #113; spec §4/§7.6). Vertices deleted since the recalc
2842    /// are skipped.
2843    ///
2844    /// One multi-source propagation: the old per-member `mark_dirty` loop was
2845    /// O(|SCC|²) per recalc for a large SCC (a converged 1000-member ring
2846    /// cost ~42 ms per no-op recalc, release); an interim `!is_dirty` skip
2847    /// fixed that but leaned on dirty-flag semantics that non-propagating
2848    /// `set_dirty` callers do not uphold. The shared seen-set in
2849    /// `mark_dirty_many` is O(component) without any such invariant.
2850    pub(crate) fn redirty_iterative_members(&mut self, members: &[VertexId]) {
2851        let live: Vec<VertexId> = members
2852            .iter()
2853            .copied()
2854            .filter(|&id| self.vertex_exists(id))
2855            .collect();
2856        let _ = self.mark_dirty_many(&live);
2857    }
2858
2859    fn get_or_create_vertex(
2860        &mut self,
2861        addr: &CellRef,
2862        created_placeholders: &mut Vec<CellRef>,
2863    ) -> VertexId {
2864        if let Some(&vertex_id) = self.cell_to_vertex.get(addr) {
2865            return vertex_id;
2866        }
2867
2868        // During first-load bulk ingest the fast path populates
2869        // ``load_packed_to_vertex`` but skips ``cell_to_vertex``. Promote
2870        // the entry into ``cell_to_vertex`` so subsequent lookups are O(1)
2871        // and consistent across the two maps.
2872        if self.first_load_assume_new {
2873            let packed = Self::packed_cell_key(
2874                addr.sheet_id,
2875                AbsCoord::new(addr.coord.row(), addr.coord.col()),
2876            );
2877            if let Some(&existing) = self.load_packed_to_vertex.get(&packed) {
2878                self.cell_to_vertex.insert(*addr, existing);
2879                return existing;
2880            }
2881        }
2882
2883        created_placeholders.push(*addr);
2884        let position = GridAddr::new(addr.coord.row(), addr.coord.col());
2885        let vertex_id = self
2886            .store
2887            .allocate(VertexAddr::grid(position), addr.sheet_id, 0x00);
2888
2889        // Add vertex coordinate for CSR
2890        self.edges
2891            .add_vertex(VertexAddr::grid(position), vertex_id.0);
2892
2893        // Add to sheet index for O(log n + k) range queries
2894        self.sheet_index_mut(addr.sheet_id)
2895            .add_vertex(position, vertex_id);
2896
2897        self.store.set_kind(vertex_id, VertexKind::Empty);
2898        self.cell_to_vertex.insert(*addr, vertex_id);
2899        vertex_id
2900    }
2901
2902    fn add_dependent_edges(&mut self, dependent: VertexId, dependencies: &[VertexId]) {
2903        // Batch to avoid repeated CSR rebuilds and keep reverse edges current
2904        self.edges.begin_batch();
2905
2906        // If PK enabled, update order using a short-lived adapter without holding &mut self
2907        // Track dependencies that should be skipped if rejecting cycle-creating edges
2908        let mut skip_deps: rustc_hash::FxHashSet<VertexId> = rustc_hash::FxHashSet::default();
2909        if self.pk_order.is_some()
2910            && let Some(mut pk) = self.pk_order.take()
2911        {
2912            pk.ensure_nodes(std::iter::once(dependent));
2913            pk.ensure_nodes(dependencies.iter().copied());
2914            {
2915                let adapter = GraphAdapter { g: self };
2916                for &dep_id in dependencies {
2917                    match pk.try_add_edge(&adapter, dep_id, dependent) {
2918                        Ok(_) => {}
2919                        Err(_cycle) => {
2920                            if self.config.pk_reject_cycle_edges {
2921                                skip_deps.insert(dep_id);
2922                            } else {
2923                                pk.rebuild_full(&adapter);
2924                            }
2925                        }
2926                    }
2927                }
2928            } // drop adapter
2929            self.pk_order = Some(pk);
2930        }
2931
2932        // Now mutate engine edges; if rejecting cycles, re-check and skip those that would create cycles
2933        for &dep_id in dependencies {
2934            if self.config.pk_reject_cycle_edges && skip_deps.contains(&dep_id) {
2935                continue;
2936            }
2937            self.edges.add_edge(dependent, dep_id);
2938            #[cfg(test)]
2939            {
2940                if let Ok(mut g) = self.instr.lock() {
2941                    g.edges_added += 1;
2942                }
2943            }
2944        }
2945
2946        self.edges.end_batch();
2947    }
2948
2949    /// Like add_dependent_edges, but assumes caller is managing edges.begin_batch/end_batch
2950    fn add_dependent_edges_nobatch(&mut self, dependent: VertexId, dependencies: &[VertexId]) {
2951        // If PK enabled, update order using a short-lived adapter without holding &mut self
2952        let mut skip_deps: rustc_hash::FxHashSet<VertexId> = rustc_hash::FxHashSet::default();
2953        if self.pk_order.is_some()
2954            && let Some(mut pk) = self.pk_order.take()
2955        {
2956            pk.ensure_nodes(std::iter::once(dependent));
2957            pk.ensure_nodes(dependencies.iter().copied());
2958            {
2959                let adapter = GraphAdapter { g: self };
2960                for &dep_id in dependencies {
2961                    match pk.try_add_edge(&adapter, dep_id, dependent) {
2962                        Ok(_) => {}
2963                        Err(_cycle) => {
2964                            if self.config.pk_reject_cycle_edges {
2965                                skip_deps.insert(dep_id);
2966                            } else {
2967                                pk.rebuild_full(&adapter);
2968                            }
2969                        }
2970                    }
2971                }
2972            }
2973            self.pk_order = Some(pk);
2974        }
2975
2976        for &dep_id in dependencies {
2977            if self.config.pk_reject_cycle_edges && skip_deps.contains(&dep_id) {
2978                continue;
2979            }
2980            self.edges.add_edge(dependent, dep_id);
2981            #[cfg(test)]
2982            {
2983                if let Ok(mut g) = self.instr.lock() {
2984                    g.edges_added += 1;
2985                }
2986            }
2987        }
2988    }
2989
2990    /// Bulk set formulas on a sheet using a single dependency plan and batched edge updates.
2991    pub fn bulk_set_formulas<I>(&mut self, sheet: &str, items: I) -> Result<usize, ExcelError>
2992    where
2993        I: IntoIterator<Item = (u32, u32, ASTNode)>,
2994    {
2995        let collected: Vec<(u32, u32, ASTNode)> = items.into_iter().collect();
2996        if collected.is_empty() {
2997            return Ok(0);
2998        }
2999        let vol_flags: Vec<bool> = collected
3000            .iter()
3001            .map(|(_, _, ast)| self.is_ast_volatile(ast))
3002            .collect();
3003        self.bulk_set_formulas_with_volatility(sheet, collected, vol_flags)
3004    }
3005
3006    pub fn bulk_set_formulas_with_volatility(
3007        &mut self,
3008        sheet: &str,
3009        collected: Vec<(u32, u32, ASTNode)>,
3010        _vol_flags: Vec<bool>,
3011    ) -> Result<usize, ExcelError> {
3012        let sheet_id = self.sheet_id_mut(sheet);
3013        if collected.is_empty() {
3014            return Ok(0);
3015        }
3016        let provider = RegistryFunctionProvider;
3017        let ingested = {
3018            let mut pipeline = self.ingest_pipeline(&provider);
3019            let inputs = collected.into_iter().map(|(row, col, ast)| {
3020                let placement = CellRef::new(sheet_id, Coord::from_excel(row, col, true, true));
3021                (FormulaAstInput::Tree(ast), placement, None)
3022            });
3023            pipeline.ingest_batch(inputs)?
3024        };
3025        let planned = ingested
3026            .into_iter()
3027            .map(|formula| {
3028                (
3029                    formula.placement.coord.row() + 1,
3030                    formula.placement.coord.col() + 1,
3031                    formula.ast_id,
3032                    formula.dep_plan,
3033                )
3034            })
3035            .collect();
3036        self.bulk_set_formulas_with_plans(sheet, planned)
3037    }
3038
3039    pub(crate) fn bulk_set_formulas_with_plans(
3040        &mut self,
3041        sheet: &str,
3042        planned: Vec<(u32, u32, AstNodeId, DependencyPlanRow)>,
3043    ) -> Result<usize, ExcelError> {
3044        let sheet_id = self.sheet_id_mut(sheet);
3045        if planned.is_empty() {
3046            return Ok(0);
3047        }
3048        let budgets = self.self_admission_budgets();
3049        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
3050            let admission_plans = planned
3051                .iter()
3052                .map(|(row, col, _, plan)| (sheet_id, *row, *col, plan.clone()))
3053                .collect::<Vec<_>>();
3054            let usage = self.preview_formula_mutations(&admission_plans)?;
3055            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
3056                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
3057        }
3058        let mut created_placeholders: Vec<CellRef> = Vec::new();
3059        let mut target_vids: Vec<VertexId> = Vec::with_capacity(planned.len());
3060        for (row, col, _, _) in &planned {
3061            let addr = CellRef::new(sheet_id, Coord::from_excel(*row, *col, true, true));
3062            target_vids.push(self.get_or_create_vertex(&addr, &mut created_placeholders));
3063        }
3064        // Create direct-dependency placeholders before edge batching starts. If a formula-plane
3065        // demotion materializes formulas into an otherwise Arrow-only graph, interleaving
3066        // dependency vertex creation with edge insertion forces the CSR delta slab to rebuild on
3067        // every new dependency vertex. Pre-creating these vertices keeps bulk edge insertion O(n).
3068        for (_, _, _, plan) in &planned {
3069            for cell in &plan.direct_cell_deps {
3070                self.get_or_create_vertex(cell, &mut created_placeholders);
3071            }
3072        }
3073
3074        for (i, &tvid) in target_vids.iter().enumerate() {
3075            if self.vertex_formulas.contains_key(&tvid) {
3076                self.remove_dependent_edges(tvid);
3077            }
3078            self.detach_vertex_from_names(tvid);
3079            self.clear_pending_name_references(tvid);
3080            self.store.set_kind(tvid, VertexKind::FormulaScalar);
3081            self.store.set_dirty(tvid, true);
3082            self.vertex_values.remove(&tvid);
3083            self.vertex_formulas.insert(tvid, planned[i].2);
3084            self.mark_volatile(tvid, planned[i].3.volatile);
3085            self.store.set_dynamic(tvid, planned[i].3.dynamic);
3086        }
3087        self.formula_dirty
3088            .legacy_extend(target_vids.iter().copied());
3089
3090        self.edges.begin_batch();
3091        for (i, tvid) in target_vids.iter().copied().enumerate() {
3092            let plan = &planned[i].3;
3093            let mut deps: Vec<VertexId> = Vec::new();
3094            for cell in &plan.direct_cell_deps {
3095                let dep_vid = self.get_or_create_vertex(cell, &mut created_placeholders);
3096                if !deps.contains(&dep_vid) {
3097                    deps.push(dep_vid);
3098                }
3099            }
3100
3101            let mut name_vertices = Vec::new();
3102            for name in plan
3103                .resolved_named_refs
3104                .iter()
3105                .chain(plan.named_refs.iter())
3106            {
3107                if let Some(named) = self.resolve_name_entry(name, sheet_id) {
3108                    if !deps.contains(&named.vertex) {
3109                        deps.push(named.vertex);
3110                    }
3111                    if !name_vertices.contains(&named.vertex) {
3112                        name_vertices.push(named.vertex);
3113                    }
3114                } else if let Some(source) = self.resolve_source_scalar_entry(name) {
3115                    if !deps.contains(&source.vertex) {
3116                        deps.push(source.vertex);
3117                    }
3118                } else {
3119                    self.record_pending_name_reference(sheet_id, name, tvid);
3120                }
3121            }
3122            for source_name in &plan.source_refs {
3123                if let Some(source) = self.resolve_source_scalar_entry(source_name) {
3124                    if !deps.contains(&source.vertex) {
3125                        deps.push(source.vertex);
3126                    }
3127                } else if let Some(source) = self.resolve_source_table_entry(source_name)
3128                    && !deps.contains(&source.vertex)
3129                {
3130                    deps.push(source.vertex);
3131                }
3132            }
3133            for table_name in &plan.table_refs {
3134                if let Some(table) = self.resolve_table_entry(table_name) {
3135                    if !deps.contains(&table.vertex) {
3136                        deps.push(table.vertex);
3137                    }
3138                } else if let Some(source) = self.resolve_source_table_entry(table_name)
3139                    && !deps.contains(&source.vertex)
3140                {
3141                    deps.push(source.vertex);
3142                }
3143            }
3144            if !name_vertices.is_empty() {
3145                self.attach_vertex_to_names(tvid, &name_vertices);
3146            }
3147            if !deps.is_empty() {
3148                self.add_dependent_edges_nobatch(tvid, &deps);
3149            }
3150            self.add_range_dependent_edges(tvid, &plan.range_deps, sheet_id);
3151        }
3152        self.edges.end_batch();
3153
3154        Ok(planned.len())
3155    }
3156
3157    /// Public (crate) helper to add a single dependency edge (dependent -> dependency) used for restoration/undo.
3158    pub fn add_dependency_edge(
3159        &mut self,
3160        dependent: VertexId,
3161        dependency: VertexId,
3162    ) -> Result<(), ExcelError> {
3163        if dependent == dependency {
3164            return Ok(());
3165        }
3166        let budgets = self.self_admission_budgets();
3167        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
3168            let stats = self.baseline_stats();
3169            let added = usize::from(!self.get_dependencies(dependent).contains(&dependency));
3170            crate::engine::resource_ledger::preflight_graph_admission(
3171                &budgets,
3172                crate::engine::resource_ledger::GraphAdmission {
3173                    final_vertices: stats.graph_vertex_count,
3174                    final_edges: stats.graph_edge_count.checked_add(added).ok_or_else(|| {
3175                        ExcelError::new(ExcelErrorKind::NImpl)
3176                            .with_message("graph edge count overflow")
3177                    })?,
3178                    materialization_cells: 0,
3179                    added_vertices: 0,
3180                    added_edges: added,
3181                },
3182                None,
3183            )
3184            .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
3185        }
3186        // If PK enabled attempt to add maintaining ordering; fallback to rebuild if cycle
3187        if self.pk_order.is_some()
3188            && let Some(mut pk) = self.pk_order.take()
3189        {
3190            pk.ensure_nodes(std::iter::once(dependent));
3191            pk.ensure_nodes(std::iter::once(dependency));
3192            let adapter = GraphAdapter { g: self };
3193            if pk.try_add_edge(&adapter, dependency, dependent).is_err() {
3194                // Cycle: rebuild full (conservative)
3195                pk.rebuild_full(&adapter);
3196            }
3197            self.pk_order = Some(pk);
3198        }
3199        self.edges.add_edge(dependent, dependency);
3200        self.store.set_dirty(dependent, true);
3201        self.formula_dirty.legacy_insert(dependent);
3202        Ok(())
3203    }
3204
3205    fn remove_dependent_edges(&mut self, vertex: VertexId) {
3206        // Remove all outgoing edges from this vertex (its dependencies)
3207        let dependencies = self.edges.out_edges(vertex);
3208
3209        self.edges.begin_batch();
3210        if self.pk_order.is_some()
3211            && let Some(mut pk) = self.pk_order.take()
3212        {
3213            for dep in &dependencies {
3214                pk.remove_edge(*dep, vertex);
3215            }
3216            self.pk_order = Some(pk);
3217        }
3218        for dep in dependencies {
3219            self.edges.remove_edge(vertex, dep);
3220        }
3221        self.edges.end_batch();
3222
3223        // Remove range dependencies and clean up stripes
3224        if let Some(old_ranges) = self.formula_to_range_deps.remove(&vertex) {
3225            let old_sheet_id = self.store.sheet_id(vertex);
3226
3227            for range in &old_ranges {
3228                // `Current` is the sheet the moved formula used to live on.
3229                let sheet_id = self
3230                    .sheet_reg
3231                    .resolve_locator(&range.sheet, old_sheet_id)
3232                    .unwrap_or(old_sheet_id);
3233                let s_row = range.start_row.map(|b| b.index);
3234                let e_row = range.end_row.map(|b| b.index);
3235                let s_col = range.start_col.map(|b| b.index);
3236                let e_col = range.end_col.map(|b| b.index);
3237
3238                let mut keys_to_clean = FxHashSet::default();
3239
3240                let col_stripes = (s_row.is_none() && e_row.is_none())
3241                    || (s_col.is_some() && e_col.is_some() && (s_row.is_none() || e_row.is_none()));
3242                let row_stripes = (s_col.is_none() && e_col.is_none())
3243                    || (s_row.is_some() && e_row.is_some() && (s_col.is_none() || e_col.is_none()));
3244
3245                if col_stripes && !row_stripes {
3246                    let sc = s_col.unwrap_or(0);
3247                    let ec = e_col.unwrap_or(sc);
3248                    for col in sc..=ec {
3249                        keys_to_clean.insert(StripeKey {
3250                            sheet_id,
3251                            stripe_type: StripeType::Column,
3252                            index: col,
3253                        });
3254                    }
3255                } else if row_stripes && !col_stripes {
3256                    let sr = s_row.unwrap_or(0);
3257                    let er = e_row.unwrap_or(sr);
3258                    for row in sr..=er {
3259                        keys_to_clean.insert(StripeKey {
3260                            sheet_id,
3261                            stripe_type: StripeType::Row,
3262                            index: row,
3263                        });
3264                    }
3265                } else {
3266                    let start_row = s_row.unwrap_or(0);
3267                    let start_col = s_col.unwrap_or(0);
3268                    let end_row = e_row.unwrap_or(start_row);
3269                    let end_col = e_col.unwrap_or(start_col);
3270
3271                    let height = end_row.saturating_sub(start_row) + 1;
3272                    let width = end_col.saturating_sub(start_col) + 1;
3273
3274                    if self.config.enable_block_stripes && height > 1 && width > 1 {
3275                        let start_block_row = start_row / BLOCK_H;
3276                        let end_block_row = end_row / BLOCK_H;
3277                        let start_block_col = start_col / BLOCK_W;
3278                        let end_block_col = end_col / BLOCK_W;
3279
3280                        for block_row in start_block_row..=end_block_row {
3281                            for block_col in start_block_col..=end_block_col {
3282                                keys_to_clean.insert(StripeKey {
3283                                    sheet_id,
3284                                    stripe_type: StripeType::Block,
3285                                    index: block_index(block_row * BLOCK_H, block_col * BLOCK_W),
3286                                });
3287                            }
3288                        }
3289                    } else if height > width {
3290                        for col in start_col..=end_col {
3291                            keys_to_clean.insert(StripeKey {
3292                                sheet_id,
3293                                stripe_type: StripeType::Column,
3294                                index: col,
3295                            });
3296                        }
3297                    } else {
3298                        for row in start_row..=end_row {
3299                            keys_to_clean.insert(StripeKey {
3300                                sheet_id,
3301                                stripe_type: StripeType::Row,
3302                                index: row,
3303                            });
3304                        }
3305                    }
3306                }
3307
3308                for key in keys_to_clean {
3309                    if let Some(dependents) = self.stripe_to_dependents.get_mut(&key) {
3310                        dependents.remove(&vertex);
3311                        if dependents.is_empty() {
3312                            self.stripe_to_dependents.remove(&key);
3313                            #[cfg(test)]
3314                            {
3315                                if let Ok(mut g) = self.instr.lock() {
3316                                    g.stripe_removes += 1;
3317                                }
3318                            }
3319                        }
3320                    }
3321                }
3322            }
3323        }
3324    }
3325
3326    // Removed: vertices() and get_vertex() methods - no longer needed with SoA
3327    // The old AoS Vertex struct has been eliminated in favor of direct
3328    // access to columnar data through the VertexStore
3329
3330    /// Updates the cached value of a formula vertex.
3331    pub(crate) fn update_vertex_value(&mut self, vertex_id: VertexId, value: LiteralValue) {
3332        if !self.value_cache_enabled && self.is_grid_backed(vertex_id) {
3333            // Canonical mode: grid-backed vertices must not store values in the graph.
3334            // Symbols (e.g. named-range formulas) may still cache theirs.
3335            self.vertex_values.remove(&vertex_id);
3336            return;
3337        }
3338        let value_ref = self.data_store.store_value(normalize_stored_literal(value));
3339        self.vertex_values.insert(vertex_id, value_ref);
3340    }
3341
3342    /// Plan a spill region for an anchor; returns #SPILL! if blocked
3343    pub fn plan_spill_region(
3344        &self,
3345        anchor: VertexId,
3346        target_cells: &[CellRef],
3347    ) -> Result<(), ExcelError> {
3348        self.plan_spill_region_allowing_formula_overwrite(anchor, target_cells, None)
3349    }
3350
3351    /// Plan a spill region, optionally allowing specific formula vertices to be overwritten.
3352    ///
3353    /// This is used by parallel evaluation to allow spill anchors to take precedence over
3354    /// other formula vertices that are being evaluated in the same layer.
3355    pub(crate) fn plan_spill_region_allowing_formula_overwrite(
3356        &self,
3357        anchor: VertexId,
3358        target_cells: &[CellRef],
3359        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
3360    ) -> Result<(), ExcelError> {
3361        use formualizer_common::{ExcelErrorExtra, ExcelErrorKind};
3362        // Compute expected spill shape from the target rectangle for better diagnostics
3363        let (expected_rows, expected_cols) = if target_cells.is_empty() {
3364            (0u32, 0u32)
3365        } else {
3366            let mut min_r = u32::MAX;
3367            let mut max_r = 0u32;
3368            let mut min_c = u32::MAX;
3369            let mut max_c = 0u32;
3370            for cell in target_cells {
3371                let r = cell.coord.row();
3372                let c = cell.coord.col();
3373                if r < min_r {
3374                    min_r = r;
3375                }
3376                if r > max_r {
3377                    max_r = r;
3378                }
3379                if c < min_c {
3380                    min_c = c;
3381                }
3382                if c > max_c {
3383                    max_c = c;
3384                }
3385            }
3386            (
3387                max_r.saturating_sub(min_r).saturating_add(1),
3388                max_c.saturating_sub(min_c).saturating_add(1),
3389            )
3390        };
3391        // Allow overlapping with previously owned spill cells by this anchor
3392        for cell in target_cells {
3393            // If cell is already owned by this anchor's previous spill, it's allowed.
3394            let owned_by_anchor = match self.spill_cell_to_anchor.get(cell) {
3395                Some(&existing_anchor) if existing_anchor == anchor => true,
3396                Some(_other) => {
3397                    return Err(ExcelError::new(ExcelErrorKind::Spill)
3398                        .with_message("BlockedBySpill")
3399                        .with_extra(ExcelErrorExtra::Spill {
3400                            expected_rows,
3401                            expected_cols,
3402                        }));
3403                }
3404                None => false,
3405            };
3406
3407            if owned_by_anchor {
3408                continue;
3409            }
3410
3411            // If cell is occupied by another formula anchor, block unless explicitly allowed.
3412            if let Some(&vid) = self.cell_to_vertex.get(cell)
3413                && vid != anchor
3414            {
3415                // Prevent clobbering formulas (array or scalar) in the target area
3416                match self.store.kind(vid) {
3417                    VertexKind::FormulaScalar | VertexKind::FormulaArray => {
3418                        if let Some(allow) = overwritable_formulas
3419                            && allow.contains(&vid)
3420                        {
3421                            continue;
3422                        }
3423                        return Err(ExcelError::new(ExcelErrorKind::Spill)
3424                            .with_message("BlockedByFormula")
3425                            .with_extra(ExcelErrorExtra::Spill {
3426                                expected_rows,
3427                                expected_cols,
3428                            }));
3429                    }
3430                    _ => {
3431                        // If a non-empty value exists (and not this anchor), block
3432                        if let Some(vref) = self.vertex_values.get(&vid) {
3433                            let v = self.data_store.retrieve_value(*vref);
3434                            if !matches!(v, LiteralValue::Empty) {
3435                                return Err(ExcelError::new(ExcelErrorKind::Spill)
3436                                    .with_message("BlockedByValue")
3437                                    .with_extra(ExcelErrorExtra::Spill {
3438                                        expected_rows,
3439                                        expected_cols,
3440                                    }));
3441                            }
3442                        }
3443                    }
3444                }
3445            }
3446        }
3447        Ok(())
3448    }
3449
3450    // Note: non-atomic commit_spill_region has been removed. All callers must use
3451    // commit_spill_region_atomic_with_fault for atomicity and rollback on failure.
3452
3453    /// Commit a spill atomically with an internal shadow buffer and optional fault injection.
3454    /// If a fault is injected partway through, all changes are rolled back to the pre-commit state.
3455    /// This does not change behavior under normal operation; it's primarily for Phase 3 guarantees and tests.
3456    pub fn commit_spill_region_atomic_with_fault(
3457        &mut self,
3458        anchor: VertexId,
3459        target_cells: Vec<CellRef>,
3460        values: Vec<Vec<LiteralValue>>,
3461        fault_after_ops: Option<usize>,
3462    ) -> Result<(), ExcelError> {
3463        let budgets = self.self_admission_budgets();
3464        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
3465            let admission = self.preview_spill_materialization(&target_cells)?;
3466            crate::engine::resource_ledger::preflight_graph_admission(&budgets, admission, None)
3467                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
3468        }
3469
3470        // Anchor cell coordinates (0-based) for special-casing writes.
3471        // We must never overwrite the anchor via set_cell_value(), because that would
3472        // strip the formula and break incremental recalculation.
3473        let anchor_cell = self
3474            .get_cell_ref(anchor)
3475            .expect("anchor cell ref for spill commit");
3476        let anchor_sheet_name = self.sheet_name(anchor_cell.sheet_id).to_string();
3477        let anchor_row = anchor_cell.coord.row();
3478        let anchor_col = anchor_cell.coord.col();
3479
3480        // Capture previous owned cells for this anchor
3481        let prev_cells = self
3482            .spill_anchor_to_cells
3483            .get(&anchor)
3484            .cloned()
3485            .unwrap_or_default();
3486        // Use CoordBuildHasher on CellRef keys to avoid FxHasher clustering on
3487        // packed Coord values.
3488        let new_set: std::collections::HashSet<CellRef, CoordBuildHasher> =
3489            target_cells.iter().copied().collect();
3490        let prev_set: std::collections::HashSet<CellRef, CoordBuildHasher> =
3491            prev_cells.iter().copied().collect();
3492
3493        // Compose operation list: clears first (prev - new), then writes for new rectangle
3494        #[derive(Clone)]
3495        struct Op {
3496            sheet: String,
3497            row: u32,
3498            col: u32,
3499            new_value: LiteralValue,
3500        }
3501        let mut ops: Vec<Op> = Vec::new();
3502
3503        // Clears for cells no longer used
3504        for cell in prev_cells.iter() {
3505            if !new_set.contains(cell) {
3506                let sheet = self.sheet_name(cell.sheet_id).to_string();
3507                ops.push(Op {
3508                    sheet,
3509                    row: cell.coord.row(),
3510                    col: cell.coord.col(),
3511                    new_value: LiteralValue::Empty,
3512                });
3513            }
3514        }
3515
3516        // Writes for new values (row-major to match target rectangle)
3517        if !target_cells.is_empty() {
3518            let first = target_cells.first().copied().unwrap();
3519            let row0 = first.coord.row();
3520            let col0 = first.coord.col();
3521            let sheet = self.sheet_name(first.sheet_id).to_string();
3522            for (r_off, row_vals) in values.iter().enumerate() {
3523                for (c_off, v) in row_vals.iter().enumerate() {
3524                    ops.push(Op {
3525                        sheet: sheet.clone(),
3526                        row: row0 + r_off as u32,
3527                        col: col0 + c_off as u32,
3528                        new_value: v.clone(),
3529                    });
3530                }
3531            }
3532        }
3533
3534        // Shadow buffer of old values for rollback
3535        #[derive(Clone)]
3536        struct OldVal {
3537            present: bool,
3538            value: LiteralValue,
3539        }
3540        let mut old_values: Vec<((String, u32, u32), OldVal)> = Vec::with_capacity(ops.len());
3541
3542        // Capture old values before applying
3543        for op in &ops {
3544            // op.row/op.col are internal 0-based; get_cell_value is a public 1-based API.
3545            let old = self
3546                .get_cell_value(&op.sheet, op.row + 1, op.col + 1)
3547                .unwrap_or(LiteralValue::Empty);
3548            let present = true; // unified model: we always treat as present
3549            old_values.push((
3550                (op.sheet.clone(), op.row, op.col),
3551                OldVal {
3552                    present,
3553                    value: old,
3554                },
3555            ));
3556        }
3557
3558        // Apply with optional injected fault
3559        for (applied, op) in ops.iter().enumerate() {
3560            if let Some(n) = fault_after_ops
3561                && applied == n
3562            {
3563                for idx in (0..applied).rev() {
3564                    let ((ref sheet, row, col), ref old) = old_values[idx];
3565                    if sheet == &anchor_sheet_name && row == anchor_row && col == anchor_col {
3566                        self.update_vertex_value(anchor, old.value.clone());
3567                    } else {
3568                        let _ = self.set_cell_value(sheet, row + 1, col + 1, old.value.clone());
3569                    }
3570                }
3571                return Err(ExcelError::new(ExcelErrorKind::Error)
3572                    .with_message("Injected persistence fault during spill commit"));
3573            }
3574            if op.sheet == anchor_sheet_name && op.row == anchor_row && op.col == anchor_col {
3575                self.update_vertex_value(anchor, op.new_value.clone());
3576            } else {
3577                let _ =
3578                    self.set_cell_value(&op.sheet, op.row + 1, op.col + 1, op.new_value.clone());
3579            }
3580        }
3581
3582        // Update spill ownership maps only on success
3583        // Clear previous ownership not reused
3584        for cell in prev_cells.iter() {
3585            if !new_set.contains(cell) {
3586                self.spill_cell_to_anchor.remove(cell);
3587                let remove_sheet = self
3588                    .spill_cells_by_sheet
3589                    .get_mut(&cell.sheet_id)
3590                    .is_some_and(|sheet| {
3591                        sheet.remove(&(cell.coord.row(), cell.coord.col()));
3592                        sheet.is_empty()
3593                    });
3594                if remove_sheet {
3595                    self.spill_cells_by_sheet.remove(&cell.sheet_id);
3596                }
3597            }
3598        }
3599        // Mark ownership for new rectangle using the declared target cells only
3600        for cell in &target_cells {
3601            self.spill_cell_to_anchor.insert(*cell, anchor);
3602            self.spill_cells_by_sheet
3603                .entry(cell.sheet_id)
3604                .or_default()
3605                .insert((cell.coord.row(), cell.coord.col()), anchor);
3606        }
3607        self.spill_anchor_to_cells.insert(anchor, target_cells);
3608        Ok(())
3609    }
3610
3611    pub(crate) fn spill_cells_for_anchor(&self, anchor: VertexId) -> Option<&[CellRef]> {
3612        self.spill_anchor_to_cells
3613            .get(&anchor)
3614            .map(|v| v.as_slice())
3615    }
3616
3617    pub(crate) fn spill_registry_has_anchor(&self, anchor: VertexId) -> bool {
3618        self.spill_anchor_to_cells.contains_key(&anchor)
3619    }
3620
3621    pub(crate) fn spill_registry_anchor_for_cell(&self, cell: CellRef) -> Option<VertexId> {
3622        self.spill_cell_to_anchor.get(&cell).copied()
3623    }
3624
3625    pub(crate) fn spill_registry_counts(&self) -> (usize, usize) {
3626        (
3627            self.spill_anchor_to_cells.len(),
3628            self.spill_cell_to_anchor.len(),
3629        )
3630    }
3631
3632    /// Clear an existing spill region for an anchor (set cells to Empty and forget ownership)
3633    pub fn clear_spill_region(&mut self, anchor: VertexId) {
3634        let _ = self.clear_spill_region_bulk(anchor);
3635    }
3636
3637    /// Bulk clear an existing spill region for an anchor.
3638    ///
3639    /// This avoids calling `set_cell_value()` per spill child (which can trigger O(N*V)
3640    /// dependent scans when `edges.delta_size() > 0`). Instead, it clears values directly and
3641    /// performs a single dirty propagation over the affected spill children.
3642    ///
3643    /// Returns the previously registered spill cells (including the anchor cell) for callers that
3644    /// want to mirror/record deltas.
3645    pub fn clear_spill_region_bulk(&mut self, anchor: VertexId) -> Vec<CellRef> {
3646        let anchor_cell = self.get_cell_ref(anchor);
3647        let Some(cells) = self.spill_anchor_to_cells.remove(&anchor) else {
3648            return Vec::new();
3649        };
3650
3651        // Remove ownership for all cells first.
3652        for cell in cells.iter() {
3653            self.spill_cell_to_anchor.remove(cell);
3654            let remove_sheet = self
3655                .spill_cells_by_sheet
3656                .get_mut(&cell.sheet_id)
3657                .is_some_and(|sheet| {
3658                    sheet.remove(&(cell.coord.row(), cell.coord.col()));
3659                    sheet.is_empty()
3660                });
3661            if remove_sheet {
3662                self.spill_cells_by_sheet.remove(&cell.sheet_id);
3663            }
3664        }
3665
3666        // Prepare a single arena value ref for Empty (only when caching is enabled).
3667        let empty_ref = if self.value_cache_enabled {
3668            Some(self.data_store.store_value(LiteralValue::Empty))
3669        } else {
3670            None
3671        };
3672
3673        // Clear all spill children (excluding the anchor cell).
3674        let mut changed_vertices: Vec<VertexId> = Vec::new();
3675        for cell in cells.iter().copied() {
3676            let is_anchor = anchor_cell.map(|a| a == cell).unwrap_or(false);
3677            if is_anchor {
3678                continue;
3679            }
3680            let Some(&vid) = self.cell_to_vertex.get(&cell) else {
3681                continue;
3682            };
3683            // Ensure this vertex is a plain value cell.
3684            if self.vertex_formulas.remove(&vid).is_some() {
3685                // Be conservative: remove outgoing edges if this was a formula vertex.
3686                // This should be rare for spill children under normal policies.
3687                self.remove_dependent_edges(vid);
3688            }
3689            self.store.set_kind(vid, VertexKind::Cell);
3690            if let Some(er) = empty_ref {
3691                self.vertex_values.insert(vid, er);
3692            } else {
3693                self.vertex_values.remove(&vid);
3694            }
3695            self.store.set_dirty(vid, false);
3696            self.formula_dirty.legacy_remove(&vid);
3697            changed_vertices.push(vid);
3698        }
3699
3700        // Single dirty propagation for all changed spill children.
3701        if !changed_vertices.is_empty() {
3702            self.mark_dirty_many_value_cells(&changed_vertices);
3703        }
3704
3705        cells
3706    }
3707
3708    fn mark_dirty_many_value_cells(&mut self, vertex_ids: &[VertexId]) -> Vec<VertexId> {
3709        if vertex_ids.is_empty() {
3710            return Vec::new();
3711        }
3712
3713        // Deferred-dirty scope (e.g. a spill clear inside a batched
3714        // `set_values`): queue the sources for the end-of-scope flush. The
3715        // general `mark_dirty_many` flush handles value-cell sources via its
3716        // per-source kind check, so one pending list serves both entry
3717        // points. (The flush's per-source range-dependent collection is a
3718        // subset of this path's bounding-rect collection, which conservatively
3719        // over-dirties; the per-source union is the exact required set.)
3720        if self.deferred_dirty_depth > 0 {
3721            self.deferred_dirty_pending.extend_from_slice(vertex_ids);
3722            return vertex_ids.to_vec();
3723        }
3724
3725        // Fold pending deltas once so the propagation loop below can use the
3726        // zero-allocation base `in_edges` slices. This is a deliberate
3727        // rebuild-on-read seam: one rebuild per bulk propagation, amortized
3728        // (the per-vertex alternative would allocate a merged Vec per visit).
3729        if self.edges.delta_size() > 0 {
3730            self.edges.rebuild();
3731        }
3732
3733        let mut affected: FxHashSet<VertexId> = FxHashSet::default();
3734        let mut to_visit: Vec<VertexId> = Vec::new();
3735        let mut visited_for_propagation: FxHashSet<VertexId> = FxHashSet::default();
3736
3737        // Value sources are affected but not marked dirty themselves.
3738        for &src in vertex_ids {
3739            affected.insert(src);
3740        }
3741
3742        // Collect initial direct dependents and name dependents.
3743        for &src in vertex_ids {
3744            to_visit.extend(self.edges.in_edges(src));
3745            if let Some(name_set) = self.cell_to_name_dependents.get(&src) {
3746                for &name_vertex in name_set {
3747                    to_visit.push(name_vertex);
3748                }
3749            }
3750        }
3751
3752        // Collect range dependents in bulk using spill rect bounds per sheet.
3753        let mut bounds_by_sheet: FxHashMap<SheetId, (u32, u32, u32, u32)> = FxHashMap::default();
3754        for &src in vertex_ids {
3755            let view = self.store.view(src);
3756            let sid = view.sheet_id();
3757            let r = view.row();
3758            let c = view.col();
3759            bounds_by_sheet
3760                .entry(sid)
3761                .and_modify(|b| {
3762                    b.0 = b.0.min(r);
3763                    b.1 = b.1.max(r);
3764                    b.2 = b.2.min(c);
3765                    b.3 = b.3.max(c);
3766                })
3767                .or_insert((r, r, c, c));
3768        }
3769
3770        for (sid, (sr, er, sc, ec)) in bounds_by_sheet {
3771            to_visit.extend(self.collect_range_dependents_for_rect(sid, sr, sc, er, ec));
3772        }
3773
3774        while let Some(id) = to_visit.pop() {
3775            if !visited_for_propagation.insert(id) {
3776                continue;
3777            }
3778            self.dirty_propagation_visits += 1;
3779            affected.insert(id);
3780            self.store.set_dirty(id, true);
3781            to_visit.extend(self.edges.in_edges(id));
3782            to_visit.extend(self.collect_range_dependents_for_vertex(id));
3783        }
3784
3785        self.formula_dirty.legacy_extend(affected.iter().copied());
3786        affected.into_iter().collect()
3787    }
3788
3789    fn collect_range_dependents_for_vertex(&self, vertex_id: VertexId) -> Vec<VertexId> {
3790        // Only a vertex with a position can sit inside a range. A symbol has none.
3791        let Some(position) = self.store.grid_addr(vertex_id) else {
3792            return Vec::new();
3793        };
3794        self.collect_range_dependents_for_rect(
3795            self.store.sheet_id(vertex_id),
3796            position.row(),
3797            position.col(),
3798            position.row(),
3799            position.col(),
3800        )
3801    }
3802
3803    fn collect_range_dependents_for_rect(
3804        &self,
3805        sheet_id: SheetId,
3806        start_row: u32,
3807        start_col: u32,
3808        end_row: u32,
3809        end_col: u32,
3810    ) -> Vec<VertexId> {
3811        if self.stripe_to_dependents.is_empty() {
3812            return Vec::new();
3813        }
3814        let mut candidates: FxHashSet<VertexId> = FxHashSet::default();
3815
3816        for col in start_col..=end_col {
3817            let key = StripeKey {
3818                sheet_id,
3819                stripe_type: StripeType::Column,
3820                index: col,
3821            };
3822            if let Some(deps) = self.stripe_to_dependents.get(&key) {
3823                candidates.extend(deps);
3824            }
3825        }
3826        for row in start_row..=end_row {
3827            let key = StripeKey {
3828                sheet_id,
3829                stripe_type: StripeType::Row,
3830                index: row,
3831            };
3832            if let Some(deps) = self.stripe_to_dependents.get(&key) {
3833                candidates.extend(deps);
3834            }
3835        }
3836        if self.config.enable_block_stripes {
3837            let br0 = start_row / BLOCK_H;
3838            let br1 = end_row / BLOCK_H;
3839            let bc0 = start_col / BLOCK_W;
3840            let bc1 = end_col / BLOCK_W;
3841            for br in br0..=br1 {
3842                for bc in bc0..=bc1 {
3843                    let key = StripeKey {
3844                        sheet_id,
3845                        stripe_type: StripeType::Block,
3846                        index: block_index(br * BLOCK_H, bc * BLOCK_W),
3847                    };
3848                    if let Some(deps) = self.stripe_to_dependents.get(&key) {
3849                        candidates.extend(deps);
3850                    }
3851                }
3852            }
3853        }
3854
3855        // Precision check: the dirty rect must overlap at least one of the formula's registered ranges.
3856        let mut out: Vec<VertexId> = Vec::new();
3857        for dep_id in candidates {
3858            let Some(ranges) = self.formula_to_range_deps.get(&dep_id) else {
3859                continue;
3860            };
3861            let mut hit = false;
3862            for range in ranges {
3863                // `Current` is the dependent formula's own sheet; an
3864                // unresolvable name keeps the dependent in the candidate set.
3865                let range_sheet_id = self
3866                    .sheet_reg
3867                    .resolve_locator(&range.sheet, self.get_vertex_sheet_id(dep_id))
3868                    .unwrap_or(sheet_id);
3869                if range_sheet_id != sheet_id {
3870                    continue;
3871                }
3872                let sr0 = range.start_row.map(|b| b.index).unwrap_or(0);
3873                let er0 = range.end_row.map(|b| b.index).unwrap_or(u32::MAX);
3874                let sc0 = range.start_col.map(|b| b.index).unwrap_or(0);
3875                let ec0 = range.end_col.map(|b| b.index).unwrap_or(u32::MAX);
3876                let overlap =
3877                    sr0 <= end_row && er0 >= start_row && sc0 <= end_col && ec0 >= start_col;
3878                if overlap {
3879                    hit = true;
3880                    break;
3881                }
3882            }
3883            if hit {
3884                out.push(dep_id);
3885            }
3886        }
3887        out
3888    }
3889
3890    /// Whether `vertex_id` is an existing, non-deleted vertex that still
3891    /// holds a formula (a cell overwritten with a literal keeps its vertex
3892    /// but drops its formula).
3893    pub(crate) fn is_live_formula_vertex(&self, vertex_id: VertexId) -> bool {
3894        self.store.vertex_exists_active(vertex_id) && self.get_formula_id(vertex_id).is_some()
3895    }
3896
3897    /// Check if a vertex exists
3898    pub(crate) fn vertex_exists(&self, vertex_id: VertexId) -> bool {
3899        if vertex_id.0 < FIRST_NORMAL_VERTEX {
3900            return false;
3901        }
3902        let index = (vertex_id.0 - FIRST_NORMAL_VERTEX) as usize;
3903        index < self.store.len()
3904    }
3905
3906    /// Get the kind of a vertex
3907    pub(crate) fn get_vertex_kind(&self, vertex_id: VertexId) -> VertexKind {
3908        self.store.kind(vertex_id)
3909    }
3910
3911    /// Get the sheet ID of a vertex
3912    pub(crate) fn get_vertex_sheet_id(&self, vertex_id: VertexId) -> SheetId {
3913        self.store.sheet_id(vertex_id)
3914    }
3915
3916    pub fn get_formula_id(&self, vertex_id: VertexId) -> Option<AstNodeId> {
3917        self.vertex_formulas.get(&vertex_id).copied()
3918    }
3919
3920    pub(crate) fn formula_vertices(&self) -> Vec<VertexId> {
3921        let mut vertices = self.vertex_formulas.keys().copied().collect::<Vec<_>>();
3922        vertices.sort_unstable();
3923        vertices
3924    }
3925
3926    pub fn get_formula_id_and_volatile(&self, vertex_id: VertexId) -> Option<(AstNodeId, bool)> {
3927        let ast_id = self.get_formula_id(vertex_id)?;
3928        Some((ast_id, self.is_volatile(vertex_id)))
3929    }
3930
3931    pub fn get_formula_node(&self, vertex_id: VertexId) -> Option<&super::arena::AstNodeData> {
3932        let ast_id = self.get_formula_id(vertex_id)?;
3933        self.data_store.get_node(ast_id)
3934    }
3935
3936    pub fn get_formula_node_and_volatile(
3937        &self,
3938        vertex_id: VertexId,
3939    ) -> Option<(&super::arena::AstNodeData, bool)> {
3940        let (ast_id, vol) = self.get_formula_id_and_volatile(vertex_id)?;
3941        let node = self.data_store.get_node(ast_id)?;
3942        Some((node, vol))
3943    }
3944
3945    /// Get the formula AST for a vertex.
3946    ///
3947    /// Not used in hot paths; reconstructs from arena.
3948    pub fn get_formula(&self, vertex_id: VertexId) -> Option<ASTNode> {
3949        let ast_id = self.get_formula_id(vertex_id)?;
3950        self.data_store.retrieve_ast(ast_id, &self.sheet_reg)
3951    }
3952
3953    /// Get the value stored for a vertex
3954    pub fn get_value(&self, vertex_id: VertexId) -> Option<LiteralValue> {
3955        if !self.value_cache_enabled && self.is_grid_backed(vertex_id) {
3956            // In canonical mode, grid-backed values must not be read from the graph.
3957            // Symbols (named ranges, tables, external sources) may still use graph storage.
3958            #[cfg(debug_assertions)]
3959            {
3960                self.graph_value_read_attempts
3961                    .fetch_add(1, Ordering::Relaxed);
3962            }
3963            return None;
3964        }
3965        self.vertex_values
3966            .get(&vertex_id)
3967            .map(|&value_ref| self.data_store.retrieve_value(value_ref))
3968    }
3969
3970    /// True when the vertex occupies the grid, i.e. it is a cell, formula or empty
3971    /// placeholder rather than a symbol.
3972    ///
3973    /// This replaces the `VertexKind` enumerations that used to spell out the grid-backed
3974    /// kinds. "Has a position" is now a structural property of the address, so it cannot
3975    /// drift out of step with the set of kinds.
3976    #[inline]
3977    fn is_grid_backed(&self, vertex_id: VertexId) -> bool {
3978        self.store.grid_addr(vertex_id).is_some()
3979    }
3980
3981    /// Get the cell reference for a vertex.
3982    ///
3983    /// Returns `None` for symbol vertices (names, tables, external sources): they are
3984    /// identified by name and have no position, so there is no address to return.
3985    pub(crate) fn get_cell_ref(&self, vertex_id: VertexId) -> Option<CellRef> {
3986        let grid = self.store.grid_addr(vertex_id)?;
3987        let sheet_id = self.store.sheet_id(vertex_id);
3988        let coord = Coord::new(grid.row(), grid.col(), true, true);
3989        Some(CellRef::new(sheet_id, coord))
3990    }
3991
3992    /// Create a cell reference (helper for internal use)
3993    pub(crate) fn make_cell_ref_internal(&self, sheet_id: SheetId, row: u32, col: u32) -> CellRef {
3994        let coord = Coord::new(row, col, true, true);
3995        CellRef::new(sheet_id, coord)
3996    }
3997
3998    /// Create a cell reference from sheet name and Excel 1-based coordinates.
3999    pub fn make_cell_ref(&self, sheet_name: &str, row: u32, col: u32) -> CellRef {
4000        let sheet_id = self.sheet_reg.get_id(sheet_name).unwrap_or(0);
4001        let coord = Coord::from_excel(row, col, true, true);
4002        CellRef::new(sheet_id, coord)
4003    }
4004
4005    /// Check if a vertex is dirty
4006    pub(crate) fn is_dirty(&self, vertex_id: VertexId) -> bool {
4007        self.store.is_dirty(vertex_id)
4008    }
4009
4010    /// Check if a vertex is volatile
4011    pub(crate) fn is_volatile(&self, vertex_id: VertexId) -> bool {
4012        self.store.is_volatile(vertex_id)
4013    }
4014
4015    pub(crate) fn is_dynamic(&self, vertex_id: VertexId) -> bool {
4016        self.store.is_dynamic(vertex_id)
4017    }
4018
4019    /// Get vertex ID for a cell address
4020    pub fn get_vertex_id_for_address(&self, addr: &CellRef) -> Option<&VertexId> {
4021        self.cell_to_vertex.get(addr)
4022    }
4023
4024    #[cfg(test)]
4025    pub fn cell_to_vertex(
4026        &self,
4027    ) -> &std::collections::HashMap<CellRef, VertexId, CoordBuildHasher> {
4028        &self.cell_to_vertex
4029    }
4030
4031    /// Borrow dependencies of a vertex when no pending edge delta exists.
4032    ///
4033    /// This enables zero-allocation traversal in hot scheduler paths.
4034    #[inline]
4035    pub(crate) fn dependencies_slice(&self, vertex_id: VertexId) -> Option<&[VertexId]> {
4036        self.edges.out_edges_ref(vertex_id)
4037    }
4038
4039    /// Get the dependencies of a vertex (for scheduler)
4040    pub(crate) fn get_dependencies(&self, vertex_id: VertexId) -> Vec<VertexId> {
4041        self.edges.out_edges(vertex_id)
4042    }
4043
4044    /// Check if a vertex has a self-loop
4045    pub(crate) fn has_self_loop(&self, vertex_id: VertexId) -> bool {
4046        if let Some(deps) = self.dependencies_slice(vertex_id) {
4047            deps.contains(&vertex_id)
4048        } else {
4049            self.edges.out_edges(vertex_id).contains(&vertex_id)
4050        }
4051    }
4052
4053    /// Borrow dependents of a vertex when no pending edge delta exists.
4054    ///
4055    /// This enables zero-allocation traversal in hot scheduler paths.
4056    #[inline]
4057    pub(crate) fn dependents_slice(&self, vertex_id: VertexId) -> Option<&[VertexId]> {
4058        self.edges.in_edges_ref(vertex_id)
4059    }
4060
4061    /// Get dependents of a vertex (vertices that depend on this vertex)
4062    ///
4063    /// Delta-aware: pending edge mutations that have not been folded into the
4064    /// CSR base yet are merged in via the delta slab's reverse index, so this
4065    /// is O(in-degree) even mid-edit (no O(V) scan, no forced rebuild; #125).
4066    pub(crate) fn get_dependents(&self, vertex_id: VertexId) -> Vec<VertexId> {
4067        self.edges.in_edges_merged(vertex_id)
4068    }
4069
4070    /// Bounded, delta-aware incoming-edge visitor used by read-only
4071    /// introspection. Unlike `get_dependents`, this never constructs the full
4072    /// in-degree before the caller's work limit can stop discovery.
4073    pub(crate) fn visit_direct_dependents_bounded(
4074        &self,
4075        vertex_id: VertexId,
4076        remaining_work: &mut u64,
4077        visitor: &mut dyn FnMut(VertexId) -> bool,
4078    ) -> bool {
4079        self.edges
4080            .visit_in_edges_bounded(vertex_id, remaining_work, visitor)
4081    }
4082
4083    // Internal helper methods for Milestone 0.4
4084
4085    /// Internal: Create a snapshot of vertex state for rollback
4086    #[doc(hidden)]
4087    pub fn snapshot_vertex(&self, id: VertexId) -> crate::engine::VertexSnapshot {
4088        let coord = self.store.grid_addr(id).unwrap_or_default();
4089        let sheet_id = self.store.sheet_id(id);
4090        let kind = self.store.kind(id);
4091        let flags = self.store.flags(id);
4092
4093        // Get value and formula references
4094        let value_ref = self.vertex_values.get(&id).copied();
4095        let formula_ref = self.vertex_formulas.get(&id).copied();
4096
4097        // Get outgoing edges (dependencies)
4098        let out_edges = self.get_dependencies(id);
4099
4100        crate::engine::VertexSnapshot {
4101            coord,
4102            sheet_id,
4103            kind,
4104            flags,
4105            value_ref,
4106            formula_ref,
4107            out_edges,
4108        }
4109    }
4110
4111    /// Internal: Remove all edges for a vertex
4112    #[doc(hidden)]
4113    pub fn remove_all_edges(&mut self, id: VertexId) {
4114        // Enter batch mode to avoid intermediate rebuilds
4115        self.edges.begin_batch();
4116
4117        // Remove outgoing edges (this vertex's dependencies)
4118        self.remove_dependent_edges(id);
4119
4120        // Remove incoming edges (vertices that depend on this vertex).
4121        // get_dependents is delta-aware, so no rebuild is needed here (#125).
4122        let dependents = self.get_dependents(id);
4123        if self.pk_order.is_some()
4124            && let Some(mut pk) = self.pk_order.take()
4125        {
4126            for dependent in &dependents {
4127                pk.remove_edge(id, *dependent);
4128            }
4129            self.pk_order = Some(pk);
4130        }
4131        for dependent in dependents {
4132            self.edges.remove_edge(dependent, id);
4133        }
4134
4135        // Exit batch mode and rebuild once with all changes
4136        self.edges.end_batch();
4137    }
4138
4139    /// Internal: Mark vertex as having #REF! error
4140    #[doc(hidden)]
4141    pub fn mark_as_ref_error(&mut self, id: VertexId) {
4142        if !self.value_cache_enabled && self.is_grid_backed(id) {
4143            self.ref_error_vertices.insert(id);
4144            // Canonical-only: graph does not cache grid-backed values.
4145            // Ensure the dependent subgraph is dirtied so evaluation updates Arrow truth.
4146            self.vertex_values.remove(&id);
4147            let _ = self.mark_dirty(id);
4148            return;
4149        }
4150        let error = LiteralValue::Error(ExcelError::new(ExcelErrorKind::Ref));
4151        let value_ref = self.data_store.store_value(error);
4152        self.vertex_values.insert(id, value_ref);
4153        let _ = self.mark_dirty(id);
4154    }
4155
4156    /// Check if a vertex has a #REF! error
4157    pub fn is_ref_error(&self, id: VertexId) -> bool {
4158        if !self.value_cache_enabled && self.is_grid_backed(id) {
4159            return self.ref_error_vertices.contains(&id);
4160        }
4161        if let Some(value_ref) = self.vertex_values.get(&id) {
4162            let value = self.data_store.retrieve_value(*value_ref);
4163            if let LiteralValue::Error(err) = value {
4164                return err.kind == ExcelErrorKind::Ref;
4165            }
4166        }
4167        false
4168    }
4169
4170    /// Internal: Mark all direct dependents as dirty
4171    #[doc(hidden)]
4172    pub fn mark_dependents_dirty(&mut self, id: VertexId) {
4173        let dependents = self.get_dependents(id);
4174        for dep_id in dependents {
4175            self.store.set_dirty(dep_id, true);
4176            self.formula_dirty.legacy_insert(dep_id);
4177        }
4178    }
4179
4180    /// Internal: Mark a vertex as volatile
4181    #[doc(hidden)]
4182    pub fn mark_volatile(&mut self, id: VertexId, volatile: bool) {
4183        self.store.set_volatile(id, volatile);
4184        if volatile {
4185            self.volatile_vertices.insert(id);
4186        } else {
4187            self.volatile_vertices.remove(&id);
4188        }
4189    }
4190
4191    /// Move a vertex to a new grid position.
4192    ///
4193    /// Takes a `GridAddr`, so a symbol vertex cannot be shifted onto the grid by a
4194    /// structural edit (#304).
4195    #[doc(hidden)]
4196    pub fn set_grid_addr(&mut self, id: VertexId, coord: GridAddr) {
4197        self.store.set_addr(id, VertexAddr::grid(coord));
4198    }
4199
4200    /// Update edge cache coordinate
4201    #[doc(hidden)]
4202    pub fn update_edge_grid_addr(&mut self, id: VertexId, coord: GridAddr) {
4203        self.edges.update_addr(id, VertexAddr::grid(coord));
4204    }
4205
4206    /// Mark vertex as deleted (tombstone)
4207    #[doc(hidden)]
4208    pub fn mark_deleted(&mut self, id: VertexId, deleted: bool) {
4209        self.store.mark_deleted(id, deleted);
4210    }
4211
4212    /// Set vertex kind
4213    #[doc(hidden)]
4214    pub fn set_kind(&mut self, id: VertexId, kind: VertexKind) {
4215        self.store.set_kind(id, kind);
4216    }
4217
4218    /// Set vertex dirty flag
4219    #[doc(hidden)]
4220    pub fn set_dirty(&mut self, id: VertexId, dirty: bool) {
4221        self.store.set_dirty(id, dirty);
4222        if dirty {
4223            self.formula_dirty.legacy_insert(id);
4224        } else {
4225            self.formula_dirty.legacy_remove(&id);
4226        }
4227    }
4228
4229    /// Get vertex kind (for testing)
4230    #[cfg(test)]
4231    pub(crate) fn get_kind(&self, id: VertexId) -> VertexKind {
4232        self.store.kind(id)
4233    }
4234
4235    /// Get vertex flags (for testing)
4236    #[cfg(test)]
4237    pub(crate) fn get_flags(&self, id: VertexId) -> u8 {
4238        self.store.flags(id)
4239    }
4240
4241    /// Check if vertex is deleted (for testing)
4242    #[cfg(test)]
4243    pub(crate) fn is_deleted(&self, id: VertexId) -> bool {
4244        self.store.is_deleted(id)
4245    }
4246
4247    /// Force edge rebuild (internal use)
4248    #[doc(hidden)]
4249    pub fn rebuild_edges(&mut self) {
4250        self.edges.rebuild();
4251    }
4252
4253    /// Fold pending edge deltas into the CSR base ahead of a read-heavy phase
4254    /// (scheduling/evaluation), restoring the zero-allocation slice fast
4255    /// paths. No-op when no deltas are pending. This is the read-side half of
4256    /// the #125 amortization: writes defer rebuilds, read bursts pay for at
4257    /// most one.
4258    pub fn flush_pending_edge_deltas(&mut self) {
4259        self.edges.rebuild();
4260    }
4261
4262    /// Get delta size (internal use)
4263    #[doc(hidden)]
4264    pub fn edges_delta_size(&self) -> usize {
4265        self.edges.delta_size()
4266    }
4267
4268    /// Number of full CSR rebuilds performed so far (observability; used by
4269    /// the #125 rebuild-amortization regression tests).
4270    #[doc(hidden)]
4271    pub fn edges_rebuild_count(&self) -> u64 {
4272        self.edges.rebuild_count()
4273    }
4274
4275    /// Get vertex ID for specific cell address
4276    pub fn get_vertex_for_cell(&self, addr: &CellRef) -> Option<VertexId> {
4277        self.cell_to_vertex.get(addr).copied()
4278    }
4279
4280    /// Get the grid position of a vertex (public for VertexEditor).
4281    ///
4282    /// `None` for symbol vertices, which have no position. Structural operations iterate
4283    /// grid positions, so this is what keeps them away from names, tables and sources.
4284    pub fn get_grid_addr(&self, id: VertexId) -> Option<GridAddr> {
4285        self.store.grid_addr(id)
4286    }
4287
4288    /// Get sheet_id for a vertex (public for VertexEditor)
4289    pub fn get_sheet_id(&self, id: VertexId) -> SheetId {
4290        self.store.sheet_id(id)
4291    }
4292
4293    /// Get every grid-resident vertex on a sheet, paired with its position.
4294    ///
4295    /// Symbol vertices (names, tables, external sources) are structurally absent: they have
4296    /// no grid position, so they cannot be produced here. Structural edits drive off this
4297    /// iterator, which is why a row or column operation can no longer delete or shift a
4298    /// name vertex (#302, #304).
4299    pub fn grid_vertices_in_sheet(
4300        &self,
4301        sheet_id: SheetId,
4302    ) -> impl Iterator<Item = (VertexId, GridAddr)> + '_ {
4303        self.store.all_vertices().filter_map(move |id| {
4304            if !self.vertex_exists(id) || self.store.sheet_id(id) != sheet_id {
4305                return None;
4306            }
4307            self.store.grid_addr(id).map(|addr| (id, addr))
4308        })
4309    }
4310
4311    /// Does a vertex have a formula associated
4312    pub fn vertex_has_formula(&self, id: VertexId) -> bool {
4313        self.vertex_formulas.contains_key(&id)
4314    }
4315
4316    /// Get all vertices with formulas
4317    pub fn vertices_with_formulas(&self) -> impl Iterator<Item = VertexId> + '_ {
4318        self.vertex_formulas.keys().copied()
4319    }
4320
4321    /// Update a vertex's formula
4322    pub fn update_vertex_formula(&mut self, id: VertexId, ast: ASTNode) -> Result<(), ExcelError> {
4323        // Get the sheet_id for this vertex
4324        let sheet_id = self.store.sheet_id(id);
4325
4326        // Extract dependencies from AST, retaining unresolved names for later linking.
4327        let (new_dependencies, new_range_dependencies, _, named_dependencies, unresolved_names) =
4328            self.extract_dependencies_with_pending_names(&ast, sheet_id)?;
4329
4330        let old_kind = self.store.kind(id);
4331
4332        // Remove all links owned by the previous formula.
4333        self.remove_dependent_edges(id);
4334        self.detach_vertex_from_names(id);
4335        self.clear_pending_name_references(id);
4336
4337        // Store the new formula
4338        let ast_id = self.data_store.store_ast(&ast, &self.sheet_reg);
4339        self.vertex_formulas.insert(id, ast_id);
4340
4341        // Add new dependency edges
4342        self.add_dependent_edges(id, &new_dependencies);
4343        self.add_range_dependent_edges(id, &new_range_dependencies, sheet_id);
4344
4345        if !named_dependencies.is_empty() {
4346            self.attach_vertex_to_names(id, &named_dependencies);
4347        }
4348        for unresolved_name in &unresolved_names {
4349            self.record_pending_name_reference(sheet_id, unresolved_name, id);
4350        }
4351
4352        // Formula replacement supersedes any structural error/cache state left when a
4353        // deleted dependency marked this vertex before its AST was rewritten.
4354        self.ref_error_vertices.remove(&id);
4355        self.vertex_values.remove(&id);
4356
4357        // A structural rewrite must not collapse an existing array formula kind.
4358        self.store.set_kind(
4359            id,
4360            if old_kind == VertexKind::FormulaArray {
4361                VertexKind::FormulaArray
4362            } else {
4363                VertexKind::FormulaScalar
4364            },
4365        );
4366
4367        Ok(())
4368    }
4369
4370    /// Mark a vertex as dirty without propagation (for VertexEditor)
4371    pub fn mark_vertex_dirty(&mut self, vertex_id: VertexId) {
4372        self.store.set_dirty(vertex_id, true);
4373        self.formula_dirty.legacy_insert(vertex_id);
4374    }
4375
4376    /// Batch-mark vertices dirty without propagation.
4377    pub fn mark_vertices_dirty_batch(&mut self, vertices: &[VertexId]) {
4378        self.formula_dirty.legacy_reserve(vertices.len());
4379        for &vertex_id in vertices {
4380            self.store.set_dirty(vertex_id, true);
4381        }
4382        self.formula_dirty.legacy_extend(vertices.iter().copied());
4383    }
4384
4385    /// Update cell mapping for a vertex (for VertexEditor)
4386    pub fn update_cell_mapping(
4387        &mut self,
4388        id: VertexId,
4389        old_addr: Option<CellRef>,
4390        new_addr: CellRef,
4391    ) {
4392        // Remove old mapping if it exists
4393        if let Some(old) = old_addr {
4394            self.cell_to_vertex.remove(&old);
4395        }
4396        // Add new mapping
4397        self.cell_to_vertex.insert(new_addr, id);
4398    }
4399
4400    /// Remove cell mapping (for VertexEditor)
4401    pub fn remove_cell_mapping(&mut self, addr: &CellRef) {
4402        self.cell_to_vertex.remove(addr);
4403    }
4404
4405    /// Get the cell reference for a vertex
4406    pub fn get_cell_ref_for_vertex(&self, id: VertexId) -> Option<CellRef> {
4407        let coord = self.store.grid_addr(id)?;
4408        let sheet_id = self.store.sheet_id(id);
4409        // Find the cell reference in the mapping
4410        let cell_ref = CellRef::new(sheet_id, Coord::new(coord.row(), coord.col(), true, true));
4411        // Verify it actually maps to this vertex
4412        if self.cell_to_vertex.get(&cell_ref) == Some(&id) {
4413            Some(cell_ref)
4414        } else {
4415            None
4416        }
4417    }
4418
4419    /// Rebuild dependency edges/range links for an existing formula vertex after AST changes.
4420    ///
4421    /// This intentionally reuses the same extraction and edge wiring machinery as
4422    /// `set_cell_formula[_with_volatility]` to preserve edge orientation, placeholder
4423    /// behavior, and name/range dependency semantics.
4424    pub(crate) fn rebuild_formula_dependencies(&mut self, vertex_id: VertexId, ast: &ASTNode) {
4425        let sheet_id = self.store.sheet_id(vertex_id);
4426
4427        // Remove old dependency, name, and pending-name links first.
4428        self.remove_dependent_edges(vertex_id);
4429        self.detach_vertex_from_names(vertex_id);
4430        self.clear_pending_name_references(vertex_id);
4431
4432        let (
4433            new_dependencies,
4434            new_range_dependencies,
4435            _created_placeholders,
4436            named_dependencies,
4437            unresolved_names,
4438        ) = match self.extract_dependencies_with_pending_names(ast, sheet_id) {
4439            Ok(v) => v,
4440            Err(_) => {
4441                self.mark_as_ref_error(vertex_id);
4442                return;
4443            }
4444        };
4445
4446        // Self-reference / name-cycle safety parity with set_cell_formula
4447        // (including the `CyclePolicy::Iterate` self-dependency relaxation).
4448        if new_dependencies.contains(&vertex_id) && !self.config.cycle.allows_self_dependency() {
4449            self.mark_as_ref_error(vertex_id);
4450            return;
4451        }
4452
4453        for &name_vertex in &named_dependencies {
4454            let mut visited = FxHashSet::default();
4455            if self.name_depends_on_vertex(name_vertex, vertex_id, &mut visited) {
4456                self.mark_as_ref_error(vertex_id);
4457                return;
4458            }
4459        }
4460
4461        // Formula is now recoverable again.
4462        self.ref_error_vertices.remove(&vertex_id);
4463        self.vertex_values.remove(&vertex_id);
4464
4465        if !named_dependencies.is_empty() {
4466            self.attach_vertex_to_names(vertex_id, &named_dependencies);
4467        }
4468        for unresolved_name in &unresolved_names {
4469            self.record_pending_name_reference(sheet_id, unresolved_name, vertex_id);
4470        }
4471
4472        self.add_dependent_edges(vertex_id, &new_dependencies);
4473        self.add_range_dependent_edges(vertex_id, &new_range_dependencies, sheet_id);
4474        let _ = self.mark_dirty(vertex_id);
4475    }
4476}
4477
4478// ========== Sheet Management Operations ==========