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