Skip to main content

formualizer_eval/engine/graph/
mod.rs

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