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                // `Current` is the formula's own sheet.
1753                let range_sheet = self
1754                    .sheet_reg
1755                    .resolve_locator(&range.sheet, *sheet_id)
1756                    .unwrap_or(*sheet_id);
1757                range_sheet == *sheet_id
1758                    && range
1759                        .start_row
1760                        .is_none_or(|bound| target_row >= bound.index)
1761                    && range.end_row.is_none_or(|bound| target_row <= bound.index)
1762                    && range
1763                        .start_col
1764                        .is_none_or(|bound| target_col >= bound.index)
1765                    && range.end_col.is_none_or(|bound| target_col <= bound.index)
1766            }) {
1767                dependencies.insert((1, target.as_u64()));
1768            }
1769            added_edges = added_edges.checked_add(dependencies.len()).ok_or_else(|| {
1770                ExcelError::new(ExcelErrorKind::NImpl).with_message("graph edge count overflow")
1771            })?;
1772        }
1773        let stats = self.baseline_stats();
1774        Ok(crate::engine::resource_ledger::GraphAdmission {
1775            final_vertices: stats
1776                .graph_vertex_count
1777                .checked_add(new_cells.len())
1778                .ok_or_else(|| {
1779                    ExcelError::new(ExcelErrorKind::NImpl)
1780                        .with_message("graph vertex count overflow")
1781                })?,
1782            final_edges: stats
1783                .graph_edge_count
1784                .checked_sub(removed_edges)
1785                .and_then(|count| count.checked_add(added_edges))
1786                .ok_or_else(|| {
1787                    ExcelError::new(ExcelErrorKind::NImpl).with_message("graph edge count overflow")
1788                })?,
1789            materialization_cells: plans.len() as u64,
1790            added_vertices: new_cells.len(),
1791            added_edges,
1792        })
1793    }
1794
1795    pub(crate) fn vertices_in_region(
1796        &self,
1797        sheet_id: SheetId,
1798        start_row0: u32,
1799        end_row0: u32,
1800        start_col0: u32,
1801        end_col0: u32,
1802    ) -> Vec<VertexId> {
1803        self.sheet_indexes
1804            .get(&sheet_id)
1805            .map_or_else(Vec::new, |index| {
1806                index.vertices_in_rect(start_row0, end_row0, start_col0, end_col0)
1807            })
1808    }
1809
1810    #[cfg(test)]
1811    pub(crate) fn reset_sheet_index_query_stats(&self) {
1812        for index in self.sheet_indexes.values() {
1813            index.reset_query_stats();
1814        }
1815    }
1816
1817    #[cfg(test)]
1818    pub(crate) fn sheet_index_query_stats(
1819        &self,
1820    ) -> crate::engine::sheet_index::SheetIndexQueryStats {
1821        self.sheet_indexes.values().fold(
1822            crate::engine::sheet_index::SheetIndexQueryStats::default(),
1823            |mut total, index| {
1824                let stats = index.query_stats();
1825                total.coordinate_nodes_visited = total
1826                    .coordinate_nodes_visited
1827                    .saturating_add(stats.coordinate_nodes_visited);
1828                total.values_visited = total.values_visited.saturating_add(stats.values_visited);
1829                total
1830            },
1831        )
1832    }
1833
1834    /// Set a value in a cell, returns affected vertex IDs
1835    pub fn set_cell_value(
1836        &mut self,
1837        sheet: &str,
1838        row: u32,
1839        col: u32,
1840        value: LiteralValue,
1841    ) -> Result<OperationSummary, ExcelError> {
1842        let value = normalize_stored_literal(value);
1843        let sheet_id = self.sheet_id_mut(sheet);
1844        let budgets = self.self_admission_budgets();
1845        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
1846            let usage = self.preview_value_mutation(sheet_id, row, col)?;
1847            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
1848                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
1849        }
1850        // External API is 1-based; store 0-based coords internally.
1851        let coord = Coord::from_excel(row, col, true, true);
1852        let addr = CellRef::new(sheet_id, coord);
1853        let mut created_placeholders = Vec::new();
1854
1855        let vertex_id = if let Some(&existing_id) = self.cell_to_vertex.get(&addr) {
1856            // Check if it was a formula and remove dependencies
1857            let is_formula = matches!(
1858                self.store.kind(existing_id),
1859                VertexKind::FormulaScalar | VertexKind::FormulaArray
1860            );
1861
1862            if is_formula {
1863                self.remove_dependent_edges(existing_id);
1864                self.detach_vertex_from_names(existing_id);
1865                self.clear_pending_name_references(existing_id);
1866                self.vertex_formulas.remove(&existing_id);
1867            }
1868
1869            // Update to value kind
1870            self.store.set_kind(existing_id, VertexKind::Cell);
1871            if self.value_cache_enabled {
1872                let value_ref = self.data_store.store_value(value);
1873                self.vertex_values.insert(existing_id, value_ref);
1874            } else {
1875                // Ensure no stale payload remains if cache is disabled.
1876                self.vertex_values.remove(&existing_id);
1877            }
1878            existing_id
1879        } else {
1880            // Create new vertex
1881            created_placeholders.push(addr);
1882            let packed_coord = AbsCoord::from_excel(row, col);
1883            let vertex_id = self.store.allocate(packed_coord, sheet_id, 0x01); // dirty flag
1884
1885            // Add vertex coordinate for CSR
1886            self.edges.add_vertex(packed_coord, vertex_id.0);
1887
1888            // Add to sheet index for O(log n + k) range queries
1889            self.sheet_index_mut(sheet_id)
1890                .add_vertex(packed_coord, vertex_id);
1891
1892            self.store.set_kind(vertex_id, VertexKind::Cell);
1893            if self.value_cache_enabled {
1894                let value_ref = self.data_store.store_value(value);
1895                self.vertex_values.insert(vertex_id, value_ref);
1896            }
1897            self.cell_to_vertex.insert(addr, vertex_id);
1898            vertex_id
1899        };
1900
1901        // Cell edits clear any structural #REF! marking for this vertex.
1902        self.ref_error_vertices.remove(&vertex_id);
1903
1904        Ok(OperationSummary {
1905            affected_vertices: self.mark_dirty(vertex_id),
1906            created_placeholders,
1907        })
1908    }
1909
1910    /// Reserve capacity hints for upcoming bulk cell inserts (values only for now).
1911    pub fn reserve_cells(&mut self, additional: usize) {
1912        self.store.reserve(additional);
1913        if self.value_cache_enabled {
1914            self.vertex_values.reserve(additional);
1915        }
1916        self.cell_to_vertex.reserve(additional);
1917        // sheet_indexes: cannot easily reserve per-sheet without distribution; skip.
1918    }
1919
1920    /// Fast path for initial bulk load of value cells: avoids dirty propagation & dependency work.
1921    pub fn set_cell_value_bulk_untracked(
1922        &mut self,
1923        sheet: &str,
1924        row: u32,
1925        col: u32,
1926        value: LiteralValue,
1927    ) -> Result<(), ExcelError> {
1928        let value = normalize_stored_literal(value);
1929        let sheet_id = self.sheet_id_mut(sheet);
1930        let budgets = self.self_admission_budgets();
1931        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
1932            let usage = self.preview_value_mutation(sheet_id, row, col)?;
1933            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
1934                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
1935        }
1936        let coord = Coord::from_excel(row, col, true, true);
1937        let addr = CellRef::new(sheet_id, coord);
1938        if let Some(&existing_id) = self.cell_to_vertex.get(&addr) {
1939            // Overwrite existing value vertex only (ignore formulas in bulk path)
1940            if matches!(
1941                self.store.kind(existing_id),
1942                VertexKind::FormulaScalar | VertexKind::FormulaArray
1943            ) {
1944                self.remove_dependent_edges(existing_id);
1945                self.detach_vertex_from_names(existing_id);
1946                self.clear_pending_name_references(existing_id);
1947                self.vertex_formulas.remove(&existing_id);
1948            }
1949            if self.value_cache_enabled {
1950                let value_ref = self.data_store.store_value(value);
1951                self.vertex_values.insert(existing_id, value_ref);
1952            } else {
1953                self.vertex_values.remove(&existing_id);
1954            }
1955            self.store.set_kind(existing_id, VertexKind::Cell);
1956            self.ref_error_vertices.remove(&existing_id);
1957            return Ok(());
1958        }
1959        let packed_coord = AbsCoord::from_excel(row, col);
1960        let vertex_id = self.store.allocate(packed_coord, sheet_id, 0x00); // not dirty
1961        self.edges.add_vertex(packed_coord, vertex_id.0);
1962        self.sheet_index_mut(sheet_id)
1963            .add_vertex(packed_coord, vertex_id);
1964        self.store.set_kind(vertex_id, VertexKind::Cell);
1965        self.ref_error_vertices.remove(&vertex_id);
1966        if self.value_cache_enabled {
1967            let value_ref = self.data_store.store_value(value);
1968            self.vertex_values.insert(vertex_id, value_ref);
1969        }
1970        self.cell_to_vertex.insert(addr, vertex_id);
1971        Ok(())
1972    }
1973
1974    /// Bulk insert a collection of plain value cells (no formulas) more efficiently.
1975    pub fn bulk_insert_values<I>(&mut self, sheet: &str, cells: I) -> Result<(), ExcelError>
1976    where
1977        I: IntoIterator<Item = (u32, u32, LiteralValue)>,
1978    {
1979        use crate::instant::FzInstant as Instant;
1980        let t0 = Instant::now();
1981        // Collect first to know size
1982        let collected: Vec<(u32, u32, LiteralValue)> = cells.into_iter().collect();
1983        if collected.is_empty() {
1984            return Ok(());
1985        }
1986        let sheet_id = self.sheet_id_mut(sheet);
1987        let budgets = self.self_admission_budgets();
1988        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
1989            let coordinates = collected
1990                .iter()
1991                .map(|(row, col, _)| (*row, *col))
1992                .collect::<Vec<_>>();
1993            let usage = self.preview_value_mutations(sheet_id, &coordinates)?;
1994            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
1995                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
1996        }
1997        self.reserve_cells(collected.len());
1998        let t_reserve = Instant::now();
1999        let mut new_vertices: Vec<(AbsCoord, u32)> = Vec::with_capacity(collected.len());
2000        let mut index_items: Vec<(AbsCoord, VertexId)> = Vec::with_capacity(collected.len());
2001        // For new allocations, accumulate values and assign after a single batch store
2002        let mut new_value_coords: Vec<(AbsCoord, VertexId)> = Vec::with_capacity(collected.len());
2003        let mut new_value_literals: Vec<LiteralValue> = Vec::with_capacity(collected.len());
2004        // Detect fast path: during initial ingest, caller may guarantee most cells are new.
2005        let assume_new = self.first_load_assume_new
2006            && self
2007                .sheet_id(sheet)
2008                .map(|sid| !self.ensure_touched_sheets.contains(&sid))
2009                .unwrap_or(false);
2010
2011        for (row, col, value) in collected {
2012            let value = normalize_stored_literal(value);
2013            let coord = Coord::from_excel(row, col, true, true);
2014            let addr = CellRef::new(sheet_id, coord);
2015            if !assume_new && let Some(&existing_id) = self.cell_to_vertex.get(&addr) {
2016                if matches!(
2017                    self.store.kind(existing_id),
2018                    VertexKind::FormulaScalar | VertexKind::FormulaArray
2019                ) {
2020                    self.remove_dependent_edges(existing_id);
2021                    self.detach_vertex_from_names(existing_id);
2022                    self.clear_pending_name_references(existing_id);
2023                    self.vertex_formulas.remove(&existing_id);
2024                }
2025                if self.value_cache_enabled {
2026                    let value_ref = self.data_store.store_value(value);
2027                    self.vertex_values.insert(existing_id, value_ref);
2028                } else {
2029                    self.vertex_values.remove(&existing_id);
2030                }
2031                self.store.set_kind(existing_id, VertexKind::Cell);
2032                continue;
2033            }
2034            let packed = AbsCoord::from_excel(row, col);
2035            let vertex_id = self.store.allocate(packed, sheet_id, 0x00);
2036            self.store.set_kind(vertex_id, VertexKind::Cell);
2037            // Defer value arena storage to a single batch
2038            new_value_coords.push((packed, vertex_id));
2039            new_value_literals.push(value);
2040            self.cell_to_vertex.insert(addr, vertex_id);
2041            new_vertices.push((packed, vertex_id.0));
2042            index_items.push((packed, vertex_id));
2043        }
2044        // Perform a single batch store for newly allocated values
2045        if self.value_cache_enabled && !new_value_literals.is_empty() {
2046            let vrefs = self.data_store.store_values_batch(new_value_literals);
2047            debug_assert_eq!(vrefs.len(), new_value_coords.len());
2048            for (i, (_pc, vid)) in new_value_coords.iter().enumerate() {
2049                self.vertex_values.insert(*vid, vrefs[i]);
2050            }
2051        }
2052        let t_after_alloc = Instant::now();
2053        if !new_vertices.is_empty() {
2054            let t_edges_start = Instant::now();
2055            self.edges.add_vertices_batch(&new_vertices);
2056            let t_edges_done = Instant::now();
2057
2058            match self.config.sheet_index_mode {
2059                crate::engine::SheetIndexMode::Eager => {
2060                    self.sheet_index_mut(sheet_id)
2061                        .add_vertices_batch(&index_items);
2062                }
2063                crate::engine::SheetIndexMode::Lazy => {
2064                    // Skip building index now; will be built on-demand
2065                }
2066                crate::engine::SheetIndexMode::FastBatch => {
2067                    // FastBatch for now delegates to same batch insert (future: build from sorted arrays)
2068                    self.sheet_index_mut(sheet_id)
2069                        .add_vertices_batch(&index_items);
2070                }
2071            }
2072            let t_index_done = Instant::now();
2073        }
2074        Ok(())
2075    }
2076
2077    /// Set a formula in a cell, returns affected vertex IDs
2078    pub fn set_cell_formula(
2079        &mut self,
2080        sheet: &str,
2081        row: u32,
2082        col: u32,
2083        ast: ASTNode,
2084    ) -> Result<OperationSummary, ExcelError> {
2085        self.set_cell_formula_with_volatility(sheet, row, col, ast, false)
2086    }
2087
2088    /// Set a formula in a cell. The volatility argument is retained for API compatibility;
2089    /// dependency flags now come from `IngestPipeline`.
2090    pub fn set_cell_formula_with_volatility(
2091        &mut self,
2092        sheet: &str,
2093        row: u32,
2094        col: u32,
2095        ast: ASTNode,
2096        _volatile: bool,
2097    ) -> Result<OperationSummary, ExcelError> {
2098        let sheet_id = self.sheet_id_mut(sheet);
2099        let placement = CellRef::new(sheet_id, Coord::from_excel(row, col, true, true));
2100        let provider = RegistryFunctionProvider;
2101        let ingested = {
2102            let mut pipeline = self.ingest_pipeline(&provider);
2103            pipeline.ingest_formula(FormulaAstInput::Tree(ast), placement, None)?
2104        };
2105        self.set_cell_formula_with_plan(
2106            sheet,
2107            row,
2108            col,
2109            ingested.ast_id,
2110            &ingested.dep_plan,
2111            ingested.dep_plan.volatile,
2112            ingested.dep_plan.dynamic,
2113        )
2114    }
2115
2116    pub(crate) fn set_cell_formula_with_plan(
2117        &mut self,
2118        sheet: &str,
2119        row: u32,
2120        col: u32,
2121        ast_id: AstNodeId,
2122        plan: &DependencyPlanRow,
2123        volatile: bool,
2124        dynamic: bool,
2125    ) -> Result<OperationSummary, ExcelError> {
2126        let dbg = std::env::var("FZ_DEBUG_LOAD")
2127            .ok()
2128            .is_some_and(|v| v != "0");
2129        let dep_ms_thresh: u128 = std::env::var("FZ_DEBUG_DEP_MS")
2130            .ok()
2131            .and_then(|s| s.parse().ok())
2132            .unwrap_or(0);
2133        let sample_n: usize = std::env::var("FZ_DEBUG_SAMPLE_N")
2134            .ok()
2135            .and_then(|s| s.parse().ok())
2136            .unwrap_or(0);
2137        let t0 = if dbg {
2138            Some(crate::instant::FzInstant::now())
2139        } else {
2140            None
2141        };
2142        let sheet_id = self.sheet_id_mut(sheet);
2143        let budgets = self.self_admission_budgets();
2144        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
2145            let usage = self.preview_formula_mutations(&[(sheet_id, row, col, plan.clone())])?;
2146            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
2147                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
2148        }
2149        let coord = Coord::from_excel(row, col, true, true);
2150        let addr = CellRef::new(sheet_id, coord);
2151
2152        let t_dep0 = if dbg {
2153            Some(crate::instant::FzInstant::now())
2154        } else {
2155            None
2156        };
2157        let mut created_placeholders = Vec::new();
2158        let mut new_dependencies = Vec::with_capacity(plan.direct_cell_deps.len());
2159        for dep in &plan.direct_cell_deps {
2160            let dep_vid = self.get_or_create_vertex(dep, &mut created_placeholders);
2161            if !new_dependencies.contains(&dep_vid) {
2162                new_dependencies.push(dep_vid);
2163            }
2164        }
2165        let mut named_dependencies = Vec::new();
2166        let mut unresolved_names = Vec::new();
2167        for name in plan
2168            .resolved_named_refs
2169            .iter()
2170            .chain(plan.named_refs.iter())
2171        {
2172            if let Some(named) = self.resolve_name_entry(name, sheet_id) {
2173                if !new_dependencies.contains(&named.vertex) {
2174                    new_dependencies.push(named.vertex);
2175                }
2176                if !named_dependencies.contains(&named.vertex) {
2177                    named_dependencies.push(named.vertex);
2178                }
2179            } else if let Some(source) = self.resolve_source_scalar_entry(name) {
2180                if !new_dependencies.contains(&source.vertex) {
2181                    new_dependencies.push(source.vertex);
2182                }
2183            } else {
2184                unresolved_names.push(name.clone());
2185            }
2186        }
2187        for source_name in &plan.source_refs {
2188            if let Some(source) = self.resolve_source_scalar_entry(source_name) {
2189                if !new_dependencies.contains(&source.vertex) {
2190                    new_dependencies.push(source.vertex);
2191                }
2192            } else if let Some(source) = self.resolve_source_table_entry(source_name)
2193                && !new_dependencies.contains(&source.vertex)
2194            {
2195                new_dependencies.push(source.vertex);
2196            }
2197        }
2198        for table_name in &plan.table_refs {
2199            if let Some(table) = self.resolve_table_entry(table_name) {
2200                if !new_dependencies.contains(&table.vertex) {
2201                    new_dependencies.push(table.vertex);
2202                }
2203            } else if let Some(source) = self.resolve_source_table_entry(table_name)
2204                && !new_dependencies.contains(&source.vertex)
2205            {
2206                new_dependencies.push(source.vertex);
2207            }
2208        }
2209        if let (true, Some(t)) = (dbg, t_dep0) {
2210            let elapsed = t.elapsed().as_millis();
2211            let do_log = (dep_ms_thresh > 0 && elapsed >= dep_ms_thresh)
2212                || (sample_n > 0 && (row as usize).is_multiple_of(sample_n));
2213            if (dep_ms_thresh == 0 && sample_n == 0 && row.is_multiple_of(1000)) || do_log {
2214                eprintln!(
2215                    "[fz][dep] {}!{} planned: deps={}, ranges={}, placeholders={}, names={} in {} ms",
2216                    self.sheet_name(sheet_id),
2217                    crate::reference::Coord::from_excel(row, col, true, true),
2218                    new_dependencies.len(),
2219                    plan.range_deps.len(),
2220                    created_placeholders.len(),
2221                    named_dependencies.len(),
2222                    elapsed
2223                );
2224            }
2225        }
2226
2227        // Check for self-reference (immediate cycle detection)
2228        let addr_vertex_id = self.get_or_create_vertex(&addr, &mut created_placeholders);
2229
2230        // Editing a formula clears any prior structural #REF! marking for this vertex.
2231        self.ref_error_vertices.remove(&addr_vertex_id);
2232
2233        // Under `CyclePolicy::Iterate` (Runtime detection) self-dependencies
2234        // are accepted, mirroring Excel with iterative calculation enabled:
2235        // the self-edge forms a single-vertex SCC that the scheduler emits as
2236        // a Cycle unit and `evaluate_scc_unit` iterates (RFC #113, spec §7.1/
2237        // §7.6/§7.8). Everywhere else the edit-time rejection stands.
2238        //
2239        // Scope note (persistence contract, pinned by
2240        // `formualizer-workbook/tests/cycle_persistence.rs`): this rejection
2241        // is an INTERACTIVE-EDIT nicety only. Bulk load paths
2242        // (`ingest_formula_batches` → `BulkIngestBuilder`, incl. staged
2243        // `build_graph_all`) intentionally do not perform it, so workbooks
2244        // saved with self-references under an Iterate config always reload —
2245        // under any cycle config — and resolve to `#CIRC!`/iteration at
2246        // evaluation time per the loaded policy.
2247        if new_dependencies.contains(&addr_vertex_id) && !self.config.cycle.allows_self_dependency()
2248        {
2249            return Err(ExcelError::new(ExcelErrorKind::Circ)
2250                .with_message("Self-reference detected".to_string()));
2251        }
2252
2253        for &name_vertex in &named_dependencies {
2254            let mut visited = FxHashSet::default();
2255            if self.name_depends_on_vertex(name_vertex, addr_vertex_id, &mut visited) {
2256                return Err(ExcelError::new(ExcelErrorKind::Circ)
2257                    .with_message("Circular reference through named range".to_string()));
2258            }
2259        }
2260
2261        // Remove old dependencies first
2262        self.remove_dependent_edges(addr_vertex_id);
2263        self.detach_vertex_from_names(addr_vertex_id);
2264        self.clear_pending_name_references(addr_vertex_id);
2265
2266        // Update vertex properties
2267        self.store
2268            .set_kind(addr_vertex_id, VertexKind::FormulaScalar);
2269        self.vertex_formulas.insert(addr_vertex_id, ast_id);
2270        self.store.set_dirty(addr_vertex_id, true);
2271
2272        // Clear any cached value since this is now a formula
2273        self.vertex_values.remove(&addr_vertex_id);
2274
2275        self.mark_volatile(addr_vertex_id, volatile);
2276        self.store.set_dynamic(addr_vertex_id, dynamic);
2277
2278        if !named_dependencies.is_empty() {
2279            self.attach_vertex_to_names(addr_vertex_id, &named_dependencies);
2280        }
2281        for unresolved_name in &unresolved_names {
2282            self.record_pending_name_reference(sheet_id, unresolved_name, addr_vertex_id);
2283        }
2284
2285        if let (true, Some(t)) = (dbg, t0) {
2286            let elapsed = t.elapsed().as_millis();
2287            let log_set = dep_ms_thresh > 0 && elapsed >= dep_ms_thresh;
2288            if log_set {
2289                eprintln!(
2290                    "[fz][set] {}!{} total {} ms",
2291                    self.sheet_name(sheet_id),
2292                    crate::reference::Coord::from_excel(row, col, true, true),
2293                    elapsed
2294                );
2295            }
2296        }
2297
2298        // Add new dependency edges
2299        self.add_dependent_edges(addr_vertex_id, &new_dependencies);
2300        self.add_range_dependent_edges(addr_vertex_id, &plan.range_deps, sheet_id);
2301
2302        Ok(OperationSummary {
2303            affected_vertices: self.mark_dirty(addr_vertex_id),
2304            created_placeholders,
2305        })
2306    }
2307
2308    pub(crate) fn rewrite_structured_references_for_cell(
2309        &self,
2310        ast: &mut ASTNode,
2311        cell: CellRef,
2312    ) -> Result<bool, ExcelError> {
2313        self.rewrite_structured_references_node(ast, cell)
2314    }
2315
2316    fn rewrite_structured_references_node(
2317        &self,
2318        node: &mut ASTNode,
2319        cell: CellRef,
2320    ) -> Result<bool, ExcelError> {
2321        match &mut node.node_type {
2322            ASTNodeType::Reference { reference, .. } => {
2323                self.rewrite_structured_reference(reference, cell)
2324            }
2325            ASTNodeType::UnaryOp { expr, .. } => {
2326                self.rewrite_structured_references_node(expr, cell)
2327            }
2328            ASTNodeType::BinaryOp { left, right, .. } => {
2329                let left_rewritten = self.rewrite_structured_references_node(left, cell)?;
2330                let right_rewritten = self.rewrite_structured_references_node(right, cell)?;
2331                Ok(left_rewritten || right_rewritten)
2332            }
2333            ASTNodeType::Function { args, .. } => {
2334                let mut rewritten = false;
2335                for a in args.iter_mut() {
2336                    rewritten |= self.rewrite_structured_references_node(a, cell)?;
2337                }
2338                Ok(rewritten)
2339            }
2340            ASTNodeType::Call { callee, args } => {
2341                let mut rewritten = self.rewrite_structured_references_node(callee, cell)?;
2342                for a in args.iter_mut() {
2343                    rewritten |= self.rewrite_structured_references_node(a, cell)?;
2344                }
2345                Ok(rewritten)
2346            }
2347            ASTNodeType::Array(rows) => {
2348                let mut rewritten = false;
2349                for r in rows.iter_mut() {
2350                    for item in r.iter_mut() {
2351                        rewritten |= self.rewrite_structured_references_node(item, cell)?;
2352                    }
2353                }
2354                Ok(rewritten)
2355            }
2356            ASTNodeType::Literal(_) | ASTNodeType::Omitted => Ok(false),
2357        }
2358    }
2359
2360    fn rewrite_structured_reference(
2361        &self,
2362        reference: &mut ReferenceType,
2363        cell: CellRef,
2364    ) -> Result<bool, ExcelError> {
2365        use formualizer_parse::parser::{SpecialItem, TableSpecifier};
2366
2367        let ReferenceType::Table(tref) = reference else {
2368            return Ok(false);
2369        };
2370
2371        // This-row shorthand: parsed as an unnamed table reference with a Combination specifier.
2372        if !tref.name.is_empty() {
2373            return Ok(false);
2374        }
2375
2376        let col_name = match &tref.specifier {
2377            Some(TableSpecifier::Combination(parts)) => {
2378                let mut saw_this_row = false;
2379                let mut col: Option<&str> = None;
2380                for p in parts {
2381                    match p.as_ref() {
2382                        TableSpecifier::SpecialItem(SpecialItem::ThisRow) => {
2383                            saw_this_row = true;
2384                        }
2385                        TableSpecifier::Column(c) => {
2386                            if col.is_some() {
2387                                return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
2388                                    "This-row structured reference with multiple columns is not supported"
2389                                        .to_string(),
2390                                ));
2391                            }
2392                            col = Some(c.as_str());
2393                        }
2394                        other => {
2395                            return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
2396                                format!(
2397                                    "Unsupported this-row structured reference component: {other}"
2398                                ),
2399                            ));
2400                        }
2401                    }
2402                }
2403                if !saw_this_row {
2404                    return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
2405                        "Unnamed structured reference requires a this-row selector".to_string(),
2406                    ));
2407                }
2408                col.ok_or_else(|| {
2409                    ExcelError::new(ExcelErrorKind::NImpl).with_message(
2410                        "This-row structured reference missing column selector".to_string(),
2411                    )
2412                })?
2413            }
2414            _ => {
2415                return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
2416                    "Unnamed structured reference form is not supported".to_string(),
2417                ));
2418            }
2419        };
2420
2421        let Some(table) = self.find_table_containing_cell(cell) else {
2422            return Err(ExcelError::new(ExcelErrorKind::Name)
2423                .with_message("This-row structured reference used outside a table".to_string()));
2424        };
2425
2426        let row0 = cell.coord.row();
2427        let col0 = cell.coord.col();
2428        let sr0 = table.range.start.coord.row();
2429        let sc0 = table.range.start.coord.col();
2430        let er0 = table.range.end.coord.row();
2431        let ec0 = table.range.end.coord.col();
2432
2433        if row0 < sr0 || row0 > er0 || col0 < sc0 || col0 > ec0 {
2434            return Err(ExcelError::new(ExcelErrorKind::Name)
2435                .with_message("This-row structured reference used outside a table".to_string()));
2436        }
2437
2438        if table.header_row && row0 == sr0 {
2439            return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(
2440                "This-row structured references are not valid in the table header row".to_string(),
2441            ));
2442        }
2443
2444        let data_start = if table.header_row { sr0 + 1 } else { sr0 };
2445        if row0 < data_start {
2446            return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(
2447                "This-row structured references require a data/totals row context".to_string(),
2448            ));
2449        }
2450
2451        let Some(idx) = table.col_index(col_name) else {
2452            return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(format!(
2453                "Unknown table column in this-row reference: {col_name}"
2454            )));
2455        };
2456        let target_col0 = sc0 + (idx as u32);
2457        let target_row = row0 + 1;
2458        let target_col = target_col0 + 1;
2459
2460        *reference = ReferenceType::Cell {
2461            sheet: None,
2462            row: target_row,
2463            col: target_col,
2464            row_abs: true,
2465            col_abs: true,
2466        };
2467
2468        Ok(true)
2469    }
2470
2471    fn find_table_containing_cell(&self, cell: CellRef) -> Option<&tables::TableEntry> {
2472        let row0 = cell.coord.row();
2473        let col0 = cell.coord.col();
2474
2475        let mut best: Option<&tables::TableEntry> = None;
2476        let mut best_area: u64 = u64::MAX;
2477        let mut best_name: &str = "";
2478
2479        for t in self.tables.values() {
2480            if t.sheet_id() != cell.sheet_id {
2481                continue;
2482            }
2483            let sr0 = t.range.start.coord.row();
2484            let sc0 = t.range.start.coord.col();
2485            let er0 = t.range.end.coord.row();
2486            let ec0 = t.range.end.coord.col();
2487            if row0 < sr0 || row0 > er0 || col0 < sc0 || col0 > ec0 {
2488                continue;
2489            }
2490
2491            let h = (er0 - sr0 + 1) as u64;
2492            let w = (ec0 - sc0 + 1) as u64;
2493            let area = h.saturating_mul(w);
2494            let name = t.name.as_str();
2495            let better = match best {
2496                None => true,
2497                Some(_) => area < best_area || (area == best_area && name < best_name),
2498            };
2499            if better {
2500                best = Some(t);
2501                best_area = area;
2502                best_name = name;
2503            }
2504        }
2505
2506        best
2507    }
2508
2509    #[allow(clippy::type_complexity)]
2510    pub(crate) fn fp8_parity_extract_dependencies_with_pending_names(
2511        &mut self,
2512        ast: &ASTNode,
2513        current_sheet_id: SheetId,
2514    ) -> Result<
2515        (
2516            Vec<VertexId>,
2517            Vec<SharedRangeRef<'static>>,
2518            Vec<CellRef>,
2519            Vec<VertexId>,
2520            Vec<String>,
2521        ),
2522        ExcelError,
2523    > {
2524        self.extract_dependencies_with_pending_names(ast, current_sheet_id)
2525    }
2526
2527    pub(crate) fn fp8_parity_is_ast_volatile(&self, ast: &ASTNode) -> bool {
2528        self.is_ast_volatile(ast)
2529    }
2530
2531    pub fn set_cell_value_ref(
2532        &mut self,
2533        cell: formualizer_common::SheetCellRef<'_>,
2534        value: LiteralValue,
2535    ) -> Result<OperationSummary, ExcelError> {
2536        let owned = cell.into_owned();
2537        let sheet_id = match owned.sheet {
2538            formualizer_common::SheetLocator::Id(id) => id,
2539            formualizer_common::SheetLocator::Name(name) => self.sheet_id_mut(name.as_ref()),
2540            formualizer_common::SheetLocator::Current => self.default_sheet_id,
2541        };
2542        let sheet_name = self.sheet_name(sheet_id).to_string();
2543        self.set_cell_value(
2544            &sheet_name,
2545            owned.coord.row() + 1,
2546            owned.coord.col() + 1,
2547            value,
2548        )
2549    }
2550
2551    pub fn set_cell_formula_ref(
2552        &mut self,
2553        cell: formualizer_common::SheetCellRef<'_>,
2554        ast: ASTNode,
2555    ) -> Result<OperationSummary, ExcelError> {
2556        let owned = cell.into_owned();
2557        let sheet_id = match owned.sheet {
2558            formualizer_common::SheetLocator::Id(id) => id,
2559            formualizer_common::SheetLocator::Name(name) => self.sheet_id_mut(name.as_ref()),
2560            formualizer_common::SheetLocator::Current => self.default_sheet_id,
2561        };
2562        let sheet_name = self.sheet_name(sheet_id).to_string();
2563        self.set_cell_formula(
2564            &sheet_name,
2565            owned.coord.row() + 1,
2566            owned.coord.col() + 1,
2567            ast,
2568        )
2569    }
2570
2571    pub fn get_cell_value_ref(
2572        &self,
2573        cell: formualizer_common::SheetCellRef<'_>,
2574    ) -> Option<LiteralValue> {
2575        let owned = cell.into_owned();
2576        let sheet_id = match owned.sheet {
2577            formualizer_common::SheetLocator::Id(id) => id,
2578            formualizer_common::SheetLocator::Name(name) => self.sheet_id(name.as_ref())?,
2579            formualizer_common::SheetLocator::Current => self.default_sheet_id,
2580        };
2581        let sheet_name = self.sheet_name(sheet_id);
2582        self.get_cell_value(sheet_name, owned.coord.row() + 1, owned.coord.col() + 1)
2583    }
2584
2585    /// Get current value from a cell
2586    pub fn get_cell_value(&self, sheet: &str, row: u32, col: u32) -> Option<LiteralValue> {
2587        if !self.value_cache_enabled {
2588            #[cfg(debug_assertions)]
2589            {
2590                self.graph_value_read_attempts
2591                    .fetch_add(1, Ordering::Relaxed);
2592            }
2593            return None;
2594        }
2595        let sheet_id = self.sheet_reg.get_id(sheet)?;
2596        let coord = Coord::from_excel(row, col, true, true);
2597        let addr = CellRef::new(sheet_id, coord);
2598
2599        self.get_vertex_id_for_address(&addr)
2600            .and_then(|&vertex_id| {
2601                // Check values hashmap (stores both cell values and formula results)
2602                self.vertex_values
2603                    .get(&vertex_id)
2604                    .map(|&value_ref| self.data_store.retrieve_value(value_ref))
2605            })
2606    }
2607
2608    /// Mark vertex dirty and propagate to dependents
2609    fn mark_dirty(&mut self, vertex_id: VertexId) -> Vec<VertexId> {
2610        self.mark_dirty_many(&[vertex_id])
2611    }
2612
2613    /// Multi-source `mark_dirty`: one BFS with a shared seen-set across all
2614    /// sources, marking exactly the union of per-source `mark_dirty` calls
2615    /// but visiting every vertex at most once per call.
2616    ///
2617    /// Loop-of-`mark_dirty` callers (volatile redirty, iterative-SCC redirty)
2618    /// pay O(sources × component) without this — measured quadratic by the
2619    /// iterate edge corpus. A BFS that early-stops at already-`is_dirty`
2620    /// vertices would also fix that, but it is NOT safe in general: several
2621    /// call sites set the dirty flag WITHOUT propagating to dependents
2622    /// (`DependencyGraph::set_dirty`, `mark_dependents_dirty`, names.rs
2623    /// binding invalidation, eval.rs demand-driven re-marks), so "dirty"
2624    /// does not imply "my dependents are already dirty". The per-call shared
2625    /// seen-set needs no such invariant.
2626    ///
2627    /// While a deferred-dirty scope is active (`begin_deferred_dirty`), the
2628    /// call queues its sources for the end-of-scope flush and returns ONLY
2629    /// the sources as the "affected" set (the full transitive set is
2630    /// produced once by the flush). Loop-of-edits callers must not rely on
2631    /// per-edit transitive affected sets inside such a scope.
2632    pub(crate) fn mark_dirty_many(&mut self, vertex_ids: &[VertexId]) -> Vec<VertexId> {
2633        if self.deferred_dirty_depth > 0 {
2634            self.deferred_dirty_pending.extend_from_slice(vertex_ids);
2635            return vertex_ids.to_vec();
2636        }
2637        let mut affected = FxHashSet::default();
2638        let mut to_visit = Vec::new();
2639        let mut visited_for_propagation = FxHashSet::default();
2640
2641        for &vertex_id in vertex_ids {
2642            // Only mark the source vertex as dirty if it's a formula.
2643            // Value cells don't get marked dirty themselves but are still
2644            // affected.
2645            let is_formula = matches!(
2646                self.store.kind(vertex_id),
2647                VertexKind::FormulaScalar
2648                    | VertexKind::FormulaArray
2649                    | VertexKind::NamedScalar
2650                    | VertexKind::NamedArray
2651            );
2652
2653            if is_formula {
2654                to_visit.push(vertex_id);
2655            } else {
2656                // Value cells are affected (for tracking) but not marked dirty
2657                affected.insert(vertex_id);
2658            }
2659
2660            // Initial propagation from direct and range dependents
2661            {
2662                // Get dependents (vertices that depend on this vertex)
2663                if let Some(dependents) = self.dependents_slice(vertex_id) {
2664                    to_visit.extend(dependents.iter().copied());
2665                } else {
2666                    let dependents = self.get_dependents(vertex_id);
2667                    to_visit.extend(dependents);
2668                }
2669
2670                if let Some(name_set) = self.cell_to_name_dependents.get(&vertex_id) {
2671                    for &name_vertex in name_set {
2672                        to_visit.push(name_vertex);
2673                    }
2674                }
2675
2676                to_visit.extend(self.collect_range_dependents_for_vertex(vertex_id));
2677            }
2678        }
2679
2680        while let Some(id) = to_visit.pop() {
2681            if !visited_for_propagation.insert(id) {
2682                continue; // Already processed
2683            }
2684            self.dirty_propagation_visits += 1;
2685            affected.insert(id);
2686
2687            // Mark vertex as dirty
2688            self.store.set_dirty(id, true);
2689
2690            // Add direct dependents to visit list
2691            if let Some(dependents) = self.dependents_slice(id) {
2692                to_visit.extend(dependents.iter().copied());
2693            } else {
2694                let dependents = self.get_dependents(id);
2695                to_visit.extend(dependents);
2696            }
2697            to_visit.extend(self.collect_range_dependents_for_vertex(id));
2698        }
2699
2700        // Add to dirty set
2701        self.formula_dirty.legacy_extend(affected.iter().copied());
2702
2703        // Return as Vec for compatibility
2704        affected.into_iter().collect()
2705    }
2706
2707    /// Total vertices processed by dirty-propagation BFS loops since graph
2708    /// creation (perf-shape observability; see `dirty_propagation_visits`).
2709    pub(crate) fn dirty_propagation_visits(&self) -> u64 {
2710        self.dirty_propagation_visits
2711    }
2712
2713    /// Begin a deferred-dirty scope for a multi-edit batch.
2714    ///
2715    /// While active, `mark_dirty` / `mark_dirty_many` /
2716    /// `mark_dirty_many_value_cells` queue their sources instead of running a
2717    /// BFS per call; the outermost `end_deferred_dirty` flushes the queued
2718    /// union with ONE multi-source `mark_dirty_many`. Union semantics equal
2719    /// the sequential per-edit calls (pinned by
2720    /// `mark_dirty_many_equals_sequential_single_source_marks` plus the
2721    /// deferred-scope tests): any dependent edge removed mid-batch belongs to
2722    /// a vertex that was itself edited mid-batch, and edited vertices are
2723    /// themselves pending sources, so the flush covers everything a per-edit
2724    /// propagation would have reached.
2725    ///
2726    /// Nesting is depth-counted. The scope also enters the CSR edge batch
2727    /// (`begin_batch`) so edge-heavy batches amortize delta rebuilds (#127).
2728    ///
2729    /// Callers MUST guarantee `end_deferred_dirty` runs on every exit path
2730    /// (including `?` early returns): a leaked scope would silently swallow
2731    /// future propagations. Evaluation entry points `debug_assert` that no
2732    /// scope is active.
2733    pub fn begin_deferred_dirty(&mut self) {
2734        self.edges.begin_batch();
2735        self.deferred_dirty_depth += 1;
2736    }
2737
2738    /// End a deferred-dirty scope. When the outermost scope ends, runs ONE
2739    /// multi-source propagation over every source queued while deferred and
2740    /// returns its full affected set (sources pointing at vertices deleted
2741    /// mid-batch are skipped). Inner (nested) ends return an empty set.
2742    pub fn end_deferred_dirty(&mut self) -> Vec<VertexId> {
2743        debug_assert!(
2744            self.deferred_dirty_depth > 0,
2745            "end_deferred_dirty without matching begin_deferred_dirty"
2746        );
2747        self.edges.end_batch();
2748        self.deferred_dirty_depth = self.deferred_dirty_depth.saturating_sub(1);
2749        if self.deferred_dirty_depth > 0 {
2750            return Vec::new();
2751        }
2752        let pending = std::mem::take(&mut self.deferred_dirty_pending);
2753        if pending.is_empty() {
2754            return Vec::new();
2755        }
2756        let live: Vec<VertexId> = pending
2757            .into_iter()
2758            .filter(|&id| self.vertex_exists(id))
2759            .collect();
2760        self.mark_dirty_many(&live)
2761    }
2762
2763    /// True while a deferred-dirty scope is active (see
2764    /// `begin_deferred_dirty`). Evaluation must never start in this state.
2765    pub fn deferred_dirty_active(&self) -> bool {
2766        self.deferred_dirty_depth > 0
2767    }
2768
2769    /// Get all vertices that need evaluation
2770    pub fn get_evaluation_vertices(&self) -> Vec<VertexId> {
2771        let mut combined = FxHashSet::default();
2772        combined.extend(self.formula_dirty.legacy_iter().copied());
2773        combined.extend(&self.volatile_vertices);
2774
2775        let mut result: Vec<VertexId> = combined
2776            .into_iter()
2777            .filter(|&id| {
2778                // Only include active formula/name vertices; tombstoned vertices can retain stable
2779                // IDs in the store, but must never be scheduled for evaluation.
2780                self.store.vertex_exists_active(id)
2781                    && matches!(
2782                        self.store.kind(id),
2783                        VertexKind::FormulaScalar
2784                            | VertexKind::FormulaArray
2785                            | VertexKind::NamedScalar
2786                            | VertexKind::NamedArray
2787                    )
2788            })
2789            .collect();
2790        result.sort_unstable();
2791        result
2792    }
2793
2794    /// Clear dirty flags after successful evaluation
2795    pub fn clear_dirty_flags(&mut self, vertices: &[VertexId]) {
2796        for &vertex_id in vertices {
2797            self.store.set_dirty(vertex_id, false);
2798            self.formula_dirty.legacy_remove(&vertex_id);
2799        }
2800    }
2801
2802    /// 🔮 Scalability Hook: Clear volatile vertices after evaluation cycle
2803    pub fn clear_volatile_flags(&mut self) {
2804        self.volatile_vertices.clear();
2805    }
2806
2807    /// Re-marks all volatile vertices as dirty for the next evaluation cycle.
2808    /// One multi-source propagation: many volatiles feeding one dependent
2809    /// component used to pay O(volatiles × component) (a full `mark_dirty`
2810    /// BFS per volatile); `mark_dirty_many` visits the component once.
2811    pub(crate) fn redirty_volatiles(&mut self) {
2812        let volatile_ids: Vec<VertexId> = self.volatile_vertices.iter().copied().collect();
2813        let _ = self.mark_dirty_many(&volatile_ids);
2814    }
2815
2816    /// Re-marks members of iterating SCCs (and, via propagation, their
2817    /// dependents) dirty for the next evaluation cycle — the volatile-like
2818    /// redirty that keeps `CyclePolicy::Iterate` cells re-evaluating every
2819    /// recalc (RFC #113; spec §4/§7.6). Vertices deleted since the recalc
2820    /// are skipped.
2821    ///
2822    /// One multi-source propagation: the old per-member `mark_dirty` loop was
2823    /// O(|SCC|²) per recalc for a large SCC (a converged 1000-member ring
2824    /// cost ~42 ms per no-op recalc, release); an interim `!is_dirty` skip
2825    /// fixed that but leaned on dirty-flag semantics that non-propagating
2826    /// `set_dirty` callers do not uphold. The shared seen-set in
2827    /// `mark_dirty_many` is O(component) without any such invariant.
2828    pub(crate) fn redirty_iterative_members(&mut self, members: &[VertexId]) {
2829        let live: Vec<VertexId> = members
2830            .iter()
2831            .copied()
2832            .filter(|&id| self.vertex_exists(id))
2833            .collect();
2834        let _ = self.mark_dirty_many(&live);
2835    }
2836
2837    fn get_or_create_vertex(
2838        &mut self,
2839        addr: &CellRef,
2840        created_placeholders: &mut Vec<CellRef>,
2841    ) -> VertexId {
2842        if let Some(&vertex_id) = self.cell_to_vertex.get(addr) {
2843            return vertex_id;
2844        }
2845
2846        // During first-load bulk ingest the fast path populates
2847        // ``load_packed_to_vertex`` but skips ``cell_to_vertex``. Promote
2848        // the entry into ``cell_to_vertex`` so subsequent lookups are O(1)
2849        // and consistent across the two maps.
2850        if self.first_load_assume_new {
2851            let packed = Self::packed_cell_key(
2852                addr.sheet_id,
2853                AbsCoord::new(addr.coord.row(), addr.coord.col()),
2854            );
2855            if let Some(&existing) = self.load_packed_to_vertex.get(&packed) {
2856                self.cell_to_vertex.insert(*addr, existing);
2857                return existing;
2858            }
2859        }
2860
2861        created_placeholders.push(*addr);
2862        let packed_coord = AbsCoord::new(addr.coord.row(), addr.coord.col());
2863        let vertex_id = self.store.allocate(packed_coord, addr.sheet_id, 0x00);
2864
2865        // Add vertex coordinate for CSR
2866        self.edges.add_vertex(packed_coord, vertex_id.0);
2867
2868        // Add to sheet index for O(log n + k) range queries
2869        self.sheet_index_mut(addr.sheet_id)
2870            .add_vertex(packed_coord, vertex_id);
2871
2872        self.store.set_kind(vertex_id, VertexKind::Empty);
2873        self.cell_to_vertex.insert(*addr, vertex_id);
2874        vertex_id
2875    }
2876
2877    fn add_dependent_edges(&mut self, dependent: VertexId, dependencies: &[VertexId]) {
2878        // Batch to avoid repeated CSR rebuilds and keep reverse edges current
2879        self.edges.begin_batch();
2880
2881        // If PK enabled, update order using a short-lived adapter without holding &mut self
2882        // Track dependencies that should be skipped if rejecting cycle-creating edges
2883        let mut skip_deps: rustc_hash::FxHashSet<VertexId> = rustc_hash::FxHashSet::default();
2884        if self.pk_order.is_some()
2885            && let Some(mut pk) = self.pk_order.take()
2886        {
2887            pk.ensure_nodes(std::iter::once(dependent));
2888            pk.ensure_nodes(dependencies.iter().copied());
2889            {
2890                let adapter = GraphAdapter { g: self };
2891                for &dep_id in dependencies {
2892                    match pk.try_add_edge(&adapter, dep_id, dependent) {
2893                        Ok(_) => {}
2894                        Err(_cycle) => {
2895                            if self.config.pk_reject_cycle_edges {
2896                                skip_deps.insert(dep_id);
2897                            } else {
2898                                pk.rebuild_full(&adapter);
2899                            }
2900                        }
2901                    }
2902                }
2903            } // drop adapter
2904            self.pk_order = Some(pk);
2905        }
2906
2907        // Now mutate engine edges; if rejecting cycles, re-check and skip those that would create cycles
2908        for &dep_id in dependencies {
2909            if self.config.pk_reject_cycle_edges && skip_deps.contains(&dep_id) {
2910                continue;
2911            }
2912            self.edges.add_edge(dependent, dep_id);
2913            #[cfg(test)]
2914            {
2915                if let Ok(mut g) = self.instr.lock() {
2916                    g.edges_added += 1;
2917                }
2918            }
2919        }
2920
2921        self.edges.end_batch();
2922    }
2923
2924    /// Like add_dependent_edges, but assumes caller is managing edges.begin_batch/end_batch
2925    fn add_dependent_edges_nobatch(&mut self, dependent: VertexId, dependencies: &[VertexId]) {
2926        // If PK enabled, update order using a short-lived adapter without holding &mut self
2927        let mut skip_deps: rustc_hash::FxHashSet<VertexId> = rustc_hash::FxHashSet::default();
2928        if self.pk_order.is_some()
2929            && let Some(mut pk) = self.pk_order.take()
2930        {
2931            pk.ensure_nodes(std::iter::once(dependent));
2932            pk.ensure_nodes(dependencies.iter().copied());
2933            {
2934                let adapter = GraphAdapter { g: self };
2935                for &dep_id in dependencies {
2936                    match pk.try_add_edge(&adapter, dep_id, dependent) {
2937                        Ok(_) => {}
2938                        Err(_cycle) => {
2939                            if self.config.pk_reject_cycle_edges {
2940                                skip_deps.insert(dep_id);
2941                            } else {
2942                                pk.rebuild_full(&adapter);
2943                            }
2944                        }
2945                    }
2946                }
2947            }
2948            self.pk_order = Some(pk);
2949        }
2950
2951        for &dep_id in dependencies {
2952            if self.config.pk_reject_cycle_edges && skip_deps.contains(&dep_id) {
2953                continue;
2954            }
2955            self.edges.add_edge(dependent, dep_id);
2956            #[cfg(test)]
2957            {
2958                if let Ok(mut g) = self.instr.lock() {
2959                    g.edges_added += 1;
2960                }
2961            }
2962        }
2963    }
2964
2965    /// Bulk set formulas on a sheet using a single dependency plan and batched edge updates.
2966    pub fn bulk_set_formulas<I>(&mut self, sheet: &str, items: I) -> Result<usize, ExcelError>
2967    where
2968        I: IntoIterator<Item = (u32, u32, ASTNode)>,
2969    {
2970        let collected: Vec<(u32, u32, ASTNode)> = items.into_iter().collect();
2971        if collected.is_empty() {
2972            return Ok(0);
2973        }
2974        let vol_flags: Vec<bool> = collected
2975            .iter()
2976            .map(|(_, _, ast)| self.is_ast_volatile(ast))
2977            .collect();
2978        self.bulk_set_formulas_with_volatility(sheet, collected, vol_flags)
2979    }
2980
2981    pub fn bulk_set_formulas_with_volatility(
2982        &mut self,
2983        sheet: &str,
2984        collected: Vec<(u32, u32, ASTNode)>,
2985        _vol_flags: Vec<bool>,
2986    ) -> Result<usize, ExcelError> {
2987        let sheet_id = self.sheet_id_mut(sheet);
2988        if collected.is_empty() {
2989            return Ok(0);
2990        }
2991        let provider = RegistryFunctionProvider;
2992        let ingested = {
2993            let mut pipeline = self.ingest_pipeline(&provider);
2994            let inputs = collected.into_iter().map(|(row, col, ast)| {
2995                let placement = CellRef::new(sheet_id, Coord::from_excel(row, col, true, true));
2996                (FormulaAstInput::Tree(ast), placement, None)
2997            });
2998            pipeline.ingest_batch(inputs)?
2999        };
3000        let planned = ingested
3001            .into_iter()
3002            .map(|formula| {
3003                (
3004                    formula.placement.coord.row() + 1,
3005                    formula.placement.coord.col() + 1,
3006                    formula.ast_id,
3007                    formula.dep_plan,
3008                )
3009            })
3010            .collect();
3011        self.bulk_set_formulas_with_plans(sheet, planned)
3012    }
3013
3014    pub(crate) fn bulk_set_formulas_with_plans(
3015        &mut self,
3016        sheet: &str,
3017        planned: Vec<(u32, u32, AstNodeId, DependencyPlanRow)>,
3018    ) -> Result<usize, ExcelError> {
3019        let sheet_id = self.sheet_id_mut(sheet);
3020        if planned.is_empty() {
3021            return Ok(0);
3022        }
3023        let budgets = self.self_admission_budgets();
3024        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
3025            let admission_plans = planned
3026                .iter()
3027                .map(|(row, col, _, plan)| (sheet_id, *row, *col, plan.clone()))
3028                .collect::<Vec<_>>();
3029            let usage = self.preview_formula_mutations(&admission_plans)?;
3030            crate::engine::resource_ledger::preflight_graph_admission(&budgets, usage, None)
3031                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
3032        }
3033        let mut created_placeholders: Vec<CellRef> = Vec::new();
3034        let mut target_vids: Vec<VertexId> = Vec::with_capacity(planned.len());
3035        for (row, col, _, _) in &planned {
3036            let addr = CellRef::new(sheet_id, Coord::from_excel(*row, *col, true, true));
3037            target_vids.push(self.get_or_create_vertex(&addr, &mut created_placeholders));
3038        }
3039        // Create direct-dependency placeholders before edge batching starts. If a formula-plane
3040        // demotion materializes formulas into an otherwise Arrow-only graph, interleaving
3041        // dependency vertex creation with edge insertion forces the CSR delta slab to rebuild on
3042        // every new dependency vertex. Pre-creating these vertices keeps bulk edge insertion O(n).
3043        for (_, _, _, plan) in &planned {
3044            for cell in &plan.direct_cell_deps {
3045                self.get_or_create_vertex(cell, &mut created_placeholders);
3046            }
3047        }
3048
3049        for (i, &tvid) in target_vids.iter().enumerate() {
3050            if self.vertex_formulas.contains_key(&tvid) {
3051                self.remove_dependent_edges(tvid);
3052            }
3053            self.detach_vertex_from_names(tvid);
3054            self.clear_pending_name_references(tvid);
3055            self.store.set_kind(tvid, VertexKind::FormulaScalar);
3056            self.store.set_dirty(tvid, true);
3057            self.vertex_values.remove(&tvid);
3058            self.vertex_formulas.insert(tvid, planned[i].2);
3059            self.mark_volatile(tvid, planned[i].3.volatile);
3060            self.store.set_dynamic(tvid, planned[i].3.dynamic);
3061        }
3062        self.formula_dirty
3063            .legacy_extend(target_vids.iter().copied());
3064
3065        self.edges.begin_batch();
3066        for (i, tvid) in target_vids.iter().copied().enumerate() {
3067            let plan = &planned[i].3;
3068            let mut deps: Vec<VertexId> = Vec::new();
3069            for cell in &plan.direct_cell_deps {
3070                let dep_vid = self.get_or_create_vertex(cell, &mut created_placeholders);
3071                if !deps.contains(&dep_vid) {
3072                    deps.push(dep_vid);
3073                }
3074            }
3075
3076            let mut name_vertices = Vec::new();
3077            for name in plan
3078                .resolved_named_refs
3079                .iter()
3080                .chain(plan.named_refs.iter())
3081            {
3082                if let Some(named) = self.resolve_name_entry(name, sheet_id) {
3083                    if !deps.contains(&named.vertex) {
3084                        deps.push(named.vertex);
3085                    }
3086                    if !name_vertices.contains(&named.vertex) {
3087                        name_vertices.push(named.vertex);
3088                    }
3089                } else if let Some(source) = self.resolve_source_scalar_entry(name) {
3090                    if !deps.contains(&source.vertex) {
3091                        deps.push(source.vertex);
3092                    }
3093                } else {
3094                    self.record_pending_name_reference(sheet_id, name, tvid);
3095                }
3096            }
3097            for source_name in &plan.source_refs {
3098                if let Some(source) = self.resolve_source_scalar_entry(source_name) {
3099                    if !deps.contains(&source.vertex) {
3100                        deps.push(source.vertex);
3101                    }
3102                } else if let Some(source) = self.resolve_source_table_entry(source_name)
3103                    && !deps.contains(&source.vertex)
3104                {
3105                    deps.push(source.vertex);
3106                }
3107            }
3108            for table_name in &plan.table_refs {
3109                if let Some(table) = self.resolve_table_entry(table_name) {
3110                    if !deps.contains(&table.vertex) {
3111                        deps.push(table.vertex);
3112                    }
3113                } else if let Some(source) = self.resolve_source_table_entry(table_name)
3114                    && !deps.contains(&source.vertex)
3115                {
3116                    deps.push(source.vertex);
3117                }
3118            }
3119            if !name_vertices.is_empty() {
3120                self.attach_vertex_to_names(tvid, &name_vertices);
3121            }
3122            if !deps.is_empty() {
3123                self.add_dependent_edges_nobatch(tvid, &deps);
3124            }
3125            self.add_range_dependent_edges(tvid, &plan.range_deps, sheet_id);
3126        }
3127        self.edges.end_batch();
3128
3129        Ok(planned.len())
3130    }
3131
3132    /// Public (crate) helper to add a single dependency edge (dependent -> dependency) used for restoration/undo.
3133    pub fn add_dependency_edge(
3134        &mut self,
3135        dependent: VertexId,
3136        dependency: VertexId,
3137    ) -> Result<(), ExcelError> {
3138        if dependent == dependency {
3139            return Ok(());
3140        }
3141        let budgets = self.self_admission_budgets();
3142        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
3143            let stats = self.baseline_stats();
3144            let added = usize::from(!self.get_dependencies(dependent).contains(&dependency));
3145            crate::engine::resource_ledger::preflight_graph_admission(
3146                &budgets,
3147                crate::engine::resource_ledger::GraphAdmission {
3148                    final_vertices: stats.graph_vertex_count,
3149                    final_edges: stats.graph_edge_count.checked_add(added).ok_or_else(|| {
3150                        ExcelError::new(ExcelErrorKind::NImpl)
3151                            .with_message("graph edge count overflow")
3152                    })?,
3153                    materialization_cells: 0,
3154                    added_vertices: 0,
3155                    added_edges: added,
3156                },
3157                None,
3158            )
3159            .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
3160        }
3161        // If PK enabled attempt to add maintaining ordering; fallback to rebuild if cycle
3162        if self.pk_order.is_some()
3163            && let Some(mut pk) = self.pk_order.take()
3164        {
3165            pk.ensure_nodes(std::iter::once(dependent));
3166            pk.ensure_nodes(std::iter::once(dependency));
3167            let adapter = GraphAdapter { g: self };
3168            if pk.try_add_edge(&adapter, dependency, dependent).is_err() {
3169                // Cycle: rebuild full (conservative)
3170                pk.rebuild_full(&adapter);
3171            }
3172            self.pk_order = Some(pk);
3173        }
3174        self.edges.add_edge(dependent, dependency);
3175        self.store.set_dirty(dependent, true);
3176        self.formula_dirty.legacy_insert(dependent);
3177        Ok(())
3178    }
3179
3180    fn remove_dependent_edges(&mut self, vertex: VertexId) {
3181        // Remove all outgoing edges from this vertex (its dependencies)
3182        let dependencies = self.edges.out_edges(vertex);
3183
3184        self.edges.begin_batch();
3185        if self.pk_order.is_some()
3186            && let Some(mut pk) = self.pk_order.take()
3187        {
3188            for dep in &dependencies {
3189                pk.remove_edge(*dep, vertex);
3190            }
3191            self.pk_order = Some(pk);
3192        }
3193        for dep in dependencies {
3194            self.edges.remove_edge(vertex, dep);
3195        }
3196        self.edges.end_batch();
3197
3198        // Remove range dependencies and clean up stripes
3199        if let Some(old_ranges) = self.formula_to_range_deps.remove(&vertex) {
3200            let old_sheet_id = self.store.sheet_id(vertex);
3201
3202            for range in &old_ranges {
3203                // `Current` is the sheet the moved formula used to live on.
3204                let sheet_id = self
3205                    .sheet_reg
3206                    .resolve_locator(&range.sheet, old_sheet_id)
3207                    .unwrap_or(old_sheet_id);
3208                let s_row = range.start_row.map(|b| b.index);
3209                let e_row = range.end_row.map(|b| b.index);
3210                let s_col = range.start_col.map(|b| b.index);
3211                let e_col = range.end_col.map(|b| b.index);
3212
3213                let mut keys_to_clean = FxHashSet::default();
3214
3215                let col_stripes = (s_row.is_none() && e_row.is_none())
3216                    || (s_col.is_some() && e_col.is_some() && (s_row.is_none() || e_row.is_none()));
3217                let row_stripes = (s_col.is_none() && e_col.is_none())
3218                    || (s_row.is_some() && e_row.is_some() && (s_col.is_none() || e_col.is_none()));
3219
3220                if col_stripes && !row_stripes {
3221                    let sc = s_col.unwrap_or(0);
3222                    let ec = e_col.unwrap_or(sc);
3223                    for col in sc..=ec {
3224                        keys_to_clean.insert(StripeKey {
3225                            sheet_id,
3226                            stripe_type: StripeType::Column,
3227                            index: col,
3228                        });
3229                    }
3230                } else if row_stripes && !col_stripes {
3231                    let sr = s_row.unwrap_or(0);
3232                    let er = e_row.unwrap_or(sr);
3233                    for row in sr..=er {
3234                        keys_to_clean.insert(StripeKey {
3235                            sheet_id,
3236                            stripe_type: StripeType::Row,
3237                            index: row,
3238                        });
3239                    }
3240                } else {
3241                    let start_row = s_row.unwrap_or(0);
3242                    let start_col = s_col.unwrap_or(0);
3243                    let end_row = e_row.unwrap_or(start_row);
3244                    let end_col = e_col.unwrap_or(start_col);
3245
3246                    let height = end_row.saturating_sub(start_row) + 1;
3247                    let width = end_col.saturating_sub(start_col) + 1;
3248
3249                    if self.config.enable_block_stripes && height > 1 && width > 1 {
3250                        let start_block_row = start_row / BLOCK_H;
3251                        let end_block_row = end_row / BLOCK_H;
3252                        let start_block_col = start_col / BLOCK_W;
3253                        let end_block_col = end_col / BLOCK_W;
3254
3255                        for block_row in start_block_row..=end_block_row {
3256                            for block_col in start_block_col..=end_block_col {
3257                                keys_to_clean.insert(StripeKey {
3258                                    sheet_id,
3259                                    stripe_type: StripeType::Block,
3260                                    index: block_index(block_row * BLOCK_H, block_col * BLOCK_W),
3261                                });
3262                            }
3263                        }
3264                    } else if height > width {
3265                        for col in start_col..=end_col {
3266                            keys_to_clean.insert(StripeKey {
3267                                sheet_id,
3268                                stripe_type: StripeType::Column,
3269                                index: col,
3270                            });
3271                        }
3272                    } else {
3273                        for row in start_row..=end_row {
3274                            keys_to_clean.insert(StripeKey {
3275                                sheet_id,
3276                                stripe_type: StripeType::Row,
3277                                index: row,
3278                            });
3279                        }
3280                    }
3281                }
3282
3283                for key in keys_to_clean {
3284                    if let Some(dependents) = self.stripe_to_dependents.get_mut(&key) {
3285                        dependents.remove(&vertex);
3286                        if dependents.is_empty() {
3287                            self.stripe_to_dependents.remove(&key);
3288                            #[cfg(test)]
3289                            {
3290                                if let Ok(mut g) = self.instr.lock() {
3291                                    g.stripe_removes += 1;
3292                                }
3293                            }
3294                        }
3295                    }
3296                }
3297            }
3298        }
3299    }
3300
3301    // Removed: vertices() and get_vertex() methods - no longer needed with SoA
3302    // The old AoS Vertex struct has been eliminated in favor of direct
3303    // access to columnar data through the VertexStore
3304
3305    /// Updates the cached value of a formula vertex.
3306    pub(crate) fn update_vertex_value(&mut self, vertex_id: VertexId, value: LiteralValue) {
3307        if !self.value_cache_enabled {
3308            // Canonical mode: cell/formula vertices must not store values in the graph.
3309            match self.store.kind(vertex_id) {
3310                VertexKind::Cell
3311                | VertexKind::FormulaScalar
3312                | VertexKind::FormulaArray
3313                | VertexKind::Empty => {
3314                    self.vertex_values.remove(&vertex_id);
3315                    return;
3316                }
3317                _ => {
3318                    // Allow non-cell vertices to cache values (e.g. named-range formulas).
3319                }
3320            }
3321        }
3322        let value_ref = self.data_store.store_value(normalize_stored_literal(value));
3323        self.vertex_values.insert(vertex_id, value_ref);
3324    }
3325
3326    /// Plan a spill region for an anchor; returns #SPILL! if blocked
3327    pub fn plan_spill_region(
3328        &self,
3329        anchor: VertexId,
3330        target_cells: &[CellRef],
3331    ) -> Result<(), ExcelError> {
3332        self.plan_spill_region_allowing_formula_overwrite(anchor, target_cells, None)
3333    }
3334
3335    /// Plan a spill region, optionally allowing specific formula vertices to be overwritten.
3336    ///
3337    /// This is used by parallel evaluation to allow spill anchors to take precedence over
3338    /// other formula vertices that are being evaluated in the same layer.
3339    pub(crate) fn plan_spill_region_allowing_formula_overwrite(
3340        &self,
3341        anchor: VertexId,
3342        target_cells: &[CellRef],
3343        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
3344    ) -> Result<(), ExcelError> {
3345        use formualizer_common::{ExcelErrorExtra, ExcelErrorKind};
3346        // Compute expected spill shape from the target rectangle for better diagnostics
3347        let (expected_rows, expected_cols) = if target_cells.is_empty() {
3348            (0u32, 0u32)
3349        } else {
3350            let mut min_r = u32::MAX;
3351            let mut max_r = 0u32;
3352            let mut min_c = u32::MAX;
3353            let mut max_c = 0u32;
3354            for cell in target_cells {
3355                let r = cell.coord.row();
3356                let c = cell.coord.col();
3357                if r < min_r {
3358                    min_r = r;
3359                }
3360                if r > max_r {
3361                    max_r = r;
3362                }
3363                if c < min_c {
3364                    min_c = c;
3365                }
3366                if c > max_c {
3367                    max_c = c;
3368                }
3369            }
3370            (
3371                max_r.saturating_sub(min_r).saturating_add(1),
3372                max_c.saturating_sub(min_c).saturating_add(1),
3373            )
3374        };
3375        // Allow overlapping with previously owned spill cells by this anchor
3376        for cell in target_cells {
3377            // If cell is already owned by this anchor's previous spill, it's allowed.
3378            let owned_by_anchor = match self.spill_cell_to_anchor.get(cell) {
3379                Some(&existing_anchor) if existing_anchor == anchor => true,
3380                Some(_other) => {
3381                    return Err(ExcelError::new(ExcelErrorKind::Spill)
3382                        .with_message("BlockedBySpill")
3383                        .with_extra(ExcelErrorExtra::Spill {
3384                            expected_rows,
3385                            expected_cols,
3386                        }));
3387                }
3388                None => false,
3389            };
3390
3391            if owned_by_anchor {
3392                continue;
3393            }
3394
3395            // If cell is occupied by another formula anchor, block unless explicitly allowed.
3396            if let Some(&vid) = self.cell_to_vertex.get(cell)
3397                && vid != anchor
3398            {
3399                // Prevent clobbering formulas (array or scalar) in the target area
3400                match self.store.kind(vid) {
3401                    VertexKind::FormulaScalar | VertexKind::FormulaArray => {
3402                        if let Some(allow) = overwritable_formulas
3403                            && allow.contains(&vid)
3404                        {
3405                            continue;
3406                        }
3407                        return Err(ExcelError::new(ExcelErrorKind::Spill)
3408                            .with_message("BlockedByFormula")
3409                            .with_extra(ExcelErrorExtra::Spill {
3410                                expected_rows,
3411                                expected_cols,
3412                            }));
3413                    }
3414                    _ => {
3415                        // If a non-empty value exists (and not this anchor), block
3416                        if let Some(vref) = self.vertex_values.get(&vid) {
3417                            let v = self.data_store.retrieve_value(*vref);
3418                            if !matches!(v, LiteralValue::Empty) {
3419                                return Err(ExcelError::new(ExcelErrorKind::Spill)
3420                                    .with_message("BlockedByValue")
3421                                    .with_extra(ExcelErrorExtra::Spill {
3422                                        expected_rows,
3423                                        expected_cols,
3424                                    }));
3425                            }
3426                        }
3427                    }
3428                }
3429            }
3430        }
3431        Ok(())
3432    }
3433
3434    // Note: non-atomic commit_spill_region has been removed. All callers must use
3435    // commit_spill_region_atomic_with_fault for atomicity and rollback on failure.
3436
3437    /// Commit a spill atomically with an internal shadow buffer and optional fault injection.
3438    /// If a fault is injected partway through, all changes are rolled back to the pre-commit state.
3439    /// This does not change behavior under normal operation; it's primarily for Phase 3 guarantees and tests.
3440    pub fn commit_spill_region_atomic_with_fault(
3441        &mut self,
3442        anchor: VertexId,
3443        target_cells: Vec<CellRef>,
3444        values: Vec<Vec<LiteralValue>>,
3445        fault_after_ops: Option<usize>,
3446    ) -> Result<(), ExcelError> {
3447        let budgets = self.self_admission_budgets();
3448        if crate::engine::resource_ledger::graph_admission_enabled(&budgets) {
3449            let admission = self.preview_spill_materialization(&target_cells)?;
3450            crate::engine::resource_ledger::preflight_graph_admission(&budgets, admission, None)
3451                .map_err(crate::engine::ResourceLedgerError::into_excel_error)?;
3452        }
3453
3454        // Anchor cell coordinates (0-based) for special-casing writes.
3455        // We must never overwrite the anchor via set_cell_value(), because that would
3456        // strip the formula and break incremental recalculation.
3457        let anchor_cell = self
3458            .get_cell_ref(anchor)
3459            .expect("anchor cell ref for spill commit");
3460        let anchor_sheet_name = self.sheet_name(anchor_cell.sheet_id).to_string();
3461        let anchor_row = anchor_cell.coord.row();
3462        let anchor_col = anchor_cell.coord.col();
3463
3464        // Capture previous owned cells for this anchor
3465        let prev_cells = self
3466            .spill_anchor_to_cells
3467            .get(&anchor)
3468            .cloned()
3469            .unwrap_or_default();
3470        // Use CoordBuildHasher on CellRef keys to avoid FxHasher clustering on
3471        // packed Coord values.
3472        let new_set: std::collections::HashSet<CellRef, CoordBuildHasher> =
3473            target_cells.iter().copied().collect();
3474        let prev_set: std::collections::HashSet<CellRef, CoordBuildHasher> =
3475            prev_cells.iter().copied().collect();
3476
3477        // Compose operation list: clears first (prev - new), then writes for new rectangle
3478        #[derive(Clone)]
3479        struct Op {
3480            sheet: String,
3481            row: u32,
3482            col: u32,
3483            new_value: LiteralValue,
3484        }
3485        let mut ops: Vec<Op> = Vec::new();
3486
3487        // Clears for cells no longer used
3488        for cell in prev_cells.iter() {
3489            if !new_set.contains(cell) {
3490                let sheet = self.sheet_name(cell.sheet_id).to_string();
3491                ops.push(Op {
3492                    sheet,
3493                    row: cell.coord.row(),
3494                    col: cell.coord.col(),
3495                    new_value: LiteralValue::Empty,
3496                });
3497            }
3498        }
3499
3500        // Writes for new values (row-major to match target rectangle)
3501        if !target_cells.is_empty() {
3502            let first = target_cells.first().copied().unwrap();
3503            let row0 = first.coord.row();
3504            let col0 = first.coord.col();
3505            let sheet = self.sheet_name(first.sheet_id).to_string();
3506            for (r_off, row_vals) in values.iter().enumerate() {
3507                for (c_off, v) in row_vals.iter().enumerate() {
3508                    ops.push(Op {
3509                        sheet: sheet.clone(),
3510                        row: row0 + r_off as u32,
3511                        col: col0 + c_off as u32,
3512                        new_value: v.clone(),
3513                    });
3514                }
3515            }
3516        }
3517
3518        // Shadow buffer of old values for rollback
3519        #[derive(Clone)]
3520        struct OldVal {
3521            present: bool,
3522            value: LiteralValue,
3523        }
3524        let mut old_values: Vec<((String, u32, u32), OldVal)> = Vec::with_capacity(ops.len());
3525
3526        // Capture old values before applying
3527        for op in &ops {
3528            // op.row/op.col are internal 0-based; get_cell_value is a public 1-based API.
3529            let old = self
3530                .get_cell_value(&op.sheet, op.row + 1, op.col + 1)
3531                .unwrap_or(LiteralValue::Empty);
3532            let present = true; // unified model: we always treat as present
3533            old_values.push((
3534                (op.sheet.clone(), op.row, op.col),
3535                OldVal {
3536                    present,
3537                    value: old,
3538                },
3539            ));
3540        }
3541
3542        // Apply with optional injected fault
3543        for (applied, op) in ops.iter().enumerate() {
3544            if let Some(n) = fault_after_ops
3545                && applied == n
3546            {
3547                for idx in (0..applied).rev() {
3548                    let ((ref sheet, row, col), ref old) = old_values[idx];
3549                    if sheet == &anchor_sheet_name && row == anchor_row && col == anchor_col {
3550                        self.update_vertex_value(anchor, old.value.clone());
3551                    } else {
3552                        let _ = self.set_cell_value(sheet, row + 1, col + 1, old.value.clone());
3553                    }
3554                }
3555                return Err(ExcelError::new(ExcelErrorKind::Error)
3556                    .with_message("Injected persistence fault during spill commit"));
3557            }
3558            if op.sheet == anchor_sheet_name && op.row == anchor_row && op.col == anchor_col {
3559                self.update_vertex_value(anchor, op.new_value.clone());
3560            } else {
3561                let _ =
3562                    self.set_cell_value(&op.sheet, op.row + 1, op.col + 1, op.new_value.clone());
3563            }
3564        }
3565
3566        // Update spill ownership maps only on success
3567        // Clear previous ownership not reused
3568        for cell in prev_cells.iter() {
3569            if !new_set.contains(cell) {
3570                self.spill_cell_to_anchor.remove(cell);
3571                let remove_sheet = self
3572                    .spill_cells_by_sheet
3573                    .get_mut(&cell.sheet_id)
3574                    .is_some_and(|sheet| {
3575                        sheet.remove(&(cell.coord.row(), cell.coord.col()));
3576                        sheet.is_empty()
3577                    });
3578                if remove_sheet {
3579                    self.spill_cells_by_sheet.remove(&cell.sheet_id);
3580                }
3581            }
3582        }
3583        // Mark ownership for new rectangle using the declared target cells only
3584        for cell in &target_cells {
3585            self.spill_cell_to_anchor.insert(*cell, anchor);
3586            self.spill_cells_by_sheet
3587                .entry(cell.sheet_id)
3588                .or_default()
3589                .insert((cell.coord.row(), cell.coord.col()), anchor);
3590        }
3591        self.spill_anchor_to_cells.insert(anchor, target_cells);
3592        Ok(())
3593    }
3594
3595    pub(crate) fn spill_cells_for_anchor(&self, anchor: VertexId) -> Option<&[CellRef]> {
3596        self.spill_anchor_to_cells
3597            .get(&anchor)
3598            .map(|v| v.as_slice())
3599    }
3600
3601    pub(crate) fn spill_registry_has_anchor(&self, anchor: VertexId) -> bool {
3602        self.spill_anchor_to_cells.contains_key(&anchor)
3603    }
3604
3605    pub(crate) fn spill_registry_anchor_for_cell(&self, cell: CellRef) -> Option<VertexId> {
3606        self.spill_cell_to_anchor.get(&cell).copied()
3607    }
3608
3609    pub(crate) fn spill_registry_counts(&self) -> (usize, usize) {
3610        (
3611            self.spill_anchor_to_cells.len(),
3612            self.spill_cell_to_anchor.len(),
3613        )
3614    }
3615
3616    /// Clear an existing spill region for an anchor (set cells to Empty and forget ownership)
3617    pub fn clear_spill_region(&mut self, anchor: VertexId) {
3618        let _ = self.clear_spill_region_bulk(anchor);
3619    }
3620
3621    /// Bulk clear an existing spill region for an anchor.
3622    ///
3623    /// This avoids calling `set_cell_value()` per spill child (which can trigger O(N*V)
3624    /// dependent scans when `edges.delta_size() > 0`). Instead, it clears values directly and
3625    /// performs a single dirty propagation over the affected spill children.
3626    ///
3627    /// Returns the previously registered spill cells (including the anchor cell) for callers that
3628    /// want to mirror/record deltas.
3629    pub fn clear_spill_region_bulk(&mut self, anchor: VertexId) -> Vec<CellRef> {
3630        let anchor_cell = self.get_cell_ref(anchor);
3631        let Some(cells) = self.spill_anchor_to_cells.remove(&anchor) else {
3632            return Vec::new();
3633        };
3634
3635        // Remove ownership for all cells first.
3636        for cell in cells.iter() {
3637            self.spill_cell_to_anchor.remove(cell);
3638            let remove_sheet = self
3639                .spill_cells_by_sheet
3640                .get_mut(&cell.sheet_id)
3641                .is_some_and(|sheet| {
3642                    sheet.remove(&(cell.coord.row(), cell.coord.col()));
3643                    sheet.is_empty()
3644                });
3645            if remove_sheet {
3646                self.spill_cells_by_sheet.remove(&cell.sheet_id);
3647            }
3648        }
3649
3650        // Prepare a single arena value ref for Empty (only when caching is enabled).
3651        let empty_ref = if self.value_cache_enabled {
3652            Some(self.data_store.store_value(LiteralValue::Empty))
3653        } else {
3654            None
3655        };
3656
3657        // Clear all spill children (excluding the anchor cell).
3658        let mut changed_vertices: Vec<VertexId> = Vec::new();
3659        for cell in cells.iter().copied() {
3660            let is_anchor = anchor_cell.map(|a| a == cell).unwrap_or(false);
3661            if is_anchor {
3662                continue;
3663            }
3664            let Some(&vid) = self.cell_to_vertex.get(&cell) else {
3665                continue;
3666            };
3667            // Ensure this vertex is a plain value cell.
3668            if self.vertex_formulas.remove(&vid).is_some() {
3669                // Be conservative: remove outgoing edges if this was a formula vertex.
3670                // This should be rare for spill children under normal policies.
3671                self.remove_dependent_edges(vid);
3672            }
3673            self.store.set_kind(vid, VertexKind::Cell);
3674            if let Some(er) = empty_ref {
3675                self.vertex_values.insert(vid, er);
3676            } else {
3677                self.vertex_values.remove(&vid);
3678            }
3679            self.store.set_dirty(vid, false);
3680            self.formula_dirty.legacy_remove(&vid);
3681            changed_vertices.push(vid);
3682        }
3683
3684        // Single dirty propagation for all changed spill children.
3685        if !changed_vertices.is_empty() {
3686            self.mark_dirty_many_value_cells(&changed_vertices);
3687        }
3688
3689        cells
3690    }
3691
3692    fn mark_dirty_many_value_cells(&mut self, vertex_ids: &[VertexId]) -> Vec<VertexId> {
3693        if vertex_ids.is_empty() {
3694            return Vec::new();
3695        }
3696
3697        // Deferred-dirty scope (e.g. a spill clear inside a batched
3698        // `set_values`): queue the sources for the end-of-scope flush. The
3699        // general `mark_dirty_many` flush handles value-cell sources via its
3700        // per-source kind check, so one pending list serves both entry
3701        // points. (The flush's per-source range-dependent collection is a
3702        // subset of this path's bounding-rect collection, which conservatively
3703        // over-dirties; the per-source union is the exact required set.)
3704        if self.deferred_dirty_depth > 0 {
3705            self.deferred_dirty_pending.extend_from_slice(vertex_ids);
3706            return vertex_ids.to_vec();
3707        }
3708
3709        // Fold pending deltas once so the propagation loop below can use the
3710        // zero-allocation base `in_edges` slices. This is a deliberate
3711        // rebuild-on-read seam: one rebuild per bulk propagation, amortized
3712        // (the per-vertex alternative would allocate a merged Vec per visit).
3713        if self.edges.delta_size() > 0 {
3714            self.edges.rebuild();
3715        }
3716
3717        let mut affected: FxHashSet<VertexId> = FxHashSet::default();
3718        let mut to_visit: Vec<VertexId> = Vec::new();
3719        let mut visited_for_propagation: FxHashSet<VertexId> = FxHashSet::default();
3720
3721        // Value sources are affected but not marked dirty themselves.
3722        for &src in vertex_ids {
3723            affected.insert(src);
3724        }
3725
3726        // Collect initial direct dependents and name dependents.
3727        for &src in vertex_ids {
3728            to_visit.extend(self.edges.in_edges(src));
3729            if let Some(name_set) = self.cell_to_name_dependents.get(&src) {
3730                for &name_vertex in name_set {
3731                    to_visit.push(name_vertex);
3732                }
3733            }
3734        }
3735
3736        // Collect range dependents in bulk using spill rect bounds per sheet.
3737        let mut bounds_by_sheet: FxHashMap<SheetId, (u32, u32, u32, u32)> = FxHashMap::default();
3738        for &src in vertex_ids {
3739            let view = self.store.view(src);
3740            let sid = view.sheet_id();
3741            let r = view.row();
3742            let c = view.col();
3743            bounds_by_sheet
3744                .entry(sid)
3745                .and_modify(|b| {
3746                    b.0 = b.0.min(r);
3747                    b.1 = b.1.max(r);
3748                    b.2 = b.2.min(c);
3749                    b.3 = b.3.max(c);
3750                })
3751                .or_insert((r, r, c, c));
3752        }
3753
3754        for (sid, (sr, er, sc, ec)) in bounds_by_sheet {
3755            to_visit.extend(self.collect_range_dependents_for_rect(sid, sr, sc, er, ec));
3756        }
3757
3758        while let Some(id) = to_visit.pop() {
3759            if !visited_for_propagation.insert(id) {
3760                continue;
3761            }
3762            self.dirty_propagation_visits += 1;
3763            affected.insert(id);
3764            self.store.set_dirty(id, true);
3765            to_visit.extend(self.edges.in_edges(id));
3766            to_visit.extend(self.collect_range_dependents_for_vertex(id));
3767        }
3768
3769        self.formula_dirty.legacy_extend(affected.iter().copied());
3770        affected.into_iter().collect()
3771    }
3772
3773    fn collect_range_dependents_for_vertex(&self, vertex_id: VertexId) -> Vec<VertexId> {
3774        match self.store.kind(vertex_id) {
3775            VertexKind::Cell
3776            | VertexKind::Empty
3777            | VertexKind::FormulaScalar
3778            | VertexKind::FormulaArray => {
3779                let view = self.store.view(vertex_id);
3780                self.collect_range_dependents_for_rect(
3781                    view.sheet_id(),
3782                    view.row(),
3783                    view.col(),
3784                    view.row(),
3785                    view.col(),
3786                )
3787            }
3788            _ => Vec::new(),
3789        }
3790    }
3791
3792    fn collect_range_dependents_for_rect(
3793        &self,
3794        sheet_id: SheetId,
3795        start_row: u32,
3796        start_col: u32,
3797        end_row: u32,
3798        end_col: u32,
3799    ) -> Vec<VertexId> {
3800        if self.stripe_to_dependents.is_empty() {
3801            return Vec::new();
3802        }
3803        let mut candidates: FxHashSet<VertexId> = FxHashSet::default();
3804
3805        for col in start_col..=end_col {
3806            let key = StripeKey {
3807                sheet_id,
3808                stripe_type: StripeType::Column,
3809                index: col,
3810            };
3811            if let Some(deps) = self.stripe_to_dependents.get(&key) {
3812                candidates.extend(deps);
3813            }
3814        }
3815        for row in start_row..=end_row {
3816            let key = StripeKey {
3817                sheet_id,
3818                stripe_type: StripeType::Row,
3819                index: row,
3820            };
3821            if let Some(deps) = self.stripe_to_dependents.get(&key) {
3822                candidates.extend(deps);
3823            }
3824        }
3825        if self.config.enable_block_stripes {
3826            let br0 = start_row / BLOCK_H;
3827            let br1 = end_row / BLOCK_H;
3828            let bc0 = start_col / BLOCK_W;
3829            let bc1 = end_col / BLOCK_W;
3830            for br in br0..=br1 {
3831                for bc in bc0..=bc1 {
3832                    let key = StripeKey {
3833                        sheet_id,
3834                        stripe_type: StripeType::Block,
3835                        index: block_index(br * BLOCK_H, bc * BLOCK_W),
3836                    };
3837                    if let Some(deps) = self.stripe_to_dependents.get(&key) {
3838                        candidates.extend(deps);
3839                    }
3840                }
3841            }
3842        }
3843
3844        // Precision check: the dirty rect must overlap at least one of the formula's registered ranges.
3845        let mut out: Vec<VertexId> = Vec::new();
3846        for dep_id in candidates {
3847            let Some(ranges) = self.formula_to_range_deps.get(&dep_id) else {
3848                continue;
3849            };
3850            let mut hit = false;
3851            for range in ranges {
3852                // `Current` is the dependent formula's own sheet; an
3853                // unresolvable name keeps the dependent in the candidate set.
3854                let range_sheet_id = self
3855                    .sheet_reg
3856                    .resolve_locator(&range.sheet, self.get_vertex_sheet_id(dep_id))
3857                    .unwrap_or(sheet_id);
3858                if range_sheet_id != sheet_id {
3859                    continue;
3860                }
3861                let sr0 = range.start_row.map(|b| b.index).unwrap_or(0);
3862                let er0 = range.end_row.map(|b| b.index).unwrap_or(u32::MAX);
3863                let sc0 = range.start_col.map(|b| b.index).unwrap_or(0);
3864                let ec0 = range.end_col.map(|b| b.index).unwrap_or(u32::MAX);
3865                let overlap =
3866                    sr0 <= end_row && er0 >= start_row && sc0 <= end_col && ec0 >= start_col;
3867                if overlap {
3868                    hit = true;
3869                    break;
3870                }
3871            }
3872            if hit {
3873                out.push(dep_id);
3874            }
3875        }
3876        out
3877    }
3878
3879    /// Check if a vertex exists
3880    pub(crate) fn vertex_exists(&self, vertex_id: VertexId) -> bool {
3881        if vertex_id.0 < FIRST_NORMAL_VERTEX {
3882            return false;
3883        }
3884        let index = (vertex_id.0 - FIRST_NORMAL_VERTEX) as usize;
3885        index < self.store.len()
3886    }
3887
3888    /// Get the kind of a vertex
3889    pub(crate) fn get_vertex_kind(&self, vertex_id: VertexId) -> VertexKind {
3890        self.store.kind(vertex_id)
3891    }
3892
3893    /// Get the sheet ID of a vertex
3894    pub(crate) fn get_vertex_sheet_id(&self, vertex_id: VertexId) -> SheetId {
3895        self.store.sheet_id(vertex_id)
3896    }
3897
3898    pub fn get_formula_id(&self, vertex_id: VertexId) -> Option<AstNodeId> {
3899        self.vertex_formulas.get(&vertex_id).copied()
3900    }
3901
3902    pub(crate) fn formula_vertices(&self) -> Vec<VertexId> {
3903        let mut vertices = self.vertex_formulas.keys().copied().collect::<Vec<_>>();
3904        vertices.sort_unstable();
3905        vertices
3906    }
3907
3908    pub fn get_formula_id_and_volatile(&self, vertex_id: VertexId) -> Option<(AstNodeId, bool)> {
3909        let ast_id = self.get_formula_id(vertex_id)?;
3910        Some((ast_id, self.is_volatile(vertex_id)))
3911    }
3912
3913    pub fn get_formula_node(&self, vertex_id: VertexId) -> Option<&super::arena::AstNodeData> {
3914        let ast_id = self.get_formula_id(vertex_id)?;
3915        self.data_store.get_node(ast_id)
3916    }
3917
3918    pub fn get_formula_node_and_volatile(
3919        &self,
3920        vertex_id: VertexId,
3921    ) -> Option<(&super::arena::AstNodeData, bool)> {
3922        let (ast_id, vol) = self.get_formula_id_and_volatile(vertex_id)?;
3923        let node = self.data_store.get_node(ast_id)?;
3924        Some((node, vol))
3925    }
3926
3927    /// Get the formula AST for a vertex.
3928    ///
3929    /// Not used in hot paths; reconstructs from arena.
3930    pub fn get_formula(&self, vertex_id: VertexId) -> Option<ASTNode> {
3931        let ast_id = self.get_formula_id(vertex_id)?;
3932        self.data_store.retrieve_ast(ast_id, &self.sheet_reg)
3933    }
3934
3935    /// Get the value stored for a vertex
3936    pub fn get_value(&self, vertex_id: VertexId) -> Option<LiteralValue> {
3937        if !self.value_cache_enabled {
3938            // In canonical mode, cell/formula values must not be read from the graph.
3939            // Non-cell vertices (e.g. named ranges, external sources) may still use graph storage.
3940            match self.store.kind(vertex_id) {
3941                VertexKind::Cell
3942                | VertexKind::FormulaScalar
3943                | VertexKind::FormulaArray
3944                | VertexKind::Empty => {
3945                    #[cfg(debug_assertions)]
3946                    {
3947                        self.graph_value_read_attempts
3948                            .fetch_add(1, Ordering::Relaxed);
3949                    }
3950                    return None;
3951                }
3952                _ => {
3953                    // Allow non-cell vertices to use vertex_values.
3954                }
3955            }
3956        }
3957        self.vertex_values
3958            .get(&vertex_id)
3959            .map(|&value_ref| self.data_store.retrieve_value(value_ref))
3960    }
3961
3962    /// Get the cell reference for a vertex
3963    pub(crate) fn get_cell_ref(&self, vertex_id: VertexId) -> Option<CellRef> {
3964        let packed_coord = self.store.coord(vertex_id);
3965        let sheet_id = self.store.sheet_id(vertex_id);
3966        let coord = Coord::new(packed_coord.row(), packed_coord.col(), true, true);
3967        Some(CellRef::new(sheet_id, coord))
3968    }
3969
3970    /// Create a cell reference (helper for internal use)
3971    pub(crate) fn make_cell_ref_internal(&self, sheet_id: SheetId, row: u32, col: u32) -> CellRef {
3972        let coord = Coord::new(row, col, true, true);
3973        CellRef::new(sheet_id, coord)
3974    }
3975
3976    /// Create a cell reference from sheet name and Excel 1-based coordinates.
3977    pub fn make_cell_ref(&self, sheet_name: &str, row: u32, col: u32) -> CellRef {
3978        let sheet_id = self.sheet_reg.get_id(sheet_name).unwrap_or(0);
3979        let coord = Coord::from_excel(row, col, true, true);
3980        CellRef::new(sheet_id, coord)
3981    }
3982
3983    /// Check if a vertex is dirty
3984    pub(crate) fn is_dirty(&self, vertex_id: VertexId) -> bool {
3985        self.store.is_dirty(vertex_id)
3986    }
3987
3988    /// Check if a vertex is volatile
3989    pub(crate) fn is_volatile(&self, vertex_id: VertexId) -> bool {
3990        self.store.is_volatile(vertex_id)
3991    }
3992
3993    pub(crate) fn is_dynamic(&self, vertex_id: VertexId) -> bool {
3994        self.store.is_dynamic(vertex_id)
3995    }
3996
3997    /// Get vertex ID for a cell address
3998    pub fn get_vertex_id_for_address(&self, addr: &CellRef) -> Option<&VertexId> {
3999        self.cell_to_vertex.get(addr)
4000    }
4001
4002    #[cfg(test)]
4003    pub fn cell_to_vertex(
4004        &self,
4005    ) -> &std::collections::HashMap<CellRef, VertexId, CoordBuildHasher> {
4006        &self.cell_to_vertex
4007    }
4008
4009    /// Borrow dependencies of a vertex when no pending edge delta exists.
4010    ///
4011    /// This enables zero-allocation traversal in hot scheduler paths.
4012    #[inline]
4013    pub(crate) fn dependencies_slice(&self, vertex_id: VertexId) -> Option<&[VertexId]> {
4014        self.edges.out_edges_ref(vertex_id)
4015    }
4016
4017    /// Get the dependencies of a vertex (for scheduler)
4018    pub(crate) fn get_dependencies(&self, vertex_id: VertexId) -> Vec<VertexId> {
4019        self.edges.out_edges(vertex_id)
4020    }
4021
4022    /// Check if a vertex has a self-loop
4023    pub(crate) fn has_self_loop(&self, vertex_id: VertexId) -> bool {
4024        if let Some(deps) = self.dependencies_slice(vertex_id) {
4025            deps.contains(&vertex_id)
4026        } else {
4027            self.edges.out_edges(vertex_id).contains(&vertex_id)
4028        }
4029    }
4030
4031    /// Borrow dependents of a vertex when no pending edge delta exists.
4032    ///
4033    /// This enables zero-allocation traversal in hot scheduler paths.
4034    #[inline]
4035    pub(crate) fn dependents_slice(&self, vertex_id: VertexId) -> Option<&[VertexId]> {
4036        self.edges.in_edges_ref(vertex_id)
4037    }
4038
4039    /// Get dependents of a vertex (vertices that depend on this vertex)
4040    ///
4041    /// Delta-aware: pending edge mutations that have not been folded into the
4042    /// CSR base yet are merged in via the delta slab's reverse index, so this
4043    /// is O(in-degree) even mid-edit (no O(V) scan, no forced rebuild; #125).
4044    pub(crate) fn get_dependents(&self, vertex_id: VertexId) -> Vec<VertexId> {
4045        self.edges.in_edges_merged(vertex_id)
4046    }
4047
4048    /// Bounded, delta-aware incoming-edge visitor used by read-only
4049    /// introspection. Unlike `get_dependents`, this never constructs the full
4050    /// in-degree before the caller's work limit can stop discovery.
4051    pub(crate) fn visit_direct_dependents_bounded(
4052        &self,
4053        vertex_id: VertexId,
4054        remaining_work: &mut u64,
4055        visitor: &mut dyn FnMut(VertexId) -> bool,
4056    ) -> bool {
4057        self.edges
4058            .visit_in_edges_bounded(vertex_id, remaining_work, visitor)
4059    }
4060
4061    // Internal helper methods for Milestone 0.4
4062
4063    /// Internal: Create a snapshot of vertex state for rollback
4064    #[doc(hidden)]
4065    pub fn snapshot_vertex(&self, id: VertexId) -> crate::engine::VertexSnapshot {
4066        let coord = self.store.coord(id);
4067        let sheet_id = self.store.sheet_id(id);
4068        let kind = self.store.kind(id);
4069        let flags = self.store.flags(id);
4070
4071        // Get value and formula references
4072        let value_ref = self.vertex_values.get(&id).copied();
4073        let formula_ref = self.vertex_formulas.get(&id).copied();
4074
4075        // Get outgoing edges (dependencies)
4076        let out_edges = self.get_dependencies(id);
4077
4078        crate::engine::VertexSnapshot {
4079            coord,
4080            sheet_id,
4081            kind,
4082            flags,
4083            value_ref,
4084            formula_ref,
4085            out_edges,
4086        }
4087    }
4088
4089    /// Internal: Remove all edges for a vertex
4090    #[doc(hidden)]
4091    pub fn remove_all_edges(&mut self, id: VertexId) {
4092        // Enter batch mode to avoid intermediate rebuilds
4093        self.edges.begin_batch();
4094
4095        // Remove outgoing edges (this vertex's dependencies)
4096        self.remove_dependent_edges(id);
4097
4098        // Remove incoming edges (vertices that depend on this vertex).
4099        // get_dependents is delta-aware, so no rebuild is needed here (#125).
4100        let dependents = self.get_dependents(id);
4101        if self.pk_order.is_some()
4102            && let Some(mut pk) = self.pk_order.take()
4103        {
4104            for dependent in &dependents {
4105                pk.remove_edge(id, *dependent);
4106            }
4107            self.pk_order = Some(pk);
4108        }
4109        for dependent in dependents {
4110            self.edges.remove_edge(dependent, id);
4111        }
4112
4113        // Exit batch mode and rebuild once with all changes
4114        self.edges.end_batch();
4115    }
4116
4117    /// Internal: Mark vertex as having #REF! error
4118    #[doc(hidden)]
4119    pub fn mark_as_ref_error(&mut self, id: VertexId) {
4120        if !self.value_cache_enabled {
4121            match self.store.kind(id) {
4122                VertexKind::Cell
4123                | VertexKind::FormulaScalar
4124                | VertexKind::FormulaArray
4125                | VertexKind::Empty => {
4126                    self.ref_error_vertices.insert(id);
4127                    // Canonical-only: graph does not cache cell/formula values.
4128                    // Ensure the dependent subgraph is dirtied so evaluation updates Arrow truth.
4129                    self.vertex_values.remove(&id);
4130                    let _ = self.mark_dirty(id);
4131                    return;
4132                }
4133                _ => {
4134                    // Allow non-cell vertices to use cached values.
4135                }
4136            }
4137        }
4138        let error = LiteralValue::Error(ExcelError::new(ExcelErrorKind::Ref));
4139        let value_ref = self.data_store.store_value(error);
4140        self.vertex_values.insert(id, value_ref);
4141        let _ = self.mark_dirty(id);
4142    }
4143
4144    /// Check if a vertex has a #REF! error
4145    pub fn is_ref_error(&self, id: VertexId) -> bool {
4146        if !self.value_cache_enabled {
4147            match self.store.kind(id) {
4148                VertexKind::Cell
4149                | VertexKind::FormulaScalar
4150                | VertexKind::FormulaArray
4151                | VertexKind::Empty => {
4152                    return self.ref_error_vertices.contains(&id);
4153                }
4154                _ => {
4155                    // Non-cell vertices may still have cached values.
4156                }
4157            }
4158        }
4159        if let Some(value_ref) = self.vertex_values.get(&id) {
4160            let value = self.data_store.retrieve_value(*value_ref);
4161            if let LiteralValue::Error(err) = value {
4162                return err.kind == ExcelErrorKind::Ref;
4163            }
4164        }
4165        false
4166    }
4167
4168    /// Internal: Mark all direct dependents as dirty
4169    #[doc(hidden)]
4170    pub fn mark_dependents_dirty(&mut self, id: VertexId) {
4171        let dependents = self.get_dependents(id);
4172        for dep_id in dependents {
4173            self.store.set_dirty(dep_id, true);
4174            self.formula_dirty.legacy_insert(dep_id);
4175        }
4176    }
4177
4178    /// Internal: Mark a vertex as volatile
4179    #[doc(hidden)]
4180    pub fn mark_volatile(&mut self, id: VertexId, volatile: bool) {
4181        self.store.set_volatile(id, volatile);
4182        if volatile {
4183            self.volatile_vertices.insert(id);
4184        } else {
4185            self.volatile_vertices.remove(&id);
4186        }
4187    }
4188
4189    /// Update vertex coordinate
4190    #[doc(hidden)]
4191    pub fn set_coord(&mut self, id: VertexId, coord: AbsCoord) {
4192        self.store.set_coord(id, coord);
4193    }
4194
4195    /// Update edge cache coordinate
4196    #[doc(hidden)]
4197    pub fn update_edge_coord(&mut self, id: VertexId, coord: AbsCoord) {
4198        self.edges.update_coord(id, coord);
4199    }
4200
4201    /// Mark vertex as deleted (tombstone)
4202    #[doc(hidden)]
4203    pub fn mark_deleted(&mut self, id: VertexId, deleted: bool) {
4204        self.store.mark_deleted(id, deleted);
4205    }
4206
4207    /// Set vertex kind
4208    #[doc(hidden)]
4209    pub fn set_kind(&mut self, id: VertexId, kind: VertexKind) {
4210        self.store.set_kind(id, kind);
4211    }
4212
4213    /// Set vertex dirty flag
4214    #[doc(hidden)]
4215    pub fn set_dirty(&mut self, id: VertexId, dirty: bool) {
4216        self.store.set_dirty(id, dirty);
4217        if dirty {
4218            self.formula_dirty.legacy_insert(id);
4219        } else {
4220            self.formula_dirty.legacy_remove(&id);
4221        }
4222    }
4223
4224    /// Get vertex kind (for testing)
4225    #[cfg(test)]
4226    pub(crate) fn get_kind(&self, id: VertexId) -> VertexKind {
4227        self.store.kind(id)
4228    }
4229
4230    /// Get vertex flags (for testing)
4231    #[cfg(test)]
4232    pub(crate) fn get_flags(&self, id: VertexId) -> u8 {
4233        self.store.flags(id)
4234    }
4235
4236    /// Check if vertex is deleted (for testing)
4237    #[cfg(test)]
4238    pub(crate) fn is_deleted(&self, id: VertexId) -> bool {
4239        self.store.is_deleted(id)
4240    }
4241
4242    /// Force edge rebuild (internal use)
4243    #[doc(hidden)]
4244    pub fn rebuild_edges(&mut self) {
4245        self.edges.rebuild();
4246    }
4247
4248    /// Fold pending edge deltas into the CSR base ahead of a read-heavy phase
4249    /// (scheduling/evaluation), restoring the zero-allocation slice fast
4250    /// paths. No-op when no deltas are pending. This is the read-side half of
4251    /// the #125 amortization: writes defer rebuilds, read bursts pay for at
4252    /// most one.
4253    pub fn flush_pending_edge_deltas(&mut self) {
4254        self.edges.rebuild();
4255    }
4256
4257    /// Get delta size (internal use)
4258    #[doc(hidden)]
4259    pub fn edges_delta_size(&self) -> usize {
4260        self.edges.delta_size()
4261    }
4262
4263    /// Number of full CSR rebuilds performed so far (observability; used by
4264    /// the #125 rebuild-amortization regression tests).
4265    #[doc(hidden)]
4266    pub fn edges_rebuild_count(&self) -> u64 {
4267        self.edges.rebuild_count()
4268    }
4269
4270    /// Get vertex ID for specific cell address
4271    pub fn get_vertex_for_cell(&self, addr: &CellRef) -> Option<VertexId> {
4272        self.cell_to_vertex.get(addr).copied()
4273    }
4274
4275    /// Get coord for a vertex (public for VertexEditor)
4276    pub fn get_coord(&self, id: VertexId) -> AbsCoord {
4277        self.store.coord(id)
4278    }
4279
4280    /// Get sheet_id for a vertex (public for VertexEditor)
4281    pub fn get_sheet_id(&self, id: VertexId) -> SheetId {
4282        self.store.sheet_id(id)
4283    }
4284
4285    /// Get all vertices in a sheet
4286    pub fn vertices_in_sheet(&self, sheet_id: SheetId) -> impl Iterator<Item = VertexId> + '_ {
4287        self.store
4288            .all_vertices()
4289            .filter(move |&id| self.vertex_exists(id) && self.store.sheet_id(id) == sheet_id)
4290    }
4291
4292    /// Does a vertex have a formula associated
4293    pub fn vertex_has_formula(&self, id: VertexId) -> bool {
4294        self.vertex_formulas.contains_key(&id)
4295    }
4296
4297    /// Get all vertices with formulas
4298    pub fn vertices_with_formulas(&self) -> impl Iterator<Item = VertexId> + '_ {
4299        self.vertex_formulas.keys().copied()
4300    }
4301
4302    /// Update a vertex's formula
4303    pub fn update_vertex_formula(&mut self, id: VertexId, ast: ASTNode) -> Result<(), ExcelError> {
4304        // Get the sheet_id for this vertex
4305        let sheet_id = self.store.sheet_id(id);
4306
4307        // Extract dependencies from AST, retaining unresolved names for later linking.
4308        let (new_dependencies, new_range_dependencies, _, named_dependencies, unresolved_names) =
4309            self.extract_dependencies_with_pending_names(&ast, sheet_id)?;
4310
4311        let old_kind = self.store.kind(id);
4312
4313        // Remove all links owned by the previous formula.
4314        self.remove_dependent_edges(id);
4315        self.detach_vertex_from_names(id);
4316        self.clear_pending_name_references(id);
4317
4318        // Store the new formula
4319        let ast_id = self.data_store.store_ast(&ast, &self.sheet_reg);
4320        self.vertex_formulas.insert(id, ast_id);
4321
4322        // Add new dependency edges
4323        self.add_dependent_edges(id, &new_dependencies);
4324        self.add_range_dependent_edges(id, &new_range_dependencies, sheet_id);
4325
4326        if !named_dependencies.is_empty() {
4327            self.attach_vertex_to_names(id, &named_dependencies);
4328        }
4329        for unresolved_name in &unresolved_names {
4330            self.record_pending_name_reference(sheet_id, unresolved_name, id);
4331        }
4332
4333        // Formula replacement supersedes any structural error/cache state left when a
4334        // deleted dependency marked this vertex before its AST was rewritten.
4335        self.ref_error_vertices.remove(&id);
4336        self.vertex_values.remove(&id);
4337
4338        // A structural rewrite must not collapse an existing array formula kind.
4339        self.store.set_kind(
4340            id,
4341            if old_kind == VertexKind::FormulaArray {
4342                VertexKind::FormulaArray
4343            } else {
4344                VertexKind::FormulaScalar
4345            },
4346        );
4347
4348        Ok(())
4349    }
4350
4351    /// Mark a vertex as dirty without propagation (for VertexEditor)
4352    pub fn mark_vertex_dirty(&mut self, vertex_id: VertexId) {
4353        self.store.set_dirty(vertex_id, true);
4354        self.formula_dirty.legacy_insert(vertex_id);
4355    }
4356
4357    /// Batch-mark vertices dirty without propagation.
4358    pub fn mark_vertices_dirty_batch(&mut self, vertices: &[VertexId]) {
4359        self.formula_dirty.legacy_reserve(vertices.len());
4360        for &vertex_id in vertices {
4361            self.store.set_dirty(vertex_id, true);
4362        }
4363        self.formula_dirty.legacy_extend(vertices.iter().copied());
4364    }
4365
4366    /// Update cell mapping for a vertex (for VertexEditor)
4367    pub fn update_cell_mapping(
4368        &mut self,
4369        id: VertexId,
4370        old_addr: Option<CellRef>,
4371        new_addr: CellRef,
4372    ) {
4373        // Remove old mapping if it exists
4374        if let Some(old) = old_addr {
4375            self.cell_to_vertex.remove(&old);
4376        }
4377        // Add new mapping
4378        self.cell_to_vertex.insert(new_addr, id);
4379    }
4380
4381    /// Remove cell mapping (for VertexEditor)
4382    pub fn remove_cell_mapping(&mut self, addr: &CellRef) {
4383        self.cell_to_vertex.remove(addr);
4384    }
4385
4386    /// Get the cell reference for a vertex
4387    pub fn get_cell_ref_for_vertex(&self, id: VertexId) -> Option<CellRef> {
4388        let coord = self.store.coord(id);
4389        let sheet_id = self.store.sheet_id(id);
4390        // Find the cell reference in the mapping
4391        let cell_ref = CellRef::new(sheet_id, Coord::new(coord.row(), coord.col(), true, true));
4392        // Verify it actually maps to this vertex
4393        if self.cell_to_vertex.get(&cell_ref) == Some(&id) {
4394            Some(cell_ref)
4395        } else {
4396            None
4397        }
4398    }
4399
4400    /// Rebuild dependency edges/range links for an existing formula vertex after AST changes.
4401    ///
4402    /// This intentionally reuses the same extraction and edge wiring machinery as
4403    /// `set_cell_formula[_with_volatility]` to preserve edge orientation, placeholder
4404    /// behavior, and name/range dependency semantics.
4405    pub(crate) fn rebuild_formula_dependencies(&mut self, vertex_id: VertexId, ast: &ASTNode) {
4406        let sheet_id = self.store.sheet_id(vertex_id);
4407
4408        // Remove old dependency, name, and pending-name links first.
4409        self.remove_dependent_edges(vertex_id);
4410        self.detach_vertex_from_names(vertex_id);
4411        self.clear_pending_name_references(vertex_id);
4412
4413        let (
4414            new_dependencies,
4415            new_range_dependencies,
4416            _created_placeholders,
4417            named_dependencies,
4418            unresolved_names,
4419        ) = match self.extract_dependencies_with_pending_names(ast, sheet_id) {
4420            Ok(v) => v,
4421            Err(_) => {
4422                self.mark_as_ref_error(vertex_id);
4423                return;
4424            }
4425        };
4426
4427        // Self-reference / name-cycle safety parity with set_cell_formula
4428        // (including the `CyclePolicy::Iterate` self-dependency relaxation).
4429        if new_dependencies.contains(&vertex_id) && !self.config.cycle.allows_self_dependency() {
4430            self.mark_as_ref_error(vertex_id);
4431            return;
4432        }
4433
4434        for &name_vertex in &named_dependencies {
4435            let mut visited = FxHashSet::default();
4436            if self.name_depends_on_vertex(name_vertex, vertex_id, &mut visited) {
4437                self.mark_as_ref_error(vertex_id);
4438                return;
4439            }
4440        }
4441
4442        // Formula is now recoverable again.
4443        self.ref_error_vertices.remove(&vertex_id);
4444        self.vertex_values.remove(&vertex_id);
4445
4446        if !named_dependencies.is_empty() {
4447            self.attach_vertex_to_names(vertex_id, &named_dependencies);
4448        }
4449        for unresolved_name in &unresolved_names {
4450            self.record_pending_name_reference(sheet_id, unresolved_name, vertex_id);
4451        }
4452
4453        self.add_dependent_edges(vertex_id, &new_dependencies);
4454        self.add_range_dependent_edges(vertex_id, &new_range_dependencies, sheet_id);
4455        let _ = self.mark_dirty(vertex_id);
4456    }
4457}
4458
4459// ========== Sheet Management Operations ==========