Skip to main content

formualizer_eval/engine/
eval.rs

1use crate::SheetId;
2use crate::arrow_store::{OverlayFragment, OverlayValue, SheetStore};
3use crate::engine::arena::AstNodeId;
4use crate::engine::eval_delta::{DeltaCollector, DeltaMode, EvalDelta};
5use crate::engine::ingest_pipeline::{DependencyPlanRow, FormulaAstInput};
6use crate::engine::live_edges::{LiveEdgeCollector, RecordingContext};
7use crate::engine::live_graph::analyze_live_graph;
8use crate::engine::lookup_index_cache::{
9    BuildOutcome, LookupAxis, LookupIndex, LookupIndexCache, LookupIndexCacheReport,
10    LookupIndexKey, estimate_bytes,
11};
12use crate::engine::named_range::{NameScope, NamedDefinition};
13use crate::engine::range_view::RangeView;
14use crate::engine::row_visibility::RowVisibilityState;
15use crate::engine::spill::{RegionLockManager, SpillMeta, SpillShape};
16use crate::engine::virtual_deps::VirtualDepBuilder;
17use crate::engine::{
18    CycleDetection, CyclePolicy, DependencyGraph, EvalConfig, FormulaIngestBatch,
19    FormulaIngestRecord, FormulaIngestReport, FormulaParseDiagnostic, FormulaParsePolicy,
20    FormulaPlaneMode, RowVisibilitySource, ScheduleUnit, Scheduler, VertexId, VertexKind,
21    VisibilityMaskMode,
22};
23use crate::formula_plane::placement::{
24    CandidateAnalysis, FormulaPlacementCandidate, FormulaPlacementResult, PlacementFallbackReason,
25    place_candidate_family_with_analyses, split_candidate_affine_literal_runs,
26};
27use crate::formula_plane::producer::{
28    DirtyProjectionRule, FormulaConsumerReadIndex, FormulaProducerId, FormulaProducerResultIndex,
29    FormulaProducerWork, ProducerDirtyDomain, SpanReadSummary,
30};
31use crate::formula_plane::region_index::{DirtyDomain, Region};
32use crate::formula_plane::runtime::{
33    FormulaPlane, FormulaSpanId, FormulaSpanRef, PlacementCoord, PlacementDomain, ResultRegion,
34};
35use crate::formula_plane::scheduler::{
36    MixedSchedule, MixedScheduleFallbackReason, build_mixed_schedule,
37};
38#[cfg(test)]
39use crate::formula_plane::span_eval::SpanEvalReport;
40use crate::formula_plane::span_eval::{SpanComputedWriteSink, SpanEvalTask, SpanEvaluator};
41use crate::formula_plane::structural::relocate_ast_for_template_placement;
42use crate::formula_plane::structural_shift::{SpanShiftPlan, StructuralOp, classify_span_for_op};
43use crate::interpreter::Interpreter;
44use crate::reference::{CellRef, Coord, RangeRef};
45use crate::traits::FunctionProvider;
46use crate::traits::{EvaluationContext, ReferenceInfo, Resolver};
47use chrono::Timelike;
48use formualizer_common::{
49    CoordBuildHasher, LiteralValue, col_letters_from_1based, parse_a1_1based,
50};
51use formualizer_parse::parser::ReferenceType;
52use formualizer_parse::{ASTNode, ASTNodeType, ExcelError, ExcelErrorKind};
53use rayon::ThreadPoolBuilder;
54use rustc_hash::{FxHashMap, FxHashSet};
55use std::collections::{BTreeMap, BTreeSet, VecDeque};
56use std::sync::Arc;
57use std::sync::atomic::{AtomicBool, Ordering};
58
59type StagedFormulaEntry = (u32, u32, String);
60
61/// Per-sheet staged-formula store (NOTE(#126) follow-up).
62///
63/// Ingest consumers (`build_graph_all`/`build_graph_for_sheets`) walk staged
64/// entries in INSERTION order, so the order-preserving `Vec` stays the
65/// canonical storage; a `(row, col) → index` map removes the linear dup-scan
66/// that made `stage_formula_text`/`get_staged_formula_text` O(staged-on-sheet)
67/// per call (O(n²) for an n-formula deferred load on one sheet — ~570 ms for
68/// 50k stages, release, before the index). `stage`/`get` are O(1);
69/// `remove` keeps the old O(n) `Vec::remove` (rare path, order preserved).
70#[derive(Debug, Default, Clone)]
71pub(crate) struct StagedSheet {
72    entries: Vec<StagedFormulaEntry>,
73    index: FxHashMap<(u32, u32), usize>,
74}
75
76impl StagedSheet {
77    fn stage(&mut self, row: u32, col: u32, text: String) {
78        match self.index.entry((row, col)) {
79            std::collections::hash_map::Entry::Occupied(slot) => {
80                self.entries[*slot.get()].2 = text;
81            }
82            std::collections::hash_map::Entry::Vacant(slot) => {
83                slot.insert(self.entries.len());
84                self.entries.push((row, col, text));
85            }
86        }
87    }
88
89    fn remove(&mut self, row: u32, col: u32) -> Option<String> {
90        let idx = self.index.remove(&(row, col))?;
91        let (_, _, text) = self.entries.remove(idx);
92        // `Vec::remove` shifted everything after `idx` left by one.
93        for slot in self.index.values_mut() {
94            if *slot > idx {
95                *slot -= 1;
96            }
97        }
98        Some(text)
99    }
100
101    fn get(&self, row: u32, col: u32) -> Option<&str> {
102        self.index
103            .get(&(row, col))
104            .map(|&i| self.entries[i].2.as_str())
105    }
106
107    fn len(&self) -> usize {
108        self.entries.len()
109    }
110
111    fn is_empty(&self) -> bool {
112        self.entries.is_empty()
113    }
114
115    /// Consume into the insertion-ordered entry list (ingest order).
116    fn into_entries(self) -> Vec<StagedFormulaEntry> {
117        self.entries
118    }
119}
120
121type StagedFormulaMap = std::collections::HashMap<String, StagedSheet>;
122
123fn producer_dirty_to_span_dirty(
124    dirty: ProducerDirtyDomain,
125    span_ref: FormulaSpanRef,
126) -> DirtyDomain {
127    match dirty {
128        ProducerDirtyDomain::Whole => DirtyDomain::WholeSpan(span_ref),
129        ProducerDirtyDomain::Cells(cells) => DirtyDomain::Cells(cells),
130        ProducerDirtyDomain::Regions(regions) => DirtyDomain::Regions(regions),
131    }
132}
133type PreparedFormulaBatches = Vec<FormulaIngestBatch>;
134type StagedFormulaBatches = Vec<(String, Vec<StagedFormulaEntry>)>;
135type FormulaPlaneMixedScheduleBuild = (
136    MixedSchedule,
137    BTreeMap<crate::formula_plane::runtime::FormulaSpanId, FormulaSpanRef>,
138    u64,
139    Vec<VertexId>,
140);
141
142type PlannedFormulaMaterialize = BTreeMap<String, Vec<(u32, u32, AstNodeId, DependencyPlanRow)>>;
143
144// Computed-write coalescing pays a fixed grouping/planning cost. For very narrow
145// layers there is not enough work to amortize it, and the direct point-write path
146// is faster while preserving the same visibility semantics.
147const COMPUTED_WRITE_COALESCING_MIN_LAYER_WIDTH: usize = 8;
148
149#[derive(Debug, Clone, PartialEq)]
150pub(crate) enum ComputedWrite {
151    Cell {
152        seq: u64,
153        sheet_id: SheetId,
154        row0: u32,
155        col0: u32,
156        value: OverlayValue,
157    },
158    Rect {
159        seq: u64,
160        sheet_id: SheetId,
161        sr0: u32,
162        sc0: u32,
163        values: Vec<Vec<OverlayValue>>,
164    },
165}
166
167impl ComputedWrite {
168    #[inline]
169    pub(crate) fn seq(&self) -> u64 {
170        match self {
171            ComputedWrite::Cell { seq, .. } | ComputedWrite::Rect { seq, .. } => *seq,
172        }
173    }
174}
175
176#[derive(Debug, Default)]
177pub(crate) struct ComputedWriteBuffer {
178    writes: Vec<ComputedWrite>,
179    next_seq: u64,
180    estimated_bytes: usize,
181}
182
183impl ComputedWriteBuffer {
184    const ENTRY_BASE_BYTES: usize = 32;
185
186    #[inline]
187    pub(crate) fn is_empty(&self) -> bool {
188        self.writes.is_empty()
189    }
190
191    #[inline]
192    pub(crate) fn len(&self) -> usize {
193        self.writes.len()
194    }
195
196    #[inline]
197    pub(crate) fn estimated_bytes(&self) -> usize {
198        self.estimated_bytes
199    }
200
201    #[inline]
202    pub(crate) fn writes(&self) -> &[ComputedWrite] {
203        &self.writes
204    }
205
206    pub(crate) fn push_cell(
207        &mut self,
208        sheet_id: SheetId,
209        row0: u32,
210        col0: u32,
211        value: OverlayValue,
212    ) {
213        let seq = self.next_sequence();
214        self.estimated_bytes = self
215            .estimated_bytes
216            .saturating_add(Self::estimate_value_bytes(&value));
217        self.writes.push(ComputedWrite::Cell {
218            seq,
219            sheet_id,
220            row0,
221            col0,
222            value,
223        });
224    }
225
226    pub(crate) fn push_rect(
227        &mut self,
228        sheet_id: SheetId,
229        sr0: u32,
230        sc0: u32,
231        values: Vec<Vec<OverlayValue>>,
232    ) {
233        let seq = self.next_sequence();
234        let added = values
235            .iter()
236            .flat_map(|row| row.iter())
237            .map(Self::estimate_value_bytes)
238            .fold(0usize, usize::saturating_add);
239        self.estimated_bytes = self.estimated_bytes.saturating_add(added);
240        self.writes.push(ComputedWrite::Rect {
241            seq,
242            sheet_id,
243            sr0,
244            sc0,
245            values,
246        });
247    }
248
249    pub(crate) fn clear(&mut self) {
250        self.writes.clear();
251        self.estimated_bytes = 0;
252    }
253
254    fn take_writes(&mut self) -> Vec<ComputedWrite> {
255        self.estimated_bytes = 0;
256        std::mem::take(&mut self.writes)
257    }
258
259    fn next_sequence(&mut self) -> u64 {
260        let seq = self.next_seq;
261        self.next_seq = self.next_seq.wrapping_add(1);
262        seq
263    }
264
265    #[inline]
266    fn estimate_value_bytes(value: &OverlayValue) -> usize {
267        Self::ENTRY_BASE_BYTES.saturating_add(value.estimated_payload_bytes())
268    }
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
272struct ComputedWriteChunkKey {
273    sheet_id: SheetId,
274    col0: u32,
275    chunk_idx: usize,
276    chunk_start_row0: u32,
277}
278
279#[derive(Debug, Clone, PartialEq)]
280pub(crate) struct ComputedWriteChunkEntryPlan {
281    pub(crate) row_in_chunk: usize,
282    pub(crate) seq: u64,
283    pub(crate) value: OverlayValue,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub(crate) enum ComputedWriteChunkPlanShape {
288    Point,
289    SparseOffsets {
290        entries: usize,
291        span_len: usize,
292    },
293    DenseRange {
294        start: usize,
295        len: usize,
296    },
297    RunRange {
298        start: usize,
299        len: usize,
300        runs: usize,
301    },
302}
303
304#[derive(Debug, Clone, PartialEq)]
305pub(crate) struct ComputedWriteChunkPlan {
306    pub(crate) sheet_id: SheetId,
307    pub(crate) col0: u32,
308    pub(crate) chunk_idx: usize,
309    pub(crate) chunk_start_row0: u32,
310    pub(crate) entries: Vec<ComputedWriteChunkEntryPlan>,
311    pub(crate) shape: ComputedWriteChunkPlanShape,
312}
313
314#[derive(Debug, Clone, Default, PartialEq)]
315pub(crate) struct ComputedWriteCoalescingPlan {
316    pub(crate) chunks: Vec<ComputedWriteChunkPlan>,
317    pub(crate) input_cells: usize,
318    pub(crate) coalesced_cells: usize,
319    pub(crate) overwritten_cells: usize,
320}
321
322impl ComputedWriteCoalescingPlan {
323    #[inline]
324    pub(crate) fn is_empty(&self) -> bool {
325        self.chunks.is_empty()
326    }
327}
328
329impl ComputedWriteChunkPlan {
330    fn from_group(
331        key: ComputedWriteChunkKey,
332        mut entries: Vec<ComputedWriteChunkEntryPlan>,
333    ) -> (Self, usize) {
334        entries.sort_by_key(|entry| (entry.row_in_chunk, entry.seq));
335        let input_len = entries.len();
336        let mut coalesced: Vec<ComputedWriteChunkEntryPlan> = Vec::with_capacity(input_len);
337        for entry in entries {
338            if let Some(prev) = coalesced.last_mut()
339                && prev.row_in_chunk == entry.row_in_chunk
340            {
341                *prev = entry;
342                continue;
343            }
344            coalesced.push(entry);
345        }
346        let overwritten = input_len.saturating_sub(coalesced.len());
347        let shape = Self::classify_shape(&coalesced);
348        (
349            Self {
350                sheet_id: key.sheet_id,
351                col0: key.col0,
352                chunk_idx: key.chunk_idx,
353                chunk_start_row0: key.chunk_start_row0,
354                entries: coalesced,
355                shape,
356            },
357            overwritten,
358        )
359    }
360
361    fn classify_shape(entries: &[ComputedWriteChunkEntryPlan]) -> ComputedWriteChunkPlanShape {
362        debug_assert!(!entries.is_empty());
363        if entries.len() == 1 {
364            return ComputedWriteChunkPlanShape::Point;
365        }
366
367        let start = entries[0].row_in_chunk;
368        let end = entries[entries.len() - 1].row_in_chunk;
369        let span_len = end.saturating_sub(start).saturating_add(1);
370        if span_len != entries.len() {
371            return ComputedWriteChunkPlanShape::SparseOffsets {
372                entries: entries.len(),
373                span_len,
374            };
375        }
376
377        let runs = Self::run_count(entries);
378        if runs < entries.len() {
379            ComputedWriteChunkPlanShape::RunRange {
380                start,
381                len: entries.len(),
382                runs,
383            }
384        } else {
385            ComputedWriteChunkPlanShape::DenseRange {
386                start,
387                len: entries.len(),
388            }
389        }
390    }
391
392    fn run_count(entries: &[ComputedWriteChunkEntryPlan]) -> usize {
393        let mut runs = 0usize;
394        let mut prev: Option<&OverlayValue> = None;
395        for entry in entries {
396            if prev != Some(&entry.value) {
397                runs = runs.saturating_add(1);
398                prev = Some(&entry.value);
399            }
400        }
401        runs
402    }
403}
404
405pub struct Engine<R> {
406    pub(crate) graph: DependencyGraph,
407    resolver: R,
408    pub config: EvalConfig,
409    workbook_load_limits: crate::engine::WorkbookLoadLimits,
410    /// Clock for volatile date/time builtins, wrapped in a per-recalc
411    /// snapshot: sampled once at the start of every evaluation request
412    /// ([`Self::begin_evaluation_request`]) so all `NOW()`/`TODAY()` reads in
413    /// one recalc — including SCC iteration passes — agree (spec §7.11).
414    clock: crate::timezone::SnapshotClock,
415    thread_pool: Option<Arc<rayon::ThreadPool>>,
416    pub recalc_epoch: u64,
417    snapshot_id: std::sync::atomic::AtomicU64,
418    topology_epoch: u64,
419    cached_static_schedule: Option<CachedScheduleEntry>,
420    spill_mgr: ShimSpillManager,
421    /// Arrow-backed storage for sheet values (Phase A)
422    arrow_sheets: SheetStore,
423    /// True if any edit after bulk load; disables Arrow reads for parity
424    has_edited: bool,
425    /// Overlay compaction counter (Phase C instrumentation)
426    overlay_compactions: u64,
427
428    // Overlay memory observability / budget (ticket 503)
429    computed_overlay_bytes_estimate: usize,
430    computed_overlay_mirroring_disabled: bool,
431    /// When true, RangeView resolution materializes from graph/Arrow base per-cell.
432    /// This preserves correctness if we stop mirroring formula/spill outputs into computed overlays.
433    pub(crate) force_materialize_range_views: bool,
434    // Pass-scoped cache for Arrow used-row bounds per column
435    row_bounds_cache: std::sync::RwLock<Option<RowBoundsCache>>,
436    // Snapshot-scoped final used-axis bounds for open-ended references.
437    used_axis_bounds_cache: std::sync::RwLock<Option<UsedAxisBoundsCache>>,
438    lookup_index_cache: LookupIndexCache,
439    source_cache: Arc<std::sync::RwLock<SourceCache>>,
440    /// Staged formulas by sheet when `defer_graph_building` is enabled.
441    staged_formulas: StagedFormulaMap,
442    /// Per-sheet row visibility sidecar state.
443    row_visibility: FxHashMap<SheetId, RowVisibilityState>,
444    /// Cached row visibility masks keyed by sheet/span/mode/version.
445    row_visibility_mask_cache: std::sync::RwLock<
446        FxHashMap<VisibilityMaskCacheKey, std::sync::Arc<arrow_array::BooleanArray>>,
447    >,
448    /// Non-fatal malformed formula diagnostics captured during ingest/graph-build.
449    formula_parse_diagnostics: Vec<FormulaParseDiagnostic>,
450    /// Last centralized formula ingest report.
451    last_formula_ingest_report: Option<FormulaIngestReport>,
452    /// Aggregate centralized formula ingest report for this engine.
453    formula_ingest_report_total: FormulaIngestReport,
454    /// Count of FormulaPlane spans demoted to legacy because one or more of
455    /// their member cells participate in a statically-cyclic SCC. A span member
456    /// must never be span-evaluated (gotcha G8 of the cycle-architecture track,
457    /// refs #112): under `CycleDetection::Static` the cycle stamping would race
458    /// span writes, and under `Runtime` SCC members must be evaluated by the
459    /// legacy `evaluate_scc_unit` path. Cyclic spans are demoted at
460    /// schedule-build time (the earliest point cross-cell cycles through span
461    /// producers become visible) so the cycle members land on the legacy graph
462    /// path. Observational only.
463    formula_plane_cycle_member_span_demotions: u64,
464    /// Times the FormulaPlane coordinator failed over to the legacy
465    /// primitive because the mixed schedule reported only non-cycle
466    /// fallbacks (capacity caps, unsupported projections, missing result
467    /// regions). One increment per `evaluate_all`-level bailout — the
468    /// cyclic-span demote loop must never spin on these. Observational only.
469    formula_plane_capacity_bailouts: u64,
470    /// Transient cancellation flag used during evaluation
471    active_cancel_flag: Option<Arc<AtomicBool>>,
472
473    /// Engine-level action depth.
474    ///
475    /// Ticket 614 introduces `Engine::action` as a stable, commit-only transaction surface.
476    /// Nested actions are currently disallowed (deterministic rule) and will return an error.
477    action_depth: u32,
478
479    // Phase 3b virtual-dependency convergence telemetry
480    last_virtual_dep_telemetry: VirtualDepTelemetry,
481    virtual_dep_fallback_activations: u64,
482
483    // Runtime-cycle SCC evaluation telemetry (RFC #112, Stage 2)
484    last_cycle_telemetry: CycleTelemetry,
485
486    /// SCC members that entered iterative calculation (`CyclePolicy::Iterate`
487    /// with a witnessed live cycle) during the current evaluation request.
488    ///
489    /// Excel re-evaluates circular cells on EVERY recalc (the accumulator
490    /// contract, spec §4/§7.6), but this engine's dirty model marks SCC
491    /// members clean after a recalc and would otherwise skip them forever.
492    /// Resolution: members of iterating SCCs are redirtied volatile-like at
493    /// the end of the same recalc that iterated them
494    /// ([`Self::redirty_for_next_recalc`], called wherever
495    /// `redirty_volatiles` runs). The set is per-recalc, never persisted:
496    /// if an edit breaks the cycle, the next recalc's SCC task either does
497    /// not exist or settles as phantom, nothing re-registers, and the
498    /// redirty chain stops by itself.
499    pending_iterative_redirty: Vec<VertexId>,
500
501    /// Final committed values of iterating-SCC members as of the end of the
502    /// most recent recalc (spec §4 persistence). In canonical (value-cache
503    /// disabled) mode the computed overlay is the ONLY home of a formula's
504    /// value, and structural edits clear computed overlays wholesale
505    /// (`clear_computed_overlay_after_row/_col`) — destroying iteration
506    /// state (accumulators reset to 0; found by the iterate edge corpus).
507    /// This snapshot, refreshed by [`Self::redirty_for_next_recalc`], lets
508    /// the next SCC task re-seed members whose overlay entry vanished.
509    /// Empty unless something iterated — zero cost otherwise.
510    iterative_state_values: FxHashMap<VertexId, LiteralValue>,
511
512    /// FormulaPlane authority `indexes_epoch` observed by the most recent
513    /// successful `evaluate_all` pass. Used to schedule whole-span work for
514    /// any active span the engine has not yet evaluated under the current
515    /// indexes generation; subsequent passes use bounded dirty closures.
516    formula_plane_indexes_epoch_seen: u64,
517
518    #[cfg(test)]
519    last_formula_plane_span_eval_report: Option<SpanEvalReport>,
520}
521
522/// Minimal edit surface used by `Engine::action`.
523///
524/// This wrapper is intentionally thin for ticket 614 (commit-only): it delegates to existing
525/// `Engine` edit methods and does not create changelog boundaries or implement rollback.
526impl<R: EvaluationContext> Engine<R> {
527    pub(crate) fn ingest_pipeline(&mut self) -> crate::engine::ingest_pipeline::IngestPipeline<'_> {
528        self.graph.ingest_pipeline(&self.resolver)
529    }
530}
531
532pub struct EngineAction<'a, R>
533where
534    R: EvaluationContext,
535{
536    engine: &'a mut Engine<R>,
537    name: String,
538    // Optional external ChangeLog pointer used by `Engine::action_with_logger`.
539    // Stored as a raw pointer to avoid creating aliasing `&mut` borrows alongside `&mut Engine`.
540    log: Option<*mut crate::engine::ChangeLog>,
541    // Optional Arrow undo journal used by `Engine::action_atomic`.
542    // Stored as a raw pointer to avoid aliasing issues with `&mut Engine`.
543    arrow_undo: Option<*mut crate::engine::ArrowUndoBatch>,
544    // True when this EngineAction must enforce conservative atomic transaction policy.
545    atomic_policy: bool,
546}
547
548impl<'a, R> EngineAction<'a, R>
549where
550    R: EvaluationContext,
551{
552    #[inline]
553    fn addr_for(&mut self, sheet: &str, row: u32, col: u32) -> crate::reference::CellRef {
554        let sheet_id = self.engine.graph.sheet_id_mut(sheet);
555        let coord = crate::reference::Coord::from_excel(row, col, true, true);
556        crate::reference::CellRef::new(sheet_id, coord)
557    }
558
559    #[inline]
560    pub fn name(&self) -> &str {
561        &self.name
562    }
563
564    #[inline]
565    pub fn set_cell_value(
566        &mut self,
567        sheet: &str,
568        row: u32,
569        col: u32,
570        value: LiteralValue,
571    ) -> Result<(), crate::engine::EditorError> {
572        if self.log.is_some() {
573            let old_value = self.engine.read_cell_value(sheet, row, col);
574            let mut old_formula = self.engine.read_cell_formula_ast(sheet, row, col);
575            let addr = self.addr_for(sheet, row, col);
576            let Some(log_ptr) = self.log else {
577                return Err(crate::engine::EditorError::TransactionFailed {
578                    reason: "action_with_logger: missing ChangeLog".to_string(),
579                });
580            };
581
582            // For atomic journal mode, record computed overlay effects for this cell.
583            // Delta-overlay undo is recorded semantically based on old_value/old_formula.
584            let old_comp = if self.arrow_undo.is_some() {
585                self.engine.read_computed_overlay_cell(sheet, row, col)
586            } else {
587                None
588            };
589
590            self.engine.demote_span_containing_cell_for_write(
591                addr.sheet_id,
592                addr.coord.row(),
593                addr.coord.col(),
594            )?;
595            if old_formula.is_none() {
596                old_formula = self.engine.read_cell_formula_ast(sheet, row, col);
597            }
598
599            let delta_old_sem = if old_formula.is_some() {
600                None
601            } else {
602                Some(old_value.clone().unwrap_or(LiteralValue::Empty))
603            };
604
605            let start_len = unsafe { (&*log_ptr).len() };
606
607            // Safety: `log_ptr` comes from a unique `&mut ChangeLog` in `Engine::action_with_logger`.
608            let log = unsafe { &mut *log_ptr };
609            self.engine.edit_with_logger(log, |editor| {
610                editor.set_cell_value_with_old_state(
611                    addr,
612                    value.clone(),
613                    old_value.clone(),
614                    old_formula.clone(),
615                );
616            });
617            self.engine
618                .record_formula_plane_structural_change(StructuralScope::Cell {
619                    sheet: addr.sheet_id,
620                    row: addr.coord.row(),
621                    col: addr.coord.col(),
622                });
623
624            if let Some(undo_ptr) = self.arrow_undo {
625                // 1) Spill snapshot operations (computed overlay rect restore).
626                let new_events = &unsafe { (&*log_ptr).events() }[start_len..];
627                let undo = unsafe { &mut *undo_ptr };
628                self.engine
629                    .record_spill_ops_into_arrow_undo(undo, new_events);
630
631                // 2) Delta/computed overlay single-cell deltas.
632                let new_comp = self.engine.read_computed_overlay_cell(sheet, row, col);
633                let sheet_id = self.engine.graph.sheet_id_mut(sheet);
634                let row0 = row.saturating_sub(1);
635                let col0 = col.saturating_sub(1);
636                let delta_new_sem = Some(value.clone());
637                undo.record_delta_cell(sheet_id, row0, col0, delta_old_sem, delta_new_sem);
638                undo.record_computed_cell(sheet_id, row0, col0, old_comp, new_comp);
639            }
640            Ok(())
641        } else {
642            self.engine
643                .set_cell_value(sheet, row, col, value)
644                .map_err(crate::engine::EditorError::from)
645        }
646    }
647
648    #[inline]
649    pub fn set_cell_formula(
650        &mut self,
651        sheet: &str,
652        row: u32,
653        col: u32,
654        ast: ASTNode,
655    ) -> Result<(), crate::engine::EditorError> {
656        if self.log.is_some() {
657            let old_value = self.engine.read_cell_value(sheet, row, col);
658            let mut old_formula = self.engine.read_cell_formula_ast(sheet, row, col);
659            let addr = self.addr_for(sheet, row, col);
660            let Some(log_ptr) = self.log else {
661                return Err(crate::engine::EditorError::TransactionFailed {
662                    reason: "action_with_logger: missing ChangeLog".to_string(),
663                });
664            };
665
666            self.engine.demote_span_containing_cell_for_write(
667                addr.sheet_id,
668                addr.coord.row(),
669                addr.coord.col(),
670            )?;
671            if old_formula.is_none() {
672                old_formula = self.engine.read_cell_formula_ast(sheet, row, col);
673            }
674            let delta_old = if self.arrow_undo.is_some() {
675                if old_formula.is_some() {
676                    None
677                } else {
678                    Some(old_value.clone().unwrap_or(LiteralValue::Empty))
679                }
680            } else {
681                None
682            };
683            let start_len = unsafe { (&*log_ptr).len() };
684
685            // Safety: `log_ptr` comes from a unique `&mut ChangeLog` in `Engine::action_with_logger`.
686            let log = unsafe { &mut *log_ptr };
687            self.engine.edit_with_logger(log, |editor| {
688                editor.set_cell_formula_with_old_state(addr, ast.clone(), old_value, old_formula);
689            });
690            self.engine
691                .record_formula_plane_structural_change(StructuralScope::Cell {
692                    sheet: addr.sheet_id,
693                    row: addr.coord.row(),
694                    col: addr.coord.col(),
695                });
696
697            if let Some(undo_ptr) = self.arrow_undo {
698                let new_events = &unsafe { (&*log_ptr).events() }[start_len..];
699                let undo = unsafe { &mut *undo_ptr };
700                self.engine
701                    .record_spill_ops_into_arrow_undo(undo, new_events);
702                let delta_new: Option<LiteralValue> = None;
703                let sheet_id = self.engine.graph.sheet_id_mut(sheet);
704                let row0 = row.saturating_sub(1);
705                let col0 = col.saturating_sub(1);
706                undo.record_delta_cell(sheet_id, row0, col0, delta_old, delta_new);
707            }
708            Ok(())
709        } else {
710            self.engine
711                .set_cell_formula(sheet, row, col, ast)
712                .map_err(crate::engine::EditorError::from)
713        }
714    }
715
716    #[inline]
717    pub fn set_row_hidden(
718        &mut self,
719        sheet: &str,
720        row_1based: u32,
721        hidden: bool,
722        source: RowVisibilitySource,
723    ) -> Result<(), crate::engine::EditorError> {
724        if self.log.is_some() {
725            let sheet_id = self.engine.ensure_known_sheet_id(sheet)?;
726            let row0 = Engine::<R>::normalize_row_1based(row_1based)?;
727            let old_hidden = self
728                .engine
729                .row_visibility
730                .get(&sheet_id)
731                .map(|state| state.is_row_hidden(row0, Some(source)))
732                .unwrap_or(false);
733            if old_hidden == hidden {
734                return Ok(());
735            }
736
737            let _ = self
738                .engine
739                .set_row_hidden_by_sheet_id(sheet_id, row0, hidden, source);
740
741            let Some(log_ptr) = self.log else {
742                return Err(crate::engine::EditorError::TransactionFailed {
743                    reason: "action_with_logger: missing ChangeLog".to_string(),
744                });
745            };
746            unsafe { &mut *log_ptr }.record(crate::engine::ChangeEvent::SetRowVisibility {
747                sheet_id,
748                row0,
749                source,
750                old_hidden,
751                new_hidden: hidden,
752            });
753
754            Ok(())
755        } else {
756            self.engine
757                .set_row_hidden(sheet, row_1based, hidden, source)
758        }
759    }
760
761    #[inline]
762    pub fn set_rows_hidden(
763        &mut self,
764        sheet: &str,
765        start_row_1based: u32,
766        end_row_1based: u32,
767        hidden: bool,
768        source: RowVisibilitySource,
769    ) -> Result<(), crate::engine::EditorError> {
770        if self.log.is_some() {
771            let sheet_id = self.engine.ensure_known_sheet_id(sheet)?;
772            let (start_row0, end_row0) =
773                Engine::<R>::normalize_row_range_1based(start_row_1based, end_row_1based)?;
774
775            let Some(log_ptr) = self.log else {
776                return Err(crate::engine::EditorError::TransactionFailed {
777                    reason: "action_with_logger: missing ChangeLog".to_string(),
778                });
779            };
780            let log = unsafe { &mut *log_ptr };
781
782            for row0 in start_row0..=end_row0 {
783                let old_hidden = self
784                    .engine
785                    .row_visibility
786                    .get(&sheet_id)
787                    .map(|state| state.is_row_hidden(row0, Some(source)))
788                    .unwrap_or(false);
789                if old_hidden == hidden {
790                    continue;
791                }
792
793                let _ = self
794                    .engine
795                    .set_row_hidden_by_sheet_id(sheet_id, row0, hidden, source);
796
797                log.record(crate::engine::ChangeEvent::SetRowVisibility {
798                    sheet_id,
799                    row0,
800                    source,
801                    old_hidden,
802                    new_hidden: hidden,
803                });
804            }
805
806            Ok(())
807        } else {
808            self.engine
809                .set_rows_hidden(sheet, start_row_1based, end_row_1based, hidden, source)
810        }
811    }
812
813    #[inline]
814    pub fn insert_rows(
815        &mut self,
816        sheet: &str,
817        before: u32,
818        count: u32,
819    ) -> Result<crate::engine::ShiftSummary, crate::engine::EditorError> {
820        if self.log.is_some() {
821            let Some(log_ptr) = self.log else {
822                return Err(crate::engine::EditorError::TransactionFailed {
823                    reason: "action_atomic: missing ChangeLog".to_string(),
824                });
825            };
826
827            let sheet_id = self.engine.graph.sheet_id_mut(sheet);
828            let before0 = before.saturating_sub(1);
829            let op = StructuralOp::InsertRows {
830                sheet_id,
831                before: before0,
832                count,
833            };
834            self.engine.demote_spans_for_structural_op(
835                op,
836                Engine::<R>::structural_row_region(sheet_id, before0),
837            )?;
838
839            // Graph structural insert (logged) - no snapshot bump.
840            let summary = {
841                let log = unsafe { &mut *log_ptr };
842                let mut out: Result<crate::engine::ShiftSummary, crate::engine::EditorError> =
843                    Ok(crate::engine::ShiftSummary::default());
844                self.engine.edit_with_logger(log, |editor| {
845                    out = editor.insert_rows(sheet_id, before0, count);
846                });
847                out?
848            };
849
850            // Arrow insert (truth) + undo op.
851            self.engine.ensure_arrow_sheet(sheet);
852            if let Some(asheet) = self.engine.arrow_sheets.sheet_mut(sheet) {
853                asheet.insert_rows(before0 as usize, count as usize);
854            }
855            self.engine
856                .shift_row_visibility_insert(sheet_id, before0, count);
857            if let Some(undo_ptr) = self.arrow_undo {
858                unsafe { &mut *undo_ptr }.record_insert_rows(sheet_id, before0, count);
859            }
860            Ok(summary)
861        } else {
862            self.engine.insert_rows(sheet, before, count)
863        }
864    }
865
866    #[inline]
867    pub fn delete_rows(
868        &mut self,
869        sheet: &str,
870        start: u32,
871        count: u32,
872    ) -> Result<crate::engine::ShiftSummary, crate::engine::EditorError> {
873        if self.atomic_policy {
874            return Err(crate::engine::EditorError::TransactionUnsupported {
875                reason:
876                    "delete_rows is not supported inside atomic actions (conservative rollback policy)"
877                        .to_string(),
878            });
879        }
880        self.engine.delete_rows(sheet, start, count)
881    }
882
883    #[inline]
884    pub fn insert_columns(
885        &mut self,
886        sheet: &str,
887        before: u32,
888        count: u32,
889    ) -> Result<crate::engine::ShiftSummary, crate::engine::EditorError> {
890        if self.log.is_some() {
891            let Some(log_ptr) = self.log else {
892                return Err(crate::engine::EditorError::TransactionFailed {
893                    reason: "action_atomic: missing ChangeLog".to_string(),
894                });
895            };
896
897            let sheet_id = self.engine.graph.sheet_id_mut(sheet);
898            let before0 = before.saturating_sub(1);
899            let op = StructuralOp::InsertColumns {
900                sheet_id,
901                before: before0,
902                count,
903            };
904            self.engine.demote_spans_for_structural_op(
905                op,
906                Engine::<R>::structural_col_region(sheet_id, before0),
907            )?;
908
909            let summary = {
910                let log = unsafe { &mut *log_ptr };
911                let mut out: Result<crate::engine::ShiftSummary, crate::engine::EditorError> =
912                    Ok(crate::engine::ShiftSummary::default());
913                self.engine.edit_with_logger(log, |editor| {
914                    out = editor.insert_columns(sheet_id, before0, count);
915                });
916                out?
917            };
918
919            self.engine.ensure_arrow_sheet(sheet);
920            if let Some(asheet) = self.engine.arrow_sheets.sheet_mut(sheet) {
921                asheet.insert_columns(before0 as usize, count as usize);
922            }
923            if let Some(undo_ptr) = self.arrow_undo {
924                unsafe { &mut *undo_ptr }.record_insert_cols(sheet_id, before0, count);
925            }
926            Ok(summary)
927        } else {
928            self.engine.insert_columns(sheet, before, count)
929        }
930    }
931
932    #[inline]
933    pub fn delete_columns(
934        &mut self,
935        sheet: &str,
936        start: u32,
937        count: u32,
938    ) -> Result<crate::engine::ShiftSummary, crate::engine::EditorError> {
939        if self.atomic_policy {
940            return Err(crate::engine::EditorError::TransactionUnsupported {
941                reason:
942                    "delete_columns is not supported inside atomic actions (conservative rollback policy)"
943                        .to_string(),
944            });
945        }
946        self.engine.delete_columns(sheet, start, count)
947    }
948
949    /// Start an action from within an action.
950    ///
951    /// Nested actions are currently disallowed (ticket 614), so this will return a
952    /// `EditorError::TransactionFailed` while an outer action is active.
953    #[inline]
954    pub fn action<T>(
955        &mut self,
956        name: impl AsRef<str>,
957        f: impl FnOnce(&mut EngineAction<'_, R>) -> Result<T, crate::engine::EditorError>,
958    ) -> Result<T, crate::engine::EditorError> {
959        self.engine.action(name, f)
960    }
961}
962
963struct ActionDepthGuard<'a, R> {
964    engine: *mut Engine<R>,
965    _marker: std::marker::PhantomData<&'a mut Engine<R>>,
966}
967
968impl<'a, R> Drop for ActionDepthGuard<'a, R> {
969    fn drop(&mut self) {
970        // Safety: the guard is created from a unique `&mut Engine` borrow and lives no longer
971        // than the surrounding `Engine::action` call.
972        unsafe {
973            let e = &mut *self.engine;
974            e.action_depth = e.action_depth.saturating_sub(1);
975        }
976    }
977}
978
979#[derive(Default)]
980struct SourceCache {
981    scalars: FxHashMap<(String, Option<u64>), LiteralValue>,
982    tables: FxHashMap<(String, Option<u64>), Arc<dyn crate::traits::Table>>,
983}
984
985#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
986struct VisibilityMaskCacheKey {
987    sheet_id: SheetId,
988    start_row0: u32,
989    end_row0: u32,
990    mode: VisibilityMaskMode,
991    version: u64,
992}
993
994#[derive(Debug, Clone, Copy, PartialEq, Eq)]
995enum StructuralScope {
996    Cell { sheet: SheetId, row: u32, col: u32 },
997    Region(Region),
998    Sheet(SheetId),
999    RemovedSheet(SheetId),
1000    AllSheets,
1001}
1002
1003struct SourceCacheSession {
1004    cache: Arc<std::sync::RwLock<SourceCache>>,
1005}
1006
1007impl Drop for SourceCacheSession {
1008    fn drop(&mut self) {
1009        if let Ok(mut g) = self.cache.write() {
1010            *g = SourceCache::default();
1011        }
1012    }
1013}
1014
1015#[derive(Debug)]
1016pub struct EvalResult {
1017    pub computed_vertices: usize,
1018    pub cycle_errors: usize,
1019    pub elapsed: std::time::Duration,
1020}
1021
1022/// Read-only engine counters used by benchmark/instrumentation tooling.
1023///
1024/// These counters are deliberately observational: collecting them must not mutate engine state or
1025/// alter formula evaluation semantics.
1026#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1027pub struct EngineBaselineStats {
1028    pub graph_vertex_count: usize,
1029    pub graph_formula_vertex_count: usize,
1030    pub graph_edge_count: usize,
1031    pub dirty_vertex_count: usize,
1032    pub evaluation_vertex_count: usize,
1033    pub formula_ast_root_count: usize,
1034    pub formula_ast_node_count: usize,
1035    pub staged_formula_count: usize,
1036    pub formula_plane_active_span_count: usize,
1037    pub formula_plane_producer_result_entries: usize,
1038    pub formula_plane_consumer_read_entries: usize,
1039    /// Number of spans demoted to legacy because a member participated in a
1040    /// statically-cyclic SCC (gotcha G8, refs #112).
1041    pub formula_plane_cycle_member_span_demotions: u64,
1042}
1043
1044#[derive(Debug, Clone, Default)]
1045pub struct VirtualDepTelemetry {
1046    pub candidate_vertices_total: usize,
1047    pub vdeps_vertices_total: usize,
1048    pub vdeps_edges_total: usize,
1049    pub builder_elapsed_ms_total: u128,
1050    pub schedule_virtual_passes: usize,
1051    pub schedule_static_passes: usize,
1052    pub schedule_cache_hits: usize,
1053    pub schedule_cache_misses: usize,
1054    pub reused_schedule_vertices_total: usize,
1055    pub replan_iterations: usize,
1056    pub changed_vdeps_total: usize,
1057    pub bailout_reason: Option<&'static str>,
1058    pub fallback_mode_activations: u64,
1059}
1060
1061/// Per-recalc telemetry for SCC evaluation under `CycleDetection::Runtime`
1062/// (spec `formualizer-cycle-semantics-spec.md` §10).
1063///
1064/// Collection is unconditional: SCC tasks are rare relative to ordinary
1065/// vertex evaluation and the counters are a handful of integer adds per
1066/// task, so no config flag gates them (unlike [`VirtualDepTelemetry`],
1067/// which pays per-schedule costs). Counters reset at the start of every
1068/// evaluation request.
1069#[derive(Debug, Clone, Default, PartialEq)]
1070pub struct CycleTelemetry {
1071    /// SCC tasks executed (static SCCs that reached Runtime evaluation).
1072    pub static_sccs: usize,
1073    /// SCC tasks whose live subgraph was acyclic — values produced.
1074    pub phantom_sccs: usize,
1075    /// Distinct live cycles witnessed across all SCC tasks.
1076    pub live_cycles_witnessed: usize,
1077    /// Cells stamped `#CIRC!` by Runtime SCC tasks.
1078    pub circ_cells_stamped: usize,
1079    /// Evaluation sweeps over (subsets of) SCC members, totalled across tasks
1080    /// (pass 1 included).
1081    pub settle_passes_total: usize,
1082    /// Largest pass count any single SCC task needed.
1083    pub max_passes_single_scc: usize,
1084    /// SCC tasks that entered iterative calculation (`CyclePolicy::Iterate`
1085    /// with a witnessed live cycle). RFC #113, Stage 3.
1086    pub iterated_sccs: usize,
1087    /// Iterating SCC tasks that stopped because every member passed the
1088    /// spec-§6 convergence test.
1089    pub converged_sccs: usize,
1090    /// SCC tasks that stopped at a pass cap. Under `CyclePolicy::Iterate`
1091    /// this is the Excel `max_iterations` cap (NOT an error — last values
1092    /// are kept; includes the no-convergence-test `max_iterations: 1`
1093    /// contract). Under `CyclePolicy::Error` it is the defensive acyclic
1094    /// settle cap (|SCC| + 2), which only a bug can hit.
1095    pub capped_sccs: usize,
1096    /// Largest `|Δ|` observed in any member's final-pass convergence
1097    /// comparison across iterating SCC tasks (numeric-class members only).
1098    /// `0.0` when no comparison ran (e.g. `max_iterations: 1`).
1099    pub max_abs_delta_at_stop: f64,
1100    /// Identical-bit NaN vs NaN member comparisons that were treated as
1101    /// converged (spec §6 NaN rule).
1102    pub nan_converged: usize,
1103    /// Total wall-clock time spent inside Runtime SCC tasks.
1104    pub elapsed_ms: u128,
1105}
1106
1107#[derive(Debug, Clone, Copy)]
1108struct ScheduleBuildMeta {
1109    candidate_vertices: usize,
1110    vdeps_vertices: usize,
1111    vdeps_edges: usize,
1112    builder_elapsed_ms: u128,
1113    used_virtual_schedule: bool,
1114    schedule_cache_hit: bool,
1115    schedule_cache_eligible: bool,
1116}
1117
1118#[derive(Debug, Clone)]
1119struct CachedScheduleEntry {
1120    topology_epoch: u64,
1121    candidate_vertices: Vec<VertexId>,
1122    schedule: crate::engine::scheduler::Schedule,
1123}
1124
1125type ScheduleBuildOutput = (
1126    crate::engine::scheduler::Schedule,
1127    FxHashMap<VertexId, Vec<VertexId>>,
1128    ScheduleBuildMeta,
1129);
1130
1131/// Cached evaluation schedule that can be replayed across multiple recalculations.
1132#[derive(Debug)]
1133pub struct RecalcPlan {
1134    schedule: crate::engine::Schedule,
1135    has_dynamic_refs: bool,
1136}
1137
1138impl RecalcPlan {
1139    pub fn layer_count(&self) -> usize {
1140        self.schedule.layers.len()
1141    }
1142
1143    pub fn has_dynamic_refs(&self) -> bool {
1144        self.has_dynamic_refs
1145    }
1146}
1147
1148#[cfg(test)]
1149pub(crate) mod criteria_mask_test_hooks {
1150    use std::cell::Cell;
1151
1152    thread_local! {
1153        static TEXT_SEGMENTS_TOTAL: Cell<usize> = const { Cell::new(0) };
1154        static TEXT_SEGMENTS_ALL_NULL: Cell<usize> = const { Cell::new(0) };
1155    }
1156
1157    pub fn reset_text_segment_counters() {
1158        TEXT_SEGMENTS_TOTAL.with(|c| c.set(0));
1159        TEXT_SEGMENTS_ALL_NULL.with(|c| c.set(0));
1160    }
1161
1162    pub fn text_segment_counters() -> (usize, usize) {
1163        let a = TEXT_SEGMENTS_TOTAL.with(|c| c.get());
1164        let b = TEXT_SEGMENTS_ALL_NULL.with(|c| c.get());
1165        (a, b)
1166    }
1167
1168    pub(crate) fn inc_total() {
1169        TEXT_SEGMENTS_TOTAL.with(|c| c.set(c.get() + 1));
1170    }
1171    pub(crate) fn inc_all_null() {
1172        TEXT_SEGMENTS_ALL_NULL.with(|c| c.set(c.get() + 1));
1173    }
1174}
1175
1176#[cfg(test)]
1177pub(crate) mod visibility_mask_test_hooks {
1178    use std::cell::Cell;
1179
1180    thread_local! {
1181        static HITS: Cell<usize> = const { Cell::new(0) };
1182        static MISSES: Cell<usize> = const { Cell::new(0) };
1183        static EVICTIONS: Cell<usize> = const { Cell::new(0) };
1184    }
1185
1186    pub fn reset() {
1187        HITS.with(|c| c.set(0));
1188        MISSES.with(|c| c.set(0));
1189        EVICTIONS.with(|c| c.set(0));
1190    }
1191
1192    pub fn counters() -> (usize, usize, usize) {
1193        let hits = HITS.with(|c| c.get());
1194        let misses = MISSES.with(|c| c.get());
1195        let evictions = EVICTIONS.with(|c| c.get());
1196        (hits, misses, evictions)
1197    }
1198
1199    pub(crate) fn inc_hit() {
1200        HITS.with(|c| c.set(c.get() + 1));
1201    }
1202
1203    pub(crate) fn inc_miss() {
1204        MISSES.with(|c| c.set(c.get() + 1));
1205    }
1206
1207    pub(crate) fn inc_eviction() {
1208        EVICTIONS.with(|c| c.set(c.get() + 1));
1209    }
1210}
1211
1212fn compute_criteria_mask(
1213    view: &RangeView<'_>,
1214    col_in_view: usize,
1215    pred: &crate::args::CriteriaPredicate,
1216) -> Option<std::sync::Arc<arrow_array::BooleanArray>> {
1217    use crate::compute_prelude::{boolean, cmp, concat_arrays};
1218    use arrow::compute::kernels::comparison::{ilike, nilike};
1219    use arrow_array::{
1220        Array as _, ArrayRef, BooleanArray, Float64Array, StringArray, builder::BooleanBuilder,
1221    };
1222
1223    // Helper: apply a numeric predicate to a single Float64Array chunk
1224    fn apply_numeric_pred(
1225        chunk: &Float64Array,
1226        pred: &crate::args::CriteriaPredicate,
1227    ) -> Option<BooleanArray> {
1228        match pred {
1229            crate::args::CriteriaPredicate::Gt(n) => {
1230                cmp::gt(chunk, &Float64Array::new_scalar(*n)).ok()
1231            }
1232            crate::args::CriteriaPredicate::Ge(n) => {
1233                cmp::gt_eq(chunk, &Float64Array::new_scalar(*n)).ok()
1234            }
1235            crate::args::CriteriaPredicate::Lt(n) => {
1236                cmp::lt(chunk, &Float64Array::new_scalar(*n)).ok()
1237            }
1238            crate::args::CriteriaPredicate::Le(n) => {
1239                cmp::lt_eq(chunk, &Float64Array::new_scalar(*n)).ok()
1240            }
1241            crate::args::CriteriaPredicate::Eq(v) => match v {
1242                formualizer_common::LiteralValue::Number(x) => {
1243                    cmp::eq(chunk, &Float64Array::new_scalar(*x)).ok()
1244                }
1245                formualizer_common::LiteralValue::Int(i) => {
1246                    cmp::eq(chunk, &Float64Array::new_scalar(*i as f64)).ok()
1247                }
1248                _ => None,
1249            },
1250            crate::args::CriteriaPredicate::Ne(v) => match v {
1251                formualizer_common::LiteralValue::Number(x) => {
1252                    cmp::neq(chunk, &Float64Array::new_scalar(*x)).ok()
1253                }
1254                formualizer_common::LiteralValue::Int(i) => {
1255                    cmp::neq(chunk, &Float64Array::new_scalar(*i as f64)).ok()
1256                }
1257                _ => None,
1258            },
1259            _ => None,
1260        }
1261    }
1262
1263    // Check if this is a numeric predicate that can be applied per-chunk
1264    let is_numeric_pred = matches!(
1265        pred,
1266        crate::args::CriteriaPredicate::Gt(_)
1267            | crate::args::CriteriaPredicate::Ge(_)
1268            | crate::args::CriteriaPredicate::Lt(_)
1269            | crate::args::CriteriaPredicate::Le(_)
1270            | crate::args::CriteriaPredicate::Eq(formualizer_common::LiteralValue::Number(_))
1271            | crate::args::CriteriaPredicate::Eq(formualizer_common::LiteralValue::Int(_))
1272            | crate::args::CriteriaPredicate::Ne(formualizer_common::LiteralValue::Number(_))
1273            | crate::args::CriteriaPredicate::Ne(formualizer_common::LiteralValue::Int(_))
1274    );
1275
1276    // OPTIMIZED PATH: For numeric predicates, apply per-chunk and concatenate boolean masks.
1277    // This avoids materializing the full numeric column (64-bit per element) and instead
1278    // concatenates boolean masks (1-bit per element) - a 64x memory reduction.
1279    if is_numeric_pred {
1280        let mut bool_parts: Vec<BooleanArray> = Vec::new();
1281        for res in view.numbers_slices() {
1282            let (_rs, _rl, cols_seg) = res.ok()?;
1283            if col_in_view < cols_seg.len() {
1284                let chunk = cols_seg[col_in_view].as_ref();
1285                let mask = apply_numeric_pred(chunk, pred)?;
1286                bool_parts.push(mask);
1287            }
1288        }
1289
1290        if bool_parts.is_empty() {
1291            return None;
1292        } else if bool_parts.len() == 1 {
1293            return Some(std::sync::Arc::new(bool_parts.remove(0)));
1294        } else {
1295            // Concatenate boolean masks (much cheaper than concatenating Float64 arrays)
1296            let anys: Vec<&dyn arrow_array::Array> = bool_parts
1297                .iter()
1298                .map(|a| a as &dyn arrow_array::Array)
1299                .collect();
1300            let conc: ArrayRef = concat_arrays(&anys).ok()?;
1301            let ba = conc.as_any().downcast_ref::<BooleanArray>()?.clone();
1302            return Some(std::sync::Arc::new(ba));
1303        }
1304    }
1305
1306    // TEXT PATH: build masks per row-chunk using lowered text slices.
1307    // This avoids concatenating full-string columns just to compute a boolean mask.
1308    let (text_kind, text_pat, empty_special) = match pred {
1309        crate::args::CriteriaPredicate::Eq(formualizer_common::LiteralValue::Text(t)) => {
1310            (0u8, t.to_lowercase(), t.is_empty())
1311        }
1312        crate::args::CriteriaPredicate::Ne(formualizer_common::LiteralValue::Text(t)) => {
1313            (1u8, t.to_lowercase(), false)
1314        }
1315        crate::args::CriteriaPredicate::TextLike {
1316            pattern,
1317            case_insensitive,
1318        } => {
1319            let p = if *case_insensitive {
1320                pattern.to_lowercase()
1321            } else {
1322                pattern.clone()
1323            };
1324            (2u8, p.replace('*', "%").replace('?', "_"), false)
1325        }
1326        _ => return None,
1327    };
1328
1329    let pat = StringArray::new_scalar(text_pat);
1330    let mut bool_parts: Vec<BooleanArray> = Vec::new();
1331
1332    for res in view.iter_row_chunks() {
1333        let cs = res.ok()?;
1334        if cs.row_len == 0 {
1335            continue;
1336        }
1337        #[cfg(test)]
1338        criteria_mask_test_hooks::inc_total();
1339
1340        let slices = view.slice_lowered_text(cs.row_start, cs.row_len);
1341        if col_in_view >= slices.len() {
1342            return None;
1343        }
1344
1345        let seg_opt = slices[col_in_view].as_ref().map(|a| a.as_ref());
1346        let seg = match seg_opt {
1347            Some(s) => s,
1348            None => {
1349                #[cfg(test)]
1350                criteria_mask_test_hooks::inc_all_null();
1351                if text_kind == 0 && empty_special {
1352                    // Eq("") treats nulls (Empty) as equal.
1353                    let mut bb = BooleanBuilder::with_capacity(cs.row_len);
1354                    bb.append_n(cs.row_len, true);
1355                    bool_parts.push(bb.finish());
1356                } else {
1357                    // For non-empty patterns, ilike/nilike return null on null inputs.
1358                    bool_parts.push(BooleanArray::new_null(cs.row_len));
1359                }
1360                continue;
1361            }
1362        };
1363
1364        let seg_sa = seg.as_any().downcast_ref::<StringArray>()?;
1365        let mut m = match text_kind {
1366            0 => ilike(seg_sa, &pat).ok()?,
1367            1 => nilike(seg_sa, &pat).ok()?,
1368            2 => ilike(seg_sa, &pat).ok()?,
1369            _ => return None,
1370        };
1371
1372        if text_kind == 0 && empty_special {
1373            // Treat nulls as equal to empty string
1374            let mut bb = BooleanBuilder::with_capacity(seg_sa.len());
1375            for i in 0..seg_sa.len() {
1376                bb.append_value(seg_sa.is_null(i));
1377            }
1378            let nulls = bb.finish();
1379            m = boolean::or_kleene(&m, &nulls).ok()?;
1380        }
1381
1382        bool_parts.push(m);
1383    }
1384
1385    if bool_parts.is_empty() {
1386        None
1387    } else if bool_parts.len() == 1 {
1388        Some(std::sync::Arc::new(bool_parts.remove(0)))
1389    } else {
1390        let anys: Vec<&dyn arrow_array::Array> = bool_parts
1391            .iter()
1392            .map(|a| a as &dyn arrow_array::Array)
1393            .collect();
1394        let conc: ArrayRef = concat_arrays(&anys).ok()?;
1395        let ba = conc.as_any().downcast_ref::<BooleanArray>()?.clone();
1396        Some(std::sync::Arc::new(ba))
1397    }
1398}
1399
1400#[derive(Debug, Clone)]
1401pub struct LayerInfo {
1402    pub vertex_count: usize,
1403    pub parallel_eligible: bool,
1404    pub sample_cells: Vec<String>, // Sample of up to 5 cell addresses
1405}
1406
1407#[derive(Debug, Clone)]
1408pub struct EvalPlan {
1409    pub total_vertices_to_evaluate: usize,
1410    pub layers: Vec<LayerInfo>,
1411    pub cycles_detected: usize,
1412    pub dirty_count: usize,
1413    pub volatile_count: usize,
1414    pub parallel_enabled: bool,
1415    pub estimated_parallel_layers: usize,
1416    pub target_cells: Vec<String>,
1417}
1418
1419impl<R> Engine<R>
1420where
1421    R: EvaluationContext,
1422{
1423    /// # Panics
1424    /// Panics when `config.cycle` is invalid ([`CycleConfig::validate`],
1425    /// spec §2): `Iterate` with `detection: Static`, `max_iterations == 0`,
1426    /// or a negative/non-finite `max_change`. `EvalConfig::with_cycle`
1427    /// rejects these at build; this re-validates configs assembled via
1428    /// struct literals.
1429    pub fn new(resolver: R, config: EvalConfig) -> Self {
1430        if let Err(msg) = config.cycle.validate() {
1431            panic!("invalid CycleConfig: {msg}");
1432        }
1433        crate::builtins::load_builtins();
1434
1435        let clock = config.deterministic_mode.build_clock().unwrap_or_else(|_| {
1436            #[cfg(feature = "system-clock")]
1437            {
1438                Arc::new(crate::timezone::SystemClock::new(
1439                    crate::timezone::TimeZoneSpec::default(),
1440                ))
1441            }
1442            #[cfg(not(feature = "system-clock"))]
1443            {
1444                Arc::new(crate::timezone::FixedClock::new(
1445                    chrono::DateTime::UNIX_EPOCH,
1446                    crate::timezone::TimeZoneSpec::Utc,
1447                ))
1448            }
1449        });
1450
1451        // Initialize thread pool based on config
1452        let thread_pool = if config.enable_parallel {
1453            let mut builder = ThreadPoolBuilder::new();
1454            if let Some(max_threads) = config.max_threads {
1455                builder = builder.num_threads(max_threads);
1456            }
1457
1458            match builder.build() {
1459                Ok(pool) => Some(Arc::new(pool)),
1460                Err(_) => {
1461                    // Fall back to sequential evaluation if thread pool creation fails
1462                    None
1463                }
1464            }
1465        } else {
1466            None
1467        };
1468
1469        let lookup_cache_max_bytes = config.lookup_index_cache_max_bytes;
1470        let mut engine = Self {
1471            graph: DependencyGraph::new_with_config(config.clone()),
1472            resolver,
1473            config,
1474            workbook_load_limits: crate::engine::WorkbookLoadLimits::default(),
1475            clock: crate::timezone::SnapshotClock::new(clock),
1476            thread_pool,
1477            recalc_epoch: 0,
1478            snapshot_id: std::sync::atomic::AtomicU64::new(1),
1479            topology_epoch: 0,
1480            cached_static_schedule: None,
1481            spill_mgr: ShimSpillManager::default(),
1482            arrow_sheets: SheetStore::default(),
1483            has_edited: false,
1484            overlay_compactions: 0,
1485            computed_overlay_bytes_estimate: 0,
1486            computed_overlay_mirroring_disabled: false,
1487            force_materialize_range_views: false,
1488            row_bounds_cache: std::sync::RwLock::new(None),
1489            used_axis_bounds_cache: std::sync::RwLock::new(None),
1490            lookup_index_cache: LookupIndexCache::new(lookup_cache_max_bytes),
1491            source_cache: Arc::new(std::sync::RwLock::new(SourceCache::default())),
1492            staged_formulas: std::collections::HashMap::new(),
1493            row_visibility: FxHashMap::default(),
1494            row_visibility_mask_cache: std::sync::RwLock::new(FxHashMap::default()),
1495            formula_parse_diagnostics: Vec::new(),
1496            last_formula_ingest_report: None,
1497            formula_ingest_report_total: FormulaIngestReport::default(),
1498            formula_plane_cycle_member_span_demotions: 0,
1499            formula_plane_capacity_bailouts: 0,
1500            active_cancel_flag: None,
1501            action_depth: 0,
1502            last_virtual_dep_telemetry: VirtualDepTelemetry::default(),
1503            virtual_dep_fallback_activations: 0,
1504            last_cycle_telemetry: CycleTelemetry::default(),
1505            pending_iterative_redirty: Vec::new(),
1506            iterative_state_values: FxHashMap::default(),
1507            formula_plane_indexes_epoch_seen: 0,
1508            #[cfg(test)]
1509            last_formula_plane_span_eval_report: None,
1510        };
1511        // Phase 1 (ticket 610): Arrow-truth is the only supported mode.
1512        engine.config.arrow_storage_enabled = true;
1513        engine.config.delta_overlay_enabled = true;
1514        engine.config.write_formula_overlay_enabled = true;
1515        let default_sheet = engine.graph.default_sheet_name().to_string();
1516        engine.ensure_arrow_sheet(&default_sheet);
1517        engine
1518    }
1519
1520    /// Create an Engine with a custom thread pool (for shared thread pool scenarios)
1521    ///
1522    /// # Panics
1523    /// Panics when `config.cycle` is invalid, exactly like [`Engine::new`].
1524    pub fn with_thread_pool(
1525        resolver: R,
1526        config: EvalConfig,
1527        thread_pool: Arc<rayon::ThreadPool>,
1528    ) -> Self {
1529        if let Err(msg) = config.cycle.validate() {
1530            panic!("invalid CycleConfig: {msg}");
1531        }
1532        crate::builtins::load_builtins();
1533        let clock = config.deterministic_mode.build_clock().unwrap_or_else(|_| {
1534            #[cfg(feature = "system-clock")]
1535            {
1536                Arc::new(crate::timezone::SystemClock::new(
1537                    crate::timezone::TimeZoneSpec::default(),
1538                ))
1539            }
1540            #[cfg(not(feature = "system-clock"))]
1541            {
1542                Arc::new(crate::timezone::FixedClock::new(
1543                    chrono::DateTime::UNIX_EPOCH,
1544                    crate::timezone::TimeZoneSpec::Utc,
1545                ))
1546            }
1547        });
1548        let lookup_cache_max_bytes = config.lookup_index_cache_max_bytes;
1549        let mut engine = Self {
1550            graph: DependencyGraph::new_with_config(config.clone()),
1551            resolver,
1552            config,
1553            workbook_load_limits: crate::engine::WorkbookLoadLimits::default(),
1554            clock: crate::timezone::SnapshotClock::new(clock),
1555            thread_pool: Some(thread_pool),
1556            recalc_epoch: 0,
1557            snapshot_id: std::sync::atomic::AtomicU64::new(1),
1558            topology_epoch: 0,
1559            cached_static_schedule: None,
1560            spill_mgr: ShimSpillManager::default(),
1561            arrow_sheets: SheetStore::default(),
1562            has_edited: false,
1563            overlay_compactions: 0,
1564            computed_overlay_bytes_estimate: 0,
1565            computed_overlay_mirroring_disabled: false,
1566            force_materialize_range_views: false,
1567            row_bounds_cache: std::sync::RwLock::new(None),
1568            used_axis_bounds_cache: std::sync::RwLock::new(None),
1569            lookup_index_cache: LookupIndexCache::new(lookup_cache_max_bytes),
1570            source_cache: Arc::new(std::sync::RwLock::new(SourceCache::default())),
1571            staged_formulas: std::collections::HashMap::new(),
1572            row_visibility: FxHashMap::default(),
1573            row_visibility_mask_cache: std::sync::RwLock::new(FxHashMap::default()),
1574            formula_parse_diagnostics: Vec::new(),
1575            last_formula_ingest_report: None,
1576            formula_ingest_report_total: FormulaIngestReport::default(),
1577            formula_plane_cycle_member_span_demotions: 0,
1578            formula_plane_capacity_bailouts: 0,
1579            active_cancel_flag: None,
1580            action_depth: 0,
1581            last_virtual_dep_telemetry: VirtualDepTelemetry::default(),
1582            virtual_dep_fallback_activations: 0,
1583            last_cycle_telemetry: CycleTelemetry::default(),
1584            pending_iterative_redirty: Vec::new(),
1585            iterative_state_values: FxHashMap::default(),
1586            formula_plane_indexes_epoch_seen: 0,
1587            #[cfg(test)]
1588            last_formula_plane_span_eval_report: None,
1589        };
1590        // Phase 1 (ticket 610): Arrow-truth is the only supported mode.
1591        engine.config.arrow_storage_enabled = true;
1592        engine.config.delta_overlay_enabled = true;
1593        engine.config.write_formula_overlay_enabled = true;
1594        let default_sheet = engine.graph.default_sheet_name().to_string();
1595        engine.ensure_arrow_sheet(&default_sheet);
1596        engine
1597    }
1598
1599    pub fn workbook_load_limits(&self) -> &crate::engine::WorkbookLoadLimits {
1600        &self.workbook_load_limits
1601    }
1602
1603    pub fn set_workbook_load_limits(&mut self, limits: crate::engine::WorkbookLoadLimits) {
1604        self.workbook_load_limits = limits;
1605    }
1606
1607    fn clear_source_cache(&self) {
1608        if let Ok(mut g) = self.source_cache.write() {
1609            *g = SourceCache::default();
1610        }
1611    }
1612
1613    pub fn last_virtual_dep_telemetry(&self) -> &VirtualDepTelemetry {
1614        &self.last_virtual_dep_telemetry
1615    }
1616
1617    /// Telemetry from Runtime SCC evaluation during the most recent
1618    /// evaluation request (always default-zero under `CycleDetection::Static`
1619    /// or when `enable_virtual_dep_telemetry` is off).
1620    pub fn last_cycle_telemetry(&self) -> &CycleTelemetry {
1621        &self.last_cycle_telemetry
1622    }
1623
1624    /// Begin a new evaluation request: reset per-recalc cycle telemetry and
1625    /// take the per-recalc volatile clock sample. Called at the start of
1626    /// every evaluation request that walks schedule units.
1627    fn begin_evaluation_request(&mut self) {
1628        self.last_cycle_telemetry = CycleTelemetry::default();
1629        // Defensive: consumed at the end of the previous request; a request
1630        // that errored out mid-walk must not leak its members into this one.
1631        self.pending_iterative_redirty.clear();
1632        // Spec §7.11: NOW()/TODAY() sample the clock ONCE per recalc; every
1633        // read within this request (including SCC iteration passes) observes
1634        // this sample.
1635        self.clock.refresh();
1636    }
1637
1638    /// End-of-recalc redirty: volatile vertices (as always) plus members of
1639    /// SCCs that iterated this recalc (`CyclePolicy::Iterate`), so circular
1640    /// cells re-evaluate on every recalc exactly like Excel's iterative
1641    /// calculation (spec §4 persistence / §7.6 accumulator / §7.11 volatile
1642    /// redirty). Replaces the bare `graph.redirty_volatiles()` call at every
1643    /// evaluation-flow exit; must run AFTER the flow's `clear_dirty_flags`.
1644    fn redirty_for_next_recalc(&mut self) {
1645        self.graph.redirty_volatiles();
1646        let pending = std::mem::take(&mut self.pending_iterative_redirty);
1647        // Refresh the §4-persistence snapshot: these final values survive
1648        // structural edits that clear the computed overlay (the only value
1649        // home in canonical mode) so the next SCC task can re-seed from them
1650        // (see `iterative_state_values`). Replaced wholesale each recalc —
1651        // when nothing iterates the map empties and stays free.
1652        self.iterative_state_values.clear();
1653        for &vertex in &pending {
1654            if !self.graph.vertex_exists(vertex) {
1655                continue;
1656            }
1657            if let Some(cell) = self.graph.get_cell_ref(vertex) {
1658                let sheet_name = self.graph.sheet_name(cell.sheet_id);
1659                if let Some(value) =
1660                    self.get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
1661                    && !matches!(value, LiteralValue::Empty)
1662                {
1663                    self.iterative_state_values.insert(vertex, value);
1664                }
1665            }
1666        }
1667        if !pending.is_empty() {
1668            self.graph.redirty_iterative_members(&pending);
1669        }
1670    }
1671
1672    pub fn virtual_dep_fallback_activations(&self) -> u64 {
1673        self.virtual_dep_fallback_activations
1674    }
1675
1676    pub(crate) fn last_lookup_index_cache_report(&self) -> LookupIndexCacheReport {
1677        self.lookup_index_cache.report()
1678    }
1679
1680    fn lookup_view_contains_volatile(&self, view: &RangeView<'_>, sheet_id: SheetId) -> bool {
1681        let start_row = view.start_row();
1682        let end_row = view.end_row();
1683        let start_col = view.start_col();
1684        let end_col = view.end_col();
1685        for row in start_row..=end_row {
1686            let Ok(row_u32) = u32::try_from(row) else {
1687                return true;
1688            };
1689            for col in start_col..=end_col {
1690                let Ok(col_u32) = u32::try_from(col) else {
1691                    return true;
1692                };
1693                let cell_ref = self
1694                    .graph
1695                    .make_cell_ref_internal(sheet_id, row_u32, col_u32);
1696                if let Some(vertex_id) = self.graph.get_vertex_id_for_address(&cell_ref)
1697                    && self.graph.is_volatile(*vertex_id)
1698                {
1699                    return true;
1700                }
1701            }
1702        }
1703        false
1704    }
1705
1706    fn build_lookup_index_impl(
1707        &self,
1708        view: &RangeView<'_>,
1709        axis: LookupAxis,
1710    ) -> Option<Arc<LookupIndex>> {
1711        let (rows, cols) = view.dims();
1712        if rows == 0 || cols == 0 {
1713            self.lookup_index_cache.note_skipped_tiny();
1714            return None;
1715        }
1716        let len = match axis {
1717            LookupAxis::ColumnInView(col) => {
1718                if col >= cols {
1719                    self.lookup_index_cache.note_skipped_tiny();
1720                    return None;
1721                }
1722                rows
1723            }
1724            LookupAxis::RowInView(row) => {
1725                if row >= rows {
1726                    self.lookup_index_cache.note_skipped_tiny();
1727                    return None;
1728                }
1729                cols
1730            }
1731        };
1732        if len < 64 {
1733            self.lookup_index_cache.note_skipped_tiny();
1734            return None;
1735        }
1736
1737        let sheet_id = self.graph.sheet_id(view.sheet_name())?;
1738        let key = LookupIndexKey {
1739            sheet_id,
1740            start_row: u32::try_from(view.start_row()).ok()?,
1741            start_col: u32::try_from(view.start_col()).ok()?,
1742            end_row: u32::try_from(view.end_row()).ok()?,
1743            end_col: u32::try_from(view.end_col()).ok()?,
1744            axis,
1745            snapshot_id: self.data_snapshot_id(),
1746        };
1747        if let Some(index) = self.lookup_index_cache.get(&key) {
1748            return Some(index);
1749        }
1750        if self
1751            .lookup_index_cache
1752            .would_exceed_cap(estimate_bytes(len, 0))
1753        {
1754            self.lookup_index_cache.note_skipped_cap();
1755            return None;
1756        }
1757        if !self.lookup_index_cache.should_build(key) {
1758            return None;
1759        }
1760        if self.lookup_index_cache.is_known_volatile(&key) {
1761            self.lookup_index_cache.note_skipped_volatile();
1762            return None;
1763        }
1764        if self.lookup_view_contains_volatile(view, sheet_id) {
1765            self.lookup_index_cache.note_volatile_key(key);
1766            self.lookup_index_cache.note_skipped_volatile();
1767            return None;
1768        }
1769        match LookupIndex::build(view, axis).ok()? {
1770            BuildOutcome::Built(index) => self.lookup_index_cache.insert_if_room(key, index),
1771            BuildOutcome::ErrorInLookupAxis => {
1772                self.lookup_index_cache.note_skipped_error();
1773                None
1774            }
1775            BuildOutcome::Degenerate => {
1776                self.lookup_index_cache.note_skipped_tiny();
1777                None
1778            }
1779        }
1780    }
1781
1782    fn reset_virtual_dep_telemetry_if_disabled(&mut self) {
1783        if !self.config.enable_virtual_dep_telemetry {
1784            self.last_virtual_dep_telemetry = VirtualDepTelemetry {
1785                fallback_mode_activations: self.virtual_dep_fallback_activations,
1786                ..VirtualDepTelemetry::default()
1787            };
1788        }
1789    }
1790
1791    fn source_cache_session(&self) -> SourceCacheSession {
1792        self.clear_source_cache();
1793        SourceCacheSession {
1794            cache: self.source_cache.clone(),
1795        }
1796    }
1797
1798    fn resolve_source_scalar_cached(
1799        &self,
1800        name: &str,
1801        version: Option<u64>,
1802    ) -> Result<LiteralValue, ExcelError> {
1803        let key = (name.to_string(), version);
1804        if let Ok(mut g) = self.source_cache.write() {
1805            if let Some(v) = g.scalars.get(&key) {
1806                return Ok(v.clone());
1807            }
1808
1809            let v = self.resolver.resolve_source_scalar(name).map_err(|err| {
1810                if matches!(err.kind, ExcelErrorKind::Name | ExcelErrorKind::NImpl) {
1811                    ExcelError::new(ExcelErrorKind::Ref)
1812                        .with_message(format!("Unresolved source scalar: {name}"))
1813                } else {
1814                    err
1815                }
1816            })?;
1817            g.scalars.insert(key, v.clone());
1818            Ok(v)
1819        } else {
1820            self.resolver.resolve_source_scalar(name).map_err(|err| {
1821                if matches!(err.kind, ExcelErrorKind::Name | ExcelErrorKind::NImpl) {
1822                    ExcelError::new(ExcelErrorKind::Ref)
1823                        .with_message(format!("Unresolved source scalar: {name}"))
1824                } else {
1825                    err
1826                }
1827            })
1828        }
1829    }
1830
1831    fn resolve_source_table_cached(
1832        &self,
1833        name: &str,
1834        version: Option<u64>,
1835    ) -> Result<Arc<dyn crate::traits::Table>, ExcelError> {
1836        let key = (name.to_string(), version);
1837        if let Ok(mut g) = self.source_cache.write() {
1838            if let Some(t) = g.tables.get(&key) {
1839                return Ok(t.clone());
1840            }
1841
1842            let t = self.resolver.resolve_source_table(name).map_err(|err| {
1843                if matches!(err.kind, ExcelErrorKind::Name | ExcelErrorKind::NImpl) {
1844                    ExcelError::new(ExcelErrorKind::Ref)
1845                        .with_message(format!("Unresolved source table: {name}"))
1846                } else {
1847                    err
1848                }
1849            })?;
1850            let t: Arc<dyn crate::traits::Table> = Arc::from(t);
1851            g.tables.insert(key, t.clone());
1852            Ok(t)
1853        } else {
1854            self.resolver
1855                .resolve_source_table(name)
1856                .map_err(|err| {
1857                    if matches!(err.kind, ExcelErrorKind::Name | ExcelErrorKind::NImpl) {
1858                        ExcelError::new(ExcelErrorKind::Ref)
1859                            .with_message(format!("Unresolved source table: {name}"))
1860                    } else {
1861                        err
1862                    }
1863                })
1864                .map(Arc::from)
1865        }
1866    }
1867
1868    fn source_table_to_range_view(
1869        &self,
1870        table: &dyn crate::traits::Table,
1871        spec: &Option<formualizer_parse::parser::TableSpecifier>,
1872    ) -> Result<RangeView<'static>, ExcelError> {
1873        use formualizer_parse::parser::{SpecialItem, TableSpecifier};
1874
1875        let owned = match spec {
1876            Some(TableSpecifier::Column(c)) => {
1877                let c = c.trim();
1878                if c == "@" || c.contains('[') || c.contains(']') || c.contains(',') {
1879                    return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
1880                        "Complex structured references not yet supported".to_string(),
1881                    ));
1882                }
1883                table.get_column(c)?.materialise().into_owned()
1884            }
1885            Some(TableSpecifier::ColumnRange(start, end)) => {
1886                let cols = table.columns();
1887                let start = start.trim();
1888                let end = end.trim();
1889                let start_key = start.to_lowercase();
1890                let end_key = end.to_lowercase();
1891                let start_idx = cols.iter().position(|n| n.to_lowercase() == start_key);
1892                let end_idx = cols.iter().position(|n| n.to_lowercase() == end_key);
1893                if let (Some(mut si), Some(mut ei)) = (start_idx, end_idx) {
1894                    if si > ei {
1895                        std::mem::swap(&mut si, &mut ei);
1896                    }
1897                    let h = table.data_height();
1898                    let w = ei - si + 1;
1899                    let mut rows = vec![vec![LiteralValue::Empty; w]; h];
1900                    for (offset, ci) in (si..=ei).enumerate() {
1901                        let cname = &cols[ci];
1902                        let col_range = table.get_column(cname)?;
1903                        let (rh, _) = col_range.dimensions();
1904                        for (r, row) in rows.iter_mut().enumerate().take(h.min(rh)) {
1905                            row[offset] = col_range.get(r, 0)?;
1906                        }
1907                    }
1908                    rows
1909                } else {
1910                    return Err(ExcelError::new(ExcelErrorKind::Ref)
1911                        .with_message("Column range refers to unknown column(s)".to_string()));
1912                }
1913            }
1914            Some(TableSpecifier::SpecialItem(SpecialItem::Headers))
1915            | Some(TableSpecifier::Headers) => table
1916                .headers_row()
1917                .map(|r| r.materialise().into_owned())
1918                .unwrap_or_default(),
1919            Some(TableSpecifier::SpecialItem(SpecialItem::Totals))
1920            | Some(TableSpecifier::Totals) => table
1921                .totals_row()
1922                .map(|r| r.materialise().into_owned())
1923                .unwrap_or_default(),
1924            Some(TableSpecifier::SpecialItem(SpecialItem::Data)) | Some(TableSpecifier::Data) => {
1925                table
1926                    .data_body()
1927                    .map(|r| r.materialise().into_owned())
1928                    .unwrap_or_default()
1929            }
1930            Some(TableSpecifier::SpecialItem(SpecialItem::All)) | Some(TableSpecifier::All) => {
1931                let mut out: Vec<Vec<LiteralValue>> = Vec::new();
1932                if let Some(h) = table.headers_row() {
1933                    out.extend(h.iter_rows());
1934                }
1935                if let Some(body) = table.data_body() {
1936                    out.extend(body.iter_rows());
1937                }
1938                if let Some(tr) = table.totals_row() {
1939                    out.extend(tr.iter_rows());
1940                }
1941                out
1942            }
1943            Some(TableSpecifier::SpecialItem(SpecialItem::ThisRow)) => {
1944                return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
1945                    "@ (This Row) requires table-aware context; not yet supported".to_string(),
1946                ));
1947            }
1948            Some(TableSpecifier::Row(_)) | Some(TableSpecifier::Combination(_)) => {
1949                return Err(ExcelError::new(ExcelErrorKind::NImpl)
1950                    .with_message("Complex structured references not yet supported".to_string()));
1951            }
1952            None => {
1953                return Err(ExcelError::new(ExcelErrorKind::NImpl)
1954                    .with_message("Table reference without specifier is unsupported".to_string()));
1955            }
1956        };
1957
1958        Ok(RangeView::from_owned_rows(owned, self.config.date_system))
1959    }
1960
1961    pub fn default_sheet_id(&self) -> SheetId {
1962        self.graph.default_sheet_id()
1963    }
1964
1965    pub fn default_sheet_name(&self) -> &str {
1966        self.graph.default_sheet_name()
1967    }
1968
1969    /// Update the workbook seed for deterministic RNGs in functions.
1970    pub fn set_workbook_seed(&mut self, seed: u64) {
1971        self.config.workbook_seed = seed;
1972    }
1973
1974    /// Set the volatile level policy (Always/OnRecalc/OnOpen)
1975    pub fn set_volatile_level(&mut self, level: crate::traits::VolatileLevel) {
1976        self.config.volatile_level = level;
1977    }
1978
1979    /// Enable/disable deterministic evaluation mode (fixed clock + timezone).
1980    pub fn set_deterministic_mode(
1981        &mut self,
1982        mode: crate::engine::DeterministicMode,
1983    ) -> Result<(), ExcelError> {
1984        let clock = mode.build_clock()?;
1985        self.config.deterministic_mode = mode;
1986        self.clock = crate::timezone::SnapshotClock::new(clock);
1987        Ok(())
1988    }
1989
1990    /// Inject a custom [`ClockProvider`](crate::timezone::ClockProvider) for
1991    /// volatile date/time builtins (`NOW()`, `TODAY()`).
1992    ///
1993    /// The provider is the clock *source*; per spec §7.11 the engine samples
1994    /// it once at the start of every evaluation request and all reads within
1995    /// that recalc (including SCC iteration passes) observe the frozen
1996    /// sample.
1997    pub fn set_clock(&mut self, clock: Arc<dyn crate::timezone::ClockProvider>) {
1998        self.clock = crate::timezone::SnapshotClock::new(clock);
1999    }
2000
2001    fn validate_deterministic_mode(&self) -> Result<(), ExcelError> {
2002        self.config.deterministic_mode.validate()
2003    }
2004
2005    pub fn sheet_id(&self, name: &str) -> Option<SheetId> {
2006        self.graph.sheet_id(name)
2007    }
2008
2009    pub fn sheet_id_mut(&mut self, name: &str) -> SheetId {
2010        self.add_sheet(name)
2011            .unwrap_or_else(|_| self.graph.sheet_id_mut(name))
2012    }
2013
2014    pub fn sheet_name(&self, id: SheetId) -> &str {
2015        self.graph.sheet_name(id)
2016    }
2017
2018    pub fn add_sheet(&mut self, name: &str) -> Result<SheetId, ExcelError> {
2019        let id = self.graph.add_sheet(name)?;
2020        self.ensure_arrow_sheet(name);
2021        // Adding a sheet does not invalidate existing SheetId-based FormulaPlane
2022        // spans. `graph.add_sheet` handles legacy orphan-healing for formulas
2023        // that were explicitly tombstoned for this sheet name; avoid a global
2024        // FormulaPlane demotion/dirty mark for unrelated spans.
2025        self.mark_topology_edited();
2026        Ok(id)
2027    }
2028
2029    pub fn duplicate_sheet(&mut self, source: &str, new_name: &str) -> Result<SheetId, ExcelError> {
2030        let source_id = self.graph.sheet_id(source).ok_or_else(|| {
2031            ExcelError::new(ExcelErrorKind::Value).with_message("Source sheet does not exist")
2032        })?;
2033        // Materialize only spans on the source sheet so graph duplication sees
2034        // the formulas being copied. Spans on unrelated sheets remain active.
2035        self.demote_spans_preserving_computed_overlays(source_id, Region::whole_sheet(source_id))
2036            .map_err(Self::editor_error_to_excel)?;
2037        let new_id = self.graph.duplicate_sheet(source_id, new_name)?;
2038
2039        if let Some(source_sheet) = self.arrow_sheets.sheet(source).cloned() {
2040            let mut copied_sheet = source_sheet;
2041            copied_sheet.name = Arc::<str>::from(new_name);
2042            self.arrow_sheets.sheets.push(copied_sheet);
2043        } else {
2044            self.ensure_arrow_sheet(new_name);
2045        }
2046
2047        self.clear_all_computed_overlays();
2048        self.mark_all_formula_vertices_dirty();
2049        self.mark_topology_edited();
2050        Ok(new_id)
2051    }
2052
2053    fn ensure_arrow_sheet(&mut self, name: &str) {
2054        if self.arrow_sheets.sheet(name).is_some() {
2055            return;
2056        }
2057        self.arrow_sheets
2058            .sheets
2059            .push(crate::arrow_store::ArrowSheet {
2060                name: std::sync::Arc::<str>::from(name),
2061                columns: Vec::new(),
2062                nrows: 0,
2063                chunk_starts: Vec::new(),
2064                chunk_rows: 32 * 1024,
2065            });
2066    }
2067
2068    pub fn remove_sheet(&mut self, sheet_id: SheetId) -> Result<(), ExcelError> {
2069        let name = self.graph.sheet_name(sheet_id).to_string();
2070        // Removing a sheet only affects spans on that sheet and spans reading
2071        // from that sheet. Preserve spans on unrelated sheets so sheet
2072        // lifecycle operations do not collapse the whole FormulaPlane.
2073        self.demote_spans_preserving_computed_overlays(sheet_id, Region::whole_sheet(sheet_id))
2074            .map_err(Self::editor_error_to_excel)?;
2075        self.graph.remove_sheet(sheet_id)?;
2076        self.arrow_sheets.sheets.retain(|s| s.name.as_ref() != name);
2077        self.clear_all_computed_overlays();
2078        self.mark_all_formula_vertices_dirty();
2079        self.staged_formulas.remove(&name);
2080        if self.row_visibility.remove(&sheet_id).is_some() {
2081            self.invalidate_row_visibility_mask_cache();
2082        }
2083        self.record_formula_plane_structural_change(StructuralScope::RemovedSheet(sheet_id));
2084        self.mark_topology_edited();
2085        Ok(())
2086    }
2087
2088    /// Helper to synchronize the Arrow-backed storage layer.
2089    fn rename_sheet_in_arrow_store(&mut self, target_name: &str, new_name: &str) -> bool {
2090        if let Some(asheet) = self
2091            .arrow_sheets
2092            .sheets
2093            .iter_mut()
2094            .find(|s| s.name.as_ref() == target_name)
2095        {
2096            asheet.name = std::sync::Arc::<str>::from(new_name);
2097            return true;
2098        }
2099        false
2100    }
2101
2102    pub fn rename_sheet(&mut self, sheet_id: SheetId, new_name: &str) -> Result<(), ExcelError> {
2103        let old_name = self.graph.sheet_name(sheet_id).to_string();
2104
2105        // Speculative Storage Update
2106        // Update name in storage FIRST so the Evaluator can find it during Graph rescue.
2107        self.rename_sheet_in_arrow_store(&old_name, new_name);
2108
2109        // Graph Update (Metadata + Rescue Logic)
2110        match self.graph.rename_sheet(sheet_id, new_name) {
2111            Ok(_) => {
2112                self.rename_staged_formula_sheet(&old_name, new_name);
2113                // Success! Invalidate cache for the moved sheet
2114                let sheet_vertices: Vec<VertexId> =
2115                    self.graph.vertices_in_sheet(sheet_id).collect();
2116                for v_id in sheet_vertices {
2117                    self.graph.mark_vertex_dirty(v_id);
2118                }
2119                // Sheet rename is metadata-only and preserves SheetId. References resolve by
2120                // SheetId, so no FormulaPlane changed region is required. Removing this avoids
2121                // re-evaluating every span that reads the renamed sheet.
2122                self.mark_topology_edited();
2123                Ok(())
2124            }
2125            Err(e) => {
2126                // ROLLBACK: Revert storage if graph rejected the name
2127                self.rename_sheet_in_arrow_store(new_name, &old_name);
2128                Err(e)
2129            }
2130        }
2131    }
2132
2133    pub fn named_ranges_iter(
2134        &self,
2135    ) -> impl Iterator<Item = (&String, &crate::engine::named_range::NamedRange)> {
2136        self.graph.named_ranges_iter()
2137    }
2138
2139    pub fn sheet_named_ranges_iter(
2140        &self,
2141    ) -> impl Iterator<Item = (&(SheetId, String), &crate::engine::named_range::NamedRange)> {
2142        self.graph.sheet_named_ranges_iter()
2143    }
2144
2145    pub fn resolve_name_entry(
2146        &self,
2147        name: &str,
2148        current_sheet: SheetId,
2149    ) -> Option<&crate::engine::named_range::NamedRange> {
2150        self.graph.resolve_name_entry(name, current_sheet)
2151    }
2152
2153    pub fn named_ranges_snapshot(&self) -> Vec<crate::engine::named_range::NamedRangeSnapshot> {
2154        let mut out: Vec<crate::engine::named_range::NamedRangeSnapshot> = Vec::new();
2155
2156        for (name, named) in self.graph.named_ranges_iter() {
2157            out.push(crate::engine::named_range::NamedRangeSnapshot {
2158                name: name.clone(),
2159                scope: NameScope::Workbook,
2160                definition: named.definition.clone(),
2161            });
2162        }
2163
2164        for ((sheet_id, name), named) in self.graph.sheet_named_ranges_iter() {
2165            out.push(crate::engine::named_range::NamedRangeSnapshot {
2166                name: name.clone(),
2167                scope: NameScope::Sheet(*sheet_id),
2168                definition: named.definition.clone(),
2169            });
2170        }
2171
2172        out.sort_by(|a, b| {
2173            let a_scope = match a.scope {
2174                NameScope::Workbook => (0u8, 0u32),
2175                NameScope::Sheet(id) => (1u8, u32::from(id)),
2176            };
2177            let b_scope = match b.scope {
2178                NameScope::Workbook => (0u8, 0u32),
2179                NameScope::Sheet(id) => (1u8, u32::from(id)),
2180            };
2181            a_scope.cmp(&b_scope).then_with(|| a.name.cmp(&b.name))
2182        });
2183
2184        out
2185    }
2186
2187    pub fn named_ranges_snapshot_for_sheet(
2188        &self,
2189        sheet_id: SheetId,
2190    ) -> Vec<crate::engine::named_range::NamedRangeSnapshot> {
2191        self.named_ranges_snapshot()
2192            .into_iter()
2193            .filter(|entry| match entry.scope {
2194                NameScope::Workbook => true,
2195                NameScope::Sheet(id) => id == sheet_id,
2196            })
2197            .collect()
2198    }
2199
2200    pub fn define_name(
2201        &mut self,
2202        name: &str,
2203        definition: NamedDefinition,
2204        scope: NameScope,
2205    ) -> Result<(), ExcelError> {
2206        // A new define can flip resolution for spans that previously resolved
2207        // the same name through another scope (e.g. a sheet-scoped name
2208        // shadowing a workbook-scoped one). Demote those spans BEFORE the
2209        // registry changes so their cells re-ingest and re-resolve through
2210        // the normal legacy path.
2211        self.invalidate_formula_plane_spans_for_name(name)?;
2212        self.graph.define_name(name, definition, scope)?;
2213        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2214        self.mark_topology_edited();
2215        Ok(())
2216    }
2217
2218    pub fn update_name(
2219        &mut self,
2220        name: &str,
2221        definition: NamedDefinition,
2222        scope: NameScope,
2223    ) -> Result<(), ExcelError> {
2224        // Demote name-dependent spans BEFORE the registry update: the demoted
2225        // cells re-materialize as legacy vertices attached to the name vertex
2226        // (via their resolved-name dep plans), so the registry update's
2227        // dependent dirtying reaches them exactly like long-lived legacy
2228        // formulas.
2229        self.invalidate_formula_plane_spans_for_name(name)?;
2230        self.graph.update_name(name, definition, scope)?;
2231        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2232        self.mark_topology_edited();
2233        Ok(())
2234    }
2235
2236    pub fn delete_name(&mut self, name: &str, scope: NameScope) -> Result<(), ExcelError> {
2237        // Demote first (see update_name): the demoted legacy vertices become
2238        // dependents of the name vertex, so delete_name dirties them and they
2239        // re-evaluate to #NAME? exactly as legacy formulas do.
2240        self.invalidate_formula_plane_spans_for_name(name)?;
2241        self.graph.delete_name(name, scope)?;
2242        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2243        self.mark_topology_edited();
2244        Ok(())
2245    }
2246
2247    /// Demote every FormulaPlane span whose ingest-time read projections
2248    /// resolved `name` (any scope; see the FormulaPlane name-dependents map
2249    /// for the conservative keying contract). Demotion materializes the span
2250    /// placements as legacy graph formulas through the existing demotion
2251    /// machinery, preserving computed values; the subsequent registry change
2252    /// then dirties them through the legacy name-dependents path.
2253    fn invalidate_formula_plane_spans_for_name(&mut self, name: &str) -> Result<(), ExcelError> {
2254        if self.config.formula_plane_mode == FormulaPlaneMode::Off {
2255            return Ok(());
2256        }
2257        let regions: Vec<Region> = {
2258            let authority = self.graph.formula_authority();
2259            authority
2260                .plane
2261                .name_dependent_span_refs(name)
2262                .into_iter()
2263                .filter_map(|span_ref| authority.plane.spans.get(span_ref))
2264                .map(|span| Region::from_domain(span.result_region.domain()))
2265                .collect()
2266        };
2267        for region in regions {
2268            self.demote_spans_preserving_computed_overlays(region.sheet_id(), region)
2269                .map_err(Self::editor_error_to_excel)?;
2270        }
2271        Ok(())
2272    }
2273
2274    pub fn define_table(
2275        &mut self,
2276        name: &str,
2277        range: crate::reference::RangeRef,
2278        header_row: bool,
2279        headers: Vec<String>,
2280        totals_row: bool,
2281    ) -> Result<(), ExcelError> {
2282        self.graph
2283            .define_table(name, range, header_row, headers, totals_row)?;
2284        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2285        self.mark_topology_edited();
2286        Ok(())
2287    }
2288
2289    pub fn define_source_scalar(
2290        &mut self,
2291        name: &str,
2292        version: Option<u64>,
2293    ) -> Result<(), ExcelError> {
2294        self.graph.define_source_scalar(name, version)?;
2295        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2296        self.mark_topology_edited();
2297        Ok(())
2298    }
2299
2300    pub fn define_source_table(
2301        &mut self,
2302        name: &str,
2303        version: Option<u64>,
2304    ) -> Result<(), ExcelError> {
2305        self.graph.define_source_table(name, version)?;
2306        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2307        self.mark_topology_edited();
2308        Ok(())
2309    }
2310
2311    pub fn set_source_scalar_version(
2312        &mut self,
2313        name: &str,
2314        version: Option<u64>,
2315    ) -> Result<(), ExcelError> {
2316        self.graph.set_source_scalar_version(name, version)?;
2317        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2318        Ok(())
2319    }
2320
2321    pub fn set_source_table_version(
2322        &mut self,
2323        name: &str,
2324        version: Option<u64>,
2325    ) -> Result<(), ExcelError> {
2326        self.graph.set_source_table_version(name, version)?;
2327        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2328        Ok(())
2329    }
2330
2331    pub fn invalidate_source(&mut self, name: &str) -> Result<(), ExcelError> {
2332        self.graph.invalidate_source(name)?;
2333        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2334        Ok(())
2335    }
2336
2337    pub fn vertex_value(&self, vertex: VertexId) -> Option<LiteralValue> {
2338        self.graph.get_value(vertex)
2339    }
2340
2341    pub fn graph_cell_value(&self, sheet: &str, row: u32, col: u32) -> Option<LiteralValue> {
2342        self.graph.get_cell_value(sheet, row, col)
2343    }
2344
2345    pub fn vertex_for_cell(&self, cell: &CellRef) -> Option<VertexId> {
2346        self.graph.get_vertex_for_cell(cell)
2347    }
2348
2349    pub fn evaluation_vertices(&self) -> Vec<VertexId> {
2350        self.graph.get_evaluation_vertices()
2351    }
2352
2353    /// Return read-only baseline counters for FormulaPlane/dispatch benchmarking.
2354    pub fn baseline_stats(&self) -> EngineBaselineStats {
2355        let graph = self.graph.baseline_stats();
2356        let formula_authority = self.graph.formula_authority();
2357        EngineBaselineStats {
2358            graph_vertex_count: graph.graph_vertex_count,
2359            graph_formula_vertex_count: graph.graph_formula_vertex_count,
2360            graph_edge_count: graph.graph_edge_count,
2361            dirty_vertex_count: graph.dirty_vertex_count,
2362            evaluation_vertex_count: graph.evaluation_vertex_count,
2363            formula_ast_root_count: graph.formula_ast_root_count,
2364            formula_ast_node_count: graph.formula_ast_node_count,
2365            staged_formula_count: self.staged_formula_count(),
2366            formula_plane_active_span_count: formula_authority.active_span_count(),
2367            formula_plane_producer_result_entries: formula_authority.producer_results.len(),
2368            formula_plane_consumer_read_entries: formula_authority.consumer_reads.len(),
2369            formula_plane_cycle_member_span_demotions: self
2370                .formula_plane_cycle_member_span_demotions,
2371        }
2372    }
2373
2374    #[cfg(test)]
2375    pub(crate) fn used_axis_bounds_cache_stats(&self) -> (usize, usize, usize, usize) {
2376        self.used_axis_bounds_cache
2377            .read()
2378            .ok()
2379            .and_then(|guard| {
2380                guard.as_ref().map(|cache| {
2381                    (
2382                        cache.row_hits.load(Ordering::Relaxed),
2383                        cache.row_misses.load(Ordering::Relaxed),
2384                        cache.col_hits.load(Ordering::Relaxed),
2385                        cache.col_misses.load(Ordering::Relaxed),
2386                    )
2387                })
2388            })
2389            .unwrap_or((0, 0, 0, 0))
2390    }
2391
2392    pub fn set_first_load_assume_new(&mut self, enabled: bool) {
2393        self.graph.set_first_load_assume_new(enabled);
2394    }
2395
2396    pub fn reset_ensure_touched(&mut self) {
2397        self.graph.reset_ensure_touched();
2398    }
2399
2400    pub fn finalize_sheet_index(&mut self, sheet: &str) {
2401        self.graph.finalize_sheet_index(sheet);
2402    }
2403
2404    /// Execute a named Engine action.
2405    ///
2406    /// Ticket 614 introduces this as the stable Engine-level transaction surface.
2407    /// For now actions are commit-only: they do not create changelog boundaries and they do not
2408    /// provide rollback/atomicity.
2409    ///
2410    /// Nested actions are deterministically handled by *disallowing* nesting: calling
2411    /// `Engine::action` while another action is active returns `EditorError::TransactionFailed`.
2412    pub fn action<T>(
2413        &mut self,
2414        name: impl AsRef<str>,
2415        f: impl FnOnce(&mut EngineAction<'_, R>) -> Result<T, crate::engine::EditorError>,
2416    ) -> Result<T, crate::engine::EditorError> {
2417        if self.action_depth != 0 {
2418            return Err(crate::engine::EditorError::TransactionFailed {
2419                reason: "Nested Engine::action calls are not supported (ticket 614: commit-only surface)"
2420                    .to_string(),
2421            });
2422        }
2423
2424        self.action_depth = 1;
2425        let engine_ptr: *mut Engine<R> = self;
2426        let _guard = ActionDepthGuard {
2427            engine: engine_ptr,
2428            _marker: std::marker::PhantomData,
2429        };
2430
2431        let mut tx = EngineAction {
2432            engine: self,
2433            name: name.as_ref().to_string(),
2434            log: None,
2435            arrow_undo: None,
2436            atomic_policy: false,
2437        };
2438        f(&mut tx)
2439    }
2440
2441    /// Execute a named Engine action with atomic commit/rollback semantics.
2442    ///
2443    /// This variant does not require a `ChangeLog` and uses an internal journal for rollback.
2444    pub fn action_atomic<T>(
2445        &mut self,
2446        name: impl Into<String>,
2447        f: impl FnOnce(&mut EngineAction<'_, R>) -> Result<T, crate::engine::EditorError>,
2448    ) -> Result<T, crate::engine::EditorError> {
2449        let (v, _j) = self.action_atomic_journal(name, f)?;
2450        Ok(v)
2451    }
2452
2453    /// Like `action_atomic`, but returns the committed journal entry for undo/redo storage.
2454    pub fn action_atomic_journal<T>(
2455        &mut self,
2456        name: impl Into<String>,
2457        f: impl FnOnce(&mut EngineAction<'_, R>) -> Result<T, crate::engine::EditorError>,
2458    ) -> Result<(T, crate::engine::ActionJournal), crate::engine::EditorError> {
2459        if self.action_depth != 0 {
2460            return Err(crate::engine::EditorError::TransactionFailed {
2461                reason: "Nested Engine::action calls are not supported (deterministic rule)"
2462                    .to_string(),
2463            });
2464        }
2465
2466        self.action_depth = 1;
2467        let engine_ptr: *mut Engine<R> = self;
2468        let _guard = ActionDepthGuard {
2469            engine: engine_ptr,
2470            _marker: std::marker::PhantomData,
2471        };
2472
2473        let name_str = name.into();
2474        let mut log = crate::engine::ChangeLog::new();
2475        let start_len = log.len();
2476        self.action_atomic_impl(&mut log, start_len, name_str, f)
2477    }
2478
2479    fn action_atomic_impl<T>(
2480        &mut self,
2481        log: &mut crate::engine::ChangeLog,
2482        start_len: usize,
2483        name: String,
2484        f: impl FnOnce(&mut EngineAction<'_, R>) -> Result<T, crate::engine::EditorError>,
2485    ) -> Result<(T, crate::engine::ActionJournal), crate::engine::EditorError> {
2486        let mut arrow_undo = crate::engine::ArrowUndoBatch::default();
2487        let arrow_ptr: *mut crate::engine::ArrowUndoBatch = &mut arrow_undo;
2488
2489        let log_ptr: *mut crate::engine::ChangeLog = log;
2490        let mut tx = EngineAction {
2491            engine: self,
2492            name: name.clone(),
2493            log: Some(log_ptr),
2494            arrow_undo: Some(arrow_ptr),
2495            atomic_policy: true,
2496        };
2497
2498        let res = f(&mut tx);
2499
2500        // Capture graph structural delta for this action.
2501        let graph_events: Vec<crate::engine::ChangeEvent> =
2502            unsafe { (&*log_ptr).events() }[start_len..].to_vec();
2503        let graph_batch = crate::engine::GraphUndoBatch {
2504            events: graph_events,
2505        };
2506        let affected_cells = arrow_undo.ops.len();
2507        let journal = crate::engine::ActionJournal {
2508            name,
2509            graph: graph_batch,
2510            arrow: arrow_undo,
2511            affected_cells,
2512        };
2513
2514        match res {
2515            Ok(v) => {
2516                if !journal.graph.is_empty() || !journal.arrow.is_empty() {
2517                    for event in &journal.graph.events {
2518                        self.record_formula_plane_change_for_event(event);
2519                    }
2520                    self.mark_data_edited();
2521                }
2522                Ok((v, journal))
2523            }
2524            Err(e) => {
2525                if let Err(rb) = self.rollback_from_action_journal(&journal) {
2526                    return Err(crate::engine::EditorError::TransactionFailed {
2527                        reason: format!(
2528                            "Engine::action_atomic rollback failed after error '{e}': {rb}"
2529                        ),
2530                    });
2531                }
2532                if !journal.graph.is_empty() || !journal.arrow.is_empty() {
2533                    for event in &journal.graph.events {
2534                        self.record_formula_plane_change_for_event(event);
2535                    }
2536                }
2537                Err(e)
2538            }
2539        }
2540    }
2541
2542    /// Execute a named Engine action, logging graph changes into the provided ChangeLog.
2543    ///
2544    /// Ticket 615: this variant provides atomicity. If the action returns an error, it rolls back:
2545    /// - Dependency graph structural edits (via inverse ChangeEvents)
2546    /// - Arrow-truth overlay writes mirrored from ChangeEvents
2547    /// - ChangeLog entries (truncated back to the pre-action length)
2548    pub fn action_with_logger<T>(
2549        &mut self,
2550        log: &mut crate::engine::ChangeLog,
2551        name: impl AsRef<str>,
2552        f: impl FnOnce(&mut EngineAction<'_, R>) -> Result<T, crate::engine::EditorError>,
2553    ) -> Result<T, crate::engine::EditorError> {
2554        if self.action_depth != 0 {
2555            return Err(crate::engine::EditorError::TransactionFailed {
2556                reason: "Nested Engine::action calls are not supported (deterministic rule)"
2557                    .to_string(),
2558            });
2559        }
2560
2561        self.action_depth = 1;
2562        let engine_ptr: *mut Engine<R> = self;
2563        let _guard = ActionDepthGuard {
2564            engine: engine_ptr,
2565            _marker: std::marker::PhantomData,
2566        };
2567
2568        let start_len = log.len();
2569        let name_str = name.as_ref().to_string();
2570        log.begin_compound(name_str.clone());
2571
2572        // Use the provided ChangeLog as an observability sink.
2573        // Correctness is provided by the internal `ActionJournal` returned from the atomic impl.
2574        let res = self.action_atomic_impl(log, start_len, name_str, f);
2575
2576        match res {
2577            Ok((v, _journal)) => {
2578                log.end_compound();
2579                Ok(v)
2580            }
2581            Err(e) => {
2582                // Close compound and truncate log as cleanup only.
2583                log.end_compound();
2584                log.truncate(start_len);
2585                Err(e)
2586            }
2587        }
2588    }
2589
2590    fn rollback_from_action_journal(
2591        &mut self,
2592        journal: &crate::engine::ActionJournal,
2593    ) -> Result<(), crate::engine::EditorError> {
2594        // 1) Roll back the dependency graph structure.
2595        journal.graph.undo(&mut self.graph)?;
2596        // 2) Roll back engine row-visibility sidecar events.
2597        self.apply_inverse_row_visibility_events(&journal.graph.events);
2598        // 3) Roll back Arrow-truth overlays.
2599        self.apply_arrow_undo_batch(&journal.arrow, /*undo=*/ true);
2600        Ok(())
2601    }
2602
2603    fn rollback_from_change_events(
2604        &mut self,
2605        events: &[crate::engine::ChangeEvent],
2606    ) -> Result<(), crate::engine::EditorError> {
2607        use crate::engine::ChangeEvent;
2608
2609        // 1) Roll back the dependency graph.
2610        {
2611            let mut editor = crate::engine::VertexEditor::new(&mut self.graph);
2612            let mut compound_stack: Vec<usize> = Vec::new();
2613            for ev in events.iter().rev() {
2614                match ev {
2615                    ChangeEvent::CompoundEnd { depth } => compound_stack.push(*depth),
2616                    ChangeEvent::CompoundStart { depth, .. } => {
2617                        if compound_stack.last() == Some(depth) {
2618                            compound_stack.pop();
2619                        }
2620                    }
2621                    ChangeEvent::SetRowVisibility { .. } => {
2622                        // Engine-side metadata handled after dropping graph editor borrow.
2623                    }
2624                    _ => {
2625                        editor.apply_inverse(ev.clone())?;
2626                    }
2627                }
2628            }
2629        }
2630
2631        // 2) Roll back engine row-visibility metadata.
2632        for ev in events.iter().rev() {
2633            self.apply_inverse_row_visibility_event(ev);
2634        }
2635
2636        // 3) Roll back Arrow-truth overlays mirrored from those ChangeEvents.
2637        for ev in events.iter().rev() {
2638            self.mirror_inverse_change_to_arrow(ev);
2639        }
2640
2641        Ok(())
2642    }
2643
2644    fn read_cell_formula_ast(&self, sheet: &str, row: u32, col: u32) -> Option<ASTNode> {
2645        let sheet_id = self.graph.sheet_id(sheet)?;
2646        let coord = Coord::from_excel(row, col, true, true);
2647        let cell = CellRef::new(sheet_id, coord);
2648        let vid = self.graph.get_vertex_for_cell(&cell)?;
2649        let ast_id = self.graph.get_formula_id(vid)?;
2650        self.graph
2651            .data_store()
2652            .retrieve_ast(ast_id, self.graph.sheet_reg())
2653    }
2654
2655    pub fn edit_with_logger<T>(
2656        &mut self,
2657        log: &mut crate::engine::ChangeLog,
2658        f: impl FnOnce(&mut crate::engine::VertexEditor) -> T,
2659    ) -> T {
2660        // Record starting log length so we can mirror only newly-recorded events.
2661        let start_len = log.len();
2662
2663        // Provide a spill snapshot reader so VertexEditor can snapshot Arrow-truth spill values
2664        // (graph value cache is intentionally empty in canonical mode).
2665        struct ArrowSpillReader<'a> {
2666            sheets: &'a crate::arrow_store::SheetStore,
2667        }
2668        impl crate::engine::graph::editor::vertex_editor::SpillValueReader for ArrowSpillReader<'_> {
2669            fn read_cell_value(
2670                &self,
2671                sheet: &str,
2672                row: u32,
2673                col: u32,
2674            ) -> Option<formualizer_common::LiteralValue> {
2675                use formualizer_common::LiteralValue;
2676                let asheet = self.sheets.sheet(sheet)?;
2677                let r0 = row.saturating_sub(1) as usize;
2678                let c0 = col.saturating_sub(1) as usize;
2679                let v = asheet.get_cell_value(r0, c0);
2680                if matches!(v, LiteralValue::Empty) {
2681                    None
2682                } else {
2683                    Some(v)
2684                }
2685            }
2686        }
2687
2688        let ret = {
2689            let spill_reader = ArrowSpillReader {
2690                sheets: &self.arrow_sheets,
2691            };
2692            let mut editor = crate::engine::VertexEditor::with_logger_and_spill_reader(
2693                &mut self.graph,
2694                log,
2695                &spill_reader,
2696            );
2697            f(&mut editor)
2698        };
2699
2700        // Mirror value-impacting graph events to Arrow for forward edits.
2701        // This keeps Arrow overlays (delta + computed) consistent when edits clear/commit spills.
2702        for ev in &log.events()[start_len..] {
2703            self.mirror_forward_change_to_arrow(ev);
2704        }
2705        for ev in &log.events()[start_len..] {
2706            self.record_formula_plane_change_for_event(ev);
2707        }
2708
2709        ret
2710    }
2711
2712    pub fn undo_logged(
2713        &mut self,
2714        undo: &mut crate::engine::graph::editor::undo_engine::UndoEngine,
2715        log: &mut crate::engine::ChangeLog,
2716    ) -> Result<(), crate::engine::EditorError> {
2717        let batch = undo.undo(&mut self.graph, log)?;
2718        for item in batch.iter().rev() {
2719            self.apply_inverse_row_visibility_event(&item.event);
2720            self.apply_inverse_staged_formula_event(&item.event);
2721        }
2722        self.mirror_undo_batch_to_arrow(&batch);
2723        if !batch.is_empty() {
2724            for item in &batch {
2725                self.record_formula_plane_change_for_event(&item.event);
2726            }
2727        }
2728        Ok(())
2729    }
2730
2731    pub fn redo_logged(
2732        &mut self,
2733        undo: &mut crate::engine::graph::editor::undo_engine::UndoEngine,
2734        log: &mut crate::engine::ChangeLog,
2735    ) -> Result<(), crate::engine::EditorError> {
2736        let batch = undo.redo(&mut self.graph, log)?;
2737        for item in &batch {
2738            self.apply_forward_row_visibility_event(&item.event);
2739            self.apply_forward_staged_formula_event(&item.event);
2740        }
2741        self.mirror_redo_batch_to_arrow(&batch);
2742        if !batch.is_empty() {
2743            for item in &batch {
2744                self.record_formula_plane_change_for_event(&item.event);
2745            }
2746        }
2747        Ok(())
2748    }
2749
2750    /// Undo the last committed atomic action using the journal stack.
2751    ///
2752    /// This path does not require a `ChangeLog`.
2753    pub fn undo_action(
2754        &mut self,
2755        undo: &mut crate::engine::graph::editor::undo_engine::UndoEngine,
2756    ) -> Result<(), crate::engine::EditorError> {
2757        let Some(journal) = undo.pop_undo_action() else {
2758            return Ok(());
2759        };
2760
2761        journal.graph.undo(&mut self.graph)?;
2762        self.apply_inverse_row_visibility_events(&journal.graph.events);
2763        self.apply_arrow_undo_batch(&journal.arrow, /*undo=*/ true);
2764        if !journal.graph.is_empty() || !journal.arrow.is_empty() {
2765            for event in &journal.graph.events {
2766                self.record_formula_plane_change_for_event(event);
2767            }
2768            self.mark_data_edited();
2769        }
2770
2771        undo.push_redo_action(journal);
2772        Ok(())
2773    }
2774
2775    /// Redo the last undone atomic action using the journal stack.
2776    ///
2777    /// This path does not require a `ChangeLog`.
2778    pub fn redo_action(
2779        &mut self,
2780        undo: &mut crate::engine::graph::editor::undo_engine::UndoEngine,
2781    ) -> Result<(), crate::engine::EditorError> {
2782        let Some(journal) = undo.pop_redo_action() else {
2783            return Ok(());
2784        };
2785
2786        journal.graph.redo(&mut self.graph)?;
2787        self.apply_forward_row_visibility_events(&journal.graph.events);
2788        self.apply_arrow_undo_batch(&journal.arrow, /*undo=*/ false);
2789        if !journal.graph.is_empty() || !journal.arrow.is_empty() {
2790            for event in &journal.graph.events {
2791                self.record_formula_plane_change_for_event(event);
2792            }
2793            self.mark_data_edited();
2794        }
2795
2796        undo.push_done_action(journal);
2797        Ok(())
2798    }
2799
2800    fn cellref_to_sheet_row_col(&self, addr: &crate::reference::CellRef) -> (String, u32, u32) {
2801        let sheet = self.graph.sheet_name(addr.sheet_id).to_string();
2802        // Coord stores 0-based indices.
2803        let row = addr.coord.row() + 1;
2804        let col = addr.coord.col() + 1;
2805        (sheet, row, col)
2806    }
2807
2808    fn mirror_undo_batch_to_arrow(
2809        &mut self,
2810        batch: &[crate::engine::graph::editor::undo_engine::UndoBatchItem],
2811    ) {
2812        // Undo applies inverses in reverse order.
2813        for item in batch.iter().rev() {
2814            self.mirror_inverse_change_to_arrow(&item.event);
2815        }
2816    }
2817
2818    fn mirror_redo_batch_to_arrow(
2819        &mut self,
2820        batch: &[crate::engine::graph::editor::undo_engine::UndoBatchItem],
2821    ) {
2822        // Redo applies events in forward order.
2823        for item in batch.iter() {
2824            self.mirror_forward_change_to_arrow(&item.event);
2825        }
2826    }
2827
2828    fn mirror_inverse_change_to_arrow(&mut self, ev: &crate::engine::ChangeEvent) {
2829        use crate::engine::ChangeEvent;
2830        use formualizer_common::LiteralValue;
2831
2832        match ev {
2833            ChangeEvent::SetValue {
2834                addr,
2835                old_value,
2836                old_formula,
2837                ..
2838            } => {
2839                let (sheet, row, col) = self.cellref_to_sheet_row_col(addr);
2840                if old_formula.is_some() {
2841                    self.clear_delta_overlay_cell(&sheet, row, col);
2842                } else {
2843                    let v = old_value.clone().unwrap_or(LiteralValue::Empty);
2844                    self.mirror_value_to_overlay(&sheet, row, col, &v);
2845                }
2846            }
2847            ChangeEvent::SetFormula {
2848                addr,
2849                old_value,
2850                old_formula,
2851                ..
2852            } => {
2853                let (sheet, row, col) = self.cellref_to_sheet_row_col(addr);
2854                if old_formula.is_some() {
2855                    self.clear_delta_overlay_cell(&sheet, row, col);
2856                } else {
2857                    let v = old_value.clone().unwrap_or(LiteralValue::Empty);
2858                    self.mirror_value_to_overlay(&sheet, row, col, &v);
2859                }
2860            }
2861            ChangeEvent::SpillCommitted { old, new, .. } => {
2862                // Inverse: restore `old` (or clear if none).
2863                self.mirror_spill_snapshot(new, /*clear_only=*/ true);
2864                if let Some(snap) = old {
2865                    self.mirror_spill_snapshot(snap, /*clear_only=*/ false);
2866                }
2867            }
2868            ChangeEvent::SpillCleared { old, .. } => {
2869                // Inverse: restore prior spill.
2870                self.mirror_spill_snapshot(old, /*clear_only=*/ false);
2871            }
2872            ChangeEvent::SetRowVisibility { .. } => {
2873                // Engine-side metadata only; no Arrow overlay effect.
2874            }
2875            _ => {}
2876        }
2877    }
2878
2879    fn mirror_forward_change_to_arrow(&mut self, ev: &crate::engine::ChangeEvent) {
2880        use crate::engine::ChangeEvent;
2881
2882        match ev {
2883            ChangeEvent::SetValue { addr, new, .. } => {
2884                let (sheet, row, col) = self.cellref_to_sheet_row_col(addr);
2885                self.mirror_value_to_overlay(&sheet, row, col, new);
2886            }
2887            ChangeEvent::SetFormula { addr, .. } => {
2888                let (sheet, row, col) = self.cellref_to_sheet_row_col(addr);
2889                self.clear_delta_overlay_cell(&sheet, row, col);
2890                // Keep any computed overlay for this cell as-is; it will be recomputed on demand.
2891            }
2892            ChangeEvent::SpillCommitted { old, new, .. } => {
2893                if let Some(snap) = old {
2894                    self.mirror_spill_snapshot(snap, /*clear_only=*/ true);
2895                }
2896                self.mirror_spill_snapshot(new, /*clear_only=*/ false);
2897            }
2898            ChangeEvent::SpillCleared { old, .. } => {
2899                self.mirror_spill_snapshot(old, /*clear_only=*/ true);
2900            }
2901            ChangeEvent::SetRowVisibility { .. } => {
2902                // Engine-side metadata only; no Arrow overlay effect.
2903            }
2904            _ => {
2905                // Other graph structural operations do not have direct value effects in Arrow.
2906            }
2907        }
2908    }
2909
2910    fn mirror_spill_snapshot(
2911        &mut self,
2912        snap: &crate::engine::graph::editor::change_log::SpillSnapshot,
2913        clear_only: bool,
2914    ) {
2915        use formualizer_common::LiteralValue;
2916
2917        let mut i = 0usize;
2918        for row in &snap.values {
2919            for v in row {
2920                if let Some(cell) = snap.target_cells.get(i) {
2921                    let (sheet, r, c) = self.cellref_to_sheet_row_col(cell);
2922                    let out = if clear_only {
2923                        LiteralValue::Empty
2924                    } else {
2925                        v.clone()
2926                    };
2927                    self.mirror_value_to_computed_overlay(&sheet, r, c, &out);
2928                }
2929                i += 1;
2930            }
2931        }
2932        // If target_cells is longer than values (should not happen), clear remaining cells.
2933        if clear_only {
2934            for cell in snap.target_cells.iter().skip(i) {
2935                let (sheet, r, c) = self.cellref_to_sheet_row_col(cell);
2936                self.mirror_value_to_computed_overlay(&sheet, r, c, &LiteralValue::Empty);
2937            }
2938        }
2939    }
2940
2941    pub fn set_default_sheet_by_name(&mut self, name: &str) {
2942        self.graph.set_default_sheet_by_name(name);
2943    }
2944
2945    pub fn set_default_sheet_by_id(&mut self, id: SheetId) {
2946        self.graph.set_default_sheet_by_id(id);
2947    }
2948
2949    pub fn set_sheet_index_mode(&mut self, mode: crate::engine::SheetIndexMode) {
2950        self.graph.set_sheet_index_mode(mode);
2951    }
2952
2953    fn clear_cached_static_schedule(&mut self) {
2954        self.cached_static_schedule = None;
2955    }
2956
2957    /// Mark data edited: bump snapshot and set edited flag.
2958    /// Value-only edits keep the stable-topology schedule cache alive.
2959    pub fn mark_data_edited(&mut self) {
2960        self.snapshot_id
2961            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2962        self.has_edited = true;
2963    }
2964
2965    /// Mark a topology-changing edit: bump snapshot + topology epoch and invalidate cached schedules.
2966    pub fn mark_topology_edited(&mut self) {
2967        self.snapshot_id
2968            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2969        self.topology_epoch = self.topology_epoch.wrapping_add(1);
2970        self.clear_cached_static_schedule();
2971        self.has_edited = true;
2972    }
2973
2974    fn mark_all_formula_vertices_dirty(&mut self) {
2975        let vertices: Vec<VertexId> = self.graph.vertices_with_formulas().collect();
2976        for vertex in vertices {
2977            self.graph.mark_vertex_dirty(vertex);
2978        }
2979    }
2980
2981    fn mark_moved_formula_vertices_dirty(
2982        &mut self,
2983        summary: &crate::engine::graph::editor::vertex_editor::ShiftSummary,
2984    ) {
2985        for vertex in &summary.vertices_moved {
2986            if self.graph.get_formula_id(*vertex).is_some() {
2987                self.graph.mark_vertex_dirty(*vertex);
2988            }
2989        }
2990    }
2991
2992    /// Access Arrow sheet store (read-only)
2993    pub fn sheet_store(&self) -> &SheetStore {
2994        &self.arrow_sheets
2995    }
2996
2997    /// Access Arrow sheet store (mutable)
2998    pub fn sheet_store_mut(&mut self) -> &mut SheetStore {
2999        &mut self.arrow_sheets
3000    }
3001
3002    pub fn has_staged_formulas(&self) -> bool {
3003        !self.staged_formulas.is_empty()
3004    }
3005
3006    pub fn staged_formula_count(&self) -> usize {
3007        self.staged_formulas.values().map(StagedSheet::len).sum()
3008    }
3009
3010    /// Stage a formula text instead of inserting into the graph (used when deferring is enabled).
3011    pub fn stage_formula_text(&mut self, sheet: &str, row: u32, col: u32, text: String) {
3012        self.staged_formulas
3013            .entry(sheet.to_string())
3014            .or_default()
3015            .stage(row, col, text);
3016    }
3017
3018    pub fn clear_staged_formula_text(&mut self, sheet: &str, row: u32, col: u32) -> Option<String> {
3019        let mut removed = None;
3020        let mut remove_sheet = false;
3021        if let Some(entries) = self.staged_formulas.get_mut(sheet) {
3022            removed = entries.remove(row, col);
3023            remove_sheet = entries.is_empty();
3024        }
3025        if remove_sheet {
3026            self.staged_formulas.remove(sheet);
3027        }
3028        removed
3029    }
3030
3031    pub fn clear_staged_formulas_for_sheet(&mut self, sheet: &str) {
3032        self.staged_formulas.remove(sheet);
3033    }
3034
3035    pub fn rename_staged_formula_sheet(&mut self, old: &str, new: &str) {
3036        let Some(entries) = self.staged_formulas.remove(old) else {
3037            return;
3038        };
3039        for (row, col, text) in entries.into_entries() {
3040            self.stage_formula_text(new, row, col, text);
3041        }
3042    }
3043
3044    /// Get a staged formula text for a given cell if present (cloned).
3045    pub fn get_staged_formula_text(&self, sheet: &str, row: u32, col: u32) -> Option<String> {
3046        self.staged_formulas
3047            .get(sheet)
3048            .and_then(|v| v.get(row, col).map(str::to_owned))
3049    }
3050
3051    pub fn formula_parse_diagnostics(&self) -> &[FormulaParseDiagnostic] {
3052        &self.formula_parse_diagnostics
3053    }
3054
3055    pub fn take_formula_parse_diagnostics(&mut self) -> Vec<FormulaParseDiagnostic> {
3056        std::mem::take(&mut self.formula_parse_diagnostics)
3057    }
3058
3059    pub fn clear_formula_parse_diagnostics(&mut self) {
3060        self.formula_parse_diagnostics.clear();
3061    }
3062
3063    pub fn last_formula_ingest_report(&self) -> Option<&FormulaIngestReport> {
3064        self.last_formula_ingest_report.as_ref()
3065    }
3066
3067    pub fn formula_ingest_report_total(&self) -> &FormulaIngestReport {
3068        &self.formula_ingest_report_total
3069    }
3070
3071    #[cfg(test)]
3072    pub(crate) fn last_formula_plane_span_eval_report(&self) -> Option<&SpanEvalReport> {
3073        self.last_formula_plane_span_eval_report.as_ref()
3074    }
3075
3076    #[cfg(test)]
3077    pub(crate) fn formula_plane_indexes_epoch(&self) -> u64 {
3078        self.graph.formula_authority().indexes_epoch()
3079    }
3080
3081    #[cfg(test)]
3082    pub(crate) fn formula_plane_capacity_bailouts(&self) -> u64 {
3083        self.formula_plane_capacity_bailouts
3084    }
3085
3086    fn record_formula_ingest_report(&mut self, report: FormulaIngestReport) {
3087        self.formula_ingest_report_total.mode = report.mode;
3088        self.formula_ingest_report_total.accumulate(&report);
3089        self.last_formula_ingest_report = Some(report);
3090    }
3091
3092    fn analyze_formula_plane_shadow_candidates(
3093        &mut self,
3094        batches: &[FormulaIngestBatch],
3095    ) -> FormulaIngestReport {
3096        let mut report = FormulaIngestReport::with_mode(FormulaPlaneMode::Shadow);
3097        report.formula_cells_seen = batches.iter().map(|batch| batch.len() as u64).sum();
3098
3099        // Touch graph-owned authority deliberately: Tranche 3 shadow analysis uses
3100        // scratch state, but FormulaPlane ownership now lives on DependencyGraph.
3101        let _active_epoch = self.graph.formula_authority().plane.epoch();
3102
3103        let batch_sheet_ids: Vec<SheetId> = batches
3104            .iter()
3105            .map(|batch| self.graph.sheet_id_mut(&batch.sheet_name))
3106            .collect();
3107        let mut groups: BTreeMap<
3108            (SheetId, u64, u32),
3109            Vec<(FormulaPlacementCandidate, CandidateAnalysis)>,
3110        > = BTreeMap::new();
3111        {
3112            let mut pipeline = self.ingest_pipeline();
3113            for (batch, sheet_id) in batches.iter().zip(batch_sheet_ids.iter().copied()) {
3114                for record in &batch.formulas {
3115                    if record.row == 0 || record.col == 0 {
3116                        report.shadow_candidate_cells =
3117                            report.shadow_candidate_cells.saturating_add(1);
3118                        report.shadow_fallback_cells =
3119                            report.shadow_fallback_cells.saturating_add(1);
3120                        Self::record_shadow_fallback_reason(
3121                            &mut report,
3122                            PlacementFallbackReason::UnsupportedShapeOrGaps,
3123                            1,
3124                        );
3125                        continue;
3126                    }
3127
3128                    let placement = CellRef::new(
3129                        sheet_id,
3130                        Coord::from_excel(record.row, record.col, true, true),
3131                    );
3132                    let ingested = match pipeline.ingest_formula(
3133                        FormulaAstInput::RawArena(record.ast_id),
3134                        placement,
3135                        record.formula_text.clone(),
3136                    ) {
3137                        Ok(ingested) => ingested,
3138                        Err(_) => {
3139                            report.shadow_candidate_cells =
3140                                report.shadow_candidate_cells.saturating_add(1);
3141                            report.shadow_fallback_cells =
3142                                report.shadow_fallback_cells.saturating_add(1);
3143                            Self::record_shadow_fallback_reason(
3144                                &mut report,
3145                                PlacementFallbackReason::UnsupportedCanonicalTemplate,
3146                                1,
3147                            );
3148                            continue;
3149                        }
3150                    };
3151                    let candidate = FormulaPlacementCandidate::new(
3152                        sheet_id,
3153                        record.row - 1,
3154                        record.col - 1,
3155                        ingested.ast_id,
3156                        record.formula_text.clone(),
3157                    );
3158                    let analysis = match CandidateAnalysis::from_ingested(&candidate, &ingested) {
3159                        Ok(analysis) => analysis,
3160                        Err(reason) => {
3161                            report.shadow_candidate_cells =
3162                                report.shadow_candidate_cells.saturating_add(1);
3163                            report.shadow_fallback_cells =
3164                                report.shadow_fallback_cells.saturating_add(1);
3165                            Self::record_shadow_fallback_reason(&mut report, reason, 1);
3166                            continue;
3167                        }
3168                    };
3169                    groups
3170                        .entry((
3171                            sheet_id,
3172                            ingested.parameterized_canonical_hash,
3173                            candidate.col,
3174                        ))
3175                        .or_default()
3176                        .push((candidate, analysis));
3177                }
3178            }
3179        }
3180
3181        let mut scratch_plane = FormulaPlane::default();
3182        for entries in groups.into_values() {
3183            let (candidates, analyses): (Vec<_>, Vec<_>) = entries.into_iter().unzip();
3184            for (component, component_analyses) in
3185                Self::split_candidate_components_with_analyses(candidates, analyses)
3186            {
3187                let placement_report = place_candidate_family_with_analyses(
3188                    &mut scratch_plane,
3189                    component,
3190                    component_analyses,
3191                );
3192                let counters = placement_report.counters;
3193                report.shadow_candidate_cells = report
3194                    .shadow_candidate_cells
3195                    .saturating_add(counters.formula_cells_seen);
3196                report.shadow_accepted_span_cells = report
3197                    .shadow_accepted_span_cells
3198                    .saturating_add(counters.accepted_span_cells);
3199                report.shadow_fallback_cells = report
3200                    .shadow_fallback_cells
3201                    .saturating_add(counters.legacy_cells);
3202                report.shadow_templates_interned = report
3203                    .shadow_templates_interned
3204                    .saturating_add(counters.templates_interned);
3205                report.shadow_spans_created = report
3206                    .shadow_spans_created
3207                    .saturating_add(counters.spans_created);
3208                report.graph_formula_vertices_avoided_shadow = report
3209                    .graph_formula_vertices_avoided_shadow
3210                    .saturating_add(counters.formula_vertices_avoided);
3211                report.ast_roots_avoided_shadow = report
3212                    .ast_roots_avoided_shadow
3213                    .saturating_add(counters.ast_roots_avoided);
3214                report.edge_rows_avoided_shadow = report
3215                    .edge_rows_avoided_shadow
3216                    .saturating_add(counters.edge_rows_avoided);
3217                for (reason, count) in counters.fallback_reasons {
3218                    Self::record_shadow_fallback_reason(&mut report, reason, count);
3219                }
3220            }
3221        }
3222        report
3223    }
3224
3225    fn record_shadow_fallback_reason(
3226        report: &mut FormulaIngestReport,
3227        reason: PlacementFallbackReason,
3228        count: u64,
3229    ) {
3230        *report
3231            .fallback_reasons
3232            .entry(format!("{reason:?}"))
3233            .or_default() += count;
3234    }
3235
3236    fn analyze_formula_plane_authoritative_ingest(
3237        &mut self,
3238        batches: &[FormulaIngestBatch],
3239    ) -> (
3240        FormulaIngestReport,
3241        Vec<FormulaIngestBatch>,
3242        PlannedFormulaMaterialize,
3243    ) {
3244        let mut report =
3245            FormulaIngestReport::with_mode(FormulaPlaneMode::AuthoritativeExperimental);
3246        report.formula_cells_seen = batches.iter().map(|batch| batch.len() as u64).sum();
3247
3248        let mut pending_candidates: Vec<(String, FormulaPlacementCandidate)> = Vec::new();
3249        let mut fallback: BTreeMap<String, Vec<FormulaIngestRecord>> = BTreeMap::new();
3250        let mut planned_fallback: PlannedFormulaMaterialize = BTreeMap::new();
3251
3252        for batch in batches {
3253            let sheet_id = self.graph.sheet_id_mut(&batch.sheet_name);
3254            for record in &batch.formulas {
3255                if record.row == 0 || record.col == 0 {
3256                    report.shadow_candidate_cells = report.shadow_candidate_cells.saturating_add(1);
3257                    report.shadow_fallback_cells = report.shadow_fallback_cells.saturating_add(1);
3258                    Self::record_shadow_fallback_reason(
3259                        &mut report,
3260                        PlacementFallbackReason::UnsupportedShapeOrGaps,
3261                        1,
3262                    );
3263                    fallback
3264                        .entry(batch.sheet_name.clone())
3265                        .or_default()
3266                        .push(record.clone());
3267                    continue;
3268                }
3269
3270                pending_candidates.push((
3271                    batch.sheet_name.clone(),
3272                    FormulaPlacementCandidate::new(
3273                        sheet_id,
3274                        record.row - 1,
3275                        record.col - 1,
3276                        record.ast_id,
3277                        record.formula_text.clone(),
3278                    ),
3279                ));
3280            }
3281        }
3282
3283        let mut groups: BTreeMap<(SheetId, u64, u32), Vec<usize>> = BTreeMap::new();
3284        let mut analyses_by_index: Vec<Option<CandidateAnalysis>> =
3285            (0..pending_candidates.len()).map(|_| None).collect();
3286        let mut plans_by_index: Vec<Option<DependencyPlanRow>> =
3287            (0..pending_candidates.len()).map(|_| None).collect();
3288        {
3289            let mut pipeline = self.ingest_pipeline();
3290            for (idx, (sheet_name, candidate)) in pending_candidates.iter_mut().enumerate() {
3291                let placement = CellRef::new(
3292                    candidate.sheet_id,
3293                    Coord::from_excel(
3294                        candidate.row.saturating_add(1),
3295                        candidate.col.saturating_add(1),
3296                        true,
3297                        true,
3298                    ),
3299                );
3300                let ingested = pipeline.ingest_formula(
3301                    FormulaAstInput::RawArena(candidate.ast_id),
3302                    placement,
3303                    candidate.formula_text.clone(),
3304                );
3305                match ingested {
3306                    Ok(ingested) => {
3307                        candidate.ast_id = ingested.ast_id;
3308                        let canonical_hash = ingested.parameterized_canonical_hash;
3309                        let dep_plan = ingested.dep_plan.clone();
3310                        match CandidateAnalysis::from_ingested(candidate, &ingested) {
3311                            Ok(analysis) => {
3312                                groups
3313                                    .entry((candidate.sheet_id, canonical_hash, candidate.col))
3314                                    .or_default()
3315                                    .push(idx);
3316                                analyses_by_index[idx] = Some(analysis);
3317                                plans_by_index[idx] = Some(dep_plan);
3318                            }
3319                            Err(reason) => {
3320                                report.shadow_candidate_cells =
3321                                    report.shadow_candidate_cells.saturating_add(1);
3322                                report.shadow_fallback_cells =
3323                                    report.shadow_fallback_cells.saturating_add(1);
3324                                Self::record_shadow_fallback_reason(&mut report, reason, 1);
3325                                planned_fallback
3326                                    .entry(sheet_name.clone())
3327                                    .or_default()
3328                                    .push((
3329                                        candidate.row.saturating_add(1),
3330                                        candidate.col.saturating_add(1),
3331                                        candidate.ast_id,
3332                                        dep_plan,
3333                                    ));
3334                            }
3335                        }
3336                    }
3337                    Err(_) => {
3338                        report.shadow_candidate_cells =
3339                            report.shadow_candidate_cells.saturating_add(1);
3340                        report.shadow_fallback_cells =
3341                            report.shadow_fallback_cells.saturating_add(1);
3342                        Self::record_shadow_fallback_reason(
3343                            &mut report,
3344                            PlacementFallbackReason::UnsupportedCanonicalTemplate,
3345                            1,
3346                        );
3347                        fallback.entry(sheet_name.clone()).or_default().push(
3348                            FormulaIngestRecord::new(
3349                                candidate.row.saturating_add(1),
3350                                candidate.col.saturating_add(1),
3351                                candidate.ast_id,
3352                                candidate.formula_text.clone(),
3353                            ),
3354                        );
3355                    }
3356                }
3357            }
3358        }
3359
3360        for ((_sheet_id, _canonical_hash, _col), candidate_indices) in groups {
3361            let sheet_name = pending_candidates[candidate_indices[0]].0.clone();
3362            let mut plans_by_coord: BTreeMap<(u32, u32), Vec<DependencyPlanRow>> = BTreeMap::new();
3363            for idx in &candidate_indices {
3364                // Each candidate index belongs to exactly one group, so the
3365                // plan row can be moved out instead of deep-cloned.
3366                if let Some(plan) = plans_by_index[*idx].take() {
3367                    let candidate = &pending_candidates[*idx].1;
3368                    plans_by_coord
3369                        .entry((candidate.row, candidate.col))
3370                        .or_default()
3371                        .push(plan);
3372                }
3373            }
3374            let candidates: Vec<_> = candidate_indices
3375                .iter()
3376                .map(|idx| pending_candidates[*idx].1.clone())
3377                .collect();
3378            let components = Self::split_shadow_candidate_components(candidates);
3379            let analyzed_components =
3380                if components.len() == 1 && components[0].len() == candidate_indices.len() {
3381                    let component = components.into_iter().next().expect("one component");
3382                    let component_analyses = candidate_indices
3383                        .iter()
3384                        .map(|idx| {
3385                            analyses_by_index[*idx]
3386                                .take()
3387                                .expect("candidate analysis must be used once")
3388                        })
3389                        .collect();
3390                    vec![(component, component_analyses)]
3391                } else {
3392                    let mut indices_by_coord: BTreeMap<(u32, u32), Vec<usize>> = BTreeMap::new();
3393                    for idx in candidate_indices.iter().rev() {
3394                        let candidate = &pending_candidates[*idx].1;
3395                        indices_by_coord
3396                            .entry((candidate.row, candidate.col))
3397                            .or_default()
3398                            .push(*idx);
3399                    }
3400
3401                    components
3402                        .into_iter()
3403                        .map(|component| {
3404                            let mut component_analyses = Vec::with_capacity(component.len());
3405                            for candidate in &component {
3406                                let idx = indices_by_coord
3407                                    .get_mut(&(candidate.row, candidate.col))
3408                                    .and_then(Vec::pop)
3409                                    .expect("component candidate must have a precomputed analysis");
3410                                component_analyses.push(
3411                                    analyses_by_index[idx]
3412                                        .take()
3413                                        .expect("candidate analysis must be used once"),
3414                                );
3415                            }
3416                            (component, component_analyses)
3417                        })
3418                        .collect()
3419                };
3420
3421            for (component, component_analyses) in analyzed_components {
3422                for (component, component_analyses) in
3423                    split_candidate_affine_literal_runs(component, component_analyses)
3424                {
3425                    let placement_report = {
3426                        let authority = self.graph.formula_authority_mut();
3427                        place_candidate_family_with_analyses(
3428                            &mut authority.plane,
3429                            component.clone(),
3430                            component_analyses,
3431                        )
3432                    };
3433                    Self::accumulate_formula_plane_placement_report(&mut report, &placement_report);
3434
3435                    // Index candidates by placement once per component. The
3436                    // previous per-result linear `find` made this fallback
3437                    // mapping O(N²) for rejected families (e.g. an N-cell
3438                    // chain rejected with `InternalDependency`), dominating
3439                    // first-eval ingest cost on large rejected families.
3440                    // First insert wins, matching the old `Iterator::find`
3441                    // semantics for duplicate placements.
3442                    let mut candidate_by_placement: FxHashMap<
3443                        crate::formula_plane::runtime::PlacementCoord,
3444                        &FormulaPlacementCandidate,
3445                    > = FxHashMap::with_capacity_and_hasher(component.len(), Default::default());
3446                    for candidate in &component {
3447                        candidate_by_placement
3448                            .entry(candidate.placement())
3449                            .or_insert(candidate);
3450                    }
3451                    for result in &placement_report.results {
3452                        let FormulaPlacementResult::Legacy { placement, .. } = result else {
3453                            continue;
3454                        };
3455                        if let Some(&candidate) = candidate_by_placement.get(placement) {
3456                            let plan = plans_by_coord
3457                                .get_mut(&(candidate.row, candidate.col))
3458                                .and_then(Vec::pop);
3459                            if let Some(plan) = plan {
3460                                planned_fallback
3461                                    .entry(sheet_name.clone())
3462                                    .or_default()
3463                                    .push((
3464                                        candidate.row.saturating_add(1),
3465                                        candidate.col.saturating_add(1),
3466                                        candidate.ast_id,
3467                                        plan,
3468                                    ));
3469                            } else {
3470                                fallback.entry(sheet_name.clone()).or_default().push(
3471                                    FormulaIngestRecord::new(
3472                                        candidate.row.saturating_add(1),
3473                                        candidate.col.saturating_add(1),
3474                                        candidate.ast_id,
3475                                        candidate.formula_text.clone(),
3476                                    ),
3477                                );
3478                            }
3479                        }
3480                    }
3481                }
3482            }
3483        }
3484
3485        let _index_report = self.graph.formula_authority_mut().rebuild_indexes();
3486
3487        let fallback_batches = fallback
3488            .into_iter()
3489            .map(|(sheet_name, formulas)| FormulaIngestBatch::new(sheet_name, formulas))
3490            .collect();
3491        (report, fallback_batches, planned_fallback)
3492    }
3493
3494    fn accumulate_formula_plane_placement_report(
3495        report: &mut FormulaIngestReport,
3496        placement_report: &crate::formula_plane::placement::FormulaPlacementReport,
3497    ) {
3498        let counters = &placement_report.counters;
3499        report.shadow_candidate_cells = report
3500            .shadow_candidate_cells
3501            .saturating_add(counters.formula_cells_seen);
3502        report.shadow_accepted_span_cells = report
3503            .shadow_accepted_span_cells
3504            .saturating_add(counters.accepted_span_cells);
3505        report.shadow_fallback_cells = report
3506            .shadow_fallback_cells
3507            .saturating_add(counters.legacy_cells);
3508        report.shadow_templates_interned = report
3509            .shadow_templates_interned
3510            .saturating_add(counters.templates_interned);
3511        report.shadow_spans_created = report
3512            .shadow_spans_created
3513            .saturating_add(counters.spans_created);
3514        report.graph_formula_vertices_avoided_shadow = report
3515            .graph_formula_vertices_avoided_shadow
3516            .saturating_add(counters.formula_vertices_avoided);
3517        report.ast_roots_avoided_shadow = report
3518            .ast_roots_avoided_shadow
3519            .saturating_add(counters.ast_roots_avoided);
3520        report.edge_rows_avoided_shadow = report
3521            .edge_rows_avoided_shadow
3522            .saturating_add(counters.edge_rows_avoided);
3523        for (reason, count) in &counters.fallback_reasons {
3524            Self::record_shadow_fallback_reason(report, *reason, *count);
3525        }
3526    }
3527
3528    fn split_candidate_components_with_analyses(
3529        candidates: Vec<FormulaPlacementCandidate>,
3530        mut analyses: Vec<CandidateAnalysis>,
3531    ) -> Vec<(Vec<FormulaPlacementCandidate>, Vec<CandidateAnalysis>)> {
3532        let components = Self::split_shadow_candidate_components(candidates.clone());
3533        let mut analysis_by_coord: BTreeMap<(u32, u32), Vec<CandidateAnalysis>> = BTreeMap::new();
3534        for (candidate, analysis) in candidates.into_iter().zip(analyses.drain(..)) {
3535            analysis_by_coord
3536                .entry((candidate.row, candidate.col))
3537                .or_default()
3538                .push(analysis);
3539        }
3540        components
3541            .into_iter()
3542            .flat_map(|component| {
3543                let mut component_analyses = Vec::with_capacity(component.len());
3544                for candidate in &component {
3545                    let analysis = analysis_by_coord
3546                        .get_mut(&(candidate.row, candidate.col))
3547                        .and_then(Vec::pop)
3548                        .expect("component candidate must have a precomputed analysis");
3549                    component_analyses.push(analysis);
3550                }
3551                split_candidate_affine_literal_runs(component, component_analyses)
3552            })
3553            .collect()
3554    }
3555
3556    fn split_shadow_candidate_components(
3557        candidates: Vec<FormulaPlacementCandidate>,
3558    ) -> Vec<Vec<FormulaPlacementCandidate>> {
3559        if candidates.len() <= 1 {
3560            return vec![candidates];
3561        }
3562
3563        // Fast path: candidates already ordered as one contiguous
3564        // single-column (or single-row) run form exactly one 4-connected
3565        // component in their existing (row, col) order; skip the BFS.
3566        let is_row_run = candidates.windows(2).all(|w| {
3567            w[0].sheet_id == w[1].sheet_id && w[0].col == w[1].col && w[0].row + 1 == w[1].row
3568        });
3569        let is_col_run = candidates.windows(2).all(|w| {
3570            w[0].sheet_id == w[1].sheet_id && w[0].row == w[1].row && w[0].col + 1 == w[1].col
3571        });
3572        if is_row_run || is_col_run {
3573            return vec![candidates];
3574        }
3575
3576        let mut coord_to_indices: BTreeMap<(u32, u32), Vec<usize>> = BTreeMap::new();
3577        for (idx, candidate) in candidates.iter().enumerate() {
3578            coord_to_indices
3579                .entry((candidate.row, candidate.col))
3580                .or_default()
3581                .push(idx);
3582        }
3583
3584        let mut remaining: BTreeSet<usize> = (0..candidates.len()).collect();
3585        let mut components = Vec::new();
3586        while let Some(&start) = remaining.iter().next() {
3587            remaining.remove(&start);
3588            let mut queue = VecDeque::from([start]);
3589            let mut component_indices = Vec::new();
3590
3591            while let Some(idx) = queue.pop_front() {
3592                component_indices.push(idx);
3593                let candidate = &candidates[idx];
3594                let mut neighbor_coords = Vec::with_capacity(5);
3595                neighbor_coords.push((candidate.row, candidate.col));
3596                if let Some(row) = candidate.row.checked_sub(1) {
3597                    neighbor_coords.push((row, candidate.col));
3598                }
3599                neighbor_coords.push((candidate.row.saturating_add(1), candidate.col));
3600                if let Some(col) = candidate.col.checked_sub(1) {
3601                    neighbor_coords.push((candidate.row, col));
3602                }
3603                neighbor_coords.push((candidate.row, candidate.col.saturating_add(1)));
3604
3605                for coord in neighbor_coords {
3606                    if let Some(indices) = coord_to_indices.get(&coord) {
3607                        for &neighbor in indices {
3608                            if remaining.remove(&neighbor) {
3609                                queue.push_back(neighbor);
3610                            }
3611                        }
3612                    }
3613                }
3614            }
3615
3616            component_indices.sort_by_key(|idx| {
3617                let candidate = &candidates[*idx];
3618                (candidate.row, candidate.col, *idx)
3619            });
3620            components.push(
3621                component_indices
3622                    .into_iter()
3623                    .map(|idx| candidates[idx].clone())
3624                    .collect(),
3625            );
3626        }
3627
3628        components
3629    }
3630
3631    pub fn ingest_formula_batches(
3632        &mut self,
3633        batches: Vec<FormulaIngestBatch>,
3634    ) -> Result<FormulaIngestReport, ExcelError> {
3635        let formula_cells_seen = batches.iter().map(|batch| batch.len() as u64).sum();
3636        let (mut report, materialize_batches, planned_materialize) =
3637            match self.config.formula_plane_mode {
3638                FormulaPlaneMode::Off => (
3639                    FormulaIngestReport::with_mode(FormulaPlaneMode::Off),
3640                    batches,
3641                    BTreeMap::new(),
3642                ),
3643                FormulaPlaneMode::Shadow => (
3644                    self.analyze_formula_plane_shadow_candidates(&batches),
3645                    batches,
3646                    BTreeMap::new(),
3647                ),
3648                FormulaPlaneMode::AuthoritativeExperimental => {
3649                    self.analyze_formula_plane_authoritative_ingest(&batches)
3650                }
3651            };
3652        report.formula_cells_seen = formula_cells_seen;
3653
3654        if !materialize_batches.iter().all(FormulaIngestBatch::is_empty)
3655            || !planned_materialize.is_empty()
3656        {
3657            let mut builder = self.begin_bulk_ingest();
3658            for batch in materialize_batches {
3659                if batch.is_empty() {
3660                    continue;
3661                }
3662                let sheet_id = builder.add_sheet(&batch.sheet_name);
3663                builder.add_formula_ids(
3664                    sheet_id,
3665                    batch
3666                        .formulas
3667                        .into_iter()
3668                        .map(|record| (record.row, record.col, record.ast_id)),
3669                );
3670            }
3671            for (sheet_name, formulas) in planned_materialize {
3672                if formulas.is_empty() {
3673                    continue;
3674                }
3675                let sheet_id = builder.add_sheet(&sheet_name);
3676                builder.add_formula_plans(sheet_id, formulas);
3677            }
3678            let summary = builder.finish()?;
3679            report.graph_formula_cells_materialized = summary.formulas as u64;
3680            report.graph_vertices_created = summary.vertices as u64;
3681            report.graph_edges_created = summary.edges as u64;
3682        }
3683
3684        self.record_formula_ingest_report(report.clone());
3685        Ok(report)
3686    }
3687
3688    pub fn handle_formula_parse_error(
3689        &mut self,
3690        sheet: &str,
3691        row: u32,
3692        col: u32,
3693        formula: &str,
3694        message: String,
3695    ) -> Result<Option<ASTNode>, ExcelError> {
3696        let policy = self.config.formula_parse_policy;
3697
3698        if policy == FormulaParsePolicy::Strict {
3699            let col_a1 = col_letters_from_1based(col).unwrap_or_else(|_| "?".to_string());
3700            return Err(ExcelError::new(ExcelErrorKind::Value).with_message(format!(
3701                "Formula parse error at {sheet}!{col_a1}{row}: {message}"
3702            )));
3703        }
3704
3705        self.formula_parse_diagnostics.push(FormulaParseDiagnostic {
3706            sheet: sheet.to_string(),
3707            row,
3708            col,
3709            formula: formula.to_string(),
3710            message: message.clone(),
3711            policy,
3712        });
3713
3714        match policy {
3715            FormulaParsePolicy::Strict => unreachable!(),
3716            FormulaParsePolicy::KeepCachedValue => Ok(None),
3717            FormulaParsePolicy::AsText => Ok(Some(ASTNode::new(
3718                ASTNodeType::Literal(LiteralValue::Text(formula.to_string())),
3719                None,
3720            ))),
3721            FormulaParsePolicy::CoerceToError => {
3722                let err = ExcelError::new(ExcelErrorKind::Error)
3723                    .with_message(format!("Malformed formula: {message}"));
3724                Ok(Some(ASTNode::new(
3725                    ASTNodeType::Literal(LiteralValue::Error(err)),
3726                    None,
3727                )))
3728            }
3729        }
3730    }
3731
3732    /// Build graph for all staged formulas.
3733    pub fn build_graph_all(&mut self) -> Result<(), formualizer_parse::ExcelError> {
3734        if self.staged_formulas.is_empty() {
3735            return Ok(());
3736        }
3737        // Take staged formulas before borrowing graph via builder.
3738        let staged = std::mem::take(&mut self.staged_formulas);
3739        for sheet in staged.keys() {
3740            let _ = self.add_sheet(sheet);
3741        }
3742
3743        // Parse/recover first, then pass prepared batches through the centralized ingest seam.
3744        let mut prepared: PreparedFormulaBatches = Vec::new();
3745        for (sheet, entries) in staged {
3746            let mut formulas: Vec<FormulaIngestRecord> = Vec::new();
3747            let mut cache: rustc_hash::FxHashMap<String, Option<crate::engine::arena::AstNodeId>> =
3748                rustc_hash::FxHashMap::default();
3749            cache.reserve(4096);
3750
3751            for (row, col, txt) in entries.into_entries() {
3752                let key = if txt.starts_with('=') {
3753                    txt
3754                } else {
3755                    format!("={txt}")
3756                };
3757                let ast_id = if let Some(cached) = cache.get(&key) {
3758                    *cached
3759                } else {
3760                    let parsed = match formualizer_parse::parser::parse(&key) {
3761                        Ok(parsed) => Some(parsed),
3762                        Err(e) => {
3763                            self.handle_formula_parse_error(&sheet, row, col, &key, e.to_string())?
3764                        }
3765                    };
3766                    let ast_id = parsed.as_ref().map(|ast| self.intern_formula_ast(ast));
3767                    cache.insert(key.clone(), ast_id);
3768                    ast_id
3769                };
3770
3771                if let Some(ast_id) = ast_id {
3772                    formulas.push(FormulaIngestRecord::new(
3773                        row,
3774                        col,
3775                        ast_id,
3776                        Some(Arc::<str>::from(key.clone())),
3777                    ));
3778                }
3779            }
3780
3781            if !formulas.is_empty() {
3782                prepared.push(FormulaIngestBatch::new(sheet, formulas));
3783            }
3784        }
3785
3786        if !prepared.is_empty() {
3787            let _ = self.ingest_formula_batches(prepared)?;
3788        }
3789        Ok(())
3790    }
3791
3792    /// Build graph for specific sheets (consuming only those staged entries).
3793    pub fn build_graph_for_sheets<'a, I: IntoIterator<Item = &'a str>>(
3794        &mut self,
3795        sheets: I,
3796    ) -> Result<(), formualizer_parse::ExcelError> {
3797        let mut collected: StagedFormulaBatches = Vec::new();
3798        for s in sheets {
3799            if let Some(entries) = self.staged_formulas.remove(s) {
3800                collected.push((s.to_string(), entries.into_entries()));
3801            }
3802        }
3803
3804        if collected.is_empty() {
3805            return Ok(());
3806        }
3807
3808        for (sheet, _) in &collected {
3809            let _ = self.add_sheet(sheet);
3810        }
3811
3812        // Parse/recover first, then pass prepared batches through the centralized ingest seam.
3813        let mut prepared: PreparedFormulaBatches = Vec::new();
3814        let mut cache: rustc_hash::FxHashMap<String, Option<crate::engine::arena::AstNodeId>> =
3815            rustc_hash::FxHashMap::default();
3816        cache.reserve(4096);
3817
3818        for (sheet, entries) in collected {
3819            let mut formulas: Vec<FormulaIngestRecord> = Vec::new();
3820            for (row, col, txt) in entries {
3821                let key = if txt.starts_with('=') {
3822                    txt
3823                } else {
3824                    format!("={txt}")
3825                };
3826                let ast_id = if let Some(cached) = cache.get(&key) {
3827                    *cached
3828                } else {
3829                    let parsed = match formualizer_parse::parser::parse(&key) {
3830                        Ok(parsed) => Some(parsed),
3831                        Err(e) => {
3832                            self.handle_formula_parse_error(&sheet, row, col, &key, e.to_string())?
3833                        }
3834                    };
3835                    let ast_id = parsed.as_ref().map(|ast| self.intern_formula_ast(ast));
3836                    cache.insert(key.clone(), ast_id);
3837                    ast_id
3838                };
3839
3840                if let Some(ast_id) = ast_id {
3841                    formulas.push(FormulaIngestRecord::new(
3842                        row,
3843                        col,
3844                        ast_id,
3845                        Some(Arc::<str>::from(key.clone())),
3846                    ));
3847                }
3848            }
3849            if !formulas.is_empty() {
3850                prepared.push(FormulaIngestBatch::new(sheet, formulas));
3851            }
3852        }
3853
3854        if !prepared.is_empty() {
3855            let _ = self.ingest_formula_batches(prepared)?;
3856        }
3857        Ok(())
3858    }
3859
3860    /// Begin bulk Arrow ingest for base values (Phase A)
3861    pub fn begin_bulk_ingest_arrow(
3862        &mut self,
3863    ) -> crate::engine::arrow_ingest::ArrowBulkIngestBuilder<'_, R> {
3864        crate::engine::arrow_ingest::ArrowBulkIngestBuilder::new(self)
3865    }
3866
3867    /// Begin bulk updates to Arrow store (Phase C)
3868    pub fn begin_bulk_update_arrow(
3869        &mut self,
3870    ) -> crate::engine::arrow_ingest::ArrowBulkUpdateBuilder<'_, R> {
3871        crate::engine::arrow_ingest::ArrowBulkUpdateBuilder::new(self)
3872    }
3873
3874    fn ensure_known_sheet_id(&self, sheet: &str) -> Result<SheetId, crate::engine::EditorError> {
3875        self.graph.sheet_id(sheet).ok_or(
3876            crate::engine::graph::editor::vertex_editor::EditorError::InvalidName {
3877                name: sheet.to_string(),
3878                reason: "Unknown sheet".to_string(),
3879            },
3880        )
3881    }
3882
3883    fn normalize_row_1based(row_1based: u32) -> Result<u32, crate::engine::EditorError> {
3884        if row_1based == 0 {
3885            return Err(crate::engine::EditorError::OutOfBounds { row: 0, col: 0 });
3886        }
3887        Ok(row_1based - 1)
3888    }
3889
3890    fn normalize_row_range_1based(
3891        start_row_1based: u32,
3892        end_row_1based: u32,
3893    ) -> Result<(u32, u32), crate::engine::EditorError> {
3894        if start_row_1based == 0 || end_row_1based == 0 {
3895            return Err(crate::engine::EditorError::OutOfBounds { row: 0, col: 0 });
3896        }
3897        if start_row_1based > end_row_1based {
3898            return Err(crate::engine::EditorError::TransactionFailed {
3899                reason: "Row range start is greater than end".to_string(),
3900            });
3901        }
3902        Ok((start_row_1based - 1, end_row_1based - 1))
3903    }
3904
3905    fn invalidate_row_visibility_mask_cache(&self) {
3906        if let Ok(mut cache) = self.row_visibility_mask_cache.write() {
3907            cache.clear();
3908        }
3909    }
3910
3911    fn set_row_hidden_by_sheet_id(
3912        &mut self,
3913        sheet_id: SheetId,
3914        row0: u32,
3915        hidden: bool,
3916        source: RowVisibilitySource,
3917    ) -> bool {
3918        let changed = {
3919            let state = self.row_visibility.entry(sheet_id).or_default();
3920            state.set_row_hidden(row0, hidden, source)
3921        };
3922
3923        let remove_entry = self
3924            .row_visibility
3925            .get(&sheet_id)
3926            .map(|state| state.is_empty())
3927            .unwrap_or(false);
3928        if remove_entry {
3929            self.row_visibility.remove(&sheet_id);
3930        }
3931
3932        if changed {
3933            self.invalidate_row_visibility_mask_cache();
3934        }
3935
3936        changed
3937    }
3938
3939    fn set_rows_hidden_by_sheet_id(
3940        &mut self,
3941        sheet_id: SheetId,
3942        start_row0: u32,
3943        end_row0: u32,
3944        hidden: bool,
3945        source: RowVisibilitySource,
3946    ) -> bool {
3947        let changed = {
3948            let state = self.row_visibility.entry(sheet_id).or_default();
3949            state.set_rows_hidden(start_row0, end_row0, hidden, source)
3950        };
3951
3952        let remove_entry = self
3953            .row_visibility
3954            .get(&sheet_id)
3955            .map(|state| state.is_empty())
3956            .unwrap_or(false);
3957        if remove_entry {
3958            self.row_visibility.remove(&sheet_id);
3959        }
3960
3961        if changed {
3962            self.invalidate_row_visibility_mask_cache();
3963        }
3964
3965        changed
3966    }
3967
3968    fn shift_row_visibility_insert(&mut self, sheet_id: SheetId, before0: u32, count: u32) {
3969        if count == 0 {
3970            return;
3971        }
3972        let mut changed = false;
3973        let remove_entry = if let Some(state) = self.row_visibility.get_mut(&sheet_id) {
3974            changed = state.insert_rows(before0, count);
3975            state.is_empty()
3976        } else {
3977            false
3978        };
3979        if remove_entry {
3980            self.row_visibility.remove(&sheet_id);
3981        }
3982        if changed {
3983            self.invalidate_row_visibility_mask_cache();
3984        }
3985    }
3986
3987    fn shift_row_visibility_delete(&mut self, sheet_id: SheetId, start0: u32, count: u32) {
3988        if count == 0 {
3989            return;
3990        }
3991        let mut changed = false;
3992        let remove_entry = if let Some(state) = self.row_visibility.get_mut(&sheet_id) {
3993            changed = state.delete_rows(start0, count);
3994            state.is_empty()
3995        } else {
3996            false
3997        };
3998        if remove_entry {
3999            self.row_visibility.remove(&sheet_id);
4000        }
4001        if changed {
4002            self.invalidate_row_visibility_mask_cache();
4003        }
4004    }
4005
4006    fn apply_inverse_row_visibility_event(&mut self, event: &crate::engine::ChangeEvent) {
4007        if let crate::engine::ChangeEvent::SetRowVisibility {
4008            sheet_id,
4009            row0,
4010            source,
4011            old_hidden,
4012            ..
4013        } = event
4014        {
4015            let _ = self.set_row_hidden_by_sheet_id(*sheet_id, *row0, *old_hidden, *source);
4016        }
4017    }
4018
4019    fn apply_forward_row_visibility_event(&mut self, event: &crate::engine::ChangeEvent) {
4020        if let crate::engine::ChangeEvent::SetRowVisibility {
4021            sheet_id,
4022            row0,
4023            source,
4024            new_hidden,
4025            ..
4026        } = event
4027        {
4028            let _ = self.set_row_hidden_by_sheet_id(*sheet_id, *row0, *new_hidden, *source);
4029        }
4030    }
4031
4032    fn apply_inverse_row_visibility_events(&mut self, events: &[crate::engine::ChangeEvent]) {
4033        for event in events.iter().rev() {
4034            self.apply_inverse_row_visibility_event(event);
4035        }
4036    }
4037
4038    fn apply_forward_row_visibility_events(&mut self, events: &[crate::engine::ChangeEvent]) {
4039        for event in events {
4040            self.apply_forward_row_visibility_event(event);
4041        }
4042    }
4043
4044    fn apply_inverse_staged_formula_event(&mut self, event: &crate::engine::ChangeEvent) {
4045        if let crate::engine::ChangeEvent::StagedFormulaCellChanged {
4046            sheet,
4047            row,
4048            col,
4049            old,
4050            ..
4051        } = event
4052        {
4053            self.apply_staged_formula_cell(sheet, *row, *col, old.as_deref());
4054        }
4055    }
4056
4057    fn apply_forward_staged_formula_event(&mut self, event: &crate::engine::ChangeEvent) {
4058        if let crate::engine::ChangeEvent::StagedFormulaCellChanged {
4059            sheet,
4060            row,
4061            col,
4062            new,
4063            ..
4064        } = event
4065        {
4066            self.apply_staged_formula_cell(sheet, *row, *col, new.as_deref());
4067        }
4068    }
4069
4070    /// Set a single cell's staged formula text to `target` (clearing it when
4071    /// `None`). Used by undo/redo replay of per-cell staged-formula deltas.
4072    fn apply_staged_formula_cell(&mut self, sheet: &str, row: u32, col: u32, target: Option<&str>) {
4073        match target {
4074            Some(text) => self.stage_formula_text(sheet, row, col, text.to_string()),
4075            None => {
4076                self.clear_staged_formula_text(sheet, row, col);
4077            }
4078        }
4079    }
4080
4081    pub fn set_row_hidden(
4082        &mut self,
4083        sheet: &str,
4084        row_1based: u32,
4085        hidden: bool,
4086        source: RowVisibilitySource,
4087    ) -> Result<(), crate::engine::EditorError> {
4088        let sheet_id = self.ensure_known_sheet_id(sheet)?;
4089        let row0 = Self::normalize_row_1based(row_1based)?;
4090        if self.set_row_hidden_by_sheet_id(sheet_id, row0, hidden, source) {
4091            self.record_formula_plane_structural_change(StructuralScope::Region(
4092                Region::whole_row(sheet_id, row0),
4093            ));
4094            self.mark_data_edited();
4095        }
4096        Ok(())
4097    }
4098
4099    pub fn set_rows_hidden(
4100        &mut self,
4101        sheet: &str,
4102        start_row_1based: u32,
4103        end_row_1based: u32,
4104        hidden: bool,
4105        source: RowVisibilitySource,
4106    ) -> Result<(), crate::engine::EditorError> {
4107        let sheet_id = self.ensure_known_sheet_id(sheet)?;
4108        let (start_row0, end_row0) =
4109            Self::normalize_row_range_1based(start_row_1based, end_row_1based)?;
4110        if self.set_rows_hidden_by_sheet_id(sheet_id, start_row0, end_row0, hidden, source) {
4111            if start_row0 == end_row0 {
4112                self.record_formula_plane_structural_change(StructuralScope::Region(
4113                    Region::whole_row(sheet_id, start_row0),
4114                ));
4115            } else {
4116                self.record_formula_plane_structural_change(StructuralScope::Sheet(sheet_id));
4117            }
4118            self.mark_data_edited();
4119        }
4120        Ok(())
4121    }
4122
4123    pub fn is_row_hidden(
4124        &self,
4125        sheet: &str,
4126        row_1based: u32,
4127        source: Option<RowVisibilitySource>,
4128    ) -> Option<bool> {
4129        let sheet_id = self.graph.sheet_id(sheet)?;
4130        let row0 = row_1based.checked_sub(1)?;
4131        Some(
4132            self.row_visibility
4133                .get(&sheet_id)
4134                .map(|state| state.is_row_hidden(row0, source))
4135                .unwrap_or(false),
4136        )
4137    }
4138
4139    pub fn row_visibility_version(&self, sheet: &str) -> Option<u64> {
4140        let sheet_id = self.graph.sheet_id(sheet)?;
4141        Some(
4142            self.row_visibility
4143                .get(&sheet_id)
4144                .map(|state| state.version())
4145                .unwrap_or(0),
4146        )
4147    }
4148
4149    fn build_row_visibility_mask_for_view(
4150        &self,
4151        view: &RangeView<'_>,
4152        mode: VisibilityMaskMode,
4153    ) -> Option<std::sync::Arc<arrow_array::BooleanArray>> {
4154        let sheet_rows = view.sheet().nrows as usize;
4155        if sheet_rows == 0 || view.start_row() >= sheet_rows {
4156            return Some(std::sync::Arc::new(arrow_array::BooleanArray::new_null(0)));
4157        }
4158
4159        let sheet_id = self.graph.sheet_id(view.sheet_name())?;
4160        let start_row0 = view.start_row() as u32;
4161        let end_row0 = view.end_row().min(sheet_rows.saturating_sub(1)) as u32;
4162        let version = self
4163            .row_visibility
4164            .get(&sheet_id)
4165            .map(|state| state.version())
4166            .unwrap_or(0);
4167        let key = VisibilityMaskCacheKey {
4168            sheet_id,
4169            start_row0,
4170            end_row0,
4171            mode,
4172            version,
4173        };
4174
4175        if let Ok(cache) = self.row_visibility_mask_cache.read()
4176            && let Some(mask) = cache.get(&key)
4177        {
4178            #[cfg(test)]
4179            visibility_mask_test_hooks::inc_hit();
4180            return Some(mask.clone());
4181        }
4182
4183        #[cfg(test)]
4184        visibility_mask_test_hooks::inc_miss();
4185
4186        let state = self.row_visibility.get(&sheet_id);
4187        let mut out = Vec::with_capacity((end_row0 - start_row0 + 1) as usize);
4188        for row0 in start_row0..=end_row0 {
4189            let manual_hidden = state
4190                .map(|s| s.is_row_hidden(row0, Some(RowVisibilitySource::Manual)))
4191                .unwrap_or(false);
4192            let filter_hidden = state
4193                .map(|s| s.is_row_hidden(row0, Some(RowVisibilitySource::Filter)))
4194                .unwrap_or(false);
4195
4196            let include = match mode {
4197                VisibilityMaskMode::IncludeAll => true,
4198                VisibilityMaskMode::ExcludeManualHidden => !manual_hidden,
4199                VisibilityMaskMode::ExcludeFilterHidden => !filter_hidden,
4200                VisibilityMaskMode::ExcludeManualOrFilterHidden => {
4201                    !(manual_hidden || filter_hidden)
4202                }
4203            };
4204            out.push(include);
4205        }
4206
4207        let mask = std::sync::Arc::new(arrow_array::BooleanArray::from(out));
4208        if let Ok(mut cache) = self.row_visibility_mask_cache.write() {
4209            const MAX_CACHE_ENTRIES: usize = 4096;
4210            if cache.len() >= MAX_CACHE_ENTRIES {
4211                cache.clear();
4212                #[cfg(test)]
4213                visibility_mask_test_hooks::inc_eviction();
4214            }
4215            cache.insert(key, mask.clone());
4216        }
4217
4218        Some(mask)
4219    }
4220
4221    fn editor_error_to_excel(error: crate::engine::EditorError) -> ExcelError {
4222        match error {
4223            crate::engine::EditorError::Excel(error) => error,
4224            other => ExcelError::new(ExcelErrorKind::Value).with_message(other.to_string()),
4225        }
4226    }
4227
4228    fn demote_span_containing_cell_for_write(
4229        &mut self,
4230        sheet_id: SheetId,
4231        row0: u32,
4232        col0: u32,
4233    ) -> Result<(), crate::engine::EditorError> {
4234        if self.config.formula_plane_mode == FormulaPlaneMode::Off {
4235            return Ok(());
4236        }
4237        let placement = PlacementCoord::new(sheet_id, row0, col0);
4238        let inside_active_span = self
4239            .graph
4240            .formula_authority()
4241            .plane
4242            .spans
4243            .find_at(placement)
4244            .is_some();
4245        if inside_active_span {
4246            self.demote_spans_preserving_computed_overlays(
4247                sheet_id,
4248                Region::point(sheet_id, row0, col0),
4249            )?;
4250        }
4251        Ok(())
4252    }
4253
4254    fn demote_spans_preserving_computed_overlays(
4255        &mut self,
4256        _sheet_id: SheetId,
4257        affected_region: Region,
4258    ) -> Result<(), crate::engine::EditorError> {
4259        // Per-cell write inside a span (or whole-sheet demote via remove_sheet):
4260        // not a structural axis shift. Demote every span whose result or read
4261        // region intersects `affected_region`; leave disjoint spans untouched.
4262        self.demote_spans_for_structural_op_impl(None, affected_region, false)
4263    }
4264
4265    fn structural_row_region(sheet_id: SheetId, start_row0: u32) -> Region {
4266        Region::rows_from(sheet_id, start_row0)
4267    }
4268
4269    fn structural_col_region(sheet_id: SheetId, start_col0: u32) -> Region {
4270        Region::cols_from(sheet_id, start_col0)
4271    }
4272
4273    fn span_result_region_intersects_affected(
4274        span: &crate::formula_plane::runtime::FormulaSpan,
4275        affected_region: &Region,
4276    ) -> bool {
4277        Region::from_domain(span.result_region.domain()).intersects(affected_region)
4278    }
4279
4280    fn span_any_read_region_intersects_affected(
4281        plane: &FormulaPlane,
4282        span: &crate::formula_plane::runtime::FormulaSpan,
4283        affected_region: &Region,
4284    ) -> bool {
4285        span.read_summary_id
4286            .and_then(|read_summary_id| plane.span_read_summaries.get(read_summary_id))
4287            .is_some_and(|summary| {
4288                summary
4289                    .dependencies
4290                    .iter()
4291                    .any(|dependency| dependency.read_region.intersects(affected_region))
4292            })
4293    }
4294
4295    fn insert_formula_plane_dirty_coords_for_span(
4296        &self,
4297        span_ref: FormulaSpanRef,
4298        dirty: ProducerDirtyDomain,
4299        out: &mut FxHashSet<(SheetId, u32, u32)>,
4300    ) -> Result<(), crate::engine::EditorError> {
4301        let authority = self.graph.formula_authority();
4302        let span = authority.plane.spans.get(span_ref).ok_or_else(|| {
4303            ExcelError::new(ExcelErrorKind::NImpl)
4304                .with_message("FormulaPlane dirty transfer referenced a stale span")
4305        })?;
4306        match dirty {
4307            ProducerDirtyDomain::Whole => {
4308                out.extend(
4309                    span.domain
4310                        .iter()
4311                        .map(|coord| (coord.sheet_id, coord.row, coord.col)),
4312                );
4313            }
4314            ProducerDirtyDomain::Cells(cells) => {
4315                out.extend(cells.into_iter().filter_map(|key| {
4316                    let coord = PlacementCoord::new(key.sheet_id, key.row, key.col);
4317                    span.domain
4318                        .contains(coord)
4319                        .then_some((coord.sheet_id, coord.row, coord.col))
4320                }));
4321            }
4322            ProducerDirtyDomain::Regions(regions) => {
4323                out.extend(span.domain.iter().filter_map(|coord| {
4324                    let key = crate::formula_plane::region_index::RegionKey::from(coord);
4325                    regions
4326                        .iter()
4327                        .any(|region| region.contains_key(key))
4328                        .then_some((coord.sheet_id, coord.row, coord.col))
4329                }));
4330            }
4331        }
4332        Ok(())
4333    }
4334
4335    fn compute_current_formula_plane_dirty_result_coords(
4336        &self,
4337    ) -> Result<FxHashSet<(SheetId, u32, u32)>, crate::engine::EditorError> {
4338        use crate::formula_plane::producer::compute_dirty_closure;
4339
4340        let authority = self.graph.formula_authority();
4341        let span_refs = authority.active_span_refs();
4342        let span_refs_by_id = span_refs
4343            .iter()
4344            .copied()
4345            .map(|span_ref| (span_ref.id, span_ref))
4346            .collect::<BTreeMap<_, _>>();
4347        let mut dirty_coords = FxHashSet::default();
4348
4349        if self.formula_plane_indexes_epoch_seen != authority.indexes_epoch() {
4350            for span_ref in span_refs {
4351                self.insert_formula_plane_dirty_coords_for_span(
4352                    span_ref,
4353                    ProducerDirtyDomain::Whole,
4354                    &mut dirty_coords,
4355                )?;
4356            }
4357            return Ok(dirty_coords);
4358        }
4359
4360        let pending_changed_regions = authority.pending_changed_regions();
4361        if pending_changed_regions.is_empty() {
4362            return Ok(dirty_coords);
4363        }
4364
4365        let closure = compute_dirty_closure(
4366            &authority.consumer_reads,
4367            pending_changed_regions.iter().copied(),
4368            |producer| authority.producer_results.producer_result_region(producer),
4369        );
4370        for work in closure.work {
4371            let FormulaProducerId::Span(span_id) = work.producer else {
4372                continue;
4373            };
4374            let Some(span_ref) = span_refs_by_id.get(&span_id).copied() else {
4375                continue;
4376            };
4377            self.insert_formula_plane_dirty_coords_for_span(
4378                span_ref,
4379                work.dirty,
4380                &mut dirty_coords,
4381            )?;
4382        }
4383        for fallback in closure.fallbacks {
4384            let FormulaProducerId::Span(span_id) = fallback.consumer else {
4385                continue;
4386            };
4387            let Some(span_ref) = span_refs_by_id.get(&span_id).copied() else {
4388                continue;
4389            };
4390            self.insert_formula_plane_dirty_coords_for_span(
4391                span_ref,
4392                ProducerDirtyDomain::Whole,
4393                &mut dirty_coords,
4394            )?;
4395        }
4396
4397        Ok(dirty_coords)
4398    }
4399
4400    /// Demote active FormulaPlane spans affected by a structural edit on `sheet_id`.
4401    ///
4402    /// This is the conservative Option-A correctness path for structural edits: rather than
4403    /// attempting to transform FormulaPlane span domains/templates/indexes, materialize each span
4404    /// placement as an ordinary legacy graph formula at its current coordinate, remove the span,
4405    /// and let the existing VertexEditor structural machinery shift/delete those vertices and
4406    /// adjust their ASTs.  Spans whose formula domain is on `sheet_id` are affected directly; spans
4407    /// on other sheets are also affected when one of their retained read regions targets
4408    /// `sheet_id`, because those read-region coordinates become stale after row/column shifts.
4409    fn demote_spans_for_structural_op(
4410        &mut self,
4411        op: StructuralOp,
4412        affected_region: Region,
4413    ) -> Result<(), crate::engine::EditorError> {
4414        if op.count() == 0 {
4415            return Ok(());
4416        }
4417        self.demote_spans_for_structural_op_impl(Some(op), affected_region, true)
4418    }
4419
4420    fn demote_spans_for_structural_op_impl(
4421        &mut self,
4422        op: Option<StructuralOp>,
4423        affected_region: Region,
4424        clear_computed_overlays: bool,
4425    ) -> Result<(), crate::engine::EditorError> {
4426        struct SpanPlan {
4427            span_ref: FormulaSpanRef,
4428            sheet_id: SheetId,
4429            ast: ASTNode,
4430            origin_row: u32,
4431            origin_col: u32,
4432            binding_set_id: Option<crate::formula_plane::runtime::SpanBindingSetId>,
4433            placements: Vec<(u32, u32)>,
4434        }
4435
4436        fn substitute_literal_slots_for_template_placement(
4437            ast: &ASTNode,
4438            binding: &[LiteralValue],
4439        ) -> ASTNode {
4440            fn clone_with_slots(
4441                ast: &ASTNode,
4442                binding: &[LiteralValue],
4443                next: &mut usize,
4444                in_array: bool,
4445            ) -> ASTNode {
4446                let node_type = match &ast.node_type {
4447                    ASTNodeType::Literal(_) if !in_array => {
4448                        let value = binding.get(*next).cloned().unwrap_or(LiteralValue::Empty);
4449                        *next = next.saturating_add(1);
4450                        ASTNodeType::Literal(value)
4451                    }
4452                    ASTNodeType::Literal(value) => ASTNodeType::Literal(value.clone()),
4453                    ASTNodeType::Reference {
4454                        original,
4455                        reference,
4456                    } => ASTNodeType::Reference {
4457                        original: original.clone(),
4458                        reference: reference.clone(),
4459                    },
4460                    ASTNodeType::UnaryOp { op, expr } => ASTNodeType::UnaryOp {
4461                        op: op.clone(),
4462                        expr: Box::new(clone_with_slots(expr, binding, next, in_array)),
4463                    },
4464                    ASTNodeType::BinaryOp { op, left, right } => ASTNodeType::BinaryOp {
4465                        op: op.clone(),
4466                        left: Box::new(clone_with_slots(left, binding, next, in_array)),
4467                        right: Box::new(clone_with_slots(right, binding, next, in_array)),
4468                    },
4469                    ASTNodeType::Function { name, args } => ASTNodeType::Function {
4470                        name: name.clone(),
4471                        args: args
4472                            .iter()
4473                            .map(|arg| clone_with_slots(arg, binding, next, in_array))
4474                            .collect(),
4475                    },
4476                    ASTNodeType::Call { callee, args } => ASTNodeType::Call {
4477                        callee: Box::new(clone_with_slots(callee, binding, next, in_array)),
4478                        args: args
4479                            .iter()
4480                            .map(|arg| clone_with_slots(arg, binding, next, in_array))
4481                            .collect(),
4482                    },
4483                    ASTNodeType::Array(rows) => ASTNodeType::Array(
4484                        rows.iter()
4485                            .map(|row| {
4486                                row.iter()
4487                                    .map(|cell| clone_with_slots(cell, binding, next, true))
4488                                    .collect()
4489                            })
4490                            .collect(),
4491                    ),
4492                };
4493                ASTNode::new(node_type, ast.source_token.clone())
4494            }
4495            let mut next = 0usize;
4496            clone_with_slots(ast, binding, &mut next, false)
4497        }
4498
4499        let span_refs = self.graph.formula_authority().active_span_refs();
4500        if span_refs.is_empty() {
4501            return Ok(());
4502        }
4503        let dirty_span_coords = if clear_computed_overlays {
4504            FxHashSet::default()
4505        } else {
4506            self.compute_current_formula_plane_dirty_result_coords()?
4507        };
4508
4509        struct ShiftPlan {
4510            span_ref: FormulaSpanRef,
4511            template_id: crate::formula_plane::ids::FormulaTemplateId,
4512            new_origin_row: u32,
4513            new_origin_col: u32,
4514            new_domain: crate::formula_plane::runtime::PlacementDomain,
4515            new_read_summary: Option<SpanReadSummary>,
4516            binding_set_id: Option<crate::formula_plane::runtime::SpanBindingSetId>,
4517            force_binding_residual_axes: bool,
4518        }
4519
4520        fn checked_shift_u32(value: u32, delta: i64) -> Option<u32> {
4521            u32::try_from(i64::from(value).checked_add(delta)?).ok()
4522        }
4523
4524        fn shifted_read_summary(
4525            read_summary: &SpanReadSummary,
4526            new_result_region: Region,
4527            op: StructuralOp,
4528            row_delta: i64,
4529            col_delta: i64,
4530        ) -> Option<SpanReadSummary> {
4531            let mut dependencies = Vec::with_capacity(read_summary.dependencies.len());
4532            for dependency in &read_summary.dependencies {
4533                let read_region = match op.classify_region(dependency.read_region) {
4534                    crate::formula_plane::structural_shift::AxisShiftCase::OtherSheet
4535                    | crate::formula_plane::structural_shift::AxisShiftCase::EntirelyBelow => {
4536                        dependency.read_region
4537                    }
4538                    crate::formula_plane::structural_shift::AxisShiftCase::EntirelyAboveShift {
4539                        ..
4540                    } => dependency
4541                        .read_region
4542                        .project_through_axis_shift(row_delta, col_delta)?,
4543                    crate::formula_plane::structural_shift::AxisShiftCase::Straddles
4544                    | crate::formula_plane::structural_shift::AxisShiftCase::DeleteFullyContains => {
4545                        return None;
4546                    }
4547                };
4548                dependencies.push(crate::formula_plane::producer::SpanReadDependency {
4549                    read_region,
4550                    projection: dependency.projection,
4551                });
4552            }
4553            Some(SpanReadSummary {
4554                result_region: new_result_region,
4555                dependencies,
4556            })
4557        }
4558
4559        fn compact_axis_through_delete(
4560            min: u32,
4561            max: u32,
4562            start: u32,
4563            count: u32,
4564        ) -> Option<(u32, u32)> {
4565            let end = start.saturating_add(count);
4566            if max < start || min >= end {
4567                return Some((min.saturating_sub(count), max.saturating_sub(count)));
4568            }
4569            let keeps_left = min < start;
4570            let keeps_right = max >= end;
4571            match (keeps_left, keeps_right) {
4572                (false, false) => None,
4573                (true, false) => Some((min, start.checked_sub(1)?)),
4574                (false, true) => Some((start, max.checked_sub(count)?)),
4575                (true, true) => Some((min, max.checked_sub(count)?)),
4576            }
4577        }
4578
4579        fn compact_domain_through_delete(
4580            domain: &PlacementDomain,
4581            op: StructuralOp,
4582        ) -> Option<PlacementDomain> {
4583            match (domain, op) {
4584                (
4585                    PlacementDomain::RowRun {
4586                        sheet_id,
4587                        row_start,
4588                        row_end,
4589                        col,
4590                    },
4591                    StructuralOp::DeleteRows { start, count, .. },
4592                ) => {
4593                    let (row_start, row_end) =
4594                        compact_axis_through_delete(*row_start, *row_end, start, count)?;
4595                    Some(PlacementDomain::row_run(
4596                        *sheet_id, row_start, row_end, *col,
4597                    ))
4598                }
4599                (
4600                    PlacementDomain::Rect {
4601                        sheet_id,
4602                        row_start,
4603                        row_end,
4604                        col_start,
4605                        col_end,
4606                    },
4607                    StructuralOp::DeleteRows { start, count, .. },
4608                ) => {
4609                    let (row_start, row_end) =
4610                        compact_axis_through_delete(*row_start, *row_end, start, count)?;
4611                    Some(PlacementDomain::rect(
4612                        *sheet_id, row_start, row_end, *col_start, *col_end,
4613                    ))
4614                }
4615                (
4616                    PlacementDomain::ColRun {
4617                        sheet_id,
4618                        row,
4619                        col_start,
4620                        col_end,
4621                    },
4622                    StructuralOp::DeleteColumns { start, count, .. },
4623                ) => {
4624                    let (col_start, col_end) =
4625                        compact_axis_through_delete(*col_start, *col_end, start, count)?;
4626                    Some(PlacementDomain::col_run(
4627                        *sheet_id, *row, col_start, col_end,
4628                    ))
4629                }
4630                (
4631                    PlacementDomain::Rect {
4632                        sheet_id,
4633                        row_start,
4634                        row_end,
4635                        col_start,
4636                        col_end,
4637                    },
4638                    StructuralOp::DeleteColumns { start, count, .. },
4639                ) => {
4640                    let (col_start, col_end) =
4641                        compact_axis_through_delete(*col_start, *col_end, start, count)?;
4642                    Some(PlacementDomain::rect(
4643                        *sheet_id, *row_start, *row_end, col_start, col_end,
4644                    ))
4645                }
4646                _ => None,
4647            }
4648        }
4649
4650        fn compact_axis_range_through_delete(
4651            axis: crate::formula_plane::region_index::AxisRange,
4652            start: u32,
4653            count: u32,
4654        ) -> Option<crate::formula_plane::region_index::AxisRange> {
4655            use crate::formula_plane::region_index::AxisRange;
4656            match axis {
4657                AxisRange::Point(point) => compact_axis_through_delete(point, point, start, count)
4658                    .map(|(point, _)| AxisRange::Point(point)),
4659                AxisRange::Span(min, max) => compact_axis_through_delete(min, max, start, count)
4660                    .map(|(min, max)| AxisRange::Span(min, max)),
4661                AxisRange::All => Some(AxisRange::All),
4662                AxisRange::From(_) | AxisRange::To(_) => None,
4663            }
4664        }
4665
4666        fn compact_region_through_delete(region: Region, op: StructuralOp) -> Option<Region> {
4667            let (rows, cols) = region.axis_ranges();
4668            match op {
4669                StructuralOp::DeleteRows {
4670                    sheet_id,
4671                    start,
4672                    count,
4673                } if region.sheet_id() == sheet_id => Some(Region {
4674                    sheet_id,
4675                    rows: compact_axis_range_through_delete(rows, start, count)?,
4676                    cols,
4677                }),
4678                StructuralOp::DeleteColumns {
4679                    sheet_id,
4680                    start,
4681                    count,
4682                } if region.sheet_id() == sheet_id => Some(Region {
4683                    sheet_id,
4684                    rows,
4685                    cols: compact_axis_range_through_delete(cols, start, count)?,
4686                }),
4687                _ => Some(region),
4688            }
4689        }
4690
4691        fn compact_read_summary_through_delete(
4692            read_summary: &SpanReadSummary,
4693            new_result_region: Region,
4694            op: StructuralOp,
4695        ) -> Option<SpanReadSummary> {
4696            let mut dependencies = Vec::with_capacity(read_summary.dependencies.len());
4697            for dependency in &read_summary.dependencies {
4698                let read_region = match op.classify_region(dependency.read_region) {
4699                    crate::formula_plane::structural_shift::AxisShiftCase::OtherSheet
4700                    | crate::formula_plane::structural_shift::AxisShiftCase::EntirelyBelow => {
4701                        dependency.read_region
4702                    }
4703                    crate::formula_plane::structural_shift::AxisShiftCase::EntirelyAboveShift {
4704                        ..
4705                    } => {
4706                        let (row_delta, col_delta) = op.axis_shift_delta();
4707                        dependency
4708                            .read_region
4709                            .project_through_axis_shift(row_delta, col_delta)?
4710                    }
4711                    crate::formula_plane::structural_shift::AxisShiftCase::Straddles => {
4712                        compact_region_through_delete(dependency.read_region, op)?
4713                    }
4714                    crate::formula_plane::structural_shift::AxisShiftCase::DeleteFullyContains => {
4715                        return None;
4716                    }
4717                };
4718                dependencies.push(crate::formula_plane::producer::SpanReadDependency {
4719                    read_region,
4720                    projection: dependency.projection,
4721                });
4722            }
4723            Some(SpanReadSummary {
4724                result_region: new_result_region,
4725                dependencies,
4726            })
4727        }
4728
4729        fn domain_origin_1_based(domain: &PlacementDomain) -> (u32, u32) {
4730            match domain {
4731                PlacementDomain::RowRun { row_start, col, .. } => (row_start + 1, col + 1),
4732                PlacementDomain::ColRun { row, col_start, .. } => (row + 1, col_start + 1),
4733                PlacementDomain::Rect {
4734                    row_start,
4735                    col_start,
4736                    ..
4737                } => (row_start + 1, col_start + 1),
4738            }
4739        }
4740
4741        let mut shift_plans = Vec::new();
4742        let mut remove_refs = Vec::new();
4743        let mut demote_refs = Vec::new();
4744        for span_ref in span_refs {
4745            let authority = self.graph.formula_authority();
4746            let Some(span) = authority.plane.spans.get(span_ref) else {
4747                continue;
4748            };
4749            let read_summary = span
4750                .read_summary_id
4751                .and_then(|id| authority.plane.span_read_summaries.get(id));
4752            let Some(op) = op else {
4753                // Non-structural demote path (per-cell write into span, or
4754                // remove_sheet's whole-sheet sweep). Only demote spans whose
4755                // result or read region intersects affected_region; leave
4756                // disjoint spans untouched.
4757                let result_region_affected =
4758                    Self::span_result_region_intersects_affected(span, &affected_region);
4759                let read_region_affected = Self::span_any_read_region_intersects_affected(
4760                    &authority.plane,
4761                    span,
4762                    &affected_region,
4763                );
4764                if result_region_affected || read_region_affected {
4765                    demote_refs.push(span_ref);
4766                }
4767                continue;
4768            };
4769            match classify_span_for_op(span, read_summary, op) {
4770                SpanShiftPlan::NoOp => {}
4771                SpanShiftPlan::Remove => {
4772                    remove_refs.push(span_ref);
4773                }
4774                SpanShiftPlan::Demote {
4775                    reason:
4776                        crate::formula_plane::structural_shift::SpanDemoteReason::DeletePartiallyOverlaps,
4777                } => {
4778                    let binding_compaction_safe = span
4779                        .binding_set_id
4780                        .and_then(|id| authority.plane.binding_sets.get(id))
4781                        .is_none_or(|binding_set| binding_set.is_single_literal_binding());
4782                    if binding_compaction_safe
4783                        && let Some(new_domain) = compact_domain_through_delete(&span.domain, op)
4784                    {
4785                        let new_result_region = Region::from_domain(&new_domain);
4786                        let new_read_summary = if let Some(summary) = read_summary {
4787                            compact_read_summary_through_delete(summary, new_result_region, op)
4788                        } else {
4789                            None
4790                        };
4791                        if read_summary.is_none() || new_read_summary.is_some() {
4792                            let (new_origin_row, new_origin_col) = domain_origin_1_based(&new_domain);
4793                            let Some(template) = authority.plane.templates.get(span.template_id)
4794                            else {
4795                                return Err(ExcelError::new(ExcelErrorKind::Ref)
4796                                    .with_message(
4797                                        "FormulaPlane delete compaction found a span with a missing template",
4798                                    )
4799                                    .into());
4800                            };
4801                            let force_binding_residual_axes = span
4802                                .binding_set_id
4803                                .and_then(|id| authority.plane.binding_sets.get(id))
4804                                .is_some_and(|binding_set| {
4805                                    !binding_set.value_ref_slots.is_empty()
4806                                        && (new_origin_row != template.origin_row
4807                                            || new_origin_col != template.origin_col)
4808                                });
4809                            shift_plans.push(ShiftPlan {
4810                                span_ref,
4811                                template_id: span.template_id,
4812                                new_origin_row,
4813                                new_origin_col,
4814                                new_domain,
4815                                new_read_summary,
4816                                binding_set_id: span.binding_set_id,
4817                                force_binding_residual_axes,
4818                            });
4819                        } else {
4820                            demote_refs.push(span_ref);
4821                        }
4822                    } else {
4823                        demote_refs.push(span_ref);
4824                    }
4825                }
4826                SpanShiftPlan::Demote { .. } => {
4827                    demote_refs.push(span_ref);
4828                }
4829                SpanShiftPlan::Shift {
4830                    row_delta,
4831                    col_delta,
4832                    origin_row_delta,
4833                    origin_col_delta,
4834                } => {
4835                    let Some(template) = authority.plane.templates.get(span.template_id) else {
4836                        return Err(ExcelError::new(ExcelErrorKind::Ref)
4837                            .with_message("FormulaPlane shift found a span with a missing template")
4838                            .into());
4839                    };
4840                    let Some(new_origin_row) =
4841                        checked_shift_u32(template.origin_row, origin_row_delta)
4842                    else {
4843                        return Err(ExcelError::new(ExcelErrorKind::Ref)
4844                            .with_message("FormulaPlane shift overflowed template origin row")
4845                            .into());
4846                    };
4847                    let Some(new_origin_col) =
4848                        checked_shift_u32(template.origin_col, origin_col_delta)
4849                    else {
4850                        return Err(ExcelError::new(ExcelErrorKind::Ref)
4851                            .with_message("FormulaPlane shift overflowed template origin column")
4852                            .into());
4853                    };
4854                    let Some(new_domain) =
4855                        span.domain.project_through_axis_shift(row_delta, col_delta)
4856                    else {
4857                        return Err(ExcelError::new(ExcelErrorKind::Ref)
4858                            .with_message("FormulaPlane shift overflowed span domain")
4859                            .into());
4860                    };
4861                    let new_result_region = Region::from_domain(&new_domain);
4862                    let new_read_summary = if let Some(summary) = read_summary {
4863                        Some(
4864                            shifted_read_summary(
4865                                summary,
4866                                new_result_region,
4867                                op,
4868                                row_delta,
4869                                col_delta,
4870                            )
4871                            .ok_or_else(|| {
4872                                ExcelError::new(ExcelErrorKind::Ref).with_message(
4873                                    "FormulaPlane shift could not project read summary",
4874                                )
4875                            })?,
4876                        )
4877                    } else {
4878                        None
4879                    };
4880                    let force_binding_residual_axes = span
4881                        .binding_set_id
4882                        .and_then(|id| authority.plane.binding_sets.get(id))
4883                        .is_some_and(|binding_set| {
4884                            !binding_set.value_ref_slots.is_empty()
4885                                && (origin_row_delta != 0 || origin_col_delta != 0)
4886                        });
4887                    shift_plans.push(ShiftPlan {
4888                        span_ref,
4889                        template_id: span.template_id,
4890                        new_origin_row,
4891                        new_origin_col,
4892                        new_domain,
4893                        new_read_summary,
4894                        binding_set_id: span.binding_set_id,
4895                        force_binding_residual_axes,
4896                    });
4897                }
4898            }
4899        }
4900        if !shift_plans.is_empty() || !remove_refs.is_empty() {
4901            let authority = self.graph.formula_authority_mut();
4902            for span_ref in remove_refs {
4903                authority.plane.remove_overlays_for_source_span(span_ref);
4904                authority.plane.remove_span(span_ref);
4905            }
4906            for plan in shift_plans {
4907                let Some(template_id) = authority.plane.intern_shifted_template_origin(
4908                    plan.template_id,
4909                    plan.new_origin_row,
4910                    plan.new_origin_col,
4911                ) else {
4912                    return Err(ExcelError::new(ExcelErrorKind::Ref)
4913                        .with_message("FormulaPlane shift could not clone template origin")
4914                        .into());
4915                };
4916                if let Some(binding_set_id) = plan.binding_set_id {
4917                    let Some(template) = authority.plane.templates.get(template_id) else {
4918                        return Err(ExcelError::new(ExcelErrorKind::Ref)
4919                            .with_message("FormulaPlane shift could not find shifted template")
4920                            .into());
4921                    };
4922                    let (ast_id, origin_row, origin_col) =
4923                        (template.ast_id, template.origin_row, template.origin_col);
4924                    authority.plane.set_binding_template_anchor(
4925                        binding_set_id,
4926                        ast_id,
4927                        origin_row,
4928                        origin_col,
4929                    );
4930                }
4931                let read_summary_id = plan
4932                    .new_read_summary
4933                    .map(|summary| authority.plane.insert_span_read_summary(summary));
4934                let result_region = ResultRegion::scalar_cells(plan.new_domain.clone());
4935                if !authority.plane.replace_span_geometry(
4936                    plan.span_ref,
4937                    template_id,
4938                    plan.new_domain,
4939                    result_region,
4940                    read_summary_id,
4941                ) {
4942                    return Err(ExcelError::new(ExcelErrorKind::Ref)
4943                        .with_message("FormulaPlane shift could not update span geometry")
4944                        .into());
4945                }
4946                if plan.force_binding_residual_axes
4947                    && let Some(binding_set_id) = plan.binding_set_id
4948                {
4949                    // Value-ref memoization keys are placement-relative. When a
4950                    // structural op moves the formula origin while keeping some
4951                    // precedents fixed (e.g. insert a column before a formula
4952                    // family that reads column A), those keys no longer name
4953                    // the same producer cells. Keep correctness by forcing
4954                    // placement offsets into the key so memoization falls back
4955                    // to per-placement work rather than broadcasting stale
4956                    // representative values.
4957                    authority.plane.force_binding_residual_axes(binding_set_id);
4958                }
4959            }
4960            authority.rebuild_indexes();
4961            self.formula_plane_indexes_epoch_seen = 0;
4962        }
4963
4964        let mut span_plans = Vec::new();
4965        for span_ref in demote_refs {
4966            let authority = self.graph.formula_authority();
4967            let Some(span) = authority.plane.spans.get(span_ref) else {
4968                continue;
4969            };
4970            let Some(template) = authority.plane.templates.get(span.template_id) else {
4971                return Err(ExcelError::new(ExcelErrorKind::Ref)
4972                    .with_message("FormulaPlane demotion found a span with a missing template")
4973                    .into());
4974            };
4975            let ast = self
4976                .graph
4977                .data_store()
4978                .retrieve_ast(template.ast_id, self.graph.sheet_reg())
4979                .ok_or_else(|| {
4980                    ExcelError::new(ExcelErrorKind::Ref)
4981                        .with_message("FormulaPlane demotion could not retrieve the template AST")
4982                })?;
4983            let placements = span
4984                .domain
4985                .iter()
4986                .map(|placement| (placement.row + 1, placement.col + 1))
4987                .collect();
4988            span_plans.push(SpanPlan {
4989                span_ref,
4990                sheet_id: span.sheet_id,
4991                ast,
4992                origin_row: template.origin_row,
4993                origin_col: template.origin_col,
4994                binding_set_id: span.binding_set_id,
4995                placements,
4996            });
4997        }
4998        if span_plans.is_empty() {
4999            return Ok(());
5000        }
5001
5002        let mut relocated = Vec::new();
5003        let mut placement_cells = Vec::new();
5004        for plan in &span_plans {
5005            for &(row, col) in &plan.placements {
5006                let row_delta = i64::from(row) - i64::from(plan.origin_row);
5007                let col_delta = i64::from(col) - i64::from(plan.origin_col);
5008                let bound_ast = if let Some(binding_set_id) = plan.binding_set_id {
5009                    let authority = self.graph.formula_authority();
5010                    if let Some(binding_set) = authority.plane.binding_sets.get(binding_set_id) {
5011                        if binding_set.is_single_literal_binding() {
5012                            plan.ast.clone()
5013                        } else {
5014                            let placement = crate::formula_plane::runtime::PlacementCoord::new(
5015                                plan.sheet_id,
5016                                row.saturating_sub(1),
5017                                col.saturating_sub(1),
5018                            );
5019                            let binding =
5020                                authority.plane.spans.get(plan.span_ref).and_then(|span| {
5021                                    binding_set
5022                                        .literal_bindings_for_placement(&span.domain, placement)
5023                                });
5024                            if let Some(binding) = binding {
5025                                substitute_literal_slots_for_template_placement(
5026                                    &plan.ast,
5027                                    binding.as_ref(),
5028                                )
5029                            } else {
5030                                plan.ast.clone()
5031                            }
5032                        }
5033                    } else {
5034                        plan.ast.clone()
5035                    }
5036                } else {
5037                    plan.ast.clone()
5038                };
5039                let ast = relocate_ast_for_template_placement(&bound_ast, row_delta, col_delta)?;
5040                relocated.push((plan.sheet_id, row, col, ast));
5041                placement_cells.push((plan.sheet_id, row, col));
5042            }
5043        }
5044        let planned_by_sheet = {
5045            let mut pipeline = self.ingest_pipeline();
5046            let mut planned_by_sheet: BTreeMap<
5047                SheetId,
5048                Vec<(u32, u32, AstNodeId, DependencyPlanRow)>,
5049            > = BTreeMap::new();
5050            for (formula_sheet_id, row, col, ast) in relocated {
5051                let placement =
5052                    CellRef::new(formula_sheet_id, Coord::from_excel(row, col, true, true));
5053                let ingested =
5054                    pipeline.ingest_formula(FormulaAstInput::Tree(ast), placement, None)?;
5055                planned_by_sheet.entry(formula_sheet_id).or_default().push((
5056                    row,
5057                    col,
5058                    ingested.ast_id,
5059                    ingested.dep_plan,
5060                ));
5061            }
5062            planned_by_sheet
5063        };
5064        {
5065            let authority = self.graph.formula_authority_mut();
5066            for plan in &span_plans {
5067                authority
5068                    .plane
5069                    .remove_overlays_for_source_span(plan.span_ref);
5070                authority.plane.remove_span(plan.span_ref);
5071            }
5072            authority.rebuild_indexes();
5073        }
5074        if clear_computed_overlays {
5075            // Only clear placement cells whose coordinate intersects the affected
5076            // structural region. The structural-op contract preserves cells
5077            // BEFORE the structural boundary; the legacy `clear_computed_overlay_after_*`
5078            // call honors that. Demoting a span whose footprint straddles the
5079            // boundary still must not clear cells before the boundary, even
5080            // though the span as a whole is demoted.
5081            self.clear_computed_overlay_cells_in_region(&placement_cells, &affected_region);
5082        }
5083        for (formula_sheet_id, planned) in planned_by_sheet {
5084            let sheet_name = self.graph.sheet_name(formula_sheet_id).to_string();
5085            self.graph
5086                .bulk_set_formulas_with_plans(&sheet_name, planned)?;
5087        }
5088        if !clear_computed_overlays {
5089            for (formula_sheet_id, row, col) in &placement_cells {
5090                let row0 = row.saturating_sub(1);
5091                let col0 = col.saturating_sub(1);
5092                if dirty_span_coords.contains(&(*formula_sheet_id, row0, col0)) {
5093                    continue;
5094                }
5095                let cell =
5096                    CellRef::new(*formula_sheet_id, Coord::from_excel(*row, *col, true, true));
5097                if let Some(&vertex_id) = self.graph.get_vertex_id_for_address(&cell) {
5098                    self.graph.set_dirty(vertex_id, false);
5099                }
5100            }
5101        }
5102        self.formula_plane_indexes_epoch_seen = 0;
5103        Ok(())
5104    }
5105
5106    /// Collect the [`FormulaSpanRef`]s for span producers the mixed scheduler
5107    /// reported as cycle members (gotcha G8, refs #112). These spans must be
5108    /// demoted to legacy so the cycle members are resolved on the legacy SCC
5109    /// path; see [`Self::demote_cyclic_spans`].
5110    fn collect_cyclic_span_refs(
5111        &self,
5112        schedule: &MixedSchedule,
5113        span_refs_by_id: &BTreeMap<FormulaSpanId, FormulaSpanRef>,
5114    ) -> Vec<FormulaSpanRef> {
5115        let mut refs = Vec::new();
5116        for fallback in &schedule.fallbacks {
5117            if fallback.reason != MixedScheduleFallbackReason::CycleDetected {
5118                continue;
5119            }
5120            if let FormulaProducerId::Span(span_id) = fallback.producer
5121                && let Some(span_ref) = span_refs_by_id.get(&span_id)
5122                && !refs.contains(span_ref)
5123            {
5124                refs.push(*span_ref);
5125            }
5126        }
5127        refs
5128    }
5129
5130    /// Demote the given cyclic spans to legacy graph vertices so their member
5131    /// cells participate in the legacy Tarjan SCC pass (gotcha G8, refs #112).
5132    ///
5133    /// Reuses the non-structural demotion seam, which materializes each span's
5134    /// cells back onto the legacy graph and re-promotes any acyclic remainder
5135    /// that still forms a promotable run. We pass a `Region` that covers exactly
5136    /// the demote-target span domains so disjoint spans are left untouched.
5137    fn demote_cyclic_spans(&mut self, span_refs: &[FormulaSpanRef]) -> Result<(), ExcelError> {
5138        let mut regions: Vec<Region> = Vec::new();
5139        {
5140            let authority = self.graph.formula_authority();
5141            for span_ref in span_refs {
5142                if let Some(span) = authority.plane.spans.get(*span_ref) {
5143                    regions.push(Region::from_domain(&span.domain));
5144                }
5145            }
5146        }
5147        for region in regions {
5148            self.demote_spans_preserving_computed_overlays(region.sheet_id(), region)
5149                .map_err(|err| {
5150                    ExcelError::new(ExcelErrorKind::NImpl).with_message(format!(
5151                        "FormulaPlane cycle-member span demotion failed: {err:?}"
5152                    ))
5153                })?;
5154        }
5155        self.formula_plane_cycle_member_span_demotions = self
5156            .formula_plane_cycle_member_span_demotions
5157            .saturating_add(span_refs.len() as u64);
5158        // Mirror the demotion into the cumulative ingest report's fallback
5159        // histogram so cycle exclusions are visible like every other placement
5160        // fallback reason.
5161        Self::record_shadow_fallback_reason(
5162            &mut self.formula_ingest_report_total,
5163            PlacementFallbackReason::CycleMember,
5164            span_refs.len() as u64,
5165        );
5166        Ok(())
5167    }
5168
5169    /// Evaluate residual *legacy-only* cyclic SCCs before the FormulaPlane
5170    /// mixed schedule runs (gotcha G8, refs #112).
5171    ///
5172    /// After cyclic spans are demoted to legacy ([`Self::demote_cyclic_spans`]),
5173    /// every cycle member is a graph vertex, so the cycle is now visible to the
5174    /// legacy Tarjan pass and lives entirely among legacy producers. The mixed
5175    /// schedule treats any cycle as not authoritative-safe; rather than abandon
5176    /// the surviving spans by falling through to a pure-legacy `evaluate_all`,
5177    /// stamp/evaluate just the cyclic SCC units here (`handle_cycle_unit` honors
5178    /// `CycleDetection::Static` vs `Runtime`), clear their dirty flags, and let
5179    /// the mixed schedule proceed cycle-free over the surviving spans plus the
5180    /// acyclic legacy work.
5181    ///
5182    /// Returns the number of cyclic SCC units that stamped at least one cell.
5183    fn evaluate_legacy_cycle_prepass(&mut self) -> Result<usize, ExcelError> {
5184        let dirty = self.graph.get_evaluation_vertices();
5185        if dirty.is_empty() {
5186            return Ok(0);
5187        }
5188        let (schedule, _vdeps, _meta) = self.create_evaluation_schedule(&dirty)?;
5189        let dirty_set: FxHashSet<VertexId> = dirty.iter().copied().collect();
5190        let mut cycle_errors = 0usize;
5191        let mut stamped_vertices: Vec<VertexId> = Vec::new();
5192        for &unit in &schedule.units {
5193            let ScheduleUnit::Cycle(i) = unit else {
5194                continue;
5195            };
5196            let members = schedule.unit_cycle(i);
5197            let stamped = self.handle_cycle_unit(members, None, Some(&dirty_set), None)?;
5198            if stamped > 0 {
5199                cycle_errors += 1;
5200            }
5201            stamped_vertices.extend(members.iter().copied());
5202        }
5203        // Clear dirty only on the cyclic members so the subsequent mixed
5204        // schedule no longer sees them as dirty legacy producers (which is what
5205        // surfaced the cycle). Acyclic legacy work stays dirty and is scheduled
5206        // normally alongside the surviving spans.
5207        if !stamped_vertices.is_empty() {
5208            self.graph.clear_dirty_flags(&stamped_vertices);
5209        }
5210        Ok(cycle_errors)
5211    }
5212
5213    /// Insert rows (1-based) and mirror into Arrow store when enabled
5214    pub fn insert_rows(
5215        &mut self,
5216        sheet: &str,
5217        before: u32,
5218        count: u32,
5219    ) -> Result<crate::engine::graph::editor::vertex_editor::ShiftSummary, crate::engine::EditorError>
5220    {
5221        use crate::engine::graph::editor::vertex_editor::VertexEditor;
5222        let sheet_id = self.ensure_known_sheet_id(sheet)?;
5223        let before0 = before.saturating_sub(1);
5224        let affected_region = Self::structural_row_region(sheet_id, before0);
5225        let op = StructuralOp::InsertRows {
5226            sheet_id,
5227            before: before0,
5228            count,
5229        };
5230        self.demote_spans_for_structural_op(op, affected_region)?;
5231        let summary = {
5232            let mut editor = VertexEditor::new(&mut self.graph);
5233            editor.insert_rows(sheet_id, before0, count)?
5234        };
5235        if let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) {
5236            let before0 = before0 as usize;
5237            asheet.insert_rows(before0, count as usize);
5238        }
5239        self.mark_moved_formula_vertices_dirty(&summary);
5240        self.clear_computed_overlay_after_row(sheet, before0 as usize);
5241        self.shift_row_visibility_insert(sheet_id, before0, count);
5242        self.record_formula_plane_structural_change(StructuralScope::Region(affected_region));
5243        self.mark_topology_edited();
5244        Ok(summary)
5245    }
5246
5247    /// Delete rows (1-based) and mirror into Arrow store when enabled
5248    pub fn delete_rows(
5249        &mut self,
5250        sheet: &str,
5251        start: u32,
5252        count: u32,
5253    ) -> Result<crate::engine::graph::editor::vertex_editor::ShiftSummary, crate::engine::EditorError>
5254    {
5255        use crate::engine::graph::editor::vertex_editor::VertexEditor;
5256        let sheet_id = self.ensure_known_sheet_id(sheet)?;
5257        let start0 = start.saturating_sub(1);
5258        let affected_region = Self::structural_row_region(sheet_id, start0);
5259        let op = StructuralOp::DeleteRows {
5260            sheet_id,
5261            start: start0,
5262            count,
5263        };
5264        self.demote_spans_for_structural_op(op, affected_region)?;
5265        let summary = {
5266            let mut editor = VertexEditor::new(&mut self.graph);
5267            editor.delete_rows(sheet_id, start0, count)?
5268        };
5269        if let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) {
5270            let start0 = start0 as usize;
5271            asheet.delete_rows(start0, count as usize);
5272        }
5273        self.mark_moved_formula_vertices_dirty(&summary);
5274        self.clear_computed_overlay_after_row(sheet, start0 as usize);
5275        self.shift_row_visibility_delete(sheet_id, start0, count);
5276        self.record_formula_plane_structural_change(StructuralScope::Region(affected_region));
5277        self.mark_topology_edited();
5278        Ok(summary)
5279    }
5280
5281    /// Insert columns (1-based) and mirror into Arrow store when enabled
5282    pub fn insert_columns(
5283        &mut self,
5284        sheet: &str,
5285        before: u32,
5286        count: u32,
5287    ) -> Result<crate::engine::graph::editor::vertex_editor::ShiftSummary, crate::engine::EditorError>
5288    {
5289        use crate::engine::graph::editor::vertex_editor::VertexEditor;
5290        let sheet_id = self.graph.sheet_id(sheet).ok_or(
5291            crate::engine::graph::editor::vertex_editor::EditorError::InvalidName {
5292                name: sheet.to_string(),
5293                reason: "Unknown sheet".to_string(),
5294            },
5295        )?;
5296        let before0 = before.saturating_sub(1);
5297        let affected_region = Self::structural_col_region(sheet_id, before0);
5298        let op = StructuralOp::InsertColumns {
5299            sheet_id,
5300            before: before0,
5301            count,
5302        };
5303        self.demote_spans_for_structural_op(op, affected_region)?;
5304        let summary = {
5305            let mut editor = VertexEditor::new(&mut self.graph);
5306            editor.insert_columns(sheet_id, before0, count)?
5307        };
5308        if let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) {
5309            let before0 = before0 as usize;
5310            asheet.insert_columns(before0, count as usize);
5311        }
5312        self.mark_moved_formula_vertices_dirty(&summary);
5313        self.clear_computed_overlay_after_col(sheet, before0 as usize);
5314        self.record_formula_plane_structural_change(StructuralScope::Region(affected_region));
5315        self.mark_topology_edited();
5316        Ok(summary)
5317    }
5318
5319    /// Delete columns (1-based) and mirror into Arrow store when enabled
5320    pub fn delete_columns(
5321        &mut self,
5322        sheet: &str,
5323        start: u32,
5324        count: u32,
5325    ) -> Result<crate::engine::graph::editor::vertex_editor::ShiftSummary, crate::engine::EditorError>
5326    {
5327        use crate::engine::graph::editor::vertex_editor::VertexEditor;
5328        let sheet_id = self.graph.sheet_id(sheet).ok_or(
5329            crate::engine::graph::editor::vertex_editor::EditorError::InvalidName {
5330                name: sheet.to_string(),
5331                reason: "Unknown sheet".to_string(),
5332            },
5333        )?;
5334        let start0 = start.saturating_sub(1);
5335        let affected_region = Self::structural_col_region(sheet_id, start0);
5336        let op = StructuralOp::DeleteColumns {
5337            sheet_id,
5338            start: start0,
5339            count,
5340        };
5341        self.demote_spans_for_structural_op(op, affected_region)?;
5342        let summary = {
5343            let mut editor = VertexEditor::new(&mut self.graph);
5344            editor.delete_columns(sheet_id, start0, count)?
5345        };
5346        if let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) {
5347            let start0 = start0 as usize;
5348            asheet.delete_columns(start0, count as usize);
5349        }
5350        self.mark_moved_formula_vertices_dirty(&summary);
5351        self.clear_computed_overlay_after_col(sheet, start0 as usize);
5352        self.record_formula_plane_structural_change(StructuralScope::Region(affected_region));
5353        self.mark_topology_edited();
5354        Ok(summary)
5355    }
5356    /// Arrow-backed used row bounds across a column span (1-based inclusive cols).
5357    fn arrow_used_row_bounds(
5358        &self,
5359        sheet: &str,
5360        start_col: u32,
5361        end_col: u32,
5362    ) -> Option<(u32, u32)> {
5363        let a = self.sheet_store().sheet(sheet)?;
5364        if a.columns.is_empty() {
5365            return None;
5366        }
5367        let sc0 = start_col.saturating_sub(1) as usize;
5368        let ec0 = end_col.saturating_sub(1) as usize;
5369        let col_hi = a.columns.len().saturating_sub(1);
5370        if sc0 > col_hi {
5371            return None;
5372        }
5373        let ec0 = ec0.min(col_hi);
5374        // Pass-scoped cache with snapshot guard
5375        let snap = self.data_snapshot_id();
5376        let mut min_r0: Option<usize> = None;
5377        for ci in sc0..=ec0 {
5378            let sheet_id = self.graph.sheet_id(sheet)?;
5379            if let Some((Some(mv), _)) = self.row_bounds_cache.read().ok().and_then(|g| {
5380                g.as_ref()
5381                    .and_then(|c| c.get_row_bounds(sheet_id, ci, snap))
5382            }) {
5383                let mv = mv as usize;
5384                min_r0 = Some(min_r0.map(|m| m.min(mv)).unwrap_or(mv));
5385                continue;
5386            }
5387            // Compute and store
5388            let (min_c, max_c) = Self::scan_column_used_bounds(a, ci);
5389            if let Ok(mut g) = self.row_bounds_cache.write() {
5390                g.get_or_insert_with(|| RowBoundsCache::new(snap))
5391                    .put_row_bounds(sheet_id, ci, snap, (min_c, max_c));
5392            }
5393            if let Some(m) = min_c {
5394                min_r0 = Some(min_r0.map(|mm| mm.min(m as usize)).unwrap_or(m as usize));
5395            }
5396        }
5397        min_r0?;
5398        let mut max_r0: Option<usize> = None;
5399        for ci in sc0..=ec0 {
5400            let sheet_id = self.graph.sheet_id(sheet)?;
5401            if let Some((_, Some(mv))) = self.row_bounds_cache.read().ok().and_then(|g| {
5402                g.as_ref()
5403                    .and_then(|c| c.get_row_bounds(sheet_id, ci, snap))
5404            }) {
5405                let mv = mv as usize;
5406                max_r0 = Some(max_r0.map(|m| m.max(mv)).unwrap_or(mv));
5407                continue;
5408            }
5409            let (_min_c, max_c) = Self::scan_column_used_bounds(a, ci);
5410            if let Ok(mut g) = self.row_bounds_cache.write() {
5411                g.get_or_insert_with(|| RowBoundsCache::new(snap))
5412                    .put_row_bounds(sheet_id, ci, snap, (_min_c, max_c));
5413            }
5414            if let Some(m) = max_c {
5415                max_r0 = Some(max_r0.map(|mm| mm.max(m as usize)).unwrap_or(m as usize));
5416            }
5417        }
5418        match (min_r0, max_r0) {
5419            (Some(a0), Some(b0)) => Some(((a0 as u32) + 1, (b0 as u32) + 1)),
5420            _ => None,
5421        }
5422    }
5423
5424    fn scan_column_used_bounds(
5425        a: &crate::arrow_store::ArrowSheet,
5426        ci: usize,
5427    ) -> (Option<u32>, Option<u32>) {
5428        let col = &a.columns[ci];
5429
5430        // Min: scan dense chunks first, then sparse chunks in ascending index order.
5431        let mut min_r0: Option<u32> = None;
5432        for (chunk_idx, chunk) in col.chunks.iter().enumerate() {
5433            let tags = chunk.type_tag.values();
5434            for (off, &t) in tags.iter().enumerate() {
5435                let overlay_non_empty = chunk
5436                    .overlay
5437                    .get(off)
5438                    .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5439                    .unwrap_or(false)
5440                    || chunk
5441                        .computed_overlay
5442                        .get(off)
5443                        .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5444                        .unwrap_or(false);
5445                if overlay_non_empty || t != crate::arrow_store::TypeTag::Empty as u8 {
5446                    let Some(&chunk_start) = a.chunk_starts.get(chunk_idx) else {
5447                        break;
5448                    };
5449                    let row0 = chunk_start + off;
5450                    min_r0 = Some(row0 as u32);
5451                    break;
5452                }
5453            }
5454            if min_r0.is_some() {
5455                break;
5456            }
5457        }
5458        if min_r0.is_none() && !col.sparse_chunks.is_empty() {
5459            let mut sparse_idxs: Vec<usize> = col.sparse_chunks.keys().copied().collect();
5460            sparse_idxs.sort_unstable();
5461            for chunk_idx in sparse_idxs {
5462                let Some(chunk) = col.sparse_chunks.get(&chunk_idx) else {
5463                    continue;
5464                };
5465                let Some(&chunk_start) = a.chunk_starts.get(chunk_idx) else {
5466                    continue;
5467                };
5468                let tags = chunk.type_tag.values();
5469                for (off, &t) in tags.iter().enumerate() {
5470                    let overlay_non_empty = chunk
5471                        .overlay
5472                        .get(off)
5473                        .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5474                        .unwrap_or(false)
5475                        || chunk
5476                            .computed_overlay
5477                            .get(off)
5478                            .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5479                            .unwrap_or(false);
5480                    if overlay_non_empty || t != crate::arrow_store::TypeTag::Empty as u8 {
5481                        let row0 = chunk_start + off;
5482                        min_r0 = Some(row0 as u32);
5483                        break;
5484                    }
5485                }
5486                if min_r0.is_some() {
5487                    break;
5488                }
5489            }
5490        }
5491
5492        // Max: scan sparse chunks in descending index order, then dense chunks in reverse.
5493        let mut max_r0: Option<u32> = None;
5494        if !col.sparse_chunks.is_empty() {
5495            let mut sparse_idxs: Vec<usize> = col.sparse_chunks.keys().copied().collect();
5496            sparse_idxs.sort_unstable_by(|a, b| b.cmp(a));
5497            for chunk_idx in sparse_idxs {
5498                let Some(chunk) = col.sparse_chunks.get(&chunk_idx) else {
5499                    continue;
5500                };
5501                let Some(&chunk_start) = a.chunk_starts.get(chunk_idx) else {
5502                    continue;
5503                };
5504                let tags = chunk.type_tag.values();
5505                for (rev_idx, &t) in tags.iter().enumerate().rev() {
5506                    let overlay_non_empty = chunk
5507                        .overlay
5508                        .get(rev_idx)
5509                        .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5510                        .unwrap_or(false)
5511                        || chunk
5512                            .computed_overlay
5513                            .get(rev_idx)
5514                            .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5515                            .unwrap_or(false);
5516                    if overlay_non_empty || t != crate::arrow_store::TypeTag::Empty as u8 {
5517                        let row0 = chunk_start + rev_idx;
5518                        max_r0 = Some(row0 as u32);
5519                        break;
5520                    }
5521                }
5522                if max_r0.is_some() {
5523                    break;
5524                }
5525            }
5526        }
5527        if max_r0.is_none() {
5528            for (chunk_idx, chunk) in col.chunks.iter().enumerate().rev() {
5529                let tags = chunk.type_tag.values();
5530                for (rev_idx, &t) in tags.iter().enumerate().rev() {
5531                    let overlay_non_empty = chunk
5532                        .overlay
5533                        .get(rev_idx)
5534                        .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5535                        .unwrap_or(false)
5536                        || chunk
5537                            .computed_overlay
5538                            .get(rev_idx)
5539                            .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5540                            .unwrap_or(false);
5541                    if overlay_non_empty || t != crate::arrow_store::TypeTag::Empty as u8 {
5542                        let Some(&chunk_start) = a.chunk_starts.get(chunk_idx) else {
5543                            break;
5544                        };
5545                        let row0 = chunk_start + rev_idx;
5546                        max_r0 = Some(row0 as u32);
5547                        break;
5548                    }
5549                }
5550                if max_r0.is_some() {
5551                    break;
5552                }
5553            }
5554        }
5555
5556        (min_r0, max_r0)
5557    }
5558
5559    /// Arrow-backed used column bounds across a row span (1-based inclusive rows).
5560    fn arrow_used_col_bounds(
5561        &self,
5562        sheet: &str,
5563        start_row: u32,
5564        end_row: u32,
5565    ) -> Option<(u32, u32)> {
5566        let a = self.sheet_store().sheet(sheet)?;
5567        if a.columns.is_empty() {
5568            return None;
5569        }
5570        let sr0 = start_row.saturating_sub(1) as usize;
5571        let er0 = end_row.saturating_sub(1) as usize;
5572        if sr0 > er0 {
5573            return None;
5574        }
5575        // Map start/end rows into chunk ranges
5576        // We will scan each column for any non-empty within [sr0..=er0]
5577        let mut min_c0: Option<usize> = None;
5578        let mut max_c0: Option<usize> = None;
5579        // Precompute chunk bounds for row range
5580        for (ci, col) in a.columns.iter().enumerate() {
5581            let mut any_in_range = false;
5582
5583            let scan_chunk = |chunk_idx: usize, chunk: &crate::arrow_store::ColumnChunk| -> bool {
5584                let Some(&chunk_start) = a.chunk_starts.get(chunk_idx) else {
5585                    return false;
5586                };
5587                let chunk_len = chunk.type_tag.len();
5588                if chunk_len == 0 {
5589                    return false;
5590                }
5591                let chunk_end = chunk_start + chunk_len.saturating_sub(1);
5592                // check intersection
5593                if sr0 > chunk_end || er0 < chunk_start {
5594                    return false;
5595                }
5596                let start_off = sr0.max(chunk_start) - chunk_start;
5597                let end_off = er0.min(chunk_end) - chunk_start;
5598                let tags = chunk.type_tag.values();
5599                for off in start_off..=end_off {
5600                    let overlay_non_empty = chunk
5601                        .overlay
5602                        .get(off)
5603                        .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5604                        .unwrap_or(false)
5605                        || chunk
5606                            .computed_overlay
5607                            .get(off)
5608                            .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5609                            .unwrap_or(false);
5610                    if overlay_non_empty || tags[off] != crate::arrow_store::TypeTag::Empty as u8 {
5611                        return true;
5612                    }
5613                }
5614                false
5615            };
5616
5617            for (chunk_idx, chunk) in col.chunks.iter().enumerate() {
5618                if scan_chunk(chunk_idx, chunk) {
5619                    any_in_range = true;
5620                    break;
5621                }
5622            }
5623
5624            if !any_in_range && !col.sparse_chunks.is_empty() {
5625                for (&chunk_idx, chunk) in col.sparse_chunks.iter() {
5626                    if scan_chunk(chunk_idx, chunk) {
5627                        any_in_range = true;
5628                        break;
5629                    }
5630                }
5631            }
5632
5633            if any_in_range {
5634                min_c0 = Some(min_c0.map(|m| m.min(ci)).unwrap_or(ci));
5635                max_c0 = Some(max_c0.map(|m| m.max(ci)).unwrap_or(ci));
5636            }
5637        }
5638        match (min_c0, max_c0) {
5639            (Some(a0), Some(b0)) => Some(((a0 as u32) + 1, (b0 as u32) + 1)),
5640            _ => None,
5641        }
5642    }
5643
5644    fn formula_row_bounds_for_columns(
5645        &self,
5646        sheet: &str,
5647        start_col: u32,
5648        end_col: u32,
5649    ) -> Option<(u32, u32)> {
5650        let sheet_id = self.graph.sheet_id(sheet)?;
5651        let sc0 = start_col.saturating_sub(1);
5652        let ec0 = end_col.saturating_sub(1);
5653        let mut min_r0: Option<u32> = None;
5654        let mut max_r0: Option<u32> = None;
5655
5656        if let Some(index) = self.graph.sheet_index(sheet_id) {
5657            for vid in index.vertices_in_col_range(sc0, ec0) {
5658                if !matches!(
5659                    self.graph.get_vertex_kind(vid),
5660                    VertexKind::FormulaScalar | VertexKind::FormulaArray
5661                ) {
5662                    continue;
5663                }
5664                let row0 = self.graph.vertex_coord(vid).row();
5665                min_r0 = Some(min_r0.map(|m| m.min(row0)).unwrap_or(row0));
5666                max_r0 = Some(max_r0.map(|m| m.max(row0)).unwrap_or(row0));
5667            }
5668        } else {
5669            for vid in self.graph.vertices_in_sheet(sheet_id) {
5670                if !matches!(
5671                    self.graph.get_vertex_kind(vid),
5672                    VertexKind::FormulaScalar | VertexKind::FormulaArray
5673                ) {
5674                    continue;
5675                }
5676                let coord = self.graph.vertex_coord(vid);
5677                let col0 = coord.col();
5678                if col0 < sc0 || col0 > ec0 {
5679                    continue;
5680                }
5681                let row0 = coord.row();
5682                min_r0 = Some(min_r0.map(|m| m.min(row0)).unwrap_or(row0));
5683                max_r0 = Some(max_r0.map(|m| m.max(row0)).unwrap_or(row0));
5684            }
5685        }
5686
5687        match (min_r0, max_r0) {
5688            (Some(a0), Some(b0)) => Some((a0 + 1, b0 + 1)),
5689            _ => None,
5690        }
5691    }
5692
5693    fn formula_col_bounds_for_rows(
5694        &self,
5695        sheet: &str,
5696        start_row: u32,
5697        end_row: u32,
5698    ) -> Option<(u32, u32)> {
5699        let sheet_id = self.graph.sheet_id(sheet)?;
5700        let sr0 = start_row.saturating_sub(1);
5701        let er0 = end_row.saturating_sub(1);
5702        let mut min_c0: Option<u32> = None;
5703        let mut max_c0: Option<u32> = None;
5704
5705        if let Some(index) = self.graph.sheet_index(sheet_id) {
5706            for vid in index.vertices_in_row_range(sr0, er0) {
5707                if !matches!(
5708                    self.graph.get_vertex_kind(vid),
5709                    VertexKind::FormulaScalar | VertexKind::FormulaArray
5710                ) {
5711                    continue;
5712                }
5713                let col0 = self.graph.vertex_coord(vid).col();
5714                min_c0 = Some(min_c0.map(|m| m.min(col0)).unwrap_or(col0));
5715                max_c0 = Some(max_c0.map(|m| m.max(col0)).unwrap_or(col0));
5716            }
5717        } else {
5718            for vid in self.graph.vertices_in_sheet(sheet_id) {
5719                if !matches!(
5720                    self.graph.get_vertex_kind(vid),
5721                    VertexKind::FormulaScalar | VertexKind::FormulaArray
5722                ) {
5723                    continue;
5724                }
5725                let coord = self.graph.vertex_coord(vid);
5726                let row0 = coord.row();
5727                if row0 < sr0 || row0 > er0 {
5728                    continue;
5729                }
5730                let col0 = coord.col();
5731                min_c0 = Some(min_c0.map(|m| m.min(col0)).unwrap_or(col0));
5732                max_c0 = Some(max_c0.map(|m| m.max(col0)).unwrap_or(col0));
5733            }
5734        }
5735
5736        match (min_c0, max_c0) {
5737            (Some(a0), Some(b0)) => Some((a0 + 1, b0 + 1)),
5738            _ => None,
5739        }
5740    }
5741
5742    fn union_used_bounds(
5743        first: Option<(u32, u32)>,
5744        second: Option<(u32, u32)>,
5745    ) -> Option<(u32, u32)> {
5746        match (first, second) {
5747            (Some((a0, b0)), Some((a1, b1))) => Some((a0.min(a1), b0.max(b1))),
5748            (Some(bounds), None) | (None, Some(bounds)) => Some(bounds),
5749            (None, None) => None,
5750        }
5751    }
5752
5753    /// Mirror a single cell value into the Arrow overlay if enabled.
5754    /// Handles capacity growth, per-chunk overlay set, and heuristic compaction.
5755    fn mirror_value_to_overlay(&mut self, sheet: &str, row: u32, col: u32, value: &LiteralValue) {
5756        if !(self.config.arrow_storage_enabled && self.config.delta_overlay_enabled) {
5757            return;
5758        }
5759        if self.arrow_sheets.sheet(sheet).is_none() {
5760            self.arrow_sheets
5761                .sheets
5762                .push(crate::arrow_store::ArrowSheet {
5763                    name: std::sync::Arc::<str>::from(sheet),
5764                    columns: Vec::new(),
5765                    nrows: 0,
5766                    chunk_starts: Vec::new(),
5767                    chunk_rows: 32 * 1024,
5768                });
5769        }
5770
5771        let row0 = row.saturating_sub(1) as usize;
5772        let col0 = col.saturating_sub(1) as usize;
5773
5774        let asheet = self
5775            .arrow_sheets
5776            .sheet_mut(sheet)
5777            .expect("ArrowSheet must exist");
5778
5779        let cur_cols = asheet.columns.len();
5780        if col0 >= cur_cols {
5781            asheet.insert_columns(cur_cols, (col0 + 1) - cur_cols);
5782        }
5783
5784        if row0 >= asheet.nrows as usize {
5785            if asheet.columns.is_empty() {
5786                asheet.insert_columns(0, 1);
5787            }
5788            asheet.ensure_row_capacity(row0 + 1);
5789        }
5790        if let Some((ch_idx, in_off)) = asheet.chunk_of_row(row0) {
5791            use crate::arrow_store::OverlayValue;
5792            let ov = match value {
5793                LiteralValue::Empty => OverlayValue::Empty,
5794                LiteralValue::Int(i) => OverlayValue::Number(*i as f64),
5795                LiteralValue::Number(n) => OverlayValue::Number(*n),
5796                LiteralValue::Boolean(b) => OverlayValue::Boolean(*b),
5797                LiteralValue::Text(s) => OverlayValue::Text(std::sync::Arc::from(s.clone())),
5798                LiteralValue::Error(e) => {
5799                    OverlayValue::Error(crate::arrow_store::map_error_code(e.kind))
5800                }
5801                LiteralValue::Date(d) => {
5802                    let dt = d.and_hms_opt(0, 0, 0).unwrap();
5803                    let serial = crate::builtins::datetime::datetime_to_serial_for(
5804                        self.config.date_system,
5805                        &dt,
5806                    );
5807                    OverlayValue::DateTime(serial)
5808                }
5809                LiteralValue::DateTime(dt) => {
5810                    let serial = crate::builtins::datetime::datetime_to_serial_for(
5811                        self.config.date_system,
5812                        dt,
5813                    );
5814                    OverlayValue::DateTime(serial)
5815                }
5816                LiteralValue::Time(t) => {
5817                    let serial = t.num_seconds_from_midnight() as f64 / 86_400.0;
5818                    OverlayValue::DateTime(serial)
5819                }
5820                LiteralValue::Duration(d) => {
5821                    let serial = d.num_seconds() as f64 / 86_400.0;
5822                    OverlayValue::Duration(serial)
5823                }
5824                LiteralValue::Pending => OverlayValue::Pending,
5825                LiteralValue::Array(_) => OverlayValue::Error(crate::arrow_store::map_error_code(
5826                    formualizer_common::ExcelErrorKind::Value,
5827                )),
5828            };
5829            let computed_delta = if let Some(ch) = asheet.ensure_column_chunk_mut(col0, ch_idx) {
5830                let _ = ch.overlay.set(in_off, ov);
5831                // A user edit must invalidate any computed (formula/spill) overlay entry at
5832                // this cell. Otherwise, if the delta overlay later compacts into the base lanes
5833                // (clearing `overlay`), a stale `computed_overlay=Empty` could incorrectly mask
5834                // the edited base value under the read cascade.
5835                ch.computed_overlay.remove(in_off)
5836            } else {
5837                return;
5838            };
5839            // Heuristic compaction: > len/50 or > 1024
5840            let abs_threshold = 1024usize;
5841            let frac_den = 50usize;
5842            let freed = asheet.maybe_compact_chunk(col0, ch_idx, abs_threshold, frac_den);
5843            if freed > 0 {
5844                self.overlay_compactions = self.overlay_compactions.saturating_add(1);
5845            }
5846            self.adjust_computed_overlay_bytes(computed_delta);
5847        }
5848    }
5849
5850    /// Remove a delta-overlay entry for a single cell (if present).
5851    ///
5852    /// This is used when transitioning a cell to a formula so that any previous user-edit overlay
5853    /// does not continue to mask computed overlay outputs.
5854    fn clear_delta_overlay_cell(&mut self, sheet: &str, row: u32, col: u32) {
5855        if !(self.config.arrow_storage_enabled && self.config.delta_overlay_enabled) {
5856            return;
5857        }
5858        let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) else {
5859            return;
5860        };
5861        let row0 = row.saturating_sub(1) as usize;
5862        let col0 = col.saturating_sub(1) as usize;
5863        if row0 >= asheet.nrows as usize {
5864            return;
5865        }
5866        if col0 >= asheet.columns.len() {
5867            return;
5868        }
5869        let Some((ch_idx, in_off)) = asheet.chunk_of_row(row0) else {
5870            return;
5871        };
5872        if let Some(ch) = asheet.columns[col0].chunk_mut(ch_idx) {
5873            let _ = ch.overlay.remove(in_off);
5874        }
5875    }
5876
5877    fn clear_computed_overlay_col_row_range(
5878        &mut self,
5879        sheet: &str,
5880        col0: usize,
5881        start_row0: usize,
5882        end_row0_exclusive: usize,
5883    ) {
5884        if !(self.config.arrow_storage_enabled && self.config.write_formula_overlay_enabled) {
5885            return;
5886        }
5887        if start_row0 >= end_row0_exclusive {
5888            return;
5889        }
5890
5891        let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) else {
5892            return;
5893        };
5894        if col0 >= asheet.columns.len() || start_row0 >= asheet.nrows as usize {
5895            return;
5896        }
5897        let end_row0_exclusive = end_row0_exclusive.min(asheet.nrows as usize);
5898        if start_row0 >= end_row0_exclusive {
5899            return;
5900        }
5901
5902        let starts = asheet.chunk_starts.clone();
5903        let nrows = asheet.nrows as usize;
5904        let mut delta = 0isize;
5905        let Some(col) = asheet.columns.get_mut(col0) else {
5906            return;
5907        };
5908        for (chunk_idx, ch) in col.chunks.iter_mut().enumerate() {
5909            let Some(&chunk_start) = starts.get(chunk_idx) else {
5910                continue;
5911            };
5912            let chunk_end = starts
5913                .get(chunk_idx + 1)
5914                .copied()
5915                .unwrap_or(nrows)
5916                .min(chunk_start.saturating_add(ch.len()));
5917            let clear_start = start_row0.max(chunk_start);
5918            let clear_end = end_row0_exclusive.min(chunk_end);
5919            if clear_start >= clear_end {
5920                continue;
5921            }
5922            if clear_start == chunk_start && clear_end == chunk_end {
5923                delta = delta.saturating_sub(ch.computed_overlay.clear() as isize);
5924            } else {
5925                let start_in_chunk = clear_start.saturating_sub(chunk_start).min(ch.len());
5926                let end_in_chunk = clear_end.saturating_sub(chunk_start).min(ch.len());
5927                delta = delta.saturating_add(
5928                    ch.computed_overlay
5929                        .remove_range(start_in_chunk..end_in_chunk),
5930                );
5931            }
5932        }
5933        for (chunk_idx, ch) in &mut col.sparse_chunks {
5934            let Some(&chunk_start) = starts.get(*chunk_idx) else {
5935                continue;
5936            };
5937            let chunk_end = starts
5938                .get(*chunk_idx + 1)
5939                .copied()
5940                .unwrap_or(nrows)
5941                .min(chunk_start.saturating_add(ch.len()));
5942            let clear_start = start_row0.max(chunk_start);
5943            let clear_end = end_row0_exclusive.min(chunk_end);
5944            if clear_start >= clear_end {
5945                continue;
5946            }
5947            if clear_start == chunk_start && clear_end == chunk_end {
5948                delta = delta.saturating_sub(ch.computed_overlay.clear() as isize);
5949            } else {
5950                let start_in_chunk = clear_start.saturating_sub(chunk_start).min(ch.len());
5951                let end_in_chunk = clear_end.saturating_sub(chunk_start).min(ch.len());
5952                delta = delta.saturating_add(
5953                    ch.computed_overlay
5954                        .remove_range(start_in_chunk..end_in_chunk),
5955                );
5956            }
5957        }
5958        self.adjust_computed_overlay_bytes(delta);
5959    }
5960
5961    fn clear_computed_overlay_cells_in_region(
5962        &mut self,
5963        cells: &[(SheetId, u32, u32)],
5964        affected_region: &Region,
5965    ) {
5966        let mut by_col: BTreeMap<(SheetId, u32), Vec<u32>> = BTreeMap::new();
5967        for (formula_sheet_id, row, col) in cells {
5968            let row0 = row.saturating_sub(1);
5969            let col0 = col.saturating_sub(1);
5970            let placement_region = Region::point(*formula_sheet_id, row0, col0);
5971            if placement_region.intersects(affected_region) {
5972                by_col
5973                    .entry((*formula_sheet_id, col0))
5974                    .or_default()
5975                    .push(row0);
5976            }
5977        }
5978
5979        for ((formula_sheet_id, col0), mut rows) in by_col {
5980            rows.sort_unstable();
5981            rows.dedup();
5982            let sheet_name = self.graph.sheet_name(formula_sheet_id).to_string();
5983            let mut start = rows[0];
5984            let mut prev = rows[0];
5985            for row in rows.into_iter().skip(1) {
5986                if row == prev.saturating_add(1) {
5987                    prev = row;
5988                    continue;
5989                }
5990                self.clear_computed_overlay_col_row_range(
5991                    &sheet_name,
5992                    col0 as usize,
5993                    start as usize,
5994                    prev.saturating_add(1) as usize,
5995                );
5996                start = row;
5997                prev = row;
5998            }
5999            self.clear_computed_overlay_col_row_range(
6000                &sheet_name,
6001                col0 as usize,
6002                start as usize,
6003                prev.saturating_add(1) as usize,
6004            );
6005        }
6006    }
6007
6008    fn clear_computed_overlay_after_row(&mut self, sheet: &str, start_row0: usize) {
6009        if !(self.config.arrow_storage_enabled && self.config.write_formula_overlay_enabled) {
6010            return;
6011        }
6012
6013        let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) else {
6014            return;
6015        };
6016        if start_row0 >= asheet.nrows as usize {
6017            return;
6018        }
6019
6020        let starts = asheet.chunk_starts.clone();
6021        let nrows = asheet.nrows as usize;
6022        let mut delta = 0isize;
6023        for col in &mut asheet.columns {
6024            for (chunk_idx, ch) in col.chunks.iter_mut().enumerate() {
6025                let Some(&chunk_start) = starts.get(chunk_idx) else {
6026                    continue;
6027                };
6028                let chunk_end = starts
6029                    .get(chunk_idx + 1)
6030                    .copied()
6031                    .unwrap_or(nrows)
6032                    .min(chunk_start.saturating_add(ch.len()));
6033                if chunk_end <= start_row0 {
6034                    continue;
6035                }
6036                if chunk_start >= start_row0 {
6037                    delta = delta.saturating_sub(ch.computed_overlay.clear() as isize);
6038                } else {
6039                    let start_in_chunk = start_row0.saturating_sub(chunk_start).min(ch.len());
6040                    delta = delta
6041                        .saturating_add(ch.computed_overlay.remove_range(start_in_chunk..ch.len()));
6042                }
6043            }
6044
6045            for (chunk_idx, ch) in &mut col.sparse_chunks {
6046                let Some(&chunk_start) = starts.get(*chunk_idx) else {
6047                    continue;
6048                };
6049                let chunk_end = starts
6050                    .get(*chunk_idx + 1)
6051                    .copied()
6052                    .unwrap_or(nrows)
6053                    .min(chunk_start.saturating_add(ch.len()));
6054                if chunk_end <= start_row0 {
6055                    continue;
6056                }
6057                if chunk_start >= start_row0 {
6058                    delta = delta.saturating_sub(ch.computed_overlay.clear() as isize);
6059                } else {
6060                    let start_in_chunk = start_row0.saturating_sub(chunk_start).min(ch.len());
6061                    delta = delta
6062                        .saturating_add(ch.computed_overlay.remove_range(start_in_chunk..ch.len()));
6063                }
6064            }
6065        }
6066        self.adjust_computed_overlay_bytes(delta);
6067    }
6068
6069    fn clear_computed_overlay_after_col(&mut self, sheet: &str, start_col0: usize) {
6070        if !(self.config.arrow_storage_enabled && self.config.write_formula_overlay_enabled) {
6071            return;
6072        }
6073
6074        let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) else {
6075            return;
6076        };
6077        if start_col0 >= asheet.columns.len() {
6078            return;
6079        }
6080
6081        let mut delta = 0isize;
6082        for col in asheet.columns.iter_mut().skip(start_col0) {
6083            for ch in &mut col.chunks {
6084                delta = delta.saturating_sub(ch.computed_overlay.clear() as isize);
6085            }
6086            for ch in col.sparse_chunks.values_mut() {
6087                delta = delta.saturating_sub(ch.computed_overlay.clear() as isize);
6088            }
6089        }
6090        self.adjust_computed_overlay_bytes(delta);
6091    }
6092
6093    #[inline]
6094    fn literal_to_overlay_value(&self, value: &LiteralValue) -> crate::arrow_store::OverlayValue {
6095        use crate::arrow_store::OverlayValue;
6096        match value {
6097            LiteralValue::Empty => OverlayValue::Empty,
6098            LiteralValue::Int(i) => OverlayValue::Number(*i as f64),
6099            LiteralValue::Number(n) => OverlayValue::Number(*n),
6100            LiteralValue::Boolean(b) => OverlayValue::Boolean(*b),
6101            LiteralValue::Text(s) => OverlayValue::Text(std::sync::Arc::from(s.clone())),
6102            LiteralValue::Error(e) => {
6103                OverlayValue::Error(crate::arrow_store::map_error_code(e.kind))
6104            }
6105            LiteralValue::Date(d) => {
6106                let dt = d.and_hms_opt(0, 0, 0).unwrap();
6107                let serial =
6108                    crate::builtins::datetime::datetime_to_serial_for(self.config.date_system, &dt);
6109                OverlayValue::DateTime(serial)
6110            }
6111            LiteralValue::DateTime(dt) => {
6112                let serial =
6113                    crate::builtins::datetime::datetime_to_serial_for(self.config.date_system, dt);
6114                OverlayValue::DateTime(serial)
6115            }
6116            LiteralValue::Time(t) => {
6117                let serial = t.num_seconds_from_midnight() as f64 / 86_400.0;
6118                OverlayValue::DateTime(serial)
6119            }
6120            LiteralValue::Duration(d) => {
6121                let serial = d.num_seconds() as f64 / 86_400.0;
6122                OverlayValue::Duration(serial)
6123            }
6124            LiteralValue::Pending => OverlayValue::Pending,
6125            LiteralValue::Array(_) => OverlayValue::Error(crate::arrow_store::map_error_code(
6126                formualizer_common::ExcelErrorKind::Value,
6127            )),
6128        }
6129    }
6130
6131    /// Read a single cell's delta overlay entry (if present), preserving the distinction between
6132    /// absent and explicit `Empty`.
6133    fn read_delta_overlay_cell(&self, sheet: &str, row: u32, col: u32) -> Option<LiteralValue> {
6134        if !(self.config.arrow_storage_enabled && self.config.delta_overlay_enabled) {
6135            return None;
6136        }
6137        let asheet = self.arrow_sheets.sheet(sheet)?;
6138        let row0 = row.saturating_sub(1) as usize;
6139        let col0 = col.saturating_sub(1) as usize;
6140        if row0 >= asheet.nrows as usize || col0 >= asheet.columns.len() {
6141            return None;
6142        }
6143        let (ch_idx, in_off) = asheet.chunk_of_row(row0)?;
6144        let ch = asheet.columns[col0].chunk(ch_idx)?;
6145        ch.overlay.get_scalar(in_off).map(|ov| ov.to_literal())
6146    }
6147
6148    /// Read a single cell's computed overlay entry (if present), preserving the distinction
6149    /// between absent and explicit `Empty`.
6150    fn read_computed_overlay_cell(&self, sheet: &str, row: u32, col: u32) -> Option<LiteralValue> {
6151        if !(self.config.arrow_storage_enabled
6152            && self.config.delta_overlay_enabled
6153            && self.config.write_formula_overlay_enabled)
6154        {
6155            return None;
6156        }
6157        let asheet = self.arrow_sheets.sheet(sheet)?;
6158        let row0 = row.saturating_sub(1) as usize;
6159        let col0 = col.saturating_sub(1) as usize;
6160        if row0 >= asheet.nrows as usize || col0 >= asheet.columns.len() {
6161            return None;
6162        }
6163        let (ch_idx, in_off) = asheet.chunk_of_row(row0)?;
6164        let ch = asheet.columns[col0].chunk(ch_idx)?;
6165        ch.computed_overlay
6166            .get_scalar(in_off)
6167            .map(|ov| ov.to_literal())
6168    }
6169
6170    fn set_delta_overlay_cell_raw(
6171        &mut self,
6172        sheet: &str,
6173        row: u32,
6174        col: u32,
6175        value: Option<LiteralValue>,
6176    ) {
6177        if !(self.config.arrow_storage_enabled && self.config.delta_overlay_enabled) {
6178            return;
6179        }
6180
6181        self.ensure_arrow_sheet(sheet);
6182        let ov_opt = value.as_ref().map(|v| self.literal_to_overlay_value(v));
6183        let row0 = row.saturating_sub(1) as usize;
6184        let col0 = col.saturating_sub(1) as usize;
6185        let asheet = self
6186            .arrow_sheets
6187            .sheet_mut(sheet)
6188            .expect("ArrowSheet must exist");
6189
6190        let cur_cols = asheet.columns.len();
6191        if col0 >= cur_cols {
6192            asheet.insert_columns(cur_cols, (col0 + 1) - cur_cols);
6193        }
6194        if row0 >= asheet.nrows as usize {
6195            if asheet.columns.is_empty() {
6196                asheet.insert_columns(0, 1);
6197            }
6198            asheet.ensure_row_capacity(row0 + 1);
6199        }
6200
6201        let Some((ch_idx, in_off)) = asheet.chunk_of_row(row0) else {
6202            return;
6203        };
6204        let Some(ch) = asheet.ensure_column_chunk_mut(col0, ch_idx) else {
6205            return;
6206        };
6207
6208        if let Some(ov) = ov_opt {
6209            let _ = ch.overlay.set(in_off, ov);
6210        } else {
6211            let _ = ch.overlay.remove(in_off);
6212        }
6213    }
6214
6215    fn set_computed_overlay_cell_raw(
6216        &mut self,
6217        sheet: &str,
6218        row: u32,
6219        col: u32,
6220        value: Option<LiteralValue>,
6221    ) {
6222        if !(self.config.arrow_storage_enabled
6223            && self.config.delta_overlay_enabled
6224            && self.config.write_formula_overlay_enabled)
6225        {
6226            return;
6227        }
6228
6229        self.ensure_arrow_sheet(sheet);
6230        let ov_opt = value.as_ref().map(|v| self.literal_to_overlay_value(v));
6231        let row0 = row.saturating_sub(1) as usize;
6232        let col0 = col.saturating_sub(1) as usize;
6233        let asheet = self
6234            .arrow_sheets
6235            .sheet_mut(sheet)
6236            .expect("ArrowSheet must exist");
6237
6238        let cur_cols = asheet.columns.len();
6239        if col0 >= cur_cols {
6240            asheet.insert_columns(cur_cols, (col0 + 1) - cur_cols);
6241        }
6242        if row0 >= asheet.nrows as usize {
6243            if asheet.columns.is_empty() {
6244                asheet.insert_columns(0, 1);
6245            }
6246            asheet.ensure_row_capacity(row0 + 1);
6247        }
6248
6249        let Some((ch_idx, in_off)) = asheet.chunk_of_row(row0) else {
6250            return;
6251        };
6252        let Some(ch) = asheet.ensure_column_chunk_mut(col0, ch_idx) else {
6253            return;
6254        };
6255
6256        let delta = if let Some(ov) = ov_opt {
6257            ch.computed_overlay.set(in_off, ov)
6258        } else {
6259            ch.computed_overlay.remove(in_off)
6260        };
6261        self.adjust_computed_overlay_bytes(delta);
6262    }
6263
6264    fn apply_arrow_undo_batch(&mut self, batch: &crate::engine::ArrowUndoBatch, undo: bool) {
6265        use crate::engine::ArrowOp;
6266
6267        let iter: Box<dyn Iterator<Item = &ArrowOp>> = if undo {
6268            Box::new(batch.ops.iter().rev())
6269        } else {
6270            Box::new(batch.ops.iter())
6271        };
6272
6273        for op in iter {
6274            match op {
6275                ArrowOp::SetDeltaCell {
6276                    sheet_id,
6277                    row0,
6278                    col0,
6279                    old,
6280                    new,
6281                } => {
6282                    let sheet = self.graph.sheet_name(*sheet_id).to_string();
6283                    let v = if undo { old.clone() } else { new.clone() };
6284                    self.set_delta_overlay_cell_raw(&sheet, row0 + 1, col0 + 1, v);
6285                }
6286                ArrowOp::SetComputedCell {
6287                    sheet_id,
6288                    row0,
6289                    col0,
6290                    old,
6291                    new,
6292                } => {
6293                    let sheet = self.graph.sheet_name(*sheet_id).to_string();
6294                    let v = if undo { old.clone() } else { new.clone() };
6295                    self.set_computed_overlay_cell_raw(&sheet, row0 + 1, col0 + 1, v);
6296                }
6297                ArrowOp::RestoreComputedRect {
6298                    sheet_id,
6299                    sr0,
6300                    sc0,
6301                    er0,
6302                    ec0,
6303                    old,
6304                    new,
6305                } => {
6306                    let sheet = self.graph.sheet_name(*sheet_id).to_string();
6307                    let vals = if undo { old } else { new };
6308                    let height = (*er0).saturating_sub(*sr0) as usize + 1;
6309                    let width = (*ec0).saturating_sub(*sc0) as usize + 1;
6310                    for r in 0..height {
6311                        for c in 0..width {
6312                            let v = vals
6313                                .get(r)
6314                                .and_then(|row| row.get(c))
6315                                .cloned()
6316                                .unwrap_or(LiteralValue::Empty);
6317                            self.set_computed_overlay_cell_raw(
6318                                &sheet,
6319                                *sr0 + 1 + r as u32,
6320                                *sc0 + 1 + c as u32,
6321                                Some(v),
6322                            );
6323                        }
6324                    }
6325                }
6326                ArrowOp::InsertRows {
6327                    sheet_id,
6328                    before0,
6329                    count,
6330                } => {
6331                    let sheet = self.graph.sheet_name(*sheet_id).to_string();
6332                    self.ensure_arrow_sheet(&sheet);
6333                    if let Some(asheet) = self.arrow_sheets.sheet_mut(&sheet) {
6334                        if undo {
6335                            asheet.delete_rows(*before0 as usize, *count as usize);
6336                        } else {
6337                            asheet.insert_rows(*before0 as usize, *count as usize);
6338                        }
6339                    }
6340                }
6341                ArrowOp::InsertCols {
6342                    sheet_id,
6343                    before0,
6344                    count,
6345                } => {
6346                    let sheet = self.graph.sheet_name(*sheet_id).to_string();
6347                    self.ensure_arrow_sheet(&sheet);
6348                    if let Some(asheet) = self.arrow_sheets.sheet_mut(&sheet) {
6349                        if undo {
6350                            asheet.delete_columns(*before0 as usize, *count as usize);
6351                        } else {
6352                            asheet.insert_columns(*before0 as usize, *count as usize);
6353                        }
6354                    }
6355                }
6356            }
6357        }
6358    }
6359
6360    fn record_spill_ops_into_arrow_undo(
6361        &mut self,
6362        undo: &mut crate::engine::ArrowUndoBatch,
6363        events: &[crate::engine::ChangeEvent],
6364    ) {
6365        use crate::engine::ChangeEvent;
6366        use formualizer_common::LiteralValue;
6367
6368        #[allow(clippy::type_complexity)]
6369        let rect_from_snapshot =
6370            |snap: &crate::engine::graph::editor::change_log::SpillSnapshot|
6371             -> Option<(SheetId, u32, u32, u32, u32, Vec<Vec<LiteralValue>>)> {
6372                if snap.target_cells.is_empty() {
6373                    return None;
6374                }
6375                let sheet_id = snap.target_cells[0].sheet_id;
6376                let sr0 = snap.target_cells[0].coord.row();
6377                let sc0 = snap.target_cells[0].coord.col();
6378                if snap.values.is_empty() || snap.values[0].is_empty() {
6379                    return None;
6380                }
6381                let h = snap.values.len() as u32;
6382                let w = snap.values[0].len() as u32;
6383                let er0 = sr0.saturating_add(h.saturating_sub(1));
6384                let ec0 = sc0.saturating_add(w.saturating_sub(1));
6385                Some((sheet_id, sr0, sc0, er0, ec0, snap.values.clone()))
6386            };
6387
6388        for ev in events {
6389            match ev {
6390                ChangeEvent::SpillCommitted { old, new, .. } => {
6391                    if let Some((sid, sr0, sc0, er0, ec0, new_vals)) = rect_from_snapshot(new) {
6392                        let old_vals = if let Some(old_snap) = old {
6393                            rect_from_snapshot(old_snap)
6394                                .map(|(_, _, _, _, _, v)| v)
6395                                .unwrap_or_else(|| {
6396                                    vec![
6397                                        vec![LiteralValue::Empty; new_vals[0].len()];
6398                                        new_vals.len()
6399                                    ]
6400                                })
6401                        } else {
6402                            vec![vec![LiteralValue::Empty; new_vals[0].len()]; new_vals.len()]
6403                        };
6404                        undo.record_restore_computed_rect(
6405                            sid, sr0, sc0, er0, ec0, old_vals, new_vals,
6406                        );
6407                    }
6408                }
6409                ChangeEvent::SpillCleared { old, .. } => {
6410                    if let Some((sid, sr0, sc0, er0, ec0, old_vals)) = rect_from_snapshot(old) {
6411                        let new_vals =
6412                            vec![vec![LiteralValue::Empty; old_vals[0].len()]; old_vals.len()];
6413                        undo.record_restore_computed_rect(
6414                            sid, sr0, sc0, er0, ec0, old_vals, new_vals,
6415                        );
6416                    }
6417                }
6418                _ => {}
6419            }
6420        }
6421    }
6422
6423    /// Mirror a value into the computed overlay (formula/spill outputs).
6424    ///
6425    /// This path is subject to `EvalConfig.max_overlay_memory_bytes`.
6426    /// If the cap is exceeded, computed overlays are compacted into base lanes.
6427    fn mirror_value_to_computed_overlay(
6428        &mut self,
6429        sheet: &str,
6430        row: u32,
6431        col: u32,
6432        value: &LiteralValue,
6433    ) {
6434        if !(self.config.arrow_storage_enabled
6435            && self.config.delta_overlay_enabled
6436            && self.config.write_formula_overlay_enabled)
6437        {
6438            return;
6439        }
6440        if self.computed_overlay_mirroring_disabled {
6441            return;
6442        }
6443
6444        let ov = self.literal_to_overlay_value(value);
6445        self.write_computed_overlay_value_0based(
6446            sheet,
6447            row.saturating_sub(1),
6448            col.saturating_sub(1),
6449            ov,
6450        );
6451    }
6452
6453    fn write_computed_overlay_value_0based(
6454        &mut self,
6455        sheet: &str,
6456        row0: u32,
6457        col0: u32,
6458        value: OverlayValue,
6459    ) {
6460        if !(self.config.arrow_storage_enabled
6461            && self.config.delta_overlay_enabled
6462            && self.config.write_formula_overlay_enabled)
6463        {
6464            return;
6465        }
6466        if self.computed_overlay_mirroring_disabled {
6467            return;
6468        }
6469
6470        self.ensure_arrow_sheet(sheet);
6471
6472        let row0 = row0 as usize;
6473        let col0 = col0 as usize;
6474        let asheet = self
6475            .arrow_sheets
6476            .sheet_mut(sheet)
6477            .expect("ArrowSheet must exist");
6478
6479        let cur_cols = asheet.columns.len();
6480        if col0 >= cur_cols {
6481            asheet.insert_columns(cur_cols, (col0 + 1) - cur_cols);
6482        }
6483
6484        if row0 >= asheet.nrows as usize {
6485            if asheet.columns.is_empty() {
6486                asheet.insert_columns(0, 1);
6487            }
6488            asheet.ensure_row_capacity(row0 + 1);
6489        }
6490
6491        let Some((ch_idx, in_off)) = asheet.chunk_of_row(row0) else {
6492            return;
6493        };
6494        let Some(ch) = asheet.ensure_column_chunk_mut(col0, ch_idx) else {
6495            return;
6496        };
6497
6498        let delta = ch.computed_overlay.set_scalar(in_off, value);
6499        self.adjust_computed_overlay_bytes(delta);
6500
6501        if let Some(cap) = self.config.max_overlay_memory_bytes
6502            && self.computed_overlay_bytes_estimate > cap
6503        {
6504            self.disable_computed_overlay_mirroring_due_to_budget(cap);
6505        }
6506    }
6507
6508    pub(crate) fn plan_computed_write_coalescing(
6509        &self,
6510        buffer: &ComputedWriteBuffer,
6511    ) -> ComputedWriteCoalescingPlan {
6512        self.plan_computed_write_coalescing_from_writes(buffer.writes().iter().cloned())
6513    }
6514
6515    fn plan_owned_computed_write_coalescing(
6516        &self,
6517        writes: Vec<ComputedWrite>,
6518    ) -> ComputedWriteCoalescingPlan {
6519        self.plan_computed_write_coalescing_from_writes(writes)
6520    }
6521
6522    fn plan_computed_write_coalescing_from_writes(
6523        &self,
6524        writes: impl IntoIterator<Item = ComputedWrite>,
6525    ) -> ComputedWriteCoalescingPlan {
6526        let mut groups: BTreeMap<ComputedWriteChunkKey, Vec<ComputedWriteChunkEntryPlan>> =
6527            BTreeMap::new();
6528        let mut input_cells = 0usize;
6529
6530        for write in writes {
6531            match write {
6532                ComputedWrite::Cell {
6533                    seq,
6534                    sheet_id,
6535                    row0,
6536                    col0,
6537                    value,
6538                } => {
6539                    input_cells = input_cells.saturating_add(1);
6540                    self.push_computed_write_plan_entry(
6541                        &mut groups,
6542                        seq,
6543                        sheet_id,
6544                        row0,
6545                        col0,
6546                        value,
6547                    );
6548                }
6549                ComputedWrite::Rect {
6550                    seq,
6551                    sheet_id,
6552                    sr0,
6553                    sc0,
6554                    values,
6555                } => {
6556                    for (r_off, row) in values.into_iter().enumerate() {
6557                        for (c_off, value) in row.into_iter().enumerate() {
6558                            input_cells = input_cells.saturating_add(1);
6559                            self.push_computed_write_plan_entry(
6560                                &mut groups,
6561                                seq,
6562                                sheet_id,
6563                                sr0.saturating_add(r_off as u32),
6564                                sc0.saturating_add(c_off as u32),
6565                                value,
6566                            );
6567                        }
6568                    }
6569                }
6570            }
6571        }
6572
6573        let mut plan = ComputedWriteCoalescingPlan {
6574            chunks: Vec::with_capacity(groups.len()),
6575            input_cells,
6576            coalesced_cells: 0,
6577            overwritten_cells: 0,
6578        };
6579        for (key, entries) in groups {
6580            let (chunk_plan, overwritten) = ComputedWriteChunkPlan::from_group(key, entries);
6581            plan.coalesced_cells = plan
6582                .coalesced_cells
6583                .saturating_add(chunk_plan.entries.len());
6584            plan.overwritten_cells = plan.overwritten_cells.saturating_add(overwritten);
6585            plan.chunks.push(chunk_plan);
6586        }
6587        debug_assert_eq!(
6588            plan.input_cells,
6589            plan.coalesced_cells.saturating_add(plan.overwritten_cells)
6590        );
6591        plan
6592    }
6593
6594    fn push_computed_write_plan_entry(
6595        &self,
6596        groups: &mut BTreeMap<ComputedWriteChunkKey, Vec<ComputedWriteChunkEntryPlan>>,
6597        seq: u64,
6598        sheet_id: SheetId,
6599        row0: u32,
6600        col0: u32,
6601        value: OverlayValue,
6602    ) {
6603        let (chunk_idx, chunk_start_row0, row_in_chunk) =
6604            self.locate_computed_write_chunk(sheet_id, row0);
6605        let key = ComputedWriteChunkKey {
6606            sheet_id,
6607            col0,
6608            chunk_idx,
6609            chunk_start_row0,
6610        };
6611        groups
6612            .entry(key)
6613            .or_default()
6614            .push(ComputedWriteChunkEntryPlan {
6615                row_in_chunk,
6616                seq,
6617                value,
6618            });
6619    }
6620
6621    fn locate_computed_write_chunk(&self, sheet_id: SheetId, row0: u32) -> (usize, u32, usize) {
6622        let sheet_name = self.graph.sheet_name(sheet_id);
6623        if let Some(sheet) = self.arrow_sheets.sheet(sheet_name) {
6624            return Self::locate_row_in_sheet_for_computed_write_plan(sheet, row0 as usize);
6625        }
6626        Self::locate_row_in_empty_sheet_for_computed_write_plan(row0 as usize, 32 * 1024)
6627    }
6628
6629    fn locate_row_in_sheet_for_computed_write_plan(
6630        sheet: &crate::arrow_store::ArrowSheet,
6631        row0: usize,
6632    ) -> (usize, u32, usize) {
6633        if row0 < sheet.nrows as usize
6634            && let Some((chunk_idx, row_in_chunk)) = sheet.chunk_of_row(row0)
6635        {
6636            let chunk_start = sheet.chunk_starts.get(chunk_idx).copied().unwrap_or(0);
6637            return (chunk_idx, chunk_start as u32, row_in_chunk);
6638        }
6639
6640        let chunk_rows = sheet.chunk_rows.max(1);
6641        if sheet.chunk_starts.is_empty() {
6642            return Self::locate_row_in_empty_sheet_for_computed_write_plan(row0, chunk_rows);
6643        }
6644
6645        let mut chunk_idx = sheet.chunk_starts.len().saturating_sub(1);
6646        let mut chunk_start = sheet.chunk_starts[chunk_idx];
6647        while chunk_start.saturating_add(chunk_rows) <= row0 {
6648            chunk_idx = chunk_idx.saturating_add(1);
6649            chunk_start = chunk_start.saturating_add(chunk_rows);
6650        }
6651        (
6652            chunk_idx,
6653            chunk_start as u32,
6654            row0.saturating_sub(chunk_start),
6655        )
6656    }
6657
6658    fn locate_row_in_empty_sheet_for_computed_write_plan(
6659        row0: usize,
6660        chunk_rows: usize,
6661    ) -> (usize, u32, usize) {
6662        let chunk_rows = chunk_rows.max(1);
6663        let chunk_idx = row0 / chunk_rows;
6664        let chunk_start = chunk_idx.saturating_mul(chunk_rows);
6665        (
6666            chunk_idx,
6667            chunk_start as u32,
6668            row0.saturating_sub(chunk_start),
6669        )
6670    }
6671
6672    #[cfg(test)]
6673    pub(crate) fn debug_plan_computed_write_coalescing(
6674        &self,
6675        buffer: &ComputedWriteBuffer,
6676    ) -> ComputedWriteCoalescingPlan {
6677        self.plan_computed_write_coalescing(buffer)
6678    }
6679
6680    pub(crate) fn flush_computed_write_buffer(
6681        &mut self,
6682        buffer: &mut ComputedWriteBuffer,
6683    ) -> Result<(), ExcelError> {
6684        if buffer.is_empty() {
6685            return Ok(());
6686        }
6687
6688        let plan = self.plan_owned_computed_write_coalescing(buffer.take_writes());
6689        self.flush_computed_write_plan(plan);
6690
6691        Ok(())
6692    }
6693
6694    fn flush_computed_write_plan(&mut self, plan: ComputedWriteCoalescingPlan) {
6695        for chunk in plan.chunks {
6696            self.flush_computed_write_chunk_plan(chunk);
6697        }
6698    }
6699
6700    fn flush_computed_write_chunk_plan(&mut self, chunk: ComputedWriteChunkPlan) {
6701        match &chunk.shape {
6702            ComputedWriteChunkPlanShape::Point => {
6703                self.flush_computed_write_chunk_plan_as_points(chunk);
6704            }
6705            ComputedWriteChunkPlanShape::SparseOffsets { .. } => {
6706                self.flush_computed_write_chunk_plan_as_sparse_fragment_or_points(chunk);
6707            }
6708            ComputedWriteChunkPlanShape::DenseRange { .. } => {
6709                self.flush_computed_write_chunk_plan_as_dense_fragment(chunk);
6710            }
6711            ComputedWriteChunkPlanShape::RunRange { len, runs, .. } => {
6712                if Self::should_emit_computed_run_fragment(*len, *runs) {
6713                    self.flush_computed_write_chunk_plan_as_run_fragment(chunk);
6714                } else {
6715                    self.flush_computed_write_chunk_plan_as_dense_fragment(chunk);
6716                }
6717            }
6718        }
6719    }
6720
6721    #[inline]
6722    fn should_emit_computed_run_fragment(len: usize, runs: usize) -> bool {
6723        runs <= len / 2
6724    }
6725
6726    fn flush_computed_write_chunk_plan_as_points(&mut self, chunk: ComputedWriteChunkPlan) {
6727        let sheet_name = self.graph.sheet_name(chunk.sheet_id).to_string();
6728        for entry in chunk.entries {
6729            let row0 = chunk
6730                .chunk_start_row0
6731                .saturating_add(entry.row_in_chunk as u32);
6732            self.write_computed_overlay_value_0based(&sheet_name, row0, chunk.col0, entry.value);
6733        }
6734    }
6735
6736    fn flush_computed_write_chunk_plan_as_sparse_fragment_or_points(
6737        &mut self,
6738        chunk: ComputedWriteChunkPlan,
6739    ) {
6740        let point_estimate = Self::computed_write_chunk_plan_point_estimate(&chunk);
6741        let sheet_id = chunk.sheet_id;
6742        let col0 = chunk.col0;
6743        let chunk_idx = chunk.chunk_idx;
6744        let chunk_start_row0 = chunk.chunk_start_row0;
6745        let items: Vec<(usize, OverlayValue)> = chunk
6746            .entries
6747            .into_iter()
6748            .map(|entry| (entry.row_in_chunk, entry.value))
6749            .collect();
6750        match OverlayFragment::sparse_offsets_if_estimated_smaller_than_points(
6751            items,
6752            point_estimate,
6753        ) {
6754            Some(Ok(fragment)) => {
6755                self.apply_computed_overlay_fragment(sheet_id, col0, chunk_idx, fragment);
6756            }
6757            Some(Err(cells)) => {
6758                self.flush_computed_overlay_cells_as_points(
6759                    sheet_id,
6760                    col0,
6761                    chunk_start_row0,
6762                    cells,
6763                );
6764            }
6765            None => {}
6766        }
6767    }
6768
6769    #[inline]
6770    fn computed_write_chunk_plan_point_estimate(chunk: &ComputedWriteChunkPlan) -> usize {
6771        chunk
6772            .entries
6773            .iter()
6774            .map(|entry| ComputedWriteBuffer::estimate_value_bytes(&entry.value))
6775            .fold(0usize, usize::saturating_add)
6776    }
6777
6778    fn flush_computed_overlay_cells_as_points(
6779        &mut self,
6780        sheet_id: SheetId,
6781        col0: u32,
6782        chunk_start_row0: u32,
6783        cells: Vec<(usize, OverlayValue)>,
6784    ) {
6785        let sheet_name = self.graph.sheet_name(sheet_id).to_string();
6786        for (row_in_chunk, value) in cells {
6787            let row0 = chunk_start_row0.saturating_add(row_in_chunk as u32);
6788            self.write_computed_overlay_value_0based(&sheet_name, row0, col0, value);
6789        }
6790    }
6791
6792    fn flush_computed_write_chunk_plan_as_dense_fragment(&mut self, chunk: ComputedWriteChunkPlan) {
6793        if chunk.entries.is_empty() {
6794            return;
6795        }
6796        let start = chunk.entries[0].row_in_chunk;
6797        let values: Vec<OverlayValue> =
6798            chunk.entries.into_iter().map(|entry| entry.value).collect();
6799        if let Some(fragment) = OverlayFragment::dense_range(start, values) {
6800            self.apply_computed_overlay_fragment(
6801                chunk.sheet_id,
6802                chunk.col0,
6803                chunk.chunk_idx,
6804                fragment,
6805            );
6806        }
6807    }
6808
6809    fn flush_computed_write_chunk_plan_as_run_fragment(&mut self, chunk: ComputedWriteChunkPlan) {
6810        if chunk.entries.is_empty() {
6811            return;
6812        }
6813        let start = chunk.entries[0].row_in_chunk;
6814        let values: Vec<OverlayValue> =
6815            chunk.entries.into_iter().map(|entry| entry.value).collect();
6816        if let Some(fragment) = OverlayFragment::run_range(start, values) {
6817            self.apply_computed_overlay_fragment(
6818                chunk.sheet_id,
6819                chunk.col0,
6820                chunk.chunk_idx,
6821                fragment,
6822            );
6823        }
6824    }
6825
6826    fn apply_computed_overlay_fragment(
6827        &mut self,
6828        sheet_id: SheetId,
6829        col0: u32,
6830        chunk_idx: usize,
6831        fragment: OverlayFragment,
6832    ) {
6833        if !(self.config.arrow_storage_enabled
6834            && self.config.delta_overlay_enabled
6835            && self.config.write_formula_overlay_enabled)
6836        {
6837            return;
6838        }
6839        if self.computed_overlay_mirroring_disabled {
6840            return;
6841        }
6842
6843        let sheet_name = self.graph.sheet_name(sheet_id).to_string();
6844        self.ensure_arrow_sheet(&sheet_name);
6845
6846        let col0 = col0 as usize;
6847        let asheet = self
6848            .arrow_sheets
6849            .sheet_mut(&sheet_name)
6850            .expect("ArrowSheet must exist");
6851
6852        let cur_cols = asheet.columns.len();
6853        if col0 >= cur_cols {
6854            asheet.insert_columns(cur_cols, (col0 + 1) - cur_cols);
6855        }
6856
6857        let start_row0 = asheet
6858            .chunk_starts
6859            .get(chunk_idx)
6860            .copied()
6861            .unwrap_or_else(|| chunk_idx.saturating_mul(asheet.chunk_rows.max(1)));
6862        let required_rows =
6863            start_row0.saturating_add(fragment.max_covered_offset().saturating_add(1));
6864        if required_rows > asheet.nrows as usize {
6865            if asheet.columns.is_empty() {
6866                asheet.insert_columns(0, 1);
6867            }
6868            asheet.ensure_row_capacity(required_rows);
6869        }
6870
6871        let Some(ch) = asheet.ensure_column_chunk_mut(col0, chunk_idx) else {
6872            return;
6873        };
6874        let delta = ch.computed_overlay.apply_fragment(fragment);
6875        self.adjust_computed_overlay_bytes(delta);
6876
6877        if let Some(cap) = self.config.max_overlay_memory_bytes
6878            && self.computed_overlay_bytes_estimate > cap
6879        {
6880            self.disable_computed_overlay_mirroring_due_to_budget(cap);
6881        }
6882    }
6883
6884    #[inline]
6885    fn adjust_computed_overlay_bytes(&mut self, delta: isize) {
6886        if delta >= 0 {
6887            self.computed_overlay_bytes_estimate = self
6888                .computed_overlay_bytes_estimate
6889                .saturating_add(delta as usize);
6890        } else {
6891            self.computed_overlay_bytes_estimate = self
6892                .computed_overlay_bytes_estimate
6893                .saturating_sub((-delta) as usize);
6894        }
6895    }
6896
6897    fn clear_all_computed_overlays(&mut self) {
6898        let mut freed_total = 0usize;
6899        for sh in self.arrow_sheets.sheets.iter_mut() {
6900            for col in sh.columns.iter_mut() {
6901                for ch in col.chunks.iter_mut() {
6902                    freed_total = freed_total.saturating_add(ch.computed_overlay.clear());
6903                }
6904                for ch in col.sparse_chunks.values_mut() {
6905                    freed_total = freed_total.saturating_add(ch.computed_overlay.clear());
6906                }
6907            }
6908        }
6909        self.computed_overlay_bytes_estimate = self
6910            .computed_overlay_bytes_estimate
6911            .saturating_sub(freed_total);
6912    }
6913
6914    fn disable_computed_overlay_mirroring_due_to_budget(&mut self, _cap: usize) {
6915        // Phase 1 (ticket 610): Arrow-truth is the only supported mode.
6916        // Handle budget pressure by compacting computed overlays into base lanes.
6917        self.compact_all_computed_overlays();
6918    }
6919
6920    /// Fold all computed overlay entries across all sheets into their base arrays.
6921    /// This preserves data while freeing overlay memory, allowing mirroring to continue.
6922    fn compact_all_computed_overlays(&mut self) {
6923        let mut freed_total = 0usize;
6924        for sheet in self.arrow_sheets.sheets.iter_mut() {
6925            for col_idx in 0..sheet.columns.len() {
6926                // Dense chunks
6927                let num_dense = sheet.columns[col_idx].chunks.len();
6928                for ch_idx in 0..num_dense {
6929                    freed_total += sheet.compact_computed_overlay_chunk(col_idx, ch_idx);
6930                }
6931                // Sparse chunks
6932                let sparse_keys: Vec<usize> = sheet.columns[col_idx]
6933                    .sparse_chunks
6934                    .keys()
6935                    .copied()
6936                    .collect();
6937                for ch_idx in sparse_keys {
6938                    freed_total += sheet.compact_computed_overlay_sparse_chunk(col_idx, ch_idx);
6939                }
6940            }
6941        }
6942        self.computed_overlay_bytes_estimate = self
6943            .computed_overlay_bytes_estimate
6944            .saturating_sub(freed_total);
6945        self.overlay_compactions = self.overlay_compactions.saturating_add(1);
6946    }
6947
6948    fn mirror_vertex_value_to_overlay(&mut self, vertex_id: VertexId, value: &LiteralValue) {
6949        let _ = self.record_vertex_value_to_overlay(vertex_id, value, None);
6950    }
6951
6952    fn record_vertex_value_to_overlay(
6953        &mut self,
6954        vertex_id: VertexId,
6955        value: &LiteralValue,
6956        computed_writes: Option<&mut ComputedWriteBuffer>,
6957    ) -> Result<(), ExcelError> {
6958        if !(self.config.arrow_storage_enabled
6959            && self.config.delta_overlay_enabled
6960            && self.config.write_formula_overlay_enabled)
6961        {
6962            return Ok(());
6963        }
6964        if self.computed_overlay_mirroring_disabled {
6965            return Ok(());
6966        }
6967        if !matches!(
6968            self.graph.get_vertex_kind(vertex_id),
6969            VertexKind::FormulaScalar | VertexKind::FormulaArray
6970        ) {
6971            return Ok(());
6972        }
6973        let Some(cell) = self.graph.get_cell_ref(vertex_id) else {
6974            return Ok(());
6975        };
6976        let ov = self.literal_to_overlay_value(value);
6977        if let Some(buffer) = computed_writes {
6978            buffer.push_cell(cell.sheet_id, cell.coord.row(), cell.coord.col(), ov);
6979            if self.should_flush_computed_write_buffer(buffer) {
6980                self.flush_computed_write_buffer(buffer)?;
6981            }
6982        } else {
6983            let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
6984            self.write_computed_overlay_value_0based(
6985                &sheet_name,
6986                cell.coord.row(),
6987                cell.coord.col(),
6988                ov,
6989            );
6990        }
6991        Ok(())
6992    }
6993
6994    #[inline]
6995    fn should_flush_computed_write_buffer(&self, buffer: &ComputedWriteBuffer) -> bool {
6996        self.config.max_overlay_memory_bytes.is_some_and(|cap| {
6997            if cap == 0 {
6998                return false;
6999            }
7000            self.computed_overlay_bytes_estimate
7001                .saturating_add(buffer.estimated_bytes())
7002                > cap
7003        })
7004    }
7005
7006    /// Estimated memory usage for computed overlays (formula/spill mirroring).
7007    pub fn overlay_memory_usage(&self) -> usize {
7008        self.computed_overlay_bytes_estimate
7009    }
7010
7011    #[cfg(test)]
7012    pub(crate) fn debug_overlay_compactions(&self) -> u64 {
7013        self.overlay_compactions
7014    }
7015
7016    #[cfg(test)]
7017    pub(crate) fn debug_recompute_computed_overlay_bytes(&mut self) -> usize {
7018        let mut total = 0usize;
7019        for sheet in &self.arrow_sheets.sheets {
7020            for column in &sheet.columns {
7021                for chunk in &column.chunks {
7022                    total = total.saturating_add(chunk.computed_overlay.estimated_bytes());
7023                }
7024                for chunk in column.sparse_chunks.values() {
7025                    total = total.saturating_add(chunk.computed_overlay.estimated_bytes());
7026                }
7027            }
7028        }
7029        self.computed_overlay_bytes_estimate = total;
7030        total
7031    }
7032
7033    fn resolve_sheet_locator_for_write(
7034        &mut self,
7035        loc: formualizer_common::SheetLocator<'_>,
7036        current_sheet: &str,
7037    ) -> Result<SheetId, ExcelError> {
7038        Ok(match loc {
7039            formualizer_common::SheetLocator::Id(id) => id,
7040            formualizer_common::SheetLocator::Name(name) => self.graph.sheet_id_mut(name.as_ref()),
7041            formualizer_common::SheetLocator::Current => self.graph.sheet_id_mut(current_sheet),
7042        })
7043    }
7044
7045    fn resolve_sheet_locator_for_read(
7046        &self,
7047        loc: formualizer_common::SheetLocator<'_>,
7048        current_sheet: &str,
7049    ) -> Result<SheetId, ExcelError> {
7050        match loc {
7051            formualizer_common::SheetLocator::Id(id) => Ok(id),
7052            formualizer_common::SheetLocator::Name(name) => self
7053                .graph
7054                .sheet_id(name.as_ref())
7055                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref)),
7056            formualizer_common::SheetLocator::Current => self
7057                .graph
7058                .sheet_id(current_sheet)
7059                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref)),
7060        }
7061    }
7062
7063    /// Set a cell value
7064    pub fn set_cell_value(
7065        &mut self,
7066        sheet: &str,
7067        row: u32,
7068        col: u32,
7069        value: LiteralValue,
7070    ) -> Result<(), ExcelError> {
7071        let sheet_id = self.graph.sheet_id_mut(sheet);
7072        self.demote_span_containing_cell_for_write(
7073            sheet_id,
7074            row.saturating_sub(1),
7075            col.saturating_sub(1),
7076        )
7077        .map_err(Self::editor_error_to_excel)?;
7078        self.graph.set_cell_value(sheet, row, col, value.clone())?;
7079        self.record_formula_plane_changed_cell(sheet, row, col);
7080        // Mirror into Arrow overlay when enabled
7081        self.mirror_value_to_overlay(sheet, row, col, &value);
7082        // Advance snapshot to reflect external mutation
7083        self.snapshot_id
7084            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7085        self.has_edited = true;
7086        Ok(())
7087    }
7088
7089    /// Record a single-cell change in FormulaPlane authority so the next
7090    /// `evaluate_all` under `AuthoritativeExperimental` can derive bounded
7091    /// span work from `FormulaConsumerReadIndex` instead of recomputing every
7092    /// active span.
7093    fn record_formula_plane_changed_cell(&mut self, sheet: &str, row: u32, col: u32) {
7094        if self.config.formula_plane_mode == FormulaPlaneMode::Off {
7095            return;
7096        }
7097        let sheet_id = self.graph.sheet_id_mut(sheet);
7098        self.record_formula_plane_structural_change(StructuralScope::Cell {
7099            sheet: sheet_id,
7100            row: row.saturating_sub(1),
7101            col: col.saturating_sub(1),
7102        });
7103    }
7104
7105    fn record_formula_plane_change_for_event(&mut self, event: &ChangeEvent) {
7106        if self.config.formula_plane_mode == FormulaPlaneMode::Off {
7107            return;
7108        }
7109
7110        match event {
7111            ChangeEvent::SetValue { addr, .. } | ChangeEvent::SetFormula { addr, .. } => {
7112                self.record_formula_plane_structural_change(StructuralScope::Cell {
7113                    sheet: addr.sheet_id,
7114                    row: addr.coord.row(),
7115                    col: addr.coord.col(),
7116                });
7117            }
7118            ChangeEvent::SpillCommitted { new, .. } => {
7119                if let Some(scope) = Self::formula_plane_region_from_cells(&new.target_cells) {
7120                    self.record_formula_plane_structural_change(scope);
7121                }
7122            }
7123            ChangeEvent::SpillCleared { old, .. } => {
7124                if let Some(scope) = Self::formula_plane_region_from_cells(&old.target_cells) {
7125                    self.record_formula_plane_structural_change(scope);
7126                }
7127            }
7128            ChangeEvent::DefineName { .. }
7129            | ChangeEvent::UpdateName { .. }
7130            | ChangeEvent::DeleteName { .. }
7131            | ChangeEvent::VertexMoved { .. }
7132            | ChangeEvent::FormulaAdjusted { .. }
7133            | ChangeEvent::NamedRangeAdjusted { .. } => {
7134                self.record_formula_plane_structural_change(StructuralScope::AllSheets);
7135            }
7136            ChangeEvent::SetRowVisibility { sheet_id, row0, .. } => {
7137                self.record_formula_plane_structural_change(StructuralScope::Region(
7138                    Region::whole_row(*sheet_id, *row0),
7139                ));
7140            }
7141            ChangeEvent::AddVertex { .. }
7142            | ChangeEvent::RemoveVertex { .. }
7143            | ChangeEvent::EdgeAdded { .. }
7144            | ChangeEvent::EdgeRemoved { .. }
7145            | ChangeEvent::CompoundStart { .. }
7146            | ChangeEvent::CompoundEnd { .. }
7147            | ChangeEvent::StagedFormulaCellChanged { .. } => {}
7148        }
7149    }
7150
7151    fn record_formula_plane_structural_change(&mut self, scope: StructuralScope) {
7152        if self.config.formula_plane_mode == FormulaPlaneMode::Off {
7153            return;
7154        }
7155
7156        match scope {
7157            StructuralScope::Cell { sheet, row, col } => {
7158                self.graph
7159                    .formula_authority_mut()
7160                    .record_changed_region(Region::point(sheet, row, col));
7161            }
7162            StructuralScope::Region(region) => {
7163                self.graph
7164                    .formula_authority_mut()
7165                    .record_changed_region(region);
7166            }
7167            StructuralScope::Sheet(sheet_id) => {
7168                self.graph
7169                    .formula_authority_mut()
7170                    .record_changed_region(Region::whole_sheet(sheet_id));
7171            }
7172            StructuralScope::RemovedSheet(sheet_id) => {
7173                let removed_refs = {
7174                    let authority = self.graph.formula_authority();
7175                    authority
7176                        .active_span_refs()
7177                        .into_iter()
7178                        .filter(|span_ref| {
7179                            authority
7180                                .plane
7181                                .spans
7182                                .get(*span_ref)
7183                                .map(|span| span.sheet_id == sheet_id)
7184                                .unwrap_or(false)
7185                        })
7186                        .collect::<Vec<_>>()
7187                };
7188
7189                let authority = self.graph.formula_authority_mut();
7190                for span_ref in removed_refs {
7191                    authority.plane.remove_span(span_ref);
7192                }
7193                authority.mark_all_active_spans_dirty();
7194                let _ = authority.rebuild_indexes();
7195            }
7196            StructuralScope::AllSheets => {
7197                let authority = self.graph.formula_authority_mut();
7198                authority.mark_all_active_spans_dirty();
7199                let _ = authority.rebuild_indexes();
7200            }
7201        }
7202    }
7203
7204    fn formula_plane_region_from_cells(cells: &[CellRef]) -> Option<StructuralScope> {
7205        let first = cells.first()?;
7206        let sheet_id = first.sheet_id;
7207        if cells.iter().any(|cell| cell.sheet_id != sheet_id) {
7208            return Some(StructuralScope::AllSheets);
7209        }
7210        let mut row_start = first.coord.row();
7211        let mut row_end = row_start;
7212        let mut col_start = first.coord.col();
7213        let mut col_end = col_start;
7214        for cell in cells.iter().skip(1) {
7215            row_start = row_start.min(cell.coord.row());
7216            row_end = row_end.max(cell.coord.row());
7217            col_start = col_start.min(cell.coord.col());
7218            col_end = col_end.max(cell.coord.col());
7219        }
7220        Some(StructuralScope::Region(Region::rect(
7221            sheet_id, row_start, row_end, col_start, col_end,
7222        )))
7223    }
7224
7225    pub fn set_cell_value_ref(
7226        &mut self,
7227        cell: formualizer_common::SheetCellRef<'_>,
7228        current_sheet: &str,
7229        value: LiteralValue,
7230    ) -> Result<(), ExcelError> {
7231        let owned = cell.into_owned();
7232        let sheet_id = self.resolve_sheet_locator_for_write(owned.sheet, current_sheet)?;
7233        let sheet_name = self.graph.sheet_name(sheet_id).to_string();
7234        self.set_cell_value(
7235            &sheet_name,
7236            owned.coord.row() + 1,
7237            owned.coord.col() + 1,
7238            value,
7239        )
7240    }
7241
7242    pub fn set_cell_formula_ref(
7243        &mut self,
7244        cell: formualizer_common::SheetCellRef<'_>,
7245        current_sheet: &str,
7246        ast: ASTNode,
7247    ) -> Result<(), ExcelError> {
7248        let owned = cell.into_owned();
7249        let sheet_id = self.resolve_sheet_locator_for_write(owned.sheet, current_sheet)?;
7250        let sheet_name = self.graph.sheet_name(sheet_id).to_string();
7251        self.set_cell_formula(
7252            &sheet_name,
7253            owned.coord.row() + 1,
7254            owned.coord.col() + 1,
7255            ast,
7256        )
7257    }
7258
7259    pub fn get_cell_value_ref(
7260        &self,
7261        cell: formualizer_common::SheetCellRef<'_>,
7262        current_sheet: &str,
7263    ) -> Result<Option<LiteralValue>, ExcelError> {
7264        let owned = cell.into_owned();
7265        let sheet_id = self.resolve_sheet_locator_for_read(owned.sheet, current_sheet)?;
7266        let sheet_name = self.graph.sheet_name(sheet_id);
7267        Ok(self.get_cell_value(sheet_name, owned.coord.row() + 1, owned.coord.col() + 1))
7268    }
7269
7270    pub fn resolve_range_view_sheet_ref<'c>(
7271        &'c self,
7272        r: &formualizer_common::SheetRef<'_>,
7273        current_sheet: &str,
7274    ) -> Result<RangeView<'c>, ExcelError> {
7275        use formualizer_common::SheetLocator;
7276
7277        let sheet_to_opt_name = |loc: SheetLocator<'_>| -> Result<Option<String>, ExcelError> {
7278            match loc {
7279                SheetLocator::Current => Ok(None),
7280                SheetLocator::Name(name) => Ok(Some(name.as_ref().to_string())),
7281                SheetLocator::Id(id) => Ok(Some(self.graph.sheet_name(id).to_string())),
7282            }
7283        };
7284
7285        let rt = match r {
7286            formualizer_common::SheetRef::Cell(cell) => ReferenceType::Cell {
7287                sheet: sheet_to_opt_name(cell.sheet.clone())?,
7288                row: cell.coord.row() + 1,
7289                col: cell.coord.col() + 1,
7290                row_abs: cell.coord.row_abs(),
7291                col_abs: cell.coord.col_abs(),
7292            },
7293            formualizer_common::SheetRef::Range(range) => ReferenceType::Range {
7294                sheet: sheet_to_opt_name(range.sheet.clone())?,
7295                start_row: range.start_row.map(|b| b.index + 1),
7296                start_col: range.start_col.map(|b| b.index + 1),
7297                end_row: range.end_row.map(|b| b.index + 1),
7298                end_col: range.end_col.map(|b| b.index + 1),
7299                start_row_abs: range.start_row.map(|b| b.abs).unwrap_or(false),
7300                start_col_abs: range.start_col.map(|b| b.abs).unwrap_or(false),
7301                end_row_abs: range.end_row.map(|b| b.abs).unwrap_or(false),
7302                end_col_abs: range.end_col.map(|b| b.abs).unwrap_or(false),
7303            },
7304        };
7305
7306        crate::traits::EvaluationContext::resolve_range_view(self, &rt, current_sheet)
7307    }
7308
7309    /// Set a cell formula
7310    pub fn set_cell_formula(
7311        &mut self,
7312        sheet: &str,
7313        row: u32,
7314        col: u32,
7315        ast: ASTNode,
7316    ) -> Result<(), ExcelError> {
7317        let sheet_id = self.graph.sheet_id_mut(sheet);
7318        self.demote_span_containing_cell_for_write(
7319            sheet_id,
7320            row.saturating_sub(1),
7321            col.saturating_sub(1),
7322        )
7323        .map_err(Self::editor_error_to_excel)?;
7324        let placement = CellRef::new(sheet_id, Coord::from_excel(row, col, true, true));
7325        let ingested = {
7326            let mut pipeline = self.ingest_pipeline();
7327            pipeline.ingest_formula(FormulaAstInput::Tree(ast), placement, None)?
7328        };
7329        self.graph.set_cell_formula_with_plan(
7330            sheet,
7331            row,
7332            col,
7333            ingested.ast_id,
7334            &ingested.dep_plan,
7335            ingested.dep_plan.volatile,
7336            ingested.dep_plan.dynamic,
7337        )?;
7338        self.record_formula_plane_changed_cell(sheet, row, col);
7339
7340        // If the cell previously held a user value in the delta overlay, it must not continue
7341        // to mask the formula result under Arrow-canonical reads (overlay precedence is
7342        // delta -> computed -> base). Remove the overlay entry instead of writing `Empty`,
7343        // because an explicit `Empty` overlay would still take precedence over computed values.
7344        self.clear_delta_overlay_cell(sheet, row, col);
7345
7346        // Advance snapshot to reflect external mutation
7347        self.mark_topology_edited();
7348        Ok(())
7349    }
7350
7351    /// Bulk set many formulas on a sheet. Skips per-cell snapshot bumping and minimizes edge rebuilds.
7352    pub fn bulk_set_formulas<I>(&mut self, sheet: &str, items: I) -> Result<usize, ExcelError>
7353    where
7354        I: IntoIterator<Item = (u32, u32, ASTNode)>,
7355    {
7356        let collected: Vec<(u32, u32, ASTNode)> = items.into_iter().collect();
7357        let edited_cells: Vec<(u32, u32)> = collected.iter().map(|(r, c, _)| (*r, *c)).collect();
7358        let sheet_id = self.graph.sheet_id_mut(sheet);
7359        let writes_inside_active_span = edited_cells.iter().any(|(row, col)| {
7360            let placement =
7361                PlacementCoord::new(sheet_id, row.saturating_sub(1), col.saturating_sub(1));
7362            self.graph
7363                .formula_authority()
7364                .plane
7365                .spans
7366                .find_at(placement)
7367                .is_some()
7368        });
7369        if writes_inside_active_span {
7370            self.demote_spans_preserving_computed_overlays(sheet_id, Region::whole_sheet(sheet_id))
7371                .map_err(Self::editor_error_to_excel)?;
7372        }
7373        let ingested = {
7374            let mut pipeline = self.ingest_pipeline();
7375            let inputs = collected.into_iter().map(|(row, col, ast)| {
7376                let placement = CellRef::new(sheet_id, Coord::from_excel(row, col, true, true));
7377                (FormulaAstInput::Tree(ast), placement, None)
7378            });
7379            pipeline.ingest_batch(inputs)?
7380        };
7381        let planned = ingested
7382            .into_iter()
7383            .map(|formula| {
7384                (
7385                    formula.placement.coord.row() + 1,
7386                    formula.placement.coord.col() + 1,
7387                    formula.ast_id,
7388                    formula.dep_plan,
7389                )
7390            })
7391            .collect();
7392        let n = self.graph.bulk_set_formulas_with_plans(sheet, planned)?;
7393        for (row, col) in edited_cells {
7394            self.record_formula_plane_changed_cell(sheet, row, col);
7395        }
7396        // Single topology bump after batch
7397        if n > 0 {
7398            self.mark_topology_edited();
7399        }
7400        Ok(n)
7401    }
7402
7403    #[inline]
7404    fn normalize_public_cell_read(v: LiteralValue) -> Option<LiteralValue> {
7405        match v {
7406            LiteralValue::Empty => None,
7407            LiteralValue::Int(i) => Some(LiteralValue::Number(i as f64)),
7408            other => Some(other),
7409        }
7410    }
7411
7412    /// Get a cell value
7413    pub fn get_cell_value(&self, sheet: &str, row: u32, col: u32) -> Option<LiteralValue> {
7414        self.read_cell_value(sheet, row, col)
7415            .and_then(Self::normalize_public_cell_read)
7416    }
7417
7418    /// Unified internal read API for a single cell value (Arrow-truth).
7419    pub(crate) fn read_cell_value(&self, sheet: &str, row: u32, col: u32) -> Option<LiteralValue> {
7420        let asheet = self.sheet_store().sheet(sheet)?;
7421        let r0 = row.saturating_sub(1) as usize;
7422        let c0 = col.saturating_sub(1) as usize;
7423        let v = asheet.get_cell_value(r0, c0);
7424        if matches!(v, LiteralValue::Empty) {
7425            None
7426        } else {
7427            Some(v)
7428        }
7429    }
7430
7431    /// Unified internal read API for a range of cell values (Arrow-truth).
7432    pub(crate) fn read_range_values(
7433        &self,
7434        sheet: &str,
7435        sr: u32,
7436        sc: u32,
7437        er: u32,
7438        ec: u32,
7439    ) -> RangeView<'_> {
7440        let Some(asheet) = self.sheet_store().sheet(sheet) else {
7441            return RangeView::from_owned_rows(Vec::new(), self.config.date_system);
7442        };
7443        if er < sr || ec < sc {
7444            return asheet.range_view(1, 1, 0, 0);
7445        }
7446        let sr0 = sr.saturating_sub(1) as usize;
7447        let sc0 = sc.saturating_sub(1) as usize;
7448        let er0 = er.saturating_sub(1) as usize;
7449        let ec0 = ec.saturating_sub(1) as usize;
7450        asheet.range_view(sr0, sc0, er0, ec0)
7451    }
7452
7453    /// Get formula AST (if any) and current stored value for a cell
7454    pub fn get_cell(
7455        &self,
7456        sheet: &str,
7457        row: u32,
7458        col: u32,
7459    ) -> Option<(Option<formualizer_parse::ASTNode>, Option<LiteralValue>)> {
7460        let v = self.get_cell_value(sheet, row, col);
7461        let sheet_id = self.graph.sheet_id(sheet)?;
7462        let coord = Coord::from_excel(row, col, true, true);
7463        let cell = CellRef::new(sheet_id, coord);
7464        if let Some(vid) = self.graph.get_vertex_for_cell(&cell) {
7465            let ast = self.graph.get_formula_id(vid).and_then(|ast_id| {
7466                self.graph
7467                    .data_store()
7468                    .retrieve_ast(ast_id, self.graph.sheet_reg())
7469            });
7470            return Some((ast, v));
7471        }
7472
7473        let placement =
7474            crate::formula_plane::runtime::PlacementCoord::new(sheet_id, coord.row(), coord.col());
7475        let handle = self
7476            .graph
7477            .formula_authority()
7478            .plane
7479            .resolve_formula_at(placement, None);
7480        let template_id = match handle.resolution {
7481            crate::formula_plane::runtime::FormulaResolution::SpanPlacement {
7482                template_id, ..
7483            } => Some(template_id),
7484            crate::formula_plane::runtime::FormulaResolution::Overlay(overlay_ref) => self
7485                .graph
7486                .formula_authority()
7487                .plane
7488                .formula_overlay
7489                .get(overlay_ref)
7490                .and_then(|overlay| match overlay.kind {
7491                    crate::formula_plane::runtime::FormulaOverlayEntryKind::FormulaOverride(
7492                        template_id,
7493                    ) => Some(template_id),
7494                    _ => None,
7495                }),
7496            _ => None,
7497        };
7498        let ast = template_id.and_then(|template_id| {
7499            let ast_id = self
7500                .graph
7501                .formula_authority()
7502                .plane
7503                .templates
7504                .get(template_id)?
7505                .ast_id;
7506            self.graph
7507                .data_store()
7508                .retrieve_ast(ast_id, self.graph.sheet_reg())
7509        });
7510        if let Some(ast) = ast {
7511            Some((Some(ast), v))
7512        } else if v.is_some() {
7513            Some((None, v))
7514        } else {
7515            None
7516        }
7517    }
7518
7519    /// Begin batch operations - defer CSR rebuilds for better performance
7520    pub fn begin_batch(&mut self) {
7521        self.graph.begin_batch();
7522    }
7523
7524    /// End batch operations and trigger CSR rebuild
7525    pub fn end_batch(&mut self) {
7526        self.graph.end_batch();
7527    }
7528
7529    /// Begin a deferred-dirty scope for a multi-edit batch: while active,
7530    /// every edit's dirty propagation queues its sources instead of running
7531    /// a full BFS per edit, and the outermost `end_deferred_dirty` flushes
7532    /// the union with ONE multi-source propagation (O(component) instead of
7533    /// O(edits × component)). See `DependencyGraph::begin_deferred_dirty`.
7534    ///
7535    /// Callers MUST run `end_deferred_dirty` on every exit path, including
7536    /// error returns; evaluation entry points `debug_assert` no scope leaked.
7537    pub fn begin_deferred_dirty(&mut self) {
7538        self.graph.begin_deferred_dirty();
7539    }
7540
7541    /// End a deferred-dirty scope, flushing the queued propagation when the
7542    /// outermost scope closes. See `Engine::begin_deferred_dirty`.
7543    pub fn end_deferred_dirty(&mut self) {
7544        let _ = self.graph.end_deferred_dirty();
7545    }
7546
7547    /// Total vertices processed by dirty-propagation BFS loops since graph
7548    /// creation. Perf-shape observability only (cross-crate tests assert
7549    /// batched edits propagate O(component), not O(edits × component)).
7550    pub fn dirty_propagation_visits(&self) -> u64 {
7551        self.graph.dirty_propagation_visits()
7552    }
7553
7554    /// Evaluate a single vertex.
7555    /// This is the core of the sequential evaluation logic for Milestone 3.1.
7556    #[inline]
7557    fn record_cell_if_changed(
7558        delta: &mut DeltaCollector,
7559        cell: &CellRef,
7560        old: &LiteralValue,
7561        new: &LiteralValue,
7562    ) {
7563        if old != new {
7564            delta.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
7565        }
7566    }
7567
7568    pub fn evaluate_vertex(&mut self, vertex_id: VertexId) -> Result<LiteralValue, ExcelError> {
7569        if self.graph.formula_authority().active_span_count() > 0 {
7570            let _ = self.evaluate_authoritative_formula_plane_all()?;
7571        }
7572        self.evaluate_vertex_impl(vertex_id, None)
7573    }
7574
7575    fn evaluate_vertex_impl(
7576        &mut self,
7577        vertex_id: VertexId,
7578        delta: Option<&mut DeltaCollector>,
7579    ) -> Result<LiteralValue, ExcelError> {
7580        let mut delta = delta;
7581        // Check if vertex exists
7582        if !self.graph.vertex_exists(vertex_id) {
7583            return Err(ExcelError::new(formualizer_common::ExcelErrorKind::Ref)
7584                .with_message(format!("Vertex not found: {vertex_id:?}")));
7585        }
7586
7587        // Get vertex kind and check if it needs evaluation
7588        let kind = self.graph.get_vertex_kind(vertex_id);
7589        let sheet_id = self.graph.get_vertex_sheet_id(vertex_id);
7590
7591        let ast_id = match kind {
7592            VertexKind::FormulaScalar | VertexKind::FormulaArray => {
7593                if let Some(ast_id) = self.graph.get_formula_id(vertex_id) {
7594                    ast_id
7595                } else {
7596                    return Ok(LiteralValue::Number(0.0));
7597                }
7598            }
7599            VertexKind::Empty | VertexKind::Cell => {
7600                if let Some(cell_ref) = self.graph.get_cell_ref(vertex_id) {
7601                    let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
7602                    let row = cell_ref.coord.row() + 1;
7603                    let col = cell_ref.coord.col() + 1;
7604                    if let Some(v) = self.read_cell_value(sheet_name, row, col) {
7605                        return Ok(v);
7606                    }
7607                }
7608                return Ok(LiteralValue::Number(0.0));
7609            }
7610            VertexKind::NamedScalar => {
7611                let value = self.evaluate_named_scalar(vertex_id, sheet_id)?;
7612                return Ok(value);
7613            }
7614            VertexKind::NamedArray => {
7615                let value = self.evaluate_named_array(vertex_id, sheet_id)?;
7616                return Ok(value);
7617            }
7618            VertexKind::InfiniteRange
7619            | VertexKind::Range
7620            | VertexKind::External
7621            | VertexKind::Table => {
7622                // Not directly evaluatable here.
7623                return Ok(LiteralValue::Number(0.0));
7624            }
7625        };
7626
7627        // The interpreter uses a reference to the engine as the context.
7628        let sheet_name = self.graph.sheet_name(sheet_id);
7629        let cell_ref = self
7630            .graph
7631            .get_cell_ref(vertex_id)
7632            .expect("cell ref for vertex");
7633        let interpreter = Interpreter::new_with_cell(self, sheet_name, cell_ref);
7634
7635        let result =
7636            interpreter.evaluate_arena_ast(ast_id, self.graph.data_store(), self.graph.sheet_reg());
7637
7638        // If array result, perform spill from the anchor cell
7639        match result {
7640            Ok(cv) => {
7641                let result_literal = cv.into_literal();
7642                match result_literal {
7643                    LiteralValue::Array(rows) => {
7644                        // Update kind to FormulaArray for tracking
7645                        self.graph
7646                            .set_kind(vertex_id, crate::engine::vertex::VertexKind::FormulaArray);
7647                        // Build target cells rectangle starting from anchor
7648                        let anchor = self
7649                            .graph
7650                            .get_cell_ref(vertex_id)
7651                            .expect("cell ref for vertex");
7652                        let sheet_id = anchor.sheet_id;
7653                        let h = rows.len() as u32;
7654                        let w = rows.first().map(|r| r.len()).unwrap_or(0) as u32;
7655
7656                        // Hard cap to avoid vertex explosion from huge dynamic arrays.
7657                        let spill_cells = (h as u64).saturating_mul(w as u64);
7658                        if spill_cells > self.config.spill.max_spill_cells as u64 {
7659                            self.clear_spill_projection_and_mirror(vertex_id, delta.as_deref_mut());
7660                            let spill_err = ExcelError::new(ExcelErrorKind::Spill)
7661                                .with_message("SpillTooLarge")
7662                                .with_extra(formualizer_common::ExcelErrorExtra::Spill {
7663                                    expected_rows: h,
7664                                    expected_cols: w,
7665                                });
7666                            let spill_val = LiteralValue::Error(spill_err.clone());
7667                            if let Some(d) = delta.as_deref_mut() {
7668                                let old = self
7669                                    .read_cell_value(
7670                                        self.graph.sheet_name(anchor.sheet_id),
7671                                        anchor.coord.row() + 1,
7672                                        anchor.coord.col() + 1,
7673                                    )
7674                                    .unwrap_or(LiteralValue::Empty);
7675                                if old != spill_val {
7676                                    d.record_cell(
7677                                        anchor.sheet_id,
7678                                        anchor.coord.row(),
7679                                        anchor.coord.col(),
7680                                    );
7681                                }
7682                            }
7683                            self.graph.update_vertex_value(vertex_id, spill_val.clone());
7684                            if self.config.arrow_storage_enabled
7685                                && self.config.delta_overlay_enabled
7686                                && self.config.write_formula_overlay_enabled
7687                            {
7688                                let sheet_name = self.graph.sheet_name(anchor.sheet_id).to_string();
7689                                self.mirror_value_to_computed_overlay(
7690                                    &sheet_name,
7691                                    anchor.coord.row() + 1,
7692                                    anchor.coord.col() + 1,
7693                                    &spill_val,
7694                                );
7695                            }
7696                            return Ok(spill_val);
7697                        }
7698                        // Bounds check to avoid out-of-range writes (align to AbsCoord capacity)
7699                        const PACKED_MAX_ROW: u32 = 1_048_575; // 20-bit max
7700                        const PACKED_MAX_COL: u32 = 16_383; // 14-bit max
7701                        let end_row = anchor.coord.row().saturating_add(h).saturating_sub(1);
7702                        let end_col = anchor.coord.col().saturating_add(w).saturating_sub(1);
7703                        if end_row > PACKED_MAX_ROW || end_col > PACKED_MAX_COL {
7704                            self.clear_spill_projection_and_mirror(vertex_id, delta.as_deref_mut());
7705                            let spill_err = ExcelError::new(ExcelErrorKind::Spill)
7706                                .with_message("Spill exceeds sheet bounds")
7707                                .with_extra(formualizer_common::ExcelErrorExtra::Spill {
7708                                    expected_rows: h,
7709                                    expected_cols: w,
7710                                });
7711                            let spill_val = LiteralValue::Error(spill_err.clone());
7712                            if let Some(d) = delta.as_deref_mut() {
7713                                let old = self
7714                                    .read_cell_value(
7715                                        self.graph.sheet_name(anchor.sheet_id),
7716                                        anchor.coord.row() + 1,
7717                                        anchor.coord.col() + 1,
7718                                    )
7719                                    .unwrap_or(LiteralValue::Empty);
7720                                if old != spill_val {
7721                                    d.record_cell(
7722                                        anchor.sheet_id,
7723                                        anchor.coord.row(),
7724                                        anchor.coord.col(),
7725                                    );
7726                                }
7727                            }
7728                            self.graph.update_vertex_value(vertex_id, spill_val.clone());
7729                            if self.config.arrow_storage_enabled
7730                                && self.config.delta_overlay_enabled
7731                                && self.config.write_formula_overlay_enabled
7732                            {
7733                                let sheet_name = self.graph.sheet_name(anchor.sheet_id).to_string();
7734                                self.mirror_value_to_computed_overlay(
7735                                    &sheet_name,
7736                                    anchor.coord.row() + 1,
7737                                    anchor.coord.col() + 1,
7738                                    &spill_val,
7739                                );
7740                            }
7741                            return Ok(spill_val);
7742                        }
7743                        let mut targets = Vec::new();
7744                        for r in 0..h {
7745                            for c in 0..w {
7746                                targets.push(self.graph.make_cell_ref_internal(
7747                                    sheet_id,
7748                                    anchor.coord.row() + r,
7749                                    anchor.coord.col() + c,
7750                                ));
7751                            }
7752                        }
7753
7754                        // Plan spill via spill manager shim
7755                        match self.spill_mgr.reserve(
7756                            vertex_id,
7757                            anchor,
7758                            SpillShape { rows: h, cols: w },
7759                            SpillMeta {
7760                                epoch: self.recalc_epoch,
7761                                config: self.config.spill,
7762                            },
7763                        ) {
7764                            Ok(()) => {
7765                                // Commit: write values to grid
7766                                // Default conflict policy is Error + FirstWins; reserve() enforces in-flight locks
7767                                // and plan_spill_region enforces overlap with committed formulas/spills/values.
7768                                if let Err(e) = self.commit_spill_and_mirror(
7769                                    vertex_id,
7770                                    &targets,
7771                                    rows.clone(),
7772                                    delta.as_deref_mut(),
7773                                    None,
7774                                ) {
7775                                    // If commit fails, mark as error
7776                                    self.clear_spill_projection_and_mirror(
7777                                        vertex_id,
7778                                        delta.as_deref_mut(),
7779                                    );
7780                                    if let Some(d) = delta.as_deref_mut() {
7781                                        let old = self
7782                                            .read_cell_value(
7783                                                self.graph.sheet_name(anchor.sheet_id),
7784                                                anchor.coord.row() + 1,
7785                                                anchor.coord.col() + 1,
7786                                            )
7787                                            .unwrap_or(LiteralValue::Empty);
7788                                        let new = LiteralValue::Error(e.clone());
7789                                        if old != new {
7790                                            d.record_cell(
7791                                                anchor.sheet_id,
7792                                                anchor.coord.row(),
7793                                                anchor.coord.col(),
7794                                            );
7795                                        }
7796                                    }
7797                                    let err_val = LiteralValue::Error(e.clone());
7798                                    self.graph.update_vertex_value(vertex_id, err_val.clone());
7799                                    if self.config.arrow_storage_enabled
7800                                        && self.config.delta_overlay_enabled
7801                                        && self.config.write_formula_overlay_enabled
7802                                    {
7803                                        let sheet_name =
7804                                            self.graph.sheet_name(anchor.sheet_id).to_string();
7805                                        self.mirror_value_to_computed_overlay(
7806                                            &sheet_name,
7807                                            anchor.coord.row() + 1,
7808                                            anchor.coord.col() + 1,
7809                                            &err_val,
7810                                        );
7811                                    }
7812                                    return Ok(err_val);
7813                                }
7814                                // Anchor shows the top-left value, like Excel
7815                                let top_left = rows
7816                                    .first()
7817                                    .and_then(|r| r.first())
7818                                    .cloned()
7819                                    .unwrap_or(LiteralValue::Empty);
7820                                self.graph.update_vertex_value(vertex_id, top_left.clone());
7821                                Ok(top_left)
7822                            }
7823                            Err(e) => {
7824                                self.clear_spill_projection_and_mirror(
7825                                    vertex_id,
7826                                    delta.as_deref_mut(),
7827                                );
7828                                let spill_err = ExcelError::new(ExcelErrorKind::Spill)
7829                                    .with_message(
7830                                        e.message.unwrap_or_else(|| "Spill blocked".to_string()),
7831                                    )
7832                                    .with_extra(formualizer_common::ExcelErrorExtra::Spill {
7833                                        expected_rows: h,
7834                                        expected_cols: w,
7835                                    });
7836                                let spill_val = LiteralValue::Error(spill_err.clone());
7837                                if let Some(d) = delta.as_deref_mut() {
7838                                    let old = self
7839                                        .read_cell_value(
7840                                            self.graph.sheet_name(anchor.sheet_id),
7841                                            anchor.coord.row() + 1,
7842                                            anchor.coord.col() + 1,
7843                                        )
7844                                        .unwrap_or(LiteralValue::Empty);
7845                                    if old != spill_val {
7846                                        d.record_cell(
7847                                            anchor.sheet_id,
7848                                            anchor.coord.row(),
7849                                            anchor.coord.col(),
7850                                        );
7851                                    }
7852                                }
7853                                self.graph.update_vertex_value(vertex_id, spill_val.clone());
7854                                if self.config.arrow_storage_enabled
7855                                    && self.config.delta_overlay_enabled
7856                                    && self.config.write_formula_overlay_enabled
7857                                {
7858                                    let sheet_name =
7859                                        self.graph.sheet_name(anchor.sheet_id).to_string();
7860                                    self.mirror_value_to_computed_overlay(
7861                                        &sheet_name,
7862                                        anchor.coord.row() + 1,
7863                                        anchor.coord.col() + 1,
7864                                        &spill_val,
7865                                    );
7866                                }
7867                                Ok(spill_val)
7868                            }
7869                        }
7870                    }
7871                    other => {
7872                        // Scalar result: store value and ensure any previous spill is cleared
7873                        let spill_cells = self
7874                            .graph
7875                            .spill_cells_for_anchor(vertex_id)
7876                            .map(|cells| cells.to_vec())
7877                            .unwrap_or_default();
7878                        if let Some(d) = delta.as_deref_mut()
7879                            && let Some(anchor) = self.graph.get_cell_ref_for_vertex(vertex_id)
7880                        {
7881                            if spill_cells.is_empty() {
7882                                let old = self
7883                                    .read_cell_value(
7884                                        self.graph.sheet_name(anchor.sheet_id),
7885                                        anchor.coord.row() + 1,
7886                                        anchor.coord.col() + 1,
7887                                    )
7888                                    .unwrap_or(LiteralValue::Empty);
7889                                if old != other {
7890                                    d.record_cell(
7891                                        anchor.sheet_id,
7892                                        anchor.coord.row(),
7893                                        anchor.coord.col(),
7894                                    );
7895                                }
7896                            } else {
7897                                for cell in spill_cells.iter() {
7898                                    let sheet_name = self.graph.sheet_name(cell.sheet_id);
7899                                    let old = self
7900                                        .get_cell_value(
7901                                            sheet_name,
7902                                            cell.coord.row() + 1,
7903                                            cell.coord.col() + 1,
7904                                        )
7905                                        .unwrap_or(LiteralValue::Empty);
7906                                    let new = if cell.sheet_id == anchor.sheet_id
7907                                        && cell.coord.row() == anchor.coord.row()
7908                                        && cell.coord.col() == anchor.coord.col()
7909                                    {
7910                                        other.clone()
7911                                    } else {
7912                                        LiteralValue::Empty
7913                                    };
7914                                    Self::record_cell_if_changed(d, cell, &old, &new);
7915                                }
7916                            }
7917                        }
7918                        self.graph.clear_spill_region(vertex_id);
7919                        if let Some(scope) = Self::formula_plane_region_from_cells(&spill_cells) {
7920                            self.record_formula_plane_structural_change(scope);
7921                        }
7922                        if self.config.arrow_storage_enabled
7923                            && self.config.delta_overlay_enabled
7924                            && self.config.write_formula_overlay_enabled
7925                        {
7926                            let empty = LiteralValue::Empty;
7927                            for cell in spill_cells.iter() {
7928                                let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
7929                                self.mirror_value_to_computed_overlay(
7930                                    &sheet_name,
7931                                    cell.coord.row() + 1,
7932                                    cell.coord.col() + 1,
7933                                    &empty,
7934                                );
7935                            }
7936                        }
7937                        self.graph.update_vertex_value(vertex_id, other.clone());
7938                        // Optionally mirror into Arrow overlay for Arrow-backed reads
7939                        if self.config.arrow_storage_enabled
7940                            && self.config.delta_overlay_enabled
7941                            && self.config.write_formula_overlay_enabled
7942                        {
7943                            let anchor = self
7944                                .graph
7945                                .get_cell_ref(vertex_id)
7946                                .expect("cell ref for vertex");
7947                            let sheet_name = self.graph.sheet_name(anchor.sheet_id).to_string();
7948                            self.mirror_value_to_computed_overlay(
7949                                &sheet_name,
7950                                anchor.coord.row() + 1,
7951                                anchor.coord.col() + 1,
7952                                &other,
7953                            );
7954                        }
7955                        Ok(other)
7956                    }
7957                }
7958            }
7959            Err(e) => {
7960                // Runtime Excel error: store as a cell value instead of propagating
7961                // as an exception so bulk eval paths don't fail the whole pass.
7962                let spill_cells = self
7963                    .graph
7964                    .spill_cells_for_anchor(vertex_id)
7965                    .map(|cells| cells.to_vec())
7966                    .unwrap_or_default();
7967                let err_val = LiteralValue::Error(e.clone());
7968                if let Some(d) = delta
7969                    && let Some(anchor) = self.graph.get_cell_ref_for_vertex(vertex_id)
7970                {
7971                    if spill_cells.is_empty() {
7972                        let old = self
7973                            .read_cell_value(
7974                                self.graph.sheet_name(anchor.sheet_id),
7975                                anchor.coord.row() + 1,
7976                                anchor.coord.col() + 1,
7977                            )
7978                            .unwrap_or(LiteralValue::Empty);
7979                        if old != err_val {
7980                            d.record_cell(anchor.sheet_id, anchor.coord.row(), anchor.coord.col());
7981                        }
7982                    } else {
7983                        for cell in spill_cells.iter() {
7984                            let sheet_name = self.graph.sheet_name(cell.sheet_id);
7985                            let old = self
7986                                .get_cell_value(
7987                                    sheet_name,
7988                                    cell.coord.row() + 1,
7989                                    cell.coord.col() + 1,
7990                                )
7991                                .unwrap_or(LiteralValue::Empty);
7992                            let new = if cell.sheet_id == anchor.sheet_id
7993                                && cell.coord.row() == anchor.coord.row()
7994                                && cell.coord.col() == anchor.coord.col()
7995                            {
7996                                err_val.clone()
7997                            } else {
7998                                LiteralValue::Empty
7999                            };
8000                            Self::record_cell_if_changed(d, cell, &old, &new);
8001                        }
8002                    }
8003                }
8004                self.graph.clear_spill_region(vertex_id);
8005                if let Some(scope) = Self::formula_plane_region_from_cells(&spill_cells) {
8006                    self.record_formula_plane_structural_change(scope);
8007                }
8008                if self.config.arrow_storage_enabled
8009                    && self.config.delta_overlay_enabled
8010                    && self.config.write_formula_overlay_enabled
8011                {
8012                    let empty = LiteralValue::Empty;
8013                    for cell in spill_cells.iter() {
8014                        let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
8015                        self.mirror_value_to_computed_overlay(
8016                            &sheet_name,
8017                            cell.coord.row() + 1,
8018                            cell.coord.col() + 1,
8019                            &empty,
8020                        );
8021                    }
8022                }
8023                self.graph.update_vertex_value(vertex_id, err_val.clone());
8024                if self.config.arrow_storage_enabled
8025                    && self.config.delta_overlay_enabled
8026                    && self.config.write_formula_overlay_enabled
8027                {
8028                    let anchor = self
8029                        .graph
8030                        .get_cell_ref(vertex_id)
8031                        .expect("cell ref for vertex");
8032                    let sheet_name = self.graph.sheet_name(anchor.sheet_id).to_string();
8033                    self.mirror_value_to_computed_overlay(
8034                        &sheet_name,
8035                        anchor.coord.row() + 1,
8036                        anchor.coord.col() + 1,
8037                        &err_val,
8038                    );
8039                }
8040                Ok(err_val)
8041            }
8042        }
8043    }
8044
8045    fn evaluate_named_scalar(
8046        &mut self,
8047        vertex_id: VertexId,
8048        sheet_id: SheetId,
8049    ) -> Result<LiteralValue, ExcelError> {
8050        let named_range = self.graph.named_range_by_vertex(vertex_id).ok_or_else(|| {
8051            ExcelError::new(ExcelErrorKind::Name)
8052                .with_message("Named range metadata missing".to_string())
8053        })?;
8054
8055        match &named_range.definition {
8056            NamedDefinition::Cell(cell_ref) => {
8057                let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
8058                let row = cell_ref.coord.row() + 1;
8059                let col = cell_ref.coord.col() + 1;
8060
8061                if let Some(dep_vertex) = self.graph.get_vertex_for_cell(cell_ref)
8062                    && matches!(
8063                        self.graph.get_vertex_kind(dep_vertex),
8064                        VertexKind::FormulaScalar | VertexKind::FormulaArray
8065                    )
8066                {
8067                    // Graph does not cache cell/formula values; ensure the precedent is evaluated.
8068                    let value = self.evaluate_vertex(dep_vertex)?;
8069                    self.graph.update_vertex_value(vertex_id, value.clone());
8070                    Ok(value)
8071                } else {
8072                    let value = self
8073                        .get_cell_value(sheet_name, row, col)
8074                        .unwrap_or(LiteralValue::Empty);
8075                    self.graph.update_vertex_value(vertex_id, value.clone());
8076                    Ok(value)
8077                }
8078            }
8079            NamedDefinition::Literal(v) => {
8080                let out = v.clone();
8081                self.graph.update_vertex_value(vertex_id, out.clone());
8082                Ok(out)
8083            }
8084            NamedDefinition::Formula { ast, .. } => {
8085                let context_sheet = match named_range.scope {
8086                    NameScope::Sheet(id) => id,
8087                    NameScope::Workbook => sheet_id,
8088                };
8089                let sheet_name = self.graph.sheet_name(context_sheet);
8090                let cell_ref = self
8091                    .graph
8092                    .get_cell_ref(vertex_id)
8093                    .unwrap_or_else(|| self.graph.make_cell_ref(sheet_name, 0, 0));
8094                let interpreter = Interpreter::new_with_cell(self, sheet_name, cell_ref);
8095                match interpreter.evaluate_ast(ast) {
8096                    Ok(cv) => {
8097                        let value = cv.into_literal();
8098                        match value {
8099                            LiteralValue::Array(_) => {
8100                                let err = ExcelError::new(ExcelErrorKind::NImpl)
8101                                    .with_message("Array result in scalar named range".to_string());
8102                                let err_val = LiteralValue::Error(err.clone());
8103                                self.graph.update_vertex_value(vertex_id, err_val.clone());
8104                                Ok(err_val)
8105                            }
8106                            other => {
8107                                self.graph.update_vertex_value(vertex_id, other.clone());
8108                                Ok(other)
8109                            }
8110                        }
8111                    }
8112                    Err(err) => {
8113                        let err_val = LiteralValue::Error(err.clone());
8114                        self.graph.update_vertex_value(vertex_id, err_val.clone());
8115                        Ok(err_val)
8116                    }
8117                }
8118            }
8119            NamedDefinition::Range(_) => Err(ExcelError::new(ExcelErrorKind::Value)
8120                .with_message("Range-valued name evaluated as scalar".to_string())),
8121        }
8122    }
8123
8124    fn evaluate_named_array(
8125        &mut self,
8126        vertex_id: VertexId,
8127        sheet_id: SheetId,
8128    ) -> Result<LiteralValue, ExcelError> {
8129        let named_range = self.graph.named_range_by_vertex(vertex_id).ok_or_else(|| {
8130            ExcelError::new(ExcelErrorKind::Name)
8131                .with_message("Named range metadata missing".to_string())
8132        })?;
8133
8134        let out = match &named_range.definition {
8135            NamedDefinition::Range(range_ref) => {
8136                if range_ref.start.sheet_id != range_ref.end.sheet_id {
8137                    return Err(ExcelError::new(ExcelErrorKind::Ref)
8138                        .with_message("Named range cannot span sheets".to_string()));
8139                }
8140
8141                let sheet_name = self.graph.sheet_name(range_ref.start.sheet_id);
8142                let sr0 = range_ref.start.coord.row();
8143                let sc0 = range_ref.start.coord.col();
8144                let er0 = range_ref.end.coord.row();
8145                let ec0 = range_ref.end.coord.col();
8146                if sr0 > er0 || sc0 > ec0 {
8147                    return Err(ExcelError::new(ExcelErrorKind::Ref)
8148                        .with_message("Invalid named range bounds".to_string()));
8149                }
8150
8151                let h = (er0 - sr0 + 1) as usize;
8152                let w = (ec0 - sc0 + 1) as usize;
8153                let cell_count = (h as u64).saturating_mul(w as u64);
8154                if cell_count > self.config.spill.max_spill_cells as u64 {
8155                    return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
8156                        "Named range too large to materialize as an array".to_string(),
8157                    ));
8158                }
8159
8160                let mut rows = Vec::with_capacity(h);
8161                for r0 in sr0..=er0 {
8162                    let mut row = Vec::with_capacity(w);
8163                    for c0 in sc0..=ec0 {
8164                        let v = self
8165                            .get_cell_value(sheet_name, r0 + 1, c0 + 1)
8166                            .unwrap_or(LiteralValue::Empty);
8167                        row.push(v);
8168                    }
8169                    rows.push(row);
8170                }
8171                LiteralValue::Array(rows)
8172            }
8173            NamedDefinition::Cell(cell_ref) => {
8174                let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
8175                let row = cell_ref.coord.row() + 1;
8176                let col = cell_ref.coord.col() + 1;
8177                let v = self
8178                    .get_cell_value(sheet_name, row, col)
8179                    .unwrap_or(LiteralValue::Empty);
8180                LiteralValue::Array(vec![vec![v]])
8181            }
8182            NamedDefinition::Literal(v) => LiteralValue::Array(vec![vec![v.clone()]]),
8183            NamedDefinition::Formula { ast, .. } => {
8184                let context_sheet = match named_range.scope {
8185                    NameScope::Sheet(id) => id,
8186                    NameScope::Workbook => sheet_id,
8187                };
8188                let sheet_name = self.graph.sheet_name(context_sheet);
8189                let cell_ref = self
8190                    .graph
8191                    .get_cell_ref(vertex_id)
8192                    .unwrap_or_else(|| self.graph.make_cell_ref(sheet_name, 0, 0));
8193                let interpreter = Interpreter::new_with_cell(self, sheet_name, cell_ref);
8194                match interpreter.evaluate_ast(ast) {
8195                    Ok(cv) => {
8196                        let v = cv.into_literal();
8197                        match v {
8198                            LiteralValue::Array(_) => v,
8199                            other => LiteralValue::Array(vec![vec![other]]),
8200                        }
8201                    }
8202                    Err(err) => LiteralValue::Error(err),
8203                }
8204            }
8205        };
8206
8207        self.graph.update_vertex_value(vertex_id, out.clone());
8208        Ok(out)
8209    }
8210
8211    /// Evaluate only the necessary precedents for specific target cells (demand-driven)
8212    pub fn evaluate_until(
8213        &mut self,
8214        targets: &[(&str, u32, u32)],
8215    ) -> Result<EvalResult, ExcelError> {
8216        #[cfg(feature = "tracing")]
8217        let _span_eval = tracing::info_span!("evaluate_until", targets = targets.len()).entered();
8218        let start = crate::instant::FzInstant::now();
8219        self.begin_evaluation_request();
8220        // Fold any pending edge deltas once so scheduling/eval reads use the
8221        // zero-allocation CSR slices (#125 write-cheap / read-flush split).
8222        self.graph.flush_pending_edge_deltas();
8223        let _source_cache = self.source_cache_session();
8224        if self.graph.formula_authority().active_span_count() > 0 {
8225            return self.evaluate_authoritative_formula_plane_all();
8226        }
8227
8228        // Parse target cell addresses
8229        let mut target_addrs = Vec::new();
8230        for (sheet, row, col) in targets {
8231            // For now, assume simple A1-style references on default sheet
8232            // TODO: Parse complex references with sheets
8233            let sheet_id = self.graph.sheet_id_mut(sheet);
8234            let coord = Coord::from_excel(*row, *col, true, true);
8235            target_addrs.push(CellRef::new(sheet_id, coord));
8236        }
8237
8238        // Find vertex IDs for targets
8239        let mut target_vertex_ids = Vec::new();
8240        for addr in &target_addrs {
8241            if let Some(vertex_id) = self.graph.get_vertex_id_for_address(addr) {
8242                target_vertex_ids.push(*vertex_id);
8243            }
8244        }
8245
8246        if target_vertex_ids.is_empty() {
8247            return Ok(EvalResult {
8248                computed_vertices: 0,
8249                cycle_errors: 0,
8250                elapsed: start.elapsed(),
8251            });
8252        }
8253
8254        // Build demand subgraph with virtual edges for compressed ranges
8255        #[cfg(feature = "tracing")]
8256        let _span_sub = tracing::info_span!("demand_subgraph_build").entered();
8257        let (precedents_to_eval, vdeps) = self.build_demand_subgraph(&target_vertex_ids);
8258        #[cfg(feature = "tracing")]
8259        drop(_span_sub);
8260
8261        if precedents_to_eval.is_empty() {
8262            return Ok(EvalResult {
8263                computed_vertices: 0,
8264                cycle_errors: 0,
8265                elapsed: start.elapsed(),
8266            });
8267        }
8268
8269        // Create schedule for the minimal subgraph, honoring virtual edges
8270        let scheduler = Scheduler::new(&self.graph);
8271        #[cfg(feature = "tracing")]
8272        let _span_sched =
8273            tracing::info_span!("schedule_build", vertices = precedents_to_eval.len()).entered();
8274        let schedule = scheduler.create_schedule_with_virtual(&precedents_to_eval, &vdeps)?;
8275        #[cfg(feature = "tracing")]
8276        drop(_span_sched);
8277
8278        // Walk schedule units in condensation order: stamp each cyclic SCC at
8279        // its position, evaluate layers (parallel when enabled, mirroring
8280        // evaluate_all).
8281        let mut cycle_errors = 0;
8282        let mut computed_vertices = 0;
8283        for &unit in &schedule.units {
8284            match unit {
8285                ScheduleUnit::Cycle(i) => {
8286                    if self.handle_cycle_unit(schedule.unit_cycle(i), None, None, None)? > 0 {
8287                        cycle_errors += 1;
8288                    }
8289                }
8290                ScheduleUnit::Layer(i) => {
8291                    let layer = schedule.unit_layer(i);
8292                    if self.thread_pool.is_some() && layer.vertices.len() > 1 {
8293                        computed_vertices += self.evaluate_layer_parallel(layer)?;
8294                    } else {
8295                        computed_vertices += self.evaluate_layer_sequential(layer)?;
8296                    }
8297                }
8298            }
8299        }
8300
8301        // Clear warmup context at end of evaluation
8302
8303        // Clear dirty flags for evaluated vertices
8304        self.graph.clear_dirty_flags(&precedents_to_eval);
8305
8306        // Re-dirty volatile vertices
8307        self.redirty_for_next_recalc();
8308
8309        Ok(EvalResult {
8310            computed_vertices,
8311            cycle_errors,
8312            elapsed: start.elapsed(),
8313        })
8314    }
8315
8316    fn evaluate_until_with_delta_collector(
8317        &mut self,
8318        targets: &[(&str, u32, u32)],
8319        delta: &mut DeltaCollector,
8320    ) -> Result<EvalResult, ExcelError> {
8321        #[cfg(feature = "tracing")]
8322        let _span_eval =
8323            tracing::info_span!("evaluate_until_with_delta", targets = targets.len()).entered();
8324        let start = crate::instant::FzInstant::now();
8325        self.begin_evaluation_request();
8326        self.graph.flush_pending_edge_deltas();
8327        let _source_cache = self.source_cache_session();
8328
8329        let mut target_addrs = Vec::new();
8330        for (sheet, row, col) in targets {
8331            let sheet_id = self.graph.sheet_id_mut(sheet);
8332            let coord = Coord::from_excel(*row, *col, true, true);
8333            target_addrs.push(CellRef::new(sheet_id, coord));
8334        }
8335
8336        let mut target_vertex_ids = Vec::new();
8337        for addr in &target_addrs {
8338            if let Some(vertex_id) = self.graph.get_vertex_id_for_address(addr) {
8339                target_vertex_ids.push(*vertex_id);
8340            }
8341        }
8342
8343        if target_vertex_ids.is_empty() {
8344            return Ok(EvalResult {
8345                computed_vertices: 0,
8346                cycle_errors: 0,
8347                elapsed: start.elapsed(),
8348            });
8349        }
8350
8351        let (precedents_to_eval, vdeps) = self.build_demand_subgraph(&target_vertex_ids);
8352
8353        if precedents_to_eval.is_empty() {
8354            return Ok(EvalResult {
8355                computed_vertices: 0,
8356                cycle_errors: 0,
8357                elapsed: start.elapsed(),
8358            });
8359        }
8360
8361        let scheduler = Scheduler::new(&self.graph);
8362        let schedule = scheduler.create_schedule_with_virtual(&precedents_to_eval, &vdeps)?;
8363
8364        let mut cycle_errors = 0;
8365        let mut computed_vertices = 0;
8366        for &unit in &schedule.units {
8367            match unit {
8368                ScheduleUnit::Cycle(i) => {
8369                    if self.handle_cycle_unit(schedule.unit_cycle(i), Some(delta), None, None)? > 0
8370                    {
8371                        cycle_errors += 1;
8372                    }
8373                }
8374                ScheduleUnit::Layer(i) => {
8375                    let layer = schedule.unit_layer(i);
8376                    if self.thread_pool.is_some() && layer.vertices.len() > 1 {
8377                        computed_vertices +=
8378                            self.evaluate_layer_parallel_with_delta(layer, delta)?;
8379                    } else {
8380                        computed_vertices +=
8381                            self.evaluate_layer_sequential_with_delta(layer, delta)?;
8382                    }
8383                }
8384            }
8385        }
8386
8387        self.graph.clear_dirty_flags(&precedents_to_eval);
8388        self.redirty_for_next_recalc();
8389
8390        Ok(EvalResult {
8391            computed_vertices,
8392            cycle_errors,
8393            elapsed: start.elapsed(),
8394        })
8395    }
8396
8397    /// Build a reusable evaluation plan that covers every formula vertex in the workbook.
8398    pub fn build_recalc_plan(&self) -> Result<RecalcPlan, ExcelError> {
8399        let mut vertices: Vec<VertexId> = self.graph.vertices_with_formulas().collect();
8400        vertices.sort_unstable();
8401        if vertices.is_empty() {
8402            return Ok(RecalcPlan {
8403                schedule: crate::engine::Schedule {
8404                    units: Vec::new(),
8405                    layers: Vec::new(),
8406                    cycles: Vec::new(),
8407                },
8408                has_dynamic_refs: false,
8409            });
8410        }
8411
8412        let has_dynamic_refs = vertices.iter().copied().any(|v| self.graph.is_dynamic(v));
8413        let (schedule, _, _) = self.create_evaluation_schedule_uncached(&vertices)?;
8414        Ok(RecalcPlan {
8415            schedule,
8416            has_dynamic_refs,
8417        })
8418    }
8419
8420    /// Evaluate using a previously constructed plan. This avoids rebuilding layer schedules for each run.
8421    pub fn evaluate_recalc_plan(&mut self, plan: &RecalcPlan) -> Result<EvalResult, ExcelError> {
8422        self.begin_evaluation_request();
8423        let _source_cache = self.source_cache_session();
8424        self.validate_deterministic_mode()?;
8425        if self.config.defer_graph_building {
8426            self.build_graph_all()?;
8427        }
8428        if self.graph.formula_authority().active_span_count() > 0 {
8429            return self.evaluate_authoritative_formula_plane_all();
8430        }
8431
8432        let start = crate::instant::FzInstant::now();
8433        let dirty_vertices = self.graph.get_evaluation_vertices();
8434        if dirty_vertices.is_empty() {
8435            return Ok(EvalResult {
8436                computed_vertices: 0,
8437                cycle_errors: 0,
8438                elapsed: start.elapsed(),
8439            });
8440        }
8441
8442        // Dynamic-reference formulas (INDIRECT/OFFSET-class) require per-pass virtual-dep
8443        // augmentation. Reuse the direct recalc flow to preserve semantic parity.
8444        if plan.has_dynamic_refs {
8445            self.virtual_dep_fallback_activations =
8446                self.virtual_dep_fallback_activations.saturating_add(1);
8447            return self.evaluate_all();
8448        }
8449
8450        let dirty_set: FxHashSet<VertexId> = dirty_vertices.iter().copied().collect();
8451        let mut computed_vertices = 0;
8452        let mut cycle_errors = 0;
8453
8454        for &unit in &plan.schedule.units {
8455            match unit {
8456                ScheduleUnit::Cycle(i) => {
8457                    // Recalc-plan quirk (Static): stamp only the DIRTY members
8458                    // of the cycle, and count the cycle only when it had any.
8459                    // Under Runtime the filter means: skip when no member is
8460                    // dirty, evaluate the whole SCC when any is.
8461                    let stamped = self.handle_cycle_unit(
8462                        plan.schedule.unit_cycle(i),
8463                        None,
8464                        Some(&dirty_set),
8465                        None,
8466                    )?;
8467                    if stamped > 0 {
8468                        cycle_errors += 1;
8469                    }
8470                }
8471                ScheduleUnit::Layer(i) => {
8472                    let work: Vec<VertexId> = plan
8473                        .schedule
8474                        .unit_layer(i)
8475                        .vertices
8476                        .iter()
8477                        .copied()
8478                        .filter(|v| dirty_set.contains(v))
8479                        .collect();
8480                    if work.is_empty() {
8481                        continue;
8482                    }
8483                    let temp_layer = crate::engine::scheduler::Layer { vertices: work };
8484                    if self.thread_pool.is_some() && temp_layer.vertices.len() > 1 {
8485                        computed_vertices += self.evaluate_layer_parallel(&temp_layer)?;
8486                    } else {
8487                        computed_vertices += self.evaluate_layer_sequential(&temp_layer)?;
8488                    }
8489                }
8490            }
8491        }
8492
8493        self.graph.clear_dirty_flags(&dirty_vertices);
8494        self.redirty_for_next_recalc();
8495
8496        Ok(EvalResult {
8497            computed_vertices,
8498            cycle_errors,
8499            elapsed: start.elapsed(),
8500        })
8501    }
8502    fn evaluate_authoritative_formula_plane_all(&mut self) -> Result<EvalResult, ExcelError> {
8503        // Fresh per-request cycle counters. Some callers (`evaluate_vertex`,
8504        // `evaluate_cells*`) reach this coordinator without an entry-point
8505        // reset; callers that did reset have accumulated nothing in between,
8506        // so the duplicate reset is harmless. The composed legacy primitive
8507        // below intentionally does not reset, so `evaluate_legacy_cycle_prepass`
8508        // counts survive into the final telemetry.
8509        self.begin_evaluation_request();
8510        // The FormulaPlane coordinator is now selected by mode for evaluate_all.
8511        // SingletonUnique formulas intentionally remain legacy graph vertices;
8512        // when no spans are active, execute through the private legacy primitive
8513        // rather than the public legacy entry path.
8514        if self.graph.formula_authority().active_span_count() == 0 {
8515            #[cfg(test)]
8516            {
8517                self.last_formula_plane_span_eval_report = None;
8518            }
8519            return self.evaluate_all_legacy_impl();
8520        }
8521
8522        // Decide span work seeding strategy: any active span we have not yet
8523        // evaluated under the current authority indexes generation must run
8524        // whole; subsequent passes use bounded dirty closures derived from
8525        // captured changed regions.
8526        let current_indexes_epoch = self.graph.formula_authority().indexes_epoch();
8527        let span_seed_mode = if self.formula_plane_indexes_epoch_seen != current_indexes_epoch {
8528            SpanSeedMode::WholeAll
8529        } else {
8530            SpanSeedMode::DirtyClosure
8531        };
8532        // Take pending regions out of the authority so subsequent reschedules
8533        // start from a clean slate after a successful eval pass.
8534        let pending_changed_regions = self
8535            .graph
8536            .formula_authority_mut()
8537            .take_pending_changed_regions();
8538
8539        // Steady-state shortcut: in `DirtyClosure` mode span work is derived
8540        // exclusively from pending changed regions, so with none pending the
8541        // mixed schedule could only ever contain dirty legacy vertices (e.g.
8542        // re-dirtied volatiles). Skip the O(all formula vertices)
8543        // producer/consumer index rebuild and run them through the legacy
8544        // primitive directly — identical evaluation set, with the legacy
8545        // path's native cycle/virtual-dep handling.
8546        if matches!(span_seed_mode, SpanSeedMode::DirtyClosure)
8547            && pending_changed_regions.is_empty()
8548        {
8549            #[cfg(test)]
8550            {
8551                self.last_formula_plane_span_eval_report = None;
8552            }
8553            return self.evaluate_all_legacy_impl();
8554        }
8555
8556        let start = crate::instant::FzInstant::now();
8557        let mut span_seed_mode = span_seed_mode;
8558        let mut pending_changed_regions = pending_changed_regions;
8559        // #CIRC stamps produced by demoting cyclic spans and resolving the
8560        // residual legacy-only cycle ahead of the mixed schedule (gotcha G8).
8561        let mut prepass_cycle_errors = 0usize;
8562        const MAX_CYCLE_DEMOTE_ITERS: usize = 64;
8563        let mut cycle_demote_iters = 0usize;
8564        let (schedule, span_refs_by_id, plane_epoch, legacy_vertices) = loop {
8565            let (schedule, span_refs_by_id, plane_epoch, legacy_vertices) =
8566                self.build_formula_plane_mixed_schedule(span_seed_mode, &pending_changed_regions)?;
8567
8568            if schedule.is_authoritative_safe() {
8569                break (schedule, span_refs_by_id, plane_epoch, legacy_vertices);
8570            }
8571
8572            // The demote loop below can only make progress on cycles: it
8573            // demotes cyclic spans and stamps residual legacy-only cycles.
8574            // Every other fallback reason (capacity caps, unsupported
8575            // projections, missing result regions) is a property of the
8576            // inputs — rebuilding the schedule from identical state
8577            // reproduces the identical fallback, so iterating would spin
8578            // `MAX_CYCLE_DEMOTE_ITERS` times doing O(graph) schedule builds
8579            // per iteration before giving up anyway. Fail over to the legacy
8580            // primitive immediately instead.
8581            let has_cycle_fallback = schedule.stats.cycle_count > 0
8582                || schedule
8583                    .fallbacks
8584                    .iter()
8585                    .any(|fb| fb.reason == MixedScheduleFallbackReason::CycleDetected);
8586            if !has_cycle_fallback {
8587                self.formula_plane_capacity_bailouts =
8588                    self.formula_plane_capacity_bailouts.saturating_add(1);
8589                #[cfg(test)]
8590                {
8591                    self.last_formula_plane_span_eval_report = None;
8592                }
8593                return self.evaluate_all_legacy_impl();
8594            }
8595
8596            // Gotcha G8 (refs #112): a span whose member cell participates in a
8597            // statically-cyclic SCC must never be span-evaluated. Cross-cell
8598            // cycles that route through a span producer are invisible to the
8599            // legacy Tarjan pass (the span member has no graph vertex) and only
8600            // surface here, as `CycleDetected` fallbacks in the producer-bounded
8601            // mixed schedule. Demote the cyclic spans to legacy graph vertices
8602            // so the cycle members move onto the legacy SCC path, then resolve
8603            // the now legacy-only cycle ahead of the schedule and rebuild.
8604            // Spans that do not touch the cycle are left untouched.
8605            let cyclic_spans = self.collect_cyclic_span_refs(&schedule, &span_refs_by_id);
8606            if !cyclic_spans.is_empty() {
8607                self.demote_cyclic_spans(&cyclic_spans)?;
8608            }
8609
8610            if self.graph.formula_authority().active_span_count() == 0 {
8611                // All spans demoted; nothing left for the FP coordinator. The
8612                // legacy evaluator resolves the (now fully legacy) cycle.
8613                return self.evaluate_all_legacy_impl();
8614            }
8615
8616            // Resolve the residual legacy-only cycle (`handle_cycle_unit`
8617            // honors Static vs Runtime) before rebuilding so the mixed schedule
8618            // is cycle-free and the surviving spans still get evaluated.
8619            prepass_cycle_errors =
8620                prepass_cycle_errors.saturating_add(self.evaluate_legacy_cycle_prepass()?);
8621
8622            // Re-seed every surviving span whole after the geometry/dirty
8623            // changes; the demotion already reset
8624            // `formula_plane_indexes_epoch_seen` to 0.
8625            span_seed_mode = SpanSeedMode::WholeAll;
8626            pending_changed_regions = self
8627                .graph
8628                .formula_authority_mut()
8629                .take_pending_changed_regions();
8630
8631            cycle_demote_iters += 1;
8632            if cycle_demote_iters >= MAX_CYCLE_DEMOTE_ITERS {
8633                // Defensive bound: every iteration either demotes ≥1 span or
8634                // stamps the legacy cycle, both strictly reducing residual work.
8635                // If we somehow fail to converge, fall back to pure legacy to
8636                // stay correct rather than spin.
8637                return self.evaluate_all_legacy_impl();
8638            }
8639        };
8640
8641        let mut computed_vertices = 0usize;
8642        #[cfg(test)]
8643        {
8644            self.last_formula_plane_span_eval_report = None;
8645        }
8646        for layer in schedule.layers {
8647            let mut buffer = ComputedWriteBuffer::default();
8648            let mut sink = SpanComputedWriteSink::new(&mut buffer);
8649            let work_items = layer.work;
8650            let mut work_index = 0usize;
8651            while work_index < work_items.len() {
8652                match work_items[work_index].producer {
8653                    FormulaProducerId::Span(span_id) => {
8654                        let span_ref = *span_refs_by_id.get(&span_id).ok_or_else(|| {
8655                            ExcelError::new(ExcelErrorKind::NImpl)
8656                                .with_message("FormulaPlane schedule referenced a stale span")
8657                        })?;
8658                        let sheet_id = {
8659                            let authority = self.graph.formula_authority();
8660                            let span = authority.plane.spans.get(span_ref).ok_or_else(|| {
8661                                ExcelError::new(ExcelErrorKind::NImpl)
8662                                    .with_message("FormulaPlane schedule referenced a stale span")
8663                            })?;
8664                            span.sheet_id
8665                        };
8666                        let current_sheet = self.graph.sheet_name(sheet_id);
8667                        let authority = self.graph.formula_authority();
8668                        let evaluator = SpanEvaluator::new(
8669                            &authority.plane,
8670                            self,
8671                            current_sheet,
8672                            self.graph.data_store(),
8673                            self.graph.sheet_reg(),
8674                        );
8675                        #[cfg(test)]
8676                        let mut last_group_report = None;
8677                        while work_index < work_items.len() {
8678                            let FormulaProducerId::Span(group_span_id) =
8679                                work_items[work_index].producer
8680                            else {
8681                                break;
8682                            };
8683                            let group_span_ref =
8684                                *span_refs_by_id.get(&group_span_id).ok_or_else(|| {
8685                                    ExcelError::new(ExcelErrorKind::NImpl).with_message(
8686                                        "FormulaPlane schedule referenced a stale span",
8687                                    )
8688                                })?;
8689                            let group_sheet_id = {
8690                                let authority = self.graph.formula_authority();
8691                                let span =
8692                                    authority.plane.spans.get(group_span_ref).ok_or_else(|| {
8693                                        ExcelError::new(ExcelErrorKind::NImpl).with_message(
8694                                            "FormulaPlane schedule referenced a stale span",
8695                                        )
8696                                    })?;
8697                                span.sheet_id
8698                            };
8699                            if group_sheet_id != sheet_id {
8700                                break;
8701                            }
8702
8703                            let dirty = producer_dirty_to_span_dirty(
8704                                work_items[work_index].dirty.clone(),
8705                                group_span_ref,
8706                            );
8707                            let task = SpanEvalTask {
8708                                span: group_span_ref,
8709                                dirty,
8710                                plane_epoch,
8711                            };
8712                            let report =
8713                                evaluator.evaluate_task(&task, &mut sink).map_err(|err| {
8714                                    ExcelError::new(ExcelErrorKind::NImpl).with_message(format!(
8715                                        "FormulaPlane span evaluation failed: {err:?}"
8716                                    ))
8717                                })?;
8718                            #[cfg(test)]
8719                            {
8720                                last_group_report = Some(report.clone());
8721                            }
8722                            computed_vertices = computed_vertices
8723                                .saturating_add(report.span_eval_placement_count as usize);
8724                            work_index = work_index.saturating_add(1);
8725                        }
8726                        #[cfg(test)]
8727                        {
8728                            if let Some(report) = last_group_report {
8729                                self.last_formula_plane_span_eval_report = Some(report);
8730                            }
8731                        }
8732                    }
8733                    FormulaProducerId::Legacy(_) => {
8734                        // Batch the contiguous run of legacy work items into a
8735                        // synthetic layer and evaluate it through the same
8736                        // coalesced effects pipeline as the legacy scheduler.
8737                        // Items in one mixed layer have no edges between them
8738                        // (same invariant the legacy Kahn layers rely on), so
8739                        // batching preserves ordering semantics while
8740                        // amortizing per-write overlay mirroring that makes
8741                        // one-vertex-at-a-time evaluation ~30x slower.
8742                        let mut vertices = Vec::new();
8743                        while work_index < work_items.len() {
8744                            let FormulaProducerId::Legacy(vertex_id) =
8745                                work_items[work_index].producer
8746                            else {
8747                                break;
8748                            };
8749                            vertices.push(vertex_id);
8750                            work_index = work_index.saturating_add(1);
8751                        }
8752                        let legacy_layer = crate::engine::scheduler::Layer { vertices };
8753                        let evaluated =
8754                            if self.thread_pool.is_some() && legacy_layer.vertices.len() > 1 {
8755                                self.evaluate_layer_parallel(&legacy_layer)?
8756                            } else {
8757                                self.evaluate_layer_sequential(&legacy_layer)?
8758                            };
8759                        computed_vertices = computed_vertices.saturating_add(evaluated);
8760                    }
8761                }
8762            }
8763            self.flush_computed_write_buffer(&mut buffer)?;
8764        }
8765
8766        self.graph.clear_dirty_flags(&legacy_vertices);
8767        // Drop dirty flags on any newly-scheduled FP runtime cells whose graph
8768        // vertices weren't in the dirty subset (e.g. recently-introduced span
8769        // result cells); legacy clear_dirty_flags is safe over the full set.
8770        self.redirty_for_next_recalc();
8771        // Mark this indexes-epoch as fully evaluated so subsequent passes can
8772        // use bounded span dirty closures rather than whole-span work.
8773        self.formula_plane_indexes_epoch_seen = self.graph.formula_authority().indexes_epoch();
8774        self.recalc_epoch = self.recalc_epoch.wrapping_add(1);
8775        Ok(EvalResult {
8776            computed_vertices,
8777            cycle_errors: prepass_cycle_errors,
8778            elapsed: start.elapsed(),
8779        })
8780    }
8781
8782    fn build_formula_plane_mixed_schedule(
8783        &self,
8784        span_seed_mode: SpanSeedMode,
8785        pending_changed_regions: &[Region],
8786    ) -> Result<FormulaPlaneMixedScheduleBuild, ExcelError> {
8787        let authority = self.graph.formula_authority();
8788        let mut producer_results = FormulaProducerResultIndex::default();
8789        let mut consumer_reads = FormulaConsumerReadIndex::default();
8790        let mut work = Vec::new();
8791
8792        // Legacy formula producers participate in the mixed runtime only when
8793        // they are dirty under graph semantics. Result/read indexes still cover
8794        // every legacy formula so that span->legacy and legacy->span ordering is
8795        // visible to the scheduler regardless of dirty status, but only dirty
8796        // vertices receive scheduled work.
8797        let dirty_legacy: rustc_hash::FxHashSet<VertexId> =
8798            self.graph.get_evaluation_vertices().into_iter().collect();
8799
8800        let span_refs = authority.active_span_refs();
8801        let span_refs_by_id = span_refs
8802            .iter()
8803            .copied()
8804            .map(|span_ref| (span_ref.id, span_ref))
8805            .collect::<BTreeMap<_, _>>();
8806        for span_ref in &span_refs {
8807            let span = authority.plane.spans.get(*span_ref).ok_or_else(|| {
8808                ExcelError::new(ExcelErrorKind::NImpl)
8809                    .with_message("FormulaPlane active span ref is stale")
8810            })?;
8811            let result_region = Region::from_domain(span.result_region.domain());
8812            producer_results.insert_producer(FormulaProducerId::Span(span.id), result_region);
8813            let Some(read_summary_id) = span.read_summary_id else {
8814                return Err(ExcelError::new(ExcelErrorKind::NImpl)
8815                    .with_message("FormulaPlane active span is missing read summary"));
8816            };
8817            let Some(read_summary) = authority.plane.span_read_summaries.get(read_summary_id)
8818            else {
8819                return Err(ExcelError::new(ExcelErrorKind::NImpl)
8820                    .with_message("FormulaPlane active span has stale read summary"));
8821            };
8822            if read_summary.result_region != result_region {
8823                return Err(ExcelError::new(ExcelErrorKind::NImpl)
8824                    .with_message("FormulaPlane active span read summary is stale"));
8825            }
8826            for dependency in &read_summary.dependencies {
8827                consumer_reads.insert_read(
8828                    FormulaProducerId::Span(span.id),
8829                    dependency.read_region,
8830                    read_summary.result_region,
8831                    dependency.projection,
8832                );
8833            }
8834            if matches!(span_seed_mode, SpanSeedMode::WholeAll) {
8835                work.push(FormulaProducerWork {
8836                    producer: FormulaProducerId::Span(span.id),
8837                    dirty: ProducerDirtyDomain::Whole,
8838                });
8839            }
8840        }
8841
8842        let legacy_vertices = self.graph.formula_vertices();
8843        let mut scheduled_legacy_vertices = Vec::new();
8844        for vertex in &legacy_vertices {
8845            let Some(cell) = self.graph.get_cell_ref_for_vertex(*vertex) else {
8846                continue;
8847            };
8848            let result_region = Region::point(cell.sheet_id, cell.coord.row(), cell.coord.col());
8849            producer_results.insert_producer(FormulaProducerId::Legacy(*vertex), result_region);
8850            if dirty_legacy.contains(vertex) {
8851                scheduled_legacy_vertices.push(*vertex);
8852                work.push(FormulaProducerWork {
8853                    producer: FormulaProducerId::Legacy(*vertex),
8854                    dirty: ProducerDirtyDomain::Whole,
8855                });
8856            }
8857        }
8858
8859        for vertex in &legacy_vertices {
8860            let Some(cell) = self.graph.get_cell_ref_for_vertex(*vertex) else {
8861                continue;
8862            };
8863            let result_region = Region::point(cell.sheet_id, cell.coord.row(), cell.coord.col());
8864            let mut seen = rustc_hash::FxHashSet::default();
8865            for dep in self.graph.get_dependencies(*vertex) {
8866                let Some(dep_cell) = self.graph.get_cell_ref_for_vertex(dep) else {
8867                    continue;
8868                };
8869                let read_region = Region::point(
8870                    dep_cell.sheet_id,
8871                    dep_cell.coord.row(),
8872                    dep_cell.coord.col(),
8873                );
8874                if seen.insert(read_region) {
8875                    consumer_reads.insert_read(
8876                        FormulaProducerId::Legacy(*vertex),
8877                        read_region,
8878                        result_region,
8879                        DirtyProjectionRule::WholeResult,
8880                    );
8881                }
8882            }
8883            if let Some(ranges) = self.graph.get_range_dependencies(*vertex) {
8884                for range in ranges {
8885                    let Some(read_region) = self.shared_range_to_region_pattern(range)? else {
8886                        continue;
8887                    };
8888                    if seen.insert(read_region) {
8889                        consumer_reads.insert_read(
8890                            FormulaProducerId::Legacy(*vertex),
8891                            read_region,
8892                            result_region,
8893                            DirtyProjectionRule::WholeResult,
8894                        );
8895                    }
8896                }
8897            }
8898        }
8899
8900        // When span seed mode is DirtyClosure, derive bounded span work from
8901        // captured changed regions via the consumer-read index. This avoids
8902        // recomputing every active span on edits that only touch a small
8903        // number of cells.
8904        if matches!(span_seed_mode, SpanSeedMode::DirtyClosure)
8905            && !pending_changed_regions.is_empty()
8906        {
8907            use crate::formula_plane::producer::compute_dirty_closure;
8908            let producer_results_ref = &producer_results;
8909            let closure = compute_dirty_closure(
8910                &consumer_reads,
8911                pending_changed_regions.iter().copied(),
8912                |producer| producer_results_ref.producer_result_region(producer),
8913            );
8914            for fallback_work in closure.work {
8915                work.push(fallback_work);
8916            }
8917            // Any unsupported/conservative fallbacks for spans imply we may have
8918            // missed work; in that case demote to whole-span for affected spans.
8919            if !closure.fallbacks.is_empty() {
8920                let mut already_whole: rustc_hash::FxHashSet<_> = work
8921                    .iter()
8922                    .filter_map(|w| match (w.producer, &w.dirty) {
8923                        (FormulaProducerId::Span(id), ProducerDirtyDomain::Whole) => Some(id),
8924                        _ => None,
8925                    })
8926                    .collect();
8927                for fb in &closure.fallbacks {
8928                    if let FormulaProducerId::Span(id) = fb.consumer
8929                        && already_whole.insert(id)
8930                    {
8931                        work.push(FormulaProducerWork {
8932                            producer: FormulaProducerId::Span(id),
8933                            dirty: ProducerDirtyDomain::Whole,
8934                        });
8935                    }
8936                }
8937            }
8938        }
8939
8940        let schedule = build_mixed_schedule(work, &producer_results, &consumer_reads);
8941        Ok((
8942            schedule,
8943            span_refs_by_id,
8944            authority.plane.epoch().0,
8945            scheduled_legacy_vertices,
8946        ))
8947    }
8948}
8949
8950/// Strategy for seeding span producer work in the FP mixed runtime.
8951/// `WholeAll` schedules every active span as `Whole`; `DirtyClosure`
8952/// computes bounded work from captured changed regions only.
8953#[derive(Clone, Copy, Debug)]
8954enum SpanSeedMode {
8955    WholeAll,
8956    DirtyClosure,
8957}
8958
8959impl<R> Engine<R>
8960where
8961    R: EvaluationContext,
8962{
8963    fn shared_range_to_region_pattern(
8964        &self,
8965        range: &crate::reference::SharedRangeRef<'static>,
8966    ) -> Result<Option<Region>, ExcelError> {
8967        use crate::reference::SharedSheetLocator;
8968        let sheet_id = match range.sheet {
8969            SharedSheetLocator::Id(id) => id,
8970            SharedSheetLocator::Current => self.graph.default_sheet_id(),
8971            SharedSheetLocator::Name(_) => return Ok(None),
8972        };
8973        match (
8974            range.start_row,
8975            range.end_row,
8976            range.start_col,
8977            range.end_col,
8978        ) {
8979            (Some(sr), Some(er), Some(sc), Some(ec)) => Ok(Some(Region::rect(
8980                sheet_id, sr.index, er.index, sc.index, ec.index,
8981            ))),
8982            (None, None, Some(sc), Some(ec)) if sc.index == ec.index => {
8983                Ok(Some(Region::whole_col(sheet_id, sc.index)))
8984            }
8985            (Some(sr), Some(er), None, None) if sr.index == er.index => {
8986                Ok(Some(Region::whole_row(sheet_id, sr.index)))
8987            }
8988            _ => Ok(None),
8989        }
8990    }
8991
8992    /// Evaluate all dirty/volatile vertices
8993    pub fn evaluate_all(&mut self) -> Result<EvalResult, ExcelError> {
8994        debug_assert!(
8995            !self.graph.deferred_dirty_active(),
8996            "deferred-dirty scope leaked into evaluate_all: a begin_deferred_dirty \
8997             was not balanced by end_deferred_dirty"
8998        );
8999        self.lookup_index_cache.reset_counters();
9000        let _source_cache = self.source_cache_session();
9001        self.validate_deterministic_mode()?;
9002        if self.config.defer_graph_building {
9003            // Build graph for all staged formulas before evaluating
9004            self.build_graph_all()?;
9005        }
9006        self.evaluate_all_coordinator()
9007    }
9008
9009    /// Central FormulaPlane-aware coordinator for `evaluate_all`. In
9010    /// `AuthoritativeExperimental` mode every call enters the FormulaPlane
9011    /// coordinator; the coordinator itself composes with private legacy
9012    /// primitives for legacy-only work.
9013    fn evaluate_all_coordinator(&mut self) -> Result<EvalResult, ExcelError> {
9014        self.begin_evaluation_request();
9015        if self.config.formula_plane_mode == FormulaPlaneMode::AuthoritativeExperimental {
9016            return self.evaluate_authoritative_formula_plane_all();
9017        }
9018        self.evaluate_all_legacy_impl()
9019    }
9020
9021    /// Walk a schedule's units in condensation order: stamp each cyclic SCC
9022    /// at its position and evaluate each layer (parallel when enabled).
9023    ///
9024    /// Returns `(computed_vertices, cycle_count)` where `cycle_count` is the
9025    /// number of Cycle units walked (the former `schedule.cycles.len()`).
9026    fn legacy_pass_run_units(
9027        &mut self,
9028        schedule: &crate::engine::scheduler::Schedule,
9029    ) -> Result<(usize, usize), ExcelError> {
9030        let mut computed_vertices = 0;
9031        let mut cycle_count = 0;
9032        for &unit in &schedule.units {
9033            match unit {
9034                ScheduleUnit::Cycle(i) => {
9035                    if self.handle_cycle_unit(schedule.unit_cycle(i), None, None, None)? > 0 {
9036                        cycle_count += 1;
9037                    }
9038                }
9039                ScheduleUnit::Layer(i) => {
9040                    let layer = schedule.unit_layer(i);
9041                    if self.thread_pool.is_some() && layer.vertices.len() > 1 {
9042                        computed_vertices += self.evaluate_layer_parallel(layer)?;
9043                    } else {
9044                        computed_vertices += self.evaluate_layer_sequential(layer)?;
9045                    }
9046                }
9047            }
9048        }
9049        Ok((computed_vertices, cycle_count))
9050    }
9051
9052    /// Legacy `evaluate_all` body, reachable from the FormulaPlane coordinator
9053    /// when no active spans exist or FormulaPlane authority is not in
9054    /// `AuthoritativeExperimental` mode. This is now an internal primitive; it
9055    /// must not be invoked directly from public APIs.
9056    ///
9057    /// Does NOT call `begin_evaluation_request` (cycle-telemetry reset +
9058    /// per-recalc clock sample): the FormulaPlane coordinator composes this
9059    /// primitive *after* `evaluate_legacy_cycle_prepass` may have accumulated
9060    /// counts (G8 demotion path), and both sub-passes belong to ONE request /
9061    /// one clock sample; request begin happens at the public entry points /
9062    /// coordinators instead.
9063    fn evaluate_all_legacy_impl(&mut self) -> Result<EvalResult, ExcelError> {
9064        self.reset_virtual_dep_telemetry_if_disabled();
9065        #[cfg(feature = "tracing")]
9066        let _span_eval = tracing::info_span!("evaluate_all").entered();
9067        let start = crate::instant::FzInstant::now();
9068        let mut computed_vertices = 0;
9069        let mut cycle_errors = 0;
9070        let mut replan_iterations = 0;
9071        const MAX_REPLAN: usize = 5;
9072        let mut telemetry = self
9073            .config
9074            .enable_virtual_dep_telemetry
9075            .then(|| self.start_virtual_dep_telemetry());
9076
9077        loop {
9078            let to_evaluate = self.graph.get_evaluation_vertices();
9079            if to_evaluate.is_empty() {
9080                if let Some(t) = telemetry.as_mut()
9081                    && t.bailout_reason.is_none()
9082                {
9083                    t.bailout_reason = Some("no_work");
9084                }
9085                break;
9086            }
9087
9088            let (schedule, old_vdeps, meta) = self.create_evaluation_schedule(&to_evaluate)?;
9089            if let Some(t) = telemetry.as_mut() {
9090                Self::accumulate_schedule_meta(t, &meta);
9091            }
9092
9093            let (pass_computed, pass_cycles) = self.legacy_pass_run_units(&schedule)?;
9094            computed_vertices += pass_computed;
9095            cycle_errors += pass_cycles;
9096
9097            // Check if dynamic dependencies changed
9098            let changed_vertices = self.changed_virtual_dep_vertices(&to_evaluate, &old_vdeps);
9099            if let Some(t) = telemetry.as_mut() {
9100                t.changed_vdeps_total += changed_vertices.len();
9101            }
9102
9103            self.graph.clear_dirty_flags(&to_evaluate);
9104            for v in &changed_vertices {
9105                self.graph.set_dirty(*v, true);
9106            }
9107
9108            if changed_vertices.is_empty() {
9109                if let Some(t) = telemetry.as_mut() {
9110                    t.bailout_reason = Some("converged");
9111                }
9112                break;
9113            }
9114            if replan_iterations >= MAX_REPLAN {
9115                if let Some(t) = telemetry.as_mut() {
9116                    t.bailout_reason = Some("max_replan");
9117                }
9118                break;
9119            }
9120
9121            replan_iterations += 1;
9122        }
9123
9124        if let Some(mut t) = telemetry {
9125            t.replan_iterations = replan_iterations;
9126            self.last_virtual_dep_telemetry = t;
9127        }
9128
9129        // Re-dirty volatile vertices for the next evaluation cycle
9130        self.redirty_for_next_recalc();
9131
9132        // Advance recalc epoch after a full evaluation pass finishes
9133        self.recalc_epoch = self.recalc_epoch.wrapping_add(1);
9134
9135        Ok(EvalResult {
9136            computed_vertices,
9137            cycle_errors,
9138            elapsed: start.elapsed(),
9139        })
9140    }
9141
9142    pub fn evaluate_all_with_delta(&mut self) -> Result<(EvalResult, EvalDelta), ExcelError> {
9143        let mut collector = DeltaCollector::new(DeltaMode::Cells);
9144        let result = self.evaluate_all_with_delta_collector(&mut collector)?;
9145        Ok((result, collector.finish()))
9146    }
9147
9148    fn evaluate_all_with_delta_collector(
9149        &mut self,
9150        delta: &mut DeltaCollector,
9151    ) -> Result<EvalResult, ExcelError> {
9152        self.begin_evaluation_request();
9153        let _source_cache = self.source_cache_session();
9154        if self.config.defer_graph_building {
9155            self.build_graph_all()?;
9156        }
9157        if self.graph.formula_authority().active_span_count() > 0 {
9158            let _ = delta;
9159            return self.evaluate_authoritative_formula_plane_all();
9160        }
9161        self.reset_virtual_dep_telemetry_if_disabled();
9162        #[cfg(feature = "tracing")]
9163        let _span_eval = tracing::info_span!("evaluate_all_with_delta").entered();
9164        let start = crate::instant::FzInstant::now();
9165        let mut computed_vertices = 0;
9166        let mut cycle_errors = 0;
9167
9168        let mut replan_iterations = 0;
9169        const MAX_REPLAN: usize = 5;
9170        let mut telemetry = self
9171            .config
9172            .enable_virtual_dep_telemetry
9173            .then(|| self.start_virtual_dep_telemetry());
9174
9175        loop {
9176            let to_evaluate = self.graph.get_evaluation_vertices();
9177            if to_evaluate.is_empty() {
9178                if let Some(t) = telemetry.as_mut()
9179                    && t.bailout_reason.is_none()
9180                {
9181                    t.bailout_reason = Some("no_work");
9182                }
9183                break;
9184            }
9185
9186            let (schedule, old_vdeps, meta) = self.create_evaluation_schedule(&to_evaluate)?;
9187            if let Some(t) = telemetry.as_mut() {
9188                Self::accumulate_schedule_meta(t, &meta);
9189            }
9190
9191            for &unit in &schedule.units {
9192                match unit {
9193                    ScheduleUnit::Cycle(i) => {
9194                        if self.handle_cycle_unit(
9195                            schedule.unit_cycle(i),
9196                            Some(delta),
9197                            None,
9198                            None,
9199                        )? > 0
9200                        {
9201                            cycle_errors += 1;
9202                        }
9203                    }
9204                    ScheduleUnit::Layer(i) => {
9205                        let layer = schedule.unit_layer(i);
9206                        if self.thread_pool.is_some() && layer.vertices.len() > 1 {
9207                            computed_vertices +=
9208                                self.evaluate_layer_parallel_with_delta(layer, delta)?;
9209                        } else {
9210                            computed_vertices +=
9211                                self.evaluate_layer_sequential_with_delta(layer, delta)?;
9212                        }
9213                    }
9214                }
9215            }
9216
9217            let changed_vertices = self.changed_virtual_dep_vertices(&to_evaluate, &old_vdeps);
9218            if let Some(t) = telemetry.as_mut() {
9219                t.changed_vdeps_total += changed_vertices.len();
9220            }
9221            self.graph.clear_dirty_flags(&to_evaluate);
9222            for v in &changed_vertices {
9223                self.graph.set_dirty(*v, true);
9224            }
9225
9226            if changed_vertices.is_empty() {
9227                if let Some(t) = telemetry.as_mut() {
9228                    t.bailout_reason = Some("converged");
9229                }
9230                break;
9231            }
9232            if replan_iterations >= MAX_REPLAN {
9233                if let Some(t) = telemetry.as_mut() {
9234                    t.bailout_reason = Some("max_replan");
9235                }
9236                break;
9237            }
9238            replan_iterations += 1;
9239        }
9240
9241        if let Some(mut t) = telemetry {
9242            t.replan_iterations = replan_iterations;
9243            self.last_virtual_dep_telemetry = t;
9244        }
9245
9246        self.redirty_for_next_recalc();
9247        self.recalc_epoch = self.recalc_epoch.wrapping_add(1);
9248
9249        Ok(EvalResult {
9250            computed_vertices,
9251            cycle_errors,
9252            elapsed: start.elapsed(),
9253        })
9254    }
9255
9256    /// Convenience: demand-driven evaluation of a single cell by sheet name and row/col.
9257    ///
9258    /// This will evaluate only the minimal set of dirty / volatile precedents required
9259    /// to bring the target cell up-to-date (as if a user asked for that single value),
9260    /// rather than scheduling a full workbook recalc. If the cell is already clean and
9261    /// non-volatile, no vertices will be recomputed.
9262    ///
9263    /// Returns the (possibly newly computed) value stored for the cell afterwards.
9264    /// Empty cells return None. Errors are surfaced via the Result type.
9265    pub fn evaluate_cell(
9266        &mut self,
9267        sheet: &str,
9268        row: u32,
9269        col: u32,
9270    ) -> Result<Option<LiteralValue>, ExcelError> {
9271        if row == 0 || col == 0 {
9272            return Err(ExcelError::new(ExcelErrorKind::Ref)
9273                .with_message("Row and column must be >= 1".to_string()));
9274        }
9275
9276        // ``defer_graph_building`` mode stages formulas during bulk load
9277        // and lazily promotes them into the dependency graph at evaluate
9278        // time. Per-cell evaluation must drain *all* staged sheets, not
9279        // just the requested target — a cell's formula can reference
9280        // any sheet in the workbook, and a cross-sheet ref to a still-
9281        // staged source would silently evaluate to ``None`` if that
9282        // source sheet hadn't been promoted yet.
9283        if self.config.defer_graph_building {
9284            self.build_graph_all()?;
9285        }
9286
9287        let result = self.evaluate_cells(&[(sheet, row, col)])?;
9288
9289        match result.len() {
9290            0 => Ok(None),
9291            1 => {
9292                let v = result.into_iter().next().unwrap();
9293                Ok(v)
9294            }
9295            _ => unreachable!("evaluate_cells returned unexpected length"),
9296        }
9297    }
9298
9299    /// Convenience: demand-driven evaluation of multiple cells; accepts a slice of
9300    /// (sheet, row, col) triples. The union of required dirty / volatile precedents
9301    /// is computed once and evaluated, which is typically faster than calling
9302    /// `evaluate_cell` repeatedly for a related set of targets.
9303    ///
9304    /// Returns the resulting values for each requested target in the same order.
9305    pub fn evaluate_cells(
9306        &mut self,
9307        targets: &[(&str, u32, u32)],
9308    ) -> Result<Vec<Option<LiteralValue>>, ExcelError> {
9309        debug_assert!(
9310            !self.graph.deferred_dirty_active(),
9311            "deferred-dirty scope leaked into evaluate_cells: a begin_deferred_dirty \
9312             was not balanced by end_deferred_dirty"
9313        );
9314        self.validate_deterministic_mode()?;
9315        if targets.is_empty() {
9316            return Ok(Vec::new());
9317        }
9318        // See ``evaluate_cell`` for why we drain *all* staged sheets in
9319        // ``defer_graph_building`` mode: cross-sheet refs to still-staged
9320        // sources would otherwise evaluate to ``None``.
9321        if self.config.defer_graph_building {
9322            self.build_graph_all()?;
9323        }
9324        if self.graph.formula_authority().active_span_count() > 0 {
9325            let _ = self.evaluate_authoritative_formula_plane_all()?;
9326        } else {
9327            self.evaluate_until(targets)?;
9328        }
9329        Ok(targets
9330            .iter()
9331            .map(|(s, r, c)| self.get_cell_value(s, *r, *c))
9332            .collect())
9333    }
9334
9335    pub fn evaluate_cells_cancellable(
9336        &mut self,
9337        targets: &[(&str, u32, u32)],
9338        cancel_flag: Arc<AtomicBool>,
9339    ) -> Result<Vec<Option<LiteralValue>>, ExcelError> {
9340        self.active_cancel_flag = Some(cancel_flag.clone());
9341        let res = self.evaluate_cells_cancellable_impl(targets, &cancel_flag);
9342        self.active_cancel_flag = None;
9343        res
9344    }
9345
9346    fn evaluate_cells_cancellable_impl(
9347        &mut self,
9348        targets: &[(&str, u32, u32)],
9349        cancel_flag: &AtomicBool,
9350    ) -> Result<Vec<Option<LiteralValue>>, ExcelError> {
9351        self.validate_deterministic_mode()?;
9352        if targets.is_empty() {
9353            return Ok(Vec::new());
9354        }
9355        // See ``evaluate_cell`` for why we drain *all* staged sheets in
9356        // ``defer_graph_building`` mode: cross-sheet refs to still-staged
9357        // sources would otherwise evaluate to ``None``.
9358        if self.config.defer_graph_building {
9359            self.build_graph_all()?;
9360        }
9361        if self.graph.formula_authority().active_span_count() > 0 {
9362            if cancel_flag.load(Ordering::Relaxed) {
9363                return Err(ExcelError::new(ExcelErrorKind::Cancelled).with_message(
9364                    "Evaluation cancelled before FormulaPlane scheduling".to_string(),
9365                ));
9366            }
9367            let _ = self.evaluate_authoritative_formula_plane_all()?;
9368            return Ok(targets
9369                .iter()
9370                .map(|(s, r, c)| self.get_cell_value(s, *r, *c))
9371                .collect());
9372        }
9373
9374        // evaluate_until_cancellable takes &[&str] in A1 notation, but we have (&str, u32, u32)
9375        // Let's implement evaluate_until_coords_cancellable or similar, or just convert
9376        let a1_targets: Vec<String> = targets
9377            .iter()
9378            .map(|(s, r, c)| {
9379                format!("{}!{}", s, col_letters_from_1based(*c).unwrap()) + &r.to_string()
9380            })
9381            .collect();
9382        let a1_refs: Vec<&str> = a1_targets.iter().map(|s| s.as_str()).collect();
9383
9384        self.evaluate_until_cancellable_impl(&a1_refs, cancel_flag)?;
9385
9386        Ok(targets
9387            .iter()
9388            .map(|(s, r, c)| self.get_cell_value(s, *r, *c))
9389            .collect())
9390    }
9391
9392    pub fn evaluate_cells_with_delta(
9393        &mut self,
9394        targets: &[(&str, u32, u32)],
9395    ) -> Result<(Vec<Option<LiteralValue>>, EvalDelta), ExcelError> {
9396        self.validate_deterministic_mode()?;
9397        if targets.is_empty() {
9398            return Ok((Vec::new(), EvalDelta::default()));
9399        }
9400        if self.config.defer_graph_building {
9401            let mut sheets: rustc_hash::FxHashSet<&str> = rustc_hash::FxHashSet::default();
9402            for (s, _, _) in targets.iter() {
9403                sheets.insert(*s);
9404            }
9405            self.build_graph_for_sheets(sheets.iter().cloned())?;
9406        }
9407        if self.graph.formula_authority().active_span_count() > 0 {
9408            let _ = self.evaluate_authoritative_formula_plane_all()?;
9409            let values = targets
9410                .iter()
9411                .map(|(s, r, c)| self.get_cell_value(s, *r, *c))
9412                .collect();
9413            return Ok((values, EvalDelta::default()));
9414        }
9415        let mut collector = DeltaCollector::new(DeltaMode::Cells);
9416        self.evaluate_until_with_delta_collector(targets, &mut collector)?;
9417        let values = targets
9418            .iter()
9419            .map(|(s, r, c)| self.get_cell_value(s, *r, *c))
9420            .collect();
9421        Ok((values, collector.finish()))
9422    }
9423
9424    /// Get the evaluation plan for target cells without actually evaluating them
9425    pub fn get_eval_plan(&self, targets: &[(&str, u32, u32)]) -> Result<EvalPlan, ExcelError> {
9426        if targets.is_empty() {
9427            return Ok(EvalPlan {
9428                total_vertices_to_evaluate: 0,
9429                layers: Vec::new(),
9430                cycles_detected: 0,
9431                dirty_count: 0,
9432                volatile_count: 0,
9433                parallel_enabled: self.config.enable_parallel && self.thread_pool.is_some(),
9434                estimated_parallel_layers: 0,
9435                target_cells: Vec::new(),
9436            });
9437        }
9438        if self.config.defer_graph_building && self.has_staged_formulas() {
9439            return Err(ExcelError::new(ExcelErrorKind::Value).with_message(
9440                "Evaluation plan requested with deferred graph; build first or call evaluate_*",
9441            ));
9442        }
9443
9444        // Convert targets to A1 notation for consistency
9445        let addresses: Vec<String> = targets
9446            .iter()
9447            .map(|(s, r, c)| format!("{}!{}{}", s, Self::col_to_letters(*c), r))
9448            .collect();
9449
9450        // Parse target cell addresses
9451        let mut target_addrs = Vec::new();
9452        for (sheet, row, col) in targets {
9453            if let Some(sheet_id) = self.graph.sheet_id(sheet) {
9454                let coord = Coord::from_excel(*row, *col, true, true);
9455                target_addrs.push(CellRef::new(sheet_id, coord));
9456            }
9457        }
9458
9459        // Find vertex IDs for targets
9460        let mut target_vertex_ids = Vec::new();
9461        for addr in &target_addrs {
9462            if let Some(vertex_id) = self.graph.get_vertex_id_for_address(addr) {
9463                target_vertex_ids.push(*vertex_id);
9464            }
9465        }
9466
9467        if target_vertex_ids.is_empty() {
9468            return Ok(EvalPlan {
9469                total_vertices_to_evaluate: 0,
9470                layers: Vec::new(),
9471                cycles_detected: 0,
9472                dirty_count: 0,
9473                volatile_count: 0,
9474                parallel_enabled: self.config.enable_parallel && self.thread_pool.is_some(),
9475                estimated_parallel_layers: 0,
9476                target_cells: addresses,
9477            });
9478        }
9479
9480        // Build demand subgraph with virtual edges (same as evaluate_until)
9481        let (precedents_to_eval, vdeps) = self.build_demand_subgraph(&target_vertex_ids);
9482
9483        if precedents_to_eval.is_empty() {
9484            return Ok(EvalPlan {
9485                total_vertices_to_evaluate: 0,
9486                layers: Vec::new(),
9487                cycles_detected: 0,
9488                dirty_count: 0,
9489                volatile_count: 0,
9490                parallel_enabled: self.config.enable_parallel && self.thread_pool.is_some(),
9491                estimated_parallel_layers: 0,
9492                target_cells: addresses,
9493            });
9494        }
9495
9496        // Count dirty and volatile vertices
9497        let mut dirty_count = 0;
9498        let mut volatile_count = 0;
9499        for &vertex_id in &precedents_to_eval {
9500            if self.graph.is_dirty(vertex_id) {
9501                dirty_count += 1;
9502            }
9503            if self.graph.is_volatile(vertex_id) {
9504                volatile_count += 1;
9505            }
9506        }
9507
9508        // Create schedule for the minimal subgraph honoring virtual edges
9509        let scheduler = Scheduler::new(&self.graph);
9510        let schedule = scheduler.create_schedule_with_virtual(&precedents_to_eval, &vdeps)?;
9511
9512        // Build layer information
9513        let mut layers = Vec::new();
9514        let mut estimated_parallel_layers = 0;
9515        let parallel_enabled = self.config.enable_parallel && self.thread_pool.is_some();
9516
9517        for layer in &schedule.layers {
9518            let parallel_eligible = parallel_enabled && layer.vertices.len() > 1;
9519            if parallel_eligible {
9520                estimated_parallel_layers += 1;
9521            }
9522
9523            // Get sample cell addresses (up to 5)
9524            let sample_cells: Vec<String> = layer
9525                .vertices
9526                .iter()
9527                .take(5)
9528                .filter_map(|&vertex_id| {
9529                    self.graph
9530                        .get_cell_ref_for_vertex(vertex_id)
9531                        .map(|cell_ref| {
9532                            let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
9533                            format!(
9534                                "{}!{}{}",
9535                                sheet_name,
9536                                Self::col_to_letters(cell_ref.coord.col()),
9537                                cell_ref.coord.row() + 1
9538                            )
9539                        })
9540                })
9541                .collect();
9542
9543            layers.push(LayerInfo {
9544                vertex_count: layer.vertices.len(),
9545                parallel_eligible,
9546                sample_cells,
9547            });
9548        }
9549
9550        Ok(EvalPlan {
9551            total_vertices_to_evaluate: precedents_to_eval.len(),
9552            layers,
9553            cycles_detected: schedule.cycles.len(),
9554            dirty_count,
9555            volatile_count,
9556            parallel_enabled,
9557            estimated_parallel_layers,
9558            target_cells: addresses,
9559        })
9560    }
9561    /// Helper to create a schedule, integrating virtual dependencies automatically.
9562    fn create_evaluation_schedule(
9563        &mut self,
9564        to_evaluate: &[VertexId],
9565    ) -> Result<ScheduleBuildOutput, ExcelError> {
9566        // Fold pending edge deltas once per schedule build so traversal uses
9567        // the zero-allocation CSR slices (#125).
9568        self.graph.flush_pending_edge_deltas();
9569        if self.can_use_static_schedule_cache(to_evaluate) {
9570            if let Some(cached) = self.cached_static_schedule.as_ref()
9571                && cached.topology_epoch == self.topology_epoch
9572                && cached.candidate_vertices.as_slice() == to_evaluate
9573            {
9574                let meta = ScheduleBuildMeta {
9575                    candidate_vertices: to_evaluate.len(),
9576                    vdeps_vertices: 0,
9577                    vdeps_edges: 0,
9578                    builder_elapsed_ms: 0,
9579                    used_virtual_schedule: false,
9580                    schedule_cache_hit: true,
9581                    schedule_cache_eligible: true,
9582                };
9583                return Ok((cached.schedule.clone(), FxHashMap::default(), meta));
9584            }
9585
9586            let (schedule, vdeps, mut meta) =
9587                self.create_evaluation_schedule_uncached(to_evaluate)?;
9588            meta.schedule_cache_hit = false;
9589            meta.schedule_cache_eligible = true;
9590            if vdeps.is_empty() {
9591                self.cached_static_schedule = Some(CachedScheduleEntry {
9592                    topology_epoch: self.topology_epoch,
9593                    candidate_vertices: to_evaluate.to_vec(),
9594                    schedule: schedule.clone(),
9595                });
9596            }
9597            return Ok((schedule, vdeps, meta));
9598        }
9599
9600        let (schedule, vdeps, mut meta) = self.create_evaluation_schedule_uncached(to_evaluate)?;
9601        meta.schedule_cache_hit = false;
9602        meta.schedule_cache_eligible = false;
9603        Ok((schedule, vdeps, meta))
9604    }
9605
9606    fn create_evaluation_schedule_uncached(
9607        &self,
9608        to_evaluate: &[VertexId],
9609    ) -> Result<ScheduleBuildOutput, ExcelError> {
9610        let builder = VirtualDepBuilder::new(self);
9611        let (vdeps, augmented, builder_elapsed_ms, vdeps_edges) =
9612            if self.config.enable_virtual_dep_telemetry {
9613                let build_started = crate::instant::FzInstant::now();
9614                let (vdeps, augmented) = builder.build(to_evaluate);
9615                let builder_elapsed_ms = build_started.elapsed().as_millis();
9616                let vdeps_edges = vdeps.values().map(|deps| deps.len()).sum::<usize>();
9617                (vdeps, augmented, builder_elapsed_ms, vdeps_edges)
9618            } else {
9619                let (vdeps, augmented) = builder.build(to_evaluate);
9620                (vdeps, augmented, 0, 0)
9621            };
9622
9623        let mut final_evaluate = to_evaluate.to_vec();
9624        if !augmented.is_empty() {
9625            final_evaluate.extend(augmented);
9626            final_evaluate.sort_unstable();
9627            final_evaluate.dedup();
9628        }
9629
9630        let use_virtual = !vdeps.is_empty();
9631
9632        let scheduler = Scheduler::new(&self.graph);
9633        let schedule = if use_virtual {
9634            scheduler.create_schedule_with_virtual(&final_evaluate, &vdeps)?
9635        } else {
9636            scheduler.create_schedule(&final_evaluate)?
9637        };
9638
9639        let meta = ScheduleBuildMeta {
9640            candidate_vertices: to_evaluate.len(),
9641            vdeps_vertices: vdeps.len(),
9642            vdeps_edges,
9643            builder_elapsed_ms,
9644            used_virtual_schedule: use_virtual,
9645            schedule_cache_hit: false,
9646            schedule_cache_eligible: false,
9647        };
9648
9649        Ok((schedule, vdeps, meta))
9650    }
9651
9652    fn can_use_static_schedule_cache(&self, to_evaluate: &[VertexId]) -> bool {
9653        !to_evaluate.is_empty()
9654            && to_evaluate.iter().copied().all(|v| {
9655                !self.graph.is_dynamic(v) && self.graph.get_range_dependencies(v).is_none()
9656            })
9657    }
9658
9659    fn start_virtual_dep_telemetry(&self) -> VirtualDepTelemetry {
9660        VirtualDepTelemetry {
9661            fallback_mode_activations: self.virtual_dep_fallback_activations,
9662            ..VirtualDepTelemetry::default()
9663        }
9664    }
9665
9666    fn accumulate_schedule_meta(telemetry: &mut VirtualDepTelemetry, meta: &ScheduleBuildMeta) {
9667        telemetry.candidate_vertices_total += meta.candidate_vertices;
9668        telemetry.vdeps_vertices_total += meta.vdeps_vertices;
9669        telemetry.vdeps_edges_total += meta.vdeps_edges;
9670        telemetry.builder_elapsed_ms_total += meta.builder_elapsed_ms;
9671        if meta.schedule_cache_eligible {
9672            if meta.schedule_cache_hit {
9673                telemetry.schedule_cache_hits += 1;
9674                telemetry.reused_schedule_vertices_total += meta.candidate_vertices;
9675            } else {
9676                telemetry.schedule_cache_misses += 1;
9677            }
9678        }
9679        if meta.used_virtual_schedule {
9680            telemetry.schedule_virtual_passes += 1;
9681        } else {
9682            telemetry.schedule_static_passes += 1;
9683        }
9684    }
9685
9686    fn changed_virtual_dep_vertices(
9687        &self,
9688        to_evaluate: &[VertexId],
9689        old_vdeps: &FxHashMap<VertexId, Vec<VertexId>>,
9690    ) -> Vec<VertexId> {
9691        if !to_evaluate
9692            .iter()
9693            .copied()
9694            .any(|v| self.graph.is_dynamic(v))
9695        {
9696            return Vec::new();
9697        }
9698
9699        let builder = VirtualDepBuilder::new(self);
9700        let (new_vdeps, _) = builder.build(to_evaluate);
9701
9702        let mut candidates = FxHashSet::default();
9703        candidates.extend(old_vdeps.keys().copied());
9704        candidates.extend(new_vdeps.keys().copied());
9705
9706        let mut changed = Vec::new();
9707        for v in candidates {
9708            if old_vdeps.get(&v) != new_vdeps.get(&v) {
9709                changed.push(v);
9710            }
9711        }
9712        changed
9713    }
9714
9715    /// Build a demand-driven subgraph for the given targets, including ephemeral edges for
9716    /// compressed ranges, and returning the set of dirty/volatile precedents and virtual deps.
9717    fn build_demand_subgraph(
9718        &self,
9719        target_vertices: &[VertexId],
9720    ) -> (
9721        Vec<VertexId>,
9722        rustc_hash::FxHashMap<VertexId, Vec<VertexId>>,
9723    ) {
9724        #[cfg(feature = "tracing")]
9725        let _span =
9726            tracing::info_span!("demand_subgraph", targets = target_vertices.len()).entered();
9727        use rustc_hash::{FxHashMap, FxHashSet};
9728
9729        let mut to_evaluate: FxHashSet<VertexId> = FxHashSet::default();
9730        let mut visited: FxHashSet<VertexId> = FxHashSet::default();
9731        let mut stack: Vec<VertexId> = Vec::new();
9732        let mut vdeps: FxHashMap<VertexId, Vec<VertexId>> = FxHashMap::default(); // incoming deps per vertex
9733
9734        for &t in target_vertices {
9735            stack.push(t);
9736        }
9737
9738        while let Some(v) = stack.pop() {
9739            if !visited.insert(v) {
9740                continue;
9741            }
9742            if !self.graph.vertex_exists(v) {
9743                continue;
9744            }
9745            // Schedule dirty/volatile formulas. Also schedule pass-through
9746            // Named*/Range vertices so the scheduler honours the
9747            // topological position of any formula cells that sit underneath
9748            // them — without these in `vertex_set` the scheduler skips the
9749            // edges that route a target through a named-range vertex into
9750            // its underlying cells, and the underlying cells then end up
9751            // in the same (or an earlier) layer as the target.
9752            match self.graph.get_vertex_kind(v) {
9753                VertexKind::FormulaScalar | VertexKind::FormulaArray => {
9754                    if self.graph.is_dirty(v) || self.graph.is_volatile(v) {
9755                        to_evaluate.insert(v);
9756                    }
9757                }
9758                VertexKind::NamedScalar
9759                | VertexKind::NamedArray
9760                | VertexKind::Range
9761                | VertexKind::InfiniteRange => {
9762                    to_evaluate.insert(v);
9763                }
9764                _ => {}
9765            }
9766
9767            // Explicit dependencies (graph edges). We push *every* dep onto
9768            // the stack — not just formulas — because intermediate vertices
9769            // (NamedScalar, NamedArray, Range) are pass-through nodes whose
9770            // own dependencies point at the actual formula cells. Filtering
9771            // by kind here previously caused DN-range refs to be dropped
9772            // from the demand subgraph, so a target like
9773            // ``=SUM(named_range_pointing_at_dirty_cells)`` would evaluate
9774            // using stale values for those cells. The kind check at the top
9775            // of the loop still gates which vertices end up in
9776            // ``to_evaluate``; only Formula vertices are scheduled.
9777            if let Some(dependencies) = self.graph.dependencies_slice(v) {
9778                for &dep in dependencies {
9779                    if self.graph.vertex_exists(dep) && !visited.contains(&dep) {
9780                        stack.push(dep);
9781                    }
9782                }
9783            } else {
9784                for dep in self.graph.get_dependencies(v) {
9785                    if self.graph.vertex_exists(dep) && !visited.contains(&dep) {
9786                        stack.push(dep);
9787                    }
9788                }
9789            } // Virtual dependencies (compressed ranges + dynamic like INDIRECT)
9790            let builder = VirtualDepBuilder::new(self);
9791            let (vdeps_map, _) = builder.build(&[v]);
9792            if let Some(deps) = vdeps_map.get(&v) {
9793                for &u in deps {
9794                    vdeps.entry(v).or_default().push(u);
9795                    if !visited.contains(&u) {
9796                        stack.push(u);
9797                    }
9798                }
9799            }
9800        }
9801
9802        let mut result: Vec<VertexId> = to_evaluate.into_iter().collect();
9803        result.sort_unstable();
9804        // Dedup virtual deps
9805        for deps in vdeps.values_mut() {
9806            deps.sort_unstable();
9807            deps.dedup();
9808        }
9809        (result, vdeps)
9810    }
9811
9812    /// Helper: convert 1-based column index to Excel-style letters (1 -> A, 27 -> AA)
9813    fn col_to_letters(col: u32) -> String {
9814        col_letters_from_1based(col).expect("column index must be >= 1")
9815    }
9816
9817    /// Evaluate all dirty/volatile vertices with cancellation support
9818    pub fn evaluate_all_cancellable(
9819        &mut self,
9820        cancel_flag: Arc<AtomicBool>,
9821    ) -> Result<EvalResult, ExcelError> {
9822        self.active_cancel_flag = Some(cancel_flag.clone());
9823        let res = self.evaluate_all_cancellable_impl(&cancel_flag);
9824        self.active_cancel_flag = None;
9825        res
9826    }
9827
9828    fn evaluate_all_cancellable_impl(
9829        &mut self,
9830        cancel_flag: &AtomicBool,
9831    ) -> Result<EvalResult, ExcelError> {
9832        self.begin_evaluation_request();
9833        let _source_cache = self.source_cache_session();
9834        self.validate_deterministic_mode()?;
9835        if self.config.defer_graph_building {
9836            self.build_graph_all()?;
9837        }
9838        if self.graph.formula_authority().active_span_count() > 0 {
9839            if cancel_flag.load(Ordering::Relaxed) {
9840                return Err(ExcelError::new(ExcelErrorKind::Cancelled).with_message(
9841                    "Evaluation cancelled before FormulaPlane scheduling".to_string(),
9842                ));
9843            }
9844            return self.evaluate_authoritative_formula_plane_all();
9845        }
9846        self.reset_virtual_dep_telemetry_if_disabled();
9847        let start = crate::instant::FzInstant::now();
9848        let mut computed_vertices = 0;
9849        let mut cycle_errors = 0;
9850
9851        let mut replan_iterations = 0;
9852        const MAX_REPLAN: usize = 5;
9853        let mut telemetry = self
9854            .config
9855            .enable_virtual_dep_telemetry
9856            .then(|| self.start_virtual_dep_telemetry());
9857
9858        loop {
9859            if cancel_flag.load(Ordering::Relaxed) {
9860                if let Some(mut t) = telemetry {
9861                    t.bailout_reason = Some("cancelled");
9862                    t.replan_iterations = replan_iterations;
9863                    self.last_virtual_dep_telemetry = t;
9864                }
9865                return Err(ExcelError::new(ExcelErrorKind::Cancelled)
9866                    .with_message("Evaluation cancelled before scheduling".to_string()));
9867            }
9868
9869            let to_evaluate = self.graph.get_evaluation_vertices();
9870            if to_evaluate.is_empty() {
9871                if let Some(t) = telemetry.as_mut()
9872                    && t.bailout_reason.is_none()
9873                {
9874                    t.bailout_reason = Some("no_work");
9875                }
9876                break;
9877            }
9878
9879            let (schedule, old_vdeps, meta) = self.create_evaluation_schedule(&to_evaluate)?;
9880            if let Some(t) = telemetry.as_mut() {
9881                Self::accumulate_schedule_meta(t, &meta);
9882            }
9883
9884            // Walk units in condensation order, checking cancellation between
9885            // units (formerly between cycles and between layers).
9886            for &unit in &schedule.units {
9887                match unit {
9888                    ScheduleUnit::Cycle(i) => {
9889                        // Check cancellation between cycles
9890                        if cancel_flag.load(Ordering::Relaxed) {
9891                            if let Some(mut t) = telemetry {
9892                                t.bailout_reason = Some("cancelled");
9893                                t.replan_iterations = replan_iterations;
9894                                self.last_virtual_dep_telemetry = t;
9895                            }
9896                            return Err(ExcelError::new(ExcelErrorKind::Cancelled).with_message(
9897                                "Evaluation cancelled during cycle handling".to_string(),
9898                            ));
9899                        }
9900
9901                        if self.handle_cycle_unit(
9902                            schedule.unit_cycle(i),
9903                            None,
9904                            None,
9905                            Some(cancel_flag),
9906                        )? > 0
9907                        {
9908                            cycle_errors += 1;
9909                        }
9910                    }
9911                    ScheduleUnit::Layer(i) => {
9912                        let layer = schedule.unit_layer(i);
9913                        // Check cancellation between layers
9914                        if cancel_flag.load(Ordering::Relaxed) {
9915                            if let Some(mut t) = telemetry {
9916                                t.bailout_reason = Some("cancelled");
9917                                t.replan_iterations = replan_iterations;
9918                                self.last_virtual_dep_telemetry = t;
9919                            }
9920                            return Err(ExcelError::new(ExcelErrorKind::Cancelled)
9921                                .with_message("Evaluation cancelled between layers".to_string()));
9922                        }
9923
9924                        // Evaluate vertices in this layer (parallel or sequential)
9925                        if self.thread_pool.is_some() && layer.vertices.len() > 1 {
9926                            computed_vertices +=
9927                                self.evaluate_layer_parallel_cancellable(layer, cancel_flag)?;
9928                        } else {
9929                            computed_vertices +=
9930                                self.evaluate_layer_sequential_cancellable(layer, cancel_flag)?;
9931                        }
9932                    }
9933                }
9934            }
9935
9936            let changed_vertices = self.changed_virtual_dep_vertices(&to_evaluate, &old_vdeps);
9937            if let Some(t) = telemetry.as_mut() {
9938                t.changed_vdeps_total += changed_vertices.len();
9939            }
9940            self.graph.clear_dirty_flags(&to_evaluate);
9941            for v in &changed_vertices {
9942                self.graph.set_dirty(*v, true);
9943            }
9944
9945            if changed_vertices.is_empty() {
9946                if let Some(t) = telemetry.as_mut() {
9947                    t.bailout_reason = Some("converged");
9948                }
9949                break;
9950            }
9951            if replan_iterations >= MAX_REPLAN {
9952                if let Some(t) = telemetry.as_mut() {
9953                    t.bailout_reason = Some("max_replan");
9954                }
9955                break;
9956            }
9957            replan_iterations += 1;
9958        }
9959
9960        if let Some(mut t) = telemetry {
9961            t.replan_iterations = replan_iterations;
9962            self.last_virtual_dep_telemetry = t;
9963        }
9964
9965        // Re-dirty volatile vertices for the next evaluation cycle
9966        self.redirty_for_next_recalc();
9967        self.recalc_epoch = self.recalc_epoch.wrapping_add(1);
9968
9969        Ok(EvalResult {
9970            computed_vertices,
9971            cycle_errors,
9972            elapsed: start.elapsed(),
9973        })
9974    }
9975
9976    /// Evaluate only the necessary precedents for specific target cells with cancellation support
9977    pub fn evaluate_until_cancellable(
9978        &mut self,
9979        targets: &[&str],
9980        cancel_flag: Arc<AtomicBool>,
9981    ) -> Result<EvalResult, ExcelError> {
9982        self.active_cancel_flag = Some(cancel_flag.clone());
9983        let res = self.evaluate_until_cancellable_impl(targets, &cancel_flag);
9984        self.active_cancel_flag = None;
9985        res
9986    }
9987
9988    fn evaluate_until_cancellable_impl(
9989        &mut self,
9990        targets: &[&str],
9991        cancel_flag: &AtomicBool,
9992    ) -> Result<EvalResult, ExcelError> {
9993        let start = crate::instant::FzInstant::now();
9994        self.begin_evaluation_request();
9995        self.graph.flush_pending_edge_deltas();
9996        if self.graph.formula_authority().active_span_count() > 0 {
9997            if cancel_flag.load(Ordering::Relaxed) {
9998                return Err(ExcelError::new(ExcelErrorKind::Cancelled).with_message(
9999                    "Evaluation cancelled before FormulaPlane scheduling".to_string(),
10000                ));
10001            }
10002            return self.evaluate_authoritative_formula_plane_all();
10003        }
10004
10005        // Parse target cell addresses
10006        let mut target_addrs = Vec::new();
10007        for target in targets {
10008            let (sheet, row, col) = self.parse_a1_notation(target)?;
10009            let sheet_id = self.graph.sheet_id_mut(&sheet);
10010            let coord = Coord::from_excel(row, col, true, true);
10011            target_addrs.push(CellRef::new(sheet_id, coord));
10012        }
10013
10014        // Find vertex IDs for targets
10015        let mut target_vertex_ids = Vec::new();
10016        for addr in &target_addrs {
10017            if let Some(vertex_id) = self.graph.get_vertex_id_for_address(addr) {
10018                target_vertex_ids.push(*vertex_id);
10019            }
10020        }
10021
10022        if target_vertex_ids.is_empty() {
10023            return Ok(EvalResult {
10024                computed_vertices: 0,
10025                cycle_errors: 0,
10026                elapsed: start.elapsed(),
10027            });
10028        }
10029
10030        // Build demand subgraph with virtual edges
10031        let (precedents_to_eval, vdeps) = self.build_demand_subgraph(&target_vertex_ids);
10032
10033        if precedents_to_eval.is_empty() {
10034            return Ok(EvalResult {
10035                computed_vertices: 0,
10036                cycle_errors: 0,
10037                elapsed: start.elapsed(),
10038            });
10039        }
10040
10041        // Create schedule honoring virtual edges
10042        let scheduler = Scheduler::new(&self.graph);
10043        let schedule = scheduler.create_schedule_with_virtual(&precedents_to_eval, &vdeps)?;
10044
10045        // Walk units in condensation order with cancellation checks between
10046        // units (formerly between cycles and between layers).
10047        let mut cycle_errors = 0;
10048        let mut computed_vertices = 0;
10049        for &unit in &schedule.units {
10050            match unit {
10051                ScheduleUnit::Cycle(i) => {
10052                    // Check cancellation between cycles
10053                    if cancel_flag.load(Ordering::Relaxed) {
10054                        return Err(ExcelError::new(ExcelErrorKind::Cancelled).with_message(
10055                            "Demand-driven evaluation cancelled during cycle handling".to_string(),
10056                        ));
10057                    }
10058
10059                    if self.handle_cycle_unit(
10060                        schedule.unit_cycle(i),
10061                        None,
10062                        None,
10063                        Some(cancel_flag),
10064                    )? > 0
10065                    {
10066                        cycle_errors += 1;
10067                    }
10068                }
10069                ScheduleUnit::Layer(i) => {
10070                    let layer = schedule.unit_layer(i);
10071                    // Check cancellation between layers
10072                    if cancel_flag.load(Ordering::Relaxed) {
10073                        return Err(ExcelError::new(ExcelErrorKind::Cancelled).with_message(
10074                            "Demand-driven evaluation cancelled between layers".to_string(),
10075                        ));
10076                    }
10077
10078                    // Evaluate vertices in this layer (parallel or sequential)
10079                    if self.thread_pool.is_some() && layer.vertices.len() > 1 {
10080                        computed_vertices +=
10081                            self.evaluate_layer_parallel_cancellable(layer, cancel_flag)?;
10082                    } else {
10083                        computed_vertices += self
10084                            .evaluate_layer_sequential_cancellable_demand_driven(
10085                                layer,
10086                                cancel_flag,
10087                            )?;
10088                    }
10089                }
10090            }
10091        }
10092
10093        // Clear dirty flags for evaluated vertices
10094        self.graph.clear_dirty_flags(&precedents_to_eval);
10095
10096        // Re-dirty volatile vertices
10097        self.redirty_for_next_recalc();
10098
10099        Ok(EvalResult {
10100            computed_vertices,
10101            cycle_errors,
10102            elapsed: start.elapsed(),
10103        })
10104    }
10105
10106    fn parse_a1_notation(&self, address: &str) -> Result<(String, u32, u32), ExcelError> {
10107        let mut parts = address.splitn(2, '!');
10108        let first = parts.next().unwrap_or_default();
10109        let remainder = parts.next();
10110
10111        let (sheet, cell_part) = match remainder {
10112            Some(cell) => (first.to_string(), cell),
10113            None => (self.default_sheet_name().to_string(), first),
10114        };
10115
10116        let (row, col, _, _) = parse_a1_1based(cell_part).map_err(|err| {
10117            ExcelError::new(ExcelErrorKind::Ref)
10118                .with_message(format!("Invalid cell reference `{cell_part}`: {err}"))
10119        })?;
10120
10121        Ok((sheet, row, col))
10122    }
10123
10124    /// Determine volatility using this engine's FunctionProvider, falling back to global registry.
10125    fn is_ast_volatile_with_provider(&self, ast: &ASTNode) -> bool {
10126        use formualizer_parse::parser::ASTNodeType;
10127        match &ast.node_type {
10128            ASTNodeType::Function { name, args, .. } => {
10129                if let Some(func) = self
10130                    .get_function("", name)
10131                    .or_else(|| crate::function_registry::get("", name))
10132                    && func.caps().contains(crate::function::FnCaps::VOLATILE)
10133                {
10134                    return true;
10135                }
10136                args.iter()
10137                    .any(|arg| self.is_ast_volatile_with_provider(arg))
10138            }
10139            ASTNodeType::BinaryOp { left, right, .. } => {
10140                self.is_ast_volatile_with_provider(left)
10141                    || self.is_ast_volatile_with_provider(right)
10142            }
10143            ASTNodeType::UnaryOp { expr, .. } => self.is_ast_volatile_with_provider(expr),
10144            ASTNodeType::Array(rows) => rows.iter().any(|row| {
10145                row.iter()
10146                    .any(|cell| self.is_ast_volatile_with_provider(cell))
10147            }),
10148            _ => false,
10149        }
10150    }
10151
10152    /// Find dirty precedents that need evaluation for the given target vertices
10153    fn find_dirty_precedents(&self, target_vertices: &[VertexId]) -> Vec<VertexId> {
10154        let mut to_evaluate = FxHashSet::default();
10155        let mut visited = FxHashSet::default();
10156        let mut stack = Vec::new();
10157
10158        // Start reverse traversal from target vertices
10159        for &target in target_vertices {
10160            stack.push(target);
10161        }
10162
10163        while let Some(vertex_id) = stack.pop() {
10164            if !visited.insert(vertex_id) {
10165                continue; // Already processed
10166            }
10167
10168            if self.graph.vertex_exists(vertex_id) {
10169                // Check if this vertex needs evaluation
10170                let kind = self.graph.get_vertex_kind(vertex_id);
10171                let needs_eval = match kind {
10172                    super::vertex::VertexKind::FormulaScalar
10173                    | super::vertex::VertexKind::FormulaArray => {
10174                        self.graph.is_dirty(vertex_id) || self.graph.is_volatile(vertex_id)
10175                    }
10176                    _ => false, // Values and empty cells don't need evaluation
10177                };
10178
10179                if needs_eval {
10180                    to_evaluate.insert(vertex_id);
10181                }
10182
10183                // Continue traversal to dependencies (precedents)
10184                if let Some(dependencies) = self.graph.dependencies_slice(vertex_id) {
10185                    for &dep_id in dependencies {
10186                        if !visited.contains(&dep_id) {
10187                            stack.push(dep_id);
10188                        }
10189                    }
10190                } else {
10191                    let dependencies = self.graph.get_dependencies(vertex_id);
10192                    for dep_id in dependencies {
10193                        if !visited.contains(&dep_id) {
10194                            stack.push(dep_id);
10195                        }
10196                    }
10197                }
10198            }
10199        }
10200
10201        let mut result: Vec<VertexId> = to_evaluate.into_iter().collect();
10202        result.sort_unstable();
10203        result
10204    }
10205
10206    /// Evaluate a layer sequentially
10207    fn evaluate_layer_sequential(
10208        &mut self,
10209        layer: &super::scheduler::Layer,
10210    ) -> Result<usize, ExcelError> {
10211        self.evaluate_layer_sequential_effects(layer)
10212    }
10213
10214    fn update_vertex_value_with_delta(
10215        &mut self,
10216        vertex_id: VertexId,
10217        new_value: LiteralValue,
10218        delta: &mut DeltaCollector,
10219    ) {
10220        if delta.mode != DeltaMode::Off
10221            && let Some(cell) = self.graph.get_cell_ref_for_vertex(vertex_id)
10222        {
10223            let sheet_name = self.graph.sheet_name(cell.sheet_id);
10224            let old = self
10225                .read_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
10226                .unwrap_or(LiteralValue::Empty);
10227            if old != new_value {
10228                delta.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
10229            }
10230        }
10231        self.graph.update_vertex_value(vertex_id, new_value.clone());
10232        self.mirror_vertex_value_to_overlay(vertex_id, &new_value);
10233    }
10234
10235    fn evaluate_layer_sequential_with_delta(
10236        &mut self,
10237        layer: &super::scheduler::Layer,
10238        delta: &mut DeltaCollector,
10239    ) -> Result<usize, ExcelError> {
10240        self.evaluate_layer_sequential_with_delta_effects(layer, delta)
10241    }
10242
10243    /// Evaluate a layer sequentially with cancellation support
10244    fn evaluate_layer_sequential_cancellable(
10245        &mut self,
10246        layer: &super::scheduler::Layer,
10247        cancel_flag: &AtomicBool,
10248    ) -> Result<usize, ExcelError> {
10249        self.evaluate_layer_sequential_cancellable_effects(layer, cancel_flag)
10250    }
10251
10252    /// Evaluate a layer sequentially with more frequent cancellation checks for demand-driven evaluation
10253    fn evaluate_layer_sequential_cancellable_demand_driven(
10254        &mut self,
10255        layer: &super::scheduler::Layer,
10256        cancel_flag: &AtomicBool,
10257    ) -> Result<usize, ExcelError> {
10258        self.evaluate_layer_sequential_cancellable_demand_driven_effects(layer, cancel_flag)
10259    }
10260
10261    /// Evaluate a layer in parallel using the thread pool
10262    fn evaluate_layer_parallel(
10263        &mut self,
10264        layer: &super::scheduler::Layer,
10265    ) -> Result<usize, ExcelError> {
10266        self.evaluate_layer_parallel_effects(layer)
10267    }
10268
10269    fn evaluate_layer_parallel_with_delta(
10270        &mut self,
10271        layer: &super::scheduler::Layer,
10272        delta: &mut DeltaCollector,
10273    ) -> Result<usize, ExcelError> {
10274        self.evaluate_layer_parallel_with_delta_effects(layer, delta)
10275    }
10276
10277    /// Evaluate a layer in parallel with cancellation support
10278    fn evaluate_layer_parallel_cancellable(
10279        &mut self,
10280        layer: &super::scheduler::Layer,
10281        cancel_flag: &AtomicBool,
10282    ) -> Result<usize, ExcelError> {
10283        self.evaluate_layer_parallel_cancellable_effects(layer, cancel_flag)
10284    }
10285
10286    /// Apply a computed result produced by `evaluate_vertex_immutable()`.
10287    ///
10288    /// This is the parallel equivalent of the "apply" portion of `evaluate_vertex_impl`.
10289    /// We keep apply sequential for correctness (spill commit is inherently stateful).
10290    fn apply_parallel_vertex_result(
10291        &mut self,
10292        vertex_id: VertexId,
10293        result: LiteralValue,
10294        mut delta: Option<&mut DeltaCollector>,
10295        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
10296    ) -> Result<(), ExcelError> {
10297        // If this vertex's cell is currently covered by a spill from a different anchor,
10298        // ignore the computed result. The spill's committed values own the grid.
10299        if let Some(cell) = self.graph.get_cell_ref(vertex_id)
10300            && let Some(owner) = self.graph.spill_registry_anchor_for_cell(cell)
10301            && owner != vertex_id
10302        {
10303            return Ok(());
10304        }
10305
10306        let kind = self.graph.get_vertex_kind(vertex_id);
10307
10308        // Only formula vertices spill dynamic arrays into the grid.
10309        let is_formula = matches!(kind, VertexKind::FormulaScalar | VertexKind::FormulaArray);
10310        if is_formula {
10311            match result {
10312                LiteralValue::Array(rows) => {
10313                    self.apply_array_result_from_parallel(
10314                        vertex_id,
10315                        rows,
10316                        delta.as_deref_mut(),
10317                        overwritable_formulas,
10318                    )?;
10319                }
10320                other => {
10321                    self.apply_non_array_result_from_parallel(
10322                        vertex_id,
10323                        other,
10324                        delta.as_deref_mut(),
10325                    );
10326                }
10327            }
10328            return Ok(());
10329        }
10330
10331        // Non-formula vertices: store value as-is (arrays remain arrays; no spill).
10332        if let Some(d) = delta {
10333            self.update_vertex_value_with_delta(vertex_id, result, d);
10334        } else {
10335            self.graph.update_vertex_value(vertex_id, result.clone());
10336            self.mirror_vertex_value_to_overlay(vertex_id, &result);
10337        }
10338        Ok(())
10339    }
10340
10341    fn apply_non_array_result_from_parallel(
10342        &mut self,
10343        vertex_id: VertexId,
10344        value: LiteralValue,
10345        delta: Option<&mut DeltaCollector>,
10346    ) {
10347        // Scalar/error result: store value and ensure any previous spill is cleared.
10348        // This mirrors the sequential behavior in `evaluate_vertex_impl`.
10349        let spill_cells = self
10350            .graph
10351            .spill_cells_for_anchor(vertex_id)
10352            .map(|cells| cells.to_vec())
10353            .unwrap_or_default();
10354
10355        if let Some(d) = delta
10356            && d.mode != DeltaMode::Off
10357            && let Some(anchor) = self.graph.get_cell_ref_for_vertex(vertex_id)
10358        {
10359            if spill_cells.is_empty() {
10360                let old = self
10361                    .read_cell_value(
10362                        self.graph.sheet_name(anchor.sheet_id),
10363                        anchor.coord.row() + 1,
10364                        anchor.coord.col() + 1,
10365                    )
10366                    .unwrap_or(LiteralValue::Empty);
10367                if old != value {
10368                    d.record_cell(anchor.sheet_id, anchor.coord.row(), anchor.coord.col());
10369                }
10370            } else {
10371                for cell in spill_cells.iter() {
10372                    let sheet_name = self.graph.sheet_name(cell.sheet_id);
10373                    let old = self
10374                        .get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
10375                        .unwrap_or(LiteralValue::Empty);
10376                    let new = if cell.sheet_id == anchor.sheet_id
10377                        && cell.coord.row() == anchor.coord.row()
10378                        && cell.coord.col() == anchor.coord.col()
10379                    {
10380                        value.clone()
10381                    } else {
10382                        LiteralValue::Empty
10383                    };
10384                    Self::record_cell_if_changed(d, cell, &old, &new);
10385                }
10386            }
10387        }
10388
10389        self.graph.clear_spill_region(vertex_id);
10390        if let Some(scope) = Self::formula_plane_region_from_cells(&spill_cells) {
10391            self.record_formula_plane_structural_change(scope);
10392        }
10393
10394        if self.config.arrow_storage_enabled
10395            && self.config.delta_overlay_enabled
10396            && self.config.write_formula_overlay_enabled
10397        {
10398            let empty = LiteralValue::Empty;
10399            for cell in spill_cells.iter() {
10400                let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
10401                self.mirror_value_to_computed_overlay(
10402                    &sheet_name,
10403                    cell.coord.row() + 1,
10404                    cell.coord.col() + 1,
10405                    &empty,
10406                );
10407            }
10408        }
10409
10410        self.graph.update_vertex_value(vertex_id, value.clone());
10411        self.mirror_vertex_value_to_overlay(vertex_id, &value);
10412    }
10413
10414    fn apply_array_result_from_parallel(
10415        &mut self,
10416        vertex_id: VertexId,
10417        rows: Vec<Vec<LiteralValue>>,
10418        mut delta: Option<&mut DeltaCollector>,
10419        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
10420    ) -> Result<(), ExcelError> {
10421        // Keep behavior consistent with the sequential spill path in `evaluate_vertex_impl`.
10422        self.graph
10423            .set_kind(vertex_id, crate::engine::vertex::VertexKind::FormulaArray);
10424
10425        let anchor = self
10426            .graph
10427            .get_cell_ref(vertex_id)
10428            .expect("cell ref for vertex");
10429        let sheet_id = anchor.sheet_id;
10430        let h = rows.len() as u32;
10431        let w = rows.first().map(|r| r.len()).unwrap_or(0) as u32;
10432
10433        // Hard cap to avoid vertex explosion from huge dynamic arrays.
10434        let spill_cells = (h as u64).saturating_mul(w as u64);
10435        if spill_cells > self.config.spill.max_spill_cells as u64 {
10436            self.clear_spill_projection_and_mirror(vertex_id, delta.as_deref_mut());
10437            let spill_err = ExcelError::new(ExcelErrorKind::Spill)
10438                .with_message("SpillTooLarge")
10439                .with_extra(formualizer_common::ExcelErrorExtra::Spill {
10440                    expected_rows: h,
10441                    expected_cols: w,
10442                });
10443            let spill_val = LiteralValue::Error(spill_err.clone());
10444            if let Some(d) = delta.as_deref_mut()
10445                && d.mode != DeltaMode::Off
10446            {
10447                let old = self
10448                    .read_cell_value(
10449                        self.graph.sheet_name(anchor.sheet_id),
10450                        anchor.coord.row() + 1,
10451                        anchor.coord.col() + 1,
10452                    )
10453                    .unwrap_or(LiteralValue::Empty);
10454                if old != spill_val {
10455                    d.record_cell(anchor.sheet_id, anchor.coord.row(), anchor.coord.col());
10456                }
10457            }
10458            self.graph.update_vertex_value(vertex_id, spill_val.clone());
10459            self.mirror_vertex_value_to_overlay(vertex_id, &spill_val);
10460            return Ok(());
10461        }
10462
10463        // Bounds check to avoid out-of-range writes (align to AbsCoord capacity)
10464        const PACKED_MAX_ROW: u32 = 1_048_575; // 20-bit max
10465        const PACKED_MAX_COL: u32 = 16_383; // 14-bit max
10466        let end_row = anchor.coord.row().saturating_add(h).saturating_sub(1);
10467        let end_col = anchor.coord.col().saturating_add(w).saturating_sub(1);
10468        if end_row > PACKED_MAX_ROW || end_col > PACKED_MAX_COL {
10469            self.clear_spill_projection_and_mirror(vertex_id, delta.as_deref_mut());
10470            let spill_err = ExcelError::new(ExcelErrorKind::Spill)
10471                .with_message("Spill exceeds sheet bounds")
10472                .with_extra(formualizer_common::ExcelErrorExtra::Spill {
10473                    expected_rows: h,
10474                    expected_cols: w,
10475                });
10476            let spill_val = LiteralValue::Error(spill_err.clone());
10477            if let Some(d) = delta.as_deref_mut()
10478                && d.mode != DeltaMode::Off
10479            {
10480                let old = self
10481                    .read_cell_value(
10482                        self.graph.sheet_name(anchor.sheet_id),
10483                        anchor.coord.row() + 1,
10484                        anchor.coord.col() + 1,
10485                    )
10486                    .unwrap_or(LiteralValue::Empty);
10487                if old != spill_val {
10488                    d.record_cell(anchor.sheet_id, anchor.coord.row(), anchor.coord.col());
10489                }
10490            }
10491            self.graph.update_vertex_value(vertex_id, spill_val.clone());
10492            self.mirror_vertex_value_to_overlay(vertex_id, &spill_val);
10493            return Ok(());
10494        }
10495
10496        let mut targets = Vec::new();
10497        for r in 0..h {
10498            for c in 0..w {
10499                targets.push(self.graph.make_cell_ref_internal(
10500                    sheet_id,
10501                    anchor.coord.row() + r,
10502                    anchor.coord.col() + c,
10503                ));
10504            }
10505        }
10506
10507        match self.spill_mgr.reserve(
10508            vertex_id,
10509            anchor,
10510            SpillShape { rows: h, cols: w },
10511            SpillMeta {
10512                epoch: self.recalc_epoch,
10513                config: self.config.spill,
10514            },
10515        ) {
10516            Ok(()) => {
10517                if let Err(e) = self.commit_spill_and_mirror(
10518                    vertex_id,
10519                    &targets,
10520                    rows.clone(),
10521                    delta.as_deref_mut(),
10522                    overwritable_formulas,
10523                ) {
10524                    self.clear_spill_projection_and_mirror(vertex_id, delta.as_deref_mut());
10525                    let err_val = LiteralValue::Error(e.clone());
10526                    if let Some(d) = delta.as_deref_mut()
10527                        && d.mode != DeltaMode::Off
10528                    {
10529                        let old = self
10530                            .read_cell_value(
10531                                self.graph.sheet_name(anchor.sheet_id),
10532                                anchor.coord.row() + 1,
10533                                anchor.coord.col() + 1,
10534                            )
10535                            .unwrap_or(LiteralValue::Empty);
10536                        if old != err_val {
10537                            d.record_cell(anchor.sheet_id, anchor.coord.row(), anchor.coord.col());
10538                        }
10539                    }
10540                    self.graph.update_vertex_value(vertex_id, err_val.clone());
10541                    self.mirror_vertex_value_to_overlay(vertex_id, &err_val);
10542                    return Ok(());
10543                }
10544
10545                // Anchor shows the top-left value, like Excel
10546                let top_left = rows
10547                    .first()
10548                    .and_then(|r| r.first())
10549                    .cloned()
10550                    .unwrap_or(LiteralValue::Empty);
10551                self.graph.update_vertex_value(vertex_id, top_left.clone());
10552                self.mirror_vertex_value_to_overlay(vertex_id, &top_left);
10553                Ok(())
10554            }
10555            Err(e) => {
10556                self.clear_spill_projection_and_mirror(vertex_id, delta.as_deref_mut());
10557                let spill_err = ExcelError::new(ExcelErrorKind::Spill)
10558                    .with_message(e.message.unwrap_or_else(|| "Spill blocked".to_string()))
10559                    .with_extra(formualizer_common::ExcelErrorExtra::Spill {
10560                        expected_rows: h,
10561                        expected_cols: w,
10562                    });
10563                let spill_val = LiteralValue::Error(spill_err.clone());
10564                if let Some(d) = delta
10565                    && d.mode != DeltaMode::Off
10566                {
10567                    let old = self
10568                        .read_cell_value(
10569                            self.graph.sheet_name(anchor.sheet_id),
10570                            anchor.coord.row() + 1,
10571                            anchor.coord.col() + 1,
10572                        )
10573                        .unwrap_or(LiteralValue::Empty);
10574                    if old != spill_val {
10575                        d.record_cell(anchor.sheet_id, anchor.coord.row(), anchor.coord.col());
10576                    }
10577                }
10578                self.graph.update_vertex_value(vertex_id, spill_val.clone());
10579                self.mirror_vertex_value_to_overlay(vertex_id, &spill_val);
10580                Ok(())
10581            }
10582        }
10583    }
10584
10585    /// Evaluate a single vertex without mutating the graph (for parallel evaluation)
10586    fn evaluate_vertex_immutable(&self, vertex_id: VertexId) -> Result<LiteralValue, ExcelError> {
10587        // Check if vertex exists
10588        if !self.graph.vertex_exists(vertex_id) {
10589            return Err(ExcelError::new(formualizer_common::ExcelErrorKind::Ref)
10590                .with_message(format!("Vertex not found: {vertex_id:?}")));
10591        }
10592
10593        // Get vertex kind and check if it needs evaluation
10594        let kind = self.graph.get_vertex_kind(vertex_id);
10595        let sheet_id = self.graph.get_vertex_sheet_id(vertex_id);
10596
10597        let ast_id = match kind {
10598            VertexKind::FormulaScalar | VertexKind::FormulaArray => {
10599                if let Some(ast_id) = self.graph.get_formula_id(vertex_id) {
10600                    ast_id
10601                } else {
10602                    return Ok(LiteralValue::Number(0.0));
10603                }
10604            }
10605            VertexKind::Empty | VertexKind::Cell => {
10606                if let Some(cell_ref) = self.graph.get_cell_ref(vertex_id) {
10607                    let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
10608                    let row = cell_ref.coord.row() + 1;
10609                    let col = cell_ref.coord.col() + 1;
10610                    if let Some(v) = self.read_cell_value(sheet_name, row, col) {
10611                        return Ok(v);
10612                    }
10613                }
10614                return Ok(LiteralValue::Number(0.0));
10615            }
10616            VertexKind::NamedScalar => {
10617                let named_range = self.graph.named_range_by_vertex(vertex_id).ok_or_else(|| {
10618                    ExcelError::new(ExcelErrorKind::Name)
10619                        .with_message("Named range metadata missing".to_string())
10620                })?;
10621
10622                return match &named_range.definition {
10623                    NamedDefinition::Cell(cell_ref) => {
10624                        let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
10625                        Ok(self
10626                            .get_cell_value(
10627                                sheet_name,
10628                                cell_ref.coord.row() + 1,
10629                                cell_ref.coord.col() + 1,
10630                            )
10631                            .unwrap_or(LiteralValue::Empty))
10632                    }
10633                    NamedDefinition::Literal(v) => Ok(v.clone()),
10634                    NamedDefinition::Formula { ast, .. } => {
10635                        let context_sheet = match named_range.scope {
10636                            NameScope::Sheet(id) => id,
10637                            NameScope::Workbook => sheet_id,
10638                        };
10639                        let sheet_name = self.graph.sheet_name(context_sheet);
10640                        let cell_ref = self
10641                            .graph
10642                            .get_cell_ref(vertex_id)
10643                            .unwrap_or_else(|| self.graph.make_cell_ref(sheet_name, 0, 0));
10644                        let interpreter = Interpreter::new_with_cell(self, sheet_name, cell_ref);
10645                        interpreter.evaluate_ast(ast).map(|cv| cv.into_literal())
10646                    }
10647                    NamedDefinition::Range(_) => Err(ExcelError::new(ExcelErrorKind::Value)
10648                        .with_message("Range-valued name evaluated as scalar".to_string())),
10649                };
10650            }
10651            VertexKind::NamedArray => {
10652                let named_range = self.graph.named_range_by_vertex(vertex_id).ok_or_else(|| {
10653                    ExcelError::new(ExcelErrorKind::Name)
10654                        .with_message("Named range metadata missing".to_string())
10655                })?;
10656
10657                return match &named_range.definition {
10658                    NamedDefinition::Range(range_ref) => {
10659                        if range_ref.start.sheet_id != range_ref.end.sheet_id {
10660                            return Err(ExcelError::new(ExcelErrorKind::Ref)
10661                                .with_message("Named range cannot span sheets".to_string()));
10662                        }
10663                        let sheet_name = self.graph.sheet_name(range_ref.start.sheet_id);
10664                        let sr0 = range_ref.start.coord.row();
10665                        let sc0 = range_ref.start.coord.col();
10666                        let er0 = range_ref.end.coord.row();
10667                        let ec0 = range_ref.end.coord.col();
10668                        if sr0 > er0 || sc0 > ec0 {
10669                            return Err(ExcelError::new(ExcelErrorKind::Ref)
10670                                .with_message("Invalid named range bounds".to_string()));
10671                        }
10672
10673                        let h = (er0 - sr0 + 1) as usize;
10674                        let w = (ec0 - sc0 + 1) as usize;
10675                        let cell_count = (h as u64).saturating_mul(w as u64);
10676                        if cell_count > self.config.spill.max_spill_cells as u64 {
10677                            return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
10678                                "Named range too large to materialize as an array".to_string(),
10679                            ));
10680                        }
10681
10682                        let mut rows = Vec::with_capacity(h);
10683                        for r0 in sr0..=er0 {
10684                            let mut row = Vec::with_capacity(w);
10685                            for c0 in sc0..=ec0 {
10686                                let v = self
10687                                    .get_cell_value(sheet_name, r0 + 1, c0 + 1)
10688                                    .unwrap_or(LiteralValue::Empty);
10689                                row.push(v);
10690                            }
10691                            rows.push(row);
10692                        }
10693                        Ok(LiteralValue::Array(rows))
10694                    }
10695                    NamedDefinition::Cell(cell_ref) => {
10696                        let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
10697                        let row = cell_ref.coord.row() + 1;
10698                        let col = cell_ref.coord.col() + 1;
10699                        let v = self
10700                            .get_cell_value(sheet_name, row, col)
10701                            .unwrap_or(LiteralValue::Empty);
10702                        Ok(LiteralValue::Array(vec![vec![v]]))
10703                    }
10704                    NamedDefinition::Literal(v) => Ok(LiteralValue::Array(vec![vec![v.clone()]])),
10705                    NamedDefinition::Formula { ast, .. } => {
10706                        let context_sheet = match named_range.scope {
10707                            NameScope::Sheet(id) => id,
10708                            NameScope::Workbook => sheet_id,
10709                        };
10710                        let sheet_name = self.graph.sheet_name(context_sheet);
10711                        let cell_ref = self
10712                            .graph
10713                            .get_cell_ref(vertex_id)
10714                            .unwrap_or_else(|| self.graph.make_cell_ref(sheet_name, 0, 0));
10715                        let interpreter = Interpreter::new_with_cell(self, sheet_name, cell_ref);
10716                        match interpreter.evaluate_ast(ast) {
10717                            Ok(cv) => {
10718                                let v = cv.into_literal();
10719                                match v {
10720                                    LiteralValue::Array(_) => Ok(v),
10721                                    other => Ok(LiteralValue::Array(vec![vec![other]])),
10722                                }
10723                            }
10724                            Err(err) => Ok(LiteralValue::Error(err)),
10725                        }
10726                    }
10727                };
10728            }
10729            VertexKind::InfiniteRange
10730            | VertexKind::Range
10731            | VertexKind::External
10732            | VertexKind::Table => {
10733                // Not directly evaluatable here.
10734                return Ok(LiteralValue::Number(0.0));
10735            }
10736        };
10737
10738        // The interpreter uses a reference to the engine as the context
10739        let sheet_name = self.graph.sheet_name(sheet_id);
10740        let cell_ref = self
10741            .graph
10742            .get_cell_ref(vertex_id)
10743            .expect("cell ref for vertex");
10744        let interpreter = Interpreter::new_with_cell(self, sheet_name, cell_ref);
10745
10746        interpreter
10747            .evaluate_arena_ast(ast_id, self.graph.data_store(), self.graph.sheet_reg())
10748            .map(|cv| cv.into_literal())
10749    }
10750
10751    /// Get access to the shared thread pool for parallel evaluation
10752    pub fn thread_pool(&self) -> Option<&Arc<rayon::ThreadPool>> {
10753        self.thread_pool.as_ref()
10754    }
10755}
10756
10757#[derive(Default)]
10758struct RowBoundsCache {
10759    snapshot: u64,
10760    // key: (sheet_id, col_idx)
10761    map: rustc_hash::FxHashMap<(u32, usize), (Option<u32>, Option<u32>)>,
10762}
10763
10764impl RowBoundsCache {
10765    fn new(snapshot: u64) -> Self {
10766        Self {
10767            snapshot,
10768            map: Default::default(),
10769        }
10770    }
10771    fn get_row_bounds(
10772        &self,
10773        sheet_id: SheetId,
10774        col_idx: usize,
10775        snapshot: u64,
10776    ) -> Option<(Option<u32>, Option<u32>)> {
10777        if self.snapshot != snapshot {
10778            return None;
10779        }
10780        self.map.get(&(sheet_id as u32, col_idx)).copied()
10781    }
10782    fn put_row_bounds(
10783        &mut self,
10784        sheet_id: SheetId,
10785        col_idx: usize,
10786        snapshot: u64,
10787        bounds: (Option<u32>, Option<u32>),
10788    ) {
10789        if self.snapshot != snapshot {
10790            self.snapshot = snapshot;
10791            self.map.clear();
10792        }
10793        self.map.insert((sheet_id as u32, col_idx), bounds);
10794    }
10795}
10796
10797struct UsedAxisBoundsCache {
10798    snapshot: u64,
10799    row_bounds_by_col_span: rustc_hash::FxHashMap<(SheetId, u32, u32), Option<(u32, u32)>>,
10800    col_bounds_by_row_span: rustc_hash::FxHashMap<(SheetId, u32, u32), Option<(u32, u32)>>,
10801    #[cfg(test)]
10802    row_hits: std::sync::atomic::AtomicUsize,
10803    #[cfg(test)]
10804    row_misses: std::sync::atomic::AtomicUsize,
10805    #[cfg(test)]
10806    col_hits: std::sync::atomic::AtomicUsize,
10807    #[cfg(test)]
10808    col_misses: std::sync::atomic::AtomicUsize,
10809}
10810
10811impl UsedAxisBoundsCache {
10812    fn new(snapshot: u64) -> Self {
10813        Self {
10814            snapshot,
10815            row_bounds_by_col_span: Default::default(),
10816            col_bounds_by_row_span: Default::default(),
10817            #[cfg(test)]
10818            row_hits: std::sync::atomic::AtomicUsize::new(0),
10819            #[cfg(test)]
10820            row_misses: std::sync::atomic::AtomicUsize::new(0),
10821            #[cfg(test)]
10822            col_hits: std::sync::atomic::AtomicUsize::new(0),
10823            #[cfg(test)]
10824            col_misses: std::sync::atomic::AtomicUsize::new(0),
10825        }
10826    }
10827
10828    fn reset_for_snapshot(&mut self, snapshot: u64) {
10829        if self.snapshot != snapshot {
10830            self.snapshot = snapshot;
10831            self.row_bounds_by_col_span.clear();
10832            self.col_bounds_by_row_span.clear();
10833        }
10834    }
10835
10836    fn get_row_bounds(
10837        &self,
10838        sheet_id: SheetId,
10839        start_col: u32,
10840        end_col: u32,
10841        snapshot: u64,
10842    ) -> Option<Option<(u32, u32)>> {
10843        if self.snapshot != snapshot {
10844            return None;
10845        }
10846        let cached = self
10847            .row_bounds_by_col_span
10848            .get(&(sheet_id, start_col, end_col))
10849            .copied();
10850        #[cfg(test)]
10851        if cached.is_some() {
10852            self.row_hits.fetch_add(1, Ordering::Relaxed);
10853        }
10854        cached
10855    }
10856
10857    fn put_row_bounds(
10858        &mut self,
10859        sheet_id: SheetId,
10860        start_col: u32,
10861        end_col: u32,
10862        snapshot: u64,
10863        bounds: Option<(u32, u32)>,
10864    ) {
10865        self.reset_for_snapshot(snapshot);
10866        self.row_bounds_by_col_span
10867            .insert((sheet_id, start_col, end_col), bounds);
10868        #[cfg(test)]
10869        self.row_misses.fetch_add(1, Ordering::Relaxed);
10870    }
10871
10872    fn get_col_bounds(
10873        &self,
10874        sheet_id: SheetId,
10875        start_row: u32,
10876        end_row: u32,
10877        snapshot: u64,
10878    ) -> Option<Option<(u32, u32)>> {
10879        if self.snapshot != snapshot {
10880            return None;
10881        }
10882        let cached = self
10883            .col_bounds_by_row_span
10884            .get(&(sheet_id, start_row, end_row))
10885            .copied();
10886        #[cfg(test)]
10887        if cached.is_some() {
10888            self.col_hits.fetch_add(1, Ordering::Relaxed);
10889        }
10890        cached
10891    }
10892
10893    fn put_col_bounds(
10894        &mut self,
10895        sheet_id: SheetId,
10896        start_row: u32,
10897        end_row: u32,
10898        snapshot: u64,
10899        bounds: Option<(u32, u32)>,
10900    ) {
10901        self.reset_for_snapshot(snapshot);
10902        self.col_bounds_by_row_span
10903            .insert((sheet_id, start_row, end_row), bounds);
10904        #[cfg(test)]
10905        self.col_misses.fetch_add(1, Ordering::Relaxed);
10906    }
10907}
10908
10909// Phase 2 shim: in-process spill manager delegating to current graph methods.
10910#[derive(Default)]
10911pub struct ShimSpillManager {
10912    region_locks: RegionLockManager,
10913    pub(crate) active_locks: rustc_hash::FxHashMap<VertexId, u64>,
10914}
10915
10916impl ShimSpillManager {
10917    pub(crate) fn reserve(
10918        &mut self,
10919        owner: VertexId,
10920        anchor_cell: CellRef,
10921        shape: SpillShape,
10922        _meta: SpillMeta,
10923    ) -> Result<(), ExcelError> {
10924        // Derive region from anchor + shape; enforce in-flight exclusivity only.
10925        let region = crate::engine::spill::Region {
10926            sheet_id: anchor_cell.sheet_id as u32,
10927            row_start: anchor_cell.coord.row(),
10928            row_end: anchor_cell
10929                .coord
10930                .row()
10931                .saturating_add(shape.rows)
10932                .saturating_sub(1),
10933            col_start: anchor_cell.coord.col(),
10934            col_end: anchor_cell
10935                .coord
10936                .col()
10937                .saturating_add(shape.cols)
10938                .saturating_sub(1),
10939        };
10940        match self.region_locks.reserve(region, owner) {
10941            Ok(id) => {
10942                if id != 0 {
10943                    self.active_locks.insert(owner, id);
10944                }
10945                Ok(())
10946            }
10947            Err(e) => Err(e),
10948        }
10949    }
10950
10951    /// Release any in-flight region reservation still held for `owner`.
10952    ///
10953    /// Reservations are normally released on commit/rollback, but if an anchor is
10954    /// abandoned without committing (e.g. cycle detection stamps it with #CIRC), a
10955    /// stale reservation could remain. This is a no-op when nothing is held.
10956    pub(crate) fn release_owner(&mut self, owner: VertexId) {
10957        if let Some(id) = self.active_locks.remove(&owner) {
10958            self.region_locks.release(id);
10959        }
10960    }
10961
10962    pub(crate) fn commit_array_with_value_probe<F>(
10963        &mut self,
10964        graph: &mut DependencyGraph,
10965        anchor_vertex: VertexId,
10966        targets: &[CellRef],
10967        rows: Vec<Vec<LiteralValue>>,
10968        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
10969        mut value_probe: F,
10970    ) -> Result<(), ExcelError>
10971    where
10972        F: FnMut(&DependencyGraph, &CellRef) -> Option<LiteralValue>,
10973    {
10974        use formualizer_common::{ExcelErrorExtra, ExcelErrorKind};
10975
10976        // Re-run plan on concrete targets before committing to respect blockers.
10977        // This plan checks formula/spill ownership in the graph, but when the graph value cache
10978        // is disabled (Arrow-canonical mode), it cannot see non-empty value blockers.
10979        let plan_res = graph.plan_spill_region_allowing_formula_overwrite(
10980            anchor_vertex,
10981            targets,
10982            overwritable_formulas,
10983        );
10984        if let Err(e) = plan_res {
10985            if let Some(id) = self.active_locks.remove(&anchor_vertex) {
10986                self.region_locks.release(id);
10987            }
10988            return Err(e);
10989        }
10990
10991        if !graph.value_cache_enabled() {
10992            // Compute expected spill shape from the target rectangle for diagnostics.
10993            let (expected_rows, expected_cols) = if targets.is_empty() {
10994                (0u32, 0u32)
10995            } else {
10996                let mut min_r = u32::MAX;
10997                let mut max_r = 0u32;
10998                let mut min_c = u32::MAX;
10999                let mut max_c = 0u32;
11000                for cell in targets {
11001                    let r = cell.coord.row();
11002                    let c = cell.coord.col();
11003                    min_r = min_r.min(r);
11004                    max_r = max_r.max(r);
11005                    min_c = min_c.min(c);
11006                    max_c = max_c.max(c);
11007                }
11008                (
11009                    max_r.saturating_sub(min_r).saturating_add(1),
11010                    max_c.saturating_sub(min_c).saturating_add(1),
11011                )
11012            };
11013
11014            let anchor_cell = graph
11015                .get_cell_ref(anchor_vertex)
11016                .expect("anchor cell ref for spill commit");
11017
11018            for cell in targets {
11019                // Never treat the anchor as a blocker.
11020                if *cell == anchor_cell {
11021                    continue;
11022                }
11023                // Skip cells already known to be owned by a spill; plan() handled spill conflicts.
11024                if graph.spill_registry_anchor_for_cell(*cell).is_some() {
11025                    continue;
11026                }
11027                // Skip formula vertices in the target region; plan() handled them (or allowed).
11028                if let Some(&vid) = graph.get_vertex_id_for_address(cell)
11029                    && vid != anchor_vertex
11030                {
11031                    match graph.get_vertex_kind(vid) {
11032                        crate::engine::vertex::VertexKind::FormulaScalar
11033                        | crate::engine::vertex::VertexKind::FormulaArray => {
11034                            // plan() already approved allowed overwrites.
11035                            continue;
11036                        }
11037                        _ => {}
11038                    }
11039                }
11040
11041                if let Some(v) = value_probe(graph, cell)
11042                    && !matches!(v, LiteralValue::Empty)
11043                {
11044                    if let Some(id) = self.active_locks.remove(&anchor_vertex) {
11045                        self.region_locks.release(id);
11046                    }
11047                    return Err(ExcelError::new(ExcelErrorKind::Spill)
11048                        .with_message("BlockedByValue")
11049                        .with_extra(ExcelErrorExtra::Spill {
11050                            expected_rows,
11051                            expected_cols,
11052                        }));
11053                }
11054            }
11055        }
11056
11057        let commit_res = graph.commit_spill_region_atomic_with_fault(
11058            anchor_vertex,
11059            targets.to_vec(),
11060            rows,
11061            None,
11062        );
11063        if let Some(id) = self.active_locks.remove(&anchor_vertex) {
11064            self.region_locks.release(id);
11065        }
11066        commit_res.map(|_| ())
11067    }
11068
11069    /// Commit a spill and mirror all written cells into Arrow overlay via the owning engine.
11070    pub(crate) fn commit_array_with_overlay<R: EvaluationContext>(
11071        &mut self,
11072        engine: &mut Engine<R>,
11073        anchor_vertex: VertexId,
11074        targets: &[CellRef],
11075        rows: Vec<Vec<LiteralValue>>,
11076        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
11077    ) -> Result<(), ExcelError> {
11078        // Re-run plan on concrete targets before committing to respect blockers.
11079        let plan_res = engine.graph.plan_spill_region_allowing_formula_overwrite(
11080            anchor_vertex,
11081            targets,
11082            overwritable_formulas,
11083        );
11084        if let Err(e) = plan_res {
11085            if let Some(id) = self.active_locks.remove(&anchor_vertex) {
11086                self.region_locks.release(id);
11087            }
11088            return Err(e);
11089        }
11090
11091        let commit_res = engine.graph.commit_spill_region_atomic_with_fault(
11092            anchor_vertex,
11093            targets.to_vec(),
11094            rows.clone(),
11095            None,
11096        );
11097        if let Some(id) = self.active_locks.remove(&anchor_vertex) {
11098            self.region_locks.release(id);
11099        }
11100        commit_res.map(|_| ())?;
11101
11102        // Mirror into Arrow overlay when enabled
11103        if engine.config.arrow_storage_enabled
11104            && engine.config.delta_overlay_enabled
11105            && engine.config.write_formula_overlay_enabled
11106        {
11107            // Expect targets to be a contiguous rectangle row-major starting at some anchor
11108            for (idx, cell) in targets.iter().enumerate() {
11109                let (r_off, c_off) = {
11110                    if rows.is_empty() || rows[0].is_empty() {
11111                        (0usize, 0usize)
11112                    } else {
11113                        let width = rows[0].len();
11114                        (idx / width, idx % width)
11115                    }
11116                };
11117                let v = rows
11118                    .get(r_off)
11119                    .and_then(|r| r.get(c_off))
11120                    .cloned()
11121                    .unwrap_or(LiteralValue::Empty);
11122                let sheet_name = engine.graph.sheet_name(cell.sheet_id).to_string();
11123                engine.mirror_value_to_computed_overlay(
11124                    &sheet_name,
11125                    cell.coord.row() + 1,
11126                    cell.coord.col() + 1,
11127                    &v,
11128                );
11129            }
11130        }
11131        Ok(())
11132    }
11133}
11134
11135impl<R> Engine<R>
11136where
11137    R: EvaluationContext,
11138{
11139    fn resolve_shared_ref(
11140        &self,
11141        reference: &ReferenceType,
11142        current_sheet: &str,
11143    ) -> Result<formualizer_common::SheetRef<'static>, ExcelError> {
11144        use formualizer_common::{
11145            SheetCellRef as SharedCellRef, SheetLocator, SheetRangeRef as SharedRangeRef,
11146            SheetRef as SharedRef,
11147        };
11148
11149        // Preserve anchor flags from the parsed reference when possible.
11150        let sr = match reference {
11151            ReferenceType::Cell {
11152                sheet,
11153                row,
11154                col,
11155                row_abs,
11156                col_abs,
11157            } => {
11158                let row0 = row
11159                    .checked_sub(1)
11160                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11161                let col0 = col
11162                    .checked_sub(1)
11163                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11164                let sheet_loc = match sheet.as_deref() {
11165                    Some(name) => SheetLocator::from_name(name),
11166                    None => SheetLocator::Current,
11167                };
11168                let coord = formualizer_common::RelativeCoord::new(row0, col0, *row_abs, *col_abs);
11169                SharedRef::Cell(SharedCellRef::new(sheet_loc, coord))
11170            }
11171            ReferenceType::Range {
11172                sheet,
11173                start_row,
11174                start_col,
11175                end_row,
11176                end_col,
11177                start_row_abs,
11178                start_col_abs,
11179                end_row_abs,
11180                end_col_abs,
11181            } => {
11182                let sheet_loc = match sheet.as_deref() {
11183                    Some(name) => SheetLocator::from_name(name),
11184                    None => SheetLocator::Current,
11185                };
11186                let sr = start_row
11187                    .map(|r| {
11188                        r.checked_sub(1)
11189                            .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))
11190                    })
11191                    .transpose()?;
11192                let sc = start_col
11193                    .map(|c| {
11194                        c.checked_sub(1)
11195                            .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))
11196                    })
11197                    .transpose()?;
11198                let er = end_row
11199                    .map(|r| {
11200                        r.checked_sub(1)
11201                            .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))
11202                    })
11203                    .transpose()?;
11204                let ec = end_col
11205                    .map(|c| {
11206                        c.checked_sub(1)
11207                            .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))
11208                    })
11209                    .transpose()?;
11210                let range = SharedRangeRef::from_parts(
11211                    sheet_loc,
11212                    sr.map(|idx| formualizer_common::AxisBound::new(idx, *start_row_abs)),
11213                    sc.map(|idx| formualizer_common::AxisBound::new(idx, *start_col_abs)),
11214                    er.map(|idx| formualizer_common::AxisBound::new(idx, *end_row_abs)),
11215                    ec.map(|idx| formualizer_common::AxisBound::new(idx, *end_col_abs)),
11216                )
11217                .map_err(|_| ExcelError::new(ExcelErrorKind::Ref))?;
11218                SharedRef::Range(range)
11219            }
11220            _ => return Err(ExcelError::new(ExcelErrorKind::Ref)),
11221        };
11222
11223        let current_id = self
11224            .graph
11225            .sheet_id(current_sheet)
11226            .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11227
11228        let resolve_loc = |loc: SheetLocator<'_>| -> Result<SheetLocator<'static>, ExcelError> {
11229            match loc {
11230                SheetLocator::Current => Ok(SheetLocator::Id(current_id)),
11231                SheetLocator::Id(id) => Ok(SheetLocator::Id(id)),
11232                SheetLocator::Name(name) => {
11233                    let n = name.as_ref();
11234                    self.graph
11235                        .sheet_id(n)
11236                        .map(SheetLocator::Id)
11237                        .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))
11238                }
11239            }
11240        };
11241
11242        match sr {
11243            SharedRef::Cell(cell) => {
11244                let owned = cell.into_owned();
11245                let sheet = resolve_loc(owned.sheet)?;
11246                Ok(SharedRef::Cell(SharedCellRef::new(sheet, owned.coord)))
11247            }
11248            SharedRef::Range(range) => {
11249                let owned = range.into_owned();
11250                let sheet = resolve_loc(owned.sheet)?;
11251                Ok(SharedRef::Range(SharedRangeRef {
11252                    sheet,
11253                    start_row: owned.start_row,
11254                    start_col: owned.start_col,
11255                    end_row: owned.end_row,
11256                    end_col: owned.end_col,
11257                }))
11258            }
11259        }
11260    }
11261}
11262
11263// Implement the resolver traits for the Engine.
11264// This allows the interpreter to resolve references by querying the engine's graph.
11265impl<R> crate::traits::ReferenceResolver for Engine<R>
11266where
11267    R: EvaluationContext,
11268{
11269    fn resolve_cell_reference(
11270        &self,
11271        sheet: Option<&str>,
11272        row: u32,
11273        col: u32,
11274    ) -> Result<LiteralValue, ExcelError> {
11275        // This context-free trait method has no knowledge of the formula's
11276        // current sheet, so an unqualified (`None`) reference cannot be resolved
11277        // here. Previously this fell back to `default_sheet_name()`, which leaked
11278        // the reference onto an unrelated sheet (issue #110). Interpreter paths
11279        // already qualify references with the current sheet before reaching this
11280        // method (see `Interpreter::implicit_intersection_from_reference`), and
11281        // the sheet-aware scalar path goes through `resolve_cell_reference_value`
11282        // with an explicit `current_sheet`. Returning #REF! for an unqualified
11283        // reference here surfaces the missing context instead of silently
11284        // returning data from the wrong sheet.
11285        let Some(sheet_name) = sheet else {
11286            return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(
11287                "Unqualified cell reference resolved without sheet context".to_string(),
11288            ));
11289        };
11290        // Prefer engine's unified accessor which consults Arrow store for base values
11291        // and falls back to graph for formulas and stored values.
11292        if let Some(v) = self.get_cell_value(sheet_name, row, col) {
11293            Ok(v)
11294        } else {
11295            // Excel semantics: empty cell coerces to 0 in numeric contexts
11296            Ok(LiteralValue::Number(0.0))
11297        }
11298    }
11299}
11300
11301impl<R> crate::traits::RangeResolver for Engine<R>
11302where
11303    R: EvaluationContext,
11304{
11305    fn resolve_range_reference(
11306        &self,
11307        sheet: Option<&str>,
11308        sr: Option<u32>,
11309        sc: Option<u32>,
11310        er: Option<u32>,
11311        ec: Option<u32>,
11312    ) -> Result<Box<dyn crate::traits::Range>, ExcelError> {
11313        // For now, delegate range resolution to the external resolver.
11314        // A future optimization could be to handle this within the graph.
11315        self.resolver.resolve_range_reference(sheet, sr, sc, er, ec)
11316    }
11317}
11318
11319impl<R> crate::traits::NamedRangeResolver for Engine<R>
11320where
11321    R: EvaluationContext,
11322{
11323    fn resolve_named_range_reference(
11324        &self,
11325        name: &str,
11326    ) -> Result<Vec<Vec<LiteralValue>>, ExcelError> {
11327        self.resolver.resolve_named_range_reference(name)
11328    }
11329}
11330
11331impl<R> crate::traits::TableResolver for Engine<R>
11332where
11333    R: EvaluationContext,
11334{
11335    fn resolve_table_reference(
11336        &self,
11337        tref: &formualizer_parse::parser::TableReference,
11338    ) -> Result<Box<dyn crate::traits::Table>, ExcelError> {
11339        self.resolver.resolve_table_reference(tref)
11340    }
11341}
11342
11343impl<R> crate::traits::SourceResolver for Engine<R>
11344where
11345    R: EvaluationContext,
11346{
11347    fn source_scalar_version(&self, name: &str) -> Option<u64> {
11348        self.resolver.source_scalar_version(name)
11349    }
11350
11351    fn resolve_source_scalar(&self, name: &str) -> Result<LiteralValue, ExcelError> {
11352        self.resolver.resolve_source_scalar(name)
11353    }
11354
11355    fn source_table_version(&self, name: &str) -> Option<u64> {
11356        self.resolver.source_table_version(name)
11357    }
11358
11359    fn resolve_source_table(
11360        &self,
11361        name: &str,
11362    ) -> Result<Box<dyn crate::traits::Table>, ExcelError> {
11363        self.resolver.resolve_source_table(name)
11364    }
11365}
11366
11367// The Engine is a Resolver because it implements the constituent traits.
11368impl<R> crate::traits::Resolver for Engine<R> where R: EvaluationContext {}
11369
11370// The Engine provides functions by delegating to its internal resolver.
11371impl<R> crate::traits::FunctionProvider for Engine<R>
11372where
11373    R: EvaluationContext,
11374{
11375    fn get_function(
11376        &self,
11377        prefix: &str,
11378        name: &str,
11379    ) -> Option<std::sync::Arc<dyn crate::function::Function>> {
11380        self.resolver.get_function(prefix, name)
11381    }
11382}
11383
11384// Override EvaluationContext to provide thread pool access
11385impl<R> crate::traits::EvaluationContext for Engine<R>
11386where
11387    R: EvaluationContext,
11388{
11389    fn clock(&self) -> &dyn crate::timezone::ClockProvider {
11390        &self.clock
11391    }
11392
11393    fn thread_pool(&self) -> Option<&Arc<rayon::ThreadPool>> {
11394        self.thread_pool.as_ref()
11395    }
11396
11397    fn cancellation_token(&self) -> Option<Arc<std::sync::atomic::AtomicBool>> {
11398        self.active_cancel_flag.clone()
11399    }
11400
11401    fn chunk_hint(&self) -> Option<usize> {
11402        // Use a simple heuristic from configuration (stripe width * height) as a default hint.
11403        let hint =
11404            (self.config.stripe_height as usize).saturating_mul(self.config.stripe_width as usize);
11405        Some(hint.clamp(1024, 1 << 20)) // clamp between 1K and ~1M
11406    }
11407
11408    fn volatile_level(&self) -> crate::traits::VolatileLevel {
11409        self.config.volatile_level
11410    }
11411
11412    fn workbook_seed(&self) -> u64 {
11413        self.config.workbook_seed
11414    }
11415
11416    fn recalc_epoch(&self) -> u64 {
11417        self.recalc_epoch
11418    }
11419
11420    fn workbook_sheet_count(&self) -> Option<usize> {
11421        Some(self.graph.sheet_reg().active_len())
11422    }
11423
11424    fn sheet_index_by_name(&self, sheet: &str) -> Option<usize> {
11425        self.graph.sheet_reg().active_position(sheet)
11426    }
11427
11428    fn current_sheet_index(&self, current_sheet: &str) -> Option<usize> {
11429        self.sheet_index_by_name(current_sheet)
11430    }
11431
11432    fn inspect_reference(
11433        &self,
11434        reference: &ReferenceType,
11435        current_sheet: &str,
11436    ) -> Result<Option<ReferenceInfo>, ExcelError> {
11437        let sheet_info = |sheet_name: &str| -> Result<(SheetId, usize), ExcelError> {
11438            let sheet_id = self
11439                .graph
11440                .sheet_id(sheet_name)
11441                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11442            let sheet_index = self
11443                .graph
11444                .sheet_reg()
11445                .active_position_by_id(sheet_id)
11446                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11447            Ok((sheet_id, sheet_index))
11448        };
11449
11450        let cell_info =
11451            |sheet_name: &str, row: u32, col: u32| -> Result<ReferenceInfo, ExcelError> {
11452                let (sheet_id, sheet_index) = sheet_info(sheet_name)?;
11453                let row0 = row
11454                    .checked_sub(1)
11455                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11456                let col0 = col
11457                    .checked_sub(1)
11458                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11459                Ok(ReferenceInfo {
11460                    first_sheet_index: Some(sheet_index),
11461                    sheet_count: Some(1),
11462                    first_cell: Some(CellRef::new(sheet_id, Coord::new(row0, col0, true, true))),
11463                })
11464            };
11465
11466        let range_info = |sheet_name: &str,
11467                          start_row: Option<u32>,
11468                          start_col: Option<u32>|
11469         -> Result<ReferenceInfo, ExcelError> {
11470            let (sheet_id, sheet_index) = sheet_info(sheet_name)?;
11471            let row = start_row.unwrap_or(1);
11472            let col = start_col.unwrap_or(1);
11473            let row0 = row
11474                .checked_sub(1)
11475                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11476            let col0 = col
11477                .checked_sub(1)
11478                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11479            Ok(ReferenceInfo {
11480                first_sheet_index: Some(sheet_index),
11481                sheet_count: Some(1),
11482                first_cell: Some(CellRef::new(sheet_id, Coord::new(row0, col0, true, true))),
11483            })
11484        };
11485
11486        let info = match reference {
11487            ReferenceType::Cell {
11488                sheet, row, col, ..
11489            } => {
11490                let sheet_name = sheet.as_deref().unwrap_or(current_sheet);
11491                cell_info(sheet_name, *row, *col)?
11492            }
11493            ReferenceType::Range {
11494                sheet,
11495                start_row,
11496                start_col,
11497                ..
11498            } => {
11499                let sheet_name = sheet.as_deref().unwrap_or(current_sheet);
11500                range_info(sheet_name, *start_row, *start_col)?
11501            }
11502            ReferenceType::Cell3D {
11503                sheet_first,
11504                sheet_last,
11505                row,
11506                col,
11507                ..
11508            } => {
11509                let first = cell_info(sheet_first, *row, *col)?;
11510                ReferenceInfo {
11511                    first_sheet_index: first.first_sheet_index,
11512                    sheet_count: self
11513                        .graph
11514                        .sheet_reg()
11515                        .active_span_len(sheet_first, sheet_last),
11516                    first_cell: first.first_cell,
11517                }
11518            }
11519            ReferenceType::Range3D {
11520                sheet_first,
11521                sheet_last,
11522                start_row,
11523                start_col,
11524                ..
11525            } => {
11526                let first = range_info(sheet_first, *start_row, *start_col)?;
11527                ReferenceInfo {
11528                    first_sheet_index: first.first_sheet_index,
11529                    sheet_count: self
11530                        .graph
11531                        .sheet_reg()
11532                        .active_span_len(sheet_first, sheet_last),
11533                    first_cell: first.first_cell,
11534                }
11535            }
11536            ReferenceType::NamedRange(name) => {
11537                let current_id = self
11538                    .graph
11539                    .sheet_id(current_sheet)
11540                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11541                let named = self
11542                    .graph
11543                    .resolve_name_entry(name, current_id)
11544                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11545                match &named.definition {
11546                    NamedDefinition::Cell(cell) => ReferenceInfo {
11547                        first_sheet_index: self
11548                            .graph
11549                            .sheet_reg()
11550                            .active_position_by_id(cell.sheet_id),
11551                        sheet_count: Some(1),
11552                        first_cell: Some(*cell),
11553                    },
11554                    NamedDefinition::Range(range) => ReferenceInfo {
11555                        first_sheet_index: self
11556                            .graph
11557                            .sheet_reg()
11558                            .active_position_by_id(range.start.sheet_id),
11559                        sheet_count: Some(1),
11560                        first_cell: Some(range.start),
11561                    },
11562                    NamedDefinition::Literal(_) | NamedDefinition::Formula { .. } => {
11563                        ReferenceInfo {
11564                            first_sheet_index: None,
11565                            sheet_count: None,
11566                            first_cell: None,
11567                        }
11568                    }
11569                }
11570            }
11571            ReferenceType::Table(tref) => {
11572                let table = self
11573                    .graph
11574                    .resolve_table_entry(&tref.name)
11575                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11576                ReferenceInfo {
11577                    first_sheet_index: self
11578                        .graph
11579                        .sheet_reg()
11580                        .active_position_by_id(table.range.start.sheet_id),
11581                    sheet_count: Some(1),
11582                    first_cell: Some(table.range.start),
11583                }
11584            }
11585            ReferenceType::External(_) => return Err(ExcelError::new(ExcelErrorKind::Ref)),
11586        };
11587
11588        Ok(Some(info))
11589    }
11590
11591    fn formula_text_at_cell(&self, cell: CellRef) -> Result<Option<String>, ExcelError> {
11592        let sheet_name = self.graph.sheet_name(cell.sheet_id);
11593        if sheet_name.is_empty() {
11594            return Err(ExcelError::new(ExcelErrorKind::Ref));
11595        }
11596        let row = cell.coord.row() + 1;
11597        let col = cell.coord.col() + 1;
11598
11599        if let Some(entries) = self.staged_formulas.get(sheet_name)
11600            && let Some(text) = entries.get(row, col)
11601        {
11602            return Ok(Some(if text.starts_with('=') {
11603                text.to_owned()
11604            } else {
11605                format!("={text}")
11606            }));
11607        }
11608
11609        let Some(vertex) = self.graph.get_vertex_for_cell(&cell) else {
11610            return Ok(None);
11611        };
11612        let Some(ast) = self.graph.get_formula(vertex) else {
11613            return Ok(None);
11614        };
11615        Ok(Some(formualizer_parse::pretty::canonical_formula(&ast)))
11616    }
11617
11618    fn used_rows_for_columns(
11619        &self,
11620        sheet: &str,
11621        start_col: u32,
11622        end_col: u32,
11623    ) -> Option<(u32, u32)> {
11624        // Union Arrow-backed used-region with formula rows that have not been materialized yet.
11625        let sheet_id = self.graph.sheet_id(sheet)?;
11626        let snap = self.data_snapshot_id();
11627        if let Some(cached) = self.used_axis_bounds_cache.read().ok().and_then(|guard| {
11628            guard
11629                .as_ref()
11630                .and_then(|cache| cache.get_row_bounds(sheet_id, start_col, end_col, snap))
11631        }) {
11632            return cached;
11633        }
11634
11635        let arrow_bounds = self
11636            .sheet_store()
11637            .sheet(sheet)
11638            .and_then(|_| self.arrow_used_row_bounds(sheet, start_col, end_col));
11639        let formula_bounds = self.formula_row_bounds_for_columns(sheet, start_col, end_col);
11640        let computed = if let Some(bounds) = Self::union_used_bounds(arrow_bounds, formula_bounds) {
11641            Some(bounds)
11642        } else {
11643            let sc0 = start_col.saturating_sub(1);
11644            let ec0 = end_col.saturating_sub(1);
11645            self.graph
11646                .used_row_bounds_for_columns(sheet_id, sc0, ec0)
11647                .map(|(a0, b0)| (a0 + 1, b0 + 1))
11648        };
11649
11650        if let Ok(mut guard) = self.used_axis_bounds_cache.write() {
11651            guard
11652                .get_or_insert_with(|| UsedAxisBoundsCache::new(snap))
11653                .put_row_bounds(sheet_id, start_col, end_col, snap, computed);
11654        }
11655
11656        computed
11657    }
11658
11659    fn used_cols_for_rows(&self, sheet: &str, start_row: u32, end_row: u32) -> Option<(u32, u32)> {
11660        // Union Arrow-backed used-region with formula columns that have not been materialized yet.
11661        let sheet_id = self.graph.sheet_id(sheet)?;
11662        let snap = self.data_snapshot_id();
11663        if let Some(cached) = self.used_axis_bounds_cache.read().ok().and_then(|guard| {
11664            guard
11665                .as_ref()
11666                .and_then(|cache| cache.get_col_bounds(sheet_id, start_row, end_row, snap))
11667        }) {
11668            return cached;
11669        }
11670
11671        let arrow_bounds = self
11672            .sheet_store()
11673            .sheet(sheet)
11674            .and_then(|_| self.arrow_used_col_bounds(sheet, start_row, end_row));
11675        let formula_bounds = self.formula_col_bounds_for_rows(sheet, start_row, end_row);
11676        let computed = if let Some(bounds) = Self::union_used_bounds(arrow_bounds, formula_bounds) {
11677            Some(bounds)
11678        } else {
11679            let sr0 = start_row.saturating_sub(1);
11680            let er0 = end_row.saturating_sub(1);
11681            self.graph
11682                .used_col_bounds_for_rows(sheet_id, sr0, er0)
11683                .map(|(a0, b0)| (a0 + 1, b0 + 1))
11684        };
11685
11686        if let Ok(mut guard) = self.used_axis_bounds_cache.write() {
11687            guard
11688                .get_or_insert_with(|| UsedAxisBoundsCache::new(snap))
11689                .put_col_bounds(sheet_id, start_row, end_row, snap, computed);
11690        }
11691
11692        computed
11693    }
11694
11695    fn sheet_bounds(&self, sheet: &str) -> Option<(u32, u32)> {
11696        let _ = self.graph.sheet_id(sheet)?;
11697        // Excel-like upper bounds; we expose something finite but large.
11698        // Backends may override with real bounds.
11699        Some((1_048_576, 16_384)) // 1048576 rows, 16384 cols (XFD)
11700    }
11701
11702    fn data_snapshot_id(&self) -> u64 {
11703        self.snapshot_id.load(std::sync::atomic::Ordering::Relaxed)
11704    }
11705
11706    fn backend_caps(&self) -> crate::traits::BackendCaps {
11707        crate::traits::BackendCaps {
11708            streaming: true,
11709            used_region: true,
11710            write: false,
11711            tables: false,
11712            async_stream: false,
11713        }
11714    }
11715
11716    fn build_lookup_index(
11717        &self,
11718        view: &RangeView<'_>,
11719        axis: LookupAxis,
11720    ) -> Option<Arc<LookupIndex>> {
11721        self.build_lookup_index_impl(view, axis)
11722    }
11723
11724    // Flats removed
11725
11726    fn date_system(&self) -> crate::engine::DateSystem {
11727        self.config.date_system
11728    }
11729    /// New: resolve a reference into a RangeView (Phase 2 API)
11730    fn resolve_range_view<'c>(
11731        &'c self,
11732        reference: &ReferenceType,
11733        current_sheet: &str,
11734    ) -> Result<RangeView<'c>, ExcelError> {
11735        match reference {
11736            ReferenceType::External(ext) => {
11737                let name = ext.raw.as_str();
11738                match ext.kind {
11739                    formualizer_parse::parser::ExternalRefKind::Cell { .. } => {
11740                        let Some(source) = self.graph.resolve_source_scalar_entry(name) else {
11741                            return Err(ExcelError::new(ExcelErrorKind::Name)
11742                                .with_message(format!("Undefined name: {name}")));
11743                        };
11744                        let version = source
11745                            .version
11746                            .or_else(|| self.resolver.source_scalar_version(name));
11747                        let v = self.resolve_source_scalar_cached(name, version)?;
11748                        Ok(RangeView::from_owned_rows(
11749                            vec![vec![v]],
11750                            self.config.date_system,
11751                        ))
11752                    }
11753                    formualizer_parse::parser::ExternalRefKind::Range { .. } => {
11754                        let Some(source) = self.graph.resolve_source_table_entry(name) else {
11755                            return Err(ExcelError::new(ExcelErrorKind::Name)
11756                                .with_message(format!("Undefined table: {name}")));
11757                        };
11758                        let version = source
11759                            .version
11760                            .or_else(|| self.resolver.source_table_version(name));
11761                        let table = self.resolve_source_table_cached(name, version)?;
11762                        let spec = Some(formualizer_parse::parser::TableSpecifier::Data);
11763                        self.source_table_to_range_view(table.as_ref(), &spec)
11764                    }
11765                }
11766            }
11767            ReferenceType::Range { .. } => {
11768                let shared = self.resolve_shared_ref(reference, current_sheet)?;
11769                let formualizer_common::SheetRef::Range(range) = shared else {
11770                    return Err(ExcelError::new(ExcelErrorKind::Ref));
11771                };
11772                let sheet_id = match range.sheet {
11773                    formualizer_common::SheetLocator::Id(id) => id,
11774                    _ => return Err(ExcelError::new(ExcelErrorKind::Ref)),
11775                };
11776                let sheet_name = self.graph.sheet_name(sheet_id);
11777
11778                let bounded_range = if range.start_row.is_some()
11779                    && range.start_col.is_some()
11780                    && range.end_row.is_some()
11781                    && range.end_col.is_some()
11782                {
11783                    Some(RangeRef::try_from_shared(range.as_ref())?)
11784                } else {
11785                    None
11786                };
11787
11788                let mut sr = bounded_range
11789                    .as_ref()
11790                    .map(|r| r.start.coord.row() + 1)
11791                    .or_else(|| range.start_row.map(|b| b.index + 1));
11792                let mut sc = bounded_range
11793                    .as_ref()
11794                    .map(|r| r.start.coord.col() + 1)
11795                    .or_else(|| range.start_col.map(|b| b.index + 1));
11796                let mut er = bounded_range
11797                    .as_ref()
11798                    .map(|r| r.end.coord.row() + 1)
11799                    .or_else(|| range.end_row.map(|b| b.index + 1));
11800                let mut ec = bounded_range
11801                    .as_ref()
11802                    .map(|r| r.end.coord.col() + 1)
11803                    .or_else(|| range.end_col.map(|b| b.index + 1));
11804
11805                if sr.is_none() && er.is_none() {
11806                    // Full-column reference: anchor at row 1
11807                    let scv = sc.unwrap_or(1);
11808                    let ecv = ec.unwrap_or(scv);
11809                    sr = Some(1);
11810                    if let Some((_, max_r)) = self.used_rows_for_columns(sheet_name, scv, ecv) {
11811                        er = Some(max_r);
11812                    } else if let Some((max_rows, _)) = self.sheet_bounds(sheet_name) {
11813                        er = Some(self.config.max_open_ended_rows);
11814                    }
11815                }
11816                if sc.is_none() && ec.is_none() {
11817                    // Full-row reference: anchor at column 1
11818                    let srv = sr.unwrap_or(1);
11819                    let erv = er.unwrap_or(srv);
11820                    sc = Some(1);
11821                    if let Some((_, max_c)) = self.used_cols_for_rows(sheet_name, srv, erv) {
11822                        ec = Some(max_c);
11823                    } else if let Some((_, max_cols)) = self.sheet_bounds(sheet_name) {
11824                        ec = Some(self.config.max_open_ended_cols);
11825                    }
11826                }
11827                if sr.is_some() && er.is_none() {
11828                    let scv = sc.unwrap_or(1);
11829                    let ecv = ec.unwrap_or(scv);
11830                    if let Some((_, max_r)) = self.used_rows_for_columns(sheet_name, scv, ecv) {
11831                        er = Some(max_r);
11832                    } else if let Some((max_rows, _)) = self.sheet_bounds(sheet_name) {
11833                        er = Some(self.config.max_open_ended_rows);
11834                    }
11835                }
11836                if er.is_some() && sr.is_none() {
11837                    // Open start: anchor at row 1
11838                    sr = Some(1);
11839                }
11840                if sc.is_some() && ec.is_none() {
11841                    let srv = sr.unwrap_or(1);
11842                    let erv = er.unwrap_or(srv);
11843                    if let Some((_, max_c)) = self.used_cols_for_rows(sheet_name, srv, erv) {
11844                        ec = Some(max_c);
11845                    } else if let Some((_, max_cols)) = self.sheet_bounds(sheet_name) {
11846                        ec = Some(self.config.max_open_ended_cols);
11847                    }
11848                }
11849                if ec.is_some() && sc.is_none() {
11850                    // Open start: anchor at column 1
11851                    sc = Some(1);
11852                }
11853
11854                let sr = sr.unwrap_or(1);
11855                let sc = sc.unwrap_or(1);
11856                let er = er.unwrap_or(sr.saturating_sub(1));
11857                let ec = ec.unwrap_or(sc.saturating_sub(1));
11858
11859                if self.force_materialize_range_views {
11860                    if er < sr || ec < sc {
11861                        return Ok(RangeView::from_owned_rows(
11862                            Vec::new(),
11863                            self.config.date_system,
11864                        ));
11865                    }
11866                    let h = (er - sr + 1) as u64;
11867                    let w = (ec - sc + 1) as u64;
11868                    let cell_count = h.saturating_mul(w);
11869                    if cell_count <= self.config.spill.max_spill_cells as u64 {
11870                        let mut rows: Vec<Vec<LiteralValue>> = Vec::with_capacity(h as usize);
11871                        for r in sr..=er {
11872                            let mut rowv: Vec<LiteralValue> = Vec::with_capacity(w as usize);
11873                            for c in sc..=ec {
11874                                rowv.push(
11875                                    self.get_cell_value(sheet_name, r, c)
11876                                        .unwrap_or(LiteralValue::Empty),
11877                                );
11878                            }
11879                            rows.push(rowv);
11880                        }
11881                        return Ok(RangeView::from_owned_rows(rows, self.config.date_system));
11882                    }
11883                }
11884
11885                let Some(asheet) = self.sheet_store().sheet(sheet_name) else {
11886                    return Ok(RangeView::from_owned_rows(
11887                        Vec::new(),
11888                        self.config.date_system,
11889                    ));
11890                };
11891
11892                let rv = if er < sr || ec < sc {
11893                    asheet.range_view(1, 1, 0, 0)
11894                } else {
11895                    let sr0 = sr.saturating_sub(1) as usize;
11896                    let sc0 = sc.saturating_sub(1) as usize;
11897                    let er0 = er.saturating_sub(1) as usize;
11898                    let ec0 = ec.saturating_sub(1) as usize;
11899                    asheet.range_view(sr0, sc0, er0, ec0)
11900                };
11901
11902                Ok(rv)
11903            }
11904            ReferenceType::Cell { .. } => {
11905                let shared = self.resolve_shared_ref(reference, current_sheet)?;
11906                let formualizer_common::SheetRef::Cell(cell) = shared else {
11907                    return Err(ExcelError::new(ExcelErrorKind::Ref));
11908                };
11909                let addr = CellRef::try_from_shared(cell)?;
11910                let sheet_id = addr.sheet_id;
11911                let sheet_name = self.graph.sheet_name(sheet_id);
11912                let row = addr.coord.row() + 1;
11913                let col = addr.coord.col() + 1;
11914
11915                if self.force_materialize_range_views {
11916                    let v = self
11917                        .get_cell_value(sheet_name, row, col)
11918                        .unwrap_or(LiteralValue::Empty);
11919                    return Ok(RangeView::from_owned_rows(
11920                        vec![vec![v]],
11921                        self.config.date_system,
11922                    ));
11923                }
11924
11925                if let Some(asheet) = self.sheet_store().sheet(sheet_name) {
11926                    let r0 = row.saturating_sub(1) as usize;
11927                    let c0 = col.saturating_sub(1) as usize;
11928                    let rv = asheet.range_view(r0, c0, r0, c0);
11929                    Ok(rv)
11930                } else {
11931                    let v = self
11932                        .get_cell_value(sheet_name, row, col)
11933                        .unwrap_or(LiteralValue::Empty);
11934                    Ok(RangeView::from_owned_rows(
11935                        vec![vec![v]],
11936                        self.config.date_system,
11937                    ))
11938                }
11939            }
11940            ReferenceType::NamedRange(name) => {
11941                if let Some(current_id) = self.graph.sheet_id(current_sheet)
11942                    && let Some(named) = self.graph.resolve_name_entry(name, current_id)
11943                {
11944                    match &named.definition {
11945                        NamedDefinition::Cell(cell_ref) => {
11946                            let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
11947                            if self.force_materialize_range_views {
11948                                let v = self
11949                                    .get_cell_value(
11950                                        sheet_name,
11951                                        cell_ref.coord.row() + 1,
11952                                        cell_ref.coord.col() + 1,
11953                                    )
11954                                    .unwrap_or(LiteralValue::Empty);
11955                                return Ok(RangeView::from_owned_rows(
11956                                    vec![vec![v]],
11957                                    self.config.date_system,
11958                                ));
11959                            } else {
11960                                let asheet = self
11961                                    .sheet_store()
11962                                    .sheet(sheet_name)
11963                                    .expect("Arrow sheet missing for named cell");
11964                                let r0 = cell_ref.coord.row() as usize;
11965                                let c0 = cell_ref.coord.col() as usize;
11966                                let rv = asheet.range_view(r0, c0, r0, c0);
11967                                return Ok(rv);
11968                            }
11969                        }
11970                        NamedDefinition::Range(range_ref) => {
11971                            let sheet_name = self.graph.sheet_name(range_ref.start.sheet_id);
11972                            let sr = range_ref.start.coord.row() + 1;
11973                            let sc = range_ref.start.coord.col() + 1;
11974                            let er = range_ref.end.coord.row() + 1;
11975                            let ec = range_ref.end.coord.col() + 1;
11976                            if self.force_materialize_range_views {
11977                                let h = (er.saturating_sub(sr) + 1) as u64;
11978                                let w = (ec.saturating_sub(sc) + 1) as u64;
11979                                let cell_count = h.saturating_mul(w);
11980                                if cell_count <= self.config.spill.max_spill_cells as u64 {
11981                                    let mut rows: Vec<Vec<LiteralValue>> =
11982                                        Vec::with_capacity(h as usize);
11983                                    for r in sr..=er {
11984                                        let mut rowv: Vec<LiteralValue> =
11985                                            Vec::with_capacity(w as usize);
11986                                        for c in sc..=ec {
11987                                            rowv.push(
11988                                                self.get_cell_value(sheet_name, r, c)
11989                                                    .unwrap_or(LiteralValue::Empty),
11990                                            );
11991                                        }
11992                                        rows.push(rowv);
11993                                    }
11994                                    return Ok(RangeView::from_owned_rows(
11995                                        rows,
11996                                        self.config.date_system,
11997                                    ));
11998                                }
11999                            }
12000                            let asheet = self
12001                                .sheet_store()
12002                                .sheet(sheet_name)
12003                                .expect("Arrow sheet missing for named range");
12004                            let sr0 = range_ref.start.coord.row() as usize;
12005                            let sc0 = range_ref.start.coord.col() as usize;
12006                            let er0 = range_ref.end.coord.row() as usize;
12007                            let ec0 = range_ref.end.coord.col() as usize;
12008                            let rv = asheet.range_view(sr0, sc0, er0, ec0);
12009                            return Ok(rv);
12010                        }
12011                        NamedDefinition::Literal(v) => {
12012                            return Ok(RangeView::from_owned_rows(
12013                                vec![vec![v.clone()]],
12014                                self.config.date_system,
12015                            ));
12016                        }
12017                        NamedDefinition::Formula { .. } => {
12018                            if let Some(value) = self.graph.get_value(named.vertex) {
12019                                return Ok(RangeView::from_owned_rows(
12020                                    vec![vec![value]],
12021                                    self.config.date_system,
12022                                ));
12023                            }
12024                        }
12025                    }
12026                }
12027
12028                if let Some(source) = self.graph.resolve_source_scalar_entry(name) {
12029                    let version = source
12030                        .version
12031                        .or_else(|| self.resolver.source_scalar_version(name));
12032                    let v = self.resolve_source_scalar_cached(name, version)?;
12033                    return Ok(RangeView::from_owned_rows(
12034                        vec![vec![v]],
12035                        self.config.date_system,
12036                    ));
12037                }
12038
12039                let data = self.resolver.resolve_named_range_reference(name)?;
12040                Ok(RangeView::from_owned_rows(data, self.config.date_system))
12041            }
12042            ReferenceType::Table(tref) => {
12043                if let Some(table) = self.graph.resolve_table_entry(&tref.name) {
12044                    let sheet_name = self.graph.sheet_name(table.range.start.sheet_id);
12045                    let asheet = self
12046                        .sheet_store()
12047                        .sheet(sheet_name)
12048                        .expect("Arrow sheet missing for table reference");
12049
12050                    let sr0 = table.range.start.coord.row() as usize;
12051                    let sc0 = table.range.start.coord.col() as usize;
12052                    let er0 = table.range.end.coord.row() as usize;
12053                    let ec0 = table.range.end.coord.col() as usize;
12054
12055                    let has_totals = table.totals_row;
12056                    let has_headers = table.header_row;
12057                    let data_sr = if has_headers {
12058                        sr0.saturating_add(1)
12059                    } else {
12060                        sr0
12061                    };
12062                    let data_er = if has_totals {
12063                        er0.saturating_sub(1)
12064                    } else {
12065                        er0
12066                    };
12067
12068                    let select = |sr: usize, sc: usize, er: usize, ec: usize| {
12069                        if sr > er || sc > ec {
12070                            asheet.range_view(1, 1, 0, 0)
12071                        } else {
12072                            asheet.range_view(sr, sc, er, ec)
12073                        }
12074                    };
12075
12076                    let av = match &tref.specifier {
12077                        None => {
12078                            return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
12079                                "Table reference without specifier is unsupported".to_string(),
12080                            ));
12081                        }
12082                        Some(formualizer_parse::parser::TableSpecifier::Column(col)) => {
12083                            let Some(idx) = table.col_index(col) else {
12084                                return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(
12085                                    "Column refers to unknown table column".to_string(),
12086                                ));
12087                            };
12088                            let c0 = sc0 + idx;
12089                            select(data_sr, c0, data_er, c0)
12090                        }
12091                        Some(formualizer_parse::parser::TableSpecifier::ColumnRange(
12092                            start,
12093                            end,
12094                        )) => {
12095                            let Some(si) = table.col_index(start) else {
12096                                return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(
12097                                    "Column range refers to unknown column(s)".to_string(),
12098                                ));
12099                            };
12100                            let Some(ei) = table.col_index(end) else {
12101                                return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(
12102                                    "Column range refers to unknown column(s)".to_string(),
12103                                ));
12104                            };
12105                            let (mut a, mut b) = (si, ei);
12106                            if a > b {
12107                                std::mem::swap(&mut a, &mut b);
12108                            }
12109                            let c_start = sc0 + a;
12110                            let c_end = sc0 + b;
12111                            select(data_sr, c_start, data_er, c_end)
12112                        }
12113                        Some(formualizer_parse::parser::TableSpecifier::All)
12114                        | Some(formualizer_parse::parser::TableSpecifier::SpecialItem(
12115                            formualizer_parse::parser::SpecialItem::All,
12116                        )) => select(sr0, sc0, er0, ec0),
12117                        Some(formualizer_parse::parser::TableSpecifier::Data)
12118                        | Some(formualizer_parse::parser::TableSpecifier::SpecialItem(
12119                            formualizer_parse::parser::SpecialItem::Data,
12120                        )) => select(data_sr, sc0, data_er, ec0),
12121                        Some(formualizer_parse::parser::TableSpecifier::Headers)
12122                        | Some(formualizer_parse::parser::TableSpecifier::SpecialItem(
12123                            formualizer_parse::parser::SpecialItem::Headers,
12124                        )) => {
12125                            if !has_headers {
12126                                asheet.range_view(1, 1, 0, 0)
12127                            } else {
12128                                select(sr0, sc0, sr0, ec0)
12129                            }
12130                        }
12131                        Some(formualizer_parse::parser::TableSpecifier::Totals)
12132                        | Some(formualizer_parse::parser::TableSpecifier::SpecialItem(
12133                            formualizer_parse::parser::SpecialItem::Totals,
12134                        )) => {
12135                            if !has_totals {
12136                                asheet.range_view(1, 1, 0, 0)
12137                            } else {
12138                                select(er0, sc0, er0, ec0)
12139                            }
12140                        }
12141                        Some(formualizer_parse::parser::TableSpecifier::SpecialItem(
12142                            formualizer_parse::parser::SpecialItem::ThisRow,
12143                        )) => {
12144                            return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
12145                                "@ (This Row) requires table-aware context; not yet supported"
12146                                    .to_string(),
12147                            ));
12148                        }
12149                        Some(formualizer_parse::parser::TableSpecifier::Row(_))
12150                        | Some(formualizer_parse::parser::TableSpecifier::Combination(_)) => {
12151                            return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
12152                                "Complex structured references not yet supported".to_string(),
12153                            ));
12154                        }
12155                    };
12156
12157                    return Ok(av);
12158                }
12159
12160                if let Some(source) = self.graph.resolve_source_table_entry(&tref.name) {
12161                    let version = source
12162                        .version
12163                        .or_else(|| self.resolver.source_table_version(&tref.name));
12164                    let table = self.resolve_source_table_cached(&tref.name, version)?;
12165                    return self.source_table_to_range_view(table.as_ref(), &tref.specifier);
12166                }
12167
12168                // Fallback: materialize via Resolver::resolve_range_like tranche 1
12169                let boxed = self.resolve_range_like(&ReferenceType::Table(tref.clone()))?;
12170                let owned = boxed.materialise().into_owned();
12171                Ok(RangeView::from_owned_rows(owned, self.config.date_system))
12172            }
12173            ReferenceType::Cell3D { .. } | ReferenceType::Range3D { .. } => {
12174                Err(ExcelError::new(ExcelErrorKind::NImpl)
12175                    .with_message("3D references are not yet supported".to_string()))
12176            }
12177        }
12178    }
12179
12180    fn resolve_cell_reference_value(
12181        &self,
12182        sheet: Option<&str>,
12183        row: u32,
12184        col: u32,
12185        current_sheet: &str,
12186    ) -> Result<LiteralValue, ExcelError> {
12187        let sheet_name = sheet.unwrap_or(current_sheet);
12188        if self.graph.sheet_id(sheet_name).is_none() {
12189            return Err(ExcelError::new(ExcelErrorKind::Ref));
12190        }
12191        Ok(self
12192            .get_cell_value(sheet_name, row, col)
12193            .unwrap_or(LiteralValue::Empty))
12194    }
12195
12196    fn build_criteria_mask(
12197        &self,
12198        view: &RangeView<'_>,
12199        col_in_view: usize,
12200        pred: &crate::args::CriteriaPredicate,
12201    ) -> Option<std::sync::Arc<arrow_array::BooleanArray>> {
12202        if view.dims().1 == 0 {
12203            return None;
12204        }
12205        // If the view is logically open-ended but the backing sheet has no physical rows,
12206        // treat the mask as empty (0-len) rather than attempting to build a huge mask.
12207        let sheet_rows = view.sheet().nrows as usize;
12208        if sheet_rows == 0 || view.start_row() >= sheet_rows {
12209            return Some(std::sync::Arc::new(arrow_array::BooleanArray::new_null(0)));
12210        }
12211        compute_criteria_mask(view, col_in_view, pred)
12212    }
12213
12214    fn build_row_visibility_mask(
12215        &self,
12216        view: &RangeView<'_>,
12217        mode: VisibilityMaskMode,
12218    ) -> Option<std::sync::Arc<arrow_array::BooleanArray>> {
12219        self.build_row_visibility_mask_for_view(view, mode)
12220    }
12221}
12222
12223impl<R> Engine<R>
12224where
12225    R: EvaluationContext,
12226{
12227    fn clear_spill_projection_and_mirror(
12228        &mut self,
12229        anchor_vertex: VertexId,
12230        delta: Option<&mut DeltaCollector>,
12231    ) {
12232        let spill_cells = self
12233            .graph
12234            .spill_cells_for_anchor(anchor_vertex)
12235            .map(|cells| cells.to_vec())
12236            .unwrap_or_default();
12237        if spill_cells.is_empty() {
12238            return;
12239        }
12240
12241        if let Some(delta) = delta
12242            && delta.mode != DeltaMode::Off
12243        {
12244            let empty = LiteralValue::Empty;
12245            for cell in spill_cells.iter() {
12246                let sheet_name = self.graph.sheet_name(cell.sheet_id);
12247                let old = self
12248                    .get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
12249                    .unwrap_or(LiteralValue::Empty);
12250                if old != empty {
12251                    delta.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
12252                }
12253            }
12254        }
12255
12256        self.graph.clear_spill_region(anchor_vertex);
12257        if let Some(scope) = Self::formula_plane_region_from_cells(&spill_cells) {
12258            self.record_formula_plane_structural_change(scope);
12259        }
12260
12261        if self.config.arrow_storage_enabled
12262            && self.config.delta_overlay_enabled
12263            && self.config.write_formula_overlay_enabled
12264        {
12265            let empty = LiteralValue::Empty;
12266            for cell in spill_cells.iter() {
12267                let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
12268                self.mirror_value_to_computed_overlay(
12269                    &sheet_name,
12270                    cell.coord.row() + 1,
12271                    cell.coord.col() + 1,
12272                    &empty,
12273                );
12274            }
12275        }
12276    }
12277
12278    /// Apply the evaluation outcome for one cyclic SCC: stamp `#CIRC!` on its
12279    /// (optionally filtered) members via `stamp_cycle_error`.
12280    ///
12281    /// This is the single per-SCC application point used by every schedule
12282    /// consumer walking `Schedule::units` (pre-work for #112, where cyclic
12283    /// SCCs will gain runtime verdicts instead of an unconditional stamp).
12284    ///
12285    /// `dirty_filter` preserves the recalc-plan quirk: when `Some(dirty)`,
12286    /// only members present in the set are stamped.
12287    ///
12288    /// Returns the number of vertices stamped (0 when a filter excludes every
12289    /// member), so callers can keep their site-specific `cycle_errors`
12290    /// accounting.
12291    fn apply_cycle_outcome(
12292        &mut self,
12293        cycle: &[VertexId],
12294        mut delta: Option<&mut DeltaCollector>,
12295        dirty_filter: Option<&FxHashSet<VertexId>>,
12296    ) -> usize {
12297        let circ_error = LiteralValue::Error(
12298            ExcelError::new(ExcelErrorKind::Circ)
12299                .with_message("Circular dependency detected".to_string()),
12300        );
12301        let mut stamped = 0usize;
12302        for &vertex_id in cycle {
12303            if let Some(filter) = dirty_filter
12304                && !filter.contains(&vertex_id)
12305            {
12306                continue;
12307            }
12308            self.stamp_cycle_error(vertex_id, &circ_error, delta.as_deref_mut());
12309            stamped += 1;
12310        }
12311        stamped
12312    }
12313
12314    /// Stamp a vertex with `#CIRC!` as part of cycle handling.
12315    ///
12316    /// Unlike a bare `update_vertex_value`, this first tears down any spill the
12317    /// vertex previously anchored: it clears the spilled cells, releases the graph
12318    /// spill registry, drops any lingering region reservation, and mirrors the
12319    /// cleared cells into the computed overlay — the same teardown a normal scalar/
12320    /// error result performs (see `apply_non_array_result_from_parallel` /
12321    /// `clear_spill_projection_and_mirror`). Without this, a #CIRC stamp on a former
12322    /// spill anchor would leave stale spilled values and a reserved region behind
12323    /// (issue #111).
12324    ///
12325    /// When `delta` is provided, the cleared spill cells are recorded (by
12326    /// `clear_spill_projection_and_mirror`) and the anchor's own #CIRC change is
12327    /// recorded here, matching how other result paths emit deltas.
12328    fn stamp_cycle_error(
12329        &mut self,
12330        vertex_id: VertexId,
12331        circ_error: &LiteralValue,
12332        mut delta: Option<&mut DeltaCollector>,
12333    ) {
12334        // Tear down any previous spill projection/region before overwriting the anchor.
12335        if self.graph.spill_registry_has_anchor(vertex_id) {
12336            self.clear_spill_projection_and_mirror(vertex_id, delta.as_deref_mut());
12337        }
12338        // Drop any reservation that was never committed (defensive; normally released
12339        // on the prior successful commit).
12340        self.spill_mgr.release_owner(vertex_id);
12341
12342        // Record the anchor's own #CIRC delta, like other result paths.
12343        if let Some(d) = delta
12344            && d.mode != DeltaMode::Off
12345            && let Some(cell) = self.graph.get_cell_ref_for_vertex(vertex_id)
12346        {
12347            let sheet_name = self.graph.sheet_name(cell.sheet_id);
12348            let old = self
12349                .read_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
12350                .unwrap_or(LiteralValue::Empty);
12351            if old != *circ_error {
12352                d.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
12353            }
12354        }
12355
12356        self.graph
12357            .update_vertex_value(vertex_id, circ_error.clone());
12358        self.mirror_vertex_value_to_overlay(vertex_id, circ_error);
12359    }
12360
12361    /// Dispatch point for one `ScheduleUnit::Cycle` (RFC #112, Stage 2).
12362    ///
12363    /// * `CycleDetection::Static` — today's behavior, byte-for-byte: stamp
12364    ///   `#CIRC!` on the (optionally dirty-filtered) members.
12365    /// * `CycleDetection::Runtime` — evaluate the SCC via
12366    ///   [`Self::evaluate_scc_unit`]. The recalc-plan dirty quirk maps to:
12367    ///   no dirty member → skip the task entirely (values stand); any dirty
12368    ///   member → the whole SCC evaluates (an SCC cannot be partially
12369    ///   evaluated).
12370    ///
12371    /// Returns the number of `#CIRC!`-stamped vertices, so call sites can
12372    /// keep their `cycle_errors` accounting (`> 0` ⇒ count the unit).
12373    fn handle_cycle_unit(
12374        &mut self,
12375        cycle: &[VertexId],
12376        mut delta: Option<&mut DeltaCollector>,
12377        dirty_filter: Option<&FxHashSet<VertexId>>,
12378        cancel_flag: Option<&AtomicBool>,
12379    ) -> Result<usize, ExcelError> {
12380        match self.config.cycle.detection {
12381            CycleDetection::Static => {
12382                Ok(self.apply_cycle_outcome(cycle, delta.as_deref_mut(), dirty_filter))
12383            }
12384            CycleDetection::Runtime => {
12385                if let Some(filter) = dirty_filter
12386                    && !cycle.iter().any(|v| filter.contains(v))
12387                {
12388                    return Ok(0);
12389                }
12390                // Both policies share `evaluate_scc_unit`; they differ only
12391                // in the settle loop's live-cycle arm (Error stamps,
12392                // Iterate keeps passing — RFC #113).
12393                self.evaluate_scc_unit(cycle, delta, cancel_flag)
12394            }
12395        }
12396    }
12397
12398    /// Evaluate one statically-cyclic SCC under `CycleDetection::Runtime`
12399    /// (design doc `formualizer-stage2-scc-evaluation-design.md` §3; contract
12400    /// spec §3; Iterate policy arm per RFC #113).
12401    ///
12402    /// Phantom SCCs (live-acyclic) produce ordinary values under both
12403    /// policies; live cycles get `#CIRC!` with live-cycle-only blast radius
12404    /// under `CyclePolicy::Error`, or Excel-style iterative calculation
12405    /// (converge per spec §6 or cap at `max_iterations` passes) under
12406    /// `CyclePolicy::Iterate`. Runs sequentially on the
12407    /// coordinating thread; commits are write-through per member (no
12408    /// `ComputedWriteBuffer` — that buffer is scoped to layer evaluation and
12409    /// always flushed before a Cycle unit runs, G1), so later members' scalar
12410    /// *and* range reads observe earlier members' results through the overlay
12411    /// cascade. Deltas are recorded once per member at end of task (G11).
12412    ///
12413    /// Returns the number of vertices stamped `#CIRC!`.
12414    ///
12415    /// `pub(crate)` so tests can drive SCC shapes (e.g. name-vertex members)
12416    /// that ingest-time cycle rejection makes unreachable via public edits.
12417    pub(crate) fn evaluate_scc_unit(
12418        &mut self,
12419        cycle: &[VertexId],
12420        mut delta: Option<&mut DeltaCollector>,
12421        cancel_flag: Option<&AtomicBool>,
12422    ) -> Result<usize, ExcelError> {
12423        struct SccMember {
12424            vertex: VertexId,
12425            cell: Option<CellRef>,
12426        }
12427
12428        let task_start = crate::instant::FzInstant::now();
12429
12430        // ── 0. Member order (spec §7.13): cells ascending (sheet, row, col);
12431        // name vertices after, lexicographic by folded canonical name; any
12432        // other vertex kind (defensive — `get_evaluation_vertices` only emits
12433        // formula/name kinds) last by id, never evaluated.
12434        let mut cell_members: Vec<(VertexId, CellRef)> = Vec::new();
12435        let mut name_members: Vec<(VertexId, String)> = Vec::new();
12436        let mut other_members: Vec<VertexId> = Vec::new();
12437        for &v in cycle {
12438            match self.graph.get_vertex_kind(v) {
12439                VertexKind::FormulaScalar | VertexKind::FormulaArray => {
12440                    match self.graph.get_cell_ref(v) {
12441                        Some(cell) => cell_members.push((v, cell)),
12442                        None => other_members.push(v),
12443                    }
12444                }
12445                VertexKind::NamedScalar | VertexKind::NamedArray => {
12446                    match self.graph.name_key_for_vertex(v) {
12447                        Some(key) => name_members.push((v, key)),
12448                        None => other_members.push(v),
12449                    }
12450                }
12451                _ => other_members.push(v),
12452            }
12453        }
12454        cell_members.sort_unstable_by_key(|(_, c)| (c.sheet_id, c.coord.row(), c.coord.col()));
12455        name_members.sort_unstable_by(|(av, ak), (bv, bk)| ak.cmp(bk).then(av.cmp(bv)));
12456        other_members.sort_unstable();
12457
12458        let cell_refs: Vec<CellRef> = cell_members.iter().map(|(_, c)| *c).collect();
12459        let name_keys: Vec<String> = name_members.iter().map(|(_, k)| k.clone()).collect();
12460        let mut members: Vec<SccMember> = Vec::with_capacity(cycle.len());
12461        for (v, c) in &cell_members {
12462            members.push(SccMember {
12463                vertex: *v,
12464                cell: Some(*c),
12465            });
12466        }
12467        for (v, _) in &name_members {
12468            members.push(SccMember {
12469                vertex: *v,
12470                cell: None,
12471            });
12472        }
12473        for v in &other_members {
12474            members.push(SccMember {
12475                vertex: *v,
12476                cell: None,
12477            });
12478        }
12479        let n = members.len();
12480        // Indices addressable by the collector (cells + names); `other`
12481        // members can be neither edge sources nor targets.
12482        let recordable = cell_refs.len() + name_keys.len();
12483
12484        let circ_error = LiteralValue::Error(
12485            ExcelError::new(ExcelErrorKind::Circ)
12486                .with_message("Circular dependency detected".to_string()),
12487        );
12488
12489        // ── 0b. Spec-§4 persistence repair: structural edits clear computed
12490        // overlays wholesale (`clear_computed_overlay_after_row/_col`), but
12491        // an iterating member's committed value is cycle STATE, not a
12492        // recomputable cache — and in canonical mode the overlay is its ONLY
12493        // home. If the overlay entry vanished since the last recalc, re-seed
12494        // it from the end-of-recalc snapshot (`iterative_state_values`) so
12495        // pass-1 reads (scalar AND range, via the overlay cascade) observe
12496        // the persisted value instead of silently restarting at Empty→0.
12497        // (Found by the iterate edge corpus: inserting/deleting an unrelated
12498        // row reset accumulators, violating spec §4/§7.15.)
12499        if !self.iterative_state_values.is_empty() {
12500            let restore: Vec<(VertexId, LiteralValue)> = members
12501                .iter()
12502                .filter_map(|m| {
12503                    let cell = m.cell?;
12504                    let persisted = self.iterative_state_values.get(&m.vertex)?;
12505                    let sheet_name = self.graph.sheet_name(cell.sheet_id);
12506                    let overlay = self
12507                        .get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
12508                        .unwrap_or(LiteralValue::Empty);
12509                    if matches!(overlay, LiteralValue::Empty) {
12510                        Some((m.vertex, persisted.clone()))
12511                    } else {
12512                        None
12513                    }
12514                })
12515                .collect();
12516            for (vertex, value) in restore {
12517                self.mirror_vertex_value_to_overlay(vertex, &value);
12518            }
12519        }
12520
12521        // ── 1. Pre-task value snapshot (overlay-first for cells — G3; the
12522        // graph value map may be evicted in value-cache-disabled mode).
12523        let snapshot: Vec<LiteralValue> = members
12524            .iter()
12525            .map(|m| match m.cell {
12526                Some(cell) => {
12527                    let sheet_name = self.graph.sheet_name(cell.sheet_id);
12528                    self.get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
12529                        .unwrap_or(LiteralValue::Empty)
12530                }
12531                None => self
12532                    .graph
12533                    .get_value(m.vertex)
12534                    .unwrap_or(LiteralValue::Empty),
12535            })
12536            .collect();
12537
12538        // ── 2. Pre-scan: spill anchors (FormulaArray) are stamped `#CIRC!`
12539        // with full spill teardown (spec §7.9, #115) and excluded from
12540        // evaluation. They stay recordable edge TARGETS (readers see `#CIRC!`
12541        // and propagate). Non-evaluable defensive members are excluded too.
12542        let mut excluded = vec![false; n];
12543        let mut last_value = snapshot.clone();
12544        let mut stamped = 0usize;
12545        for (i, m) in members.iter().enumerate() {
12546            match self.graph.get_vertex_kind(m.vertex) {
12547                VertexKind::FormulaArray => {
12548                    // Deltas for the cleared spill-region cells (non-members)
12549                    // can only be recorded here; the anchor's own delta is
12550                    // covered by the end-of-task snapshot comparison (dedup).
12551                    self.stamp_cycle_error(m.vertex, &circ_error, delta.as_deref_mut());
12552                    excluded[i] = true;
12553                    last_value[i] = circ_error.clone();
12554                    stamped += 1;
12555                }
12556                VertexKind::FormulaScalar | VertexKind::NamedScalar | VertexKind::NamedArray => {}
12557                _ => excluded[i] = true,
12558            }
12559        }
12560
12561        let collector = LiveEdgeCollector::new_with_names(&cell_refs, &name_keys);
12562
12563        // Per-member live out-edges, refreshed whenever a member re-runs.
12564        let mut out_edges: Vec<Vec<u32>> = vec![Vec::new(); n];
12565        // Position of each member in the most recent pass (-1 = did not run).
12566        let mut pos: Vec<i64> = vec![-1; n];
12567        // Whether each member's committed value changed in the most recent pass.
12568        let mut changed = vec![false; n];
12569
12570        // Evaluate-and-commit one member; returns Ok(true) when the member was
12571        // stamped `#CIRC!` (array result — would-be spill anchor, spec §7.9).
12572        macro_rules! run_member {
12573            ($i:expr) => {{
12574                let i: usize = $i;
12575                let m = &members[i];
12576                if i < recordable {
12577                    collector.set_current(i as u32);
12578                }
12579                let value = {
12580                    let ctx = RecordingContext::new(&*self, &collector);
12581                    match self.evaluate_vertex_recorded(m.vertex, &ctx, &collector) {
12582                        Ok(v) => v,
12583                        Err(e) => LiteralValue::Error(e),
12584                    }
12585                };
12586                let is_cell_formula = m.cell.is_some();
12587                if is_cell_formula && matches!(value, LiteralValue::Array(_)) {
12588                    // A member that *would* spill inside an SCC gets the
12589                    // conservative §7.9 verdict. It has never spilled before
12590                    // (a prior spill would make it FormulaArray, pre-stamped
12591                    // above), so there is no projection to tear down.
12592                    self.stamp_cycle_error(m.vertex, &circ_error, None);
12593                    excluded[i] = true;
12594                    stamped += 1;
12595                    changed[i] = last_value[i] != circ_error;
12596                    last_value[i] = circ_error.clone();
12597                } else {
12598                    self.graph.update_vertex_value(m.vertex, value.clone());
12599                    self.mirror_vertex_value_to_overlay(m.vertex, &value);
12600                    // §7.14 invariant (G2): a formula member must never be
12601                    // shadowed by a user/delta overlay entry, or iteration
12602                    // reads would silently diverge from committed values.
12603                    #[cfg(debug_assertions)]
12604                    if let Some(cell) = m.cell {
12605                        let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
12606                        debug_assert!(
12607                            self.read_delta_overlay_cell(
12608                                &sheet_name,
12609                                cell.coord.row() + 1,
12610                                cell.coord.col() + 1
12611                            )
12612                            .is_none(),
12613                            "user overlay must never shadow a formula SCC member ({sheet_name}!r{}c{})",
12614                            cell.coord.row() + 1,
12615                            cell.coord.col() + 1
12616                        );
12617                    }
12618                    changed[i] = last_value[i] != value;
12619                    last_value[i] = value;
12620                }
12621            }};
12622        }
12623
12624        let check_cancel = |flag: Option<&AtomicBool>| -> Result<(), ExcelError> {
12625            if let Some(flag) = flag
12626                && flag.load(Ordering::Relaxed)
12627            {
12628                return Err(ExcelError::new(ExcelErrorKind::Cancelled)
12629                    .with_message("Evaluation cancelled during SCC evaluation".to_string()));
12630            }
12631            Ok(())
12632        };
12633
12634        // ── 3. Pass 1: all evaluable members in member order.
12635        check_cancel(cancel_flag)?;
12636        let mut passes = 1usize;
12637        {
12638            let mut p = 0i64;
12639            for i in 0..n {
12640                if excluded[i] {
12641                    continue;
12642                }
12643                run_member!(i);
12644                pos[i] = p;
12645                p += 1;
12646            }
12647        }
12648
12649        // ── 4. Settle loop (design doc §3 step 4; RFC #113 policy arm).
12650        //
12651        // Acyclic classifications settle stale readers exactly (identical
12652        // under both policies — phantom SCCs never iterate). A witnessed
12653        // live cycle dispatches on policy: `Error` stamps `#CIRC!` and
12654        // stops; `Iterate` keeps running full passes over all members in
12655        // member order until converged (spec §6) or capped at
12656        // `max_iterations` total passes. A live cycle that only appears
12657        // mid-settle takes the same arm, and a cycle that dissolves
12658        // mid-iteration falls back to exact acyclic settling.
12659        //
12660        // Defensive acyclic budget: the acyclic settle is monotone, so more
12661        // than |SCC| + 2 settle passes can only be a bug; cap hits stamp the
12662        // remainder and set telemetry. Tracked via `settle_passes` so
12663        // iteration passes (legitimately many) don't consume the budget.
12664        let policy = self.config.cycle.policy;
12665        let cap = n + 2;
12666        let mut witnessed_cycles = 0usize;
12667        let mut capped = false;
12668        // ── Iterate-policy state ──
12669        let mut iterating = false;
12670        let mut converged = false;
12671        // Values committed by the last *full* pass; `None` until the first
12672        // iteration pass runs (pass 1 has no predecessor to compare against)
12673        // and reset when a settle pass runs (no cross-kind comparisons).
12674        let mut prev_pass: Option<Vec<LiteralValue>> = None;
12675        // Final-round convergence stats (overwritten per round so the values
12676        // reported are the ones observed at stop).
12677        let mut iter_max_delta = 0f64;
12678        let mut iter_nan_converged = 0usize;
12679        // Acyclic stale-reader re-eval passes (defensive budget; under pure
12680        // Error flow `1 + settle_passes == passes`, preserving Stage-2
12681        // behavior exactly).
12682        let mut settle_passes = 0usize;
12683        loop {
12684            // Drain this pass's recordings; members that ran replace their
12685            // out-edge set, members that didn't keep last-known edges.
12686            let drained = collector.take_edges();
12687            for i in 0..n {
12688                if pos[i] >= 0 {
12689                    out_edges[i].clear();
12690                }
12691            }
12692            for (from, to) in drained {
12693                debug_assert!(
12694                    pos[from as usize] >= 0,
12695                    "edge from a member that did not run"
12696                );
12697                out_edges[from as usize].push(to);
12698            }
12699            let mut edges: Vec<(u32, u32)> = Vec::new();
12700            for (i, outs) in out_edges.iter().enumerate() {
12701                if excluded[i] {
12702                    continue;
12703                }
12704                for &t in outs {
12705                    edges.push((i as u32, t));
12706                }
12707            }
12708            edges.sort_unstable();
12709            edges.dedup();
12710
12711            let analysis = analyze_live_graph(n, &edges);
12712
12713            if analysis.cycle_count > 0 {
12714                // Classification repeats every iteration pass under
12715                // `Iterate`; record the widest single witness instead of
12716                // accumulating so the count stays "distinct live cycles".
12717                witnessed_cycles = witnessed_cycles.max(analysis.cycle_count);
12718                match policy {
12719                    CyclePolicy::Error => {
12720                        // POLICY (Error): stamp every member of a live cycle,
12721                        // then one settling pass over the remaining members in
12722                        // live-topological order so error propagation
12723                        // downstream is consistent (spec §3.4). Blast radius =
12724                        // live cycles only.
12725                        for i in 0..n {
12726                            if analysis.in_cycle[i] && !excluded[i] {
12727                                self.stamp_cycle_error(members[i].vertex, &circ_error, None);
12728                                excluded[i] = true;
12729                                last_value[i] = circ_error.clone();
12730                                stamped += 1;
12731                            }
12732                        }
12733                        check_cancel(cancel_flag)?;
12734                        let order: Vec<usize> = analysis
12735                            .topo
12736                            .iter()
12737                            .map(|&i| i as usize)
12738                            .filter(|&i| !excluded[i])
12739                            .collect();
12740                        if !order.is_empty() {
12741                            passes += 1;
12742                            for i in order {
12743                                run_member!(i);
12744                            }
12745                        }
12746                        break;
12747                    }
12748                    CyclePolicy::Iterate {
12749                        max_iterations,
12750                        max_change,
12751                    } => {
12752                        // POLICY (Iterate), spec §3.5/§6.
12753                        iterating = true;
12754
12755                        // Convergence test: the full pass that just completed
12756                        // vs the previous full pass, per the spec-§6 rules.
12757                        // `prev_pass` is `None` until an iteration pass has
12758                        // run — pass 1 has no predecessor, so no convergence
12759                        // test occurs before the second pass (spec §6).
12760                        if let Some(prev) = &prev_pass {
12761                            let mut round_max_delta = 0f64;
12762                            let mut round_nan = 0usize;
12763                            let mut all_converged = true;
12764                            for i in 0..n {
12765                                if excluded[i] {
12766                                    // Stamped mid-iteration (array result,
12767                                    // §7.9): the value is pinned and cannot
12768                                    // change again — trivially settled.
12769                                    continue;
12770                                }
12771                                let out = crate::engine::convergence::values_converged(
12772                                    &prev[i],
12773                                    &last_value[i],
12774                                    max_change,
12775                                    self.config.date_system,
12776                                );
12777                                if out.nan_converged {
12778                                    round_nan += 1;
12779                                }
12780                                if let Some(d) = out.abs_delta {
12781                                    round_max_delta = round_max_delta.max(d);
12782                                }
12783                                if !out.converged {
12784                                    all_converged = false;
12785                                }
12786                            }
12787                            // Overwrite (not max): telemetry reports the
12788                            // round observed at stop.
12789                            iter_max_delta = round_max_delta;
12790                            iter_nan_converged = round_nan;
12791                            if all_converged {
12792                                converged = true;
12793                                break;
12794                            }
12795                        }
12796
12797                        // ── Pass-counting reconciliation (spec §6/§7.6):
12798                        // `max_iterations` counts TOTAL passes, pass 1
12799                        // included, and pass 1 has already run by the time a
12800                        // live cycle is first witnessed here. The budget is
12801                        // therefore checked BEFORE evaluating anything more:
12802                        // with `max_iterations: 1` we stop right here — each
12803                        // member was evaluated exactly once this recalc (the
12804                        // Excel accumulator contract) and no convergence test
12805                        // ran (`prev_pass` is still `None`). Capping keeps
12806                        // the last committed values and is NOT an error
12807                        // (Excel parity); telemetry records it.
12808                        if passes >= max_iterations as usize {
12809                            capped = true;
12810                            break;
12811                        }
12812
12813                        check_cancel(cancel_flag)?;
12814                        // One more full pass over every evaluable member in
12815                        // member order (Gauss–Seidel: each commit is visible
12816                        // to later members within the pass). Live edges
12817                        // re-record — guards can flip near convergence
12818                        // (§7.3) — so classification repeats next time
12819                        // around, and a cycle that dissolves drops back to
12820                        // the exact acyclic settle below.
12821                        prev_pass = Some(last_value.clone());
12822                        for x in pos.iter_mut() {
12823                            *x = -1;
12824                        }
12825                        changed.fill(false);
12826                        passes += 1;
12827                        let mut p = 0i64;
12828                        for i in 0..n {
12829                            if excluded[i] {
12830                                continue;
12831                            }
12832                            run_member!(i);
12833                            pos[i] = p;
12834                            p += 1;
12835                        }
12836                        continue;
12837                    }
12838                }
12839            }
12840
12841            // Acyclic: find stale readers — members whose live read of `to`
12842            // happened before `to`'s value changed in the pass that just ran.
12843            let mut stale: Vec<usize> = Vec::new();
12844            for i in 0..n {
12845                if excluded[i] {
12846                    continue;
12847                }
12848                let is_stale = out_edges[i].iter().any(|&t| {
12849                    let t = t as usize;
12850                    changed[t] && (pos[i] < 0 || (pos[t] >= 0 && pos[i] < pos[t]))
12851                });
12852                if is_stale {
12853                    stale.push(i);
12854                }
12855            }
12856            if stale.is_empty() {
12857                break; // values exact — phantom SCC (or dissolved live cycle)
12858            }
12859            if 1 + settle_passes >= cap {
12860                // Defensive only; hitting this is a bug (loud telemetry).
12861                capped = true;
12862                for (i, m) in members.iter().enumerate() {
12863                    if !excluded[i] {
12864                        self.stamp_cycle_error(m.vertex, &circ_error, None);
12865                        excluded[i] = true;
12866                        last_value[i] = circ_error.clone();
12867                        stamped += 1;
12868                    }
12869                }
12870                break;
12871            }
12872
12873            check_cancel(cancel_flag)?;
12874            // Re-evaluate stale readers in live-topo order, recording fresh
12875            // edges (branches may flip on re-eval — spec §7.3 — which is why
12876            // classification repeats).
12877            // A settle pass is a partial sweep: drop the full-pass baseline
12878            // so a live cycle (re)appearing afterwards never compares values
12879            // across mixed pass kinds.
12880            prev_pass = None;
12881            let topo_pos = analysis.topo_positions();
12882            stale.sort_unstable_by_key(|&i| topo_pos[i]);
12883            for x in pos.iter_mut() {
12884                *x = -1;
12885            }
12886            changed.fill(false);
12887            passes += 1;
12888            settle_passes += 1;
12889            for (p, i) in stale.into_iter().enumerate() {
12890                run_member!(i);
12891                pos[i] = p as i64;
12892            }
12893        }
12894
12895        // Iteration that ended because the live cycle dissolved and the
12896        // acyclic settle reached exactness counts as converged (values are
12897        // exact, strictly better than threshold-converged). The defensive
12898        // settle cap (`capped` + stamping) is not.
12899        if iterating && !converged && !capped {
12900            converged = true;
12901        }
12902
12903        // ── 5. End of task: one delta per member whose final value differs
12904        // from the pre-task snapshot (spec §3 side-effect rule, G11).
12905        collector.clear_current();
12906        if let Some(d) = delta
12907            && d.mode != DeltaMode::Off
12908        {
12909            for (i, m) in members.iter().enumerate() {
12910                if let Some(cell) = m.cell
12911                    && last_value[i] != snapshot[i]
12912                {
12913                    d.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
12914                }
12915            }
12916        }
12917
12918        // Members of an SCC that iterated re-evaluate on EVERY recalc, like
12919        // Excel's circular cells: register them for the end-of-recalc
12920        // volatile-like redirty (see `pending_iterative_redirty`). Marking
12921        // any one member propagates around the (strongly connected) SCC and
12922        // to downstream dependents, but all members are registered so the
12923        // contract survives partial structural edits between recalcs.
12924        if iterating {
12925            self.pending_iterative_redirty
12926                .extend(members.iter().map(|m| m.vertex));
12927        }
12928
12929        {
12930            let t = &mut self.last_cycle_telemetry;
12931            t.static_sccs += 1;
12932            if witnessed_cycles == 0 && stamped == 0 && !capped {
12933                t.phantom_sccs += 1;
12934            }
12935            t.live_cycles_witnessed += witnessed_cycles;
12936            t.circ_cells_stamped += stamped;
12937            t.settle_passes_total += passes;
12938            t.max_passes_single_scc = t.max_passes_single_scc.max(passes);
12939            if iterating {
12940                t.iterated_sccs += 1;
12941                if converged {
12942                    t.converged_sccs += 1;
12943                }
12944                t.max_abs_delta_at_stop = t.max_abs_delta_at_stop.max(iter_max_delta);
12945                t.nan_converged += iter_nan_converged;
12946            }
12947            if capped {
12948                t.capped_sccs += 1;
12949            }
12950            t.elapsed_ms += task_start.elapsed().as_millis();
12951        }
12952
12953        Ok(stamped)
12954    }
12955
12956    /// Recorded sibling of [`Self::evaluate_vertex_immutable`]: evaluates one
12957    /// SCC member's AST via an [`Interpreter`] over a [`RecordingContext`] so
12958    /// reads that actually occur are captured as live edges. Value semantics
12959    /// must match `evaluate_vertex_immutable` exactly (including the missing-
12960    /// AST `Number(0.0)` quirk, G14); named Cell/Range/Literal definitions
12961    /// delegate to it after recording the definition region by hand (those
12962    /// reads bypass the context).
12963    fn evaluate_vertex_recorded(
12964        &self,
12965        vertex_id: VertexId,
12966        ctx: &RecordingContext<'_, R>,
12967        collector: &LiveEdgeCollector,
12968    ) -> Result<LiteralValue, ExcelError> {
12969        if !self.graph.vertex_exists(vertex_id) {
12970            return Err(ExcelError::new(formualizer_common::ExcelErrorKind::Ref)
12971                .with_message(format!("Vertex not found: {vertex_id:?}")));
12972        }
12973
12974        let kind = self.graph.get_vertex_kind(vertex_id);
12975        let sheet_id = self.graph.get_vertex_sheet_id(vertex_id);
12976
12977        match kind {
12978            VertexKind::FormulaScalar | VertexKind::FormulaArray => {
12979                let Some(ast_id) = self.graph.get_formula_id(vertex_id) else {
12980                    return Ok(LiteralValue::Number(0.0)); // G14 quirk
12981                };
12982                let sheet_name = self.graph.sheet_name(sheet_id);
12983                let cell_ref = self
12984                    .graph
12985                    .get_cell_ref(vertex_id)
12986                    .expect("cell ref for vertex");
12987                let interpreter = Interpreter::new_with_cell(ctx, sheet_name, cell_ref);
12988                interpreter
12989                    .evaluate_arena_ast(ast_id, self.graph.data_store(), self.graph.sheet_reg())
12990                    .map(|cv| cv.into_literal())
12991            }
12992            VertexKind::NamedScalar | VertexKind::NamedArray => {
12993                let named_range = self.graph.named_range_by_vertex(vertex_id).ok_or_else(|| {
12994                    ExcelError::new(ExcelErrorKind::Name)
12995                        .with_message("Named range metadata missing".to_string())
12996                })?;
12997
12998                match &named_range.definition {
12999                    NamedDefinition::Formula { ast, .. } => {
13000                        let context_sheet = match named_range.scope {
13001                            NameScope::Sheet(id) => id,
13002                            NameScope::Workbook => sheet_id,
13003                        };
13004                        let sheet_name = self.graph.sheet_name(context_sheet);
13005                        let cell_ref = self
13006                            .graph
13007                            .get_cell_ref(vertex_id)
13008                            .unwrap_or_else(|| self.graph.make_cell_ref(sheet_name, 0, 0));
13009                        let interpreter = Interpreter::new_with_cell(ctx, sheet_name, cell_ref);
13010                        if kind == VertexKind::NamedScalar {
13011                            interpreter.evaluate_ast(ast).map(|cv| cv.into_literal())
13012                        } else {
13013                            match interpreter.evaluate_ast(ast) {
13014                                Ok(cv) => match cv.into_literal() {
13015                                    v @ LiteralValue::Array(_) => Ok(v),
13016                                    other => Ok(LiteralValue::Array(vec![vec![other]])),
13017                                },
13018                                Err(err) => Ok(LiteralValue::Error(err)),
13019                            }
13020                        }
13021                    }
13022                    NamedDefinition::Cell(cell_ref) => {
13023                        // The definition is read via direct grid access in
13024                        // `evaluate_vertex_immutable`; record the live edge
13025                        // by hand before delegating.
13026                        collector.record_scalar(
13027                            cell_ref.sheet_id,
13028                            cell_ref.coord.row(),
13029                            cell_ref.coord.col(),
13030                        );
13031                        self.evaluate_vertex_immutable(vertex_id)
13032                    }
13033                    NamedDefinition::Range(range_ref) => {
13034                        if range_ref.start.sheet_id == range_ref.end.sheet_id {
13035                            collector.record_rect(
13036                                range_ref.start.sheet_id,
13037                                range_ref.start.coord.row(),
13038                                range_ref.start.coord.col(),
13039                                range_ref.end.coord.row(),
13040                                range_ref.end.coord.col(),
13041                            );
13042                        }
13043                        self.evaluate_vertex_immutable(vertex_id)
13044                    }
13045                    NamedDefinition::Literal(_) => self.evaluate_vertex_immutable(vertex_id),
13046                }
13047            }
13048            _ => self.evaluate_vertex_immutable(vertex_id),
13049        }
13050    }
13051
13052    /// Helper: commit spill via shim and mirror resulting cells into Arrow overlay when enabled.
13053    fn commit_spill_and_mirror(
13054        &mut self,
13055        anchor_vertex: VertexId,
13056        targets: &[CellRef],
13057        rows: Vec<Vec<LiteralValue>>,
13058        delta: Option<&mut DeltaCollector>,
13059        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
13060    ) -> Result<(), ExcelError> {
13061        let prev_spill_cells = self
13062            .graph
13063            .spill_cells_for_anchor(anchor_vertex)
13064            .map(|cells| cells.to_vec())
13065            .unwrap_or_default();
13066
13067        if let Some(delta) = delta
13068            && delta.mode != DeltaMode::Off
13069        {
13070            let target_set: std::collections::HashSet<CellRef, CoordBuildHasher> =
13071                targets.iter().copied().collect();
13072            let empty = LiteralValue::Empty;
13073
13074            // Clears (prev - targets)
13075            for cell in prev_spill_cells.iter() {
13076                if target_set.contains(cell) {
13077                    continue;
13078                }
13079                let sheet_name = self.graph.sheet_name(cell.sheet_id);
13080                let old = self
13081                    .get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
13082                    .unwrap_or(LiteralValue::Empty);
13083                if old != empty {
13084                    delta.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
13085                }
13086            }
13087
13088            // Writes (targets)
13089            if !targets.is_empty() && !rows.is_empty() && !rows[0].is_empty() {
13090                let width = rows[0].len();
13091                for (idx, cell) in targets.iter().enumerate() {
13092                    let r_off = idx / width;
13093                    let c_off = idx % width;
13094                    let new = rows
13095                        .get(r_off)
13096                        .and_then(|r| r.get(c_off))
13097                        .cloned()
13098                        .unwrap_or(LiteralValue::Empty);
13099                    let sheet_name = self.graph.sheet_name(cell.sheet_id);
13100                    let old = self
13101                        .get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
13102                        .unwrap_or(LiteralValue::Empty);
13103                    if old != new {
13104                        delta.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
13105                    }
13106                }
13107            } else {
13108                // Degenerate shapes: if we have targets but no rows, treat as writing Empty.
13109                for cell in targets.iter() {
13110                    let sheet_name = self.graph.sheet_name(cell.sheet_id);
13111                    let old = self
13112                        .get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
13113                        .unwrap_or(LiteralValue::Empty);
13114                    if !matches!(old, LiteralValue::Empty) {
13115                        delta.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
13116                    }
13117                }
13118            }
13119        }
13120
13121        // Commit via shim (releases locks). When the graph value cache is disabled (Arrow-canonical
13122        // values), plan/commit must consult Arrow storage to detect non-empty value blockers.
13123        let arrow_sheets = &self.arrow_sheets;
13124        self.spill_mgr.commit_array_with_value_probe(
13125            &mut self.graph,
13126            anchor_vertex,
13127            targets,
13128            rows.clone(),
13129            overwritable_formulas,
13130            |g, cell| {
13131                let sheet_name = g.sheet_name(cell.sheet_id);
13132                let asheet = arrow_sheets.sheet(sheet_name)?;
13133                let r0 = cell.coord.row() as usize;
13134                let c0 = cell.coord.col() as usize;
13135                let v = asheet.get_cell_value(r0, c0);
13136                if matches!(v, LiteralValue::Empty) {
13137                    None
13138                } else {
13139                    Some(v)
13140                }
13141            },
13142        )?;
13143
13144        if let Some(scope) = Self::formula_plane_region_from_cells(&prev_spill_cells) {
13145            self.record_formula_plane_structural_change(scope);
13146        }
13147        if let Some(scope) = Self::formula_plane_region_from_cells(targets) {
13148            self.record_formula_plane_structural_change(scope);
13149        }
13150
13151        if self.config.arrow_storage_enabled
13152            && self.config.delta_overlay_enabled
13153            && self.config.write_formula_overlay_enabled
13154        {
13155            if !prev_spill_cells.is_empty() {
13156                let target_set: std::collections::HashSet<CellRef, CoordBuildHasher> =
13157                    targets.iter().copied().collect();
13158                let empty = LiteralValue::Empty;
13159                for cell in prev_spill_cells.iter() {
13160                    if !target_set.contains(cell) {
13161                        let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
13162                        self.mirror_value_to_computed_overlay(
13163                            &sheet_name,
13164                            cell.coord.row() + 1,
13165                            cell.coord.col() + 1,
13166                            &empty,
13167                        );
13168                    }
13169                }
13170            }
13171
13172            for (idx, cell) in targets.iter().enumerate() {
13173                if rows.is_empty() || rows[0].is_empty() {
13174                    break;
13175                }
13176                let width = rows[0].len();
13177                let r_off = idx / width;
13178                let c_off = idx % width;
13179                let v = rows[r_off][c_off].clone();
13180                let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
13181                self.mirror_value_to_computed_overlay(
13182                    &sheet_name,
13183                    cell.coord.row() + 1,
13184                    cell.coord.col() + 1,
13185                    &v,
13186                );
13187            }
13188        }
13189        Ok(())
13190    }
13191}
13192
13193// ── Effects pipeline (ticket 603) ──────────────────────────────────────────
13194//
13195// Compute → Plan → Apply separation for evaluation side-effects.
13196
13197use crate::engine::effects::Effect;
13198use crate::engine::graph::editor::change_log::{ChangeEvent, ChangeLog, SpillSnapshot};
13199
13200impl<R> Engine<R>
13201where
13202    R: EvaluationContext,
13203{
13204    /// Plan effects for a single vertex after its value has been computed.
13205    ///
13206    /// This reads graph state but only performs lightweight mutations
13207    /// (`set_kind`, `spill_mgr.reserve`) that are needed for correctness
13208    /// during the planning phase.  Value-changing mutations are deferred to
13209    /// `apply_effect`.
13210    pub(crate) fn plan_vertex_effects(
13211        &mut self,
13212        vertex_id: VertexId,
13213        computed_value: LiteralValue,
13214        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
13215    ) -> Result<Vec<Effect>, ExcelError> {
13216        let kind = self.graph.get_vertex_kind(vertex_id);
13217        let is_formula = matches!(kind, VertexKind::FormulaScalar | VertexKind::FormulaArray);
13218
13219        // If this vertex's cell is currently covered by a spill from a different
13220        // anchor, ignore the computed result.  Formula vertices are exempt:
13221        // they must still evaluate so that overlapping spills produce #SPILL!.
13222        if !is_formula {
13223            if let Some(cell) = self.graph.get_cell_ref(vertex_id)
13224                && let Some(owner) = self.graph.spill_registry_anchor_for_cell(cell)
13225                && owner != vertex_id
13226            {
13227                return Ok(Vec::new());
13228            }
13229            // Non-formula vertices: store value as-is (arrays remain arrays; no spill).
13230            return Ok(vec![Effect::WriteCell {
13231                vertex_id,
13232                value: computed_value,
13233            }]);
13234        }
13235
13236        match computed_value {
13237            LiteralValue::Array(rows) => {
13238                self.plan_array_effects(vertex_id, rows, overwritable_formulas)
13239            }
13240            other => self.plan_scalar_effects(vertex_id, other),
13241        }
13242    }
13243
13244    /// Plan effects for a formula vertex that produced a scalar/error result.
13245    fn plan_scalar_effects(
13246        &self,
13247        vertex_id: VertexId,
13248        value: LiteralValue,
13249    ) -> Result<Vec<Effect>, ExcelError> {
13250        let has_spill = self
13251            .graph
13252            .spill_cells_for_anchor(vertex_id)
13253            .is_some_and(|c| !c.is_empty());
13254
13255        let mut effects = Vec::new();
13256        if has_spill {
13257            effects.push(Effect::SpillClear {
13258                anchor_vertex: vertex_id,
13259            });
13260        }
13261        effects.push(Effect::WriteCell { vertex_id, value });
13262        Ok(effects)
13263    }
13264
13265    /// Plan effects for a formula vertex that produced an array result.
13266    fn plan_array_effects(
13267        &mut self,
13268        vertex_id: VertexId,
13269        rows: Vec<Vec<LiteralValue>>,
13270        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
13271    ) -> Result<Vec<Effect>, ExcelError> {
13272        // Lightweight mutation needed for correct spill-blocking checks.
13273        self.graph.set_kind(vertex_id, VertexKind::FormulaArray);
13274
13275        let anchor = self
13276            .graph
13277            .get_cell_ref(vertex_id)
13278            .expect("cell ref for vertex");
13279        let sheet_id = anchor.sheet_id;
13280        let h = rows.len() as u32;
13281        let w = rows.first().map(|r| r.len()).unwrap_or(0) as u32;
13282
13283        // Hard cap to avoid vertex explosion from huge dynamic arrays.
13284        let spill_cells = (h as u64).saturating_mul(w as u64);
13285        if spill_cells > self.config.spill.max_spill_cells as u64 {
13286            return self.plan_spill_error_effects(vertex_id, "SpillTooLarge", h, w);
13287        }
13288
13289        // Bounds check to avoid out-of-range writes (align to AbsCoord capacity).
13290        const PACKED_MAX_ROW: u32 = 1_048_575;
13291        const PACKED_MAX_COL: u32 = 16_383;
13292        let end_row = anchor.coord.row().saturating_add(h).saturating_sub(1);
13293        let end_col = anchor.coord.col().saturating_add(w).saturating_sub(1);
13294        if end_row > PACKED_MAX_ROW || end_col > PACKED_MAX_COL {
13295            return self.plan_spill_error_effects(vertex_id, "Spill exceeds sheet bounds", h, w);
13296        }
13297
13298        let mut targets = Vec::new();
13299        for r in 0..h {
13300            for c in 0..w {
13301                targets.push(self.graph.make_cell_ref_internal(
13302                    sheet_id,
13303                    anchor.coord.row() + r,
13304                    anchor.coord.col() + c,
13305                ));
13306            }
13307        }
13308
13309        // Region lock via spill manager.
13310        match self.spill_mgr.reserve(
13311            vertex_id,
13312            anchor,
13313            SpillShape { rows: h, cols: w },
13314            SpillMeta {
13315                epoch: self.recalc_epoch,
13316                config: self.config.spill,
13317            },
13318        ) {
13319            Ok(()) => {
13320                // Validate spill region is available.
13321                if let Err(_e) = self.graph.plan_spill_region_allowing_formula_overwrite(
13322                    vertex_id,
13323                    &targets,
13324                    overwritable_formulas,
13325                ) {
13326                    return self.plan_spill_error_effects(vertex_id, "Spill blocked", h, w);
13327                }
13328
13329                // Arrow-canonical mode: graph planning cannot see non-empty value blockers because
13330                // cell values are not cached in the dependency graph. Consult Arrow storage to
13331                // detect occupied cells in the target region.
13332                if !self.graph.value_cache_enabled() {
13333                    let sheet_name = self.graph.sheet_name(sheet_id);
13334                    if let Some(asheet) = self.sheet_store().sheet(sheet_name) {
13335                        for cell in targets.iter() {
13336                            // Allow overwriting the anchor itself.
13337                            if *cell == anchor {
13338                                continue;
13339                            }
13340                            // Allow cells already owned by a spill (plan() validated spill ownership).
13341                            if self.graph.spill_registry_anchor_for_cell(*cell).is_some() {
13342                                continue;
13343                            }
13344                            // Skip formula blockers; plan() handled them (or allowed).
13345                            if let Some(&vid) = self.graph.get_vertex_id_for_address(cell)
13346                                && vid != vertex_id
13347                            {
13348                                match self.graph.get_vertex_kind(vid) {
13349                                    VertexKind::FormulaScalar | VertexKind::FormulaArray => {
13350                                        continue;
13351                                    }
13352                                    _ => {}
13353                                }
13354                            }
13355
13356                            let v = asheet.get_cell_value(
13357                                cell.coord.row() as usize,
13358                                cell.coord.col() as usize,
13359                            );
13360                            if !matches!(v, LiteralValue::Empty) {
13361                                return self.plan_spill_error_effects(
13362                                    vertex_id,
13363                                    "BlockedByValue",
13364                                    h,
13365                                    w,
13366                                );
13367                            }
13368                        }
13369                    }
13370                }
13371
13372                let top_left = rows
13373                    .first()
13374                    .and_then(|r| r.first())
13375                    .cloned()
13376                    .unwrap_or(LiteralValue::Empty);
13377
13378                let mut effects = Vec::new();
13379                // Clear previous spill if any.
13380                let has_prev = self
13381                    .graph
13382                    .spill_cells_for_anchor(vertex_id)
13383                    .is_some_and(|c| !c.is_empty());
13384                if has_prev {
13385                    effects.push(Effect::SpillClear {
13386                        anchor_vertex: vertex_id,
13387                    });
13388                }
13389                effects.push(Effect::SpillCommit {
13390                    anchor_vertex: vertex_id,
13391                    anchor_cell: anchor,
13392                    target_cells: targets,
13393                    values: rows,
13394                });
13395                effects.push(Effect::WriteCell {
13396                    vertex_id,
13397                    value: top_left,
13398                });
13399                Ok(effects)
13400            }
13401            Err(e) => {
13402                let msg = e.message.unwrap_or_else(|| "Spill blocked".to_string());
13403                self.plan_spill_error_effects(vertex_id, &msg, h, w)
13404            }
13405        }
13406    }
13407
13408    /// Build the effect list for a spill that failed validation.
13409    fn plan_spill_error_effects(
13410        &self,
13411        vertex_id: VertexId,
13412        message: &str,
13413        expected_rows: u32,
13414        expected_cols: u32,
13415    ) -> Result<Vec<Effect>, ExcelError> {
13416        let spill_err = ExcelError::new(ExcelErrorKind::Spill)
13417            .with_message(message)
13418            .with_extra(formualizer_common::ExcelErrorExtra::Spill {
13419                expected_rows,
13420                expected_cols,
13421            });
13422        let spill_val = LiteralValue::Error(spill_err);
13423
13424        let effects = vec![
13425            Effect::SpillClear {
13426                anchor_vertex: vertex_id,
13427            },
13428            Effect::WriteCell {
13429                vertex_id,
13430                value: spill_val,
13431            },
13432        ];
13433        Ok(effects)
13434    }
13435
13436    /// Apply a single effect, performing the actual graph mutations.
13437    pub(crate) fn apply_effect(
13438        &mut self,
13439        effect: &Effect,
13440        delta: Option<&mut DeltaCollector>,
13441        log: Option<&mut ChangeLog>,
13442    ) -> Result<(), ExcelError> {
13443        self.apply_effect_with_computed_writes(effect, delta, log, None)
13444    }
13445
13446    fn apply_effect_with_computed_writes(
13447        &mut self,
13448        effect: &Effect,
13449        delta: Option<&mut DeltaCollector>,
13450        log: Option<&mut ChangeLog>,
13451        computed_writes: Option<&mut ComputedWriteBuffer>,
13452    ) -> Result<(), ExcelError> {
13453        match effect {
13454            Effect::WriteCell { vertex_id, value } => {
13455                self.apply_write_cell(*vertex_id, value, delta, computed_writes)?;
13456            }
13457            Effect::SpillClear { anchor_vertex } => {
13458                self.apply_spill_clear(*anchor_vertex, delta, log, computed_writes)?;
13459            }
13460            Effect::SpillCommit {
13461                anchor_vertex,
13462                anchor_cell: _,
13463                target_cells,
13464                values,
13465            } => {
13466                self.apply_spill_commit(
13467                    *anchor_vertex,
13468                    target_cells,
13469                    values.clone(),
13470                    delta,
13471                    log,
13472                    computed_writes,
13473                )?;
13474            }
13475        }
13476        Ok(())
13477    }
13478
13479    /// Apply a WriteCell effect.
13480    fn apply_write_cell(
13481        &mut self,
13482        vertex_id: VertexId,
13483        value: &LiteralValue,
13484        delta: Option<&mut DeltaCollector>,
13485        mut computed_writes: Option<&mut ComputedWriteBuffer>,
13486    ) -> Result<(), ExcelError> {
13487        if let Some(d) = delta
13488            && d.mode != DeltaMode::Off
13489        {
13490            if let Some(buffer) = computed_writes.as_deref_mut() {
13491                self.flush_computed_write_buffer(buffer)?;
13492            }
13493            if let Some(cell) = self.graph.get_cell_ref_for_vertex(vertex_id) {
13494                let sheet_name = self.graph.sheet_name(cell.sheet_id);
13495                let old = self
13496                    .read_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
13497                    .unwrap_or(LiteralValue::Empty);
13498                if old != *value {
13499                    d.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
13500                }
13501            }
13502        }
13503        self.graph.update_vertex_value(vertex_id, value.clone());
13504        self.record_vertex_value_to_overlay(vertex_id, value, computed_writes)?;
13505        Ok(())
13506    }
13507
13508    /// Apply a SpillClear effect.
13509    fn apply_spill_clear(
13510        &mut self,
13511        anchor_vertex: VertexId,
13512        delta: Option<&mut DeltaCollector>,
13513        log: Option<&mut ChangeLog>,
13514        computed_writes: Option<&mut ComputedWriteBuffer>,
13515    ) -> Result<(), ExcelError> {
13516        if let Some(buffer) = computed_writes {
13517            self.flush_computed_write_buffer(buffer)?;
13518        }
13519
13520        let spill_cells = self
13521            .graph
13522            .spill_cells_for_anchor(anchor_vertex)
13523            .map(|cells| cells.to_vec())
13524            .unwrap_or_default();
13525        if spill_cells.is_empty() {
13526            return Ok(());
13527        }
13528
13529        // Snapshot for ChangeLog before clearing.
13530        let snapshot = if log.is_some() {
13531            self.snapshot_spill_for_anchor(anchor_vertex)
13532        } else {
13533            None
13534        };
13535
13536        // Record delta for cleared cells.
13537        if let Some(d) = delta
13538            && d.mode != DeltaMode::Off
13539        {
13540            let empty = LiteralValue::Empty;
13541            for cell in spill_cells.iter() {
13542                let sheet_name = self.graph.sheet_name(cell.sheet_id);
13543                let old = self
13544                    .get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
13545                    .unwrap_or(LiteralValue::Empty);
13546                if old != empty {
13547                    d.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
13548                }
13549            }
13550        }
13551
13552        self.graph.clear_spill_region(anchor_vertex);
13553        if let Some(scope) = Self::formula_plane_region_from_cells(&spill_cells) {
13554            self.record_formula_plane_structural_change(scope);
13555        }
13556
13557        // Mirror Empty to Arrow overlay for cleared cells.
13558        if self.config.arrow_storage_enabled
13559            && self.config.delta_overlay_enabled
13560            && self.config.write_formula_overlay_enabled
13561        {
13562            let empty = LiteralValue::Empty;
13563            for cell in spill_cells.iter() {
13564                let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
13565                self.mirror_value_to_computed_overlay(
13566                    &sheet_name,
13567                    cell.coord.row() + 1,
13568                    cell.coord.col() + 1,
13569                    &empty,
13570                );
13571            }
13572        }
13573
13574        // ChangeLog.
13575        if let Some(log) = log
13576            && let Some(old) = snapshot
13577        {
13578            log.record(ChangeEvent::SpillCleared {
13579                anchor: anchor_vertex,
13580                old,
13581            });
13582        }
13583        Ok(())
13584    }
13585
13586    /// Apply a SpillCommit effect.
13587    fn apply_spill_commit(
13588        &mut self,
13589        anchor_vertex: VertexId,
13590        target_cells: &[CellRef],
13591        values: Vec<Vec<LiteralValue>>,
13592        delta: Option<&mut DeltaCollector>,
13593        log: Option<&mut ChangeLog>,
13594        computed_writes: Option<&mut ComputedWriteBuffer>,
13595    ) -> Result<(), ExcelError> {
13596        if let Some(buffer) = computed_writes {
13597            self.flush_computed_write_buffer(buffer)?;
13598        }
13599
13600        // Snapshot for ChangeLog before commit.
13601        let old_snapshot = if log.is_some() {
13602            self.snapshot_spill_for_anchor(anchor_vertex)
13603        } else {
13604            None
13605        };
13606
13607        // Delegate to existing commit_spill_and_mirror for delta + overlay logic.
13608        self.commit_spill_and_mirror(
13609            anchor_vertex,
13610            target_cells,
13611            values.clone(),
13612            delta,
13613            None, // overwritable_formulas already validated in plan phase
13614        )?;
13615
13616        // ChangeLog.
13617        if let Some(log) = log {
13618            log.record(ChangeEvent::SpillCommitted {
13619                anchor: anchor_vertex,
13620                old: old_snapshot,
13621                new: SpillSnapshot {
13622                    target_cells: target_cells.to_vec(),
13623                    values,
13624                },
13625            });
13626        }
13627        Ok(())
13628    }
13629
13630    /// Snapshot a spill region for ChangeLog recording.
13631    ///
13632    /// Extracted from `VertexEditor::snapshot_spill_for_anchor` to be usable
13633    /// without creating a `VertexEditor`.
13634    fn snapshot_spill_for_anchor(&self, anchor: VertexId) -> Option<SpillSnapshot> {
13635        let cells = self.graph.spill_cells_for_anchor(anchor)?.to_vec();
13636        if cells.is_empty() {
13637            return None;
13638        }
13639
13640        let max = self.config.spill.max_spill_cells as usize;
13641        let mut cells = cells;
13642        if cells.len() > max {
13643            cells.truncate(max);
13644        }
13645
13646        let first = *cells.first().expect("non-empty spill cells");
13647        let sheet_name = self.graph.sheet_name(first.sheet_id).to_string();
13648        let row0 = first.coord.row();
13649        let col0 = first.coord.col();
13650
13651        let mut max_row = row0;
13652        let mut max_col = col0;
13653        let mut by_coord: FxHashMap<(u32, u32), LiteralValue> = FxHashMap::default();
13654        for cell in &cells {
13655            max_row = max_row.max(cell.coord.row());
13656            max_col = max_col.max(cell.coord.col());
13657            let v = self
13658                .get_cell_value(&sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
13659                .unwrap_or(LiteralValue::Empty);
13660            by_coord.insert((cell.coord.row(), cell.coord.col()), v);
13661        }
13662
13663        let rows = (max_row - row0 + 1) as usize;
13664        let cols = (max_col - col0 + 1) as usize;
13665        let mut values: Vec<Vec<LiteralValue>> = Vec::with_capacity(rows);
13666        for r in 0..rows {
13667            let mut row: Vec<LiteralValue> = Vec::with_capacity(cols);
13668            for c in 0..cols {
13669                row.push(
13670                    by_coord
13671                        .get(&(row0 + r as u32, col0 + c as u32))
13672                        .cloned()
13673                        .unwrap_or(LiteralValue::Empty),
13674                );
13675            }
13676            values.push(row);
13677        }
13678
13679        Some(SpillSnapshot {
13680            target_cells: cells,
13681            values,
13682        })
13683    }
13684
13685    fn flush_before_range_dependent_vertex(
13686        &mut self,
13687        vertex_id: VertexId,
13688        computed_writes: &mut ComputedWriteBuffer,
13689    ) -> Result<(), ExcelError> {
13690        if self.graph.get_range_dependencies(vertex_id).is_some() {
13691            self.flush_computed_write_buffer(computed_writes)?;
13692        }
13693        Ok(())
13694    }
13695
13696    fn plan_vertex_effects_with_computed_flush(
13697        &mut self,
13698        vertex_id: VertexId,
13699        computed_value: LiteralValue,
13700        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
13701        computed_writes: &mut ComputedWriteBuffer,
13702    ) -> Result<Vec<Effect>, ExcelError> {
13703        if matches!(&computed_value, LiteralValue::Array(_)) {
13704            self.flush_computed_write_buffer(computed_writes)?;
13705        }
13706        self.plan_vertex_effects(vertex_id, computed_value, overwritable_formulas)
13707    }
13708
13709    // ── Layer evaluation via effects pipeline ──────────────────────────────
13710
13711    fn evaluate_small_layer_direct_effects(
13712        &mut self,
13713        layer: &super::scheduler::Layer,
13714        mut delta: Option<&mut DeltaCollector>,
13715        mut log: Option<&mut ChangeLog>,
13716        cancel_flag: Option<&AtomicBool>,
13717        cancel_check_every: usize,
13718        cancel_message: &'static str,
13719    ) -> Result<usize, ExcelError> {
13720        for (i, &vertex_id) in layer.vertices.iter().enumerate() {
13721            if cancel_check_every > 0
13722                && i % cancel_check_every == 0
13723                && cancel_flag.is_some_and(|flag| flag.load(Ordering::Relaxed))
13724            {
13725                return Err(ExcelError::new(ExcelErrorKind::Cancelled)
13726                    .with_message(cancel_message.to_string()));
13727            }
13728            let value = match self.evaluate_vertex_immutable(vertex_id) {
13729                Ok(v) => v,
13730                Err(e) => LiteralValue::Error(e),
13731            };
13732            let effects = self.plan_vertex_effects(vertex_id, value, None)?;
13733            for effect in &effects {
13734                self.apply_effect_with_computed_writes(
13735                    effect,
13736                    delta.as_deref_mut(),
13737                    log.as_deref_mut(),
13738                    None,
13739                )?;
13740            }
13741        }
13742        Ok(layer.vertices.len())
13743    }
13744
13745    /// Evaluate a layer sequentially using the effects pipeline.
13746    fn evaluate_layer_sequential_effects(
13747        &mut self,
13748        layer: &super::scheduler::Layer,
13749    ) -> Result<usize, ExcelError> {
13750        if layer.vertices.len() < COMPUTED_WRITE_COALESCING_MIN_LAYER_WIDTH {
13751            return self.evaluate_small_layer_direct_effects(
13752                layer,
13753                None,
13754                None,
13755                None,
13756                0,
13757                "Evaluation cancelled within layer",
13758            );
13759        }
13760
13761        let mut computed_writes = ComputedWriteBuffer::default();
13762        for &vertex_id in &layer.vertices {
13763            self.flush_before_range_dependent_vertex(vertex_id, &mut computed_writes)?;
13764            let value = match self.evaluate_vertex_immutable(vertex_id) {
13765                Ok(v) => v,
13766                Err(e) => LiteralValue::Error(e),
13767            };
13768            let effects = match self.plan_vertex_effects_with_computed_flush(
13769                vertex_id,
13770                value,
13771                None,
13772                &mut computed_writes,
13773            ) {
13774                Ok(effects) => effects,
13775                Err(e) => {
13776                    self.flush_computed_write_buffer(&mut computed_writes)?;
13777                    return Err(e);
13778                }
13779            };
13780            for effect in &effects {
13781                if let Err(e) = self.apply_effect_with_computed_writes(
13782                    effect,
13783                    None,
13784                    None,
13785                    Some(&mut computed_writes),
13786                ) {
13787                    self.flush_computed_write_buffer(&mut computed_writes)?;
13788                    return Err(e);
13789                }
13790            }
13791        }
13792        self.flush_computed_write_buffer(&mut computed_writes)?;
13793        Ok(layer.vertices.len())
13794    }
13795
13796    /// Evaluate a layer sequentially with delta collection via effects pipeline.
13797    fn evaluate_layer_sequential_with_delta_effects(
13798        &mut self,
13799        layer: &super::scheduler::Layer,
13800        delta: &mut DeltaCollector,
13801    ) -> Result<usize, ExcelError> {
13802        if layer.vertices.len() < COMPUTED_WRITE_COALESCING_MIN_LAYER_WIDTH {
13803            return self.evaluate_small_layer_direct_effects(
13804                layer,
13805                Some(delta),
13806                None,
13807                None,
13808                0,
13809                "Evaluation cancelled within layer",
13810            );
13811        }
13812
13813        let mut computed_writes = ComputedWriteBuffer::default();
13814        for &vertex_id in &layer.vertices {
13815            self.flush_before_range_dependent_vertex(vertex_id, &mut computed_writes)?;
13816            let value = match self.evaluate_vertex_immutable(vertex_id) {
13817                Ok(v) => v,
13818                Err(e) => LiteralValue::Error(e),
13819            };
13820            let effects = match self.plan_vertex_effects_with_computed_flush(
13821                vertex_id,
13822                value,
13823                None,
13824                &mut computed_writes,
13825            ) {
13826                Ok(effects) => effects,
13827                Err(e) => {
13828                    self.flush_computed_write_buffer(&mut computed_writes)?;
13829                    return Err(e);
13830                }
13831            };
13832            for effect in &effects {
13833                if let Err(e) = self.apply_effect_with_computed_writes(
13834                    effect,
13835                    Some(delta),
13836                    None,
13837                    Some(&mut computed_writes),
13838                ) {
13839                    self.flush_computed_write_buffer(&mut computed_writes)?;
13840                    return Err(e);
13841                }
13842            }
13843        }
13844        self.flush_computed_write_buffer(&mut computed_writes)?;
13845        Ok(layer.vertices.len())
13846    }
13847
13848    /// Evaluate a layer sequentially with cancellation support via effects pipeline.
13849    fn evaluate_layer_sequential_cancellable_effects(
13850        &mut self,
13851        layer: &super::scheduler::Layer,
13852        cancel_flag: &AtomicBool,
13853    ) -> Result<usize, ExcelError> {
13854        if layer.vertices.len() < COMPUTED_WRITE_COALESCING_MIN_LAYER_WIDTH {
13855            return self.evaluate_small_layer_direct_effects(
13856                layer,
13857                None,
13858                None,
13859                Some(cancel_flag),
13860                256,
13861                "Evaluation cancelled within layer",
13862            );
13863        }
13864
13865        let mut computed_writes = ComputedWriteBuffer::default();
13866        for (i, &vertex_id) in layer.vertices.iter().enumerate() {
13867            if i % 256 == 0 && cancel_flag.load(Ordering::Relaxed) {
13868                self.flush_computed_write_buffer(&mut computed_writes)?;
13869                return Err(ExcelError::new(ExcelErrorKind::Cancelled)
13870                    .with_message("Evaluation cancelled within layer".to_string()));
13871            }
13872            self.flush_before_range_dependent_vertex(vertex_id, &mut computed_writes)?;
13873            let value = match self.evaluate_vertex_immutable(vertex_id) {
13874                Ok(v) => v,
13875                Err(e) => LiteralValue::Error(e),
13876            };
13877            let effects = match self.plan_vertex_effects_with_computed_flush(
13878                vertex_id,
13879                value,
13880                None,
13881                &mut computed_writes,
13882            ) {
13883                Ok(effects) => effects,
13884                Err(e) => {
13885                    self.flush_computed_write_buffer(&mut computed_writes)?;
13886                    return Err(e);
13887                }
13888            };
13889            for effect in &effects {
13890                if let Err(e) = self.apply_effect_with_computed_writes(
13891                    effect,
13892                    None,
13893                    None,
13894                    Some(&mut computed_writes),
13895                ) {
13896                    self.flush_computed_write_buffer(&mut computed_writes)?;
13897                    return Err(e);
13898                }
13899            }
13900        }
13901        self.flush_computed_write_buffer(&mut computed_writes)?;
13902        Ok(layer.vertices.len())
13903    }
13904
13905    /// Evaluate a layer sequentially with more frequent cancellation for demand-driven eval.
13906    fn evaluate_layer_sequential_cancellable_demand_driven_effects(
13907        &mut self,
13908        layer: &super::scheduler::Layer,
13909        cancel_flag: &AtomicBool,
13910    ) -> Result<usize, ExcelError> {
13911        if layer.vertices.len() < COMPUTED_WRITE_COALESCING_MIN_LAYER_WIDTH {
13912            return self.evaluate_small_layer_direct_effects(
13913                layer,
13914                None,
13915                None,
13916                Some(cancel_flag),
13917                128,
13918                "Demand-driven evaluation cancelled within layer",
13919            );
13920        }
13921
13922        let mut computed_writes = ComputedWriteBuffer::default();
13923        for (i, &vertex_id) in layer.vertices.iter().enumerate() {
13924            if i % 128 == 0 && cancel_flag.load(Ordering::Relaxed) {
13925                self.flush_computed_write_buffer(&mut computed_writes)?;
13926                return Err(ExcelError::new(ExcelErrorKind::Cancelled)
13927                    .with_message("Demand-driven evaluation cancelled within layer".to_string()));
13928            }
13929            self.flush_before_range_dependent_vertex(vertex_id, &mut computed_writes)?;
13930            let value = match self.evaluate_vertex_immutable(vertex_id) {
13931                Ok(v) => v,
13932                Err(e) => LiteralValue::Error(e),
13933            };
13934            let effects = match self.plan_vertex_effects_with_computed_flush(
13935                vertex_id,
13936                value,
13937                None,
13938                &mut computed_writes,
13939            ) {
13940                Ok(effects) => effects,
13941                Err(e) => {
13942                    self.flush_computed_write_buffer(&mut computed_writes)?;
13943                    return Err(e);
13944                }
13945            };
13946            for effect in &effects {
13947                if let Err(e) = self.apply_effect_with_computed_writes(
13948                    effect,
13949                    None,
13950                    None,
13951                    Some(&mut computed_writes),
13952                ) {
13953                    self.flush_computed_write_buffer(&mut computed_writes)?;
13954                    return Err(e);
13955                }
13956            }
13957        }
13958        self.flush_computed_write_buffer(&mut computed_writes)?;
13959        Ok(layer.vertices.len())
13960    }
13961
13962    /// Evaluate a layer in parallel, applying via effects pipeline.
13963    fn evaluate_layer_parallel_effects(
13964        &mut self,
13965        layer: &super::scheduler::Layer,
13966    ) -> Result<usize, ExcelError> {
13967        use rayon::prelude::*;
13968
13969        let thread_pool = self.thread_pool.as_ref().unwrap().clone();
13970
13971        let mut phase1: Vec<VertexId> = Vec::new();
13972        let mut phase2: Vec<VertexId> = Vec::new();
13973        for &vid in &layer.vertices {
13974            if self.graph.get_range_dependencies(vid).is_some() {
13975                phase2.push(vid);
13976            } else {
13977                phase1.push(vid);
13978            }
13979        }
13980
13981        let inflight: rustc_hash::FxHashSet<VertexId> = layer.vertices.iter().copied().collect();
13982        let mut applied = 0usize;
13983
13984        for group in [&phase1[..], &phase2[..]] {
13985            if group.is_empty() {
13986                continue;
13987            }
13988            let mut computed_writes = ComputedWriteBuffer::default();
13989
13990            let results: Result<Vec<(VertexId, LiteralValue)>, ExcelError> =
13991                thread_pool.install(|| {
13992                    group
13993                        .par_iter()
13994                        .map(
13995                            |&vertex_id| match self.evaluate_vertex_immutable(vertex_id) {
13996                                Ok(v) => Ok((vertex_id, v)),
13997                                Err(e) => Ok((vertex_id, LiteralValue::Error(e))),
13998                            },
13999                        )
14000                        .collect()
14001                });
14002
14003            match results {
14004                Ok(vertex_results) => {
14005                    // Arrays first, then scalars — establishes spill regions before
14006                    // scalar results that might land inside a spilled region.
14007                    let mut arrays: Vec<(VertexId, LiteralValue)> = Vec::new();
14008                    let mut others: Vec<(VertexId, LiteralValue)> = Vec::new();
14009                    for (vertex_id, result) in vertex_results {
14010                        if matches!(result, LiteralValue::Array(_)) {
14011                            arrays.push((vertex_id, result));
14012                        } else {
14013                            others.push((vertex_id, result));
14014                        }
14015                    }
14016                    for (vertex_id, result) in arrays {
14017                        let effects = match self.plan_vertex_effects_with_computed_flush(
14018                            vertex_id,
14019                            result,
14020                            Some(&inflight),
14021                            &mut computed_writes,
14022                        ) {
14023                            Ok(effects) => effects,
14024                            Err(e) => {
14025                                self.flush_computed_write_buffer(&mut computed_writes)?;
14026                                return Err(e);
14027                            }
14028                        };
14029                        for effect in &effects {
14030                            if let Err(e) = self.apply_effect_with_computed_writes(
14031                                effect,
14032                                None,
14033                                None,
14034                                Some(&mut computed_writes),
14035                            ) {
14036                                self.flush_computed_write_buffer(&mut computed_writes)?;
14037                                return Err(e);
14038                            }
14039                        }
14040                        applied = applied.saturating_add(1);
14041                    }
14042                    // Make all array spill/top-left writes visible before scalar effects in this group.
14043                    self.flush_computed_write_buffer(&mut computed_writes)?;
14044                    for (vertex_id, result) in others {
14045                        let effects = match self.plan_vertex_effects_with_computed_flush(
14046                            vertex_id,
14047                            result,
14048                            Some(&inflight),
14049                            &mut computed_writes,
14050                        ) {
14051                            Ok(effects) => effects,
14052                            Err(e) => {
14053                                self.flush_computed_write_buffer(&mut computed_writes)?;
14054                                return Err(e);
14055                            }
14056                        };
14057                        for effect in &effects {
14058                            if let Err(e) = self.apply_effect_with_computed_writes(
14059                                effect,
14060                                None,
14061                                None,
14062                                Some(&mut computed_writes),
14063                            ) {
14064                                self.flush_computed_write_buffer(&mut computed_writes)?;
14065                                return Err(e);
14066                            }
14067                        }
14068                        applied = applied.saturating_add(1);
14069                    }
14070                    // Flush at the group boundary; phase1 must be visible before phase2.
14071                    self.flush_computed_write_buffer(&mut computed_writes)?;
14072                }
14073                Err(e) => {
14074                    self.flush_computed_write_buffer(&mut computed_writes)?;
14075                    return Err(e);
14076                }
14077            }
14078        }
14079
14080        Ok(applied)
14081    }
14082
14083    /// Evaluate a layer in parallel with delta collection via effects pipeline.
14084    fn evaluate_layer_parallel_with_delta_effects(
14085        &mut self,
14086        layer: &super::scheduler::Layer,
14087        delta: &mut DeltaCollector,
14088    ) -> Result<usize, ExcelError> {
14089        use rayon::prelude::*;
14090
14091        let thread_pool = self.thread_pool.as_ref().unwrap().clone();
14092
14093        let mut phase1: Vec<VertexId> = Vec::new();
14094        let mut phase2: Vec<VertexId> = Vec::new();
14095        for &vid in &layer.vertices {
14096            if self.graph.get_range_dependencies(vid).is_some() {
14097                phase2.push(vid);
14098            } else {
14099                phase1.push(vid);
14100            }
14101        }
14102
14103        let inflight: rustc_hash::FxHashSet<VertexId> = layer.vertices.iter().copied().collect();
14104        let mut applied = 0usize;
14105
14106        for group in [&phase1[..], &phase2[..]] {
14107            if group.is_empty() {
14108                continue;
14109            }
14110            let mut computed_writes = ComputedWriteBuffer::default();
14111            let results: Result<Vec<(VertexId, LiteralValue)>, ExcelError> =
14112                thread_pool.install(|| {
14113                    group
14114                        .par_iter()
14115                        .map(
14116                            |&vertex_id| match self.evaluate_vertex_immutable(vertex_id) {
14117                                Ok(v) => Ok((vertex_id, v)),
14118                                Err(e) => Ok((vertex_id, LiteralValue::Error(e))),
14119                            },
14120                        )
14121                        .collect()
14122                });
14123
14124            match results {
14125                Ok(vertex_results) => {
14126                    let mut arrays: Vec<(VertexId, LiteralValue)> = Vec::new();
14127                    let mut others: Vec<(VertexId, LiteralValue)> = Vec::new();
14128                    for (vertex_id, result) in vertex_results {
14129                        if matches!(result, LiteralValue::Array(_)) {
14130                            arrays.push((vertex_id, result));
14131                        } else {
14132                            others.push((vertex_id, result));
14133                        }
14134                    }
14135                    for (vertex_id, result) in arrays {
14136                        let effects = match self.plan_vertex_effects_with_computed_flush(
14137                            vertex_id,
14138                            result,
14139                            Some(&inflight),
14140                            &mut computed_writes,
14141                        ) {
14142                            Ok(effects) => effects,
14143                            Err(e) => {
14144                                self.flush_computed_write_buffer(&mut computed_writes)?;
14145                                return Err(e);
14146                            }
14147                        };
14148                        for effect in &effects {
14149                            if let Err(e) = self.apply_effect_with_computed_writes(
14150                                effect,
14151                                Some(delta),
14152                                None,
14153                                Some(&mut computed_writes),
14154                            ) {
14155                                self.flush_computed_write_buffer(&mut computed_writes)?;
14156                                return Err(e);
14157                            }
14158                        }
14159                        applied = applied.saturating_add(1);
14160                    }
14161                    self.flush_computed_write_buffer(&mut computed_writes)?;
14162                    for (vertex_id, result) in others {
14163                        let effects = match self.plan_vertex_effects_with_computed_flush(
14164                            vertex_id,
14165                            result,
14166                            Some(&inflight),
14167                            &mut computed_writes,
14168                        ) {
14169                            Ok(effects) => effects,
14170                            Err(e) => {
14171                                self.flush_computed_write_buffer(&mut computed_writes)?;
14172                                return Err(e);
14173                            }
14174                        };
14175                        for effect in &effects {
14176                            if let Err(e) = self.apply_effect_with_computed_writes(
14177                                effect,
14178                                Some(delta),
14179                                None,
14180                                Some(&mut computed_writes),
14181                            ) {
14182                                self.flush_computed_write_buffer(&mut computed_writes)?;
14183                                return Err(e);
14184                            }
14185                        }
14186                        applied = applied.saturating_add(1);
14187                    }
14188                    self.flush_computed_write_buffer(&mut computed_writes)?;
14189                }
14190                Err(e) => {
14191                    self.flush_computed_write_buffer(&mut computed_writes)?;
14192                    return Err(e);
14193                }
14194            }
14195        }
14196
14197        Ok(applied)
14198    }
14199
14200    /// Evaluate a layer in parallel with cancellation support via effects pipeline.
14201    fn evaluate_layer_parallel_cancellable_effects(
14202        &mut self,
14203        layer: &super::scheduler::Layer,
14204        cancel_flag: &AtomicBool,
14205    ) -> Result<usize, ExcelError> {
14206        use rayon::prelude::*;
14207
14208        let thread_pool = self.thread_pool.as_ref().unwrap().clone();
14209
14210        if cancel_flag.load(Ordering::Relaxed) {
14211            return Err(ExcelError::new(ExcelErrorKind::Cancelled)
14212                .with_message("Parallel evaluation cancelled before starting".to_string()));
14213        }
14214
14215        let mut phase1: Vec<VertexId> = Vec::new();
14216        let mut phase2: Vec<VertexId> = Vec::new();
14217        for &vid in &layer.vertices {
14218            if self.graph.get_range_dependencies(vid).is_some() {
14219                phase2.push(vid);
14220            } else {
14221                phase1.push(vid);
14222            }
14223        }
14224
14225        let inflight: rustc_hash::FxHashSet<VertexId> = layer.vertices.iter().copied().collect();
14226        let mut applied = 0usize;
14227
14228        for group in [&phase1[..], &phase2[..]] {
14229            if group.is_empty() {
14230                continue;
14231            }
14232            let mut computed_writes = ComputedWriteBuffer::default();
14233
14234            let results: Result<Vec<(VertexId, LiteralValue)>, ExcelError> =
14235                thread_pool.install(|| {
14236                    group
14237                        .par_iter()
14238                        .map(|&vertex_id| {
14239                            if cancel_flag.load(Ordering::Relaxed) {
14240                                return Err(ExcelError::new(ExcelErrorKind::Cancelled)
14241                                    .with_message(
14242                                        "Parallel evaluation cancelled during execution"
14243                                            .to_string(),
14244                                    ));
14245                            }
14246                            match self.evaluate_vertex_immutable(vertex_id) {
14247                                Ok(v) => Ok((vertex_id, v)),
14248                                Err(e) => Ok((vertex_id, LiteralValue::Error(e))),
14249                            }
14250                        })
14251                        .collect()
14252                });
14253
14254            match results {
14255                Ok(vertex_results) => {
14256                    let mut arrays: Vec<(VertexId, LiteralValue)> = Vec::new();
14257                    let mut others: Vec<(VertexId, LiteralValue)> = Vec::new();
14258                    for (vertex_id, result) in vertex_results {
14259                        if matches!(result, LiteralValue::Array(_)) {
14260                            arrays.push((vertex_id, result));
14261                        } else {
14262                            others.push((vertex_id, result));
14263                        }
14264                    }
14265                    for (vertex_id, result) in arrays {
14266                        let effects = match self.plan_vertex_effects_with_computed_flush(
14267                            vertex_id,
14268                            result,
14269                            Some(&inflight),
14270                            &mut computed_writes,
14271                        ) {
14272                            Ok(effects) => effects,
14273                            Err(e) => {
14274                                self.flush_computed_write_buffer(&mut computed_writes)?;
14275                                return Err(e);
14276                            }
14277                        };
14278                        for effect in &effects {
14279                            if let Err(e) = self.apply_effect_with_computed_writes(
14280                                effect,
14281                                None,
14282                                None,
14283                                Some(&mut computed_writes),
14284                            ) {
14285                                self.flush_computed_write_buffer(&mut computed_writes)?;
14286                                return Err(e);
14287                            }
14288                        }
14289                        applied = applied.saturating_add(1);
14290                    }
14291                    self.flush_computed_write_buffer(&mut computed_writes)?;
14292                    for (vertex_id, result) in others {
14293                        let effects = match self.plan_vertex_effects_with_computed_flush(
14294                            vertex_id,
14295                            result,
14296                            Some(&inflight),
14297                            &mut computed_writes,
14298                        ) {
14299                            Ok(effects) => effects,
14300                            Err(e) => {
14301                                self.flush_computed_write_buffer(&mut computed_writes)?;
14302                                return Err(e);
14303                            }
14304                        };
14305                        for effect in &effects {
14306                            if let Err(e) = self.apply_effect_with_computed_writes(
14307                                effect,
14308                                None,
14309                                None,
14310                                Some(&mut computed_writes),
14311                            ) {
14312                                self.flush_computed_write_buffer(&mut computed_writes)?;
14313                                return Err(e);
14314                            }
14315                        }
14316                        applied = applied.saturating_add(1);
14317                    }
14318                    self.flush_computed_write_buffer(&mut computed_writes)?;
14319                }
14320                Err(e) => {
14321                    self.flush_computed_write_buffer(&mut computed_writes)?;
14322                    return Err(e);
14323                }
14324            }
14325        }
14326
14327        Ok(applied)
14328    }
14329
14330    // ── Top-level evaluate_all_logged ───────────────────────────────────────
14331
14332    /// Evaluate all dirty/volatile vertices, recording effects into a ChangeLog.
14333    ///
14334    /// This is the same flow as `evaluate_all` but threads a ChangeLog through
14335    /// every effect application so that spill commits/clears are captured.
14336    pub fn evaluate_all_logged(&mut self, log: &mut ChangeLog) -> Result<EvalResult, ExcelError> {
14337        self.begin_evaluation_request();
14338        let _source_cache = self.source_cache_session();
14339        self.validate_deterministic_mode()?;
14340        if self.config.defer_graph_building {
14341            self.build_graph_all()?;
14342        }
14343        if self.graph.formula_authority().active_span_count() > 0 {
14344            return self.evaluate_authoritative_formula_plane_all();
14345        }
14346        self.reset_virtual_dep_telemetry_if_disabled();
14347        let start = crate::instant::FzInstant::now();
14348        let mut computed_vertices = 0;
14349        let mut cycle_errors = 0;
14350
14351        let mut replan_iterations = 0;
14352        const MAX_REPLAN: usize = 5;
14353        let mut telemetry = self
14354            .config
14355            .enable_virtual_dep_telemetry
14356            .then(|| self.start_virtual_dep_telemetry());
14357
14358        log.begin_compound(format!("evaluate_all(epoch={})", self.recalc_epoch));
14359
14360        loop {
14361            let to_evaluate = self.graph.get_evaluation_vertices();
14362            if to_evaluate.is_empty() {
14363                if let Some(t) = telemetry.as_mut()
14364                    && t.bailout_reason.is_none()
14365                {
14366                    t.bailout_reason = Some("no_work");
14367                }
14368                break;
14369            }
14370
14371            let (schedule, old_vdeps, meta) = self.create_evaluation_schedule(&to_evaluate)?;
14372            if let Some(t) = telemetry.as_mut() {
14373                Self::accumulate_schedule_meta(t, &meta);
14374            }
14375
14376            // Walk units in condensation order: stamp cycles at their
14377            // position, evaluate layers with ChangeLog recording.
14378            for &unit in &schedule.units {
14379                match unit {
14380                    ScheduleUnit::Cycle(i) => {
14381                        // Journal integration (design doc §4 last row): the
14382                        // ChangeLog in this path only records SpillClear /
14383                        // SpillCommit events; WriteCell effects are never
14384                        // logged (see `apply_write_cell`). Runtime SCC tasks
14385                        // write values directly and never spill (§7.9 stamps
14386                        // would-be anchors), and their spill *teardown* is the
14387                        // same unlogged `stamp_cycle_error` the Static path
14388                        // already uses here — so direct commits coexist with
14389                        // the journal cleanly, with identical semantics to
14390                        // Static. Pinned by `scc_runtime_cycles` tests.
14391                        if self.handle_cycle_unit(schedule.unit_cycle(i), None, None, None)? > 0 {
14392                            cycle_errors += 1;
14393                        }
14394                    }
14395                    ScheduleUnit::Layer(i) => {
14396                        computed_vertices +=
14397                            self.evaluate_layer_logged(schedule.unit_layer(i), log)?;
14398                    }
14399                }
14400            }
14401
14402            let changed_vertices = self.changed_virtual_dep_vertices(&to_evaluate, &old_vdeps);
14403            if let Some(t) = telemetry.as_mut() {
14404                t.changed_vdeps_total += changed_vertices.len();
14405            }
14406            self.graph.clear_dirty_flags(&to_evaluate);
14407            for v in &changed_vertices {
14408                self.graph.set_dirty(*v, true);
14409            }
14410
14411            if changed_vertices.is_empty() {
14412                if let Some(t) = telemetry.as_mut() {
14413                    t.bailout_reason = Some("converged");
14414                }
14415                break;
14416            }
14417            if replan_iterations >= MAX_REPLAN {
14418                if let Some(t) = telemetry.as_mut() {
14419                    t.bailout_reason = Some("max_replan");
14420                }
14421                break;
14422            }
14423            replan_iterations += 1;
14424        }
14425
14426        if let Some(mut t) = telemetry {
14427            t.replan_iterations = replan_iterations;
14428            self.last_virtual_dep_telemetry = t;
14429        }
14430
14431        log.end_compound();
14432
14433        self.redirty_for_next_recalc();
14434        self.recalc_epoch = self.recalc_epoch.wrapping_add(1);
14435
14436        Ok(EvalResult {
14437            computed_vertices,
14438            cycle_errors,
14439            elapsed: start.elapsed(),
14440        })
14441    }
14442
14443    /// Evaluate a single layer with ChangeLog recording.
14444    fn evaluate_layer_logged(
14445        &mut self,
14446        layer: &super::scheduler::Layer,
14447        log: &mut ChangeLog,
14448    ) -> Result<usize, ExcelError> {
14449        let mut computed_writes = ComputedWriteBuffer::default();
14450        for &vertex_id in &layer.vertices {
14451            self.flush_before_range_dependent_vertex(vertex_id, &mut computed_writes)?;
14452            let value = match self.evaluate_vertex_immutable(vertex_id) {
14453                Ok(v) => v,
14454                Err(e) => LiteralValue::Error(e),
14455            };
14456            let effects = match self.plan_vertex_effects_with_computed_flush(
14457                vertex_id,
14458                value,
14459                None,
14460                &mut computed_writes,
14461            ) {
14462                Ok(effects) => effects,
14463                Err(e) => {
14464                    self.flush_computed_write_buffer(&mut computed_writes)?;
14465                    return Err(e);
14466                }
14467            };
14468            for effect in &effects {
14469                if let Err(e) = self.apply_effect_with_computed_writes(
14470                    effect,
14471                    None,
14472                    Some(log),
14473                    Some(&mut computed_writes),
14474                ) {
14475                    self.flush_computed_write_buffer(&mut computed_writes)?;
14476                    return Err(e);
14477                }
14478            }
14479        }
14480        self.flush_computed_write_buffer(&mut computed_writes)?;
14481        Ok(layer.vertices.len())
14482    }
14483}