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