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 ne_matches_blank = text_kind == 1 && !text_pat.is_empty();
1330    let pat = StringArray::new_scalar(text_pat);
1331    let mut bool_parts: Vec<BooleanArray> = Vec::new();
1332
1333    for res in view.iter_row_chunks() {
1334        let cs = res.ok()?;
1335        if cs.row_len == 0 {
1336            continue;
1337        }
1338        #[cfg(test)]
1339        criteria_mask_test_hooks::inc_total();
1340
1341        let slices = view.slice_lowered_text(cs.row_start, cs.row_len);
1342        if col_in_view >= slices.len() {
1343            return None;
1344        }
1345
1346        let seg_opt = slices[col_in_view].as_ref().map(|a| a.as_ref());
1347        let seg = match seg_opt {
1348            Some(s) => s,
1349            None => {
1350                #[cfg(test)]
1351                criteria_mask_test_hooks::inc_all_null();
1352                if (text_kind == 0 && empty_special) || ne_matches_blank {
1353                    // Eq("") treats nulls (Empty) as equal.
1354                    let mut bb = BooleanBuilder::with_capacity(cs.row_len);
1355                    bb.append_n(cs.row_len, true);
1356                    bool_parts.push(bb.finish());
1357                } else {
1358                    // For non-empty patterns, ilike/nilike return null on null inputs.
1359                    bool_parts.push(BooleanArray::new_null(cs.row_len));
1360                }
1361                continue;
1362            }
1363        };
1364
1365        let seg_sa = seg.as_any().downcast_ref::<StringArray>()?;
1366        let mut m = match text_kind {
1367            0 => ilike(seg_sa, &pat).ok()?,
1368            1 => nilike(seg_sa, &pat).ok()?,
1369            2 => ilike(seg_sa, &pat).ok()?,
1370            _ => return None,
1371        };
1372
1373        // Only fold blank/Empty (null) cells into the mask when the segment
1374        // actually contains any. The null-fill loop + or_kleene are pure
1375        // overhead on blank-free chunks, so a `<>text` (or `=""`) aggregation
1376        // over a column with no blanks stays fully vectorized on the ilike/
1377        // nilike result.
1378        if ((text_kind == 0 && empty_special) || ne_matches_blank) && seg_sa.null_count() > 0 {
1379            // Treat nulls as equal to empty string
1380            let mut bb = BooleanBuilder::with_capacity(seg_sa.len());
1381            for i in 0..seg_sa.len() {
1382                bb.append_value(seg_sa.is_null(i));
1383            }
1384            let nulls = bb.finish();
1385            m = boolean::or_kleene(&m, &nulls).ok()?;
1386        }
1387
1388        bool_parts.push(m);
1389    }
1390
1391    if bool_parts.is_empty() {
1392        None
1393    } else if bool_parts.len() == 1 {
1394        Some(std::sync::Arc::new(bool_parts.remove(0)))
1395    } else {
1396        let anys: Vec<&dyn arrow_array::Array> = bool_parts
1397            .iter()
1398            .map(|a| a as &dyn arrow_array::Array)
1399            .collect();
1400        let conc: ArrayRef = concat_arrays(&anys).ok()?;
1401        let ba = conc.as_any().downcast_ref::<BooleanArray>()?.clone();
1402        Some(std::sync::Arc::new(ba))
1403    }
1404}
1405
1406#[derive(Debug, Clone)]
1407pub struct LayerInfo {
1408    pub vertex_count: usize,
1409    pub parallel_eligible: bool,
1410    pub sample_cells: Vec<String>, // Sample of up to 5 cell addresses
1411}
1412
1413#[derive(Debug, Clone)]
1414pub struct EvalPlan {
1415    pub total_vertices_to_evaluate: usize,
1416    pub layers: Vec<LayerInfo>,
1417    pub cycles_detected: usize,
1418    pub dirty_count: usize,
1419    pub volatile_count: usize,
1420    pub parallel_enabled: bool,
1421    pub estimated_parallel_layers: usize,
1422    pub target_cells: Vec<String>,
1423}
1424
1425impl<R> Engine<R>
1426where
1427    R: EvaluationContext,
1428{
1429    /// # Panics
1430    /// Panics when `config.cycle` is invalid ([`CycleConfig::validate`],
1431    /// spec §2): `Iterate` with `detection: Static`, `max_iterations == 0`,
1432    /// or a negative/non-finite `max_change`. `EvalConfig::with_cycle`
1433    /// rejects these at build; this re-validates configs assembled via
1434    /// struct literals.
1435    pub fn new(resolver: R, config: EvalConfig) -> Self {
1436        if let Err(msg) = config.cycle.validate() {
1437            panic!("invalid CycleConfig: {msg}");
1438        }
1439        crate::builtins::load_builtins();
1440
1441        let clock = config.deterministic_mode.build_clock().unwrap_or_else(|_| {
1442            #[cfg(feature = "system-clock")]
1443            {
1444                Arc::new(crate::timezone::SystemClock::new(
1445                    crate::timezone::TimeZoneSpec::default(),
1446                ))
1447            }
1448            #[cfg(not(feature = "system-clock"))]
1449            {
1450                Arc::new(crate::timezone::FixedClock::new(
1451                    chrono::DateTime::UNIX_EPOCH,
1452                    crate::timezone::TimeZoneSpec::Utc,
1453                ))
1454            }
1455        });
1456
1457        // Initialize thread pool based on config
1458        let thread_pool = if config.enable_parallel {
1459            let mut builder = ThreadPoolBuilder::new();
1460            if let Some(max_threads) = config.max_threads {
1461                builder = builder.num_threads(max_threads);
1462            }
1463
1464            match builder.build() {
1465                Ok(pool) => Some(Arc::new(pool)),
1466                Err(_) => {
1467                    // Fall back to sequential evaluation if thread pool creation fails
1468                    None
1469                }
1470            }
1471        } else {
1472            None
1473        };
1474
1475        let lookup_cache_max_bytes = config.lookup_index_cache_max_bytes;
1476        let mut engine = Self {
1477            graph: DependencyGraph::new_with_config(config.clone()),
1478            resolver,
1479            config,
1480            workbook_load_limits: crate::engine::WorkbookLoadLimits::default(),
1481            clock: crate::timezone::SnapshotClock::new(clock),
1482            thread_pool,
1483            recalc_epoch: 0,
1484            snapshot_id: std::sync::atomic::AtomicU64::new(1),
1485            topology_epoch: 0,
1486            cached_static_schedule: None,
1487            spill_mgr: ShimSpillManager::default(),
1488            arrow_sheets: SheetStore::default(),
1489            has_edited: false,
1490            overlay_compactions: 0,
1491            computed_overlay_bytes_estimate: 0,
1492            computed_overlay_mirroring_disabled: false,
1493            force_materialize_range_views: false,
1494            row_bounds_cache: std::sync::RwLock::new(None),
1495            used_axis_bounds_cache: std::sync::RwLock::new(None),
1496            lookup_index_cache: LookupIndexCache::new(lookup_cache_max_bytes),
1497            source_cache: Arc::new(std::sync::RwLock::new(SourceCache::default())),
1498            staged_formulas: std::collections::HashMap::new(),
1499            row_visibility: FxHashMap::default(),
1500            row_visibility_mask_cache: std::sync::RwLock::new(FxHashMap::default()),
1501            formula_parse_diagnostics: Vec::new(),
1502            last_formula_ingest_report: None,
1503            formula_ingest_report_total: FormulaIngestReport::default(),
1504            formula_plane_cycle_member_span_demotions: 0,
1505            formula_plane_capacity_bailouts: 0,
1506            active_cancel_flag: None,
1507            action_depth: 0,
1508            last_virtual_dep_telemetry: VirtualDepTelemetry::default(),
1509            virtual_dep_fallback_activations: 0,
1510            last_cycle_telemetry: CycleTelemetry::default(),
1511            pending_iterative_redirty: Vec::new(),
1512            iterative_state_values: FxHashMap::default(),
1513            formula_plane_indexes_epoch_seen: 0,
1514            #[cfg(test)]
1515            last_formula_plane_span_eval_report: None,
1516        };
1517        // Phase 1 (ticket 610): Arrow-truth is the only supported mode.
1518        engine.config.arrow_storage_enabled = true;
1519        engine.config.delta_overlay_enabled = true;
1520        engine.config.write_formula_overlay_enabled = true;
1521        let default_sheet = engine.graph.default_sheet_name().to_string();
1522        engine.ensure_arrow_sheet(&default_sheet);
1523        engine
1524    }
1525
1526    /// Create an Engine with a custom thread pool (for shared thread pool scenarios)
1527    ///
1528    /// # Panics
1529    /// Panics when `config.cycle` is invalid, exactly like [`Engine::new`].
1530    pub fn with_thread_pool(
1531        resolver: R,
1532        config: EvalConfig,
1533        thread_pool: Arc<rayon::ThreadPool>,
1534    ) -> Self {
1535        if let Err(msg) = config.cycle.validate() {
1536            panic!("invalid CycleConfig: {msg}");
1537        }
1538        crate::builtins::load_builtins();
1539        let clock = config.deterministic_mode.build_clock().unwrap_or_else(|_| {
1540            #[cfg(feature = "system-clock")]
1541            {
1542                Arc::new(crate::timezone::SystemClock::new(
1543                    crate::timezone::TimeZoneSpec::default(),
1544                ))
1545            }
1546            #[cfg(not(feature = "system-clock"))]
1547            {
1548                Arc::new(crate::timezone::FixedClock::new(
1549                    chrono::DateTime::UNIX_EPOCH,
1550                    crate::timezone::TimeZoneSpec::Utc,
1551                ))
1552            }
1553        });
1554        let lookup_cache_max_bytes = config.lookup_index_cache_max_bytes;
1555        let mut engine = Self {
1556            graph: DependencyGraph::new_with_config(config.clone()),
1557            resolver,
1558            config,
1559            workbook_load_limits: crate::engine::WorkbookLoadLimits::default(),
1560            clock: crate::timezone::SnapshotClock::new(clock),
1561            thread_pool: Some(thread_pool),
1562            recalc_epoch: 0,
1563            snapshot_id: std::sync::atomic::AtomicU64::new(1),
1564            topology_epoch: 0,
1565            cached_static_schedule: None,
1566            spill_mgr: ShimSpillManager::default(),
1567            arrow_sheets: SheetStore::default(),
1568            has_edited: false,
1569            overlay_compactions: 0,
1570            computed_overlay_bytes_estimate: 0,
1571            computed_overlay_mirroring_disabled: false,
1572            force_materialize_range_views: false,
1573            row_bounds_cache: std::sync::RwLock::new(None),
1574            used_axis_bounds_cache: std::sync::RwLock::new(None),
1575            lookup_index_cache: LookupIndexCache::new(lookup_cache_max_bytes),
1576            source_cache: Arc::new(std::sync::RwLock::new(SourceCache::default())),
1577            staged_formulas: std::collections::HashMap::new(),
1578            row_visibility: FxHashMap::default(),
1579            row_visibility_mask_cache: std::sync::RwLock::new(FxHashMap::default()),
1580            formula_parse_diagnostics: Vec::new(),
1581            last_formula_ingest_report: None,
1582            formula_ingest_report_total: FormulaIngestReport::default(),
1583            formula_plane_cycle_member_span_demotions: 0,
1584            formula_plane_capacity_bailouts: 0,
1585            active_cancel_flag: None,
1586            action_depth: 0,
1587            last_virtual_dep_telemetry: VirtualDepTelemetry::default(),
1588            virtual_dep_fallback_activations: 0,
1589            last_cycle_telemetry: CycleTelemetry::default(),
1590            pending_iterative_redirty: Vec::new(),
1591            iterative_state_values: FxHashMap::default(),
1592            formula_plane_indexes_epoch_seen: 0,
1593            #[cfg(test)]
1594            last_formula_plane_span_eval_report: None,
1595        };
1596        // Phase 1 (ticket 610): Arrow-truth is the only supported mode.
1597        engine.config.arrow_storage_enabled = true;
1598        engine.config.delta_overlay_enabled = true;
1599        engine.config.write_formula_overlay_enabled = true;
1600        let default_sheet = engine.graph.default_sheet_name().to_string();
1601        engine.ensure_arrow_sheet(&default_sheet);
1602        engine
1603    }
1604
1605    pub fn workbook_load_limits(&self) -> &crate::engine::WorkbookLoadLimits {
1606        &self.workbook_load_limits
1607    }
1608
1609    pub fn set_workbook_load_limits(&mut self, limits: crate::engine::WorkbookLoadLimits) {
1610        self.workbook_load_limits = limits;
1611    }
1612
1613    fn clear_source_cache(&self) {
1614        if let Ok(mut g) = self.source_cache.write() {
1615            *g = SourceCache::default();
1616        }
1617    }
1618
1619    pub fn last_virtual_dep_telemetry(&self) -> &VirtualDepTelemetry {
1620        &self.last_virtual_dep_telemetry
1621    }
1622
1623    /// Telemetry from Runtime SCC evaluation during the most recent
1624    /// evaluation request (always default-zero under `CycleDetection::Static`
1625    /// or when `enable_virtual_dep_telemetry` is off).
1626    pub fn last_cycle_telemetry(&self) -> &CycleTelemetry {
1627        &self.last_cycle_telemetry
1628    }
1629
1630    /// Begin a new evaluation request: reset per-recalc cycle telemetry and
1631    /// take the per-recalc volatile clock sample. Called at the start of
1632    /// every evaluation request that walks schedule units.
1633    fn begin_evaluation_request(&mut self) {
1634        self.last_cycle_telemetry = CycleTelemetry::default();
1635        // Defensive: consumed at the end of the previous request; a request
1636        // that errored out mid-walk must not leak its members into this one.
1637        self.pending_iterative_redirty.clear();
1638        // Spec §7.11: NOW()/TODAY() sample the clock ONCE per recalc; every
1639        // read within this request (including SCC iteration passes) observes
1640        // this sample.
1641        self.clock.refresh();
1642    }
1643
1644    /// End-of-recalc redirty: volatile vertices (as always) plus members of
1645    /// SCCs that iterated this recalc (`CyclePolicy::Iterate`), so circular
1646    /// cells re-evaluate on every recalc exactly like Excel's iterative
1647    /// calculation (spec §4 persistence / §7.6 accumulator / §7.11 volatile
1648    /// redirty). Replaces the bare `graph.redirty_volatiles()` call at every
1649    /// evaluation-flow exit; must run AFTER the flow's `clear_dirty_flags`.
1650    fn redirty_for_next_recalc(&mut self) {
1651        self.graph.redirty_volatiles();
1652        let pending = std::mem::take(&mut self.pending_iterative_redirty);
1653        // Refresh the §4-persistence snapshot: these final values survive
1654        // structural edits that clear the computed overlay (the only value
1655        // home in canonical mode) so the next SCC task can re-seed from them
1656        // (see `iterative_state_values`). Replaced wholesale each recalc —
1657        // when nothing iterates the map empties and stays free.
1658        self.iterative_state_values.clear();
1659        for &vertex in &pending {
1660            if !self.graph.vertex_exists(vertex) {
1661                continue;
1662            }
1663            if let Some(cell) = self.graph.get_cell_ref(vertex) {
1664                let sheet_name = self.graph.sheet_name(cell.sheet_id);
1665                if let Some(value) =
1666                    self.get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
1667                    && !matches!(value, LiteralValue::Empty)
1668                {
1669                    self.iterative_state_values.insert(vertex, value);
1670                }
1671            }
1672        }
1673        if !pending.is_empty() {
1674            self.graph.redirty_iterative_members(&pending);
1675        }
1676    }
1677
1678    pub fn virtual_dep_fallback_activations(&self) -> u64 {
1679        self.virtual_dep_fallback_activations
1680    }
1681
1682    pub(crate) fn last_lookup_index_cache_report(&self) -> LookupIndexCacheReport {
1683        self.lookup_index_cache.report()
1684    }
1685
1686    fn lookup_view_contains_volatile(&self, view: &RangeView<'_>, sheet_id: SheetId) -> bool {
1687        let start_row = view.start_row();
1688        let end_row = view.end_row();
1689        let start_col = view.start_col();
1690        let end_col = view.end_col();
1691        for row in start_row..=end_row {
1692            let Ok(row_u32) = u32::try_from(row) else {
1693                return true;
1694            };
1695            for col in start_col..=end_col {
1696                let Ok(col_u32) = u32::try_from(col) else {
1697                    return true;
1698                };
1699                let cell_ref = self
1700                    .graph
1701                    .make_cell_ref_internal(sheet_id, row_u32, col_u32);
1702                if let Some(vertex_id) = self.graph.get_vertex_id_for_address(&cell_ref)
1703                    && self.graph.is_volatile(*vertex_id)
1704                {
1705                    return true;
1706                }
1707            }
1708        }
1709        false
1710    }
1711
1712    fn build_lookup_index_impl(
1713        &self,
1714        view: &RangeView<'_>,
1715        axis: LookupAxis,
1716    ) -> Option<Arc<LookupIndex>> {
1717        let (rows, cols) = view.dims();
1718        if rows == 0 || cols == 0 {
1719            self.lookup_index_cache.note_skipped_tiny();
1720            return None;
1721        }
1722        let len = match axis {
1723            LookupAxis::ColumnInView(col) => {
1724                if col >= cols {
1725                    self.lookup_index_cache.note_skipped_tiny();
1726                    return None;
1727                }
1728                rows
1729            }
1730            LookupAxis::RowInView(row) => {
1731                if row >= rows {
1732                    self.lookup_index_cache.note_skipped_tiny();
1733                    return None;
1734                }
1735                cols
1736            }
1737        };
1738        if len < 64 {
1739            self.lookup_index_cache.note_skipped_tiny();
1740            return None;
1741        }
1742
1743        let sheet_id = self.graph.sheet_id(view.sheet_name())?;
1744        let key = LookupIndexKey {
1745            sheet_id,
1746            start_row: u32::try_from(view.start_row()).ok()?,
1747            start_col: u32::try_from(view.start_col()).ok()?,
1748            end_row: u32::try_from(view.end_row()).ok()?,
1749            end_col: u32::try_from(view.end_col()).ok()?,
1750            axis,
1751            snapshot_id: self.data_snapshot_id(),
1752        };
1753        if let Some(index) = self.lookup_index_cache.get(&key) {
1754            return Some(index);
1755        }
1756        if self
1757            .lookup_index_cache
1758            .would_exceed_cap(estimate_bytes(len, 0))
1759        {
1760            self.lookup_index_cache.note_skipped_cap();
1761            return None;
1762        }
1763        if !self.lookup_index_cache.should_build(key) {
1764            return None;
1765        }
1766        if self.lookup_index_cache.is_known_volatile(&key) {
1767            self.lookup_index_cache.note_skipped_volatile();
1768            return None;
1769        }
1770        if self.lookup_view_contains_volatile(view, sheet_id) {
1771            self.lookup_index_cache.note_volatile_key(key);
1772            self.lookup_index_cache.note_skipped_volatile();
1773            return None;
1774        }
1775        match LookupIndex::build(view, axis).ok()? {
1776            BuildOutcome::Built(index) => self.lookup_index_cache.insert_if_room(key, index),
1777            BuildOutcome::ErrorInLookupAxis => {
1778                self.lookup_index_cache.note_skipped_error();
1779                None
1780            }
1781            BuildOutcome::Degenerate => {
1782                self.lookup_index_cache.note_skipped_tiny();
1783                None
1784            }
1785        }
1786    }
1787
1788    fn reset_virtual_dep_telemetry_if_disabled(&mut self) {
1789        if !self.config.enable_virtual_dep_telemetry {
1790            self.last_virtual_dep_telemetry = VirtualDepTelemetry {
1791                fallback_mode_activations: self.virtual_dep_fallback_activations,
1792                ..VirtualDepTelemetry::default()
1793            };
1794        }
1795    }
1796
1797    fn source_cache_session(&self) -> SourceCacheSession {
1798        self.clear_source_cache();
1799        SourceCacheSession {
1800            cache: self.source_cache.clone(),
1801        }
1802    }
1803
1804    fn resolve_source_scalar_cached(
1805        &self,
1806        name: &str,
1807        version: Option<u64>,
1808    ) -> Result<LiteralValue, ExcelError> {
1809        let key = (name.to_string(), version);
1810        if let Ok(mut g) = self.source_cache.write() {
1811            if let Some(v) = g.scalars.get(&key) {
1812                return Ok(v.clone());
1813            }
1814
1815            let v = self.resolver.resolve_source_scalar(name).map_err(|err| {
1816                if matches!(err.kind, ExcelErrorKind::Name | ExcelErrorKind::NImpl) {
1817                    ExcelError::new(ExcelErrorKind::Ref)
1818                        .with_message(format!("Unresolved source scalar: {name}"))
1819                } else {
1820                    err
1821                }
1822            })?;
1823            g.scalars.insert(key, v.clone());
1824            Ok(v)
1825        } else {
1826            self.resolver.resolve_source_scalar(name).map_err(|err| {
1827                if matches!(err.kind, ExcelErrorKind::Name | ExcelErrorKind::NImpl) {
1828                    ExcelError::new(ExcelErrorKind::Ref)
1829                        .with_message(format!("Unresolved source scalar: {name}"))
1830                } else {
1831                    err
1832                }
1833            })
1834        }
1835    }
1836
1837    fn resolve_source_table_cached(
1838        &self,
1839        name: &str,
1840        version: Option<u64>,
1841    ) -> Result<Arc<dyn crate::traits::Table>, ExcelError> {
1842        let key = (name.to_string(), version);
1843        if let Ok(mut g) = self.source_cache.write() {
1844            if let Some(t) = g.tables.get(&key) {
1845                return Ok(t.clone());
1846            }
1847
1848            let t = self.resolver.resolve_source_table(name).map_err(|err| {
1849                if matches!(err.kind, ExcelErrorKind::Name | ExcelErrorKind::NImpl) {
1850                    ExcelError::new(ExcelErrorKind::Ref)
1851                        .with_message(format!("Unresolved source table: {name}"))
1852                } else {
1853                    err
1854                }
1855            })?;
1856            let t: Arc<dyn crate::traits::Table> = Arc::from(t);
1857            g.tables.insert(key, t.clone());
1858            Ok(t)
1859        } else {
1860            self.resolver
1861                .resolve_source_table(name)
1862                .map_err(|err| {
1863                    if matches!(err.kind, ExcelErrorKind::Name | ExcelErrorKind::NImpl) {
1864                        ExcelError::new(ExcelErrorKind::Ref)
1865                            .with_message(format!("Unresolved source table: {name}"))
1866                    } else {
1867                        err
1868                    }
1869                })
1870                .map(Arc::from)
1871        }
1872    }
1873
1874    fn source_table_to_range_view(
1875        &self,
1876        table: &dyn crate::traits::Table,
1877        spec: &Option<formualizer_parse::parser::TableSpecifier>,
1878    ) -> Result<RangeView<'static>, ExcelError> {
1879        use formualizer_parse::parser::{SpecialItem, TableSpecifier};
1880
1881        let owned = match spec {
1882            Some(TableSpecifier::Column(c)) => {
1883                let c = c.trim();
1884                if c == "@" || c.contains('[') || c.contains(']') || c.contains(',') {
1885                    return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
1886                        "Complex structured references not yet supported".to_string(),
1887                    ));
1888                }
1889                table.get_column(c)?.materialise().into_owned()
1890            }
1891            Some(TableSpecifier::ColumnRange(start, end)) => {
1892                let cols = table.columns();
1893                let start = start.trim();
1894                let end = end.trim();
1895                let start_key = start.to_lowercase();
1896                let end_key = end.to_lowercase();
1897                let start_idx = cols.iter().position(|n| n.to_lowercase() == start_key);
1898                let end_idx = cols.iter().position(|n| n.to_lowercase() == end_key);
1899                if let (Some(mut si), Some(mut ei)) = (start_idx, end_idx) {
1900                    if si > ei {
1901                        std::mem::swap(&mut si, &mut ei);
1902                    }
1903                    let h = table.data_height();
1904                    let w = ei - si + 1;
1905                    let mut rows = vec![vec![LiteralValue::Empty; w]; h];
1906                    for (offset, ci) in (si..=ei).enumerate() {
1907                        let cname = &cols[ci];
1908                        let col_range = table.get_column(cname)?;
1909                        let (rh, _) = col_range.dimensions();
1910                        for (r, row) in rows.iter_mut().enumerate().take(h.min(rh)) {
1911                            row[offset] = col_range.get(r, 0)?;
1912                        }
1913                    }
1914                    rows
1915                } else {
1916                    return Err(ExcelError::new(ExcelErrorKind::Ref)
1917                        .with_message("Column range refers to unknown column(s)".to_string()));
1918                }
1919            }
1920            Some(TableSpecifier::SpecialItem(SpecialItem::Headers))
1921            | Some(TableSpecifier::Headers) => table
1922                .headers_row()
1923                .map(|r| r.materialise().into_owned())
1924                .unwrap_or_default(),
1925            Some(TableSpecifier::SpecialItem(SpecialItem::Totals))
1926            | Some(TableSpecifier::Totals) => table
1927                .totals_row()
1928                .map(|r| r.materialise().into_owned())
1929                .unwrap_or_default(),
1930            Some(TableSpecifier::SpecialItem(SpecialItem::Data)) | Some(TableSpecifier::Data) => {
1931                table
1932                    .data_body()
1933                    .map(|r| r.materialise().into_owned())
1934                    .unwrap_or_default()
1935            }
1936            Some(TableSpecifier::SpecialItem(SpecialItem::All)) | Some(TableSpecifier::All) => {
1937                let mut out: Vec<Vec<LiteralValue>> = Vec::new();
1938                if let Some(h) = table.headers_row() {
1939                    out.extend(h.iter_rows());
1940                }
1941                if let Some(body) = table.data_body() {
1942                    out.extend(body.iter_rows());
1943                }
1944                if let Some(tr) = table.totals_row() {
1945                    out.extend(tr.iter_rows());
1946                }
1947                out
1948            }
1949            Some(TableSpecifier::SpecialItem(SpecialItem::ThisRow)) => {
1950                return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
1951                    "@ (This Row) requires table-aware context; not yet supported".to_string(),
1952                ));
1953            }
1954            Some(TableSpecifier::Row(_)) | Some(TableSpecifier::Combination(_)) => {
1955                return Err(ExcelError::new(ExcelErrorKind::NImpl)
1956                    .with_message("Complex structured references not yet supported".to_string()));
1957            }
1958            None => {
1959                return Err(ExcelError::new(ExcelErrorKind::NImpl)
1960                    .with_message("Table reference without specifier is unsupported".to_string()));
1961            }
1962        };
1963
1964        Ok(RangeView::from_owned_rows(owned, self.config.date_system))
1965    }
1966
1967    pub fn default_sheet_id(&self) -> SheetId {
1968        self.graph.default_sheet_id()
1969    }
1970
1971    pub fn default_sheet_name(&self) -> &str {
1972        self.graph.default_sheet_name()
1973    }
1974
1975    /// Update the workbook seed for deterministic RNGs in functions.
1976    pub fn set_workbook_seed(&mut self, seed: u64) {
1977        self.config.workbook_seed = seed;
1978    }
1979
1980    /// Set the volatile level policy (Always/OnRecalc/OnOpen)
1981    pub fn set_volatile_level(&mut self, level: crate::traits::VolatileLevel) {
1982        self.config.volatile_level = level;
1983    }
1984
1985    /// Enable/disable deterministic evaluation mode (fixed clock + timezone).
1986    pub fn set_deterministic_mode(
1987        &mut self,
1988        mode: crate::engine::DeterministicMode,
1989    ) -> Result<(), ExcelError> {
1990        let clock = mode.build_clock()?;
1991        self.config.deterministic_mode = mode;
1992        self.clock = crate::timezone::SnapshotClock::new(clock);
1993        Ok(())
1994    }
1995
1996    /// Inject a custom [`ClockProvider`](crate::timezone::ClockProvider) for
1997    /// volatile date/time builtins (`NOW()`, `TODAY()`).
1998    ///
1999    /// The provider is the clock *source*; per spec §7.11 the engine samples
2000    /// it once at the start of every evaluation request and all reads within
2001    /// that recalc (including SCC iteration passes) observe the frozen
2002    /// sample.
2003    pub fn set_clock(&mut self, clock: Arc<dyn crate::timezone::ClockProvider>) {
2004        self.clock = crate::timezone::SnapshotClock::new(clock);
2005    }
2006
2007    fn validate_deterministic_mode(&self) -> Result<(), ExcelError> {
2008        self.config.deterministic_mode.validate()
2009    }
2010
2011    pub fn sheet_id(&self, name: &str) -> Option<SheetId> {
2012        self.graph.sheet_id(name)
2013    }
2014
2015    pub fn sheet_id_mut(&mut self, name: &str) -> SheetId {
2016        self.add_sheet(name)
2017            .unwrap_or_else(|_| self.graph.sheet_id_mut(name))
2018    }
2019
2020    pub fn sheet_name(&self, id: SheetId) -> &str {
2021        self.graph.sheet_name(id)
2022    }
2023
2024    pub fn add_sheet(&mut self, name: &str) -> Result<SheetId, ExcelError> {
2025        let id = self.graph.add_sheet(name)?;
2026        self.ensure_arrow_sheet(name);
2027        // Adding a sheet does not invalidate existing SheetId-based FormulaPlane
2028        // spans. `graph.add_sheet` handles legacy orphan-healing for formulas
2029        // that were explicitly tombstoned for this sheet name; avoid a global
2030        // FormulaPlane demotion/dirty mark for unrelated spans.
2031        self.mark_topology_edited();
2032        Ok(id)
2033    }
2034
2035    pub fn duplicate_sheet(&mut self, source: &str, new_name: &str) -> Result<SheetId, ExcelError> {
2036        let source_id = self.graph.sheet_id(source).ok_or_else(|| {
2037            ExcelError::new(ExcelErrorKind::Value).with_message("Source sheet does not exist")
2038        })?;
2039        // Materialize only spans on the source sheet so graph duplication sees
2040        // the formulas being copied. Spans on unrelated sheets remain active.
2041        self.demote_spans_preserving_computed_overlays(source_id, Region::whole_sheet(source_id))
2042            .map_err(Self::editor_error_to_excel)?;
2043        let new_id = self.graph.duplicate_sheet(source_id, new_name)?;
2044
2045        if let Some(source_sheet) = self.arrow_sheets.sheet(source).cloned() {
2046            let mut copied_sheet = source_sheet;
2047            copied_sheet.name = Arc::<str>::from(new_name);
2048            self.arrow_sheets.sheets.push(copied_sheet);
2049        } else {
2050            self.ensure_arrow_sheet(new_name);
2051        }
2052
2053        self.clear_all_computed_overlays();
2054        self.mark_all_formula_vertices_dirty();
2055        self.mark_topology_edited();
2056        Ok(new_id)
2057    }
2058
2059    fn ensure_arrow_sheet(&mut self, name: &str) {
2060        if self.arrow_sheets.sheet(name).is_some() {
2061            return;
2062        }
2063        self.arrow_sheets
2064            .sheets
2065            .push(crate::arrow_store::ArrowSheet {
2066                name: std::sync::Arc::<str>::from(name),
2067                columns: Vec::new(),
2068                nrows: 0,
2069                chunk_starts: Vec::new(),
2070                chunk_rows: 32 * 1024,
2071            });
2072    }
2073
2074    pub fn remove_sheet(&mut self, sheet_id: SheetId) -> Result<(), ExcelError> {
2075        let name = self.graph.sheet_name(sheet_id).to_string();
2076        // Removing a sheet only affects spans on that sheet and spans reading
2077        // from that sheet. Preserve spans on unrelated sheets so sheet
2078        // lifecycle operations do not collapse the whole FormulaPlane.
2079        self.demote_spans_preserving_computed_overlays(sheet_id, Region::whole_sheet(sheet_id))
2080            .map_err(Self::editor_error_to_excel)?;
2081        self.graph.remove_sheet(sheet_id)?;
2082        self.arrow_sheets.sheets.retain(|s| s.name.as_ref() != name);
2083        self.clear_all_computed_overlays();
2084        self.mark_all_formula_vertices_dirty();
2085        self.staged_formulas.remove(&name);
2086        if self.row_visibility.remove(&sheet_id).is_some() {
2087            self.invalidate_row_visibility_mask_cache();
2088        }
2089        self.record_formula_plane_structural_change(StructuralScope::RemovedSheet(sheet_id));
2090        self.mark_topology_edited();
2091        Ok(())
2092    }
2093
2094    /// Helper to synchronize the Arrow-backed storage layer.
2095    fn rename_sheet_in_arrow_store(&mut self, target_name: &str, new_name: &str) -> bool {
2096        if let Some(asheet) = self
2097            .arrow_sheets
2098            .sheets
2099            .iter_mut()
2100            .find(|s| s.name.as_ref() == target_name)
2101        {
2102            asheet.name = std::sync::Arc::<str>::from(new_name);
2103            return true;
2104        }
2105        false
2106    }
2107
2108    pub fn rename_sheet(&mut self, sheet_id: SheetId, new_name: &str) -> Result<(), ExcelError> {
2109        let old_name = self.graph.sheet_name(sheet_id).to_string();
2110
2111        // Speculative Storage Update
2112        // Update name in storage FIRST so the Evaluator can find it during Graph rescue.
2113        self.rename_sheet_in_arrow_store(&old_name, new_name);
2114
2115        // Graph Update (Metadata + Rescue Logic)
2116        match self.graph.rename_sheet(sheet_id, new_name) {
2117            Ok(_) => {
2118                self.rename_staged_formula_sheet(&old_name, new_name);
2119                // Success! Invalidate cache for the moved sheet
2120                let sheet_vertices: Vec<VertexId> =
2121                    self.graph.vertices_in_sheet(sheet_id).collect();
2122                for v_id in sheet_vertices {
2123                    self.graph.mark_vertex_dirty(v_id);
2124                }
2125                // Sheet rename is metadata-only and preserves SheetId. References resolve by
2126                // SheetId, so no FormulaPlane changed region is required. Removing this avoids
2127                // re-evaluating every span that reads the renamed sheet.
2128                self.mark_topology_edited();
2129                Ok(())
2130            }
2131            Err(e) => {
2132                // ROLLBACK: Revert storage if graph rejected the name
2133                self.rename_sheet_in_arrow_store(new_name, &old_name);
2134                Err(e)
2135            }
2136        }
2137    }
2138
2139    pub fn named_ranges_iter(
2140        &self,
2141    ) -> impl Iterator<Item = (&String, &crate::engine::named_range::NamedRange)> {
2142        self.graph.named_ranges_iter()
2143    }
2144
2145    pub fn sheet_named_ranges_iter(
2146        &self,
2147    ) -> impl Iterator<Item = (&(SheetId, String), &crate::engine::named_range::NamedRange)> {
2148        self.graph.sheet_named_ranges_iter()
2149    }
2150
2151    pub fn resolve_name_entry(
2152        &self,
2153        name: &str,
2154        current_sheet: SheetId,
2155    ) -> Option<&crate::engine::named_range::NamedRange> {
2156        self.graph.resolve_name_entry(name, current_sheet)
2157    }
2158
2159    pub fn named_ranges_snapshot(&self) -> Vec<crate::engine::named_range::NamedRangeSnapshot> {
2160        let mut out: Vec<crate::engine::named_range::NamedRangeSnapshot> = Vec::new();
2161
2162        for (name, named) in self.graph.named_ranges_iter() {
2163            out.push(crate::engine::named_range::NamedRangeSnapshot {
2164                name: name.clone(),
2165                scope: NameScope::Workbook,
2166                definition: named.definition.clone(),
2167            });
2168        }
2169
2170        for ((sheet_id, name), named) in self.graph.sheet_named_ranges_iter() {
2171            out.push(crate::engine::named_range::NamedRangeSnapshot {
2172                name: name.clone(),
2173                scope: NameScope::Sheet(*sheet_id),
2174                definition: named.definition.clone(),
2175            });
2176        }
2177
2178        out.sort_by(|a, b| {
2179            let a_scope = match a.scope {
2180                NameScope::Workbook => (0u8, 0u32),
2181                NameScope::Sheet(id) => (1u8, u32::from(id)),
2182            };
2183            let b_scope = match b.scope {
2184                NameScope::Workbook => (0u8, 0u32),
2185                NameScope::Sheet(id) => (1u8, u32::from(id)),
2186            };
2187            a_scope.cmp(&b_scope).then_with(|| a.name.cmp(&b.name))
2188        });
2189
2190        out
2191    }
2192
2193    pub fn named_ranges_snapshot_for_sheet(
2194        &self,
2195        sheet_id: SheetId,
2196    ) -> Vec<crate::engine::named_range::NamedRangeSnapshot> {
2197        self.named_ranges_snapshot()
2198            .into_iter()
2199            .filter(|entry| match entry.scope {
2200                NameScope::Workbook => true,
2201                NameScope::Sheet(id) => id == sheet_id,
2202            })
2203            .collect()
2204    }
2205
2206    pub fn define_name(
2207        &mut self,
2208        name: &str,
2209        definition: NamedDefinition,
2210        scope: NameScope,
2211    ) -> Result<(), ExcelError> {
2212        // A new define can flip resolution for spans that previously resolved
2213        // the same name through another scope (e.g. a sheet-scoped name
2214        // shadowing a workbook-scoped one). Demote those spans BEFORE the
2215        // registry changes so their cells re-ingest and re-resolve through
2216        // the normal legacy path.
2217        self.invalidate_formula_plane_spans_for_name(name)?;
2218        self.graph.define_name(name, definition, scope)?;
2219        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2220        self.mark_topology_edited();
2221        Ok(())
2222    }
2223
2224    pub fn update_name(
2225        &mut self,
2226        name: &str,
2227        definition: NamedDefinition,
2228        scope: NameScope,
2229    ) -> Result<(), ExcelError> {
2230        // Demote name-dependent spans BEFORE the registry update: the demoted
2231        // cells re-materialize as legacy vertices attached to the name vertex
2232        // (via their resolved-name dep plans), so the registry update's
2233        // dependent dirtying reaches them exactly like long-lived legacy
2234        // formulas.
2235        self.invalidate_formula_plane_spans_for_name(name)?;
2236        self.graph.update_name(name, definition, scope)?;
2237        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2238        self.mark_topology_edited();
2239        Ok(())
2240    }
2241
2242    pub fn delete_name(&mut self, name: &str, scope: NameScope) -> Result<(), ExcelError> {
2243        // Demote first (see update_name): the demoted legacy vertices become
2244        // dependents of the name vertex, so delete_name dirties them and they
2245        // re-evaluate to #NAME? exactly as legacy formulas do.
2246        self.invalidate_formula_plane_spans_for_name(name)?;
2247        self.graph.delete_name(name, scope)?;
2248        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2249        self.mark_topology_edited();
2250        Ok(())
2251    }
2252
2253    /// Demote every FormulaPlane span whose ingest-time read projections
2254    /// resolved `name` (any scope; see the FormulaPlane name-dependents map
2255    /// for the conservative keying contract). Demotion materializes the span
2256    /// placements as legacy graph formulas through the existing demotion
2257    /// machinery, preserving computed values; the subsequent registry change
2258    /// then dirties them through the legacy name-dependents path.
2259    fn invalidate_formula_plane_spans_for_name(&mut self, name: &str) -> Result<(), ExcelError> {
2260        if self.config.formula_plane_mode == FormulaPlaneMode::Off {
2261            return Ok(());
2262        }
2263        let regions: Vec<Region> = {
2264            let authority = self.graph.formula_authority();
2265            authority
2266                .plane
2267                .name_dependent_span_refs(name)
2268                .into_iter()
2269                .filter_map(|span_ref| authority.plane.spans.get(span_ref))
2270                .map(|span| Region::from_domain(span.result_region.domain()))
2271                .collect()
2272        };
2273        for region in regions {
2274            self.demote_spans_preserving_computed_overlays(region.sheet_id(), region)
2275                .map_err(Self::editor_error_to_excel)?;
2276        }
2277        Ok(())
2278    }
2279
2280    pub fn define_table(
2281        &mut self,
2282        name: &str,
2283        range: crate::reference::RangeRef,
2284        header_row: bool,
2285        headers: Vec<String>,
2286        totals_row: bool,
2287    ) -> Result<(), ExcelError> {
2288        self.graph
2289            .define_table(name, range, header_row, headers, totals_row)?;
2290        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2291        self.mark_topology_edited();
2292        Ok(())
2293    }
2294
2295    pub fn define_source_scalar(
2296        &mut self,
2297        name: &str,
2298        version: Option<u64>,
2299    ) -> Result<(), ExcelError> {
2300        self.graph.define_source_scalar(name, version)?;
2301        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2302        self.mark_topology_edited();
2303        Ok(())
2304    }
2305
2306    pub fn define_source_table(
2307        &mut self,
2308        name: &str,
2309        version: Option<u64>,
2310    ) -> Result<(), ExcelError> {
2311        self.graph.define_source_table(name, version)?;
2312        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2313        self.mark_topology_edited();
2314        Ok(())
2315    }
2316
2317    pub fn set_source_scalar_version(
2318        &mut self,
2319        name: &str,
2320        version: Option<u64>,
2321    ) -> Result<(), ExcelError> {
2322        self.graph.set_source_scalar_version(name, version)?;
2323        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2324        Ok(())
2325    }
2326
2327    pub fn set_source_table_version(
2328        &mut self,
2329        name: &str,
2330        version: Option<u64>,
2331    ) -> Result<(), ExcelError> {
2332        self.graph.set_source_table_version(name, version)?;
2333        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2334        Ok(())
2335    }
2336
2337    pub fn invalidate_source(&mut self, name: &str) -> Result<(), ExcelError> {
2338        self.graph.invalidate_source(name)?;
2339        self.record_formula_plane_structural_change(StructuralScope::AllSheets);
2340        Ok(())
2341    }
2342
2343    pub fn vertex_value(&self, vertex: VertexId) -> Option<LiteralValue> {
2344        self.graph.get_value(vertex)
2345    }
2346
2347    pub fn graph_cell_value(&self, sheet: &str, row: u32, col: u32) -> Option<LiteralValue> {
2348        self.graph.get_cell_value(sheet, row, col)
2349    }
2350
2351    pub fn vertex_for_cell(&self, cell: &CellRef) -> Option<VertexId> {
2352        self.graph.get_vertex_for_cell(cell)
2353    }
2354
2355    pub fn evaluation_vertices(&self) -> Vec<VertexId> {
2356        self.graph.get_evaluation_vertices()
2357    }
2358
2359    /// Return read-only baseline counters for FormulaPlane/dispatch benchmarking.
2360    pub fn baseline_stats(&self) -> EngineBaselineStats {
2361        let graph = self.graph.baseline_stats();
2362        let formula_authority = self.graph.formula_authority();
2363        EngineBaselineStats {
2364            graph_vertex_count: graph.graph_vertex_count,
2365            graph_formula_vertex_count: graph.graph_formula_vertex_count,
2366            graph_edge_count: graph.graph_edge_count,
2367            dirty_vertex_count: graph.dirty_vertex_count,
2368            evaluation_vertex_count: graph.evaluation_vertex_count,
2369            formula_ast_root_count: graph.formula_ast_root_count,
2370            formula_ast_node_count: graph.formula_ast_node_count,
2371            staged_formula_count: self.staged_formula_count(),
2372            formula_plane_active_span_count: formula_authority.active_span_count(),
2373            formula_plane_producer_result_entries: formula_authority.producer_results.len(),
2374            formula_plane_consumer_read_entries: formula_authority.consumer_reads.len(),
2375            formula_plane_cycle_member_span_demotions: self
2376                .formula_plane_cycle_member_span_demotions,
2377        }
2378    }
2379
2380    #[cfg(test)]
2381    pub(crate) fn used_axis_bounds_cache_stats(&self) -> (usize, usize, usize, usize) {
2382        self.used_axis_bounds_cache
2383            .read()
2384            .ok()
2385            .and_then(|guard| {
2386                guard.as_ref().map(|cache| {
2387                    (
2388                        cache.row_hits.load(Ordering::Relaxed),
2389                        cache.row_misses.load(Ordering::Relaxed),
2390                        cache.col_hits.load(Ordering::Relaxed),
2391                        cache.col_misses.load(Ordering::Relaxed),
2392                    )
2393                })
2394            })
2395            .unwrap_or((0, 0, 0, 0))
2396    }
2397
2398    pub fn set_first_load_assume_new(&mut self, enabled: bool) {
2399        self.graph.set_first_load_assume_new(enabled);
2400    }
2401
2402    pub fn reset_ensure_touched(&mut self) {
2403        self.graph.reset_ensure_touched();
2404    }
2405
2406    pub fn finalize_sheet_index(&mut self, sheet: &str) {
2407        self.graph.finalize_sheet_index(sheet);
2408    }
2409
2410    /// Execute a named Engine action.
2411    ///
2412    /// Ticket 614 introduces this as the stable Engine-level transaction surface.
2413    /// For now actions are commit-only: they do not create changelog boundaries and they do not
2414    /// provide rollback/atomicity.
2415    ///
2416    /// Nested actions are deterministically handled by *disallowing* nesting: calling
2417    /// `Engine::action` while another action is active returns `EditorError::TransactionFailed`.
2418    pub fn action<T>(
2419        &mut self,
2420        name: impl AsRef<str>,
2421        f: impl FnOnce(&mut EngineAction<'_, R>) -> Result<T, crate::engine::EditorError>,
2422    ) -> Result<T, crate::engine::EditorError> {
2423        if self.action_depth != 0 {
2424            return Err(crate::engine::EditorError::TransactionFailed {
2425                reason: "Nested Engine::action calls are not supported (ticket 614: commit-only surface)"
2426                    .to_string(),
2427            });
2428        }
2429
2430        self.action_depth = 1;
2431        let engine_ptr: *mut Engine<R> = self;
2432        let _guard = ActionDepthGuard {
2433            engine: engine_ptr,
2434            _marker: std::marker::PhantomData,
2435        };
2436
2437        let mut tx = EngineAction {
2438            engine: self,
2439            name: name.as_ref().to_string(),
2440            log: None,
2441            arrow_undo: None,
2442            atomic_policy: false,
2443        };
2444        f(&mut tx)
2445    }
2446
2447    /// Execute a named Engine action with atomic commit/rollback semantics.
2448    ///
2449    /// This variant does not require a `ChangeLog` and uses an internal journal for rollback.
2450    pub fn action_atomic<T>(
2451        &mut self,
2452        name: impl Into<String>,
2453        f: impl FnOnce(&mut EngineAction<'_, R>) -> Result<T, crate::engine::EditorError>,
2454    ) -> Result<T, crate::engine::EditorError> {
2455        let (v, _j) = self.action_atomic_journal(name, f)?;
2456        Ok(v)
2457    }
2458
2459    /// Like `action_atomic`, but returns the committed journal entry for undo/redo storage.
2460    pub fn action_atomic_journal<T>(
2461        &mut self,
2462        name: impl Into<String>,
2463        f: impl FnOnce(&mut EngineAction<'_, R>) -> Result<T, crate::engine::EditorError>,
2464    ) -> Result<(T, crate::engine::ActionJournal), crate::engine::EditorError> {
2465        if self.action_depth != 0 {
2466            return Err(crate::engine::EditorError::TransactionFailed {
2467                reason: "Nested Engine::action calls are not supported (deterministic rule)"
2468                    .to_string(),
2469            });
2470        }
2471
2472        self.action_depth = 1;
2473        let engine_ptr: *mut Engine<R> = self;
2474        let _guard = ActionDepthGuard {
2475            engine: engine_ptr,
2476            _marker: std::marker::PhantomData,
2477        };
2478
2479        let name_str = name.into();
2480        let mut log = crate::engine::ChangeLog::new();
2481        let start_len = log.len();
2482        self.action_atomic_impl(&mut log, start_len, name_str, f)
2483    }
2484
2485    fn action_atomic_impl<T>(
2486        &mut self,
2487        log: &mut crate::engine::ChangeLog,
2488        start_len: usize,
2489        name: String,
2490        f: impl FnOnce(&mut EngineAction<'_, R>) -> Result<T, crate::engine::EditorError>,
2491    ) -> Result<(T, crate::engine::ActionJournal), crate::engine::EditorError> {
2492        let mut arrow_undo = crate::engine::ArrowUndoBatch::default();
2493        let arrow_ptr: *mut crate::engine::ArrowUndoBatch = &mut arrow_undo;
2494
2495        let log_ptr: *mut crate::engine::ChangeLog = log;
2496        let mut tx = EngineAction {
2497            engine: self,
2498            name: name.clone(),
2499            log: Some(log_ptr),
2500            arrow_undo: Some(arrow_ptr),
2501            atomic_policy: true,
2502        };
2503
2504        let res = f(&mut tx);
2505
2506        // Capture graph structural delta for this action.
2507        let graph_events: Vec<crate::engine::ChangeEvent> =
2508            unsafe { (&*log_ptr).events() }[start_len..].to_vec();
2509        let graph_batch = crate::engine::GraphUndoBatch {
2510            events: graph_events,
2511        };
2512        let affected_cells = arrow_undo.ops.len();
2513        let journal = crate::engine::ActionJournal {
2514            name,
2515            graph: graph_batch,
2516            arrow: arrow_undo,
2517            affected_cells,
2518        };
2519
2520        match res {
2521            Ok(v) => {
2522                if !journal.graph.is_empty() || !journal.arrow.is_empty() {
2523                    for event in &journal.graph.events {
2524                        self.record_formula_plane_change_for_event(event);
2525                    }
2526                    self.mark_data_edited();
2527                }
2528                Ok((v, journal))
2529            }
2530            Err(e) => {
2531                if let Err(rb) = self.rollback_from_action_journal(&journal) {
2532                    return Err(crate::engine::EditorError::TransactionFailed {
2533                        reason: format!(
2534                            "Engine::action_atomic rollback failed after error '{e}': {rb}"
2535                        ),
2536                    });
2537                }
2538                if !journal.graph.is_empty() || !journal.arrow.is_empty() {
2539                    for event in &journal.graph.events {
2540                        self.record_formula_plane_change_for_event(event);
2541                    }
2542                }
2543                Err(e)
2544            }
2545        }
2546    }
2547
2548    /// Execute a named Engine action, logging graph changes into the provided ChangeLog.
2549    ///
2550    /// Ticket 615: this variant provides atomicity. If the action returns an error, it rolls back:
2551    /// - Dependency graph structural edits (via inverse ChangeEvents)
2552    /// - Arrow-truth overlay writes mirrored from ChangeEvents
2553    /// - ChangeLog entries (truncated back to the pre-action length)
2554    pub fn action_with_logger<T>(
2555        &mut self,
2556        log: &mut crate::engine::ChangeLog,
2557        name: impl AsRef<str>,
2558        f: impl FnOnce(&mut EngineAction<'_, R>) -> Result<T, crate::engine::EditorError>,
2559    ) -> Result<T, crate::engine::EditorError> {
2560        if self.action_depth != 0 {
2561            return Err(crate::engine::EditorError::TransactionFailed {
2562                reason: "Nested Engine::action calls are not supported (deterministic rule)"
2563                    .to_string(),
2564            });
2565        }
2566
2567        self.action_depth = 1;
2568        let engine_ptr: *mut Engine<R> = self;
2569        let _guard = ActionDepthGuard {
2570            engine: engine_ptr,
2571            _marker: std::marker::PhantomData,
2572        };
2573
2574        let start_len = log.len();
2575        let name_str = name.as_ref().to_string();
2576        log.begin_compound(name_str.clone());
2577
2578        // Use the provided ChangeLog as an observability sink.
2579        // Correctness is provided by the internal `ActionJournal` returned from the atomic impl.
2580        let res = self.action_atomic_impl(log, start_len, name_str, f);
2581
2582        match res {
2583            Ok((v, _journal)) => {
2584                log.end_compound();
2585                Ok(v)
2586            }
2587            Err(e) => {
2588                // Close compound and truncate log as cleanup only.
2589                log.end_compound();
2590                log.truncate(start_len);
2591                Err(e)
2592            }
2593        }
2594    }
2595
2596    fn rollback_from_action_journal(
2597        &mut self,
2598        journal: &crate::engine::ActionJournal,
2599    ) -> Result<(), crate::engine::EditorError> {
2600        // 1) Roll back the dependency graph structure.
2601        journal.graph.undo(&mut self.graph)?;
2602        // 2) Roll back engine row-visibility sidecar events.
2603        self.apply_inverse_row_visibility_events(&journal.graph.events);
2604        // 3) Roll back Arrow-truth overlays.
2605        self.apply_arrow_undo_batch(&journal.arrow, /*undo=*/ true);
2606        Ok(())
2607    }
2608
2609    fn rollback_from_change_events(
2610        &mut self,
2611        events: &[crate::engine::ChangeEvent],
2612    ) -> Result<(), crate::engine::EditorError> {
2613        use crate::engine::ChangeEvent;
2614
2615        // 1) Roll back the dependency graph.
2616        {
2617            let mut editor = crate::engine::VertexEditor::new(&mut self.graph);
2618            let mut compound_stack: Vec<usize> = Vec::new();
2619            for ev in events.iter().rev() {
2620                match ev {
2621                    ChangeEvent::CompoundEnd { depth } => compound_stack.push(*depth),
2622                    ChangeEvent::CompoundStart { depth, .. } => {
2623                        if compound_stack.last() == Some(depth) {
2624                            compound_stack.pop();
2625                        }
2626                    }
2627                    ChangeEvent::SetRowVisibility { .. } => {
2628                        // Engine-side metadata handled after dropping graph editor borrow.
2629                    }
2630                    _ => {
2631                        editor.apply_inverse(ev.clone())?;
2632                    }
2633                }
2634            }
2635        }
2636
2637        // 2) Roll back engine row-visibility metadata.
2638        for ev in events.iter().rev() {
2639            self.apply_inverse_row_visibility_event(ev);
2640        }
2641
2642        // 3) Roll back Arrow-truth overlays mirrored from those ChangeEvents.
2643        for ev in events.iter().rev() {
2644            self.mirror_inverse_change_to_arrow(ev);
2645        }
2646
2647        Ok(())
2648    }
2649
2650    fn read_cell_formula_ast(&self, sheet: &str, row: u32, col: u32) -> Option<ASTNode> {
2651        let sheet_id = self.graph.sheet_id(sheet)?;
2652        let coord = Coord::from_excel(row, col, true, true);
2653        let cell = CellRef::new(sheet_id, coord);
2654        let vid = self.graph.get_vertex_for_cell(&cell)?;
2655        let ast_id = self.graph.get_formula_id(vid)?;
2656        self.graph
2657            .data_store()
2658            .retrieve_ast(ast_id, self.graph.sheet_reg())
2659    }
2660
2661    pub fn edit_with_logger<T>(
2662        &mut self,
2663        log: &mut crate::engine::ChangeLog,
2664        f: impl FnOnce(&mut crate::engine::VertexEditor) -> T,
2665    ) -> T {
2666        // Record starting log length so we can mirror only newly-recorded events.
2667        let start_len = log.len();
2668
2669        // Provide a spill snapshot reader so VertexEditor can snapshot Arrow-truth spill values
2670        // (graph value cache is intentionally empty in canonical mode).
2671        struct ArrowSpillReader<'a> {
2672            sheets: &'a crate::arrow_store::SheetStore,
2673        }
2674        impl crate::engine::graph::editor::vertex_editor::SpillValueReader for ArrowSpillReader<'_> {
2675            fn read_cell_value(
2676                &self,
2677                sheet: &str,
2678                row: u32,
2679                col: u32,
2680            ) -> Option<formualizer_common::LiteralValue> {
2681                use formualizer_common::LiteralValue;
2682                let asheet = self.sheets.sheet(sheet)?;
2683                let r0 = row.saturating_sub(1) as usize;
2684                let c0 = col.saturating_sub(1) as usize;
2685                let v = asheet.get_cell_value(r0, c0);
2686                if matches!(v, LiteralValue::Empty) {
2687                    None
2688                } else {
2689                    Some(v)
2690                }
2691            }
2692        }
2693
2694        let ret = {
2695            let spill_reader = ArrowSpillReader {
2696                sheets: &self.arrow_sheets,
2697            };
2698            let mut editor = crate::engine::VertexEditor::with_logger_and_spill_reader(
2699                &mut self.graph,
2700                log,
2701                &spill_reader,
2702            );
2703            f(&mut editor)
2704        };
2705
2706        // Mirror value-impacting graph events to Arrow for forward edits.
2707        // This keeps Arrow overlays (delta + computed) consistent when edits clear/commit spills.
2708        for ev in &log.events()[start_len..] {
2709            self.mirror_forward_change_to_arrow(ev);
2710        }
2711        for ev in &log.events()[start_len..] {
2712            self.record_formula_plane_change_for_event(ev);
2713        }
2714
2715        ret
2716    }
2717
2718    pub fn undo_logged(
2719        &mut self,
2720        undo: &mut crate::engine::graph::editor::undo_engine::UndoEngine,
2721        log: &mut crate::engine::ChangeLog,
2722    ) -> Result<(), crate::engine::EditorError> {
2723        let batch = undo.undo(&mut self.graph, log)?;
2724        for item in batch.iter().rev() {
2725            self.apply_inverse_row_visibility_event(&item.event);
2726            self.apply_inverse_staged_formula_event(&item.event);
2727        }
2728        self.mirror_undo_batch_to_arrow(&batch);
2729        if !batch.is_empty() {
2730            for item in &batch {
2731                self.record_formula_plane_change_for_event(&item.event);
2732            }
2733        }
2734        Ok(())
2735    }
2736
2737    pub fn redo_logged(
2738        &mut self,
2739        undo: &mut crate::engine::graph::editor::undo_engine::UndoEngine,
2740        log: &mut crate::engine::ChangeLog,
2741    ) -> Result<(), crate::engine::EditorError> {
2742        let batch = undo.redo(&mut self.graph, log)?;
2743        for item in &batch {
2744            self.apply_forward_row_visibility_event(&item.event);
2745            self.apply_forward_staged_formula_event(&item.event);
2746        }
2747        self.mirror_redo_batch_to_arrow(&batch);
2748        if !batch.is_empty() {
2749            for item in &batch {
2750                self.record_formula_plane_change_for_event(&item.event);
2751            }
2752        }
2753        Ok(())
2754    }
2755
2756    /// Undo the last committed atomic action using the journal stack.
2757    ///
2758    /// This path does not require a `ChangeLog`.
2759    pub fn undo_action(
2760        &mut self,
2761        undo: &mut crate::engine::graph::editor::undo_engine::UndoEngine,
2762    ) -> Result<(), crate::engine::EditorError> {
2763        let Some(journal) = undo.pop_undo_action() else {
2764            return Ok(());
2765        };
2766
2767        journal.graph.undo(&mut self.graph)?;
2768        self.apply_inverse_row_visibility_events(&journal.graph.events);
2769        self.apply_arrow_undo_batch(&journal.arrow, /*undo=*/ true);
2770        if !journal.graph.is_empty() || !journal.arrow.is_empty() {
2771            for event in &journal.graph.events {
2772                self.record_formula_plane_change_for_event(event);
2773            }
2774            self.mark_data_edited();
2775        }
2776
2777        undo.push_redo_action(journal);
2778        Ok(())
2779    }
2780
2781    /// Redo the last undone atomic action using the journal stack.
2782    ///
2783    /// This path does not require a `ChangeLog`.
2784    pub fn redo_action(
2785        &mut self,
2786        undo: &mut crate::engine::graph::editor::undo_engine::UndoEngine,
2787    ) -> Result<(), crate::engine::EditorError> {
2788        let Some(journal) = undo.pop_redo_action() else {
2789            return Ok(());
2790        };
2791
2792        journal.graph.redo(&mut self.graph)?;
2793        self.apply_forward_row_visibility_events(&journal.graph.events);
2794        self.apply_arrow_undo_batch(&journal.arrow, /*undo=*/ false);
2795        if !journal.graph.is_empty() || !journal.arrow.is_empty() {
2796            for event in &journal.graph.events {
2797                self.record_formula_plane_change_for_event(event);
2798            }
2799            self.mark_data_edited();
2800        }
2801
2802        undo.push_done_action(journal);
2803        Ok(())
2804    }
2805
2806    fn cellref_to_sheet_row_col(&self, addr: &crate::reference::CellRef) -> (String, u32, u32) {
2807        let sheet = self.graph.sheet_name(addr.sheet_id).to_string();
2808        // Coord stores 0-based indices.
2809        let row = addr.coord.row() + 1;
2810        let col = addr.coord.col() + 1;
2811        (sheet, row, col)
2812    }
2813
2814    fn mirror_undo_batch_to_arrow(
2815        &mut self,
2816        batch: &[crate::engine::graph::editor::undo_engine::UndoBatchItem],
2817    ) {
2818        // Undo applies inverses in reverse order.
2819        for item in batch.iter().rev() {
2820            self.mirror_inverse_change_to_arrow(&item.event);
2821        }
2822    }
2823
2824    fn mirror_redo_batch_to_arrow(
2825        &mut self,
2826        batch: &[crate::engine::graph::editor::undo_engine::UndoBatchItem],
2827    ) {
2828        // Redo applies events in forward order.
2829        for item in batch.iter() {
2830            self.mirror_forward_change_to_arrow(&item.event);
2831        }
2832    }
2833
2834    fn mirror_inverse_change_to_arrow(&mut self, ev: &crate::engine::ChangeEvent) {
2835        use crate::engine::ChangeEvent;
2836        use formualizer_common::LiteralValue;
2837
2838        match ev {
2839            ChangeEvent::SetValue {
2840                addr,
2841                old_value,
2842                old_formula,
2843                ..
2844            } => {
2845                let (sheet, row, col) = self.cellref_to_sheet_row_col(addr);
2846                if old_formula.is_some() {
2847                    self.clear_delta_overlay_cell(&sheet, row, col);
2848                } else {
2849                    let v = old_value.clone().unwrap_or(LiteralValue::Empty);
2850                    self.mirror_value_to_overlay(&sheet, row, col, &v);
2851                }
2852            }
2853            ChangeEvent::SetFormula {
2854                addr,
2855                old_value,
2856                old_formula,
2857                ..
2858            } => {
2859                let (sheet, row, col) = self.cellref_to_sheet_row_col(addr);
2860                if old_formula.is_some() {
2861                    self.clear_delta_overlay_cell(&sheet, row, col);
2862                } else {
2863                    let v = old_value.clone().unwrap_or(LiteralValue::Empty);
2864                    self.mirror_value_to_overlay(&sheet, row, col, &v);
2865                }
2866            }
2867            ChangeEvent::SpillCommitted { old, new, .. } => {
2868                // Inverse: restore `old` (or clear if none).
2869                self.mirror_spill_snapshot(new, /*clear_only=*/ true);
2870                if let Some(snap) = old {
2871                    self.mirror_spill_snapshot(snap, /*clear_only=*/ false);
2872                }
2873            }
2874            ChangeEvent::SpillCleared { old, .. } => {
2875                // Inverse: restore prior spill.
2876                self.mirror_spill_snapshot(old, /*clear_only=*/ false);
2877            }
2878            ChangeEvent::SetRowVisibility { .. } => {
2879                // Engine-side metadata only; no Arrow overlay effect.
2880            }
2881            _ => {}
2882        }
2883    }
2884
2885    fn mirror_forward_change_to_arrow(&mut self, ev: &crate::engine::ChangeEvent) {
2886        use crate::engine::ChangeEvent;
2887
2888        match ev {
2889            ChangeEvent::SetValue { addr, new, .. } => {
2890                let (sheet, row, col) = self.cellref_to_sheet_row_col(addr);
2891                self.mirror_value_to_overlay(&sheet, row, col, new);
2892            }
2893            ChangeEvent::SetFormula { addr, .. } => {
2894                let (sheet, row, col) = self.cellref_to_sheet_row_col(addr);
2895                self.clear_delta_overlay_cell(&sheet, row, col);
2896                // Keep any computed overlay for this cell as-is; it will be recomputed on demand.
2897            }
2898            ChangeEvent::SpillCommitted { old, new, .. } => {
2899                if let Some(snap) = old {
2900                    self.mirror_spill_snapshot(snap, /*clear_only=*/ true);
2901                }
2902                self.mirror_spill_snapshot(new, /*clear_only=*/ false);
2903            }
2904            ChangeEvent::SpillCleared { old, .. } => {
2905                self.mirror_spill_snapshot(old, /*clear_only=*/ true);
2906            }
2907            ChangeEvent::SetRowVisibility { .. } => {
2908                // Engine-side metadata only; no Arrow overlay effect.
2909            }
2910            _ => {
2911                // Other graph structural operations do not have direct value effects in Arrow.
2912            }
2913        }
2914    }
2915
2916    fn mirror_spill_snapshot(
2917        &mut self,
2918        snap: &crate::engine::graph::editor::change_log::SpillSnapshot,
2919        clear_only: bool,
2920    ) {
2921        use formualizer_common::LiteralValue;
2922
2923        let mut i = 0usize;
2924        for row in &snap.values {
2925            for v in row {
2926                if let Some(cell) = snap.target_cells.get(i) {
2927                    let (sheet, r, c) = self.cellref_to_sheet_row_col(cell);
2928                    let out = if clear_only {
2929                        LiteralValue::Empty
2930                    } else {
2931                        v.clone()
2932                    };
2933                    self.mirror_value_to_computed_overlay(&sheet, r, c, &out);
2934                }
2935                i += 1;
2936            }
2937        }
2938        // If target_cells is longer than values (should not happen), clear remaining cells.
2939        if clear_only {
2940            for cell in snap.target_cells.iter().skip(i) {
2941                let (sheet, r, c) = self.cellref_to_sheet_row_col(cell);
2942                self.mirror_value_to_computed_overlay(&sheet, r, c, &LiteralValue::Empty);
2943            }
2944        }
2945    }
2946
2947    pub fn set_default_sheet_by_name(&mut self, name: &str) {
2948        self.graph.set_default_sheet_by_name(name);
2949    }
2950
2951    pub fn set_default_sheet_by_id(&mut self, id: SheetId) {
2952        self.graph.set_default_sheet_by_id(id);
2953    }
2954
2955    pub fn set_sheet_index_mode(&mut self, mode: crate::engine::SheetIndexMode) {
2956        self.graph.set_sheet_index_mode(mode);
2957    }
2958
2959    fn clear_cached_static_schedule(&mut self) {
2960        self.cached_static_schedule = None;
2961    }
2962
2963    /// Mark data edited: bump snapshot and set edited flag.
2964    /// Value-only edits keep the stable-topology schedule cache alive.
2965    pub fn mark_data_edited(&mut self) {
2966        self.snapshot_id
2967            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2968        self.has_edited = true;
2969    }
2970
2971    /// Mark a topology-changing edit: bump snapshot + topology epoch and invalidate cached schedules.
2972    pub fn mark_topology_edited(&mut self) {
2973        self.snapshot_id
2974            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2975        self.topology_epoch = self.topology_epoch.wrapping_add(1);
2976        self.clear_cached_static_schedule();
2977        self.has_edited = true;
2978    }
2979
2980    fn mark_all_formula_vertices_dirty(&mut self) {
2981        let vertices: Vec<VertexId> = self.graph.vertices_with_formulas().collect();
2982        for vertex in vertices {
2983            self.graph.mark_vertex_dirty(vertex);
2984        }
2985    }
2986
2987    fn mark_moved_formula_vertices_dirty(
2988        &mut self,
2989        summary: &crate::engine::graph::editor::vertex_editor::ShiftSummary,
2990    ) {
2991        for vertex in &summary.vertices_moved {
2992            if self.graph.get_formula_id(*vertex).is_some() {
2993                self.graph.mark_vertex_dirty(*vertex);
2994            }
2995        }
2996    }
2997
2998    /// Access Arrow sheet store (read-only)
2999    pub fn sheet_store(&self) -> &SheetStore {
3000        &self.arrow_sheets
3001    }
3002
3003    /// Access Arrow sheet store (mutable)
3004    pub fn sheet_store_mut(&mut self) -> &mut SheetStore {
3005        &mut self.arrow_sheets
3006    }
3007
3008    pub fn has_staged_formulas(&self) -> bool {
3009        !self.staged_formulas.is_empty()
3010    }
3011
3012    pub fn staged_formula_count(&self) -> usize {
3013        self.staged_formulas.values().map(StagedSheet::len).sum()
3014    }
3015
3016    /// Stage a formula text instead of inserting into the graph (used when deferring is enabled).
3017    pub fn stage_formula_text(&mut self, sheet: &str, row: u32, col: u32, text: String) {
3018        self.staged_formulas
3019            .entry(sheet.to_string())
3020            .or_default()
3021            .stage(row, col, text);
3022    }
3023
3024    pub fn clear_staged_formula_text(&mut self, sheet: &str, row: u32, col: u32) -> Option<String> {
3025        let mut removed = None;
3026        let mut remove_sheet = false;
3027        if let Some(entries) = self.staged_formulas.get_mut(sheet) {
3028            removed = entries.remove(row, col);
3029            remove_sheet = entries.is_empty();
3030        }
3031        if remove_sheet {
3032            self.staged_formulas.remove(sheet);
3033        }
3034        removed
3035    }
3036
3037    pub fn clear_staged_formulas_for_sheet(&mut self, sheet: &str) {
3038        self.staged_formulas.remove(sheet);
3039    }
3040
3041    pub fn rename_staged_formula_sheet(&mut self, old: &str, new: &str) {
3042        let Some(entries) = self.staged_formulas.remove(old) else {
3043            return;
3044        };
3045        for (row, col, text) in entries.into_entries() {
3046            self.stage_formula_text(new, row, col, text);
3047        }
3048    }
3049
3050    /// Get a staged formula text for a given cell if present (cloned).
3051    pub fn get_staged_formula_text(&self, sheet: &str, row: u32, col: u32) -> Option<String> {
3052        self.staged_formulas
3053            .get(sheet)
3054            .and_then(|v| v.get(row, col).map(str::to_owned))
3055    }
3056
3057    pub fn formula_parse_diagnostics(&self) -> &[FormulaParseDiagnostic] {
3058        &self.formula_parse_diagnostics
3059    }
3060
3061    pub fn take_formula_parse_diagnostics(&mut self) -> Vec<FormulaParseDiagnostic> {
3062        std::mem::take(&mut self.formula_parse_diagnostics)
3063    }
3064
3065    pub fn clear_formula_parse_diagnostics(&mut self) {
3066        self.formula_parse_diagnostics.clear();
3067    }
3068
3069    pub fn last_formula_ingest_report(&self) -> Option<&FormulaIngestReport> {
3070        self.last_formula_ingest_report.as_ref()
3071    }
3072
3073    pub fn formula_ingest_report_total(&self) -> &FormulaIngestReport {
3074        &self.formula_ingest_report_total
3075    }
3076
3077    #[cfg(test)]
3078    pub(crate) fn last_formula_plane_span_eval_report(&self) -> Option<&SpanEvalReport> {
3079        self.last_formula_plane_span_eval_report.as_ref()
3080    }
3081
3082    #[cfg(test)]
3083    pub(crate) fn formula_plane_indexes_epoch(&self) -> u64 {
3084        self.graph.formula_authority().indexes_epoch()
3085    }
3086
3087    #[cfg(test)]
3088    pub(crate) fn formula_plane_capacity_bailouts(&self) -> u64 {
3089        self.formula_plane_capacity_bailouts
3090    }
3091
3092    fn record_formula_ingest_report(&mut self, report: FormulaIngestReport) {
3093        self.formula_ingest_report_total.mode = report.mode;
3094        self.formula_ingest_report_total.accumulate(&report);
3095        self.last_formula_ingest_report = Some(report);
3096    }
3097
3098    fn analyze_formula_plane_shadow_candidates(
3099        &mut self,
3100        batches: &[FormulaIngestBatch],
3101    ) -> FormulaIngestReport {
3102        let mut report = FormulaIngestReport::with_mode(FormulaPlaneMode::Shadow);
3103        report.formula_cells_seen = batches.iter().map(|batch| batch.len() as u64).sum();
3104
3105        // Touch graph-owned authority deliberately: Tranche 3 shadow analysis uses
3106        // scratch state, but FormulaPlane ownership now lives on DependencyGraph.
3107        let _active_epoch = self.graph.formula_authority().plane.epoch();
3108
3109        let batch_sheet_ids: Vec<SheetId> = batches
3110            .iter()
3111            .map(|batch| self.graph.sheet_id_mut(&batch.sheet_name))
3112            .collect();
3113        let mut groups: BTreeMap<
3114            (SheetId, u64, u32),
3115            Vec<(FormulaPlacementCandidate, CandidateAnalysis)>,
3116        > = BTreeMap::new();
3117        {
3118            let mut pipeline = self.ingest_pipeline();
3119            for (batch, sheet_id) in batches.iter().zip(batch_sheet_ids.iter().copied()) {
3120                for record in &batch.formulas {
3121                    if record.row == 0 || record.col == 0 {
3122                        report.shadow_candidate_cells =
3123                            report.shadow_candidate_cells.saturating_add(1);
3124                        report.shadow_fallback_cells =
3125                            report.shadow_fallback_cells.saturating_add(1);
3126                        Self::record_shadow_fallback_reason(
3127                            &mut report,
3128                            PlacementFallbackReason::UnsupportedShapeOrGaps,
3129                            1,
3130                        );
3131                        continue;
3132                    }
3133
3134                    let placement = CellRef::new(
3135                        sheet_id,
3136                        Coord::from_excel(record.row, record.col, true, true),
3137                    );
3138                    let ingested = match pipeline.ingest_formula(
3139                        FormulaAstInput::RawArena(record.ast_id),
3140                        placement,
3141                        record.formula_text.clone(),
3142                    ) {
3143                        Ok(ingested) => ingested,
3144                        Err(_) => {
3145                            report.shadow_candidate_cells =
3146                                report.shadow_candidate_cells.saturating_add(1);
3147                            report.shadow_fallback_cells =
3148                                report.shadow_fallback_cells.saturating_add(1);
3149                            Self::record_shadow_fallback_reason(
3150                                &mut report,
3151                                PlacementFallbackReason::UnsupportedCanonicalTemplate,
3152                                1,
3153                            );
3154                            continue;
3155                        }
3156                    };
3157                    let candidate = FormulaPlacementCandidate::new(
3158                        sheet_id,
3159                        record.row - 1,
3160                        record.col - 1,
3161                        ingested.ast_id,
3162                        record.formula_text.clone(),
3163                    );
3164                    let analysis = match CandidateAnalysis::from_ingested(&candidate, &ingested) {
3165                        Ok(analysis) => analysis,
3166                        Err(reason) => {
3167                            report.shadow_candidate_cells =
3168                                report.shadow_candidate_cells.saturating_add(1);
3169                            report.shadow_fallback_cells =
3170                                report.shadow_fallback_cells.saturating_add(1);
3171                            Self::record_shadow_fallback_reason(&mut report, reason, 1);
3172                            continue;
3173                        }
3174                    };
3175                    groups
3176                        .entry((
3177                            sheet_id,
3178                            ingested.parameterized_canonical_hash,
3179                            candidate.col,
3180                        ))
3181                        .or_default()
3182                        .push((candidate, analysis));
3183                }
3184            }
3185        }
3186
3187        let mut scratch_plane = FormulaPlane::default();
3188        for entries in groups.into_values() {
3189            let (candidates, analyses): (Vec<_>, Vec<_>) = entries.into_iter().unzip();
3190            for (component, component_analyses) in
3191                Self::split_candidate_components_with_analyses(candidates, analyses)
3192            {
3193                let placement_report = place_candidate_family_with_analyses(
3194                    &mut scratch_plane,
3195                    component,
3196                    component_analyses,
3197                );
3198                let counters = placement_report.counters;
3199                report.shadow_candidate_cells = report
3200                    .shadow_candidate_cells
3201                    .saturating_add(counters.formula_cells_seen);
3202                report.shadow_accepted_span_cells = report
3203                    .shadow_accepted_span_cells
3204                    .saturating_add(counters.accepted_span_cells);
3205                report.shadow_fallback_cells = report
3206                    .shadow_fallback_cells
3207                    .saturating_add(counters.legacy_cells);
3208                report.shadow_templates_interned = report
3209                    .shadow_templates_interned
3210                    .saturating_add(counters.templates_interned);
3211                report.shadow_spans_created = report
3212                    .shadow_spans_created
3213                    .saturating_add(counters.spans_created);
3214                report.graph_formula_vertices_avoided_shadow = report
3215                    .graph_formula_vertices_avoided_shadow
3216                    .saturating_add(counters.formula_vertices_avoided);
3217                report.ast_roots_avoided_shadow = report
3218                    .ast_roots_avoided_shadow
3219                    .saturating_add(counters.ast_roots_avoided);
3220                report.edge_rows_avoided_shadow = report
3221                    .edge_rows_avoided_shadow
3222                    .saturating_add(counters.edge_rows_avoided);
3223                for (reason, count) in counters.fallback_reasons {
3224                    Self::record_shadow_fallback_reason(&mut report, reason, count);
3225                }
3226            }
3227        }
3228        report
3229    }
3230
3231    fn record_shadow_fallback_reason(
3232        report: &mut FormulaIngestReport,
3233        reason: PlacementFallbackReason,
3234        count: u64,
3235    ) {
3236        *report
3237            .fallback_reasons
3238            .entry(format!("{reason:?}"))
3239            .or_default() += count;
3240    }
3241
3242    fn analyze_formula_plane_authoritative_ingest(
3243        &mut self,
3244        batches: &[FormulaIngestBatch],
3245    ) -> (
3246        FormulaIngestReport,
3247        Vec<FormulaIngestBatch>,
3248        PlannedFormulaMaterialize,
3249    ) {
3250        let mut report =
3251            FormulaIngestReport::with_mode(FormulaPlaneMode::AuthoritativeExperimental);
3252        report.formula_cells_seen = batches.iter().map(|batch| batch.len() as u64).sum();
3253
3254        let mut pending_candidates: Vec<(String, FormulaPlacementCandidate)> = Vec::new();
3255        let mut fallback: BTreeMap<String, Vec<FormulaIngestRecord>> = BTreeMap::new();
3256        let mut planned_fallback: PlannedFormulaMaterialize = BTreeMap::new();
3257
3258        for batch in batches {
3259            let sheet_id = self.graph.sheet_id_mut(&batch.sheet_name);
3260            for record in &batch.formulas {
3261                if record.row == 0 || record.col == 0 {
3262                    report.shadow_candidate_cells = report.shadow_candidate_cells.saturating_add(1);
3263                    report.shadow_fallback_cells = report.shadow_fallback_cells.saturating_add(1);
3264                    Self::record_shadow_fallback_reason(
3265                        &mut report,
3266                        PlacementFallbackReason::UnsupportedShapeOrGaps,
3267                        1,
3268                    );
3269                    fallback
3270                        .entry(batch.sheet_name.clone())
3271                        .or_default()
3272                        .push(record.clone());
3273                    continue;
3274                }
3275
3276                pending_candidates.push((
3277                    batch.sheet_name.clone(),
3278                    FormulaPlacementCandidate::new(
3279                        sheet_id,
3280                        record.row - 1,
3281                        record.col - 1,
3282                        record.ast_id,
3283                        record.formula_text.clone(),
3284                    ),
3285                ));
3286            }
3287        }
3288
3289        let mut groups: BTreeMap<(SheetId, u64, u32), Vec<usize>> = BTreeMap::new();
3290        let mut analyses_by_index: Vec<Option<CandidateAnalysis>> =
3291            (0..pending_candidates.len()).map(|_| None).collect();
3292        let mut plans_by_index: Vec<Option<DependencyPlanRow>> =
3293            (0..pending_candidates.len()).map(|_| None).collect();
3294        {
3295            let mut pipeline = self.ingest_pipeline();
3296            for (idx, (sheet_name, candidate)) in pending_candidates.iter_mut().enumerate() {
3297                let placement = CellRef::new(
3298                    candidate.sheet_id,
3299                    Coord::from_excel(
3300                        candidate.row.saturating_add(1),
3301                        candidate.col.saturating_add(1),
3302                        true,
3303                        true,
3304                    ),
3305                );
3306                let ingested = pipeline.ingest_formula(
3307                    FormulaAstInput::RawArena(candidate.ast_id),
3308                    placement,
3309                    candidate.formula_text.clone(),
3310                );
3311                match ingested {
3312                    Ok(ingested) => {
3313                        candidate.ast_id = ingested.ast_id;
3314                        let canonical_hash = ingested.parameterized_canonical_hash;
3315                        let dep_plan = ingested.dep_plan.clone();
3316                        match CandidateAnalysis::from_ingested(candidate, &ingested) {
3317                            Ok(analysis) => {
3318                                groups
3319                                    .entry((candidate.sheet_id, canonical_hash, candidate.col))
3320                                    .or_default()
3321                                    .push(idx);
3322                                analyses_by_index[idx] = Some(analysis);
3323                                plans_by_index[idx] = Some(dep_plan);
3324                            }
3325                            Err(reason) => {
3326                                report.shadow_candidate_cells =
3327                                    report.shadow_candidate_cells.saturating_add(1);
3328                                report.shadow_fallback_cells =
3329                                    report.shadow_fallback_cells.saturating_add(1);
3330                                Self::record_shadow_fallback_reason(&mut report, reason, 1);
3331                                planned_fallback
3332                                    .entry(sheet_name.clone())
3333                                    .or_default()
3334                                    .push((
3335                                        candidate.row.saturating_add(1),
3336                                        candidate.col.saturating_add(1),
3337                                        candidate.ast_id,
3338                                        dep_plan,
3339                                    ));
3340                            }
3341                        }
3342                    }
3343                    Err(_) => {
3344                        report.shadow_candidate_cells =
3345                            report.shadow_candidate_cells.saturating_add(1);
3346                        report.shadow_fallback_cells =
3347                            report.shadow_fallback_cells.saturating_add(1);
3348                        Self::record_shadow_fallback_reason(
3349                            &mut report,
3350                            PlacementFallbackReason::UnsupportedCanonicalTemplate,
3351                            1,
3352                        );
3353                        fallback.entry(sheet_name.clone()).or_default().push(
3354                            FormulaIngestRecord::new(
3355                                candidate.row.saturating_add(1),
3356                                candidate.col.saturating_add(1),
3357                                candidate.ast_id,
3358                                candidate.formula_text.clone(),
3359                            ),
3360                        );
3361                    }
3362                }
3363            }
3364        }
3365
3366        for ((_sheet_id, _canonical_hash, _col), candidate_indices) in groups {
3367            let sheet_name = pending_candidates[candidate_indices[0]].0.clone();
3368            let mut plans_by_coord: BTreeMap<(u32, u32), Vec<DependencyPlanRow>> = BTreeMap::new();
3369            for idx in &candidate_indices {
3370                // Each candidate index belongs to exactly one group, so the
3371                // plan row can be moved out instead of deep-cloned.
3372                if let Some(plan) = plans_by_index[*idx].take() {
3373                    let candidate = &pending_candidates[*idx].1;
3374                    plans_by_coord
3375                        .entry((candidate.row, candidate.col))
3376                        .or_default()
3377                        .push(plan);
3378                }
3379            }
3380            let candidates: Vec<_> = candidate_indices
3381                .iter()
3382                .map(|idx| pending_candidates[*idx].1.clone())
3383                .collect();
3384            let components = Self::split_shadow_candidate_components(candidates);
3385            let analyzed_components =
3386                if components.len() == 1 && components[0].len() == candidate_indices.len() {
3387                    let component = components.into_iter().next().expect("one component");
3388                    let component_analyses = candidate_indices
3389                        .iter()
3390                        .map(|idx| {
3391                            analyses_by_index[*idx]
3392                                .take()
3393                                .expect("candidate analysis must be used once")
3394                        })
3395                        .collect();
3396                    vec![(component, component_analyses)]
3397                } else {
3398                    let mut indices_by_coord: BTreeMap<(u32, u32), Vec<usize>> = BTreeMap::new();
3399                    for idx in candidate_indices.iter().rev() {
3400                        let candidate = &pending_candidates[*idx].1;
3401                        indices_by_coord
3402                            .entry((candidate.row, candidate.col))
3403                            .or_default()
3404                            .push(*idx);
3405                    }
3406
3407                    components
3408                        .into_iter()
3409                        .map(|component| {
3410                            let mut component_analyses = Vec::with_capacity(component.len());
3411                            for candidate in &component {
3412                                let idx = indices_by_coord
3413                                    .get_mut(&(candidate.row, candidate.col))
3414                                    .and_then(Vec::pop)
3415                                    .expect("component candidate must have a precomputed analysis");
3416                                component_analyses.push(
3417                                    analyses_by_index[idx]
3418                                        .take()
3419                                        .expect("candidate analysis must be used once"),
3420                                );
3421                            }
3422                            (component, component_analyses)
3423                        })
3424                        .collect()
3425                };
3426
3427            for (component, component_analyses) in analyzed_components {
3428                for (component, component_analyses) in
3429                    split_candidate_affine_literal_runs(component, component_analyses)
3430                {
3431                    let placement_report = {
3432                        let authority = self.graph.formula_authority_mut();
3433                        place_candidate_family_with_analyses(
3434                            &mut authority.plane,
3435                            component.clone(),
3436                            component_analyses,
3437                        )
3438                    };
3439                    Self::accumulate_formula_plane_placement_report(&mut report, &placement_report);
3440
3441                    // Index candidates by placement once per component. The
3442                    // previous per-result linear `find` made this fallback
3443                    // mapping O(N²) for rejected families (e.g. an N-cell
3444                    // chain rejected with `InternalDependency`), dominating
3445                    // first-eval ingest cost on large rejected families.
3446                    // First insert wins, matching the old `Iterator::find`
3447                    // semantics for duplicate placements.
3448                    let mut candidate_by_placement: FxHashMap<
3449                        crate::formula_plane::runtime::PlacementCoord,
3450                        &FormulaPlacementCandidate,
3451                    > = FxHashMap::with_capacity_and_hasher(component.len(), Default::default());
3452                    for candidate in &component {
3453                        candidate_by_placement
3454                            .entry(candidate.placement())
3455                            .or_insert(candidate);
3456                    }
3457                    for result in &placement_report.results {
3458                        let FormulaPlacementResult::Legacy { placement, .. } = result else {
3459                            continue;
3460                        };
3461                        if let Some(&candidate) = candidate_by_placement.get(placement) {
3462                            let plan = plans_by_coord
3463                                .get_mut(&(candidate.row, candidate.col))
3464                                .and_then(Vec::pop);
3465                            if let Some(plan) = plan {
3466                                planned_fallback
3467                                    .entry(sheet_name.clone())
3468                                    .or_default()
3469                                    .push((
3470                                        candidate.row.saturating_add(1),
3471                                        candidate.col.saturating_add(1),
3472                                        candidate.ast_id,
3473                                        plan,
3474                                    ));
3475                            } else {
3476                                fallback.entry(sheet_name.clone()).or_default().push(
3477                                    FormulaIngestRecord::new(
3478                                        candidate.row.saturating_add(1),
3479                                        candidate.col.saturating_add(1),
3480                                        candidate.ast_id,
3481                                        candidate.formula_text.clone(),
3482                                    ),
3483                                );
3484                            }
3485                        }
3486                    }
3487                }
3488            }
3489        }
3490
3491        let _index_report = self.graph.formula_authority_mut().rebuild_indexes();
3492
3493        let fallback_batches = fallback
3494            .into_iter()
3495            .map(|(sheet_name, formulas)| FormulaIngestBatch::new(sheet_name, formulas))
3496            .collect();
3497        (report, fallback_batches, planned_fallback)
3498    }
3499
3500    fn accumulate_formula_plane_placement_report(
3501        report: &mut FormulaIngestReport,
3502        placement_report: &crate::formula_plane::placement::FormulaPlacementReport,
3503    ) {
3504        let counters = &placement_report.counters;
3505        report.shadow_candidate_cells = report
3506            .shadow_candidate_cells
3507            .saturating_add(counters.formula_cells_seen);
3508        report.shadow_accepted_span_cells = report
3509            .shadow_accepted_span_cells
3510            .saturating_add(counters.accepted_span_cells);
3511        report.shadow_fallback_cells = report
3512            .shadow_fallback_cells
3513            .saturating_add(counters.legacy_cells);
3514        report.shadow_templates_interned = report
3515            .shadow_templates_interned
3516            .saturating_add(counters.templates_interned);
3517        report.shadow_spans_created = report
3518            .shadow_spans_created
3519            .saturating_add(counters.spans_created);
3520        report.graph_formula_vertices_avoided_shadow = report
3521            .graph_formula_vertices_avoided_shadow
3522            .saturating_add(counters.formula_vertices_avoided);
3523        report.ast_roots_avoided_shadow = report
3524            .ast_roots_avoided_shadow
3525            .saturating_add(counters.ast_roots_avoided);
3526        report.edge_rows_avoided_shadow = report
3527            .edge_rows_avoided_shadow
3528            .saturating_add(counters.edge_rows_avoided);
3529        for (reason, count) in &counters.fallback_reasons {
3530            Self::record_shadow_fallback_reason(report, *reason, *count);
3531        }
3532    }
3533
3534    fn split_candidate_components_with_analyses(
3535        candidates: Vec<FormulaPlacementCandidate>,
3536        mut analyses: Vec<CandidateAnalysis>,
3537    ) -> Vec<(Vec<FormulaPlacementCandidate>, Vec<CandidateAnalysis>)> {
3538        let components = Self::split_shadow_candidate_components(candidates.clone());
3539        let mut analysis_by_coord: BTreeMap<(u32, u32), Vec<CandidateAnalysis>> = BTreeMap::new();
3540        for (candidate, analysis) in candidates.into_iter().zip(analyses.drain(..)) {
3541            analysis_by_coord
3542                .entry((candidate.row, candidate.col))
3543                .or_default()
3544                .push(analysis);
3545        }
3546        components
3547            .into_iter()
3548            .flat_map(|component| {
3549                let mut component_analyses = Vec::with_capacity(component.len());
3550                for candidate in &component {
3551                    let analysis = analysis_by_coord
3552                        .get_mut(&(candidate.row, candidate.col))
3553                        .and_then(Vec::pop)
3554                        .expect("component candidate must have a precomputed analysis");
3555                    component_analyses.push(analysis);
3556                }
3557                split_candidate_affine_literal_runs(component, component_analyses)
3558            })
3559            .collect()
3560    }
3561
3562    fn split_shadow_candidate_components(
3563        candidates: Vec<FormulaPlacementCandidate>,
3564    ) -> Vec<Vec<FormulaPlacementCandidate>> {
3565        if candidates.len() <= 1 {
3566            return vec![candidates];
3567        }
3568
3569        // Fast path: candidates already ordered as one contiguous
3570        // single-column (or single-row) run form exactly one 4-connected
3571        // component in their existing (row, col) order; skip the BFS.
3572        let is_row_run = candidates.windows(2).all(|w| {
3573            w[0].sheet_id == w[1].sheet_id && w[0].col == w[1].col && w[0].row + 1 == w[1].row
3574        });
3575        let is_col_run = candidates.windows(2).all(|w| {
3576            w[0].sheet_id == w[1].sheet_id && w[0].row == w[1].row && w[0].col + 1 == w[1].col
3577        });
3578        if is_row_run || is_col_run {
3579            return vec![candidates];
3580        }
3581
3582        let mut coord_to_indices: BTreeMap<(u32, u32), Vec<usize>> = BTreeMap::new();
3583        for (idx, candidate) in candidates.iter().enumerate() {
3584            coord_to_indices
3585                .entry((candidate.row, candidate.col))
3586                .or_default()
3587                .push(idx);
3588        }
3589
3590        let mut remaining: BTreeSet<usize> = (0..candidates.len()).collect();
3591        let mut components = Vec::new();
3592        while let Some(&start) = remaining.iter().next() {
3593            remaining.remove(&start);
3594            let mut queue = VecDeque::from([start]);
3595            let mut component_indices = Vec::new();
3596
3597            while let Some(idx) = queue.pop_front() {
3598                component_indices.push(idx);
3599                let candidate = &candidates[idx];
3600                let mut neighbor_coords = Vec::with_capacity(5);
3601                neighbor_coords.push((candidate.row, candidate.col));
3602                if let Some(row) = candidate.row.checked_sub(1) {
3603                    neighbor_coords.push((row, candidate.col));
3604                }
3605                neighbor_coords.push((candidate.row.saturating_add(1), candidate.col));
3606                if let Some(col) = candidate.col.checked_sub(1) {
3607                    neighbor_coords.push((candidate.row, col));
3608                }
3609                neighbor_coords.push((candidate.row, candidate.col.saturating_add(1)));
3610
3611                for coord in neighbor_coords {
3612                    if let Some(indices) = coord_to_indices.get(&coord) {
3613                        for &neighbor in indices {
3614                            if remaining.remove(&neighbor) {
3615                                queue.push_back(neighbor);
3616                            }
3617                        }
3618                    }
3619                }
3620            }
3621
3622            component_indices.sort_by_key(|idx| {
3623                let candidate = &candidates[*idx];
3624                (candidate.row, candidate.col, *idx)
3625            });
3626            components.push(
3627                component_indices
3628                    .into_iter()
3629                    .map(|idx| candidates[idx].clone())
3630                    .collect(),
3631            );
3632        }
3633
3634        components
3635    }
3636
3637    pub fn ingest_formula_batches(
3638        &mut self,
3639        batches: Vec<FormulaIngestBatch>,
3640    ) -> Result<FormulaIngestReport, ExcelError> {
3641        let formula_cells_seen = batches.iter().map(|batch| batch.len() as u64).sum();
3642        let (mut report, materialize_batches, planned_materialize) =
3643            match self.config.formula_plane_mode {
3644                FormulaPlaneMode::Off => (
3645                    FormulaIngestReport::with_mode(FormulaPlaneMode::Off),
3646                    batches,
3647                    BTreeMap::new(),
3648                ),
3649                FormulaPlaneMode::Shadow => (
3650                    self.analyze_formula_plane_shadow_candidates(&batches),
3651                    batches,
3652                    BTreeMap::new(),
3653                ),
3654                FormulaPlaneMode::AuthoritativeExperimental => {
3655                    self.analyze_formula_plane_authoritative_ingest(&batches)
3656                }
3657            };
3658        report.formula_cells_seen = formula_cells_seen;
3659
3660        if !materialize_batches.iter().all(FormulaIngestBatch::is_empty)
3661            || !planned_materialize.is_empty()
3662        {
3663            let mut builder = self.begin_bulk_ingest();
3664            for batch in materialize_batches {
3665                if batch.is_empty() {
3666                    continue;
3667                }
3668                let sheet_id = builder.add_sheet(&batch.sheet_name);
3669                builder.add_formula_ids(
3670                    sheet_id,
3671                    batch
3672                        .formulas
3673                        .into_iter()
3674                        .map(|record| (record.row, record.col, record.ast_id)),
3675                );
3676            }
3677            for (sheet_name, formulas) in planned_materialize {
3678                if formulas.is_empty() {
3679                    continue;
3680                }
3681                let sheet_id = builder.add_sheet(&sheet_name);
3682                builder.add_formula_plans(sheet_id, formulas);
3683            }
3684            let summary = builder.finish()?;
3685            report.graph_formula_cells_materialized = summary.formulas as u64;
3686            report.graph_vertices_created = summary.vertices as u64;
3687            report.graph_edges_created = summary.edges as u64;
3688        }
3689
3690        self.record_formula_ingest_report(report.clone());
3691        Ok(report)
3692    }
3693
3694    pub fn handle_formula_parse_error(
3695        &mut self,
3696        sheet: &str,
3697        row: u32,
3698        col: u32,
3699        formula: &str,
3700        message: String,
3701    ) -> Result<Option<ASTNode>, ExcelError> {
3702        let policy = self.config.formula_parse_policy;
3703
3704        if policy == FormulaParsePolicy::Strict {
3705            let col_a1 = col_letters_from_1based(col).unwrap_or_else(|_| "?".to_string());
3706            return Err(ExcelError::new(ExcelErrorKind::Value).with_message(format!(
3707                "Formula parse error at {sheet}!{col_a1}{row}: {message}"
3708            )));
3709        }
3710
3711        self.formula_parse_diagnostics.push(FormulaParseDiagnostic {
3712            sheet: sheet.to_string(),
3713            row,
3714            col,
3715            formula: formula.to_string(),
3716            message: message.clone(),
3717            policy,
3718        });
3719
3720        match policy {
3721            FormulaParsePolicy::Strict => unreachable!(),
3722            FormulaParsePolicy::KeepCachedValue => Ok(None),
3723            FormulaParsePolicy::AsText => Ok(Some(ASTNode::new(
3724                ASTNodeType::Literal(LiteralValue::Text(formula.to_string())),
3725                None,
3726            ))),
3727            FormulaParsePolicy::CoerceToError => {
3728                let err = ExcelError::new(ExcelErrorKind::Error)
3729                    .with_message(format!("Malformed formula: {message}"));
3730                Ok(Some(ASTNode::new(
3731                    ASTNodeType::Literal(LiteralValue::Error(err)),
3732                    None,
3733                )))
3734            }
3735        }
3736    }
3737
3738    /// Build graph for all staged formulas.
3739    pub fn build_graph_all(&mut self) -> Result<(), formualizer_parse::ExcelError> {
3740        if self.staged_formulas.is_empty() {
3741            return Ok(());
3742        }
3743        // Take staged formulas before borrowing graph via builder.
3744        let staged = std::mem::take(&mut self.staged_formulas);
3745        for sheet in staged.keys() {
3746            let _ = self.add_sheet(sheet);
3747        }
3748
3749        // Parse/recover first, then pass prepared batches through the centralized ingest seam.
3750        let mut prepared: PreparedFormulaBatches = Vec::new();
3751        for (sheet, entries) in staged {
3752            let mut formulas: Vec<FormulaIngestRecord> = Vec::new();
3753            let mut cache: rustc_hash::FxHashMap<String, Option<crate::engine::arena::AstNodeId>> =
3754                rustc_hash::FxHashMap::default();
3755            cache.reserve(4096);
3756
3757            for (row, col, txt) in entries.into_entries() {
3758                let key = if txt.starts_with('=') {
3759                    txt
3760                } else {
3761                    format!("={txt}")
3762                };
3763                let ast_id = if let Some(cached) = cache.get(&key) {
3764                    *cached
3765                } else {
3766                    let parsed = match formualizer_parse::parser::parse(&key) {
3767                        Ok(parsed) => Some(parsed),
3768                        Err(e) => {
3769                            self.handle_formula_parse_error(&sheet, row, col, &key, e.to_string())?
3770                        }
3771                    };
3772                    let ast_id = parsed.as_ref().map(|ast| self.intern_formula_ast(ast));
3773                    cache.insert(key.clone(), ast_id);
3774                    ast_id
3775                };
3776
3777                if let Some(ast_id) = ast_id {
3778                    formulas.push(FormulaIngestRecord::new(
3779                        row,
3780                        col,
3781                        ast_id,
3782                        Some(Arc::<str>::from(key.clone())),
3783                    ));
3784                }
3785            }
3786
3787            if !formulas.is_empty() {
3788                prepared.push(FormulaIngestBatch::new(sheet, formulas));
3789            }
3790        }
3791
3792        if !prepared.is_empty() {
3793            let _ = self.ingest_formula_batches(prepared)?;
3794        }
3795        Ok(())
3796    }
3797
3798    /// Build graph for specific sheets (consuming only those staged entries).
3799    pub fn build_graph_for_sheets<'a, I: IntoIterator<Item = &'a str>>(
3800        &mut self,
3801        sheets: I,
3802    ) -> Result<(), formualizer_parse::ExcelError> {
3803        let mut collected: StagedFormulaBatches = Vec::new();
3804        for s in sheets {
3805            if let Some(entries) = self.staged_formulas.remove(s) {
3806                collected.push((s.to_string(), entries.into_entries()));
3807            }
3808        }
3809
3810        if collected.is_empty() {
3811            return Ok(());
3812        }
3813
3814        for (sheet, _) in &collected {
3815            let _ = self.add_sheet(sheet);
3816        }
3817
3818        // Parse/recover first, then pass prepared batches through the centralized ingest seam.
3819        let mut prepared: PreparedFormulaBatches = Vec::new();
3820        let mut cache: rustc_hash::FxHashMap<String, Option<crate::engine::arena::AstNodeId>> =
3821            rustc_hash::FxHashMap::default();
3822        cache.reserve(4096);
3823
3824        for (sheet, entries) in collected {
3825            let mut formulas: Vec<FormulaIngestRecord> = Vec::new();
3826            for (row, col, txt) in entries {
3827                let key = if txt.starts_with('=') {
3828                    txt
3829                } else {
3830                    format!("={txt}")
3831                };
3832                let ast_id = if let Some(cached) = cache.get(&key) {
3833                    *cached
3834                } else {
3835                    let parsed = match formualizer_parse::parser::parse(&key) {
3836                        Ok(parsed) => Some(parsed),
3837                        Err(e) => {
3838                            self.handle_formula_parse_error(&sheet, row, col, &key, e.to_string())?
3839                        }
3840                    };
3841                    let ast_id = parsed.as_ref().map(|ast| self.intern_formula_ast(ast));
3842                    cache.insert(key.clone(), ast_id);
3843                    ast_id
3844                };
3845
3846                if let Some(ast_id) = ast_id {
3847                    formulas.push(FormulaIngestRecord::new(
3848                        row,
3849                        col,
3850                        ast_id,
3851                        Some(Arc::<str>::from(key.clone())),
3852                    ));
3853                }
3854            }
3855            if !formulas.is_empty() {
3856                prepared.push(FormulaIngestBatch::new(sheet, formulas));
3857            }
3858        }
3859
3860        if !prepared.is_empty() {
3861            let _ = self.ingest_formula_batches(prepared)?;
3862        }
3863        Ok(())
3864    }
3865
3866    /// Begin bulk Arrow ingest for base values (Phase A)
3867    pub fn begin_bulk_ingest_arrow(
3868        &mut self,
3869    ) -> crate::engine::arrow_ingest::ArrowBulkIngestBuilder<'_, R> {
3870        crate::engine::arrow_ingest::ArrowBulkIngestBuilder::new(self)
3871    }
3872
3873    /// Begin bulk updates to Arrow store (Phase C)
3874    pub fn begin_bulk_update_arrow(
3875        &mut self,
3876    ) -> crate::engine::arrow_ingest::ArrowBulkUpdateBuilder<'_, R> {
3877        crate::engine::arrow_ingest::ArrowBulkUpdateBuilder::new(self)
3878    }
3879
3880    fn ensure_known_sheet_id(&self, sheet: &str) -> Result<SheetId, crate::engine::EditorError> {
3881        self.graph.sheet_id(sheet).ok_or(
3882            crate::engine::graph::editor::vertex_editor::EditorError::InvalidName {
3883                name: sheet.to_string(),
3884                reason: "Unknown sheet".to_string(),
3885            },
3886        )
3887    }
3888
3889    fn normalize_row_1based(row_1based: u32) -> Result<u32, crate::engine::EditorError> {
3890        if row_1based == 0 {
3891            return Err(crate::engine::EditorError::OutOfBounds { row: 0, col: 0 });
3892        }
3893        Ok(row_1based - 1)
3894    }
3895
3896    fn normalize_row_range_1based(
3897        start_row_1based: u32,
3898        end_row_1based: u32,
3899    ) -> Result<(u32, u32), crate::engine::EditorError> {
3900        if start_row_1based == 0 || end_row_1based == 0 {
3901            return Err(crate::engine::EditorError::OutOfBounds { row: 0, col: 0 });
3902        }
3903        if start_row_1based > end_row_1based {
3904            return Err(crate::engine::EditorError::TransactionFailed {
3905                reason: "Row range start is greater than end".to_string(),
3906            });
3907        }
3908        Ok((start_row_1based - 1, end_row_1based - 1))
3909    }
3910
3911    fn invalidate_row_visibility_mask_cache(&self) {
3912        if let Ok(mut cache) = self.row_visibility_mask_cache.write() {
3913            cache.clear();
3914        }
3915    }
3916
3917    fn set_row_hidden_by_sheet_id(
3918        &mut self,
3919        sheet_id: SheetId,
3920        row0: u32,
3921        hidden: bool,
3922        source: RowVisibilitySource,
3923    ) -> bool {
3924        let changed = {
3925            let state = self.row_visibility.entry(sheet_id).or_default();
3926            state.set_row_hidden(row0, hidden, source)
3927        };
3928
3929        let remove_entry = self
3930            .row_visibility
3931            .get(&sheet_id)
3932            .map(|state| state.is_empty())
3933            .unwrap_or(false);
3934        if remove_entry {
3935            self.row_visibility.remove(&sheet_id);
3936        }
3937
3938        if changed {
3939            self.invalidate_row_visibility_mask_cache();
3940        }
3941
3942        changed
3943    }
3944
3945    fn set_rows_hidden_by_sheet_id(
3946        &mut self,
3947        sheet_id: SheetId,
3948        start_row0: u32,
3949        end_row0: u32,
3950        hidden: bool,
3951        source: RowVisibilitySource,
3952    ) -> bool {
3953        let changed = {
3954            let state = self.row_visibility.entry(sheet_id).or_default();
3955            state.set_rows_hidden(start_row0, end_row0, hidden, source)
3956        };
3957
3958        let remove_entry = self
3959            .row_visibility
3960            .get(&sheet_id)
3961            .map(|state| state.is_empty())
3962            .unwrap_or(false);
3963        if remove_entry {
3964            self.row_visibility.remove(&sheet_id);
3965        }
3966
3967        if changed {
3968            self.invalidate_row_visibility_mask_cache();
3969        }
3970
3971        changed
3972    }
3973
3974    fn shift_row_visibility_insert(&mut self, sheet_id: SheetId, before0: u32, count: u32) {
3975        if count == 0 {
3976            return;
3977        }
3978        let mut changed = false;
3979        let remove_entry = if let Some(state) = self.row_visibility.get_mut(&sheet_id) {
3980            changed = state.insert_rows(before0, count);
3981            state.is_empty()
3982        } else {
3983            false
3984        };
3985        if remove_entry {
3986            self.row_visibility.remove(&sheet_id);
3987        }
3988        if changed {
3989            self.invalidate_row_visibility_mask_cache();
3990        }
3991    }
3992
3993    fn shift_row_visibility_delete(&mut self, sheet_id: SheetId, start0: u32, count: u32) {
3994        if count == 0 {
3995            return;
3996        }
3997        let mut changed = false;
3998        let remove_entry = if let Some(state) = self.row_visibility.get_mut(&sheet_id) {
3999            changed = state.delete_rows(start0, count);
4000            state.is_empty()
4001        } else {
4002            false
4003        };
4004        if remove_entry {
4005            self.row_visibility.remove(&sheet_id);
4006        }
4007        if changed {
4008            self.invalidate_row_visibility_mask_cache();
4009        }
4010    }
4011
4012    fn apply_inverse_row_visibility_event(&mut self, event: &crate::engine::ChangeEvent) {
4013        if let crate::engine::ChangeEvent::SetRowVisibility {
4014            sheet_id,
4015            row0,
4016            source,
4017            old_hidden,
4018            ..
4019        } = event
4020        {
4021            let _ = self.set_row_hidden_by_sheet_id(*sheet_id, *row0, *old_hidden, *source);
4022        }
4023    }
4024
4025    fn apply_forward_row_visibility_event(&mut self, event: &crate::engine::ChangeEvent) {
4026        if let crate::engine::ChangeEvent::SetRowVisibility {
4027            sheet_id,
4028            row0,
4029            source,
4030            new_hidden,
4031            ..
4032        } = event
4033        {
4034            let _ = self.set_row_hidden_by_sheet_id(*sheet_id, *row0, *new_hidden, *source);
4035        }
4036    }
4037
4038    fn apply_inverse_row_visibility_events(&mut self, events: &[crate::engine::ChangeEvent]) {
4039        for event in events.iter().rev() {
4040            self.apply_inverse_row_visibility_event(event);
4041        }
4042    }
4043
4044    fn apply_forward_row_visibility_events(&mut self, events: &[crate::engine::ChangeEvent]) {
4045        for event in events {
4046            self.apply_forward_row_visibility_event(event);
4047        }
4048    }
4049
4050    fn apply_inverse_staged_formula_event(&mut self, event: &crate::engine::ChangeEvent) {
4051        if let crate::engine::ChangeEvent::StagedFormulaCellChanged {
4052            sheet,
4053            row,
4054            col,
4055            old,
4056            ..
4057        } = event
4058        {
4059            self.apply_staged_formula_cell(sheet, *row, *col, old.as_deref());
4060        }
4061    }
4062
4063    fn apply_forward_staged_formula_event(&mut self, event: &crate::engine::ChangeEvent) {
4064        if let crate::engine::ChangeEvent::StagedFormulaCellChanged {
4065            sheet,
4066            row,
4067            col,
4068            new,
4069            ..
4070        } = event
4071        {
4072            self.apply_staged_formula_cell(sheet, *row, *col, new.as_deref());
4073        }
4074    }
4075
4076    /// Set a single cell's staged formula text to `target` (clearing it when
4077    /// `None`). Used by undo/redo replay of per-cell staged-formula deltas.
4078    fn apply_staged_formula_cell(&mut self, sheet: &str, row: u32, col: u32, target: Option<&str>) {
4079        match target {
4080            Some(text) => self.stage_formula_text(sheet, row, col, text.to_string()),
4081            None => {
4082                self.clear_staged_formula_text(sheet, row, col);
4083            }
4084        }
4085    }
4086
4087    pub fn set_row_hidden(
4088        &mut self,
4089        sheet: &str,
4090        row_1based: u32,
4091        hidden: bool,
4092        source: RowVisibilitySource,
4093    ) -> Result<(), crate::engine::EditorError> {
4094        let sheet_id = self.ensure_known_sheet_id(sheet)?;
4095        let row0 = Self::normalize_row_1based(row_1based)?;
4096        if self.set_row_hidden_by_sheet_id(sheet_id, row0, hidden, source) {
4097            self.record_formula_plane_structural_change(StructuralScope::Region(
4098                Region::whole_row(sheet_id, row0),
4099            ));
4100            self.mark_data_edited();
4101        }
4102        Ok(())
4103    }
4104
4105    pub fn set_rows_hidden(
4106        &mut self,
4107        sheet: &str,
4108        start_row_1based: u32,
4109        end_row_1based: u32,
4110        hidden: bool,
4111        source: RowVisibilitySource,
4112    ) -> Result<(), crate::engine::EditorError> {
4113        let sheet_id = self.ensure_known_sheet_id(sheet)?;
4114        let (start_row0, end_row0) =
4115            Self::normalize_row_range_1based(start_row_1based, end_row_1based)?;
4116        if self.set_rows_hidden_by_sheet_id(sheet_id, start_row0, end_row0, hidden, source) {
4117            if start_row0 == end_row0 {
4118                self.record_formula_plane_structural_change(StructuralScope::Region(
4119                    Region::whole_row(sheet_id, start_row0),
4120                ));
4121            } else {
4122                self.record_formula_plane_structural_change(StructuralScope::Sheet(sheet_id));
4123            }
4124            self.mark_data_edited();
4125        }
4126        Ok(())
4127    }
4128
4129    pub fn is_row_hidden(
4130        &self,
4131        sheet: &str,
4132        row_1based: u32,
4133        source: Option<RowVisibilitySource>,
4134    ) -> Option<bool> {
4135        let sheet_id = self.graph.sheet_id(sheet)?;
4136        let row0 = row_1based.checked_sub(1)?;
4137        Some(
4138            self.row_visibility
4139                .get(&sheet_id)
4140                .map(|state| state.is_row_hidden(row0, source))
4141                .unwrap_or(false),
4142        )
4143    }
4144
4145    pub fn row_visibility_version(&self, sheet: &str) -> Option<u64> {
4146        let sheet_id = self.graph.sheet_id(sheet)?;
4147        Some(
4148            self.row_visibility
4149                .get(&sheet_id)
4150                .map(|state| state.version())
4151                .unwrap_or(0),
4152        )
4153    }
4154
4155    fn build_row_visibility_mask_for_view(
4156        &self,
4157        view: &RangeView<'_>,
4158        mode: VisibilityMaskMode,
4159    ) -> Option<std::sync::Arc<arrow_array::BooleanArray>> {
4160        let sheet_rows = view.sheet().nrows as usize;
4161        if sheet_rows == 0 || view.start_row() >= sheet_rows {
4162            return Some(std::sync::Arc::new(arrow_array::BooleanArray::new_null(0)));
4163        }
4164
4165        let sheet_id = self.graph.sheet_id(view.sheet_name())?;
4166        let start_row0 = view.start_row() as u32;
4167        let end_row0 = view.end_row().min(sheet_rows.saturating_sub(1)) as u32;
4168        let version = self
4169            .row_visibility
4170            .get(&sheet_id)
4171            .map(|state| state.version())
4172            .unwrap_or(0);
4173        let key = VisibilityMaskCacheKey {
4174            sheet_id,
4175            start_row0,
4176            end_row0,
4177            mode,
4178            version,
4179        };
4180
4181        if let Ok(cache) = self.row_visibility_mask_cache.read()
4182            && let Some(mask) = cache.get(&key)
4183        {
4184            #[cfg(test)]
4185            visibility_mask_test_hooks::inc_hit();
4186            return Some(mask.clone());
4187        }
4188
4189        #[cfg(test)]
4190        visibility_mask_test_hooks::inc_miss();
4191
4192        let state = self.row_visibility.get(&sheet_id);
4193        let mut out = Vec::with_capacity((end_row0 - start_row0 + 1) as usize);
4194        for row0 in start_row0..=end_row0 {
4195            let manual_hidden = state
4196                .map(|s| s.is_row_hidden(row0, Some(RowVisibilitySource::Manual)))
4197                .unwrap_or(false);
4198            let filter_hidden = state
4199                .map(|s| s.is_row_hidden(row0, Some(RowVisibilitySource::Filter)))
4200                .unwrap_or(false);
4201
4202            let include = match mode {
4203                VisibilityMaskMode::IncludeAll => true,
4204                VisibilityMaskMode::ExcludeManualHidden => !manual_hidden,
4205                VisibilityMaskMode::ExcludeFilterHidden => !filter_hidden,
4206                VisibilityMaskMode::ExcludeManualOrFilterHidden => {
4207                    !(manual_hidden || filter_hidden)
4208                }
4209            };
4210            out.push(include);
4211        }
4212
4213        let mask = std::sync::Arc::new(arrow_array::BooleanArray::from(out));
4214        if let Ok(mut cache) = self.row_visibility_mask_cache.write() {
4215            const MAX_CACHE_ENTRIES: usize = 4096;
4216            if cache.len() >= MAX_CACHE_ENTRIES {
4217                cache.clear();
4218                #[cfg(test)]
4219                visibility_mask_test_hooks::inc_eviction();
4220            }
4221            cache.insert(key, mask.clone());
4222        }
4223
4224        Some(mask)
4225    }
4226
4227    fn editor_error_to_excel(error: crate::engine::EditorError) -> ExcelError {
4228        match error {
4229            crate::engine::EditorError::Excel(error) => error,
4230            other => ExcelError::new(ExcelErrorKind::Value).with_message(other.to_string()),
4231        }
4232    }
4233
4234    fn demote_span_containing_cell_for_write(
4235        &mut self,
4236        sheet_id: SheetId,
4237        row0: u32,
4238        col0: u32,
4239    ) -> Result<(), crate::engine::EditorError> {
4240        if self.config.formula_plane_mode == FormulaPlaneMode::Off {
4241            return Ok(());
4242        }
4243        let placement = PlacementCoord::new(sheet_id, row0, col0);
4244        let inside_active_span = self
4245            .graph
4246            .formula_authority()
4247            .plane
4248            .spans
4249            .find_at(placement)
4250            .is_some();
4251        if inside_active_span {
4252            self.demote_spans_preserving_computed_overlays(
4253                sheet_id,
4254                Region::point(sheet_id, row0, col0),
4255            )?;
4256        }
4257        Ok(())
4258    }
4259
4260    fn demote_spans_preserving_computed_overlays(
4261        &mut self,
4262        _sheet_id: SheetId,
4263        affected_region: Region,
4264    ) -> Result<(), crate::engine::EditorError> {
4265        // Per-cell write inside a span (or whole-sheet demote via remove_sheet):
4266        // not a structural axis shift. Demote every span whose result or read
4267        // region intersects `affected_region`; leave disjoint spans untouched.
4268        self.demote_spans_for_structural_op_impl(None, affected_region, false)
4269    }
4270
4271    fn structural_row_region(sheet_id: SheetId, start_row0: u32) -> Region {
4272        Region::rows_from(sheet_id, start_row0)
4273    }
4274
4275    fn structural_col_region(sheet_id: SheetId, start_col0: u32) -> Region {
4276        Region::cols_from(sheet_id, start_col0)
4277    }
4278
4279    fn span_result_region_intersects_affected(
4280        span: &crate::formula_plane::runtime::FormulaSpan,
4281        affected_region: &Region,
4282    ) -> bool {
4283        Region::from_domain(span.result_region.domain()).intersects(affected_region)
4284    }
4285
4286    fn span_any_read_region_intersects_affected(
4287        plane: &FormulaPlane,
4288        span: &crate::formula_plane::runtime::FormulaSpan,
4289        affected_region: &Region,
4290    ) -> bool {
4291        span.read_summary_id
4292            .and_then(|read_summary_id| plane.span_read_summaries.get(read_summary_id))
4293            .is_some_and(|summary| {
4294                summary
4295                    .dependencies
4296                    .iter()
4297                    .any(|dependency| dependency.read_region.intersects(affected_region))
4298            })
4299    }
4300
4301    fn insert_formula_plane_dirty_coords_for_span(
4302        &self,
4303        span_ref: FormulaSpanRef,
4304        dirty: ProducerDirtyDomain,
4305        out: &mut FxHashSet<(SheetId, u32, u32)>,
4306    ) -> Result<(), crate::engine::EditorError> {
4307        let authority = self.graph.formula_authority();
4308        let span = authority.plane.spans.get(span_ref).ok_or_else(|| {
4309            ExcelError::new(ExcelErrorKind::NImpl)
4310                .with_message("FormulaPlane dirty transfer referenced a stale span")
4311        })?;
4312        match dirty {
4313            ProducerDirtyDomain::Whole => {
4314                out.extend(
4315                    span.domain
4316                        .iter()
4317                        .map(|coord| (coord.sheet_id, coord.row, coord.col)),
4318                );
4319            }
4320            ProducerDirtyDomain::Cells(cells) => {
4321                out.extend(cells.into_iter().filter_map(|key| {
4322                    let coord = PlacementCoord::new(key.sheet_id, key.row, key.col);
4323                    span.domain
4324                        .contains(coord)
4325                        .then_some((coord.sheet_id, coord.row, coord.col))
4326                }));
4327            }
4328            ProducerDirtyDomain::Regions(regions) => {
4329                out.extend(span.domain.iter().filter_map(|coord| {
4330                    let key = crate::formula_plane::region_index::RegionKey::from(coord);
4331                    regions
4332                        .iter()
4333                        .any(|region| region.contains_key(key))
4334                        .then_some((coord.sheet_id, coord.row, coord.col))
4335                }));
4336            }
4337        }
4338        Ok(())
4339    }
4340
4341    fn compute_current_formula_plane_dirty_result_coords(
4342        &self,
4343    ) -> Result<FxHashSet<(SheetId, u32, u32)>, crate::engine::EditorError> {
4344        use crate::formula_plane::producer::compute_dirty_closure;
4345
4346        let authority = self.graph.formula_authority();
4347        let span_refs = authority.active_span_refs();
4348        let span_refs_by_id = span_refs
4349            .iter()
4350            .copied()
4351            .map(|span_ref| (span_ref.id, span_ref))
4352            .collect::<BTreeMap<_, _>>();
4353        let mut dirty_coords = FxHashSet::default();
4354
4355        if self.formula_plane_indexes_epoch_seen != authority.indexes_epoch() {
4356            for span_ref in span_refs {
4357                self.insert_formula_plane_dirty_coords_for_span(
4358                    span_ref,
4359                    ProducerDirtyDomain::Whole,
4360                    &mut dirty_coords,
4361                )?;
4362            }
4363            return Ok(dirty_coords);
4364        }
4365
4366        let pending_changed_regions = authority.pending_changed_regions();
4367        if pending_changed_regions.is_empty() {
4368            return Ok(dirty_coords);
4369        }
4370
4371        let closure = compute_dirty_closure(
4372            &authority.consumer_reads,
4373            pending_changed_regions.iter().copied(),
4374            |producer| authority.producer_results.producer_result_region(producer),
4375        );
4376        for work in closure.work {
4377            let FormulaProducerId::Span(span_id) = work.producer else {
4378                continue;
4379            };
4380            let Some(span_ref) = span_refs_by_id.get(&span_id).copied() else {
4381                continue;
4382            };
4383            self.insert_formula_plane_dirty_coords_for_span(
4384                span_ref,
4385                work.dirty,
4386                &mut dirty_coords,
4387            )?;
4388        }
4389        for fallback in closure.fallbacks {
4390            let FormulaProducerId::Span(span_id) = fallback.consumer else {
4391                continue;
4392            };
4393            let Some(span_ref) = span_refs_by_id.get(&span_id).copied() else {
4394                continue;
4395            };
4396            self.insert_formula_plane_dirty_coords_for_span(
4397                span_ref,
4398                ProducerDirtyDomain::Whole,
4399                &mut dirty_coords,
4400            )?;
4401        }
4402
4403        Ok(dirty_coords)
4404    }
4405
4406    /// Demote active FormulaPlane spans affected by a structural edit on `sheet_id`.
4407    ///
4408    /// This is the conservative Option-A correctness path for structural edits: rather than
4409    /// attempting to transform FormulaPlane span domains/templates/indexes, materialize each span
4410    /// placement as an ordinary legacy graph formula at its current coordinate, remove the span,
4411    /// and let the existing VertexEditor structural machinery shift/delete those vertices and
4412    /// adjust their ASTs.  Spans whose formula domain is on `sheet_id` are affected directly; spans
4413    /// on other sheets are also affected when one of their retained read regions targets
4414    /// `sheet_id`, because those read-region coordinates become stale after row/column shifts.
4415    fn demote_spans_for_structural_op(
4416        &mut self,
4417        op: StructuralOp,
4418        affected_region: Region,
4419    ) -> Result<(), crate::engine::EditorError> {
4420        if op.count() == 0 {
4421            return Ok(());
4422        }
4423        self.demote_spans_for_structural_op_impl(Some(op), affected_region, true)
4424    }
4425
4426    fn demote_spans_for_structural_op_impl(
4427        &mut self,
4428        op: Option<StructuralOp>,
4429        affected_region: Region,
4430        clear_computed_overlays: bool,
4431    ) -> Result<(), crate::engine::EditorError> {
4432        struct SpanPlan {
4433            span_ref: FormulaSpanRef,
4434            sheet_id: SheetId,
4435            ast: ASTNode,
4436            origin_row: u32,
4437            origin_col: u32,
4438            binding_set_id: Option<crate::formula_plane::runtime::SpanBindingSetId>,
4439            placements: Vec<(u32, u32)>,
4440        }
4441
4442        fn substitute_literal_slots_for_template_placement(
4443            ast: &ASTNode,
4444            binding: &[LiteralValue],
4445        ) -> ASTNode {
4446            fn clone_with_slots(
4447                ast: &ASTNode,
4448                binding: &[LiteralValue],
4449                next: &mut usize,
4450                in_array: bool,
4451            ) -> ASTNode {
4452                let node_type = match &ast.node_type {
4453                    ASTNodeType::Literal(_) if !in_array => {
4454                        let value = binding.get(*next).cloned().unwrap_or(LiteralValue::Empty);
4455                        *next = next.saturating_add(1);
4456                        ASTNodeType::Literal(value)
4457                    }
4458                    ASTNodeType::Literal(value) => ASTNodeType::Literal(value.clone()),
4459                    ASTNodeType::Reference {
4460                        original,
4461                        reference,
4462                    } => ASTNodeType::Reference {
4463                        original: original.clone(),
4464                        reference: reference.clone(),
4465                    },
4466                    ASTNodeType::UnaryOp { op, expr } => ASTNodeType::UnaryOp {
4467                        op: op.clone(),
4468                        expr: Box::new(clone_with_slots(expr, binding, next, in_array)),
4469                    },
4470                    ASTNodeType::BinaryOp { op, left, right } => ASTNodeType::BinaryOp {
4471                        op: op.clone(),
4472                        left: Box::new(clone_with_slots(left, binding, next, in_array)),
4473                        right: Box::new(clone_with_slots(right, binding, next, in_array)),
4474                    },
4475                    ASTNodeType::Function { name, args } => ASTNodeType::Function {
4476                        name: name.clone(),
4477                        args: args
4478                            .iter()
4479                            .map(|arg| clone_with_slots(arg, binding, next, in_array))
4480                            .collect(),
4481                    },
4482                    ASTNodeType::Call { callee, args } => ASTNodeType::Call {
4483                        callee: Box::new(clone_with_slots(callee, binding, next, in_array)),
4484                        args: args
4485                            .iter()
4486                            .map(|arg| clone_with_slots(arg, binding, next, in_array))
4487                            .collect(),
4488                    },
4489                    ASTNodeType::Array(rows) => ASTNodeType::Array(
4490                        rows.iter()
4491                            .map(|row| {
4492                                row.iter()
4493                                    .map(|cell| clone_with_slots(cell, binding, next, true))
4494                                    .collect()
4495                            })
4496                            .collect(),
4497                    ),
4498                };
4499                ASTNode::new(node_type, ast.source_token.clone())
4500            }
4501            let mut next = 0usize;
4502            clone_with_slots(ast, binding, &mut next, false)
4503        }
4504
4505        let span_refs = self.graph.formula_authority().active_span_refs();
4506        if span_refs.is_empty() {
4507            return Ok(());
4508        }
4509        let dirty_span_coords = if clear_computed_overlays {
4510            FxHashSet::default()
4511        } else {
4512            self.compute_current_formula_plane_dirty_result_coords()?
4513        };
4514
4515        struct ShiftPlan {
4516            span_ref: FormulaSpanRef,
4517            template_id: crate::formula_plane::ids::FormulaTemplateId,
4518            new_origin_row: u32,
4519            new_origin_col: u32,
4520            new_domain: crate::formula_plane::runtime::PlacementDomain,
4521            new_read_summary: Option<SpanReadSummary>,
4522            binding_set_id: Option<crate::formula_plane::runtime::SpanBindingSetId>,
4523            force_binding_residual_axes: bool,
4524        }
4525
4526        fn checked_shift_u32(value: u32, delta: i64) -> Option<u32> {
4527            u32::try_from(i64::from(value).checked_add(delta)?).ok()
4528        }
4529
4530        fn shifted_read_summary(
4531            read_summary: &SpanReadSummary,
4532            new_result_region: Region,
4533            op: StructuralOp,
4534            row_delta: i64,
4535            col_delta: i64,
4536        ) -> Option<SpanReadSummary> {
4537            let mut dependencies = Vec::with_capacity(read_summary.dependencies.len());
4538            for dependency in &read_summary.dependencies {
4539                let read_region = match op.classify_region(dependency.read_region) {
4540                    crate::formula_plane::structural_shift::AxisShiftCase::OtherSheet
4541                    | crate::formula_plane::structural_shift::AxisShiftCase::EntirelyBelow => {
4542                        dependency.read_region
4543                    }
4544                    crate::formula_plane::structural_shift::AxisShiftCase::EntirelyAboveShift {
4545                        ..
4546                    } => dependency
4547                        .read_region
4548                        .project_through_axis_shift(row_delta, col_delta)?,
4549                    crate::formula_plane::structural_shift::AxisShiftCase::Straddles
4550                    | crate::formula_plane::structural_shift::AxisShiftCase::DeleteFullyContains => {
4551                        return None;
4552                    }
4553                };
4554                dependencies.push(crate::formula_plane::producer::SpanReadDependency {
4555                    read_region,
4556                    projection: dependency.projection,
4557                });
4558            }
4559            Some(SpanReadSummary {
4560                result_region: new_result_region,
4561                dependencies,
4562            })
4563        }
4564
4565        fn compact_axis_through_delete(
4566            min: u32,
4567            max: u32,
4568            start: u32,
4569            count: u32,
4570        ) -> Option<(u32, u32)> {
4571            let end = start.saturating_add(count);
4572            if max < start || min >= end {
4573                return Some((min.saturating_sub(count), max.saturating_sub(count)));
4574            }
4575            let keeps_left = min < start;
4576            let keeps_right = max >= end;
4577            match (keeps_left, keeps_right) {
4578                (false, false) => None,
4579                (true, false) => Some((min, start.checked_sub(1)?)),
4580                (false, true) => Some((start, max.checked_sub(count)?)),
4581                (true, true) => Some((min, max.checked_sub(count)?)),
4582            }
4583        }
4584
4585        fn compact_domain_through_delete(
4586            domain: &PlacementDomain,
4587            op: StructuralOp,
4588        ) -> Option<PlacementDomain> {
4589            match (domain, op) {
4590                (
4591                    PlacementDomain::RowRun {
4592                        sheet_id,
4593                        row_start,
4594                        row_end,
4595                        col,
4596                    },
4597                    StructuralOp::DeleteRows { start, count, .. },
4598                ) => {
4599                    let (row_start, row_end) =
4600                        compact_axis_through_delete(*row_start, *row_end, start, count)?;
4601                    Some(PlacementDomain::row_run(
4602                        *sheet_id, row_start, row_end, *col,
4603                    ))
4604                }
4605                (
4606                    PlacementDomain::Rect {
4607                        sheet_id,
4608                        row_start,
4609                        row_end,
4610                        col_start,
4611                        col_end,
4612                    },
4613                    StructuralOp::DeleteRows { start, count, .. },
4614                ) => {
4615                    let (row_start, row_end) =
4616                        compact_axis_through_delete(*row_start, *row_end, start, count)?;
4617                    Some(PlacementDomain::rect(
4618                        *sheet_id, row_start, row_end, *col_start, *col_end,
4619                    ))
4620                }
4621                (
4622                    PlacementDomain::ColRun {
4623                        sheet_id,
4624                        row,
4625                        col_start,
4626                        col_end,
4627                    },
4628                    StructuralOp::DeleteColumns { start, count, .. },
4629                ) => {
4630                    let (col_start, col_end) =
4631                        compact_axis_through_delete(*col_start, *col_end, start, count)?;
4632                    Some(PlacementDomain::col_run(
4633                        *sheet_id, *row, col_start, col_end,
4634                    ))
4635                }
4636                (
4637                    PlacementDomain::Rect {
4638                        sheet_id,
4639                        row_start,
4640                        row_end,
4641                        col_start,
4642                        col_end,
4643                    },
4644                    StructuralOp::DeleteColumns { start, count, .. },
4645                ) => {
4646                    let (col_start, col_end) =
4647                        compact_axis_through_delete(*col_start, *col_end, start, count)?;
4648                    Some(PlacementDomain::rect(
4649                        *sheet_id, *row_start, *row_end, col_start, col_end,
4650                    ))
4651                }
4652                _ => None,
4653            }
4654        }
4655
4656        fn compact_axis_range_through_delete(
4657            axis: crate::formula_plane::region_index::AxisRange,
4658            start: u32,
4659            count: u32,
4660        ) -> Option<crate::formula_plane::region_index::AxisRange> {
4661            use crate::formula_plane::region_index::AxisRange;
4662            match axis {
4663                AxisRange::Point(point) => compact_axis_through_delete(point, point, start, count)
4664                    .map(|(point, _)| AxisRange::Point(point)),
4665                AxisRange::Span(min, max) => compact_axis_through_delete(min, max, start, count)
4666                    .map(|(min, max)| AxisRange::Span(min, max)),
4667                AxisRange::All => Some(AxisRange::All),
4668                AxisRange::From(_) | AxisRange::To(_) => None,
4669            }
4670        }
4671
4672        fn compact_region_through_delete(region: Region, op: StructuralOp) -> Option<Region> {
4673            let (rows, cols) = region.axis_ranges();
4674            match op {
4675                StructuralOp::DeleteRows {
4676                    sheet_id,
4677                    start,
4678                    count,
4679                } if region.sheet_id() == sheet_id => Some(Region {
4680                    sheet_id,
4681                    rows: compact_axis_range_through_delete(rows, start, count)?,
4682                    cols,
4683                }),
4684                StructuralOp::DeleteColumns {
4685                    sheet_id,
4686                    start,
4687                    count,
4688                } if region.sheet_id() == sheet_id => Some(Region {
4689                    sheet_id,
4690                    rows,
4691                    cols: compact_axis_range_through_delete(cols, start, count)?,
4692                }),
4693                _ => Some(region),
4694            }
4695        }
4696
4697        fn compact_read_summary_through_delete(
4698            read_summary: &SpanReadSummary,
4699            new_result_region: Region,
4700            op: StructuralOp,
4701        ) -> Option<SpanReadSummary> {
4702            let mut dependencies = Vec::with_capacity(read_summary.dependencies.len());
4703            for dependency in &read_summary.dependencies {
4704                let read_region = match op.classify_region(dependency.read_region) {
4705                    crate::formula_plane::structural_shift::AxisShiftCase::OtherSheet
4706                    | crate::formula_plane::structural_shift::AxisShiftCase::EntirelyBelow => {
4707                        dependency.read_region
4708                    }
4709                    crate::formula_plane::structural_shift::AxisShiftCase::EntirelyAboveShift {
4710                        ..
4711                    } => {
4712                        let (row_delta, col_delta) = op.axis_shift_delta();
4713                        dependency
4714                            .read_region
4715                            .project_through_axis_shift(row_delta, col_delta)?
4716                    }
4717                    crate::formula_plane::structural_shift::AxisShiftCase::Straddles => {
4718                        compact_region_through_delete(dependency.read_region, op)?
4719                    }
4720                    crate::formula_plane::structural_shift::AxisShiftCase::DeleteFullyContains => {
4721                        return None;
4722                    }
4723                };
4724                dependencies.push(crate::formula_plane::producer::SpanReadDependency {
4725                    read_region,
4726                    projection: dependency.projection,
4727                });
4728            }
4729            Some(SpanReadSummary {
4730                result_region: new_result_region,
4731                dependencies,
4732            })
4733        }
4734
4735        fn domain_origin_1_based(domain: &PlacementDomain) -> (u32, u32) {
4736            match domain {
4737                PlacementDomain::RowRun { row_start, col, .. } => (row_start + 1, col + 1),
4738                PlacementDomain::ColRun { row, col_start, .. } => (row + 1, col_start + 1),
4739                PlacementDomain::Rect {
4740                    row_start,
4741                    col_start,
4742                    ..
4743                } => (row_start + 1, col_start + 1),
4744            }
4745        }
4746
4747        let mut shift_plans = Vec::new();
4748        let mut remove_refs = Vec::new();
4749        let mut demote_refs = Vec::new();
4750        for span_ref in span_refs {
4751            let authority = self.graph.formula_authority();
4752            let Some(span) = authority.plane.spans.get(span_ref) else {
4753                continue;
4754            };
4755            let read_summary = span
4756                .read_summary_id
4757                .and_then(|id| authority.plane.span_read_summaries.get(id));
4758            let Some(op) = op else {
4759                // Non-structural demote path (per-cell write into span, or
4760                // remove_sheet's whole-sheet sweep). Only demote spans whose
4761                // result or read region intersects affected_region; leave
4762                // disjoint spans untouched.
4763                let result_region_affected =
4764                    Self::span_result_region_intersects_affected(span, &affected_region);
4765                let read_region_affected = Self::span_any_read_region_intersects_affected(
4766                    &authority.plane,
4767                    span,
4768                    &affected_region,
4769                );
4770                if result_region_affected || read_region_affected {
4771                    demote_refs.push(span_ref);
4772                }
4773                continue;
4774            };
4775            match classify_span_for_op(span, read_summary, op) {
4776                SpanShiftPlan::NoOp => {}
4777                SpanShiftPlan::Remove => {
4778                    remove_refs.push(span_ref);
4779                }
4780                SpanShiftPlan::Demote {
4781                    reason:
4782                        crate::formula_plane::structural_shift::SpanDemoteReason::DeletePartiallyOverlaps,
4783                } => {
4784                    let binding_compaction_safe = span
4785                        .binding_set_id
4786                        .and_then(|id| authority.plane.binding_sets.get(id))
4787                        .is_none_or(|binding_set| binding_set.is_single_literal_binding());
4788                    if binding_compaction_safe
4789                        && let Some(new_domain) = compact_domain_through_delete(&span.domain, op)
4790                    {
4791                        let new_result_region = Region::from_domain(&new_domain);
4792                        let new_read_summary = if let Some(summary) = read_summary {
4793                            compact_read_summary_through_delete(summary, new_result_region, op)
4794                        } else {
4795                            None
4796                        };
4797                        if read_summary.is_none() || new_read_summary.is_some() {
4798                            let (new_origin_row, new_origin_col) = domain_origin_1_based(&new_domain);
4799                            let Some(template) = authority.plane.templates.get(span.template_id)
4800                            else {
4801                                return Err(ExcelError::new(ExcelErrorKind::Ref)
4802                                    .with_message(
4803                                        "FormulaPlane delete compaction found a span with a missing template",
4804                                    )
4805                                    .into());
4806                            };
4807                            let force_binding_residual_axes = span
4808                                .binding_set_id
4809                                .and_then(|id| authority.plane.binding_sets.get(id))
4810                                .is_some_and(|binding_set| {
4811                                    !binding_set.value_ref_slots.is_empty()
4812                                        && (new_origin_row != template.origin_row
4813                                            || new_origin_col != template.origin_col)
4814                                });
4815                            shift_plans.push(ShiftPlan {
4816                                span_ref,
4817                                template_id: span.template_id,
4818                                new_origin_row,
4819                                new_origin_col,
4820                                new_domain,
4821                                new_read_summary,
4822                                binding_set_id: span.binding_set_id,
4823                                force_binding_residual_axes,
4824                            });
4825                        } else {
4826                            demote_refs.push(span_ref);
4827                        }
4828                    } else {
4829                        demote_refs.push(span_ref);
4830                    }
4831                }
4832                SpanShiftPlan::Demote { .. } => {
4833                    demote_refs.push(span_ref);
4834                }
4835                SpanShiftPlan::Shift {
4836                    row_delta,
4837                    col_delta,
4838                    origin_row_delta,
4839                    origin_col_delta,
4840                } => {
4841                    let Some(template) = authority.plane.templates.get(span.template_id) else {
4842                        return Err(ExcelError::new(ExcelErrorKind::Ref)
4843                            .with_message("FormulaPlane shift found a span with a missing template")
4844                            .into());
4845                    };
4846                    let Some(new_origin_row) =
4847                        checked_shift_u32(template.origin_row, origin_row_delta)
4848                    else {
4849                        return Err(ExcelError::new(ExcelErrorKind::Ref)
4850                            .with_message("FormulaPlane shift overflowed template origin row")
4851                            .into());
4852                    };
4853                    let Some(new_origin_col) =
4854                        checked_shift_u32(template.origin_col, origin_col_delta)
4855                    else {
4856                        return Err(ExcelError::new(ExcelErrorKind::Ref)
4857                            .with_message("FormulaPlane shift overflowed template origin column")
4858                            .into());
4859                    };
4860                    let Some(new_domain) =
4861                        span.domain.project_through_axis_shift(row_delta, col_delta)
4862                    else {
4863                        return Err(ExcelError::new(ExcelErrorKind::Ref)
4864                            .with_message("FormulaPlane shift overflowed span domain")
4865                            .into());
4866                    };
4867                    let new_result_region = Region::from_domain(&new_domain);
4868                    let new_read_summary = if let Some(summary) = read_summary {
4869                        Some(
4870                            shifted_read_summary(
4871                                summary,
4872                                new_result_region,
4873                                op,
4874                                row_delta,
4875                                col_delta,
4876                            )
4877                            .ok_or_else(|| {
4878                                ExcelError::new(ExcelErrorKind::Ref).with_message(
4879                                    "FormulaPlane shift could not project read summary",
4880                                )
4881                            })?,
4882                        )
4883                    } else {
4884                        None
4885                    };
4886                    let force_binding_residual_axes = span
4887                        .binding_set_id
4888                        .and_then(|id| authority.plane.binding_sets.get(id))
4889                        .is_some_and(|binding_set| {
4890                            !binding_set.value_ref_slots.is_empty()
4891                                && (origin_row_delta != 0 || origin_col_delta != 0)
4892                        });
4893                    shift_plans.push(ShiftPlan {
4894                        span_ref,
4895                        template_id: span.template_id,
4896                        new_origin_row,
4897                        new_origin_col,
4898                        new_domain,
4899                        new_read_summary,
4900                        binding_set_id: span.binding_set_id,
4901                        force_binding_residual_axes,
4902                    });
4903                }
4904            }
4905        }
4906        if !shift_plans.is_empty() || !remove_refs.is_empty() {
4907            let authority = self.graph.formula_authority_mut();
4908            for span_ref in remove_refs {
4909                authority.plane.remove_overlays_for_source_span(span_ref);
4910                authority.plane.remove_span(span_ref);
4911            }
4912            for plan in shift_plans {
4913                let Some(template_id) = authority.plane.intern_shifted_template_origin(
4914                    plan.template_id,
4915                    plan.new_origin_row,
4916                    plan.new_origin_col,
4917                ) else {
4918                    return Err(ExcelError::new(ExcelErrorKind::Ref)
4919                        .with_message("FormulaPlane shift could not clone template origin")
4920                        .into());
4921                };
4922                if let Some(binding_set_id) = plan.binding_set_id {
4923                    let Some(template) = authority.plane.templates.get(template_id) else {
4924                        return Err(ExcelError::new(ExcelErrorKind::Ref)
4925                            .with_message("FormulaPlane shift could not find shifted template")
4926                            .into());
4927                    };
4928                    let (ast_id, origin_row, origin_col) =
4929                        (template.ast_id, template.origin_row, template.origin_col);
4930                    authority.plane.set_binding_template_anchor(
4931                        binding_set_id,
4932                        ast_id,
4933                        origin_row,
4934                        origin_col,
4935                    );
4936                }
4937                let read_summary_id = plan
4938                    .new_read_summary
4939                    .map(|summary| authority.plane.insert_span_read_summary(summary));
4940                let result_region = ResultRegion::scalar_cells(plan.new_domain.clone());
4941                if !authority.plane.replace_span_geometry(
4942                    plan.span_ref,
4943                    template_id,
4944                    plan.new_domain,
4945                    result_region,
4946                    read_summary_id,
4947                ) {
4948                    return Err(ExcelError::new(ExcelErrorKind::Ref)
4949                        .with_message("FormulaPlane shift could not update span geometry")
4950                        .into());
4951                }
4952                if plan.force_binding_residual_axes
4953                    && let Some(binding_set_id) = plan.binding_set_id
4954                {
4955                    // Value-ref memoization keys are placement-relative. When a
4956                    // structural op moves the formula origin while keeping some
4957                    // precedents fixed (e.g. insert a column before a formula
4958                    // family that reads column A), those keys no longer name
4959                    // the same producer cells. Keep correctness by forcing
4960                    // placement offsets into the key so memoization falls back
4961                    // to per-placement work rather than broadcasting stale
4962                    // representative values.
4963                    authority.plane.force_binding_residual_axes(binding_set_id);
4964                }
4965            }
4966            authority.rebuild_indexes();
4967            self.formula_plane_indexes_epoch_seen = 0;
4968        }
4969
4970        let mut span_plans = Vec::new();
4971        for span_ref in demote_refs {
4972            let authority = self.graph.formula_authority();
4973            let Some(span) = authority.plane.spans.get(span_ref) else {
4974                continue;
4975            };
4976            let Some(template) = authority.plane.templates.get(span.template_id) else {
4977                return Err(ExcelError::new(ExcelErrorKind::Ref)
4978                    .with_message("FormulaPlane demotion found a span with a missing template")
4979                    .into());
4980            };
4981            let ast = self
4982                .graph
4983                .data_store()
4984                .retrieve_ast(template.ast_id, self.graph.sheet_reg())
4985                .ok_or_else(|| {
4986                    ExcelError::new(ExcelErrorKind::Ref)
4987                        .with_message("FormulaPlane demotion could not retrieve the template AST")
4988                })?;
4989            let placements = span
4990                .domain
4991                .iter()
4992                .map(|placement| (placement.row + 1, placement.col + 1))
4993                .collect();
4994            span_plans.push(SpanPlan {
4995                span_ref,
4996                sheet_id: span.sheet_id,
4997                ast,
4998                origin_row: template.origin_row,
4999                origin_col: template.origin_col,
5000                binding_set_id: span.binding_set_id,
5001                placements,
5002            });
5003        }
5004        if span_plans.is_empty() {
5005            return Ok(());
5006        }
5007
5008        let mut relocated = Vec::new();
5009        let mut placement_cells = Vec::new();
5010        for plan in &span_plans {
5011            for &(row, col) in &plan.placements {
5012                let row_delta = i64::from(row) - i64::from(plan.origin_row);
5013                let col_delta = i64::from(col) - i64::from(plan.origin_col);
5014                let bound_ast = if let Some(binding_set_id) = plan.binding_set_id {
5015                    let authority = self.graph.formula_authority();
5016                    if let Some(binding_set) = authority.plane.binding_sets.get(binding_set_id) {
5017                        if binding_set.is_single_literal_binding() {
5018                            plan.ast.clone()
5019                        } else {
5020                            let placement = crate::formula_plane::runtime::PlacementCoord::new(
5021                                plan.sheet_id,
5022                                row.saturating_sub(1),
5023                                col.saturating_sub(1),
5024                            );
5025                            let binding =
5026                                authority.plane.spans.get(plan.span_ref).and_then(|span| {
5027                                    binding_set
5028                                        .literal_bindings_for_placement(&span.domain, placement)
5029                                });
5030                            if let Some(binding) = binding {
5031                                substitute_literal_slots_for_template_placement(
5032                                    &plan.ast,
5033                                    binding.as_ref(),
5034                                )
5035                            } else {
5036                                plan.ast.clone()
5037                            }
5038                        }
5039                    } else {
5040                        plan.ast.clone()
5041                    }
5042                } else {
5043                    plan.ast.clone()
5044                };
5045                let ast = relocate_ast_for_template_placement(&bound_ast, row_delta, col_delta)?;
5046                relocated.push((plan.sheet_id, row, col, ast));
5047                placement_cells.push((plan.sheet_id, row, col));
5048            }
5049        }
5050        let planned_by_sheet = {
5051            let mut pipeline = self.ingest_pipeline();
5052            let mut planned_by_sheet: BTreeMap<
5053                SheetId,
5054                Vec<(u32, u32, AstNodeId, DependencyPlanRow)>,
5055            > = BTreeMap::new();
5056            for (formula_sheet_id, row, col, ast) in relocated {
5057                let placement =
5058                    CellRef::new(formula_sheet_id, Coord::from_excel(row, col, true, true));
5059                let ingested =
5060                    pipeline.ingest_formula(FormulaAstInput::Tree(ast), placement, None)?;
5061                planned_by_sheet.entry(formula_sheet_id).or_default().push((
5062                    row,
5063                    col,
5064                    ingested.ast_id,
5065                    ingested.dep_plan,
5066                ));
5067            }
5068            planned_by_sheet
5069        };
5070        {
5071            let authority = self.graph.formula_authority_mut();
5072            for plan in &span_plans {
5073                authority
5074                    .plane
5075                    .remove_overlays_for_source_span(plan.span_ref);
5076                authority.plane.remove_span(plan.span_ref);
5077            }
5078            authority.rebuild_indexes();
5079        }
5080        if clear_computed_overlays {
5081            // Only clear placement cells whose coordinate intersects the affected
5082            // structural region. The structural-op contract preserves cells
5083            // BEFORE the structural boundary; the legacy `clear_computed_overlay_after_*`
5084            // call honors that. Demoting a span whose footprint straddles the
5085            // boundary still must not clear cells before the boundary, even
5086            // though the span as a whole is demoted.
5087            self.clear_computed_overlay_cells_in_region(&placement_cells, &affected_region);
5088        }
5089        for (formula_sheet_id, planned) in planned_by_sheet {
5090            let sheet_name = self.graph.sheet_name(formula_sheet_id).to_string();
5091            self.graph
5092                .bulk_set_formulas_with_plans(&sheet_name, planned)?;
5093        }
5094        if !clear_computed_overlays {
5095            for (formula_sheet_id, row, col) in &placement_cells {
5096                let row0 = row.saturating_sub(1);
5097                let col0 = col.saturating_sub(1);
5098                if dirty_span_coords.contains(&(*formula_sheet_id, row0, col0)) {
5099                    continue;
5100                }
5101                let cell =
5102                    CellRef::new(*formula_sheet_id, Coord::from_excel(*row, *col, true, true));
5103                if let Some(&vertex_id) = self.graph.get_vertex_id_for_address(&cell) {
5104                    self.graph.set_dirty(vertex_id, false);
5105                }
5106            }
5107        }
5108        self.formula_plane_indexes_epoch_seen = 0;
5109        Ok(())
5110    }
5111
5112    /// Collect the [`FormulaSpanRef`]s for span producers the mixed scheduler
5113    /// reported as cycle members (gotcha G8, refs #112). These spans must be
5114    /// demoted to legacy so the cycle members are resolved on the legacy SCC
5115    /// path; see [`Self::demote_cyclic_spans`].
5116    fn collect_cyclic_span_refs(
5117        &self,
5118        schedule: &MixedSchedule,
5119        span_refs_by_id: &BTreeMap<FormulaSpanId, FormulaSpanRef>,
5120    ) -> Vec<FormulaSpanRef> {
5121        let mut refs = Vec::new();
5122        for fallback in &schedule.fallbacks {
5123            if fallback.reason != MixedScheduleFallbackReason::CycleDetected {
5124                continue;
5125            }
5126            if let FormulaProducerId::Span(span_id) = fallback.producer
5127                && let Some(span_ref) = span_refs_by_id.get(&span_id)
5128                && !refs.contains(span_ref)
5129            {
5130                refs.push(*span_ref);
5131            }
5132        }
5133        refs
5134    }
5135
5136    /// Demote the given cyclic spans to legacy graph vertices so their member
5137    /// cells participate in the legacy Tarjan SCC pass (gotcha G8, refs #112).
5138    ///
5139    /// Reuses the non-structural demotion seam, which materializes each span's
5140    /// cells back onto the legacy graph and re-promotes any acyclic remainder
5141    /// that still forms a promotable run. We pass a `Region` that covers exactly
5142    /// the demote-target span domains so disjoint spans are left untouched.
5143    fn demote_cyclic_spans(&mut self, span_refs: &[FormulaSpanRef]) -> Result<(), ExcelError> {
5144        let mut regions: Vec<Region> = Vec::new();
5145        {
5146            let authority = self.graph.formula_authority();
5147            for span_ref in span_refs {
5148                if let Some(span) = authority.plane.spans.get(*span_ref) {
5149                    regions.push(Region::from_domain(&span.domain));
5150                }
5151            }
5152        }
5153        for region in regions {
5154            self.demote_spans_preserving_computed_overlays(region.sheet_id(), region)
5155                .map_err(|err| {
5156                    ExcelError::new(ExcelErrorKind::NImpl).with_message(format!(
5157                        "FormulaPlane cycle-member span demotion failed: {err:?}"
5158                    ))
5159                })?;
5160        }
5161        self.formula_plane_cycle_member_span_demotions = self
5162            .formula_plane_cycle_member_span_demotions
5163            .saturating_add(span_refs.len() as u64);
5164        // Mirror the demotion into the cumulative ingest report's fallback
5165        // histogram so cycle exclusions are visible like every other placement
5166        // fallback reason.
5167        Self::record_shadow_fallback_reason(
5168            &mut self.formula_ingest_report_total,
5169            PlacementFallbackReason::CycleMember,
5170            span_refs.len() as u64,
5171        );
5172        Ok(())
5173    }
5174
5175    /// Evaluate residual *legacy-only* cyclic SCCs before the FormulaPlane
5176    /// mixed schedule runs (gotcha G8, refs #112).
5177    ///
5178    /// After cyclic spans are demoted to legacy ([`Self::demote_cyclic_spans`]),
5179    /// every cycle member is a graph vertex, so the cycle is now visible to the
5180    /// legacy Tarjan pass and lives entirely among legacy producers. The mixed
5181    /// schedule treats any cycle as not authoritative-safe; rather than abandon
5182    /// the surviving spans by falling through to a pure-legacy `evaluate_all`,
5183    /// stamp/evaluate just the cyclic SCC units here (`handle_cycle_unit` honors
5184    /// `CycleDetection::Static` vs `Runtime`), clear their dirty flags, and let
5185    /// the mixed schedule proceed cycle-free over the surviving spans plus the
5186    /// acyclic legacy work.
5187    ///
5188    /// Returns the number of cyclic SCC units that stamped at least one cell.
5189    fn evaluate_legacy_cycle_prepass(&mut self) -> Result<usize, ExcelError> {
5190        let dirty = self.graph.get_evaluation_vertices();
5191        if dirty.is_empty() {
5192            return Ok(0);
5193        }
5194        let (schedule, _vdeps, _meta) = self.create_evaluation_schedule(&dirty)?;
5195        let dirty_set: FxHashSet<VertexId> = dirty.iter().copied().collect();
5196        let mut cycle_errors = 0usize;
5197        let mut stamped_vertices: Vec<VertexId> = Vec::new();
5198        for &unit in &schedule.units {
5199            let ScheduleUnit::Cycle(i) = unit else {
5200                continue;
5201            };
5202            let members = schedule.unit_cycle(i);
5203            let stamped = self.handle_cycle_unit(members, None, Some(&dirty_set), None)?;
5204            if stamped > 0 {
5205                cycle_errors += 1;
5206            }
5207            stamped_vertices.extend(members.iter().copied());
5208        }
5209        // Clear dirty only on the cyclic members so the subsequent mixed
5210        // schedule no longer sees them as dirty legacy producers (which is what
5211        // surfaced the cycle). Acyclic legacy work stays dirty and is scheduled
5212        // normally alongside the surviving spans.
5213        if !stamped_vertices.is_empty() {
5214            self.graph.clear_dirty_flags(&stamped_vertices);
5215        }
5216        Ok(cycle_errors)
5217    }
5218
5219    /// Insert rows (1-based) and mirror into Arrow store when enabled
5220    pub fn insert_rows(
5221        &mut self,
5222        sheet: &str,
5223        before: u32,
5224        count: u32,
5225    ) -> Result<crate::engine::graph::editor::vertex_editor::ShiftSummary, crate::engine::EditorError>
5226    {
5227        use crate::engine::graph::editor::vertex_editor::VertexEditor;
5228        let sheet_id = self.ensure_known_sheet_id(sheet)?;
5229        let before0 = before.saturating_sub(1);
5230        let affected_region = Self::structural_row_region(sheet_id, before0);
5231        let op = StructuralOp::InsertRows {
5232            sheet_id,
5233            before: before0,
5234            count,
5235        };
5236        self.demote_spans_for_structural_op(op, affected_region)?;
5237        let summary = {
5238            let mut editor = VertexEditor::new(&mut self.graph);
5239            editor.insert_rows(sheet_id, before0, count)?
5240        };
5241        if let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) {
5242            let before0 = before0 as usize;
5243            asheet.insert_rows(before0, count as usize);
5244        }
5245        self.mark_moved_formula_vertices_dirty(&summary);
5246        self.clear_computed_overlay_after_row(sheet, before0 as usize);
5247        self.shift_row_visibility_insert(sheet_id, before0, count);
5248        self.record_formula_plane_structural_change(StructuralScope::Region(affected_region));
5249        self.mark_topology_edited();
5250        Ok(summary)
5251    }
5252
5253    /// Delete rows (1-based) and mirror into Arrow store when enabled
5254    pub fn delete_rows(
5255        &mut self,
5256        sheet: &str,
5257        start: u32,
5258        count: u32,
5259    ) -> Result<crate::engine::graph::editor::vertex_editor::ShiftSummary, crate::engine::EditorError>
5260    {
5261        use crate::engine::graph::editor::vertex_editor::VertexEditor;
5262        let sheet_id = self.ensure_known_sheet_id(sheet)?;
5263        let start0 = start.saturating_sub(1);
5264        let affected_region = Self::structural_row_region(sheet_id, start0);
5265        let op = StructuralOp::DeleteRows {
5266            sheet_id,
5267            start: start0,
5268            count,
5269        };
5270        self.demote_spans_for_structural_op(op, affected_region)?;
5271        let summary = {
5272            let mut editor = VertexEditor::new(&mut self.graph);
5273            editor.delete_rows(sheet_id, start0, count)?
5274        };
5275        if let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) {
5276            let start0 = start0 as usize;
5277            asheet.delete_rows(start0, count as usize);
5278        }
5279        self.mark_moved_formula_vertices_dirty(&summary);
5280        self.clear_computed_overlay_after_row(sheet, start0 as usize);
5281        self.shift_row_visibility_delete(sheet_id, start0, count);
5282        self.record_formula_plane_structural_change(StructuralScope::Region(affected_region));
5283        self.mark_topology_edited();
5284        Ok(summary)
5285    }
5286
5287    /// Insert columns (1-based) and mirror into Arrow store when enabled
5288    pub fn insert_columns(
5289        &mut self,
5290        sheet: &str,
5291        before: u32,
5292        count: u32,
5293    ) -> Result<crate::engine::graph::editor::vertex_editor::ShiftSummary, crate::engine::EditorError>
5294    {
5295        use crate::engine::graph::editor::vertex_editor::VertexEditor;
5296        let sheet_id = self.graph.sheet_id(sheet).ok_or(
5297            crate::engine::graph::editor::vertex_editor::EditorError::InvalidName {
5298                name: sheet.to_string(),
5299                reason: "Unknown sheet".to_string(),
5300            },
5301        )?;
5302        let before0 = before.saturating_sub(1);
5303        let affected_region = Self::structural_col_region(sheet_id, before0);
5304        let op = StructuralOp::InsertColumns {
5305            sheet_id,
5306            before: before0,
5307            count,
5308        };
5309        self.demote_spans_for_structural_op(op, affected_region)?;
5310        let summary = {
5311            let mut editor = VertexEditor::new(&mut self.graph);
5312            editor.insert_columns(sheet_id, before0, count)?
5313        };
5314        if let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) {
5315            let before0 = before0 as usize;
5316            asheet.insert_columns(before0, count as usize);
5317        }
5318        self.mark_moved_formula_vertices_dirty(&summary);
5319        self.clear_computed_overlay_after_col(sheet, before0 as usize);
5320        self.record_formula_plane_structural_change(StructuralScope::Region(affected_region));
5321        self.mark_topology_edited();
5322        Ok(summary)
5323    }
5324
5325    /// Delete columns (1-based) and mirror into Arrow store when enabled
5326    pub fn delete_columns(
5327        &mut self,
5328        sheet: &str,
5329        start: u32,
5330        count: u32,
5331    ) -> Result<crate::engine::graph::editor::vertex_editor::ShiftSummary, crate::engine::EditorError>
5332    {
5333        use crate::engine::graph::editor::vertex_editor::VertexEditor;
5334        let sheet_id = self.graph.sheet_id(sheet).ok_or(
5335            crate::engine::graph::editor::vertex_editor::EditorError::InvalidName {
5336                name: sheet.to_string(),
5337                reason: "Unknown sheet".to_string(),
5338            },
5339        )?;
5340        let start0 = start.saturating_sub(1);
5341        let affected_region = Self::structural_col_region(sheet_id, start0);
5342        let op = StructuralOp::DeleteColumns {
5343            sheet_id,
5344            start: start0,
5345            count,
5346        };
5347        self.demote_spans_for_structural_op(op, affected_region)?;
5348        let summary = {
5349            let mut editor = VertexEditor::new(&mut self.graph);
5350            editor.delete_columns(sheet_id, start0, count)?
5351        };
5352        if let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) {
5353            let start0 = start0 as usize;
5354            asheet.delete_columns(start0, count as usize);
5355        }
5356        self.mark_moved_formula_vertices_dirty(&summary);
5357        self.clear_computed_overlay_after_col(sheet, start0 as usize);
5358        self.record_formula_plane_structural_change(StructuralScope::Region(affected_region));
5359        self.mark_topology_edited();
5360        Ok(summary)
5361    }
5362    /// Arrow-backed used row bounds across a column span (1-based inclusive cols).
5363    fn arrow_used_row_bounds(
5364        &self,
5365        sheet: &str,
5366        start_col: u32,
5367        end_col: u32,
5368    ) -> Option<(u32, u32)> {
5369        let a = self.sheet_store().sheet(sheet)?;
5370        if a.columns.is_empty() {
5371            return None;
5372        }
5373        let sc0 = start_col.saturating_sub(1) as usize;
5374        let ec0 = end_col.saturating_sub(1) as usize;
5375        let col_hi = a.columns.len().saturating_sub(1);
5376        if sc0 > col_hi {
5377            return None;
5378        }
5379        let ec0 = ec0.min(col_hi);
5380        // Pass-scoped cache with snapshot guard
5381        let snap = self.data_snapshot_id();
5382        let mut min_r0: Option<usize> = None;
5383        for ci in sc0..=ec0 {
5384            let sheet_id = self.graph.sheet_id(sheet)?;
5385            if let Some((Some(mv), _)) = self.row_bounds_cache.read().ok().and_then(|g| {
5386                g.as_ref()
5387                    .and_then(|c| c.get_row_bounds(sheet_id, ci, snap))
5388            }) {
5389                let mv = mv as usize;
5390                min_r0 = Some(min_r0.map(|m| m.min(mv)).unwrap_or(mv));
5391                continue;
5392            }
5393            // Compute and store
5394            let (min_c, max_c) = Self::scan_column_used_bounds(a, ci);
5395            if let Ok(mut g) = self.row_bounds_cache.write() {
5396                g.get_or_insert_with(|| RowBoundsCache::new(snap))
5397                    .put_row_bounds(sheet_id, ci, snap, (min_c, max_c));
5398            }
5399            if let Some(m) = min_c {
5400                min_r0 = Some(min_r0.map(|mm| mm.min(m as usize)).unwrap_or(m as usize));
5401            }
5402        }
5403        min_r0?;
5404        let mut max_r0: Option<usize> = None;
5405        for ci in sc0..=ec0 {
5406            let sheet_id = self.graph.sheet_id(sheet)?;
5407            if let Some((_, Some(mv))) = self.row_bounds_cache.read().ok().and_then(|g| {
5408                g.as_ref()
5409                    .and_then(|c| c.get_row_bounds(sheet_id, ci, snap))
5410            }) {
5411                let mv = mv as usize;
5412                max_r0 = Some(max_r0.map(|m| m.max(mv)).unwrap_or(mv));
5413                continue;
5414            }
5415            let (_min_c, max_c) = Self::scan_column_used_bounds(a, ci);
5416            if let Ok(mut g) = self.row_bounds_cache.write() {
5417                g.get_or_insert_with(|| RowBoundsCache::new(snap))
5418                    .put_row_bounds(sheet_id, ci, snap, (_min_c, max_c));
5419            }
5420            if let Some(m) = max_c {
5421                max_r0 = Some(max_r0.map(|mm| mm.max(m as usize)).unwrap_or(m as usize));
5422            }
5423        }
5424        match (min_r0, max_r0) {
5425            (Some(a0), Some(b0)) => Some(((a0 as u32) + 1, (b0 as u32) + 1)),
5426            _ => None,
5427        }
5428    }
5429
5430    fn scan_column_used_bounds(
5431        a: &crate::arrow_store::ArrowSheet,
5432        ci: usize,
5433    ) -> (Option<u32>, Option<u32>) {
5434        let col = &a.columns[ci];
5435
5436        // Min: scan dense chunks first, then sparse chunks in ascending index order.
5437        let mut min_r0: Option<u32> = None;
5438        for (chunk_idx, chunk) in col.chunks.iter().enumerate() {
5439            let tags = chunk.type_tag.values();
5440            for (off, &t) in tags.iter().enumerate() {
5441                let overlay_non_empty = chunk
5442                    .overlay
5443                    .get(off)
5444                    .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5445                    .unwrap_or(false)
5446                    || chunk
5447                        .computed_overlay
5448                        .get(off)
5449                        .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5450                        .unwrap_or(false);
5451                if overlay_non_empty || t != crate::arrow_store::TypeTag::Empty as u8 {
5452                    let Some(&chunk_start) = a.chunk_starts.get(chunk_idx) else {
5453                        break;
5454                    };
5455                    let row0 = chunk_start + off;
5456                    min_r0 = Some(row0 as u32);
5457                    break;
5458                }
5459            }
5460            if min_r0.is_some() {
5461                break;
5462            }
5463        }
5464        if min_r0.is_none() && !col.sparse_chunks.is_empty() {
5465            let mut sparse_idxs: Vec<usize> = col.sparse_chunks.keys().copied().collect();
5466            sparse_idxs.sort_unstable();
5467            for chunk_idx in sparse_idxs {
5468                let Some(chunk) = col.sparse_chunks.get(&chunk_idx) else {
5469                    continue;
5470                };
5471                let Some(&chunk_start) = a.chunk_starts.get(chunk_idx) else {
5472                    continue;
5473                };
5474                let tags = chunk.type_tag.values();
5475                for (off, &t) in tags.iter().enumerate() {
5476                    let overlay_non_empty = chunk
5477                        .overlay
5478                        .get(off)
5479                        .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5480                        .unwrap_or(false)
5481                        || chunk
5482                            .computed_overlay
5483                            .get(off)
5484                            .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5485                            .unwrap_or(false);
5486                    if overlay_non_empty || t != crate::arrow_store::TypeTag::Empty as u8 {
5487                        let row0 = chunk_start + off;
5488                        min_r0 = Some(row0 as u32);
5489                        break;
5490                    }
5491                }
5492                if min_r0.is_some() {
5493                    break;
5494                }
5495            }
5496        }
5497
5498        // Max: scan sparse chunks in descending index order, then dense chunks in reverse.
5499        let mut max_r0: Option<u32> = None;
5500        if !col.sparse_chunks.is_empty() {
5501            let mut sparse_idxs: Vec<usize> = col.sparse_chunks.keys().copied().collect();
5502            sparse_idxs.sort_unstable_by(|a, b| b.cmp(a));
5503            for chunk_idx in sparse_idxs {
5504                let Some(chunk) = col.sparse_chunks.get(&chunk_idx) else {
5505                    continue;
5506                };
5507                let Some(&chunk_start) = a.chunk_starts.get(chunk_idx) else {
5508                    continue;
5509                };
5510                let tags = chunk.type_tag.values();
5511                for (rev_idx, &t) in tags.iter().enumerate().rev() {
5512                    let overlay_non_empty = chunk
5513                        .overlay
5514                        .get(rev_idx)
5515                        .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5516                        .unwrap_or(false)
5517                        || chunk
5518                            .computed_overlay
5519                            .get(rev_idx)
5520                            .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5521                            .unwrap_or(false);
5522                    if overlay_non_empty || t != crate::arrow_store::TypeTag::Empty as u8 {
5523                        let row0 = chunk_start + rev_idx;
5524                        max_r0 = Some(row0 as u32);
5525                        break;
5526                    }
5527                }
5528                if max_r0.is_some() {
5529                    break;
5530                }
5531            }
5532        }
5533        if max_r0.is_none() {
5534            for (chunk_idx, chunk) in col.chunks.iter().enumerate().rev() {
5535                let tags = chunk.type_tag.values();
5536                for (rev_idx, &t) in tags.iter().enumerate().rev() {
5537                    let overlay_non_empty = chunk
5538                        .overlay
5539                        .get(rev_idx)
5540                        .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5541                        .unwrap_or(false)
5542                        || chunk
5543                            .computed_overlay
5544                            .get(rev_idx)
5545                            .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5546                            .unwrap_or(false);
5547                    if overlay_non_empty || t != crate::arrow_store::TypeTag::Empty as u8 {
5548                        let Some(&chunk_start) = a.chunk_starts.get(chunk_idx) else {
5549                            break;
5550                        };
5551                        let row0 = chunk_start + rev_idx;
5552                        max_r0 = Some(row0 as u32);
5553                        break;
5554                    }
5555                }
5556                if max_r0.is_some() {
5557                    break;
5558                }
5559            }
5560        }
5561
5562        (min_r0, max_r0)
5563    }
5564
5565    /// Arrow-backed used column bounds across a row span (1-based inclusive rows).
5566    fn arrow_used_col_bounds(
5567        &self,
5568        sheet: &str,
5569        start_row: u32,
5570        end_row: u32,
5571    ) -> Option<(u32, u32)> {
5572        let a = self.sheet_store().sheet(sheet)?;
5573        if a.columns.is_empty() {
5574            return None;
5575        }
5576        let sr0 = start_row.saturating_sub(1) as usize;
5577        let er0 = end_row.saturating_sub(1) as usize;
5578        if sr0 > er0 {
5579            return None;
5580        }
5581        // Map start/end rows into chunk ranges
5582        // We will scan each column for any non-empty within [sr0..=er0]
5583        let mut min_c0: Option<usize> = None;
5584        let mut max_c0: Option<usize> = None;
5585        // Precompute chunk bounds for row range
5586        for (ci, col) in a.columns.iter().enumerate() {
5587            let mut any_in_range = false;
5588
5589            let scan_chunk = |chunk_idx: usize, chunk: &crate::arrow_store::ColumnChunk| -> bool {
5590                let Some(&chunk_start) = a.chunk_starts.get(chunk_idx) else {
5591                    return false;
5592                };
5593                let chunk_len = chunk.type_tag.len();
5594                if chunk_len == 0 {
5595                    return false;
5596                }
5597                let chunk_end = chunk_start + chunk_len.saturating_sub(1);
5598                // check intersection
5599                if sr0 > chunk_end || er0 < chunk_start {
5600                    return false;
5601                }
5602                let start_off = sr0.max(chunk_start) - chunk_start;
5603                let end_off = er0.min(chunk_end) - chunk_start;
5604                let tags = chunk.type_tag.values();
5605                for off in start_off..=end_off {
5606                    let overlay_non_empty = chunk
5607                        .overlay
5608                        .get(off)
5609                        .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5610                        .unwrap_or(false)
5611                        || chunk
5612                            .computed_overlay
5613                            .get(off)
5614                            .map(|ov| !matches!(ov, crate::arrow_store::OverlayValue::Empty))
5615                            .unwrap_or(false);
5616                    if overlay_non_empty || tags[off] != crate::arrow_store::TypeTag::Empty as u8 {
5617                        return true;
5618                    }
5619                }
5620                false
5621            };
5622
5623            for (chunk_idx, chunk) in col.chunks.iter().enumerate() {
5624                if scan_chunk(chunk_idx, chunk) {
5625                    any_in_range = true;
5626                    break;
5627                }
5628            }
5629
5630            if !any_in_range && !col.sparse_chunks.is_empty() {
5631                for (&chunk_idx, chunk) in col.sparse_chunks.iter() {
5632                    if scan_chunk(chunk_idx, chunk) {
5633                        any_in_range = true;
5634                        break;
5635                    }
5636                }
5637            }
5638
5639            if any_in_range {
5640                min_c0 = Some(min_c0.map(|m| m.min(ci)).unwrap_or(ci));
5641                max_c0 = Some(max_c0.map(|m| m.max(ci)).unwrap_or(ci));
5642            }
5643        }
5644        match (min_c0, max_c0) {
5645            (Some(a0), Some(b0)) => Some(((a0 as u32) + 1, (b0 as u32) + 1)),
5646            _ => None,
5647        }
5648    }
5649
5650    fn formula_row_bounds_for_columns(
5651        &self,
5652        sheet: &str,
5653        start_col: u32,
5654        end_col: u32,
5655    ) -> Option<(u32, u32)> {
5656        let sheet_id = self.graph.sheet_id(sheet)?;
5657        let sc0 = start_col.saturating_sub(1);
5658        let ec0 = end_col.saturating_sub(1);
5659        let mut min_r0: Option<u32> = None;
5660        let mut max_r0: Option<u32> = None;
5661
5662        if let Some(index) = self.graph.sheet_index(sheet_id) {
5663            for vid in index.vertices_in_col_range(sc0, ec0) {
5664                if !matches!(
5665                    self.graph.get_vertex_kind(vid),
5666                    VertexKind::FormulaScalar | VertexKind::FormulaArray
5667                ) {
5668                    continue;
5669                }
5670                let row0 = self.graph.vertex_coord(vid).row();
5671                min_r0 = Some(min_r0.map(|m| m.min(row0)).unwrap_or(row0));
5672                max_r0 = Some(max_r0.map(|m| m.max(row0)).unwrap_or(row0));
5673            }
5674        } else {
5675            for vid in self.graph.vertices_in_sheet(sheet_id) {
5676                if !matches!(
5677                    self.graph.get_vertex_kind(vid),
5678                    VertexKind::FormulaScalar | VertexKind::FormulaArray
5679                ) {
5680                    continue;
5681                }
5682                let coord = self.graph.vertex_coord(vid);
5683                let col0 = coord.col();
5684                if col0 < sc0 || col0 > ec0 {
5685                    continue;
5686                }
5687                let row0 = coord.row();
5688                min_r0 = Some(min_r0.map(|m| m.min(row0)).unwrap_or(row0));
5689                max_r0 = Some(max_r0.map(|m| m.max(row0)).unwrap_or(row0));
5690            }
5691        }
5692
5693        match (min_r0, max_r0) {
5694            (Some(a0), Some(b0)) => Some((a0 + 1, b0 + 1)),
5695            _ => None,
5696        }
5697    }
5698
5699    fn formula_col_bounds_for_rows(
5700        &self,
5701        sheet: &str,
5702        start_row: u32,
5703        end_row: u32,
5704    ) -> Option<(u32, u32)> {
5705        let sheet_id = self.graph.sheet_id(sheet)?;
5706        let sr0 = start_row.saturating_sub(1);
5707        let er0 = end_row.saturating_sub(1);
5708        let mut min_c0: Option<u32> = None;
5709        let mut max_c0: Option<u32> = None;
5710
5711        if let Some(index) = self.graph.sheet_index(sheet_id) {
5712            for vid in index.vertices_in_row_range(sr0, er0) {
5713                if !matches!(
5714                    self.graph.get_vertex_kind(vid),
5715                    VertexKind::FormulaScalar | VertexKind::FormulaArray
5716                ) {
5717                    continue;
5718                }
5719                let col0 = self.graph.vertex_coord(vid).col();
5720                min_c0 = Some(min_c0.map(|m| m.min(col0)).unwrap_or(col0));
5721                max_c0 = Some(max_c0.map(|m| m.max(col0)).unwrap_or(col0));
5722            }
5723        } else {
5724            for vid in self.graph.vertices_in_sheet(sheet_id) {
5725                if !matches!(
5726                    self.graph.get_vertex_kind(vid),
5727                    VertexKind::FormulaScalar | VertexKind::FormulaArray
5728                ) {
5729                    continue;
5730                }
5731                let coord = self.graph.vertex_coord(vid);
5732                let row0 = coord.row();
5733                if row0 < sr0 || row0 > er0 {
5734                    continue;
5735                }
5736                let col0 = coord.col();
5737                min_c0 = Some(min_c0.map(|m| m.min(col0)).unwrap_or(col0));
5738                max_c0 = Some(max_c0.map(|m| m.max(col0)).unwrap_or(col0));
5739            }
5740        }
5741
5742        match (min_c0, max_c0) {
5743            (Some(a0), Some(b0)) => Some((a0 + 1, b0 + 1)),
5744            _ => None,
5745        }
5746    }
5747
5748    fn union_used_bounds(
5749        first: Option<(u32, u32)>,
5750        second: Option<(u32, u32)>,
5751    ) -> Option<(u32, u32)> {
5752        match (first, second) {
5753            (Some((a0, b0)), Some((a1, b1))) => Some((a0.min(a1), b0.max(b1))),
5754            (Some(bounds), None) | (None, Some(bounds)) => Some(bounds),
5755            (None, None) => None,
5756        }
5757    }
5758
5759    /// Mirror a single cell value into the Arrow overlay if enabled.
5760    /// Handles capacity growth, per-chunk overlay set, and heuristic compaction.
5761    fn mirror_value_to_overlay(&mut self, sheet: &str, row: u32, col: u32, value: &LiteralValue) {
5762        if !(self.config.arrow_storage_enabled && self.config.delta_overlay_enabled) {
5763            return;
5764        }
5765        if self.arrow_sheets.sheet(sheet).is_none() {
5766            self.arrow_sheets
5767                .sheets
5768                .push(crate::arrow_store::ArrowSheet {
5769                    name: std::sync::Arc::<str>::from(sheet),
5770                    columns: Vec::new(),
5771                    nrows: 0,
5772                    chunk_starts: Vec::new(),
5773                    chunk_rows: 32 * 1024,
5774                });
5775        }
5776
5777        let row0 = row.saturating_sub(1) as usize;
5778        let col0 = col.saturating_sub(1) as usize;
5779
5780        let asheet = self
5781            .arrow_sheets
5782            .sheet_mut(sheet)
5783            .expect("ArrowSheet must exist");
5784
5785        let cur_cols = asheet.columns.len();
5786        if col0 >= cur_cols {
5787            asheet.insert_columns(cur_cols, (col0 + 1) - cur_cols);
5788        }
5789
5790        if row0 >= asheet.nrows as usize {
5791            if asheet.columns.is_empty() {
5792                asheet.insert_columns(0, 1);
5793            }
5794            asheet.ensure_row_capacity(row0 + 1);
5795        }
5796        if let Some((ch_idx, in_off)) = asheet.chunk_of_row(row0) {
5797            use crate::arrow_store::OverlayValue;
5798            let ov = match value {
5799                LiteralValue::Empty => OverlayValue::Empty,
5800                LiteralValue::Int(i) => OverlayValue::Number(*i as f64),
5801                LiteralValue::Number(n) => OverlayValue::Number(*n),
5802                LiteralValue::Boolean(b) => OverlayValue::Boolean(*b),
5803                LiteralValue::Text(s) => OverlayValue::Text(std::sync::Arc::from(s.clone())),
5804                LiteralValue::Error(e) => {
5805                    OverlayValue::Error(crate::arrow_store::map_error_code(e.kind))
5806                }
5807                LiteralValue::Date(d) => {
5808                    let dt = d.and_hms_opt(0, 0, 0).unwrap();
5809                    let serial = crate::builtins::datetime::datetime_to_serial_for(
5810                        self.config.date_system,
5811                        &dt,
5812                    );
5813                    OverlayValue::DateTime(serial)
5814                }
5815                LiteralValue::DateTime(dt) => {
5816                    let serial = crate::builtins::datetime::datetime_to_serial_for(
5817                        self.config.date_system,
5818                        dt,
5819                    );
5820                    OverlayValue::DateTime(serial)
5821                }
5822                LiteralValue::Time(t) => {
5823                    let serial = t.num_seconds_from_midnight() as f64 / 86_400.0;
5824                    OverlayValue::DateTime(serial)
5825                }
5826                LiteralValue::Duration(d) => {
5827                    let serial = d.num_seconds() as f64 / 86_400.0;
5828                    OverlayValue::Duration(serial)
5829                }
5830                LiteralValue::Pending => OverlayValue::Pending,
5831                LiteralValue::Array(_) => OverlayValue::Error(crate::arrow_store::map_error_code(
5832                    formualizer_common::ExcelErrorKind::Value,
5833                )),
5834            };
5835            let computed_delta = if let Some(ch) = asheet.ensure_column_chunk_mut(col0, ch_idx) {
5836                let _ = ch.overlay.set(in_off, ov);
5837                // A user edit must invalidate any computed (formula/spill) overlay entry at
5838                // this cell. Otherwise, if the delta overlay later compacts into the base lanes
5839                // (clearing `overlay`), a stale `computed_overlay=Empty` could incorrectly mask
5840                // the edited base value under the read cascade.
5841                ch.computed_overlay.remove(in_off)
5842            } else {
5843                return;
5844            };
5845            // Heuristic compaction: > len/50 or > 1024
5846            let abs_threshold = 1024usize;
5847            let frac_den = 50usize;
5848            let freed = asheet.maybe_compact_chunk(col0, ch_idx, abs_threshold, frac_den);
5849            if freed > 0 {
5850                self.overlay_compactions = self.overlay_compactions.saturating_add(1);
5851            }
5852            self.adjust_computed_overlay_bytes(computed_delta);
5853        }
5854    }
5855
5856    /// Remove a delta-overlay entry for a single cell (if present).
5857    ///
5858    /// This is used when transitioning a cell to a formula so that any previous user-edit overlay
5859    /// does not continue to mask computed overlay outputs.
5860    fn clear_delta_overlay_cell(&mut self, sheet: &str, row: u32, col: u32) {
5861        if !(self.config.arrow_storage_enabled && self.config.delta_overlay_enabled) {
5862            return;
5863        }
5864        let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) else {
5865            return;
5866        };
5867        let row0 = row.saturating_sub(1) as usize;
5868        let col0 = col.saturating_sub(1) as usize;
5869        if row0 >= asheet.nrows as usize {
5870            return;
5871        }
5872        if col0 >= asheet.columns.len() {
5873            return;
5874        }
5875        let Some((ch_idx, in_off)) = asheet.chunk_of_row(row0) else {
5876            return;
5877        };
5878        if let Some(ch) = asheet.columns[col0].chunk_mut(ch_idx) {
5879            let _ = ch.overlay.remove(in_off);
5880        }
5881    }
5882
5883    fn clear_computed_overlay_col_row_range(
5884        &mut self,
5885        sheet: &str,
5886        col0: usize,
5887        start_row0: usize,
5888        end_row0_exclusive: usize,
5889    ) {
5890        if !(self.config.arrow_storage_enabled && self.config.write_formula_overlay_enabled) {
5891            return;
5892        }
5893        if start_row0 >= end_row0_exclusive {
5894            return;
5895        }
5896
5897        let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) else {
5898            return;
5899        };
5900        if col0 >= asheet.columns.len() || start_row0 >= asheet.nrows as usize {
5901            return;
5902        }
5903        let end_row0_exclusive = end_row0_exclusive.min(asheet.nrows as usize);
5904        if start_row0 >= end_row0_exclusive {
5905            return;
5906        }
5907
5908        let starts = asheet.chunk_starts.clone();
5909        let nrows = asheet.nrows as usize;
5910        let mut delta = 0isize;
5911        let Some(col) = asheet.columns.get_mut(col0) else {
5912            return;
5913        };
5914        for (chunk_idx, ch) in col.chunks.iter_mut().enumerate() {
5915            let Some(&chunk_start) = starts.get(chunk_idx) else {
5916                continue;
5917            };
5918            let chunk_end = starts
5919                .get(chunk_idx + 1)
5920                .copied()
5921                .unwrap_or(nrows)
5922                .min(chunk_start.saturating_add(ch.len()));
5923            let clear_start = start_row0.max(chunk_start);
5924            let clear_end = end_row0_exclusive.min(chunk_end);
5925            if clear_start >= clear_end {
5926                continue;
5927            }
5928            if clear_start == chunk_start && clear_end == chunk_end {
5929                delta = delta.saturating_sub(ch.computed_overlay.clear() as isize);
5930            } else {
5931                let start_in_chunk = clear_start.saturating_sub(chunk_start).min(ch.len());
5932                let end_in_chunk = clear_end.saturating_sub(chunk_start).min(ch.len());
5933                delta = delta.saturating_add(
5934                    ch.computed_overlay
5935                        .remove_range(start_in_chunk..end_in_chunk),
5936                );
5937            }
5938        }
5939        for (chunk_idx, ch) in &mut col.sparse_chunks {
5940            let Some(&chunk_start) = starts.get(*chunk_idx) else {
5941                continue;
5942            };
5943            let chunk_end = starts
5944                .get(*chunk_idx + 1)
5945                .copied()
5946                .unwrap_or(nrows)
5947                .min(chunk_start.saturating_add(ch.len()));
5948            let clear_start = start_row0.max(chunk_start);
5949            let clear_end = end_row0_exclusive.min(chunk_end);
5950            if clear_start >= clear_end {
5951                continue;
5952            }
5953            if clear_start == chunk_start && clear_end == chunk_end {
5954                delta = delta.saturating_sub(ch.computed_overlay.clear() as isize);
5955            } else {
5956                let start_in_chunk = clear_start.saturating_sub(chunk_start).min(ch.len());
5957                let end_in_chunk = clear_end.saturating_sub(chunk_start).min(ch.len());
5958                delta = delta.saturating_add(
5959                    ch.computed_overlay
5960                        .remove_range(start_in_chunk..end_in_chunk),
5961                );
5962            }
5963        }
5964        self.adjust_computed_overlay_bytes(delta);
5965    }
5966
5967    fn clear_computed_overlay_cells_in_region(
5968        &mut self,
5969        cells: &[(SheetId, u32, u32)],
5970        affected_region: &Region,
5971    ) {
5972        let mut by_col: BTreeMap<(SheetId, u32), Vec<u32>> = BTreeMap::new();
5973        for (formula_sheet_id, row, col) in cells {
5974            let row0 = row.saturating_sub(1);
5975            let col0 = col.saturating_sub(1);
5976            let placement_region = Region::point(*formula_sheet_id, row0, col0);
5977            if placement_region.intersects(affected_region) {
5978                by_col
5979                    .entry((*formula_sheet_id, col0))
5980                    .or_default()
5981                    .push(row0);
5982            }
5983        }
5984
5985        for ((formula_sheet_id, col0), mut rows) in by_col {
5986            rows.sort_unstable();
5987            rows.dedup();
5988            let sheet_name = self.graph.sheet_name(formula_sheet_id).to_string();
5989            let mut start = rows[0];
5990            let mut prev = rows[0];
5991            for row in rows.into_iter().skip(1) {
5992                if row == prev.saturating_add(1) {
5993                    prev = row;
5994                    continue;
5995                }
5996                self.clear_computed_overlay_col_row_range(
5997                    &sheet_name,
5998                    col0 as usize,
5999                    start as usize,
6000                    prev.saturating_add(1) as usize,
6001                );
6002                start = row;
6003                prev = row;
6004            }
6005            self.clear_computed_overlay_col_row_range(
6006                &sheet_name,
6007                col0 as usize,
6008                start as usize,
6009                prev.saturating_add(1) as usize,
6010            );
6011        }
6012    }
6013
6014    fn clear_computed_overlay_after_row(&mut self, sheet: &str, start_row0: usize) {
6015        if !(self.config.arrow_storage_enabled && self.config.write_formula_overlay_enabled) {
6016            return;
6017        }
6018
6019        let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) else {
6020            return;
6021        };
6022        if start_row0 >= asheet.nrows as usize {
6023            return;
6024        }
6025
6026        let starts = asheet.chunk_starts.clone();
6027        let nrows = asheet.nrows as usize;
6028        let mut delta = 0isize;
6029        for col in &mut asheet.columns {
6030            for (chunk_idx, ch) in col.chunks.iter_mut().enumerate() {
6031                let Some(&chunk_start) = starts.get(chunk_idx) else {
6032                    continue;
6033                };
6034                let chunk_end = starts
6035                    .get(chunk_idx + 1)
6036                    .copied()
6037                    .unwrap_or(nrows)
6038                    .min(chunk_start.saturating_add(ch.len()));
6039                if chunk_end <= start_row0 {
6040                    continue;
6041                }
6042                if chunk_start >= start_row0 {
6043                    delta = delta.saturating_sub(ch.computed_overlay.clear() as isize);
6044                } else {
6045                    let start_in_chunk = start_row0.saturating_sub(chunk_start).min(ch.len());
6046                    delta = delta
6047                        .saturating_add(ch.computed_overlay.remove_range(start_in_chunk..ch.len()));
6048                }
6049            }
6050
6051            for (chunk_idx, ch) in &mut col.sparse_chunks {
6052                let Some(&chunk_start) = starts.get(*chunk_idx) else {
6053                    continue;
6054                };
6055                let chunk_end = starts
6056                    .get(*chunk_idx + 1)
6057                    .copied()
6058                    .unwrap_or(nrows)
6059                    .min(chunk_start.saturating_add(ch.len()));
6060                if chunk_end <= start_row0 {
6061                    continue;
6062                }
6063                if chunk_start >= start_row0 {
6064                    delta = delta.saturating_sub(ch.computed_overlay.clear() as isize);
6065                } else {
6066                    let start_in_chunk = start_row0.saturating_sub(chunk_start).min(ch.len());
6067                    delta = delta
6068                        .saturating_add(ch.computed_overlay.remove_range(start_in_chunk..ch.len()));
6069                }
6070            }
6071        }
6072        self.adjust_computed_overlay_bytes(delta);
6073    }
6074
6075    fn clear_computed_overlay_after_col(&mut self, sheet: &str, start_col0: usize) {
6076        if !(self.config.arrow_storage_enabled && self.config.write_formula_overlay_enabled) {
6077            return;
6078        }
6079
6080        let Some(asheet) = self.arrow_sheets.sheet_mut(sheet) else {
6081            return;
6082        };
6083        if start_col0 >= asheet.columns.len() {
6084            return;
6085        }
6086
6087        let mut delta = 0isize;
6088        for col in asheet.columns.iter_mut().skip(start_col0) {
6089            for ch in &mut col.chunks {
6090                delta = delta.saturating_sub(ch.computed_overlay.clear() as isize);
6091            }
6092            for ch in col.sparse_chunks.values_mut() {
6093                delta = delta.saturating_sub(ch.computed_overlay.clear() as isize);
6094            }
6095        }
6096        self.adjust_computed_overlay_bytes(delta);
6097    }
6098
6099    #[inline]
6100    fn literal_to_overlay_value(&self, value: &LiteralValue) -> crate::arrow_store::OverlayValue {
6101        use crate::arrow_store::OverlayValue;
6102        match value {
6103            LiteralValue::Empty => OverlayValue::Empty,
6104            LiteralValue::Int(i) => OverlayValue::Number(*i as f64),
6105            LiteralValue::Number(n) => OverlayValue::Number(*n),
6106            LiteralValue::Boolean(b) => OverlayValue::Boolean(*b),
6107            LiteralValue::Text(s) => OverlayValue::Text(std::sync::Arc::from(s.clone())),
6108            LiteralValue::Error(e) => {
6109                OverlayValue::Error(crate::arrow_store::map_error_code(e.kind))
6110            }
6111            LiteralValue::Date(d) => {
6112                let dt = d.and_hms_opt(0, 0, 0).unwrap();
6113                let serial =
6114                    crate::builtins::datetime::datetime_to_serial_for(self.config.date_system, &dt);
6115                OverlayValue::DateTime(serial)
6116            }
6117            LiteralValue::DateTime(dt) => {
6118                let serial =
6119                    crate::builtins::datetime::datetime_to_serial_for(self.config.date_system, dt);
6120                OverlayValue::DateTime(serial)
6121            }
6122            LiteralValue::Time(t) => {
6123                let serial = t.num_seconds_from_midnight() as f64 / 86_400.0;
6124                OverlayValue::DateTime(serial)
6125            }
6126            LiteralValue::Duration(d) => {
6127                let serial = d.num_seconds() as f64 / 86_400.0;
6128                OverlayValue::Duration(serial)
6129            }
6130            LiteralValue::Pending => OverlayValue::Pending,
6131            LiteralValue::Array(_) => OverlayValue::Error(crate::arrow_store::map_error_code(
6132                formualizer_common::ExcelErrorKind::Value,
6133            )),
6134        }
6135    }
6136
6137    /// Read a single cell's delta overlay entry (if present), preserving the distinction between
6138    /// absent and explicit `Empty`.
6139    fn read_delta_overlay_cell(&self, sheet: &str, row: u32, col: u32) -> Option<LiteralValue> {
6140        if !(self.config.arrow_storage_enabled && self.config.delta_overlay_enabled) {
6141            return None;
6142        }
6143        let asheet = self.arrow_sheets.sheet(sheet)?;
6144        let row0 = row.saturating_sub(1) as usize;
6145        let col0 = col.saturating_sub(1) as usize;
6146        if row0 >= asheet.nrows as usize || col0 >= asheet.columns.len() {
6147            return None;
6148        }
6149        let (ch_idx, in_off) = asheet.chunk_of_row(row0)?;
6150        let ch = asheet.columns[col0].chunk(ch_idx)?;
6151        ch.overlay.get_scalar(in_off).map(|ov| ov.to_literal())
6152    }
6153
6154    /// Read a single cell's computed overlay entry (if present), preserving the distinction
6155    /// between absent and explicit `Empty`.
6156    fn read_computed_overlay_cell(&self, sheet: &str, row: u32, col: u32) -> Option<LiteralValue> {
6157        if !(self.config.arrow_storage_enabled
6158            && self.config.delta_overlay_enabled
6159            && self.config.write_formula_overlay_enabled)
6160        {
6161            return None;
6162        }
6163        let asheet = self.arrow_sheets.sheet(sheet)?;
6164        let row0 = row.saturating_sub(1) as usize;
6165        let col0 = col.saturating_sub(1) as usize;
6166        if row0 >= asheet.nrows as usize || col0 >= asheet.columns.len() {
6167            return None;
6168        }
6169        let (ch_idx, in_off) = asheet.chunk_of_row(row0)?;
6170        let ch = asheet.columns[col0].chunk(ch_idx)?;
6171        ch.computed_overlay
6172            .get_scalar(in_off)
6173            .map(|ov| ov.to_literal())
6174    }
6175
6176    fn set_delta_overlay_cell_raw(
6177        &mut self,
6178        sheet: &str,
6179        row: u32,
6180        col: u32,
6181        value: Option<LiteralValue>,
6182    ) {
6183        if !(self.config.arrow_storage_enabled && self.config.delta_overlay_enabled) {
6184            return;
6185        }
6186
6187        self.ensure_arrow_sheet(sheet);
6188        let ov_opt = value.as_ref().map(|v| self.literal_to_overlay_value(v));
6189        let row0 = row.saturating_sub(1) as usize;
6190        let col0 = col.saturating_sub(1) as usize;
6191        let asheet = self
6192            .arrow_sheets
6193            .sheet_mut(sheet)
6194            .expect("ArrowSheet must exist");
6195
6196        let cur_cols = asheet.columns.len();
6197        if col0 >= cur_cols {
6198            asheet.insert_columns(cur_cols, (col0 + 1) - cur_cols);
6199        }
6200        if row0 >= asheet.nrows as usize {
6201            if asheet.columns.is_empty() {
6202                asheet.insert_columns(0, 1);
6203            }
6204            asheet.ensure_row_capacity(row0 + 1);
6205        }
6206
6207        let Some((ch_idx, in_off)) = asheet.chunk_of_row(row0) else {
6208            return;
6209        };
6210        let Some(ch) = asheet.ensure_column_chunk_mut(col0, ch_idx) else {
6211            return;
6212        };
6213
6214        if let Some(ov) = ov_opt {
6215            let _ = ch.overlay.set(in_off, ov);
6216        } else {
6217            let _ = ch.overlay.remove(in_off);
6218        }
6219    }
6220
6221    fn set_computed_overlay_cell_raw(
6222        &mut self,
6223        sheet: &str,
6224        row: u32,
6225        col: u32,
6226        value: Option<LiteralValue>,
6227    ) {
6228        if !(self.config.arrow_storage_enabled
6229            && self.config.delta_overlay_enabled
6230            && self.config.write_formula_overlay_enabled)
6231        {
6232            return;
6233        }
6234
6235        self.ensure_arrow_sheet(sheet);
6236        let ov_opt = value.as_ref().map(|v| self.literal_to_overlay_value(v));
6237        let row0 = row.saturating_sub(1) as usize;
6238        let col0 = col.saturating_sub(1) as usize;
6239        let asheet = self
6240            .arrow_sheets
6241            .sheet_mut(sheet)
6242            .expect("ArrowSheet must exist");
6243
6244        let cur_cols = asheet.columns.len();
6245        if col0 >= cur_cols {
6246            asheet.insert_columns(cur_cols, (col0 + 1) - cur_cols);
6247        }
6248        if row0 >= asheet.nrows as usize {
6249            if asheet.columns.is_empty() {
6250                asheet.insert_columns(0, 1);
6251            }
6252            asheet.ensure_row_capacity(row0 + 1);
6253        }
6254
6255        let Some((ch_idx, in_off)) = asheet.chunk_of_row(row0) else {
6256            return;
6257        };
6258        let Some(ch) = asheet.ensure_column_chunk_mut(col0, ch_idx) else {
6259            return;
6260        };
6261
6262        let delta = if let Some(ov) = ov_opt {
6263            ch.computed_overlay.set(in_off, ov)
6264        } else {
6265            ch.computed_overlay.remove(in_off)
6266        };
6267        self.adjust_computed_overlay_bytes(delta);
6268    }
6269
6270    fn apply_arrow_undo_batch(&mut self, batch: &crate::engine::ArrowUndoBatch, undo: bool) {
6271        use crate::engine::ArrowOp;
6272
6273        let iter: Box<dyn Iterator<Item = &ArrowOp>> = if undo {
6274            Box::new(batch.ops.iter().rev())
6275        } else {
6276            Box::new(batch.ops.iter())
6277        };
6278
6279        for op in iter {
6280            match op {
6281                ArrowOp::SetDeltaCell {
6282                    sheet_id,
6283                    row0,
6284                    col0,
6285                    old,
6286                    new,
6287                } => {
6288                    let sheet = self.graph.sheet_name(*sheet_id).to_string();
6289                    let v = if undo { old.clone() } else { new.clone() };
6290                    self.set_delta_overlay_cell_raw(&sheet, row0 + 1, col0 + 1, v);
6291                }
6292                ArrowOp::SetComputedCell {
6293                    sheet_id,
6294                    row0,
6295                    col0,
6296                    old,
6297                    new,
6298                } => {
6299                    let sheet = self.graph.sheet_name(*sheet_id).to_string();
6300                    let v = if undo { old.clone() } else { new.clone() };
6301                    self.set_computed_overlay_cell_raw(&sheet, row0 + 1, col0 + 1, v);
6302                }
6303                ArrowOp::RestoreComputedRect {
6304                    sheet_id,
6305                    sr0,
6306                    sc0,
6307                    er0,
6308                    ec0,
6309                    old,
6310                    new,
6311                } => {
6312                    let sheet = self.graph.sheet_name(*sheet_id).to_string();
6313                    let vals = if undo { old } else { new };
6314                    let height = (*er0).saturating_sub(*sr0) as usize + 1;
6315                    let width = (*ec0).saturating_sub(*sc0) as usize + 1;
6316                    for r in 0..height {
6317                        for c in 0..width {
6318                            let v = vals
6319                                .get(r)
6320                                .and_then(|row| row.get(c))
6321                                .cloned()
6322                                .unwrap_or(LiteralValue::Empty);
6323                            self.set_computed_overlay_cell_raw(
6324                                &sheet,
6325                                *sr0 + 1 + r as u32,
6326                                *sc0 + 1 + c as u32,
6327                                Some(v),
6328                            );
6329                        }
6330                    }
6331                }
6332                ArrowOp::InsertRows {
6333                    sheet_id,
6334                    before0,
6335                    count,
6336                } => {
6337                    let sheet = self.graph.sheet_name(*sheet_id).to_string();
6338                    self.ensure_arrow_sheet(&sheet);
6339                    if let Some(asheet) = self.arrow_sheets.sheet_mut(&sheet) {
6340                        if undo {
6341                            asheet.delete_rows(*before0 as usize, *count as usize);
6342                        } else {
6343                            asheet.insert_rows(*before0 as usize, *count as usize);
6344                        }
6345                    }
6346                }
6347                ArrowOp::InsertCols {
6348                    sheet_id,
6349                    before0,
6350                    count,
6351                } => {
6352                    let sheet = self.graph.sheet_name(*sheet_id).to_string();
6353                    self.ensure_arrow_sheet(&sheet);
6354                    if let Some(asheet) = self.arrow_sheets.sheet_mut(&sheet) {
6355                        if undo {
6356                            asheet.delete_columns(*before0 as usize, *count as usize);
6357                        } else {
6358                            asheet.insert_columns(*before0 as usize, *count as usize);
6359                        }
6360                    }
6361                }
6362            }
6363        }
6364    }
6365
6366    fn record_spill_ops_into_arrow_undo(
6367        &mut self,
6368        undo: &mut crate::engine::ArrowUndoBatch,
6369        events: &[crate::engine::ChangeEvent],
6370    ) {
6371        use crate::engine::ChangeEvent;
6372        use formualizer_common::LiteralValue;
6373
6374        #[allow(clippy::type_complexity)]
6375        let rect_from_snapshot =
6376            |snap: &crate::engine::graph::editor::change_log::SpillSnapshot|
6377             -> Option<(SheetId, u32, u32, u32, u32, Vec<Vec<LiteralValue>>)> {
6378                if snap.target_cells.is_empty() {
6379                    return None;
6380                }
6381                let sheet_id = snap.target_cells[0].sheet_id;
6382                let sr0 = snap.target_cells[0].coord.row();
6383                let sc0 = snap.target_cells[0].coord.col();
6384                if snap.values.is_empty() || snap.values[0].is_empty() {
6385                    return None;
6386                }
6387                let h = snap.values.len() as u32;
6388                let w = snap.values[0].len() as u32;
6389                let er0 = sr0.saturating_add(h.saturating_sub(1));
6390                let ec0 = sc0.saturating_add(w.saturating_sub(1));
6391                Some((sheet_id, sr0, sc0, er0, ec0, snap.values.clone()))
6392            };
6393
6394        for ev in events {
6395            match ev {
6396                ChangeEvent::SpillCommitted { old, new, .. } => {
6397                    if let Some((sid, sr0, sc0, er0, ec0, new_vals)) = rect_from_snapshot(new) {
6398                        let old_vals = if let Some(old_snap) = old {
6399                            rect_from_snapshot(old_snap)
6400                                .map(|(_, _, _, _, _, v)| v)
6401                                .unwrap_or_else(|| {
6402                                    vec![
6403                                        vec![LiteralValue::Empty; new_vals[0].len()];
6404                                        new_vals.len()
6405                                    ]
6406                                })
6407                        } else {
6408                            vec![vec![LiteralValue::Empty; new_vals[0].len()]; new_vals.len()]
6409                        };
6410                        undo.record_restore_computed_rect(
6411                            sid, sr0, sc0, er0, ec0, old_vals, new_vals,
6412                        );
6413                    }
6414                }
6415                ChangeEvent::SpillCleared { old, .. } => {
6416                    if let Some((sid, sr0, sc0, er0, ec0, old_vals)) = rect_from_snapshot(old) {
6417                        let new_vals =
6418                            vec![vec![LiteralValue::Empty; old_vals[0].len()]; old_vals.len()];
6419                        undo.record_restore_computed_rect(
6420                            sid, sr0, sc0, er0, ec0, old_vals, new_vals,
6421                        );
6422                    }
6423                }
6424                _ => {}
6425            }
6426        }
6427    }
6428
6429    /// Mirror a value into the computed overlay (formula/spill outputs).
6430    ///
6431    /// This path is subject to `EvalConfig.max_overlay_memory_bytes`.
6432    /// If the cap is exceeded, computed overlays are compacted into base lanes.
6433    fn mirror_value_to_computed_overlay(
6434        &mut self,
6435        sheet: &str,
6436        row: u32,
6437        col: u32,
6438        value: &LiteralValue,
6439    ) {
6440        if !(self.config.arrow_storage_enabled
6441            && self.config.delta_overlay_enabled
6442            && self.config.write_formula_overlay_enabled)
6443        {
6444            return;
6445        }
6446        if self.computed_overlay_mirroring_disabled {
6447            return;
6448        }
6449
6450        let ov = self.literal_to_overlay_value(value);
6451        self.write_computed_overlay_value_0based(
6452            sheet,
6453            row.saturating_sub(1),
6454            col.saturating_sub(1),
6455            ov,
6456        );
6457    }
6458
6459    fn write_computed_overlay_value_0based(
6460        &mut self,
6461        sheet: &str,
6462        row0: u32,
6463        col0: u32,
6464        value: OverlayValue,
6465    ) {
6466        if !(self.config.arrow_storage_enabled
6467            && self.config.delta_overlay_enabled
6468            && self.config.write_formula_overlay_enabled)
6469        {
6470            return;
6471        }
6472        if self.computed_overlay_mirroring_disabled {
6473            return;
6474        }
6475
6476        self.ensure_arrow_sheet(sheet);
6477
6478        let row0 = row0 as usize;
6479        let col0 = col0 as usize;
6480        let asheet = self
6481            .arrow_sheets
6482            .sheet_mut(sheet)
6483            .expect("ArrowSheet must exist");
6484
6485        let cur_cols = asheet.columns.len();
6486        if col0 >= cur_cols {
6487            asheet.insert_columns(cur_cols, (col0 + 1) - cur_cols);
6488        }
6489
6490        if row0 >= asheet.nrows as usize {
6491            if asheet.columns.is_empty() {
6492                asheet.insert_columns(0, 1);
6493            }
6494            asheet.ensure_row_capacity(row0 + 1);
6495        }
6496
6497        let Some((ch_idx, in_off)) = asheet.chunk_of_row(row0) else {
6498            return;
6499        };
6500        let Some(ch) = asheet.ensure_column_chunk_mut(col0, ch_idx) else {
6501            return;
6502        };
6503
6504        let delta = ch.computed_overlay.set_scalar(in_off, value);
6505        self.adjust_computed_overlay_bytes(delta);
6506
6507        if let Some(cap) = self.config.max_overlay_memory_bytes
6508            && self.computed_overlay_bytes_estimate > cap
6509        {
6510            self.disable_computed_overlay_mirroring_due_to_budget(cap);
6511        }
6512    }
6513
6514    pub(crate) fn plan_computed_write_coalescing(
6515        &self,
6516        buffer: &ComputedWriteBuffer,
6517    ) -> ComputedWriteCoalescingPlan {
6518        self.plan_computed_write_coalescing_from_writes(buffer.writes().iter().cloned())
6519    }
6520
6521    fn plan_owned_computed_write_coalescing(
6522        &self,
6523        writes: Vec<ComputedWrite>,
6524    ) -> ComputedWriteCoalescingPlan {
6525        self.plan_computed_write_coalescing_from_writes(writes)
6526    }
6527
6528    fn plan_computed_write_coalescing_from_writes(
6529        &self,
6530        writes: impl IntoIterator<Item = ComputedWrite>,
6531    ) -> ComputedWriteCoalescingPlan {
6532        let mut groups: BTreeMap<ComputedWriteChunkKey, Vec<ComputedWriteChunkEntryPlan>> =
6533            BTreeMap::new();
6534        let mut input_cells = 0usize;
6535
6536        for write in writes {
6537            match write {
6538                ComputedWrite::Cell {
6539                    seq,
6540                    sheet_id,
6541                    row0,
6542                    col0,
6543                    value,
6544                } => {
6545                    input_cells = input_cells.saturating_add(1);
6546                    self.push_computed_write_plan_entry(
6547                        &mut groups,
6548                        seq,
6549                        sheet_id,
6550                        row0,
6551                        col0,
6552                        value,
6553                    );
6554                }
6555                ComputedWrite::Rect {
6556                    seq,
6557                    sheet_id,
6558                    sr0,
6559                    sc0,
6560                    values,
6561                } => {
6562                    for (r_off, row) in values.into_iter().enumerate() {
6563                        for (c_off, value) in row.into_iter().enumerate() {
6564                            input_cells = input_cells.saturating_add(1);
6565                            self.push_computed_write_plan_entry(
6566                                &mut groups,
6567                                seq,
6568                                sheet_id,
6569                                sr0.saturating_add(r_off as u32),
6570                                sc0.saturating_add(c_off as u32),
6571                                value,
6572                            );
6573                        }
6574                    }
6575                }
6576            }
6577        }
6578
6579        let mut plan = ComputedWriteCoalescingPlan {
6580            chunks: Vec::with_capacity(groups.len()),
6581            input_cells,
6582            coalesced_cells: 0,
6583            overwritten_cells: 0,
6584        };
6585        for (key, entries) in groups {
6586            let (chunk_plan, overwritten) = ComputedWriteChunkPlan::from_group(key, entries);
6587            plan.coalesced_cells = plan
6588                .coalesced_cells
6589                .saturating_add(chunk_plan.entries.len());
6590            plan.overwritten_cells = plan.overwritten_cells.saturating_add(overwritten);
6591            plan.chunks.push(chunk_plan);
6592        }
6593        debug_assert_eq!(
6594            plan.input_cells,
6595            plan.coalesced_cells.saturating_add(plan.overwritten_cells)
6596        );
6597        plan
6598    }
6599
6600    fn push_computed_write_plan_entry(
6601        &self,
6602        groups: &mut BTreeMap<ComputedWriteChunkKey, Vec<ComputedWriteChunkEntryPlan>>,
6603        seq: u64,
6604        sheet_id: SheetId,
6605        row0: u32,
6606        col0: u32,
6607        value: OverlayValue,
6608    ) {
6609        let (chunk_idx, chunk_start_row0, row_in_chunk) =
6610            self.locate_computed_write_chunk(sheet_id, row0);
6611        let key = ComputedWriteChunkKey {
6612            sheet_id,
6613            col0,
6614            chunk_idx,
6615            chunk_start_row0,
6616        };
6617        groups
6618            .entry(key)
6619            .or_default()
6620            .push(ComputedWriteChunkEntryPlan {
6621                row_in_chunk,
6622                seq,
6623                value,
6624            });
6625    }
6626
6627    fn locate_computed_write_chunk(&self, sheet_id: SheetId, row0: u32) -> (usize, u32, usize) {
6628        let sheet_name = self.graph.sheet_name(sheet_id);
6629        if let Some(sheet) = self.arrow_sheets.sheet(sheet_name) {
6630            return Self::locate_row_in_sheet_for_computed_write_plan(sheet, row0 as usize);
6631        }
6632        Self::locate_row_in_empty_sheet_for_computed_write_plan(row0 as usize, 32 * 1024)
6633    }
6634
6635    fn locate_row_in_sheet_for_computed_write_plan(
6636        sheet: &crate::arrow_store::ArrowSheet,
6637        row0: usize,
6638    ) -> (usize, u32, usize) {
6639        if row0 < sheet.nrows as usize
6640            && let Some((chunk_idx, row_in_chunk)) = sheet.chunk_of_row(row0)
6641        {
6642            let chunk_start = sheet.chunk_starts.get(chunk_idx).copied().unwrap_or(0);
6643            return (chunk_idx, chunk_start as u32, row_in_chunk);
6644        }
6645
6646        let chunk_rows = sheet.chunk_rows.max(1);
6647        if sheet.chunk_starts.is_empty() {
6648            return Self::locate_row_in_empty_sheet_for_computed_write_plan(row0, chunk_rows);
6649        }
6650
6651        let mut chunk_idx = sheet.chunk_starts.len().saturating_sub(1);
6652        let mut chunk_start = sheet.chunk_starts[chunk_idx];
6653        while chunk_start.saturating_add(chunk_rows) <= row0 {
6654            chunk_idx = chunk_idx.saturating_add(1);
6655            chunk_start = chunk_start.saturating_add(chunk_rows);
6656        }
6657        (
6658            chunk_idx,
6659            chunk_start as u32,
6660            row0.saturating_sub(chunk_start),
6661        )
6662    }
6663
6664    fn locate_row_in_empty_sheet_for_computed_write_plan(
6665        row0: usize,
6666        chunk_rows: usize,
6667    ) -> (usize, u32, usize) {
6668        let chunk_rows = chunk_rows.max(1);
6669        let chunk_idx = row0 / chunk_rows;
6670        let chunk_start = chunk_idx.saturating_mul(chunk_rows);
6671        (
6672            chunk_idx,
6673            chunk_start as u32,
6674            row0.saturating_sub(chunk_start),
6675        )
6676    }
6677
6678    #[cfg(test)]
6679    pub(crate) fn debug_plan_computed_write_coalescing(
6680        &self,
6681        buffer: &ComputedWriteBuffer,
6682    ) -> ComputedWriteCoalescingPlan {
6683        self.plan_computed_write_coalescing(buffer)
6684    }
6685
6686    pub(crate) fn flush_computed_write_buffer(
6687        &mut self,
6688        buffer: &mut ComputedWriteBuffer,
6689    ) -> Result<(), ExcelError> {
6690        if buffer.is_empty() {
6691            return Ok(());
6692        }
6693
6694        let plan = self.plan_owned_computed_write_coalescing(buffer.take_writes());
6695        self.flush_computed_write_plan(plan);
6696
6697        Ok(())
6698    }
6699
6700    fn flush_computed_write_plan(&mut self, plan: ComputedWriteCoalescingPlan) {
6701        for chunk in plan.chunks {
6702            self.flush_computed_write_chunk_plan(chunk);
6703        }
6704    }
6705
6706    fn flush_computed_write_chunk_plan(&mut self, chunk: ComputedWriteChunkPlan) {
6707        match &chunk.shape {
6708            ComputedWriteChunkPlanShape::Point => {
6709                self.flush_computed_write_chunk_plan_as_points(chunk);
6710            }
6711            ComputedWriteChunkPlanShape::SparseOffsets { .. } => {
6712                self.flush_computed_write_chunk_plan_as_sparse_fragment_or_points(chunk);
6713            }
6714            ComputedWriteChunkPlanShape::DenseRange { .. } => {
6715                self.flush_computed_write_chunk_plan_as_dense_fragment(chunk);
6716            }
6717            ComputedWriteChunkPlanShape::RunRange { len, runs, .. } => {
6718                if Self::should_emit_computed_run_fragment(*len, *runs) {
6719                    self.flush_computed_write_chunk_plan_as_run_fragment(chunk);
6720                } else {
6721                    self.flush_computed_write_chunk_plan_as_dense_fragment(chunk);
6722                }
6723            }
6724        }
6725    }
6726
6727    #[inline]
6728    fn should_emit_computed_run_fragment(len: usize, runs: usize) -> bool {
6729        runs <= len / 2
6730    }
6731
6732    fn flush_computed_write_chunk_plan_as_points(&mut self, chunk: ComputedWriteChunkPlan) {
6733        let sheet_name = self.graph.sheet_name(chunk.sheet_id).to_string();
6734        for entry in chunk.entries {
6735            let row0 = chunk
6736                .chunk_start_row0
6737                .saturating_add(entry.row_in_chunk as u32);
6738            self.write_computed_overlay_value_0based(&sheet_name, row0, chunk.col0, entry.value);
6739        }
6740    }
6741
6742    fn flush_computed_write_chunk_plan_as_sparse_fragment_or_points(
6743        &mut self,
6744        chunk: ComputedWriteChunkPlan,
6745    ) {
6746        let point_estimate = Self::computed_write_chunk_plan_point_estimate(&chunk);
6747        let sheet_id = chunk.sheet_id;
6748        let col0 = chunk.col0;
6749        let chunk_idx = chunk.chunk_idx;
6750        let chunk_start_row0 = chunk.chunk_start_row0;
6751        let items: Vec<(usize, OverlayValue)> = chunk
6752            .entries
6753            .into_iter()
6754            .map(|entry| (entry.row_in_chunk, entry.value))
6755            .collect();
6756        match OverlayFragment::sparse_offsets_if_estimated_smaller_than_points(
6757            items,
6758            point_estimate,
6759        ) {
6760            Some(Ok(fragment)) => {
6761                self.apply_computed_overlay_fragment(sheet_id, col0, chunk_idx, fragment);
6762            }
6763            Some(Err(cells)) => {
6764                self.flush_computed_overlay_cells_as_points(
6765                    sheet_id,
6766                    col0,
6767                    chunk_start_row0,
6768                    cells,
6769                );
6770            }
6771            None => {}
6772        }
6773    }
6774
6775    #[inline]
6776    fn computed_write_chunk_plan_point_estimate(chunk: &ComputedWriteChunkPlan) -> usize {
6777        chunk
6778            .entries
6779            .iter()
6780            .map(|entry| ComputedWriteBuffer::estimate_value_bytes(&entry.value))
6781            .fold(0usize, usize::saturating_add)
6782    }
6783
6784    fn flush_computed_overlay_cells_as_points(
6785        &mut self,
6786        sheet_id: SheetId,
6787        col0: u32,
6788        chunk_start_row0: u32,
6789        cells: Vec<(usize, OverlayValue)>,
6790    ) {
6791        let sheet_name = self.graph.sheet_name(sheet_id).to_string();
6792        for (row_in_chunk, value) in cells {
6793            let row0 = chunk_start_row0.saturating_add(row_in_chunk as u32);
6794            self.write_computed_overlay_value_0based(&sheet_name, row0, col0, value);
6795        }
6796    }
6797
6798    fn flush_computed_write_chunk_plan_as_dense_fragment(&mut self, chunk: ComputedWriteChunkPlan) {
6799        if chunk.entries.is_empty() {
6800            return;
6801        }
6802        let start = chunk.entries[0].row_in_chunk;
6803        let values: Vec<OverlayValue> =
6804            chunk.entries.into_iter().map(|entry| entry.value).collect();
6805        if let Some(fragment) = OverlayFragment::dense_range(start, values) {
6806            self.apply_computed_overlay_fragment(
6807                chunk.sheet_id,
6808                chunk.col0,
6809                chunk.chunk_idx,
6810                fragment,
6811            );
6812        }
6813    }
6814
6815    fn flush_computed_write_chunk_plan_as_run_fragment(&mut self, chunk: ComputedWriteChunkPlan) {
6816        if chunk.entries.is_empty() {
6817            return;
6818        }
6819        let start = chunk.entries[0].row_in_chunk;
6820        let values: Vec<OverlayValue> =
6821            chunk.entries.into_iter().map(|entry| entry.value).collect();
6822        if let Some(fragment) = OverlayFragment::run_range(start, values) {
6823            self.apply_computed_overlay_fragment(
6824                chunk.sheet_id,
6825                chunk.col0,
6826                chunk.chunk_idx,
6827                fragment,
6828            );
6829        }
6830    }
6831
6832    fn apply_computed_overlay_fragment(
6833        &mut self,
6834        sheet_id: SheetId,
6835        col0: u32,
6836        chunk_idx: usize,
6837        fragment: OverlayFragment,
6838    ) {
6839        if !(self.config.arrow_storage_enabled
6840            && self.config.delta_overlay_enabled
6841            && self.config.write_formula_overlay_enabled)
6842        {
6843            return;
6844        }
6845        if self.computed_overlay_mirroring_disabled {
6846            return;
6847        }
6848
6849        let sheet_name = self.graph.sheet_name(sheet_id).to_string();
6850        self.ensure_arrow_sheet(&sheet_name);
6851
6852        let col0 = col0 as usize;
6853        let asheet = self
6854            .arrow_sheets
6855            .sheet_mut(&sheet_name)
6856            .expect("ArrowSheet must exist");
6857
6858        let cur_cols = asheet.columns.len();
6859        if col0 >= cur_cols {
6860            asheet.insert_columns(cur_cols, (col0 + 1) - cur_cols);
6861        }
6862
6863        let start_row0 = asheet
6864            .chunk_starts
6865            .get(chunk_idx)
6866            .copied()
6867            .unwrap_or_else(|| chunk_idx.saturating_mul(asheet.chunk_rows.max(1)));
6868        let required_rows =
6869            start_row0.saturating_add(fragment.max_covered_offset().saturating_add(1));
6870        if required_rows > asheet.nrows as usize {
6871            if asheet.columns.is_empty() {
6872                asheet.insert_columns(0, 1);
6873            }
6874            asheet.ensure_row_capacity(required_rows);
6875        }
6876
6877        let Some(ch) = asheet.ensure_column_chunk_mut(col0, chunk_idx) else {
6878            return;
6879        };
6880        let delta = ch.computed_overlay.apply_fragment(fragment);
6881        self.adjust_computed_overlay_bytes(delta);
6882
6883        if let Some(cap) = self.config.max_overlay_memory_bytes
6884            && self.computed_overlay_bytes_estimate > cap
6885        {
6886            self.disable_computed_overlay_mirroring_due_to_budget(cap);
6887        }
6888    }
6889
6890    #[inline]
6891    fn adjust_computed_overlay_bytes(&mut self, delta: isize) {
6892        if delta >= 0 {
6893            self.computed_overlay_bytes_estimate = self
6894                .computed_overlay_bytes_estimate
6895                .saturating_add(delta as usize);
6896        } else {
6897            self.computed_overlay_bytes_estimate = self
6898                .computed_overlay_bytes_estimate
6899                .saturating_sub((-delta) as usize);
6900        }
6901    }
6902
6903    fn clear_all_computed_overlays(&mut self) {
6904        let mut freed_total = 0usize;
6905        for sh in self.arrow_sheets.sheets.iter_mut() {
6906            for col in sh.columns.iter_mut() {
6907                for ch in col.chunks.iter_mut() {
6908                    freed_total = freed_total.saturating_add(ch.computed_overlay.clear());
6909                }
6910                for ch in col.sparse_chunks.values_mut() {
6911                    freed_total = freed_total.saturating_add(ch.computed_overlay.clear());
6912                }
6913            }
6914        }
6915        self.computed_overlay_bytes_estimate = self
6916            .computed_overlay_bytes_estimate
6917            .saturating_sub(freed_total);
6918    }
6919
6920    fn disable_computed_overlay_mirroring_due_to_budget(&mut self, _cap: usize) {
6921        // Phase 1 (ticket 610): Arrow-truth is the only supported mode.
6922        // Handle budget pressure by compacting computed overlays into base lanes.
6923        self.compact_all_computed_overlays();
6924    }
6925
6926    /// Fold all computed overlay entries across all sheets into their base arrays.
6927    /// This preserves data while freeing overlay memory, allowing mirroring to continue.
6928    fn compact_all_computed_overlays(&mut self) {
6929        let mut freed_total = 0usize;
6930        for sheet in self.arrow_sheets.sheets.iter_mut() {
6931            for col_idx in 0..sheet.columns.len() {
6932                // Dense chunks
6933                let num_dense = sheet.columns[col_idx].chunks.len();
6934                for ch_idx in 0..num_dense {
6935                    freed_total += sheet.compact_computed_overlay_chunk(col_idx, ch_idx);
6936                }
6937                // Sparse chunks
6938                let sparse_keys: Vec<usize> = sheet.columns[col_idx]
6939                    .sparse_chunks
6940                    .keys()
6941                    .copied()
6942                    .collect();
6943                for ch_idx in sparse_keys {
6944                    freed_total += sheet.compact_computed_overlay_sparse_chunk(col_idx, ch_idx);
6945                }
6946            }
6947        }
6948        self.computed_overlay_bytes_estimate = self
6949            .computed_overlay_bytes_estimate
6950            .saturating_sub(freed_total);
6951        self.overlay_compactions = self.overlay_compactions.saturating_add(1);
6952    }
6953
6954    fn mirror_vertex_value_to_overlay(&mut self, vertex_id: VertexId, value: &LiteralValue) {
6955        let _ = self.record_vertex_value_to_overlay(vertex_id, value, None);
6956    }
6957
6958    fn record_vertex_value_to_overlay(
6959        &mut self,
6960        vertex_id: VertexId,
6961        value: &LiteralValue,
6962        computed_writes: Option<&mut ComputedWriteBuffer>,
6963    ) -> Result<(), ExcelError> {
6964        if !(self.config.arrow_storage_enabled
6965            && self.config.delta_overlay_enabled
6966            && self.config.write_formula_overlay_enabled)
6967        {
6968            return Ok(());
6969        }
6970        if self.computed_overlay_mirroring_disabled {
6971            return Ok(());
6972        }
6973        if !matches!(
6974            self.graph.get_vertex_kind(vertex_id),
6975            VertexKind::FormulaScalar | VertexKind::FormulaArray
6976        ) {
6977            return Ok(());
6978        }
6979        let Some(cell) = self.graph.get_cell_ref(vertex_id) else {
6980            return Ok(());
6981        };
6982        let ov = self.literal_to_overlay_value(value);
6983        if let Some(buffer) = computed_writes {
6984            buffer.push_cell(cell.sheet_id, cell.coord.row(), cell.coord.col(), ov);
6985            if self.should_flush_computed_write_buffer(buffer) {
6986                self.flush_computed_write_buffer(buffer)?;
6987            }
6988        } else {
6989            let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
6990            self.write_computed_overlay_value_0based(
6991                &sheet_name,
6992                cell.coord.row(),
6993                cell.coord.col(),
6994                ov,
6995            );
6996        }
6997        Ok(())
6998    }
6999
7000    #[inline]
7001    fn should_flush_computed_write_buffer(&self, buffer: &ComputedWriteBuffer) -> bool {
7002        self.config.max_overlay_memory_bytes.is_some_and(|cap| {
7003            if cap == 0 {
7004                return false;
7005            }
7006            self.computed_overlay_bytes_estimate
7007                .saturating_add(buffer.estimated_bytes())
7008                > cap
7009        })
7010    }
7011
7012    /// Estimated memory usage for computed overlays (formula/spill mirroring).
7013    pub fn overlay_memory_usage(&self) -> usize {
7014        self.computed_overlay_bytes_estimate
7015    }
7016
7017    #[cfg(test)]
7018    pub(crate) fn debug_overlay_compactions(&self) -> u64 {
7019        self.overlay_compactions
7020    }
7021
7022    #[cfg(test)]
7023    pub(crate) fn debug_recompute_computed_overlay_bytes(&mut self) -> usize {
7024        let mut total = 0usize;
7025        for sheet in &self.arrow_sheets.sheets {
7026            for column in &sheet.columns {
7027                for chunk in &column.chunks {
7028                    total = total.saturating_add(chunk.computed_overlay.estimated_bytes());
7029                }
7030                for chunk in column.sparse_chunks.values() {
7031                    total = total.saturating_add(chunk.computed_overlay.estimated_bytes());
7032                }
7033            }
7034        }
7035        self.computed_overlay_bytes_estimate = total;
7036        total
7037    }
7038
7039    fn resolve_sheet_locator_for_write(
7040        &mut self,
7041        loc: formualizer_common::SheetLocator<'_>,
7042        current_sheet: &str,
7043    ) -> Result<SheetId, ExcelError> {
7044        Ok(match loc {
7045            formualizer_common::SheetLocator::Id(id) => id,
7046            formualizer_common::SheetLocator::Name(name) => self.graph.sheet_id_mut(name.as_ref()),
7047            formualizer_common::SheetLocator::Current => self.graph.sheet_id_mut(current_sheet),
7048        })
7049    }
7050
7051    fn resolve_sheet_locator_for_read(
7052        &self,
7053        loc: formualizer_common::SheetLocator<'_>,
7054        current_sheet: &str,
7055    ) -> Result<SheetId, ExcelError> {
7056        match loc {
7057            formualizer_common::SheetLocator::Id(id) => Ok(id),
7058            formualizer_common::SheetLocator::Name(name) => self
7059                .graph
7060                .sheet_id(name.as_ref())
7061                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref)),
7062            formualizer_common::SheetLocator::Current => self
7063                .graph
7064                .sheet_id(current_sheet)
7065                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref)),
7066        }
7067    }
7068
7069    /// Set a cell value
7070    pub fn set_cell_value(
7071        &mut self,
7072        sheet: &str,
7073        row: u32,
7074        col: u32,
7075        value: LiteralValue,
7076    ) -> Result<(), ExcelError> {
7077        let sheet_id = self.graph.sheet_id_mut(sheet);
7078        self.demote_span_containing_cell_for_write(
7079            sheet_id,
7080            row.saturating_sub(1),
7081            col.saturating_sub(1),
7082        )
7083        .map_err(Self::editor_error_to_excel)?;
7084        self.graph.set_cell_value(sheet, row, col, value.clone())?;
7085        self.record_formula_plane_changed_cell(sheet, row, col);
7086        // Mirror into Arrow overlay when enabled
7087        self.mirror_value_to_overlay(sheet, row, col, &value);
7088        // Advance snapshot to reflect external mutation
7089        self.snapshot_id
7090            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7091        self.has_edited = true;
7092        Ok(())
7093    }
7094
7095    /// Record a single-cell change in FormulaPlane authority so the next
7096    /// `evaluate_all` under `AuthoritativeExperimental` can derive bounded
7097    /// span work from `FormulaConsumerReadIndex` instead of recomputing every
7098    /// active span.
7099    fn record_formula_plane_changed_cell(&mut self, sheet: &str, row: u32, col: u32) {
7100        if self.config.formula_plane_mode == FormulaPlaneMode::Off {
7101            return;
7102        }
7103        let sheet_id = self.graph.sheet_id_mut(sheet);
7104        self.record_formula_plane_structural_change(StructuralScope::Cell {
7105            sheet: sheet_id,
7106            row: row.saturating_sub(1),
7107            col: col.saturating_sub(1),
7108        });
7109    }
7110
7111    fn record_formula_plane_change_for_event(&mut self, event: &ChangeEvent) {
7112        if self.config.formula_plane_mode == FormulaPlaneMode::Off {
7113            return;
7114        }
7115
7116        match event {
7117            ChangeEvent::SetValue { addr, .. } | ChangeEvent::SetFormula { addr, .. } => {
7118                self.record_formula_plane_structural_change(StructuralScope::Cell {
7119                    sheet: addr.sheet_id,
7120                    row: addr.coord.row(),
7121                    col: addr.coord.col(),
7122                });
7123            }
7124            ChangeEvent::SpillCommitted { new, .. } => {
7125                if let Some(scope) = Self::formula_plane_region_from_cells(&new.target_cells) {
7126                    self.record_formula_plane_structural_change(scope);
7127                }
7128            }
7129            ChangeEvent::SpillCleared { old, .. } => {
7130                if let Some(scope) = Self::formula_plane_region_from_cells(&old.target_cells) {
7131                    self.record_formula_plane_structural_change(scope);
7132                }
7133            }
7134            ChangeEvent::DefineName { .. }
7135            | ChangeEvent::UpdateName { .. }
7136            | ChangeEvent::DeleteName { .. }
7137            | ChangeEvent::VertexMoved { .. }
7138            | ChangeEvent::FormulaAdjusted { .. }
7139            | ChangeEvent::NamedRangeAdjusted { .. } => {
7140                self.record_formula_plane_structural_change(StructuralScope::AllSheets);
7141            }
7142            ChangeEvent::SetRowVisibility { sheet_id, row0, .. } => {
7143                self.record_formula_plane_structural_change(StructuralScope::Region(
7144                    Region::whole_row(*sheet_id, *row0),
7145                ));
7146            }
7147            ChangeEvent::AddVertex { .. }
7148            | ChangeEvent::RemoveVertex { .. }
7149            | ChangeEvent::EdgeAdded { .. }
7150            | ChangeEvent::EdgeRemoved { .. }
7151            | ChangeEvent::CompoundStart { .. }
7152            | ChangeEvent::CompoundEnd { .. }
7153            | ChangeEvent::StagedFormulaCellChanged { .. } => {}
7154        }
7155    }
7156
7157    fn record_formula_plane_structural_change(&mut self, scope: StructuralScope) {
7158        if self.config.formula_plane_mode == FormulaPlaneMode::Off {
7159            return;
7160        }
7161
7162        match scope {
7163            StructuralScope::Cell { sheet, row, col } => {
7164                self.graph
7165                    .formula_authority_mut()
7166                    .record_changed_region(Region::point(sheet, row, col));
7167            }
7168            StructuralScope::Region(region) => {
7169                self.graph
7170                    .formula_authority_mut()
7171                    .record_changed_region(region);
7172            }
7173            StructuralScope::Sheet(sheet_id) => {
7174                self.graph
7175                    .formula_authority_mut()
7176                    .record_changed_region(Region::whole_sheet(sheet_id));
7177            }
7178            StructuralScope::RemovedSheet(sheet_id) => {
7179                let removed_refs = {
7180                    let authority = self.graph.formula_authority();
7181                    authority
7182                        .active_span_refs()
7183                        .into_iter()
7184                        .filter(|span_ref| {
7185                            authority
7186                                .plane
7187                                .spans
7188                                .get(*span_ref)
7189                                .map(|span| span.sheet_id == sheet_id)
7190                                .unwrap_or(false)
7191                        })
7192                        .collect::<Vec<_>>()
7193                };
7194
7195                let authority = self.graph.formula_authority_mut();
7196                for span_ref in removed_refs {
7197                    authority.plane.remove_span(span_ref);
7198                }
7199                authority.mark_all_active_spans_dirty();
7200                let _ = authority.rebuild_indexes();
7201            }
7202            StructuralScope::AllSheets => {
7203                let authority = self.graph.formula_authority_mut();
7204                authority.mark_all_active_spans_dirty();
7205                let _ = authority.rebuild_indexes();
7206            }
7207        }
7208    }
7209
7210    fn formula_plane_region_from_cells(cells: &[CellRef]) -> Option<StructuralScope> {
7211        let first = cells.first()?;
7212        let sheet_id = first.sheet_id;
7213        if cells.iter().any(|cell| cell.sheet_id != sheet_id) {
7214            return Some(StructuralScope::AllSheets);
7215        }
7216        let mut row_start = first.coord.row();
7217        let mut row_end = row_start;
7218        let mut col_start = first.coord.col();
7219        let mut col_end = col_start;
7220        for cell in cells.iter().skip(1) {
7221            row_start = row_start.min(cell.coord.row());
7222            row_end = row_end.max(cell.coord.row());
7223            col_start = col_start.min(cell.coord.col());
7224            col_end = col_end.max(cell.coord.col());
7225        }
7226        Some(StructuralScope::Region(Region::rect(
7227            sheet_id, row_start, row_end, col_start, col_end,
7228        )))
7229    }
7230
7231    pub fn set_cell_value_ref(
7232        &mut self,
7233        cell: formualizer_common::SheetCellRef<'_>,
7234        current_sheet: &str,
7235        value: LiteralValue,
7236    ) -> Result<(), ExcelError> {
7237        let owned = cell.into_owned();
7238        let sheet_id = self.resolve_sheet_locator_for_write(owned.sheet, current_sheet)?;
7239        let sheet_name = self.graph.sheet_name(sheet_id).to_string();
7240        self.set_cell_value(
7241            &sheet_name,
7242            owned.coord.row() + 1,
7243            owned.coord.col() + 1,
7244            value,
7245        )
7246    }
7247
7248    pub fn set_cell_formula_ref(
7249        &mut self,
7250        cell: formualizer_common::SheetCellRef<'_>,
7251        current_sheet: &str,
7252        ast: ASTNode,
7253    ) -> Result<(), ExcelError> {
7254        let owned = cell.into_owned();
7255        let sheet_id = self.resolve_sheet_locator_for_write(owned.sheet, current_sheet)?;
7256        let sheet_name = self.graph.sheet_name(sheet_id).to_string();
7257        self.set_cell_formula(
7258            &sheet_name,
7259            owned.coord.row() + 1,
7260            owned.coord.col() + 1,
7261            ast,
7262        )
7263    }
7264
7265    pub fn get_cell_value_ref(
7266        &self,
7267        cell: formualizer_common::SheetCellRef<'_>,
7268        current_sheet: &str,
7269    ) -> Result<Option<LiteralValue>, ExcelError> {
7270        let owned = cell.into_owned();
7271        let sheet_id = self.resolve_sheet_locator_for_read(owned.sheet, current_sheet)?;
7272        let sheet_name = self.graph.sheet_name(sheet_id);
7273        Ok(self.get_cell_value(sheet_name, owned.coord.row() + 1, owned.coord.col() + 1))
7274    }
7275
7276    pub fn resolve_range_view_sheet_ref<'c>(
7277        &'c self,
7278        r: &formualizer_common::SheetRef<'_>,
7279        current_sheet: &str,
7280    ) -> Result<RangeView<'c>, ExcelError> {
7281        use formualizer_common::SheetLocator;
7282
7283        let sheet_to_opt_name = |loc: SheetLocator<'_>| -> Result<Option<String>, ExcelError> {
7284            match loc {
7285                SheetLocator::Current => Ok(None),
7286                SheetLocator::Name(name) => Ok(Some(name.as_ref().to_string())),
7287                SheetLocator::Id(id) => Ok(Some(self.graph.sheet_name(id).to_string())),
7288            }
7289        };
7290
7291        let rt = match r {
7292            formualizer_common::SheetRef::Cell(cell) => ReferenceType::Cell {
7293                sheet: sheet_to_opt_name(cell.sheet.clone())?,
7294                row: cell.coord.row() + 1,
7295                col: cell.coord.col() + 1,
7296                row_abs: cell.coord.row_abs(),
7297                col_abs: cell.coord.col_abs(),
7298            },
7299            formualizer_common::SheetRef::Range(range) => ReferenceType::Range {
7300                sheet: sheet_to_opt_name(range.sheet.clone())?,
7301                start_row: range.start_row.map(|b| b.index + 1),
7302                start_col: range.start_col.map(|b| b.index + 1),
7303                end_row: range.end_row.map(|b| b.index + 1),
7304                end_col: range.end_col.map(|b| b.index + 1),
7305                start_row_abs: range.start_row.map(|b| b.abs).unwrap_or(false),
7306                start_col_abs: range.start_col.map(|b| b.abs).unwrap_or(false),
7307                end_row_abs: range.end_row.map(|b| b.abs).unwrap_or(false),
7308                end_col_abs: range.end_col.map(|b| b.abs).unwrap_or(false),
7309            },
7310        };
7311
7312        crate::traits::EvaluationContext::resolve_range_view(self, &rt, current_sheet)
7313    }
7314
7315    /// Set a cell formula
7316    pub fn set_cell_formula(
7317        &mut self,
7318        sheet: &str,
7319        row: u32,
7320        col: u32,
7321        ast: ASTNode,
7322    ) -> Result<(), ExcelError> {
7323        let sheet_id = self.graph.sheet_id_mut(sheet);
7324        self.demote_span_containing_cell_for_write(
7325            sheet_id,
7326            row.saturating_sub(1),
7327            col.saturating_sub(1),
7328        )
7329        .map_err(Self::editor_error_to_excel)?;
7330        let placement = CellRef::new(sheet_id, Coord::from_excel(row, col, true, true));
7331        let ingested = {
7332            let mut pipeline = self.ingest_pipeline();
7333            pipeline.ingest_formula(FormulaAstInput::Tree(ast), placement, None)?
7334        };
7335        self.graph.set_cell_formula_with_plan(
7336            sheet,
7337            row,
7338            col,
7339            ingested.ast_id,
7340            &ingested.dep_plan,
7341            ingested.dep_plan.volatile,
7342            ingested.dep_plan.dynamic,
7343        )?;
7344        self.record_formula_plane_changed_cell(sheet, row, col);
7345
7346        // If the cell previously held a user value in the delta overlay, it must not continue
7347        // to mask the formula result under Arrow-canonical reads (overlay precedence is
7348        // delta -> computed -> base). Remove the overlay entry instead of writing `Empty`,
7349        // because an explicit `Empty` overlay would still take precedence over computed values.
7350        self.clear_delta_overlay_cell(sheet, row, col);
7351
7352        // Advance snapshot to reflect external mutation
7353        self.mark_topology_edited();
7354        Ok(())
7355    }
7356
7357    /// Bulk set many formulas on a sheet. Skips per-cell snapshot bumping and minimizes edge rebuilds.
7358    pub fn bulk_set_formulas<I>(&mut self, sheet: &str, items: I) -> Result<usize, ExcelError>
7359    where
7360        I: IntoIterator<Item = (u32, u32, ASTNode)>,
7361    {
7362        let collected: Vec<(u32, u32, ASTNode)> = items.into_iter().collect();
7363        let edited_cells: Vec<(u32, u32)> = collected.iter().map(|(r, c, _)| (*r, *c)).collect();
7364        let sheet_id = self.graph.sheet_id_mut(sheet);
7365        let writes_inside_active_span = edited_cells.iter().any(|(row, col)| {
7366            let placement =
7367                PlacementCoord::new(sheet_id, row.saturating_sub(1), col.saturating_sub(1));
7368            self.graph
7369                .formula_authority()
7370                .plane
7371                .spans
7372                .find_at(placement)
7373                .is_some()
7374        });
7375        if writes_inside_active_span {
7376            self.demote_spans_preserving_computed_overlays(sheet_id, Region::whole_sheet(sheet_id))
7377                .map_err(Self::editor_error_to_excel)?;
7378        }
7379        let ingested = {
7380            let mut pipeline = self.ingest_pipeline();
7381            let inputs = collected.into_iter().map(|(row, col, ast)| {
7382                let placement = CellRef::new(sheet_id, Coord::from_excel(row, col, true, true));
7383                (FormulaAstInput::Tree(ast), placement, None)
7384            });
7385            pipeline.ingest_batch(inputs)?
7386        };
7387        let planned = ingested
7388            .into_iter()
7389            .map(|formula| {
7390                (
7391                    formula.placement.coord.row() + 1,
7392                    formula.placement.coord.col() + 1,
7393                    formula.ast_id,
7394                    formula.dep_plan,
7395                )
7396            })
7397            .collect();
7398        let n = self.graph.bulk_set_formulas_with_plans(sheet, planned)?;
7399        for (row, col) in edited_cells {
7400            self.record_formula_plane_changed_cell(sheet, row, col);
7401        }
7402        // Single topology bump after batch
7403        if n > 0 {
7404            self.mark_topology_edited();
7405        }
7406        Ok(n)
7407    }
7408
7409    #[inline]
7410    fn normalize_public_cell_read(v: LiteralValue) -> Option<LiteralValue> {
7411        match v {
7412            LiteralValue::Empty => None,
7413            LiteralValue::Int(i) => Some(LiteralValue::Number(i as f64)),
7414            other => Some(other),
7415        }
7416    }
7417
7418    /// Get a cell value
7419    pub fn get_cell_value(&self, sheet: &str, row: u32, col: u32) -> Option<LiteralValue> {
7420        self.read_cell_value(sheet, row, col)
7421            .and_then(Self::normalize_public_cell_read)
7422    }
7423
7424    /// Unified internal read API for a single cell value (Arrow-truth).
7425    pub(crate) fn read_cell_value(&self, sheet: &str, row: u32, col: u32) -> Option<LiteralValue> {
7426        let asheet = self.sheet_store().sheet(sheet)?;
7427        let r0 = row.saturating_sub(1) as usize;
7428        let c0 = col.saturating_sub(1) as usize;
7429        let v = asheet.get_cell_value(r0, c0);
7430        if matches!(v, LiteralValue::Empty) {
7431            None
7432        } else {
7433            Some(v)
7434        }
7435    }
7436
7437    /// Unified internal read API for a range of cell values (Arrow-truth).
7438    pub(crate) fn read_range_values(
7439        &self,
7440        sheet: &str,
7441        sr: u32,
7442        sc: u32,
7443        er: u32,
7444        ec: u32,
7445    ) -> RangeView<'_> {
7446        let Some(asheet) = self.sheet_store().sheet(sheet) else {
7447            return RangeView::from_owned_rows(Vec::new(), self.config.date_system);
7448        };
7449        if er < sr || ec < sc {
7450            return asheet.range_view(1, 1, 0, 0);
7451        }
7452        let sr0 = sr.saturating_sub(1) as usize;
7453        let sc0 = sc.saturating_sub(1) as usize;
7454        let er0 = er.saturating_sub(1) as usize;
7455        let ec0 = ec.saturating_sub(1) as usize;
7456        asheet.range_view(sr0, sc0, er0, ec0)
7457    }
7458
7459    /// Get formula AST (if any) and current stored value for a cell
7460    pub fn get_cell(
7461        &self,
7462        sheet: &str,
7463        row: u32,
7464        col: u32,
7465    ) -> Option<(Option<formualizer_parse::ASTNode>, Option<LiteralValue>)> {
7466        let v = self.get_cell_value(sheet, row, col);
7467        let sheet_id = self.graph.sheet_id(sheet)?;
7468        let coord = Coord::from_excel(row, col, true, true);
7469        let cell = CellRef::new(sheet_id, coord);
7470        if let Some(vid) = self.graph.get_vertex_for_cell(&cell) {
7471            let ast = self.graph.get_formula_id(vid).and_then(|ast_id| {
7472                self.graph
7473                    .data_store()
7474                    .retrieve_ast(ast_id, self.graph.sheet_reg())
7475            });
7476            return Some((ast, v));
7477        }
7478
7479        let placement =
7480            crate::formula_plane::runtime::PlacementCoord::new(sheet_id, coord.row(), coord.col());
7481        let handle = self
7482            .graph
7483            .formula_authority()
7484            .plane
7485            .resolve_formula_at(placement, None);
7486        let template_id = match handle.resolution {
7487            crate::formula_plane::runtime::FormulaResolution::SpanPlacement {
7488                template_id, ..
7489            } => Some(template_id),
7490            crate::formula_plane::runtime::FormulaResolution::Overlay(overlay_ref) => self
7491                .graph
7492                .formula_authority()
7493                .plane
7494                .formula_overlay
7495                .get(overlay_ref)
7496                .and_then(|overlay| match overlay.kind {
7497                    crate::formula_plane::runtime::FormulaOverlayEntryKind::FormulaOverride(
7498                        template_id,
7499                    ) => Some(template_id),
7500                    _ => None,
7501                }),
7502            _ => None,
7503        };
7504        let ast = template_id.and_then(|template_id| {
7505            let ast_id = self
7506                .graph
7507                .formula_authority()
7508                .plane
7509                .templates
7510                .get(template_id)?
7511                .ast_id;
7512            self.graph
7513                .data_store()
7514                .retrieve_ast(ast_id, self.graph.sheet_reg())
7515        });
7516        if let Some(ast) = ast {
7517            Some((Some(ast), v))
7518        } else if v.is_some() {
7519            Some((None, v))
7520        } else {
7521            None
7522        }
7523    }
7524
7525    /// Begin batch operations - defer CSR rebuilds for better performance
7526    pub fn begin_batch(&mut self) {
7527        self.graph.begin_batch();
7528    }
7529
7530    /// End batch operations and trigger CSR rebuild
7531    pub fn end_batch(&mut self) {
7532        self.graph.end_batch();
7533    }
7534
7535    /// Begin a deferred-dirty scope for a multi-edit batch: while active,
7536    /// every edit's dirty propagation queues its sources instead of running
7537    /// a full BFS per edit, and the outermost `end_deferred_dirty` flushes
7538    /// the union with ONE multi-source propagation (O(component) instead of
7539    /// O(edits × component)). See `DependencyGraph::begin_deferred_dirty`.
7540    ///
7541    /// Callers MUST run `end_deferred_dirty` on every exit path, including
7542    /// error returns; evaluation entry points `debug_assert` no scope leaked.
7543    pub fn begin_deferred_dirty(&mut self) {
7544        self.graph.begin_deferred_dirty();
7545    }
7546
7547    /// End a deferred-dirty scope, flushing the queued propagation when the
7548    /// outermost scope closes. See `Engine::begin_deferred_dirty`.
7549    pub fn end_deferred_dirty(&mut self) {
7550        let _ = self.graph.end_deferred_dirty();
7551    }
7552
7553    /// Total vertices processed by dirty-propagation BFS loops since graph
7554    /// creation. Perf-shape observability only (cross-crate tests assert
7555    /// batched edits propagate O(component), not O(edits × component)).
7556    pub fn dirty_propagation_visits(&self) -> u64 {
7557        self.graph.dirty_propagation_visits()
7558    }
7559
7560    /// Evaluate a single vertex.
7561    /// This is the core of the sequential evaluation logic for Milestone 3.1.
7562    #[inline]
7563    fn record_cell_if_changed(
7564        delta: &mut DeltaCollector,
7565        cell: &CellRef,
7566        old: &LiteralValue,
7567        new: &LiteralValue,
7568    ) {
7569        if old != new {
7570            delta.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
7571        }
7572    }
7573
7574    pub fn evaluate_vertex(&mut self, vertex_id: VertexId) -> Result<LiteralValue, ExcelError> {
7575        if self.graph.formula_authority().active_span_count() > 0 {
7576            let _ = self.evaluate_authoritative_formula_plane_all()?;
7577        }
7578        self.evaluate_vertex_impl(vertex_id, None)
7579    }
7580
7581    fn evaluate_vertex_impl(
7582        &mut self,
7583        vertex_id: VertexId,
7584        delta: Option<&mut DeltaCollector>,
7585    ) -> Result<LiteralValue, ExcelError> {
7586        let mut delta = delta;
7587        // Check if vertex exists
7588        if !self.graph.vertex_exists(vertex_id) {
7589            return Err(ExcelError::new(formualizer_common::ExcelErrorKind::Ref)
7590                .with_message(format!("Vertex not found: {vertex_id:?}")));
7591        }
7592
7593        // Get vertex kind and check if it needs evaluation
7594        let kind = self.graph.get_vertex_kind(vertex_id);
7595        let sheet_id = self.graph.get_vertex_sheet_id(vertex_id);
7596
7597        let ast_id = match kind {
7598            VertexKind::FormulaScalar | VertexKind::FormulaArray => {
7599                if let Some(ast_id) = self.graph.get_formula_id(vertex_id) {
7600                    ast_id
7601                } else {
7602                    return Ok(LiteralValue::Number(0.0));
7603                }
7604            }
7605            VertexKind::Empty | VertexKind::Cell => {
7606                if let Some(cell_ref) = self.graph.get_cell_ref(vertex_id) {
7607                    let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
7608                    let row = cell_ref.coord.row() + 1;
7609                    let col = cell_ref.coord.col() + 1;
7610                    if let Some(v) = self.read_cell_value(sheet_name, row, col) {
7611                        return Ok(v);
7612                    }
7613                }
7614                return Ok(LiteralValue::Number(0.0));
7615            }
7616            VertexKind::NamedScalar => {
7617                let value = self.evaluate_named_scalar(vertex_id, sheet_id)?;
7618                return Ok(value);
7619            }
7620            VertexKind::NamedArray => {
7621                let value = self.evaluate_named_array(vertex_id, sheet_id)?;
7622                return Ok(value);
7623            }
7624            VertexKind::InfiniteRange
7625            | VertexKind::Range
7626            | VertexKind::External
7627            | VertexKind::Table => {
7628                // Not directly evaluatable here.
7629                return Ok(LiteralValue::Number(0.0));
7630            }
7631        };
7632
7633        // The interpreter uses a reference to the engine as the context.
7634        let sheet_name = self.graph.sheet_name(sheet_id);
7635        let cell_ref = self
7636            .graph
7637            .get_cell_ref(vertex_id)
7638            .expect("cell ref for vertex");
7639        let interpreter = Interpreter::new_with_cell(self, sheet_name, cell_ref);
7640
7641        let result =
7642            interpreter.evaluate_arena_ast(ast_id, self.graph.data_store(), self.graph.sheet_reg());
7643
7644        // If array result, perform spill from the anchor cell
7645        match result {
7646            Ok(cv) => {
7647                let result_literal = cv.into_literal();
7648                match result_literal {
7649                    LiteralValue::Array(rows) => {
7650                        // Update kind to FormulaArray for tracking
7651                        self.graph
7652                            .set_kind(vertex_id, crate::engine::vertex::VertexKind::FormulaArray);
7653                        // Build target cells rectangle starting from anchor
7654                        let anchor = self
7655                            .graph
7656                            .get_cell_ref(vertex_id)
7657                            .expect("cell ref for vertex");
7658                        let sheet_id = anchor.sheet_id;
7659                        let h = rows.len() as u32;
7660                        let w = rows.first().map(|r| r.len()).unwrap_or(0) as u32;
7661
7662                        // Hard cap to avoid vertex explosion from huge dynamic arrays.
7663                        let spill_cells = (h as u64).saturating_mul(w as u64);
7664                        if spill_cells > self.config.spill.max_spill_cells as u64 {
7665                            self.clear_spill_projection_and_mirror(vertex_id, delta.as_deref_mut());
7666                            let spill_err = ExcelError::new(ExcelErrorKind::Spill)
7667                                .with_message("SpillTooLarge")
7668                                .with_extra(formualizer_common::ExcelErrorExtra::Spill {
7669                                    expected_rows: h,
7670                                    expected_cols: w,
7671                                });
7672                            let spill_val = LiteralValue::Error(spill_err.clone());
7673                            if let Some(d) = delta.as_deref_mut() {
7674                                let old = self
7675                                    .read_cell_value(
7676                                        self.graph.sheet_name(anchor.sheet_id),
7677                                        anchor.coord.row() + 1,
7678                                        anchor.coord.col() + 1,
7679                                    )
7680                                    .unwrap_or(LiteralValue::Empty);
7681                                if old != spill_val {
7682                                    d.record_cell(
7683                                        anchor.sheet_id,
7684                                        anchor.coord.row(),
7685                                        anchor.coord.col(),
7686                                    );
7687                                }
7688                            }
7689                            self.graph.update_vertex_value(vertex_id, spill_val.clone());
7690                            if self.config.arrow_storage_enabled
7691                                && self.config.delta_overlay_enabled
7692                                && self.config.write_formula_overlay_enabled
7693                            {
7694                                let sheet_name = self.graph.sheet_name(anchor.sheet_id).to_string();
7695                                self.mirror_value_to_computed_overlay(
7696                                    &sheet_name,
7697                                    anchor.coord.row() + 1,
7698                                    anchor.coord.col() + 1,
7699                                    &spill_val,
7700                                );
7701                            }
7702                            return Ok(spill_val);
7703                        }
7704                        // Bounds check to avoid out-of-range writes (align to AbsCoord capacity)
7705                        const PACKED_MAX_ROW: u32 = 1_048_575; // 20-bit max
7706                        const PACKED_MAX_COL: u32 = 16_383; // 14-bit max
7707                        let end_row = anchor.coord.row().saturating_add(h).saturating_sub(1);
7708                        let end_col = anchor.coord.col().saturating_add(w).saturating_sub(1);
7709                        if end_row > PACKED_MAX_ROW || end_col > PACKED_MAX_COL {
7710                            self.clear_spill_projection_and_mirror(vertex_id, delta.as_deref_mut());
7711                            let spill_err = ExcelError::new(ExcelErrorKind::Spill)
7712                                .with_message("Spill exceeds sheet bounds")
7713                                .with_extra(formualizer_common::ExcelErrorExtra::Spill {
7714                                    expected_rows: h,
7715                                    expected_cols: w,
7716                                });
7717                            let spill_val = LiteralValue::Error(spill_err.clone());
7718                            if let Some(d) = delta.as_deref_mut() {
7719                                let old = self
7720                                    .read_cell_value(
7721                                        self.graph.sheet_name(anchor.sheet_id),
7722                                        anchor.coord.row() + 1,
7723                                        anchor.coord.col() + 1,
7724                                    )
7725                                    .unwrap_or(LiteralValue::Empty);
7726                                if old != spill_val {
7727                                    d.record_cell(
7728                                        anchor.sheet_id,
7729                                        anchor.coord.row(),
7730                                        anchor.coord.col(),
7731                                    );
7732                                }
7733                            }
7734                            self.graph.update_vertex_value(vertex_id, spill_val.clone());
7735                            if self.config.arrow_storage_enabled
7736                                && self.config.delta_overlay_enabled
7737                                && self.config.write_formula_overlay_enabled
7738                            {
7739                                let sheet_name = self.graph.sheet_name(anchor.sheet_id).to_string();
7740                                self.mirror_value_to_computed_overlay(
7741                                    &sheet_name,
7742                                    anchor.coord.row() + 1,
7743                                    anchor.coord.col() + 1,
7744                                    &spill_val,
7745                                );
7746                            }
7747                            return Ok(spill_val);
7748                        }
7749                        let mut targets = Vec::new();
7750                        for r in 0..h {
7751                            for c in 0..w {
7752                                targets.push(self.graph.make_cell_ref_internal(
7753                                    sheet_id,
7754                                    anchor.coord.row() + r,
7755                                    anchor.coord.col() + c,
7756                                ));
7757                            }
7758                        }
7759
7760                        // Plan spill via spill manager shim
7761                        match self.spill_mgr.reserve(
7762                            vertex_id,
7763                            anchor,
7764                            SpillShape { rows: h, cols: w },
7765                            SpillMeta {
7766                                epoch: self.recalc_epoch,
7767                                config: self.config.spill,
7768                            },
7769                        ) {
7770                            Ok(()) => {
7771                                // Commit: write values to grid
7772                                // Default conflict policy is Error + FirstWins; reserve() enforces in-flight locks
7773                                // and plan_spill_region enforces overlap with committed formulas/spills/values.
7774                                if let Err(e) = self.commit_spill_and_mirror(
7775                                    vertex_id,
7776                                    &targets,
7777                                    rows.clone(),
7778                                    delta.as_deref_mut(),
7779                                    None,
7780                                ) {
7781                                    // If commit fails, mark as error
7782                                    self.clear_spill_projection_and_mirror(
7783                                        vertex_id,
7784                                        delta.as_deref_mut(),
7785                                    );
7786                                    if let Some(d) = delta.as_deref_mut() {
7787                                        let old = self
7788                                            .read_cell_value(
7789                                                self.graph.sheet_name(anchor.sheet_id),
7790                                                anchor.coord.row() + 1,
7791                                                anchor.coord.col() + 1,
7792                                            )
7793                                            .unwrap_or(LiteralValue::Empty);
7794                                        let new = LiteralValue::Error(e.clone());
7795                                        if old != new {
7796                                            d.record_cell(
7797                                                anchor.sheet_id,
7798                                                anchor.coord.row(),
7799                                                anchor.coord.col(),
7800                                            );
7801                                        }
7802                                    }
7803                                    let err_val = LiteralValue::Error(e.clone());
7804                                    self.graph.update_vertex_value(vertex_id, err_val.clone());
7805                                    if self.config.arrow_storage_enabled
7806                                        && self.config.delta_overlay_enabled
7807                                        && self.config.write_formula_overlay_enabled
7808                                    {
7809                                        let sheet_name =
7810                                            self.graph.sheet_name(anchor.sheet_id).to_string();
7811                                        self.mirror_value_to_computed_overlay(
7812                                            &sheet_name,
7813                                            anchor.coord.row() + 1,
7814                                            anchor.coord.col() + 1,
7815                                            &err_val,
7816                                        );
7817                                    }
7818                                    return Ok(err_val);
7819                                }
7820                                // Anchor shows the top-left value, like Excel
7821                                let top_left = rows
7822                                    .first()
7823                                    .and_then(|r| r.first())
7824                                    .cloned()
7825                                    .unwrap_or(LiteralValue::Empty);
7826                                self.graph.update_vertex_value(vertex_id, top_left.clone());
7827                                Ok(top_left)
7828                            }
7829                            Err(e) => {
7830                                self.clear_spill_projection_and_mirror(
7831                                    vertex_id,
7832                                    delta.as_deref_mut(),
7833                                );
7834                                let spill_err = ExcelError::new(ExcelErrorKind::Spill)
7835                                    .with_message(
7836                                        e.message.unwrap_or_else(|| "Spill blocked".to_string()),
7837                                    )
7838                                    .with_extra(formualizer_common::ExcelErrorExtra::Spill {
7839                                        expected_rows: h,
7840                                        expected_cols: w,
7841                                    });
7842                                let spill_val = LiteralValue::Error(spill_err.clone());
7843                                if let Some(d) = delta.as_deref_mut() {
7844                                    let old = self
7845                                        .read_cell_value(
7846                                            self.graph.sheet_name(anchor.sheet_id),
7847                                            anchor.coord.row() + 1,
7848                                            anchor.coord.col() + 1,
7849                                        )
7850                                        .unwrap_or(LiteralValue::Empty);
7851                                    if old != spill_val {
7852                                        d.record_cell(
7853                                            anchor.sheet_id,
7854                                            anchor.coord.row(),
7855                                            anchor.coord.col(),
7856                                        );
7857                                    }
7858                                }
7859                                self.graph.update_vertex_value(vertex_id, spill_val.clone());
7860                                if self.config.arrow_storage_enabled
7861                                    && self.config.delta_overlay_enabled
7862                                    && self.config.write_formula_overlay_enabled
7863                                {
7864                                    let sheet_name =
7865                                        self.graph.sheet_name(anchor.sheet_id).to_string();
7866                                    self.mirror_value_to_computed_overlay(
7867                                        &sheet_name,
7868                                        anchor.coord.row() + 1,
7869                                        anchor.coord.col() + 1,
7870                                        &spill_val,
7871                                    );
7872                                }
7873                                Ok(spill_val)
7874                            }
7875                        }
7876                    }
7877                    other => {
7878                        // Scalar result: store value and ensure any previous spill is cleared
7879                        let spill_cells = self
7880                            .graph
7881                            .spill_cells_for_anchor(vertex_id)
7882                            .map(|cells| cells.to_vec())
7883                            .unwrap_or_default();
7884                        if let Some(d) = delta.as_deref_mut()
7885                            && let Some(anchor) = self.graph.get_cell_ref_for_vertex(vertex_id)
7886                        {
7887                            if spill_cells.is_empty() {
7888                                let old = self
7889                                    .read_cell_value(
7890                                        self.graph.sheet_name(anchor.sheet_id),
7891                                        anchor.coord.row() + 1,
7892                                        anchor.coord.col() + 1,
7893                                    )
7894                                    .unwrap_or(LiteralValue::Empty);
7895                                if old != other {
7896                                    d.record_cell(
7897                                        anchor.sheet_id,
7898                                        anchor.coord.row(),
7899                                        anchor.coord.col(),
7900                                    );
7901                                }
7902                            } else {
7903                                for cell in spill_cells.iter() {
7904                                    let sheet_name = self.graph.sheet_name(cell.sheet_id);
7905                                    let old = self
7906                                        .get_cell_value(
7907                                            sheet_name,
7908                                            cell.coord.row() + 1,
7909                                            cell.coord.col() + 1,
7910                                        )
7911                                        .unwrap_or(LiteralValue::Empty);
7912                                    let new = if cell.sheet_id == anchor.sheet_id
7913                                        && cell.coord.row() == anchor.coord.row()
7914                                        && cell.coord.col() == anchor.coord.col()
7915                                    {
7916                                        other.clone()
7917                                    } else {
7918                                        LiteralValue::Empty
7919                                    };
7920                                    Self::record_cell_if_changed(d, cell, &old, &new);
7921                                }
7922                            }
7923                        }
7924                        self.graph.clear_spill_region(vertex_id);
7925                        if let Some(scope) = Self::formula_plane_region_from_cells(&spill_cells) {
7926                            self.record_formula_plane_structural_change(scope);
7927                        }
7928                        if self.config.arrow_storage_enabled
7929                            && self.config.delta_overlay_enabled
7930                            && self.config.write_formula_overlay_enabled
7931                        {
7932                            let empty = LiteralValue::Empty;
7933                            for cell in spill_cells.iter() {
7934                                let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
7935                                self.mirror_value_to_computed_overlay(
7936                                    &sheet_name,
7937                                    cell.coord.row() + 1,
7938                                    cell.coord.col() + 1,
7939                                    &empty,
7940                                );
7941                            }
7942                        }
7943                        self.graph.update_vertex_value(vertex_id, other.clone());
7944                        // Optionally mirror into Arrow overlay for Arrow-backed reads
7945                        if self.config.arrow_storage_enabled
7946                            && self.config.delta_overlay_enabled
7947                            && self.config.write_formula_overlay_enabled
7948                        {
7949                            let anchor = self
7950                                .graph
7951                                .get_cell_ref(vertex_id)
7952                                .expect("cell ref for vertex");
7953                            let sheet_name = self.graph.sheet_name(anchor.sheet_id).to_string();
7954                            self.mirror_value_to_computed_overlay(
7955                                &sheet_name,
7956                                anchor.coord.row() + 1,
7957                                anchor.coord.col() + 1,
7958                                &other,
7959                            );
7960                        }
7961                        Ok(other)
7962                    }
7963                }
7964            }
7965            Err(e) => {
7966                // Runtime Excel error: store as a cell value instead of propagating
7967                // as an exception so bulk eval paths don't fail the whole pass.
7968                let spill_cells = self
7969                    .graph
7970                    .spill_cells_for_anchor(vertex_id)
7971                    .map(|cells| cells.to_vec())
7972                    .unwrap_or_default();
7973                let err_val = LiteralValue::Error(e.clone());
7974                if let Some(d) = delta
7975                    && let Some(anchor) = self.graph.get_cell_ref_for_vertex(vertex_id)
7976                {
7977                    if spill_cells.is_empty() {
7978                        let old = self
7979                            .read_cell_value(
7980                                self.graph.sheet_name(anchor.sheet_id),
7981                                anchor.coord.row() + 1,
7982                                anchor.coord.col() + 1,
7983                            )
7984                            .unwrap_or(LiteralValue::Empty);
7985                        if old != err_val {
7986                            d.record_cell(anchor.sheet_id, anchor.coord.row(), anchor.coord.col());
7987                        }
7988                    } else {
7989                        for cell in spill_cells.iter() {
7990                            let sheet_name = self.graph.sheet_name(cell.sheet_id);
7991                            let old = self
7992                                .get_cell_value(
7993                                    sheet_name,
7994                                    cell.coord.row() + 1,
7995                                    cell.coord.col() + 1,
7996                                )
7997                                .unwrap_or(LiteralValue::Empty);
7998                            let new = if cell.sheet_id == anchor.sheet_id
7999                                && cell.coord.row() == anchor.coord.row()
8000                                && cell.coord.col() == anchor.coord.col()
8001                            {
8002                                err_val.clone()
8003                            } else {
8004                                LiteralValue::Empty
8005                            };
8006                            Self::record_cell_if_changed(d, cell, &old, &new);
8007                        }
8008                    }
8009                }
8010                self.graph.clear_spill_region(vertex_id);
8011                if let Some(scope) = Self::formula_plane_region_from_cells(&spill_cells) {
8012                    self.record_formula_plane_structural_change(scope);
8013                }
8014                if self.config.arrow_storage_enabled
8015                    && self.config.delta_overlay_enabled
8016                    && self.config.write_formula_overlay_enabled
8017                {
8018                    let empty = LiteralValue::Empty;
8019                    for cell in spill_cells.iter() {
8020                        let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
8021                        self.mirror_value_to_computed_overlay(
8022                            &sheet_name,
8023                            cell.coord.row() + 1,
8024                            cell.coord.col() + 1,
8025                            &empty,
8026                        );
8027                    }
8028                }
8029                self.graph.update_vertex_value(vertex_id, err_val.clone());
8030                if self.config.arrow_storage_enabled
8031                    && self.config.delta_overlay_enabled
8032                    && self.config.write_formula_overlay_enabled
8033                {
8034                    let anchor = self
8035                        .graph
8036                        .get_cell_ref(vertex_id)
8037                        .expect("cell ref for vertex");
8038                    let sheet_name = self.graph.sheet_name(anchor.sheet_id).to_string();
8039                    self.mirror_value_to_computed_overlay(
8040                        &sheet_name,
8041                        anchor.coord.row() + 1,
8042                        anchor.coord.col() + 1,
8043                        &err_val,
8044                    );
8045                }
8046                Ok(err_val)
8047            }
8048        }
8049    }
8050
8051    fn evaluate_named_scalar(
8052        &mut self,
8053        vertex_id: VertexId,
8054        sheet_id: SheetId,
8055    ) -> Result<LiteralValue, ExcelError> {
8056        let named_range = self.graph.named_range_by_vertex(vertex_id).ok_or_else(|| {
8057            ExcelError::new(ExcelErrorKind::Name)
8058                .with_message("Named range metadata missing".to_string())
8059        })?;
8060
8061        match &named_range.definition {
8062            NamedDefinition::Cell(cell_ref) => {
8063                let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
8064                let row = cell_ref.coord.row() + 1;
8065                let col = cell_ref.coord.col() + 1;
8066
8067                if let Some(dep_vertex) = self.graph.get_vertex_for_cell(cell_ref)
8068                    && matches!(
8069                        self.graph.get_vertex_kind(dep_vertex),
8070                        VertexKind::FormulaScalar | VertexKind::FormulaArray
8071                    )
8072                {
8073                    // Graph does not cache cell/formula values; ensure the precedent is evaluated.
8074                    let value = self.evaluate_vertex(dep_vertex)?;
8075                    self.graph.update_vertex_value(vertex_id, value.clone());
8076                    Ok(value)
8077                } else {
8078                    let value = self
8079                        .get_cell_value(sheet_name, row, col)
8080                        .unwrap_or(LiteralValue::Empty);
8081                    self.graph.update_vertex_value(vertex_id, value.clone());
8082                    Ok(value)
8083                }
8084            }
8085            NamedDefinition::Literal(v) => {
8086                let out = v.clone();
8087                self.graph.update_vertex_value(vertex_id, out.clone());
8088                Ok(out)
8089            }
8090            NamedDefinition::Formula { ast, .. } => {
8091                let context_sheet = match named_range.scope {
8092                    NameScope::Sheet(id) => id,
8093                    NameScope::Workbook => sheet_id,
8094                };
8095                let sheet_name = self.graph.sheet_name(context_sheet);
8096                let cell_ref = self
8097                    .graph
8098                    .get_cell_ref(vertex_id)
8099                    .unwrap_or_else(|| self.graph.make_cell_ref(sheet_name, 0, 0));
8100                let interpreter = Interpreter::new_with_cell(self, sheet_name, cell_ref);
8101                match interpreter.evaluate_ast(ast) {
8102                    Ok(cv) => {
8103                        let value = cv.into_literal();
8104                        match value {
8105                            LiteralValue::Array(_) => {
8106                                let err = ExcelError::new(ExcelErrorKind::NImpl)
8107                                    .with_message("Array result in scalar named range".to_string());
8108                                let err_val = LiteralValue::Error(err.clone());
8109                                self.graph.update_vertex_value(vertex_id, err_val.clone());
8110                                Ok(err_val)
8111                            }
8112                            other => {
8113                                self.graph.update_vertex_value(vertex_id, other.clone());
8114                                Ok(other)
8115                            }
8116                        }
8117                    }
8118                    Err(err) => {
8119                        let err_val = LiteralValue::Error(err.clone());
8120                        self.graph.update_vertex_value(vertex_id, err_val.clone());
8121                        Ok(err_val)
8122                    }
8123                }
8124            }
8125            NamedDefinition::Range(_) => Err(ExcelError::new(ExcelErrorKind::Value)
8126                .with_message("Range-valued name evaluated as scalar".to_string())),
8127        }
8128    }
8129
8130    fn evaluate_named_array(
8131        &mut self,
8132        vertex_id: VertexId,
8133        sheet_id: SheetId,
8134    ) -> Result<LiteralValue, ExcelError> {
8135        let named_range = self.graph.named_range_by_vertex(vertex_id).ok_or_else(|| {
8136            ExcelError::new(ExcelErrorKind::Name)
8137                .with_message("Named range metadata missing".to_string())
8138        })?;
8139
8140        let out = match &named_range.definition {
8141            NamedDefinition::Range(range_ref) => {
8142                if range_ref.start.sheet_id != range_ref.end.sheet_id {
8143                    return Err(ExcelError::new(ExcelErrorKind::Ref)
8144                        .with_message("Named range cannot span sheets".to_string()));
8145                }
8146
8147                let sheet_name = self.graph.sheet_name(range_ref.start.sheet_id);
8148                let sr0 = range_ref.start.coord.row();
8149                let sc0 = range_ref.start.coord.col();
8150                let er0 = range_ref.end.coord.row();
8151                let ec0 = range_ref.end.coord.col();
8152                if sr0 > er0 || sc0 > ec0 {
8153                    return Err(ExcelError::new(ExcelErrorKind::Ref)
8154                        .with_message("Invalid named range bounds".to_string()));
8155                }
8156
8157                let h = (er0 - sr0 + 1) as usize;
8158                let w = (ec0 - sc0 + 1) as usize;
8159                let cell_count = (h as u64).saturating_mul(w as u64);
8160                if cell_count > self.config.spill.max_spill_cells as u64 {
8161                    return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
8162                        "Named range too large to materialize as an array".to_string(),
8163                    ));
8164                }
8165
8166                let mut rows = Vec::with_capacity(h);
8167                for r0 in sr0..=er0 {
8168                    let mut row = Vec::with_capacity(w);
8169                    for c0 in sc0..=ec0 {
8170                        let v = self
8171                            .get_cell_value(sheet_name, r0 + 1, c0 + 1)
8172                            .unwrap_or(LiteralValue::Empty);
8173                        row.push(v);
8174                    }
8175                    rows.push(row);
8176                }
8177                LiteralValue::Array(rows)
8178            }
8179            NamedDefinition::Cell(cell_ref) => {
8180                let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
8181                let row = cell_ref.coord.row() + 1;
8182                let col = cell_ref.coord.col() + 1;
8183                let v = self
8184                    .get_cell_value(sheet_name, row, col)
8185                    .unwrap_or(LiteralValue::Empty);
8186                LiteralValue::Array(vec![vec![v]])
8187            }
8188            NamedDefinition::Literal(v) => LiteralValue::Array(vec![vec![v.clone()]]),
8189            NamedDefinition::Formula { ast, .. } => {
8190                let context_sheet = match named_range.scope {
8191                    NameScope::Sheet(id) => id,
8192                    NameScope::Workbook => sheet_id,
8193                };
8194                let sheet_name = self.graph.sheet_name(context_sheet);
8195                let cell_ref = self
8196                    .graph
8197                    .get_cell_ref(vertex_id)
8198                    .unwrap_or_else(|| self.graph.make_cell_ref(sheet_name, 0, 0));
8199                let interpreter = Interpreter::new_with_cell(self, sheet_name, cell_ref);
8200                match interpreter.evaluate_ast(ast) {
8201                    Ok(cv) => {
8202                        let v = cv.into_literal();
8203                        match v {
8204                            LiteralValue::Array(_) => v,
8205                            other => LiteralValue::Array(vec![vec![other]]),
8206                        }
8207                    }
8208                    Err(err) => LiteralValue::Error(err),
8209                }
8210            }
8211        };
8212
8213        self.graph.update_vertex_value(vertex_id, out.clone());
8214        Ok(out)
8215    }
8216
8217    /// Evaluate only the necessary precedents for specific target cells (demand-driven)
8218    pub fn evaluate_until(
8219        &mut self,
8220        targets: &[(&str, u32, u32)],
8221    ) -> Result<EvalResult, ExcelError> {
8222        #[cfg(feature = "tracing")]
8223        let _span_eval = tracing::info_span!("evaluate_until", targets = targets.len()).entered();
8224        let start = crate::instant::FzInstant::now();
8225        self.begin_evaluation_request();
8226        // Fold any pending edge deltas once so scheduling/eval reads use the
8227        // zero-allocation CSR slices (#125 write-cheap / read-flush split).
8228        self.graph.flush_pending_edge_deltas();
8229        let _source_cache = self.source_cache_session();
8230        if self.graph.formula_authority().active_span_count() > 0 {
8231            return self.evaluate_authoritative_formula_plane_all();
8232        }
8233
8234        // Parse target cell addresses
8235        let mut target_addrs = Vec::new();
8236        for (sheet, row, col) in targets {
8237            // For now, assume simple A1-style references on default sheet
8238            // TODO: Parse complex references with sheets
8239            let sheet_id = self.graph.sheet_id_mut(sheet);
8240            let coord = Coord::from_excel(*row, *col, true, true);
8241            target_addrs.push(CellRef::new(sheet_id, coord));
8242        }
8243
8244        // Find vertex IDs for targets
8245        let mut target_vertex_ids = Vec::new();
8246        for addr in &target_addrs {
8247            if let Some(vertex_id) = self.graph.get_vertex_id_for_address(addr) {
8248                target_vertex_ids.push(*vertex_id);
8249            }
8250        }
8251
8252        if target_vertex_ids.is_empty() {
8253            return Ok(EvalResult {
8254                computed_vertices: 0,
8255                cycle_errors: 0,
8256                elapsed: start.elapsed(),
8257            });
8258        }
8259
8260        // Build demand subgraph with virtual edges for compressed ranges
8261        #[cfg(feature = "tracing")]
8262        let _span_sub = tracing::info_span!("demand_subgraph_build").entered();
8263        let (precedents_to_eval, vdeps) = self.build_demand_subgraph(&target_vertex_ids);
8264        #[cfg(feature = "tracing")]
8265        drop(_span_sub);
8266
8267        if precedents_to_eval.is_empty() {
8268            return Ok(EvalResult {
8269                computed_vertices: 0,
8270                cycle_errors: 0,
8271                elapsed: start.elapsed(),
8272            });
8273        }
8274
8275        // Create schedule for the minimal subgraph, honoring virtual edges
8276        let scheduler = Scheduler::new(&self.graph);
8277        #[cfg(feature = "tracing")]
8278        let _span_sched =
8279            tracing::info_span!("schedule_build", vertices = precedents_to_eval.len()).entered();
8280        let schedule = scheduler.create_schedule_with_virtual(&precedents_to_eval, &vdeps)?;
8281        #[cfg(feature = "tracing")]
8282        drop(_span_sched);
8283
8284        // Walk schedule units in condensation order: stamp each cyclic SCC at
8285        // its position, evaluate layers (parallel when enabled, mirroring
8286        // evaluate_all).
8287        let mut cycle_errors = 0;
8288        let mut computed_vertices = 0;
8289        for &unit in &schedule.units {
8290            match unit {
8291                ScheduleUnit::Cycle(i) => {
8292                    if self.handle_cycle_unit(schedule.unit_cycle(i), None, None, None)? > 0 {
8293                        cycle_errors += 1;
8294                    }
8295                }
8296                ScheduleUnit::Layer(i) => {
8297                    let layer = schedule.unit_layer(i);
8298                    if self.thread_pool.is_some() && layer.vertices.len() > 1 {
8299                        computed_vertices += self.evaluate_layer_parallel(layer)?;
8300                    } else {
8301                        computed_vertices += self.evaluate_layer_sequential(layer)?;
8302                    }
8303                }
8304            }
8305        }
8306
8307        // Clear warmup context at end of evaluation
8308
8309        // Clear dirty flags for evaluated vertices
8310        self.graph.clear_dirty_flags(&precedents_to_eval);
8311
8312        // Re-dirty volatile vertices
8313        self.redirty_for_next_recalc();
8314
8315        Ok(EvalResult {
8316            computed_vertices,
8317            cycle_errors,
8318            elapsed: start.elapsed(),
8319        })
8320    }
8321
8322    fn evaluate_until_with_delta_collector(
8323        &mut self,
8324        targets: &[(&str, u32, u32)],
8325        delta: &mut DeltaCollector,
8326    ) -> Result<EvalResult, ExcelError> {
8327        #[cfg(feature = "tracing")]
8328        let _span_eval =
8329            tracing::info_span!("evaluate_until_with_delta", targets = targets.len()).entered();
8330        let start = crate::instant::FzInstant::now();
8331        self.begin_evaluation_request();
8332        self.graph.flush_pending_edge_deltas();
8333        let _source_cache = self.source_cache_session();
8334
8335        let mut target_addrs = Vec::new();
8336        for (sheet, row, col) in targets {
8337            let sheet_id = self.graph.sheet_id_mut(sheet);
8338            let coord = Coord::from_excel(*row, *col, true, true);
8339            target_addrs.push(CellRef::new(sheet_id, coord));
8340        }
8341
8342        let mut target_vertex_ids = Vec::new();
8343        for addr in &target_addrs {
8344            if let Some(vertex_id) = self.graph.get_vertex_id_for_address(addr) {
8345                target_vertex_ids.push(*vertex_id);
8346            }
8347        }
8348
8349        if target_vertex_ids.is_empty() {
8350            return Ok(EvalResult {
8351                computed_vertices: 0,
8352                cycle_errors: 0,
8353                elapsed: start.elapsed(),
8354            });
8355        }
8356
8357        let (precedents_to_eval, vdeps) = self.build_demand_subgraph(&target_vertex_ids);
8358
8359        if precedents_to_eval.is_empty() {
8360            return Ok(EvalResult {
8361                computed_vertices: 0,
8362                cycle_errors: 0,
8363                elapsed: start.elapsed(),
8364            });
8365        }
8366
8367        let scheduler = Scheduler::new(&self.graph);
8368        let schedule = scheduler.create_schedule_with_virtual(&precedents_to_eval, &vdeps)?;
8369
8370        let mut cycle_errors = 0;
8371        let mut computed_vertices = 0;
8372        for &unit in &schedule.units {
8373            match unit {
8374                ScheduleUnit::Cycle(i) => {
8375                    if self.handle_cycle_unit(schedule.unit_cycle(i), Some(delta), None, None)? > 0
8376                    {
8377                        cycle_errors += 1;
8378                    }
8379                }
8380                ScheduleUnit::Layer(i) => {
8381                    let layer = schedule.unit_layer(i);
8382                    if self.thread_pool.is_some() && layer.vertices.len() > 1 {
8383                        computed_vertices +=
8384                            self.evaluate_layer_parallel_with_delta(layer, delta)?;
8385                    } else {
8386                        computed_vertices +=
8387                            self.evaluate_layer_sequential_with_delta(layer, delta)?;
8388                    }
8389                }
8390            }
8391        }
8392
8393        self.graph.clear_dirty_flags(&precedents_to_eval);
8394        self.redirty_for_next_recalc();
8395
8396        Ok(EvalResult {
8397            computed_vertices,
8398            cycle_errors,
8399            elapsed: start.elapsed(),
8400        })
8401    }
8402
8403    /// Build a reusable evaluation plan that covers every formula vertex in the workbook.
8404    pub fn build_recalc_plan(&self) -> Result<RecalcPlan, ExcelError> {
8405        let mut vertices: Vec<VertexId> = self.graph.vertices_with_formulas().collect();
8406        vertices.sort_unstable();
8407        if vertices.is_empty() {
8408            return Ok(RecalcPlan {
8409                schedule: crate::engine::Schedule {
8410                    units: Vec::new(),
8411                    layers: Vec::new(),
8412                    cycles: Vec::new(),
8413                },
8414                has_dynamic_refs: false,
8415            });
8416        }
8417
8418        let has_dynamic_refs = vertices.iter().copied().any(|v| self.graph.is_dynamic(v));
8419        let (schedule, _, _) = self.create_evaluation_schedule_uncached(&vertices)?;
8420        Ok(RecalcPlan {
8421            schedule,
8422            has_dynamic_refs,
8423        })
8424    }
8425
8426    /// Evaluate using a previously constructed plan. This avoids rebuilding layer schedules for each run.
8427    pub fn evaluate_recalc_plan(&mut self, plan: &RecalcPlan) -> Result<EvalResult, ExcelError> {
8428        self.begin_evaluation_request();
8429        let _source_cache = self.source_cache_session();
8430        self.validate_deterministic_mode()?;
8431        if self.config.defer_graph_building {
8432            self.build_graph_all()?;
8433        }
8434        if self.graph.formula_authority().active_span_count() > 0 {
8435            return self.evaluate_authoritative_formula_plane_all();
8436        }
8437
8438        let start = crate::instant::FzInstant::now();
8439        let dirty_vertices = self.graph.get_evaluation_vertices();
8440        if dirty_vertices.is_empty() {
8441            return Ok(EvalResult {
8442                computed_vertices: 0,
8443                cycle_errors: 0,
8444                elapsed: start.elapsed(),
8445            });
8446        }
8447
8448        // Dynamic-reference formulas (INDIRECT/OFFSET-class) require per-pass virtual-dep
8449        // augmentation. Reuse the direct recalc flow to preserve semantic parity.
8450        if plan.has_dynamic_refs {
8451            self.virtual_dep_fallback_activations =
8452                self.virtual_dep_fallback_activations.saturating_add(1);
8453            return self.evaluate_all();
8454        }
8455
8456        let dirty_set: FxHashSet<VertexId> = dirty_vertices.iter().copied().collect();
8457        let mut computed_vertices = 0;
8458        let mut cycle_errors = 0;
8459
8460        for &unit in &plan.schedule.units {
8461            match unit {
8462                ScheduleUnit::Cycle(i) => {
8463                    // Recalc-plan quirk (Static): stamp only the DIRTY members
8464                    // of the cycle, and count the cycle only when it had any.
8465                    // Under Runtime the filter means: skip when no member is
8466                    // dirty, evaluate the whole SCC when any is.
8467                    let stamped = self.handle_cycle_unit(
8468                        plan.schedule.unit_cycle(i),
8469                        None,
8470                        Some(&dirty_set),
8471                        None,
8472                    )?;
8473                    if stamped > 0 {
8474                        cycle_errors += 1;
8475                    }
8476                }
8477                ScheduleUnit::Layer(i) => {
8478                    let work: Vec<VertexId> = plan
8479                        .schedule
8480                        .unit_layer(i)
8481                        .vertices
8482                        .iter()
8483                        .copied()
8484                        .filter(|v| dirty_set.contains(v))
8485                        .collect();
8486                    if work.is_empty() {
8487                        continue;
8488                    }
8489                    let temp_layer = crate::engine::scheduler::Layer { vertices: work };
8490                    if self.thread_pool.is_some() && temp_layer.vertices.len() > 1 {
8491                        computed_vertices += self.evaluate_layer_parallel(&temp_layer)?;
8492                    } else {
8493                        computed_vertices += self.evaluate_layer_sequential(&temp_layer)?;
8494                    }
8495                }
8496            }
8497        }
8498
8499        self.graph.clear_dirty_flags(&dirty_vertices);
8500        self.redirty_for_next_recalc();
8501
8502        Ok(EvalResult {
8503            computed_vertices,
8504            cycle_errors,
8505            elapsed: start.elapsed(),
8506        })
8507    }
8508    fn evaluate_authoritative_formula_plane_all(&mut self) -> Result<EvalResult, ExcelError> {
8509        // Fresh per-request cycle counters. Some callers (`evaluate_vertex`,
8510        // `evaluate_cells*`) reach this coordinator without an entry-point
8511        // reset; callers that did reset have accumulated nothing in between,
8512        // so the duplicate reset is harmless. The composed legacy primitive
8513        // below intentionally does not reset, so `evaluate_legacy_cycle_prepass`
8514        // counts survive into the final telemetry.
8515        self.begin_evaluation_request();
8516        // The FormulaPlane coordinator is now selected by mode for evaluate_all.
8517        // SingletonUnique formulas intentionally remain legacy graph vertices;
8518        // when no spans are active, execute through the private legacy primitive
8519        // rather than the public legacy entry path.
8520        if self.graph.formula_authority().active_span_count() == 0 {
8521            #[cfg(test)]
8522            {
8523                self.last_formula_plane_span_eval_report = None;
8524            }
8525            return self.evaluate_all_legacy_impl();
8526        }
8527
8528        // Decide span work seeding strategy: any active span we have not yet
8529        // evaluated under the current authority indexes generation must run
8530        // whole; subsequent passes use bounded dirty closures derived from
8531        // captured changed regions.
8532        let current_indexes_epoch = self.graph.formula_authority().indexes_epoch();
8533        let span_seed_mode = if self.formula_plane_indexes_epoch_seen != current_indexes_epoch {
8534            SpanSeedMode::WholeAll
8535        } else {
8536            SpanSeedMode::DirtyClosure
8537        };
8538        // Take pending regions out of the authority so subsequent reschedules
8539        // start from a clean slate after a successful eval pass.
8540        let pending_changed_regions = self
8541            .graph
8542            .formula_authority_mut()
8543            .take_pending_changed_regions();
8544
8545        // Steady-state shortcut: in `DirtyClosure` mode span work is derived
8546        // exclusively from pending changed regions, so with none pending the
8547        // mixed schedule could only ever contain dirty legacy vertices (e.g.
8548        // re-dirtied volatiles). Skip the O(all formula vertices)
8549        // producer/consumer index rebuild and run them through the legacy
8550        // primitive directly — identical evaluation set, with the legacy
8551        // path's native cycle/virtual-dep handling.
8552        if matches!(span_seed_mode, SpanSeedMode::DirtyClosure)
8553            && pending_changed_regions.is_empty()
8554        {
8555            #[cfg(test)]
8556            {
8557                self.last_formula_plane_span_eval_report = None;
8558            }
8559            return self.evaluate_all_legacy_impl();
8560        }
8561
8562        let start = crate::instant::FzInstant::now();
8563        let mut span_seed_mode = span_seed_mode;
8564        let mut pending_changed_regions = pending_changed_regions;
8565        // #CIRC stamps produced by demoting cyclic spans and resolving the
8566        // residual legacy-only cycle ahead of the mixed schedule (gotcha G8).
8567        let mut prepass_cycle_errors = 0usize;
8568        const MAX_CYCLE_DEMOTE_ITERS: usize = 64;
8569        let mut cycle_demote_iters = 0usize;
8570        let (schedule, span_refs_by_id, plane_epoch, legacy_vertices) = loop {
8571            let (schedule, span_refs_by_id, plane_epoch, legacy_vertices) =
8572                self.build_formula_plane_mixed_schedule(span_seed_mode, &pending_changed_regions)?;
8573
8574            if schedule.is_authoritative_safe() {
8575                break (schedule, span_refs_by_id, plane_epoch, legacy_vertices);
8576            }
8577
8578            // The demote loop below can only make progress on cycles: it
8579            // demotes cyclic spans and stamps residual legacy-only cycles.
8580            // Every other fallback reason (capacity caps, unsupported
8581            // projections, missing result regions) is a property of the
8582            // inputs — rebuilding the schedule from identical state
8583            // reproduces the identical fallback, so iterating would spin
8584            // `MAX_CYCLE_DEMOTE_ITERS` times doing O(graph) schedule builds
8585            // per iteration before giving up anyway. Fail over to the legacy
8586            // primitive immediately instead.
8587            let has_cycle_fallback = schedule.stats.cycle_count > 0
8588                || schedule
8589                    .fallbacks
8590                    .iter()
8591                    .any(|fb| fb.reason == MixedScheduleFallbackReason::CycleDetected);
8592            if !has_cycle_fallback {
8593                self.formula_plane_capacity_bailouts =
8594                    self.formula_plane_capacity_bailouts.saturating_add(1);
8595                #[cfg(test)]
8596                {
8597                    self.last_formula_plane_span_eval_report = None;
8598                }
8599                return self.evaluate_all_legacy_impl();
8600            }
8601
8602            // Gotcha G8 (refs #112): a span whose member cell participates in a
8603            // statically-cyclic SCC must never be span-evaluated. Cross-cell
8604            // cycles that route through a span producer are invisible to the
8605            // legacy Tarjan pass (the span member has no graph vertex) and only
8606            // surface here, as `CycleDetected` fallbacks in the producer-bounded
8607            // mixed schedule. Demote the cyclic spans to legacy graph vertices
8608            // so the cycle members move onto the legacy SCC path, then resolve
8609            // the now legacy-only cycle ahead of the schedule and rebuild.
8610            // Spans that do not touch the cycle are left untouched.
8611            let cyclic_spans = self.collect_cyclic_span_refs(&schedule, &span_refs_by_id);
8612            if !cyclic_spans.is_empty() {
8613                self.demote_cyclic_spans(&cyclic_spans)?;
8614            }
8615
8616            if self.graph.formula_authority().active_span_count() == 0 {
8617                // All spans demoted; nothing left for the FP coordinator. The
8618                // legacy evaluator resolves the (now fully legacy) cycle.
8619                return self.evaluate_all_legacy_impl();
8620            }
8621
8622            // Resolve the residual legacy-only cycle (`handle_cycle_unit`
8623            // honors Static vs Runtime) before rebuilding so the mixed schedule
8624            // is cycle-free and the surviving spans still get evaluated.
8625            prepass_cycle_errors =
8626                prepass_cycle_errors.saturating_add(self.evaluate_legacy_cycle_prepass()?);
8627
8628            // Re-seed every surviving span whole after the geometry/dirty
8629            // changes; the demotion already reset
8630            // `formula_plane_indexes_epoch_seen` to 0.
8631            span_seed_mode = SpanSeedMode::WholeAll;
8632            pending_changed_regions = self
8633                .graph
8634                .formula_authority_mut()
8635                .take_pending_changed_regions();
8636
8637            cycle_demote_iters += 1;
8638            if cycle_demote_iters >= MAX_CYCLE_DEMOTE_ITERS {
8639                // Defensive bound: every iteration either demotes ≥1 span or
8640                // stamps the legacy cycle, both strictly reducing residual work.
8641                // If we somehow fail to converge, fall back to pure legacy to
8642                // stay correct rather than spin.
8643                return self.evaluate_all_legacy_impl();
8644            }
8645        };
8646
8647        let mut computed_vertices = 0usize;
8648        #[cfg(test)]
8649        {
8650            self.last_formula_plane_span_eval_report = None;
8651        }
8652        for layer in schedule.layers {
8653            let mut buffer = ComputedWriteBuffer::default();
8654            let mut sink = SpanComputedWriteSink::new(&mut buffer);
8655            let work_items = layer.work;
8656            let mut work_index = 0usize;
8657            while work_index < work_items.len() {
8658                match work_items[work_index].producer {
8659                    FormulaProducerId::Span(span_id) => {
8660                        let span_ref = *span_refs_by_id.get(&span_id).ok_or_else(|| {
8661                            ExcelError::new(ExcelErrorKind::NImpl)
8662                                .with_message("FormulaPlane schedule referenced a stale span")
8663                        })?;
8664                        let sheet_id = {
8665                            let authority = self.graph.formula_authority();
8666                            let span = authority.plane.spans.get(span_ref).ok_or_else(|| {
8667                                ExcelError::new(ExcelErrorKind::NImpl)
8668                                    .with_message("FormulaPlane schedule referenced a stale span")
8669                            })?;
8670                            span.sheet_id
8671                        };
8672                        let current_sheet = self.graph.sheet_name(sheet_id);
8673                        let authority = self.graph.formula_authority();
8674                        let evaluator = SpanEvaluator::new(
8675                            &authority.plane,
8676                            self,
8677                            current_sheet,
8678                            self.graph.data_store(),
8679                            self.graph.sheet_reg(),
8680                        );
8681                        #[cfg(test)]
8682                        let mut last_group_report = None;
8683                        while work_index < work_items.len() {
8684                            let FormulaProducerId::Span(group_span_id) =
8685                                work_items[work_index].producer
8686                            else {
8687                                break;
8688                            };
8689                            let group_span_ref =
8690                                *span_refs_by_id.get(&group_span_id).ok_or_else(|| {
8691                                    ExcelError::new(ExcelErrorKind::NImpl).with_message(
8692                                        "FormulaPlane schedule referenced a stale span",
8693                                    )
8694                                })?;
8695                            let group_sheet_id = {
8696                                let authority = self.graph.formula_authority();
8697                                let span =
8698                                    authority.plane.spans.get(group_span_ref).ok_or_else(|| {
8699                                        ExcelError::new(ExcelErrorKind::NImpl).with_message(
8700                                            "FormulaPlane schedule referenced a stale span",
8701                                        )
8702                                    })?;
8703                                span.sheet_id
8704                            };
8705                            if group_sheet_id != sheet_id {
8706                                break;
8707                            }
8708
8709                            let dirty = producer_dirty_to_span_dirty(
8710                                work_items[work_index].dirty.clone(),
8711                                group_span_ref,
8712                            );
8713                            let task = SpanEvalTask {
8714                                span: group_span_ref,
8715                                dirty,
8716                                plane_epoch,
8717                            };
8718                            let report =
8719                                evaluator.evaluate_task(&task, &mut sink).map_err(|err| {
8720                                    ExcelError::new(ExcelErrorKind::NImpl).with_message(format!(
8721                                        "FormulaPlane span evaluation failed: {err:?}"
8722                                    ))
8723                                })?;
8724                            #[cfg(test)]
8725                            {
8726                                last_group_report = Some(report.clone());
8727                            }
8728                            computed_vertices = computed_vertices
8729                                .saturating_add(report.span_eval_placement_count as usize);
8730                            work_index = work_index.saturating_add(1);
8731                        }
8732                        #[cfg(test)]
8733                        {
8734                            if let Some(report) = last_group_report {
8735                                self.last_formula_plane_span_eval_report = Some(report);
8736                            }
8737                        }
8738                    }
8739                    FormulaProducerId::Legacy(_) => {
8740                        // Batch the contiguous run of legacy work items into a
8741                        // synthetic layer and evaluate it through the same
8742                        // coalesced effects pipeline as the legacy scheduler.
8743                        // Items in one mixed layer have no edges between them
8744                        // (same invariant the legacy Kahn layers rely on), so
8745                        // batching preserves ordering semantics while
8746                        // amortizing per-write overlay mirroring that makes
8747                        // one-vertex-at-a-time evaluation ~30x slower.
8748                        let mut vertices = Vec::new();
8749                        while work_index < work_items.len() {
8750                            let FormulaProducerId::Legacy(vertex_id) =
8751                                work_items[work_index].producer
8752                            else {
8753                                break;
8754                            };
8755                            vertices.push(vertex_id);
8756                            work_index = work_index.saturating_add(1);
8757                        }
8758                        let legacy_layer = crate::engine::scheduler::Layer { vertices };
8759                        let evaluated =
8760                            if self.thread_pool.is_some() && legacy_layer.vertices.len() > 1 {
8761                                self.evaluate_layer_parallel(&legacy_layer)?
8762                            } else {
8763                                self.evaluate_layer_sequential(&legacy_layer)?
8764                            };
8765                        computed_vertices = computed_vertices.saturating_add(evaluated);
8766                    }
8767                }
8768            }
8769            self.flush_computed_write_buffer(&mut buffer)?;
8770        }
8771
8772        self.graph.clear_dirty_flags(&legacy_vertices);
8773        // Drop dirty flags on any newly-scheduled FP runtime cells whose graph
8774        // vertices weren't in the dirty subset (e.g. recently-introduced span
8775        // result cells); legacy clear_dirty_flags is safe over the full set.
8776        self.redirty_for_next_recalc();
8777        // Mark this indexes-epoch as fully evaluated so subsequent passes can
8778        // use bounded span dirty closures rather than whole-span work.
8779        self.formula_plane_indexes_epoch_seen = self.graph.formula_authority().indexes_epoch();
8780        self.recalc_epoch = self.recalc_epoch.wrapping_add(1);
8781        Ok(EvalResult {
8782            computed_vertices,
8783            cycle_errors: prepass_cycle_errors,
8784            elapsed: start.elapsed(),
8785        })
8786    }
8787
8788    fn build_formula_plane_mixed_schedule(
8789        &self,
8790        span_seed_mode: SpanSeedMode,
8791        pending_changed_regions: &[Region],
8792    ) -> Result<FormulaPlaneMixedScheduleBuild, ExcelError> {
8793        let authority = self.graph.formula_authority();
8794        let mut producer_results = FormulaProducerResultIndex::default();
8795        let mut consumer_reads = FormulaConsumerReadIndex::default();
8796        let mut work = Vec::new();
8797
8798        // Legacy formula producers participate in the mixed runtime only when
8799        // they are dirty under graph semantics. Result/read indexes still cover
8800        // every legacy formula so that span->legacy and legacy->span ordering is
8801        // visible to the scheduler regardless of dirty status, but only dirty
8802        // vertices receive scheduled work.
8803        let dirty_legacy: rustc_hash::FxHashSet<VertexId> =
8804            self.graph.get_evaluation_vertices().into_iter().collect();
8805
8806        let span_refs = authority.active_span_refs();
8807        let span_refs_by_id = span_refs
8808            .iter()
8809            .copied()
8810            .map(|span_ref| (span_ref.id, span_ref))
8811            .collect::<BTreeMap<_, _>>();
8812        for span_ref in &span_refs {
8813            let span = authority.plane.spans.get(*span_ref).ok_or_else(|| {
8814                ExcelError::new(ExcelErrorKind::NImpl)
8815                    .with_message("FormulaPlane active span ref is stale")
8816            })?;
8817            let result_region = Region::from_domain(span.result_region.domain());
8818            producer_results.insert_producer(FormulaProducerId::Span(span.id), result_region);
8819            let Some(read_summary_id) = span.read_summary_id else {
8820                return Err(ExcelError::new(ExcelErrorKind::NImpl)
8821                    .with_message("FormulaPlane active span is missing read summary"));
8822            };
8823            let Some(read_summary) = authority.plane.span_read_summaries.get(read_summary_id)
8824            else {
8825                return Err(ExcelError::new(ExcelErrorKind::NImpl)
8826                    .with_message("FormulaPlane active span has stale read summary"));
8827            };
8828            if read_summary.result_region != result_region {
8829                return Err(ExcelError::new(ExcelErrorKind::NImpl)
8830                    .with_message("FormulaPlane active span read summary is stale"));
8831            }
8832            for dependency in &read_summary.dependencies {
8833                consumer_reads.insert_read(
8834                    FormulaProducerId::Span(span.id),
8835                    dependency.read_region,
8836                    read_summary.result_region,
8837                    dependency.projection,
8838                );
8839            }
8840            if matches!(span_seed_mode, SpanSeedMode::WholeAll) {
8841                work.push(FormulaProducerWork {
8842                    producer: FormulaProducerId::Span(span.id),
8843                    dirty: ProducerDirtyDomain::Whole,
8844                });
8845            }
8846        }
8847
8848        let legacy_vertices = self.graph.formula_vertices();
8849        let mut scheduled_legacy_vertices = Vec::new();
8850        for vertex in &legacy_vertices {
8851            let Some(cell) = self.graph.get_cell_ref_for_vertex(*vertex) else {
8852                continue;
8853            };
8854            let result_region = Region::point(cell.sheet_id, cell.coord.row(), cell.coord.col());
8855            producer_results.insert_producer(FormulaProducerId::Legacy(*vertex), result_region);
8856            if dirty_legacy.contains(vertex) {
8857                scheduled_legacy_vertices.push(*vertex);
8858                work.push(FormulaProducerWork {
8859                    producer: FormulaProducerId::Legacy(*vertex),
8860                    dirty: ProducerDirtyDomain::Whole,
8861                });
8862            }
8863        }
8864
8865        for vertex in &legacy_vertices {
8866            let Some(cell) = self.graph.get_cell_ref_for_vertex(*vertex) else {
8867                continue;
8868            };
8869            let result_region = Region::point(cell.sheet_id, cell.coord.row(), cell.coord.col());
8870            let mut seen = rustc_hash::FxHashSet::default();
8871            for dep in self.graph.get_dependencies(*vertex) {
8872                let Some(dep_cell) = self.graph.get_cell_ref_for_vertex(dep) else {
8873                    continue;
8874                };
8875                let read_region = Region::point(
8876                    dep_cell.sheet_id,
8877                    dep_cell.coord.row(),
8878                    dep_cell.coord.col(),
8879                );
8880                if seen.insert(read_region) {
8881                    consumer_reads.insert_read(
8882                        FormulaProducerId::Legacy(*vertex),
8883                        read_region,
8884                        result_region,
8885                        DirtyProjectionRule::WholeResult,
8886                    );
8887                }
8888            }
8889            if let Some(ranges) = self.graph.get_range_dependencies(*vertex) {
8890                for range in ranges {
8891                    let Some(read_region) = self.shared_range_to_region_pattern(range)? else {
8892                        continue;
8893                    };
8894                    if seen.insert(read_region) {
8895                        consumer_reads.insert_read(
8896                            FormulaProducerId::Legacy(*vertex),
8897                            read_region,
8898                            result_region,
8899                            DirtyProjectionRule::WholeResult,
8900                        );
8901                    }
8902                }
8903            }
8904        }
8905
8906        // When span seed mode is DirtyClosure, derive bounded span work from
8907        // captured changed regions via the consumer-read index. This avoids
8908        // recomputing every active span on edits that only touch a small
8909        // number of cells.
8910        if matches!(span_seed_mode, SpanSeedMode::DirtyClosure)
8911            && !pending_changed_regions.is_empty()
8912        {
8913            use crate::formula_plane::producer::compute_dirty_closure;
8914            let producer_results_ref = &producer_results;
8915            let closure = compute_dirty_closure(
8916                &consumer_reads,
8917                pending_changed_regions.iter().copied(),
8918                |producer| producer_results_ref.producer_result_region(producer),
8919            );
8920            for fallback_work in closure.work {
8921                work.push(fallback_work);
8922            }
8923            // Any unsupported/conservative fallbacks for spans imply we may have
8924            // missed work; in that case demote to whole-span for affected spans.
8925            if !closure.fallbacks.is_empty() {
8926                let mut already_whole: rustc_hash::FxHashSet<_> = work
8927                    .iter()
8928                    .filter_map(|w| match (w.producer, &w.dirty) {
8929                        (FormulaProducerId::Span(id), ProducerDirtyDomain::Whole) => Some(id),
8930                        _ => None,
8931                    })
8932                    .collect();
8933                for fb in &closure.fallbacks {
8934                    if let FormulaProducerId::Span(id) = fb.consumer
8935                        && already_whole.insert(id)
8936                    {
8937                        work.push(FormulaProducerWork {
8938                            producer: FormulaProducerId::Span(id),
8939                            dirty: ProducerDirtyDomain::Whole,
8940                        });
8941                    }
8942                }
8943            }
8944        }
8945
8946        let schedule = build_mixed_schedule(work, &producer_results, &consumer_reads);
8947        Ok((
8948            schedule,
8949            span_refs_by_id,
8950            authority.plane.epoch().0,
8951            scheduled_legacy_vertices,
8952        ))
8953    }
8954}
8955
8956/// Strategy for seeding span producer work in the FP mixed runtime.
8957/// `WholeAll` schedules every active span as `Whole`; `DirtyClosure`
8958/// computes bounded work from captured changed regions only.
8959#[derive(Clone, Copy, Debug)]
8960enum SpanSeedMode {
8961    WholeAll,
8962    DirtyClosure,
8963}
8964
8965impl<R> Engine<R>
8966where
8967    R: EvaluationContext,
8968{
8969    fn shared_range_to_region_pattern(
8970        &self,
8971        range: &crate::reference::SharedRangeRef<'static>,
8972    ) -> Result<Option<Region>, ExcelError> {
8973        use crate::reference::SharedSheetLocator;
8974        let sheet_id = match range.sheet {
8975            SharedSheetLocator::Id(id) => id,
8976            SharedSheetLocator::Current => self.graph.default_sheet_id(),
8977            SharedSheetLocator::Name(_) => return Ok(None),
8978        };
8979        match (
8980            range.start_row,
8981            range.end_row,
8982            range.start_col,
8983            range.end_col,
8984        ) {
8985            (Some(sr), Some(er), Some(sc), Some(ec)) => Ok(Some(Region::rect(
8986                sheet_id, sr.index, er.index, sc.index, ec.index,
8987            ))),
8988            (None, None, Some(sc), Some(ec)) if sc.index == ec.index => {
8989                Ok(Some(Region::whole_col(sheet_id, sc.index)))
8990            }
8991            (Some(sr), Some(er), None, None) if sr.index == er.index => {
8992                Ok(Some(Region::whole_row(sheet_id, sr.index)))
8993            }
8994            _ => Ok(None),
8995        }
8996    }
8997
8998    /// Evaluate all dirty/volatile vertices
8999    pub fn evaluate_all(&mut self) -> Result<EvalResult, ExcelError> {
9000        debug_assert!(
9001            !self.graph.deferred_dirty_active(),
9002            "deferred-dirty scope leaked into evaluate_all: a begin_deferred_dirty \
9003             was not balanced by end_deferred_dirty"
9004        );
9005        self.lookup_index_cache.reset_counters();
9006        let _source_cache = self.source_cache_session();
9007        self.validate_deterministic_mode()?;
9008        if self.config.defer_graph_building {
9009            // Build graph for all staged formulas before evaluating
9010            self.build_graph_all()?;
9011        }
9012        self.evaluate_all_coordinator()
9013    }
9014
9015    /// Central FormulaPlane-aware coordinator for `evaluate_all`. In
9016    /// `AuthoritativeExperimental` mode every call enters the FormulaPlane
9017    /// coordinator; the coordinator itself composes with private legacy
9018    /// primitives for legacy-only work.
9019    fn evaluate_all_coordinator(&mut self) -> Result<EvalResult, ExcelError> {
9020        self.begin_evaluation_request();
9021        if self.config.formula_plane_mode == FormulaPlaneMode::AuthoritativeExperimental {
9022            return self.evaluate_authoritative_formula_plane_all();
9023        }
9024        self.evaluate_all_legacy_impl()
9025    }
9026
9027    /// Walk a schedule's units in condensation order: stamp each cyclic SCC
9028    /// at its position and evaluate each layer (parallel when enabled).
9029    ///
9030    /// Returns `(computed_vertices, cycle_count)` where `cycle_count` is the
9031    /// number of Cycle units walked (the former `schedule.cycles.len()`).
9032    fn legacy_pass_run_units(
9033        &mut self,
9034        schedule: &crate::engine::scheduler::Schedule,
9035    ) -> Result<(usize, usize), ExcelError> {
9036        let mut computed_vertices = 0;
9037        let mut cycle_count = 0;
9038        for &unit in &schedule.units {
9039            match unit {
9040                ScheduleUnit::Cycle(i) => {
9041                    if self.handle_cycle_unit(schedule.unit_cycle(i), None, None, None)? > 0 {
9042                        cycle_count += 1;
9043                    }
9044                }
9045                ScheduleUnit::Layer(i) => {
9046                    let layer = schedule.unit_layer(i);
9047                    if self.thread_pool.is_some() && layer.vertices.len() > 1 {
9048                        computed_vertices += self.evaluate_layer_parallel(layer)?;
9049                    } else {
9050                        computed_vertices += self.evaluate_layer_sequential(layer)?;
9051                    }
9052                }
9053            }
9054        }
9055        Ok((computed_vertices, cycle_count))
9056    }
9057
9058    /// Legacy `evaluate_all` body, reachable from the FormulaPlane coordinator
9059    /// when no active spans exist or FormulaPlane authority is not in
9060    /// `AuthoritativeExperimental` mode. This is now an internal primitive; it
9061    /// must not be invoked directly from public APIs.
9062    ///
9063    /// Does NOT call `begin_evaluation_request` (cycle-telemetry reset +
9064    /// per-recalc clock sample): the FormulaPlane coordinator composes this
9065    /// primitive *after* `evaluate_legacy_cycle_prepass` may have accumulated
9066    /// counts (G8 demotion path), and both sub-passes belong to ONE request /
9067    /// one clock sample; request begin happens at the public entry points /
9068    /// coordinators instead.
9069    fn evaluate_all_legacy_impl(&mut self) -> Result<EvalResult, ExcelError> {
9070        self.reset_virtual_dep_telemetry_if_disabled();
9071        #[cfg(feature = "tracing")]
9072        let _span_eval = tracing::info_span!("evaluate_all").entered();
9073        let start = crate::instant::FzInstant::now();
9074        let mut computed_vertices = 0;
9075        let mut cycle_errors = 0;
9076        let mut replan_iterations = 0;
9077        const MAX_REPLAN: usize = 5;
9078        let mut telemetry = self
9079            .config
9080            .enable_virtual_dep_telemetry
9081            .then(|| self.start_virtual_dep_telemetry());
9082
9083        loop {
9084            let to_evaluate = self.graph.get_evaluation_vertices();
9085            if to_evaluate.is_empty() {
9086                if let Some(t) = telemetry.as_mut()
9087                    && t.bailout_reason.is_none()
9088                {
9089                    t.bailout_reason = Some("no_work");
9090                }
9091                break;
9092            }
9093
9094            let (schedule, old_vdeps, meta) = self.create_evaluation_schedule(&to_evaluate)?;
9095            if let Some(t) = telemetry.as_mut() {
9096                Self::accumulate_schedule_meta(t, &meta);
9097            }
9098
9099            let (pass_computed, pass_cycles) = self.legacy_pass_run_units(&schedule)?;
9100            computed_vertices += pass_computed;
9101            cycle_errors += pass_cycles;
9102
9103            // Check if dynamic dependencies changed
9104            let changed_vertices = self.changed_virtual_dep_vertices(&to_evaluate, &old_vdeps);
9105            if let Some(t) = telemetry.as_mut() {
9106                t.changed_vdeps_total += changed_vertices.len();
9107            }
9108
9109            self.graph.clear_dirty_flags(&to_evaluate);
9110            for v in &changed_vertices {
9111                self.graph.set_dirty(*v, true);
9112            }
9113
9114            if changed_vertices.is_empty() {
9115                if let Some(t) = telemetry.as_mut() {
9116                    t.bailout_reason = Some("converged");
9117                }
9118                break;
9119            }
9120            if replan_iterations >= MAX_REPLAN {
9121                if let Some(t) = telemetry.as_mut() {
9122                    t.bailout_reason = Some("max_replan");
9123                }
9124                break;
9125            }
9126
9127            replan_iterations += 1;
9128        }
9129
9130        if let Some(mut t) = telemetry {
9131            t.replan_iterations = replan_iterations;
9132            self.last_virtual_dep_telemetry = t;
9133        }
9134
9135        // Re-dirty volatile vertices for the next evaluation cycle
9136        self.redirty_for_next_recalc();
9137
9138        // Advance recalc epoch after a full evaluation pass finishes
9139        self.recalc_epoch = self.recalc_epoch.wrapping_add(1);
9140
9141        Ok(EvalResult {
9142            computed_vertices,
9143            cycle_errors,
9144            elapsed: start.elapsed(),
9145        })
9146    }
9147
9148    pub fn evaluate_all_with_delta(&mut self) -> Result<(EvalResult, EvalDelta), ExcelError> {
9149        let mut collector = DeltaCollector::new(DeltaMode::Cells);
9150        let result = self.evaluate_all_with_delta_collector(&mut collector)?;
9151        Ok((result, collector.finish()))
9152    }
9153
9154    fn evaluate_all_with_delta_collector(
9155        &mut self,
9156        delta: &mut DeltaCollector,
9157    ) -> Result<EvalResult, ExcelError> {
9158        self.begin_evaluation_request();
9159        let _source_cache = self.source_cache_session();
9160        if self.config.defer_graph_building {
9161            self.build_graph_all()?;
9162        }
9163        if self.graph.formula_authority().active_span_count() > 0 {
9164            let _ = delta;
9165            return self.evaluate_authoritative_formula_plane_all();
9166        }
9167        self.reset_virtual_dep_telemetry_if_disabled();
9168        #[cfg(feature = "tracing")]
9169        let _span_eval = tracing::info_span!("evaluate_all_with_delta").entered();
9170        let start = crate::instant::FzInstant::now();
9171        let mut computed_vertices = 0;
9172        let mut cycle_errors = 0;
9173
9174        let mut replan_iterations = 0;
9175        const MAX_REPLAN: usize = 5;
9176        let mut telemetry = self
9177            .config
9178            .enable_virtual_dep_telemetry
9179            .then(|| self.start_virtual_dep_telemetry());
9180
9181        loop {
9182            let to_evaluate = self.graph.get_evaluation_vertices();
9183            if to_evaluate.is_empty() {
9184                if let Some(t) = telemetry.as_mut()
9185                    && t.bailout_reason.is_none()
9186                {
9187                    t.bailout_reason = Some("no_work");
9188                }
9189                break;
9190            }
9191
9192            let (schedule, old_vdeps, meta) = self.create_evaluation_schedule(&to_evaluate)?;
9193            if let Some(t) = telemetry.as_mut() {
9194                Self::accumulate_schedule_meta(t, &meta);
9195            }
9196
9197            for &unit in &schedule.units {
9198                match unit {
9199                    ScheduleUnit::Cycle(i) => {
9200                        if self.handle_cycle_unit(
9201                            schedule.unit_cycle(i),
9202                            Some(delta),
9203                            None,
9204                            None,
9205                        )? > 0
9206                        {
9207                            cycle_errors += 1;
9208                        }
9209                    }
9210                    ScheduleUnit::Layer(i) => {
9211                        let layer = schedule.unit_layer(i);
9212                        if self.thread_pool.is_some() && layer.vertices.len() > 1 {
9213                            computed_vertices +=
9214                                self.evaluate_layer_parallel_with_delta(layer, delta)?;
9215                        } else {
9216                            computed_vertices +=
9217                                self.evaluate_layer_sequential_with_delta(layer, delta)?;
9218                        }
9219                    }
9220                }
9221            }
9222
9223            let changed_vertices = self.changed_virtual_dep_vertices(&to_evaluate, &old_vdeps);
9224            if let Some(t) = telemetry.as_mut() {
9225                t.changed_vdeps_total += changed_vertices.len();
9226            }
9227            self.graph.clear_dirty_flags(&to_evaluate);
9228            for v in &changed_vertices {
9229                self.graph.set_dirty(*v, true);
9230            }
9231
9232            if changed_vertices.is_empty() {
9233                if let Some(t) = telemetry.as_mut() {
9234                    t.bailout_reason = Some("converged");
9235                }
9236                break;
9237            }
9238            if replan_iterations >= MAX_REPLAN {
9239                if let Some(t) = telemetry.as_mut() {
9240                    t.bailout_reason = Some("max_replan");
9241                }
9242                break;
9243            }
9244            replan_iterations += 1;
9245        }
9246
9247        if let Some(mut t) = telemetry {
9248            t.replan_iterations = replan_iterations;
9249            self.last_virtual_dep_telemetry = t;
9250        }
9251
9252        self.redirty_for_next_recalc();
9253        self.recalc_epoch = self.recalc_epoch.wrapping_add(1);
9254
9255        Ok(EvalResult {
9256            computed_vertices,
9257            cycle_errors,
9258            elapsed: start.elapsed(),
9259        })
9260    }
9261
9262    /// Convenience: demand-driven evaluation of a single cell by sheet name and row/col.
9263    ///
9264    /// This will evaluate only the minimal set of dirty / volatile precedents required
9265    /// to bring the target cell up-to-date (as if a user asked for that single value),
9266    /// rather than scheduling a full workbook recalc. If the cell is already clean and
9267    /// non-volatile, no vertices will be recomputed.
9268    ///
9269    /// Returns the (possibly newly computed) value stored for the cell afterwards.
9270    /// Empty cells return None. Errors are surfaced via the Result type.
9271    pub fn evaluate_cell(
9272        &mut self,
9273        sheet: &str,
9274        row: u32,
9275        col: u32,
9276    ) -> Result<Option<LiteralValue>, ExcelError> {
9277        if row == 0 || col == 0 {
9278            return Err(ExcelError::new(ExcelErrorKind::Ref)
9279                .with_message("Row and column must be >= 1".to_string()));
9280        }
9281
9282        // ``defer_graph_building`` mode stages formulas during bulk load
9283        // and lazily promotes them into the dependency graph at evaluate
9284        // time. Per-cell evaluation must drain *all* staged sheets, not
9285        // just the requested target — a cell's formula can reference
9286        // any sheet in the workbook, and a cross-sheet ref to a still-
9287        // staged source would silently evaluate to ``None`` if that
9288        // source sheet hadn't been promoted yet.
9289        if self.config.defer_graph_building {
9290            self.build_graph_all()?;
9291        }
9292
9293        let result = self.evaluate_cells(&[(sheet, row, col)])?;
9294
9295        match result.len() {
9296            0 => Ok(None),
9297            1 => {
9298                let v = result.into_iter().next().unwrap();
9299                Ok(v)
9300            }
9301            _ => unreachable!("evaluate_cells returned unexpected length"),
9302        }
9303    }
9304
9305    /// Convenience: demand-driven evaluation of multiple cells; accepts a slice of
9306    /// (sheet, row, col) triples. The union of required dirty / volatile precedents
9307    /// is computed once and evaluated, which is typically faster than calling
9308    /// `evaluate_cell` repeatedly for a related set of targets.
9309    ///
9310    /// Returns the resulting values for each requested target in the same order.
9311    pub fn evaluate_cells(
9312        &mut self,
9313        targets: &[(&str, u32, u32)],
9314    ) -> Result<Vec<Option<LiteralValue>>, ExcelError> {
9315        debug_assert!(
9316            !self.graph.deferred_dirty_active(),
9317            "deferred-dirty scope leaked into evaluate_cells: a begin_deferred_dirty \
9318             was not balanced by end_deferred_dirty"
9319        );
9320        self.validate_deterministic_mode()?;
9321        if targets.is_empty() {
9322            return Ok(Vec::new());
9323        }
9324        // See ``evaluate_cell`` for why we drain *all* staged sheets in
9325        // ``defer_graph_building`` mode: cross-sheet refs to still-staged
9326        // sources would otherwise evaluate to ``None``.
9327        if self.config.defer_graph_building {
9328            self.build_graph_all()?;
9329        }
9330        if self.graph.formula_authority().active_span_count() > 0 {
9331            let _ = self.evaluate_authoritative_formula_plane_all()?;
9332        } else {
9333            self.evaluate_until(targets)?;
9334        }
9335        Ok(targets
9336            .iter()
9337            .map(|(s, r, c)| self.get_cell_value(s, *r, *c))
9338            .collect())
9339    }
9340
9341    pub fn evaluate_cells_cancellable(
9342        &mut self,
9343        targets: &[(&str, u32, u32)],
9344        cancel_flag: Arc<AtomicBool>,
9345    ) -> Result<Vec<Option<LiteralValue>>, ExcelError> {
9346        self.active_cancel_flag = Some(cancel_flag.clone());
9347        let res = self.evaluate_cells_cancellable_impl(targets, &cancel_flag);
9348        self.active_cancel_flag = None;
9349        res
9350    }
9351
9352    fn evaluate_cells_cancellable_impl(
9353        &mut self,
9354        targets: &[(&str, u32, u32)],
9355        cancel_flag: &AtomicBool,
9356    ) -> Result<Vec<Option<LiteralValue>>, ExcelError> {
9357        self.validate_deterministic_mode()?;
9358        if targets.is_empty() {
9359            return Ok(Vec::new());
9360        }
9361        // See ``evaluate_cell`` for why we drain *all* staged sheets in
9362        // ``defer_graph_building`` mode: cross-sheet refs to still-staged
9363        // sources would otherwise evaluate to ``None``.
9364        if self.config.defer_graph_building {
9365            self.build_graph_all()?;
9366        }
9367        if self.graph.formula_authority().active_span_count() > 0 {
9368            if cancel_flag.load(Ordering::Relaxed) {
9369                return Err(ExcelError::new(ExcelErrorKind::Cancelled).with_message(
9370                    "Evaluation cancelled before FormulaPlane scheduling".to_string(),
9371                ));
9372            }
9373            let _ = self.evaluate_authoritative_formula_plane_all()?;
9374            return Ok(targets
9375                .iter()
9376                .map(|(s, r, c)| self.get_cell_value(s, *r, *c))
9377                .collect());
9378        }
9379
9380        // evaluate_until_cancellable takes &[&str] in A1 notation, but we have (&str, u32, u32)
9381        // Let's implement evaluate_until_coords_cancellable or similar, or just convert
9382        let a1_targets: Vec<String> = targets
9383            .iter()
9384            .map(|(s, r, c)| {
9385                format!("{}!{}", s, col_letters_from_1based(*c).unwrap()) + &r.to_string()
9386            })
9387            .collect();
9388        let a1_refs: Vec<&str> = a1_targets.iter().map(|s| s.as_str()).collect();
9389
9390        self.evaluate_until_cancellable_impl(&a1_refs, cancel_flag)?;
9391
9392        Ok(targets
9393            .iter()
9394            .map(|(s, r, c)| self.get_cell_value(s, *r, *c))
9395            .collect())
9396    }
9397
9398    pub fn evaluate_cells_with_delta(
9399        &mut self,
9400        targets: &[(&str, u32, u32)],
9401    ) -> Result<(Vec<Option<LiteralValue>>, EvalDelta), ExcelError> {
9402        self.validate_deterministic_mode()?;
9403        if targets.is_empty() {
9404            return Ok((Vec::new(), EvalDelta::default()));
9405        }
9406        if self.config.defer_graph_building {
9407            let mut sheets: rustc_hash::FxHashSet<&str> = rustc_hash::FxHashSet::default();
9408            for (s, _, _) in targets.iter() {
9409                sheets.insert(*s);
9410            }
9411            self.build_graph_for_sheets(sheets.iter().cloned())?;
9412        }
9413        if self.graph.formula_authority().active_span_count() > 0 {
9414            let _ = self.evaluate_authoritative_formula_plane_all()?;
9415            let values = targets
9416                .iter()
9417                .map(|(s, r, c)| self.get_cell_value(s, *r, *c))
9418                .collect();
9419            return Ok((values, EvalDelta::default()));
9420        }
9421        let mut collector = DeltaCollector::new(DeltaMode::Cells);
9422        self.evaluate_until_with_delta_collector(targets, &mut collector)?;
9423        let values = targets
9424            .iter()
9425            .map(|(s, r, c)| self.get_cell_value(s, *r, *c))
9426            .collect();
9427        Ok((values, collector.finish()))
9428    }
9429
9430    /// Get the evaluation plan for target cells without actually evaluating them
9431    pub fn get_eval_plan(&self, targets: &[(&str, u32, u32)]) -> Result<EvalPlan, ExcelError> {
9432        if targets.is_empty() {
9433            return Ok(EvalPlan {
9434                total_vertices_to_evaluate: 0,
9435                layers: Vec::new(),
9436                cycles_detected: 0,
9437                dirty_count: 0,
9438                volatile_count: 0,
9439                parallel_enabled: self.config.enable_parallel && self.thread_pool.is_some(),
9440                estimated_parallel_layers: 0,
9441                target_cells: Vec::new(),
9442            });
9443        }
9444        if self.config.defer_graph_building && self.has_staged_formulas() {
9445            return Err(ExcelError::new(ExcelErrorKind::Value).with_message(
9446                "Evaluation plan requested with deferred graph; build first or call evaluate_*",
9447            ));
9448        }
9449
9450        // Convert targets to A1 notation for consistency
9451        let addresses: Vec<String> = targets
9452            .iter()
9453            .map(|(s, r, c)| format!("{}!{}{}", s, Self::col_to_letters(*c), r))
9454            .collect();
9455
9456        // Parse target cell addresses
9457        let mut target_addrs = Vec::new();
9458        for (sheet, row, col) in targets {
9459            if let Some(sheet_id) = self.graph.sheet_id(sheet) {
9460                let coord = Coord::from_excel(*row, *col, true, true);
9461                target_addrs.push(CellRef::new(sheet_id, coord));
9462            }
9463        }
9464
9465        // Find vertex IDs for targets
9466        let mut target_vertex_ids = Vec::new();
9467        for addr in &target_addrs {
9468            if let Some(vertex_id) = self.graph.get_vertex_id_for_address(addr) {
9469                target_vertex_ids.push(*vertex_id);
9470            }
9471        }
9472
9473        if target_vertex_ids.is_empty() {
9474            return Ok(EvalPlan {
9475                total_vertices_to_evaluate: 0,
9476                layers: Vec::new(),
9477                cycles_detected: 0,
9478                dirty_count: 0,
9479                volatile_count: 0,
9480                parallel_enabled: self.config.enable_parallel && self.thread_pool.is_some(),
9481                estimated_parallel_layers: 0,
9482                target_cells: addresses,
9483            });
9484        }
9485
9486        // Build demand subgraph with virtual edges (same as evaluate_until)
9487        let (precedents_to_eval, vdeps) = self.build_demand_subgraph(&target_vertex_ids);
9488
9489        if precedents_to_eval.is_empty() {
9490            return Ok(EvalPlan {
9491                total_vertices_to_evaluate: 0,
9492                layers: Vec::new(),
9493                cycles_detected: 0,
9494                dirty_count: 0,
9495                volatile_count: 0,
9496                parallel_enabled: self.config.enable_parallel && self.thread_pool.is_some(),
9497                estimated_parallel_layers: 0,
9498                target_cells: addresses,
9499            });
9500        }
9501
9502        // Count dirty and volatile vertices
9503        let mut dirty_count = 0;
9504        let mut volatile_count = 0;
9505        for &vertex_id in &precedents_to_eval {
9506            if self.graph.is_dirty(vertex_id) {
9507                dirty_count += 1;
9508            }
9509            if self.graph.is_volatile(vertex_id) {
9510                volatile_count += 1;
9511            }
9512        }
9513
9514        // Create schedule for the minimal subgraph honoring virtual edges
9515        let scheduler = Scheduler::new(&self.graph);
9516        let schedule = scheduler.create_schedule_with_virtual(&precedents_to_eval, &vdeps)?;
9517
9518        // Build layer information
9519        let mut layers = Vec::new();
9520        let mut estimated_parallel_layers = 0;
9521        let parallel_enabled = self.config.enable_parallel && self.thread_pool.is_some();
9522
9523        for layer in &schedule.layers {
9524            let parallel_eligible = parallel_enabled && layer.vertices.len() > 1;
9525            if parallel_eligible {
9526                estimated_parallel_layers += 1;
9527            }
9528
9529            // Get sample cell addresses (up to 5)
9530            let sample_cells: Vec<String> = layer
9531                .vertices
9532                .iter()
9533                .take(5)
9534                .filter_map(|&vertex_id| {
9535                    self.graph
9536                        .get_cell_ref_for_vertex(vertex_id)
9537                        .map(|cell_ref| {
9538                            let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
9539                            format!(
9540                                "{}!{}{}",
9541                                sheet_name,
9542                                Self::col_to_letters(cell_ref.coord.col()),
9543                                cell_ref.coord.row() + 1
9544                            )
9545                        })
9546                })
9547                .collect();
9548
9549            layers.push(LayerInfo {
9550                vertex_count: layer.vertices.len(),
9551                parallel_eligible,
9552                sample_cells,
9553            });
9554        }
9555
9556        Ok(EvalPlan {
9557            total_vertices_to_evaluate: precedents_to_eval.len(),
9558            layers,
9559            cycles_detected: schedule.cycles.len(),
9560            dirty_count,
9561            volatile_count,
9562            parallel_enabled,
9563            estimated_parallel_layers,
9564            target_cells: addresses,
9565        })
9566    }
9567    /// Helper to create a schedule, integrating virtual dependencies automatically.
9568    fn create_evaluation_schedule(
9569        &mut self,
9570        to_evaluate: &[VertexId],
9571    ) -> Result<ScheduleBuildOutput, ExcelError> {
9572        // Fold pending edge deltas once per schedule build so traversal uses
9573        // the zero-allocation CSR slices (#125).
9574        self.graph.flush_pending_edge_deltas();
9575        if self.can_use_static_schedule_cache(to_evaluate) {
9576            if let Some(cached) = self.cached_static_schedule.as_ref()
9577                && cached.topology_epoch == self.topology_epoch
9578                && cached.candidate_vertices.as_slice() == to_evaluate
9579            {
9580                let meta = ScheduleBuildMeta {
9581                    candidate_vertices: to_evaluate.len(),
9582                    vdeps_vertices: 0,
9583                    vdeps_edges: 0,
9584                    builder_elapsed_ms: 0,
9585                    used_virtual_schedule: false,
9586                    schedule_cache_hit: true,
9587                    schedule_cache_eligible: true,
9588                };
9589                return Ok((cached.schedule.clone(), FxHashMap::default(), meta));
9590            }
9591
9592            let (schedule, vdeps, mut meta) =
9593                self.create_evaluation_schedule_uncached(to_evaluate)?;
9594            meta.schedule_cache_hit = false;
9595            meta.schedule_cache_eligible = true;
9596            if vdeps.is_empty() {
9597                self.cached_static_schedule = Some(CachedScheduleEntry {
9598                    topology_epoch: self.topology_epoch,
9599                    candidate_vertices: to_evaluate.to_vec(),
9600                    schedule: schedule.clone(),
9601                });
9602            }
9603            return Ok((schedule, vdeps, meta));
9604        }
9605
9606        let (schedule, vdeps, mut meta) = self.create_evaluation_schedule_uncached(to_evaluate)?;
9607        meta.schedule_cache_hit = false;
9608        meta.schedule_cache_eligible = false;
9609        Ok((schedule, vdeps, meta))
9610    }
9611
9612    fn create_evaluation_schedule_uncached(
9613        &self,
9614        to_evaluate: &[VertexId],
9615    ) -> Result<ScheduleBuildOutput, ExcelError> {
9616        let builder = VirtualDepBuilder::new(self);
9617        let (vdeps, augmented, builder_elapsed_ms, vdeps_edges) =
9618            if self.config.enable_virtual_dep_telemetry {
9619                let build_started = crate::instant::FzInstant::now();
9620                let (vdeps, augmented) = builder.build(to_evaluate);
9621                let builder_elapsed_ms = build_started.elapsed().as_millis();
9622                let vdeps_edges = vdeps.values().map(|deps| deps.len()).sum::<usize>();
9623                (vdeps, augmented, builder_elapsed_ms, vdeps_edges)
9624            } else {
9625                let (vdeps, augmented) = builder.build(to_evaluate);
9626                (vdeps, augmented, 0, 0)
9627            };
9628
9629        let mut final_evaluate = to_evaluate.to_vec();
9630        if !augmented.is_empty() {
9631            final_evaluate.extend(augmented);
9632            final_evaluate.sort_unstable();
9633            final_evaluate.dedup();
9634        }
9635
9636        let use_virtual = !vdeps.is_empty();
9637
9638        let scheduler = Scheduler::new(&self.graph);
9639        let schedule = if use_virtual {
9640            scheduler.create_schedule_with_virtual(&final_evaluate, &vdeps)?
9641        } else {
9642            scheduler.create_schedule(&final_evaluate)?
9643        };
9644
9645        let meta = ScheduleBuildMeta {
9646            candidate_vertices: to_evaluate.len(),
9647            vdeps_vertices: vdeps.len(),
9648            vdeps_edges,
9649            builder_elapsed_ms,
9650            used_virtual_schedule: use_virtual,
9651            schedule_cache_hit: false,
9652            schedule_cache_eligible: false,
9653        };
9654
9655        Ok((schedule, vdeps, meta))
9656    }
9657
9658    fn can_use_static_schedule_cache(&self, to_evaluate: &[VertexId]) -> bool {
9659        !to_evaluate.is_empty()
9660            && to_evaluate.iter().copied().all(|v| {
9661                !self.graph.is_dynamic(v) && self.graph.get_range_dependencies(v).is_none()
9662            })
9663    }
9664
9665    fn start_virtual_dep_telemetry(&self) -> VirtualDepTelemetry {
9666        VirtualDepTelemetry {
9667            fallback_mode_activations: self.virtual_dep_fallback_activations,
9668            ..VirtualDepTelemetry::default()
9669        }
9670    }
9671
9672    fn accumulate_schedule_meta(telemetry: &mut VirtualDepTelemetry, meta: &ScheduleBuildMeta) {
9673        telemetry.candidate_vertices_total += meta.candidate_vertices;
9674        telemetry.vdeps_vertices_total += meta.vdeps_vertices;
9675        telemetry.vdeps_edges_total += meta.vdeps_edges;
9676        telemetry.builder_elapsed_ms_total += meta.builder_elapsed_ms;
9677        if meta.schedule_cache_eligible {
9678            if meta.schedule_cache_hit {
9679                telemetry.schedule_cache_hits += 1;
9680                telemetry.reused_schedule_vertices_total += meta.candidate_vertices;
9681            } else {
9682                telemetry.schedule_cache_misses += 1;
9683            }
9684        }
9685        if meta.used_virtual_schedule {
9686            telemetry.schedule_virtual_passes += 1;
9687        } else {
9688            telemetry.schedule_static_passes += 1;
9689        }
9690    }
9691
9692    fn changed_virtual_dep_vertices(
9693        &self,
9694        to_evaluate: &[VertexId],
9695        old_vdeps: &FxHashMap<VertexId, Vec<VertexId>>,
9696    ) -> Vec<VertexId> {
9697        if !to_evaluate
9698            .iter()
9699            .copied()
9700            .any(|v| self.graph.is_dynamic(v))
9701        {
9702            return Vec::new();
9703        }
9704
9705        let builder = VirtualDepBuilder::new(self);
9706        let (new_vdeps, _) = builder.build(to_evaluate);
9707
9708        let mut candidates = FxHashSet::default();
9709        candidates.extend(old_vdeps.keys().copied());
9710        candidates.extend(new_vdeps.keys().copied());
9711
9712        let mut changed = Vec::new();
9713        for v in candidates {
9714            if old_vdeps.get(&v) != new_vdeps.get(&v) {
9715                changed.push(v);
9716            }
9717        }
9718        changed
9719    }
9720
9721    /// Build a demand-driven subgraph for the given targets, including ephemeral edges for
9722    /// compressed ranges, and returning the set of dirty/volatile precedents and virtual deps.
9723    fn build_demand_subgraph(
9724        &self,
9725        target_vertices: &[VertexId],
9726    ) -> (
9727        Vec<VertexId>,
9728        rustc_hash::FxHashMap<VertexId, Vec<VertexId>>,
9729    ) {
9730        #[cfg(feature = "tracing")]
9731        let _span =
9732            tracing::info_span!("demand_subgraph", targets = target_vertices.len()).entered();
9733        use rustc_hash::{FxHashMap, FxHashSet};
9734
9735        let mut to_evaluate: FxHashSet<VertexId> = FxHashSet::default();
9736        let mut visited: FxHashSet<VertexId> = FxHashSet::default();
9737        let mut stack: Vec<VertexId> = Vec::new();
9738        let mut vdeps: FxHashMap<VertexId, Vec<VertexId>> = FxHashMap::default(); // incoming deps per vertex
9739
9740        for &t in target_vertices {
9741            stack.push(t);
9742        }
9743
9744        while let Some(v) = stack.pop() {
9745            if !visited.insert(v) {
9746                continue;
9747            }
9748            if !self.graph.vertex_exists(v) {
9749                continue;
9750            }
9751            // Schedule dirty/volatile formulas. Also schedule pass-through
9752            // Named*/Range vertices so the scheduler honours the
9753            // topological position of any formula cells that sit underneath
9754            // them — without these in `vertex_set` the scheduler skips the
9755            // edges that route a target through a named-range vertex into
9756            // its underlying cells, and the underlying cells then end up
9757            // in the same (or an earlier) layer as the target.
9758            match self.graph.get_vertex_kind(v) {
9759                VertexKind::FormulaScalar | VertexKind::FormulaArray => {
9760                    if self.graph.is_dirty(v) || self.graph.is_volatile(v) {
9761                        to_evaluate.insert(v);
9762                    }
9763                }
9764                VertexKind::NamedScalar
9765                | VertexKind::NamedArray
9766                | VertexKind::Range
9767                | VertexKind::InfiniteRange => {
9768                    to_evaluate.insert(v);
9769                }
9770                _ => {}
9771            }
9772
9773            // Explicit dependencies (graph edges). We push *every* dep onto
9774            // the stack — not just formulas — because intermediate vertices
9775            // (NamedScalar, NamedArray, Range) are pass-through nodes whose
9776            // own dependencies point at the actual formula cells. Filtering
9777            // by kind here previously caused DN-range refs to be dropped
9778            // from the demand subgraph, so a target like
9779            // ``=SUM(named_range_pointing_at_dirty_cells)`` would evaluate
9780            // using stale values for those cells. The kind check at the top
9781            // of the loop still gates which vertices end up in
9782            // ``to_evaluate``; only Formula vertices are scheduled.
9783            if let Some(dependencies) = self.graph.dependencies_slice(v) {
9784                for &dep in dependencies {
9785                    if self.graph.vertex_exists(dep) && !visited.contains(&dep) {
9786                        stack.push(dep);
9787                    }
9788                }
9789            } else {
9790                for dep in self.graph.get_dependencies(v) {
9791                    if self.graph.vertex_exists(dep) && !visited.contains(&dep) {
9792                        stack.push(dep);
9793                    }
9794                }
9795            } // Virtual dependencies (compressed ranges + dynamic like INDIRECT)
9796            let builder = VirtualDepBuilder::new(self);
9797            let (vdeps_map, _) = builder.build(&[v]);
9798            if let Some(deps) = vdeps_map.get(&v) {
9799                for &u in deps {
9800                    vdeps.entry(v).or_default().push(u);
9801                    if !visited.contains(&u) {
9802                        stack.push(u);
9803                    }
9804                }
9805            }
9806        }
9807
9808        let mut result: Vec<VertexId> = to_evaluate.into_iter().collect();
9809        result.sort_unstable();
9810        // Dedup virtual deps
9811        for deps in vdeps.values_mut() {
9812            deps.sort_unstable();
9813            deps.dedup();
9814        }
9815        (result, vdeps)
9816    }
9817
9818    /// Helper: convert 1-based column index to Excel-style letters (1 -> A, 27 -> AA)
9819    fn col_to_letters(col: u32) -> String {
9820        col_letters_from_1based(col).expect("column index must be >= 1")
9821    }
9822
9823    /// Evaluate all dirty/volatile vertices with cancellation support
9824    pub fn evaluate_all_cancellable(
9825        &mut self,
9826        cancel_flag: Arc<AtomicBool>,
9827    ) -> Result<EvalResult, ExcelError> {
9828        self.active_cancel_flag = Some(cancel_flag.clone());
9829        let res = self.evaluate_all_cancellable_impl(&cancel_flag);
9830        self.active_cancel_flag = None;
9831        res
9832    }
9833
9834    fn evaluate_all_cancellable_impl(
9835        &mut self,
9836        cancel_flag: &AtomicBool,
9837    ) -> Result<EvalResult, ExcelError> {
9838        self.begin_evaluation_request();
9839        let _source_cache = self.source_cache_session();
9840        self.validate_deterministic_mode()?;
9841        if self.config.defer_graph_building {
9842            self.build_graph_all()?;
9843        }
9844        if self.graph.formula_authority().active_span_count() > 0 {
9845            if cancel_flag.load(Ordering::Relaxed) {
9846                return Err(ExcelError::new(ExcelErrorKind::Cancelled).with_message(
9847                    "Evaluation cancelled before FormulaPlane scheduling".to_string(),
9848                ));
9849            }
9850            return self.evaluate_authoritative_formula_plane_all();
9851        }
9852        self.reset_virtual_dep_telemetry_if_disabled();
9853        let start = crate::instant::FzInstant::now();
9854        let mut computed_vertices = 0;
9855        let mut cycle_errors = 0;
9856
9857        let mut replan_iterations = 0;
9858        const MAX_REPLAN: usize = 5;
9859        let mut telemetry = self
9860            .config
9861            .enable_virtual_dep_telemetry
9862            .then(|| self.start_virtual_dep_telemetry());
9863
9864        loop {
9865            if cancel_flag.load(Ordering::Relaxed) {
9866                if let Some(mut t) = telemetry {
9867                    t.bailout_reason = Some("cancelled");
9868                    t.replan_iterations = replan_iterations;
9869                    self.last_virtual_dep_telemetry = t;
9870                }
9871                return Err(ExcelError::new(ExcelErrorKind::Cancelled)
9872                    .with_message("Evaluation cancelled before scheduling".to_string()));
9873            }
9874
9875            let to_evaluate = self.graph.get_evaluation_vertices();
9876            if to_evaluate.is_empty() {
9877                if let Some(t) = telemetry.as_mut()
9878                    && t.bailout_reason.is_none()
9879                {
9880                    t.bailout_reason = Some("no_work");
9881                }
9882                break;
9883            }
9884
9885            let (schedule, old_vdeps, meta) = self.create_evaluation_schedule(&to_evaluate)?;
9886            if let Some(t) = telemetry.as_mut() {
9887                Self::accumulate_schedule_meta(t, &meta);
9888            }
9889
9890            // Walk units in condensation order, checking cancellation between
9891            // units (formerly between cycles and between layers).
9892            for &unit in &schedule.units {
9893                match unit {
9894                    ScheduleUnit::Cycle(i) => {
9895                        // Check cancellation between cycles
9896                        if cancel_flag.load(Ordering::Relaxed) {
9897                            if let Some(mut t) = telemetry {
9898                                t.bailout_reason = Some("cancelled");
9899                                t.replan_iterations = replan_iterations;
9900                                self.last_virtual_dep_telemetry = t;
9901                            }
9902                            return Err(ExcelError::new(ExcelErrorKind::Cancelled).with_message(
9903                                "Evaluation cancelled during cycle handling".to_string(),
9904                            ));
9905                        }
9906
9907                        if self.handle_cycle_unit(
9908                            schedule.unit_cycle(i),
9909                            None,
9910                            None,
9911                            Some(cancel_flag),
9912                        )? > 0
9913                        {
9914                            cycle_errors += 1;
9915                        }
9916                    }
9917                    ScheduleUnit::Layer(i) => {
9918                        let layer = schedule.unit_layer(i);
9919                        // Check cancellation between layers
9920                        if cancel_flag.load(Ordering::Relaxed) {
9921                            if let Some(mut t) = telemetry {
9922                                t.bailout_reason = Some("cancelled");
9923                                t.replan_iterations = replan_iterations;
9924                                self.last_virtual_dep_telemetry = t;
9925                            }
9926                            return Err(ExcelError::new(ExcelErrorKind::Cancelled)
9927                                .with_message("Evaluation cancelled between layers".to_string()));
9928                        }
9929
9930                        // Evaluate vertices in this layer (parallel or sequential)
9931                        if self.thread_pool.is_some() && layer.vertices.len() > 1 {
9932                            computed_vertices +=
9933                                self.evaluate_layer_parallel_cancellable(layer, cancel_flag)?;
9934                        } else {
9935                            computed_vertices +=
9936                                self.evaluate_layer_sequential_cancellable(layer, cancel_flag)?;
9937                        }
9938                    }
9939                }
9940            }
9941
9942            let changed_vertices = self.changed_virtual_dep_vertices(&to_evaluate, &old_vdeps);
9943            if let Some(t) = telemetry.as_mut() {
9944                t.changed_vdeps_total += changed_vertices.len();
9945            }
9946            self.graph.clear_dirty_flags(&to_evaluate);
9947            for v in &changed_vertices {
9948                self.graph.set_dirty(*v, true);
9949            }
9950
9951            if changed_vertices.is_empty() {
9952                if let Some(t) = telemetry.as_mut() {
9953                    t.bailout_reason = Some("converged");
9954                }
9955                break;
9956            }
9957            if replan_iterations >= MAX_REPLAN {
9958                if let Some(t) = telemetry.as_mut() {
9959                    t.bailout_reason = Some("max_replan");
9960                }
9961                break;
9962            }
9963            replan_iterations += 1;
9964        }
9965
9966        if let Some(mut t) = telemetry {
9967            t.replan_iterations = replan_iterations;
9968            self.last_virtual_dep_telemetry = t;
9969        }
9970
9971        // Re-dirty volatile vertices for the next evaluation cycle
9972        self.redirty_for_next_recalc();
9973        self.recalc_epoch = self.recalc_epoch.wrapping_add(1);
9974
9975        Ok(EvalResult {
9976            computed_vertices,
9977            cycle_errors,
9978            elapsed: start.elapsed(),
9979        })
9980    }
9981
9982    /// Evaluate only the necessary precedents for specific target cells with cancellation support
9983    pub fn evaluate_until_cancellable(
9984        &mut self,
9985        targets: &[&str],
9986        cancel_flag: Arc<AtomicBool>,
9987    ) -> Result<EvalResult, ExcelError> {
9988        self.active_cancel_flag = Some(cancel_flag.clone());
9989        let res = self.evaluate_until_cancellable_impl(targets, &cancel_flag);
9990        self.active_cancel_flag = None;
9991        res
9992    }
9993
9994    fn evaluate_until_cancellable_impl(
9995        &mut self,
9996        targets: &[&str],
9997        cancel_flag: &AtomicBool,
9998    ) -> Result<EvalResult, ExcelError> {
9999        let start = crate::instant::FzInstant::now();
10000        self.begin_evaluation_request();
10001        self.graph.flush_pending_edge_deltas();
10002        if self.graph.formula_authority().active_span_count() > 0 {
10003            if cancel_flag.load(Ordering::Relaxed) {
10004                return Err(ExcelError::new(ExcelErrorKind::Cancelled).with_message(
10005                    "Evaluation cancelled before FormulaPlane scheduling".to_string(),
10006                ));
10007            }
10008            return self.evaluate_authoritative_formula_plane_all();
10009        }
10010
10011        // Parse target cell addresses
10012        let mut target_addrs = Vec::new();
10013        for target in targets {
10014            let (sheet, row, col) = self.parse_a1_notation(target)?;
10015            let sheet_id = self.graph.sheet_id_mut(&sheet);
10016            let coord = Coord::from_excel(row, col, true, true);
10017            target_addrs.push(CellRef::new(sheet_id, coord));
10018        }
10019
10020        // Find vertex IDs for targets
10021        let mut target_vertex_ids = Vec::new();
10022        for addr in &target_addrs {
10023            if let Some(vertex_id) = self.graph.get_vertex_id_for_address(addr) {
10024                target_vertex_ids.push(*vertex_id);
10025            }
10026        }
10027
10028        if target_vertex_ids.is_empty() {
10029            return Ok(EvalResult {
10030                computed_vertices: 0,
10031                cycle_errors: 0,
10032                elapsed: start.elapsed(),
10033            });
10034        }
10035
10036        // Build demand subgraph with virtual edges
10037        let (precedents_to_eval, vdeps) = self.build_demand_subgraph(&target_vertex_ids);
10038
10039        if precedents_to_eval.is_empty() {
10040            return Ok(EvalResult {
10041                computed_vertices: 0,
10042                cycle_errors: 0,
10043                elapsed: start.elapsed(),
10044            });
10045        }
10046
10047        // Create schedule honoring virtual edges
10048        let scheduler = Scheduler::new(&self.graph);
10049        let schedule = scheduler.create_schedule_with_virtual(&precedents_to_eval, &vdeps)?;
10050
10051        // Walk units in condensation order with cancellation checks between
10052        // units (formerly between cycles and between layers).
10053        let mut cycle_errors = 0;
10054        let mut computed_vertices = 0;
10055        for &unit in &schedule.units {
10056            match unit {
10057                ScheduleUnit::Cycle(i) => {
10058                    // Check cancellation between cycles
10059                    if cancel_flag.load(Ordering::Relaxed) {
10060                        return Err(ExcelError::new(ExcelErrorKind::Cancelled).with_message(
10061                            "Demand-driven evaluation cancelled during cycle handling".to_string(),
10062                        ));
10063                    }
10064
10065                    if self.handle_cycle_unit(
10066                        schedule.unit_cycle(i),
10067                        None,
10068                        None,
10069                        Some(cancel_flag),
10070                    )? > 0
10071                    {
10072                        cycle_errors += 1;
10073                    }
10074                }
10075                ScheduleUnit::Layer(i) => {
10076                    let layer = schedule.unit_layer(i);
10077                    // Check cancellation between layers
10078                    if cancel_flag.load(Ordering::Relaxed) {
10079                        return Err(ExcelError::new(ExcelErrorKind::Cancelled).with_message(
10080                            "Demand-driven evaluation cancelled between layers".to_string(),
10081                        ));
10082                    }
10083
10084                    // Evaluate vertices in this layer (parallel or sequential)
10085                    if self.thread_pool.is_some() && layer.vertices.len() > 1 {
10086                        computed_vertices +=
10087                            self.evaluate_layer_parallel_cancellable(layer, cancel_flag)?;
10088                    } else {
10089                        computed_vertices += self
10090                            .evaluate_layer_sequential_cancellable_demand_driven(
10091                                layer,
10092                                cancel_flag,
10093                            )?;
10094                    }
10095                }
10096            }
10097        }
10098
10099        // Clear dirty flags for evaluated vertices
10100        self.graph.clear_dirty_flags(&precedents_to_eval);
10101
10102        // Re-dirty volatile vertices
10103        self.redirty_for_next_recalc();
10104
10105        Ok(EvalResult {
10106            computed_vertices,
10107            cycle_errors,
10108            elapsed: start.elapsed(),
10109        })
10110    }
10111
10112    fn parse_a1_notation(&self, address: &str) -> Result<(String, u32, u32), ExcelError> {
10113        let mut parts = address.splitn(2, '!');
10114        let first = parts.next().unwrap_or_default();
10115        let remainder = parts.next();
10116
10117        let (sheet, cell_part) = match remainder {
10118            Some(cell) => (first.to_string(), cell),
10119            None => (self.default_sheet_name().to_string(), first),
10120        };
10121
10122        let (row, col, _, _) = parse_a1_1based(cell_part).map_err(|err| {
10123            ExcelError::new(ExcelErrorKind::Ref)
10124                .with_message(format!("Invalid cell reference `{cell_part}`: {err}"))
10125        })?;
10126
10127        Ok((sheet, row, col))
10128    }
10129
10130    /// Determine volatility using this engine's FunctionProvider, falling back to global registry.
10131    fn is_ast_volatile_with_provider(&self, ast: &ASTNode) -> bool {
10132        use formualizer_parse::parser::ASTNodeType;
10133        match &ast.node_type {
10134            ASTNodeType::Function { name, args, .. } => {
10135                if let Some(func) = self
10136                    .get_function("", name)
10137                    .or_else(|| crate::function_registry::get("", name))
10138                    && func.caps().contains(crate::function::FnCaps::VOLATILE)
10139                {
10140                    return true;
10141                }
10142                args.iter()
10143                    .any(|arg| self.is_ast_volatile_with_provider(arg))
10144            }
10145            ASTNodeType::BinaryOp { left, right, .. } => {
10146                self.is_ast_volatile_with_provider(left)
10147                    || self.is_ast_volatile_with_provider(right)
10148            }
10149            ASTNodeType::UnaryOp { expr, .. } => self.is_ast_volatile_with_provider(expr),
10150            ASTNodeType::Array(rows) => rows.iter().any(|row| {
10151                row.iter()
10152                    .any(|cell| self.is_ast_volatile_with_provider(cell))
10153            }),
10154            _ => false,
10155        }
10156    }
10157
10158    /// Find dirty precedents that need evaluation for the given target vertices
10159    fn find_dirty_precedents(&self, target_vertices: &[VertexId]) -> Vec<VertexId> {
10160        let mut to_evaluate = FxHashSet::default();
10161        let mut visited = FxHashSet::default();
10162        let mut stack = Vec::new();
10163
10164        // Start reverse traversal from target vertices
10165        for &target in target_vertices {
10166            stack.push(target);
10167        }
10168
10169        while let Some(vertex_id) = stack.pop() {
10170            if !visited.insert(vertex_id) {
10171                continue; // Already processed
10172            }
10173
10174            if self.graph.vertex_exists(vertex_id) {
10175                // Check if this vertex needs evaluation
10176                let kind = self.graph.get_vertex_kind(vertex_id);
10177                let needs_eval = match kind {
10178                    super::vertex::VertexKind::FormulaScalar
10179                    | super::vertex::VertexKind::FormulaArray => {
10180                        self.graph.is_dirty(vertex_id) || self.graph.is_volatile(vertex_id)
10181                    }
10182                    _ => false, // Values and empty cells don't need evaluation
10183                };
10184
10185                if needs_eval {
10186                    to_evaluate.insert(vertex_id);
10187                }
10188
10189                // Continue traversal to dependencies (precedents)
10190                if let Some(dependencies) = self.graph.dependencies_slice(vertex_id) {
10191                    for &dep_id in dependencies {
10192                        if !visited.contains(&dep_id) {
10193                            stack.push(dep_id);
10194                        }
10195                    }
10196                } else {
10197                    let dependencies = self.graph.get_dependencies(vertex_id);
10198                    for dep_id in dependencies {
10199                        if !visited.contains(&dep_id) {
10200                            stack.push(dep_id);
10201                        }
10202                    }
10203                }
10204            }
10205        }
10206
10207        let mut result: Vec<VertexId> = to_evaluate.into_iter().collect();
10208        result.sort_unstable();
10209        result
10210    }
10211
10212    /// Evaluate a layer sequentially
10213    fn evaluate_layer_sequential(
10214        &mut self,
10215        layer: &super::scheduler::Layer,
10216    ) -> Result<usize, ExcelError> {
10217        self.evaluate_layer_sequential_effects(layer)
10218    }
10219
10220    fn update_vertex_value_with_delta(
10221        &mut self,
10222        vertex_id: VertexId,
10223        new_value: LiteralValue,
10224        delta: &mut DeltaCollector,
10225    ) {
10226        if delta.mode != DeltaMode::Off
10227            && let Some(cell) = self.graph.get_cell_ref_for_vertex(vertex_id)
10228        {
10229            let sheet_name = self.graph.sheet_name(cell.sheet_id);
10230            let old = self
10231                .read_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
10232                .unwrap_or(LiteralValue::Empty);
10233            if old != new_value {
10234                delta.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
10235            }
10236        }
10237        self.graph.update_vertex_value(vertex_id, new_value.clone());
10238        self.mirror_vertex_value_to_overlay(vertex_id, &new_value);
10239    }
10240
10241    fn evaluate_layer_sequential_with_delta(
10242        &mut self,
10243        layer: &super::scheduler::Layer,
10244        delta: &mut DeltaCollector,
10245    ) -> Result<usize, ExcelError> {
10246        self.evaluate_layer_sequential_with_delta_effects(layer, delta)
10247    }
10248
10249    /// Evaluate a layer sequentially with cancellation support
10250    fn evaluate_layer_sequential_cancellable(
10251        &mut self,
10252        layer: &super::scheduler::Layer,
10253        cancel_flag: &AtomicBool,
10254    ) -> Result<usize, ExcelError> {
10255        self.evaluate_layer_sequential_cancellable_effects(layer, cancel_flag)
10256    }
10257
10258    /// Evaluate a layer sequentially with more frequent cancellation checks for demand-driven evaluation
10259    fn evaluate_layer_sequential_cancellable_demand_driven(
10260        &mut self,
10261        layer: &super::scheduler::Layer,
10262        cancel_flag: &AtomicBool,
10263    ) -> Result<usize, ExcelError> {
10264        self.evaluate_layer_sequential_cancellable_demand_driven_effects(layer, cancel_flag)
10265    }
10266
10267    /// Evaluate a layer in parallel using the thread pool
10268    fn evaluate_layer_parallel(
10269        &mut self,
10270        layer: &super::scheduler::Layer,
10271    ) -> Result<usize, ExcelError> {
10272        self.evaluate_layer_parallel_effects(layer)
10273    }
10274
10275    fn evaluate_layer_parallel_with_delta(
10276        &mut self,
10277        layer: &super::scheduler::Layer,
10278        delta: &mut DeltaCollector,
10279    ) -> Result<usize, ExcelError> {
10280        self.evaluate_layer_parallel_with_delta_effects(layer, delta)
10281    }
10282
10283    /// Evaluate a layer in parallel with cancellation support
10284    fn evaluate_layer_parallel_cancellable(
10285        &mut self,
10286        layer: &super::scheduler::Layer,
10287        cancel_flag: &AtomicBool,
10288    ) -> Result<usize, ExcelError> {
10289        self.evaluate_layer_parallel_cancellable_effects(layer, cancel_flag)
10290    }
10291
10292    /// Apply a computed result produced by `evaluate_vertex_immutable()`.
10293    ///
10294    /// This is the parallel equivalent of the "apply" portion of `evaluate_vertex_impl`.
10295    /// We keep apply sequential for correctness (spill commit is inherently stateful).
10296    fn apply_parallel_vertex_result(
10297        &mut self,
10298        vertex_id: VertexId,
10299        result: LiteralValue,
10300        mut delta: Option<&mut DeltaCollector>,
10301        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
10302    ) -> Result<(), ExcelError> {
10303        // If this vertex's cell is currently covered by a spill from a different anchor,
10304        // ignore the computed result. The spill's committed values own the grid.
10305        if let Some(cell) = self.graph.get_cell_ref(vertex_id)
10306            && let Some(owner) = self.graph.spill_registry_anchor_for_cell(cell)
10307            && owner != vertex_id
10308        {
10309            return Ok(());
10310        }
10311
10312        let kind = self.graph.get_vertex_kind(vertex_id);
10313
10314        // Only formula vertices spill dynamic arrays into the grid.
10315        let is_formula = matches!(kind, VertexKind::FormulaScalar | VertexKind::FormulaArray);
10316        if is_formula {
10317            match result {
10318                LiteralValue::Array(rows) => {
10319                    self.apply_array_result_from_parallel(
10320                        vertex_id,
10321                        rows,
10322                        delta.as_deref_mut(),
10323                        overwritable_formulas,
10324                    )?;
10325                }
10326                other => {
10327                    self.apply_non_array_result_from_parallel(
10328                        vertex_id,
10329                        other,
10330                        delta.as_deref_mut(),
10331                    );
10332                }
10333            }
10334            return Ok(());
10335        }
10336
10337        // Non-formula vertices: store value as-is (arrays remain arrays; no spill).
10338        if let Some(d) = delta {
10339            self.update_vertex_value_with_delta(vertex_id, result, d);
10340        } else {
10341            self.graph.update_vertex_value(vertex_id, result.clone());
10342            self.mirror_vertex_value_to_overlay(vertex_id, &result);
10343        }
10344        Ok(())
10345    }
10346
10347    fn apply_non_array_result_from_parallel(
10348        &mut self,
10349        vertex_id: VertexId,
10350        value: LiteralValue,
10351        delta: Option<&mut DeltaCollector>,
10352    ) {
10353        // Scalar/error result: store value and ensure any previous spill is cleared.
10354        // This mirrors the sequential behavior in `evaluate_vertex_impl`.
10355        let spill_cells = self
10356            .graph
10357            .spill_cells_for_anchor(vertex_id)
10358            .map(|cells| cells.to_vec())
10359            .unwrap_or_default();
10360
10361        if let Some(d) = delta
10362            && d.mode != DeltaMode::Off
10363            && let Some(anchor) = self.graph.get_cell_ref_for_vertex(vertex_id)
10364        {
10365            if spill_cells.is_empty() {
10366                let old = self
10367                    .read_cell_value(
10368                        self.graph.sheet_name(anchor.sheet_id),
10369                        anchor.coord.row() + 1,
10370                        anchor.coord.col() + 1,
10371                    )
10372                    .unwrap_or(LiteralValue::Empty);
10373                if old != value {
10374                    d.record_cell(anchor.sheet_id, anchor.coord.row(), anchor.coord.col());
10375                }
10376            } else {
10377                for cell in spill_cells.iter() {
10378                    let sheet_name = self.graph.sheet_name(cell.sheet_id);
10379                    let old = self
10380                        .get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
10381                        .unwrap_or(LiteralValue::Empty);
10382                    let new = if cell.sheet_id == anchor.sheet_id
10383                        && cell.coord.row() == anchor.coord.row()
10384                        && cell.coord.col() == anchor.coord.col()
10385                    {
10386                        value.clone()
10387                    } else {
10388                        LiteralValue::Empty
10389                    };
10390                    Self::record_cell_if_changed(d, cell, &old, &new);
10391                }
10392            }
10393        }
10394
10395        self.graph.clear_spill_region(vertex_id);
10396        if let Some(scope) = Self::formula_plane_region_from_cells(&spill_cells) {
10397            self.record_formula_plane_structural_change(scope);
10398        }
10399
10400        if self.config.arrow_storage_enabled
10401            && self.config.delta_overlay_enabled
10402            && self.config.write_formula_overlay_enabled
10403        {
10404            let empty = LiteralValue::Empty;
10405            for cell in spill_cells.iter() {
10406                let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
10407                self.mirror_value_to_computed_overlay(
10408                    &sheet_name,
10409                    cell.coord.row() + 1,
10410                    cell.coord.col() + 1,
10411                    &empty,
10412                );
10413            }
10414        }
10415
10416        self.graph.update_vertex_value(vertex_id, value.clone());
10417        self.mirror_vertex_value_to_overlay(vertex_id, &value);
10418    }
10419
10420    fn apply_array_result_from_parallel(
10421        &mut self,
10422        vertex_id: VertexId,
10423        rows: Vec<Vec<LiteralValue>>,
10424        mut delta: Option<&mut DeltaCollector>,
10425        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
10426    ) -> Result<(), ExcelError> {
10427        // Keep behavior consistent with the sequential spill path in `evaluate_vertex_impl`.
10428        self.graph
10429            .set_kind(vertex_id, crate::engine::vertex::VertexKind::FormulaArray);
10430
10431        let anchor = self
10432            .graph
10433            .get_cell_ref(vertex_id)
10434            .expect("cell ref for vertex");
10435        let sheet_id = anchor.sheet_id;
10436        let h = rows.len() as u32;
10437        let w = rows.first().map(|r| r.len()).unwrap_or(0) as u32;
10438
10439        // Hard cap to avoid vertex explosion from huge dynamic arrays.
10440        let spill_cells = (h as u64).saturating_mul(w as u64);
10441        if spill_cells > self.config.spill.max_spill_cells as u64 {
10442            self.clear_spill_projection_and_mirror(vertex_id, delta.as_deref_mut());
10443            let spill_err = ExcelError::new(ExcelErrorKind::Spill)
10444                .with_message("SpillTooLarge")
10445                .with_extra(formualizer_common::ExcelErrorExtra::Spill {
10446                    expected_rows: h,
10447                    expected_cols: w,
10448                });
10449            let spill_val = LiteralValue::Error(spill_err.clone());
10450            if let Some(d) = delta.as_deref_mut()
10451                && d.mode != DeltaMode::Off
10452            {
10453                let old = self
10454                    .read_cell_value(
10455                        self.graph.sheet_name(anchor.sheet_id),
10456                        anchor.coord.row() + 1,
10457                        anchor.coord.col() + 1,
10458                    )
10459                    .unwrap_or(LiteralValue::Empty);
10460                if old != spill_val {
10461                    d.record_cell(anchor.sheet_id, anchor.coord.row(), anchor.coord.col());
10462                }
10463            }
10464            self.graph.update_vertex_value(vertex_id, spill_val.clone());
10465            self.mirror_vertex_value_to_overlay(vertex_id, &spill_val);
10466            return Ok(());
10467        }
10468
10469        // Bounds check to avoid out-of-range writes (align to AbsCoord capacity)
10470        const PACKED_MAX_ROW: u32 = 1_048_575; // 20-bit max
10471        const PACKED_MAX_COL: u32 = 16_383; // 14-bit max
10472        let end_row = anchor.coord.row().saturating_add(h).saturating_sub(1);
10473        let end_col = anchor.coord.col().saturating_add(w).saturating_sub(1);
10474        if end_row > PACKED_MAX_ROW || end_col > PACKED_MAX_COL {
10475            self.clear_spill_projection_and_mirror(vertex_id, delta.as_deref_mut());
10476            let spill_err = ExcelError::new(ExcelErrorKind::Spill)
10477                .with_message("Spill exceeds sheet bounds")
10478                .with_extra(formualizer_common::ExcelErrorExtra::Spill {
10479                    expected_rows: h,
10480                    expected_cols: w,
10481                });
10482            let spill_val = LiteralValue::Error(spill_err.clone());
10483            if let Some(d) = delta.as_deref_mut()
10484                && d.mode != DeltaMode::Off
10485            {
10486                let old = self
10487                    .read_cell_value(
10488                        self.graph.sheet_name(anchor.sheet_id),
10489                        anchor.coord.row() + 1,
10490                        anchor.coord.col() + 1,
10491                    )
10492                    .unwrap_or(LiteralValue::Empty);
10493                if old != spill_val {
10494                    d.record_cell(anchor.sheet_id, anchor.coord.row(), anchor.coord.col());
10495                }
10496            }
10497            self.graph.update_vertex_value(vertex_id, spill_val.clone());
10498            self.mirror_vertex_value_to_overlay(vertex_id, &spill_val);
10499            return Ok(());
10500        }
10501
10502        let mut targets = Vec::new();
10503        for r in 0..h {
10504            for c in 0..w {
10505                targets.push(self.graph.make_cell_ref_internal(
10506                    sheet_id,
10507                    anchor.coord.row() + r,
10508                    anchor.coord.col() + c,
10509                ));
10510            }
10511        }
10512
10513        match self.spill_mgr.reserve(
10514            vertex_id,
10515            anchor,
10516            SpillShape { rows: h, cols: w },
10517            SpillMeta {
10518                epoch: self.recalc_epoch,
10519                config: self.config.spill,
10520            },
10521        ) {
10522            Ok(()) => {
10523                if let Err(e) = self.commit_spill_and_mirror(
10524                    vertex_id,
10525                    &targets,
10526                    rows.clone(),
10527                    delta.as_deref_mut(),
10528                    overwritable_formulas,
10529                ) {
10530                    self.clear_spill_projection_and_mirror(vertex_id, delta.as_deref_mut());
10531                    let err_val = LiteralValue::Error(e.clone());
10532                    if let Some(d) = delta.as_deref_mut()
10533                        && d.mode != DeltaMode::Off
10534                    {
10535                        let old = self
10536                            .read_cell_value(
10537                                self.graph.sheet_name(anchor.sheet_id),
10538                                anchor.coord.row() + 1,
10539                                anchor.coord.col() + 1,
10540                            )
10541                            .unwrap_or(LiteralValue::Empty);
10542                        if old != err_val {
10543                            d.record_cell(anchor.sheet_id, anchor.coord.row(), anchor.coord.col());
10544                        }
10545                    }
10546                    self.graph.update_vertex_value(vertex_id, err_val.clone());
10547                    self.mirror_vertex_value_to_overlay(vertex_id, &err_val);
10548                    return Ok(());
10549                }
10550
10551                // Anchor shows the top-left value, like Excel
10552                let top_left = rows
10553                    .first()
10554                    .and_then(|r| r.first())
10555                    .cloned()
10556                    .unwrap_or(LiteralValue::Empty);
10557                self.graph.update_vertex_value(vertex_id, top_left.clone());
10558                self.mirror_vertex_value_to_overlay(vertex_id, &top_left);
10559                Ok(())
10560            }
10561            Err(e) => {
10562                self.clear_spill_projection_and_mirror(vertex_id, delta.as_deref_mut());
10563                let spill_err = ExcelError::new(ExcelErrorKind::Spill)
10564                    .with_message(e.message.unwrap_or_else(|| "Spill blocked".to_string()))
10565                    .with_extra(formualizer_common::ExcelErrorExtra::Spill {
10566                        expected_rows: h,
10567                        expected_cols: w,
10568                    });
10569                let spill_val = LiteralValue::Error(spill_err.clone());
10570                if let Some(d) = delta
10571                    && d.mode != DeltaMode::Off
10572                {
10573                    let old = self
10574                        .read_cell_value(
10575                            self.graph.sheet_name(anchor.sheet_id),
10576                            anchor.coord.row() + 1,
10577                            anchor.coord.col() + 1,
10578                        )
10579                        .unwrap_or(LiteralValue::Empty);
10580                    if old != spill_val {
10581                        d.record_cell(anchor.sheet_id, anchor.coord.row(), anchor.coord.col());
10582                    }
10583                }
10584                self.graph.update_vertex_value(vertex_id, spill_val.clone());
10585                self.mirror_vertex_value_to_overlay(vertex_id, &spill_val);
10586                Ok(())
10587            }
10588        }
10589    }
10590
10591    /// Evaluate a single vertex without mutating the graph (for parallel evaluation)
10592    fn evaluate_vertex_immutable(&self, vertex_id: VertexId) -> Result<LiteralValue, ExcelError> {
10593        // Check if vertex exists
10594        if !self.graph.vertex_exists(vertex_id) {
10595            return Err(ExcelError::new(formualizer_common::ExcelErrorKind::Ref)
10596                .with_message(format!("Vertex not found: {vertex_id:?}")));
10597        }
10598
10599        // Get vertex kind and check if it needs evaluation
10600        let kind = self.graph.get_vertex_kind(vertex_id);
10601        let sheet_id = self.graph.get_vertex_sheet_id(vertex_id);
10602
10603        let ast_id = match kind {
10604            VertexKind::FormulaScalar | VertexKind::FormulaArray => {
10605                if let Some(ast_id) = self.graph.get_formula_id(vertex_id) {
10606                    ast_id
10607                } else {
10608                    return Ok(LiteralValue::Number(0.0));
10609                }
10610            }
10611            VertexKind::Empty | VertexKind::Cell => {
10612                if let Some(cell_ref) = self.graph.get_cell_ref(vertex_id) {
10613                    let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
10614                    let row = cell_ref.coord.row() + 1;
10615                    let col = cell_ref.coord.col() + 1;
10616                    if let Some(v) = self.read_cell_value(sheet_name, row, col) {
10617                        return Ok(v);
10618                    }
10619                }
10620                return Ok(LiteralValue::Number(0.0));
10621            }
10622            VertexKind::NamedScalar => {
10623                let named_range = self.graph.named_range_by_vertex(vertex_id).ok_or_else(|| {
10624                    ExcelError::new(ExcelErrorKind::Name)
10625                        .with_message("Named range metadata missing".to_string())
10626                })?;
10627
10628                return match &named_range.definition {
10629                    NamedDefinition::Cell(cell_ref) => {
10630                        let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
10631                        Ok(self
10632                            .get_cell_value(
10633                                sheet_name,
10634                                cell_ref.coord.row() + 1,
10635                                cell_ref.coord.col() + 1,
10636                            )
10637                            .unwrap_or(LiteralValue::Empty))
10638                    }
10639                    NamedDefinition::Literal(v) => Ok(v.clone()),
10640                    NamedDefinition::Formula { ast, .. } => {
10641                        let context_sheet = match named_range.scope {
10642                            NameScope::Sheet(id) => id,
10643                            NameScope::Workbook => sheet_id,
10644                        };
10645                        let sheet_name = self.graph.sheet_name(context_sheet);
10646                        let cell_ref = self
10647                            .graph
10648                            .get_cell_ref(vertex_id)
10649                            .unwrap_or_else(|| self.graph.make_cell_ref(sheet_name, 0, 0));
10650                        let interpreter = Interpreter::new_with_cell(self, sheet_name, cell_ref);
10651                        interpreter.evaluate_ast(ast).map(|cv| cv.into_literal())
10652                    }
10653                    NamedDefinition::Range(_) => Err(ExcelError::new(ExcelErrorKind::Value)
10654                        .with_message("Range-valued name evaluated as scalar".to_string())),
10655                };
10656            }
10657            VertexKind::NamedArray => {
10658                let named_range = self.graph.named_range_by_vertex(vertex_id).ok_or_else(|| {
10659                    ExcelError::new(ExcelErrorKind::Name)
10660                        .with_message("Named range metadata missing".to_string())
10661                })?;
10662
10663                return match &named_range.definition {
10664                    NamedDefinition::Range(range_ref) => {
10665                        if range_ref.start.sheet_id != range_ref.end.sheet_id {
10666                            return Err(ExcelError::new(ExcelErrorKind::Ref)
10667                                .with_message("Named range cannot span sheets".to_string()));
10668                        }
10669                        let sheet_name = self.graph.sheet_name(range_ref.start.sheet_id);
10670                        let sr0 = range_ref.start.coord.row();
10671                        let sc0 = range_ref.start.coord.col();
10672                        let er0 = range_ref.end.coord.row();
10673                        let ec0 = range_ref.end.coord.col();
10674                        if sr0 > er0 || sc0 > ec0 {
10675                            return Err(ExcelError::new(ExcelErrorKind::Ref)
10676                                .with_message("Invalid named range bounds".to_string()));
10677                        }
10678
10679                        let h = (er0 - sr0 + 1) as usize;
10680                        let w = (ec0 - sc0 + 1) as usize;
10681                        let cell_count = (h as u64).saturating_mul(w as u64);
10682                        if cell_count > self.config.spill.max_spill_cells as u64 {
10683                            return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
10684                                "Named range too large to materialize as an array".to_string(),
10685                            ));
10686                        }
10687
10688                        let mut rows = Vec::with_capacity(h);
10689                        for r0 in sr0..=er0 {
10690                            let mut row = Vec::with_capacity(w);
10691                            for c0 in sc0..=ec0 {
10692                                let v = self
10693                                    .get_cell_value(sheet_name, r0 + 1, c0 + 1)
10694                                    .unwrap_or(LiteralValue::Empty);
10695                                row.push(v);
10696                            }
10697                            rows.push(row);
10698                        }
10699                        Ok(LiteralValue::Array(rows))
10700                    }
10701                    NamedDefinition::Cell(cell_ref) => {
10702                        let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
10703                        let row = cell_ref.coord.row() + 1;
10704                        let col = cell_ref.coord.col() + 1;
10705                        let v = self
10706                            .get_cell_value(sheet_name, row, col)
10707                            .unwrap_or(LiteralValue::Empty);
10708                        Ok(LiteralValue::Array(vec![vec![v]]))
10709                    }
10710                    NamedDefinition::Literal(v) => Ok(LiteralValue::Array(vec![vec![v.clone()]])),
10711                    NamedDefinition::Formula { ast, .. } => {
10712                        let context_sheet = match named_range.scope {
10713                            NameScope::Sheet(id) => id,
10714                            NameScope::Workbook => sheet_id,
10715                        };
10716                        let sheet_name = self.graph.sheet_name(context_sheet);
10717                        let cell_ref = self
10718                            .graph
10719                            .get_cell_ref(vertex_id)
10720                            .unwrap_or_else(|| self.graph.make_cell_ref(sheet_name, 0, 0));
10721                        let interpreter = Interpreter::new_with_cell(self, sheet_name, cell_ref);
10722                        match interpreter.evaluate_ast(ast) {
10723                            Ok(cv) => {
10724                                let v = cv.into_literal();
10725                                match v {
10726                                    LiteralValue::Array(_) => Ok(v),
10727                                    other => Ok(LiteralValue::Array(vec![vec![other]])),
10728                                }
10729                            }
10730                            Err(err) => Ok(LiteralValue::Error(err)),
10731                        }
10732                    }
10733                };
10734            }
10735            VertexKind::InfiniteRange
10736            | VertexKind::Range
10737            | VertexKind::External
10738            | VertexKind::Table => {
10739                // Not directly evaluatable here.
10740                return Ok(LiteralValue::Number(0.0));
10741            }
10742        };
10743
10744        // The interpreter uses a reference to the engine as the context
10745        let sheet_name = self.graph.sheet_name(sheet_id);
10746        let cell_ref = self
10747            .graph
10748            .get_cell_ref(vertex_id)
10749            .expect("cell ref for vertex");
10750        let interpreter = Interpreter::new_with_cell(self, sheet_name, cell_ref);
10751
10752        interpreter
10753            .evaluate_arena_ast(ast_id, self.graph.data_store(), self.graph.sheet_reg())
10754            .map(|cv| cv.into_literal())
10755    }
10756
10757    /// Get access to the shared thread pool for parallel evaluation
10758    pub fn thread_pool(&self) -> Option<&Arc<rayon::ThreadPool>> {
10759        self.thread_pool.as_ref()
10760    }
10761}
10762
10763#[derive(Default)]
10764struct RowBoundsCache {
10765    snapshot: u64,
10766    // key: (sheet_id, col_idx)
10767    map: rustc_hash::FxHashMap<(u32, usize), (Option<u32>, Option<u32>)>,
10768}
10769
10770impl RowBoundsCache {
10771    fn new(snapshot: u64) -> Self {
10772        Self {
10773            snapshot,
10774            map: Default::default(),
10775        }
10776    }
10777    fn get_row_bounds(
10778        &self,
10779        sheet_id: SheetId,
10780        col_idx: usize,
10781        snapshot: u64,
10782    ) -> Option<(Option<u32>, Option<u32>)> {
10783        if self.snapshot != snapshot {
10784            return None;
10785        }
10786        self.map.get(&(sheet_id as u32, col_idx)).copied()
10787    }
10788    fn put_row_bounds(
10789        &mut self,
10790        sheet_id: SheetId,
10791        col_idx: usize,
10792        snapshot: u64,
10793        bounds: (Option<u32>, Option<u32>),
10794    ) {
10795        if self.snapshot != snapshot {
10796            self.snapshot = snapshot;
10797            self.map.clear();
10798        }
10799        self.map.insert((sheet_id as u32, col_idx), bounds);
10800    }
10801}
10802
10803struct UsedAxisBoundsCache {
10804    snapshot: u64,
10805    row_bounds_by_col_span: rustc_hash::FxHashMap<(SheetId, u32, u32), Option<(u32, u32)>>,
10806    col_bounds_by_row_span: rustc_hash::FxHashMap<(SheetId, u32, u32), Option<(u32, u32)>>,
10807    #[cfg(test)]
10808    row_hits: std::sync::atomic::AtomicUsize,
10809    #[cfg(test)]
10810    row_misses: std::sync::atomic::AtomicUsize,
10811    #[cfg(test)]
10812    col_hits: std::sync::atomic::AtomicUsize,
10813    #[cfg(test)]
10814    col_misses: std::sync::atomic::AtomicUsize,
10815}
10816
10817impl UsedAxisBoundsCache {
10818    fn new(snapshot: u64) -> Self {
10819        Self {
10820            snapshot,
10821            row_bounds_by_col_span: Default::default(),
10822            col_bounds_by_row_span: Default::default(),
10823            #[cfg(test)]
10824            row_hits: std::sync::atomic::AtomicUsize::new(0),
10825            #[cfg(test)]
10826            row_misses: std::sync::atomic::AtomicUsize::new(0),
10827            #[cfg(test)]
10828            col_hits: std::sync::atomic::AtomicUsize::new(0),
10829            #[cfg(test)]
10830            col_misses: std::sync::atomic::AtomicUsize::new(0),
10831        }
10832    }
10833
10834    fn reset_for_snapshot(&mut self, snapshot: u64) {
10835        if self.snapshot != snapshot {
10836            self.snapshot = snapshot;
10837            self.row_bounds_by_col_span.clear();
10838            self.col_bounds_by_row_span.clear();
10839        }
10840    }
10841
10842    fn get_row_bounds(
10843        &self,
10844        sheet_id: SheetId,
10845        start_col: u32,
10846        end_col: u32,
10847        snapshot: u64,
10848    ) -> Option<Option<(u32, u32)>> {
10849        if self.snapshot != snapshot {
10850            return None;
10851        }
10852        let cached = self
10853            .row_bounds_by_col_span
10854            .get(&(sheet_id, start_col, end_col))
10855            .copied();
10856        #[cfg(test)]
10857        if cached.is_some() {
10858            self.row_hits.fetch_add(1, Ordering::Relaxed);
10859        }
10860        cached
10861    }
10862
10863    fn put_row_bounds(
10864        &mut self,
10865        sheet_id: SheetId,
10866        start_col: u32,
10867        end_col: u32,
10868        snapshot: u64,
10869        bounds: Option<(u32, u32)>,
10870    ) {
10871        self.reset_for_snapshot(snapshot);
10872        self.row_bounds_by_col_span
10873            .insert((sheet_id, start_col, end_col), bounds);
10874        #[cfg(test)]
10875        self.row_misses.fetch_add(1, Ordering::Relaxed);
10876    }
10877
10878    fn get_col_bounds(
10879        &self,
10880        sheet_id: SheetId,
10881        start_row: u32,
10882        end_row: u32,
10883        snapshot: u64,
10884    ) -> Option<Option<(u32, u32)>> {
10885        if self.snapshot != snapshot {
10886            return None;
10887        }
10888        let cached = self
10889            .col_bounds_by_row_span
10890            .get(&(sheet_id, start_row, end_row))
10891            .copied();
10892        #[cfg(test)]
10893        if cached.is_some() {
10894            self.col_hits.fetch_add(1, Ordering::Relaxed);
10895        }
10896        cached
10897    }
10898
10899    fn put_col_bounds(
10900        &mut self,
10901        sheet_id: SheetId,
10902        start_row: u32,
10903        end_row: u32,
10904        snapshot: u64,
10905        bounds: Option<(u32, u32)>,
10906    ) {
10907        self.reset_for_snapshot(snapshot);
10908        self.col_bounds_by_row_span
10909            .insert((sheet_id, start_row, end_row), bounds);
10910        #[cfg(test)]
10911        self.col_misses.fetch_add(1, Ordering::Relaxed);
10912    }
10913}
10914
10915// Phase 2 shim: in-process spill manager delegating to current graph methods.
10916#[derive(Default)]
10917pub struct ShimSpillManager {
10918    region_locks: RegionLockManager,
10919    pub(crate) active_locks: rustc_hash::FxHashMap<VertexId, u64>,
10920}
10921
10922impl ShimSpillManager {
10923    pub(crate) fn reserve(
10924        &mut self,
10925        owner: VertexId,
10926        anchor_cell: CellRef,
10927        shape: SpillShape,
10928        _meta: SpillMeta,
10929    ) -> Result<(), ExcelError> {
10930        // Derive region from anchor + shape; enforce in-flight exclusivity only.
10931        let region = crate::engine::spill::Region {
10932            sheet_id: anchor_cell.sheet_id as u32,
10933            row_start: anchor_cell.coord.row(),
10934            row_end: anchor_cell
10935                .coord
10936                .row()
10937                .saturating_add(shape.rows)
10938                .saturating_sub(1),
10939            col_start: anchor_cell.coord.col(),
10940            col_end: anchor_cell
10941                .coord
10942                .col()
10943                .saturating_add(shape.cols)
10944                .saturating_sub(1),
10945        };
10946        match self.region_locks.reserve(region, owner) {
10947            Ok(id) => {
10948                if id != 0 {
10949                    self.active_locks.insert(owner, id);
10950                }
10951                Ok(())
10952            }
10953            Err(e) => Err(e),
10954        }
10955    }
10956
10957    /// Release any in-flight region reservation still held for `owner`.
10958    ///
10959    /// Reservations are normally released on commit/rollback, but if an anchor is
10960    /// abandoned without committing (e.g. cycle detection stamps it with #CIRC), a
10961    /// stale reservation could remain. This is a no-op when nothing is held.
10962    pub(crate) fn release_owner(&mut self, owner: VertexId) {
10963        if let Some(id) = self.active_locks.remove(&owner) {
10964            self.region_locks.release(id);
10965        }
10966    }
10967
10968    pub(crate) fn commit_array_with_value_probe<F>(
10969        &mut self,
10970        graph: &mut DependencyGraph,
10971        anchor_vertex: VertexId,
10972        targets: &[CellRef],
10973        rows: Vec<Vec<LiteralValue>>,
10974        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
10975        mut value_probe: F,
10976    ) -> Result<(), ExcelError>
10977    where
10978        F: FnMut(&DependencyGraph, &CellRef) -> Option<LiteralValue>,
10979    {
10980        use formualizer_common::{ExcelErrorExtra, ExcelErrorKind};
10981
10982        // Re-run plan on concrete targets before committing to respect blockers.
10983        // This plan checks formula/spill ownership in the graph, but when the graph value cache
10984        // is disabled (Arrow-canonical mode), it cannot see non-empty value blockers.
10985        let plan_res = graph.plan_spill_region_allowing_formula_overwrite(
10986            anchor_vertex,
10987            targets,
10988            overwritable_formulas,
10989        );
10990        if let Err(e) = plan_res {
10991            if let Some(id) = self.active_locks.remove(&anchor_vertex) {
10992                self.region_locks.release(id);
10993            }
10994            return Err(e);
10995        }
10996
10997        if !graph.value_cache_enabled() {
10998            // Compute expected spill shape from the target rectangle for diagnostics.
10999            let (expected_rows, expected_cols) = if targets.is_empty() {
11000                (0u32, 0u32)
11001            } else {
11002                let mut min_r = u32::MAX;
11003                let mut max_r = 0u32;
11004                let mut min_c = u32::MAX;
11005                let mut max_c = 0u32;
11006                for cell in targets {
11007                    let r = cell.coord.row();
11008                    let c = cell.coord.col();
11009                    min_r = min_r.min(r);
11010                    max_r = max_r.max(r);
11011                    min_c = min_c.min(c);
11012                    max_c = max_c.max(c);
11013                }
11014                (
11015                    max_r.saturating_sub(min_r).saturating_add(1),
11016                    max_c.saturating_sub(min_c).saturating_add(1),
11017                )
11018            };
11019
11020            let anchor_cell = graph
11021                .get_cell_ref(anchor_vertex)
11022                .expect("anchor cell ref for spill commit");
11023
11024            for cell in targets {
11025                // Never treat the anchor as a blocker.
11026                if *cell == anchor_cell {
11027                    continue;
11028                }
11029                // Skip cells already known to be owned by a spill; plan() handled spill conflicts.
11030                if graph.spill_registry_anchor_for_cell(*cell).is_some() {
11031                    continue;
11032                }
11033                // Skip formula vertices in the target region; plan() handled them (or allowed).
11034                if let Some(&vid) = graph.get_vertex_id_for_address(cell)
11035                    && vid != anchor_vertex
11036                {
11037                    match graph.get_vertex_kind(vid) {
11038                        crate::engine::vertex::VertexKind::FormulaScalar
11039                        | crate::engine::vertex::VertexKind::FormulaArray => {
11040                            // plan() already approved allowed overwrites.
11041                            continue;
11042                        }
11043                        _ => {}
11044                    }
11045                }
11046
11047                if let Some(v) = value_probe(graph, cell)
11048                    && !matches!(v, LiteralValue::Empty)
11049                {
11050                    if let Some(id) = self.active_locks.remove(&anchor_vertex) {
11051                        self.region_locks.release(id);
11052                    }
11053                    return Err(ExcelError::new(ExcelErrorKind::Spill)
11054                        .with_message("BlockedByValue")
11055                        .with_extra(ExcelErrorExtra::Spill {
11056                            expected_rows,
11057                            expected_cols,
11058                        }));
11059                }
11060            }
11061        }
11062
11063        let commit_res = graph.commit_spill_region_atomic_with_fault(
11064            anchor_vertex,
11065            targets.to_vec(),
11066            rows,
11067            None,
11068        );
11069        if let Some(id) = self.active_locks.remove(&anchor_vertex) {
11070            self.region_locks.release(id);
11071        }
11072        commit_res.map(|_| ())
11073    }
11074
11075    /// Commit a spill and mirror all written cells into Arrow overlay via the owning engine.
11076    pub(crate) fn commit_array_with_overlay<R: EvaluationContext>(
11077        &mut self,
11078        engine: &mut Engine<R>,
11079        anchor_vertex: VertexId,
11080        targets: &[CellRef],
11081        rows: Vec<Vec<LiteralValue>>,
11082        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
11083    ) -> Result<(), ExcelError> {
11084        // Re-run plan on concrete targets before committing to respect blockers.
11085        let plan_res = engine.graph.plan_spill_region_allowing_formula_overwrite(
11086            anchor_vertex,
11087            targets,
11088            overwritable_formulas,
11089        );
11090        if let Err(e) = plan_res {
11091            if let Some(id) = self.active_locks.remove(&anchor_vertex) {
11092                self.region_locks.release(id);
11093            }
11094            return Err(e);
11095        }
11096
11097        let commit_res = engine.graph.commit_spill_region_atomic_with_fault(
11098            anchor_vertex,
11099            targets.to_vec(),
11100            rows.clone(),
11101            None,
11102        );
11103        if let Some(id) = self.active_locks.remove(&anchor_vertex) {
11104            self.region_locks.release(id);
11105        }
11106        commit_res.map(|_| ())?;
11107
11108        // Mirror into Arrow overlay when enabled
11109        if engine.config.arrow_storage_enabled
11110            && engine.config.delta_overlay_enabled
11111            && engine.config.write_formula_overlay_enabled
11112        {
11113            // Expect targets to be a contiguous rectangle row-major starting at some anchor
11114            for (idx, cell) in targets.iter().enumerate() {
11115                let (r_off, c_off) = {
11116                    if rows.is_empty() || rows[0].is_empty() {
11117                        (0usize, 0usize)
11118                    } else {
11119                        let width = rows[0].len();
11120                        (idx / width, idx % width)
11121                    }
11122                };
11123                let v = rows
11124                    .get(r_off)
11125                    .and_then(|r| r.get(c_off))
11126                    .cloned()
11127                    .unwrap_or(LiteralValue::Empty);
11128                let sheet_name = engine.graph.sheet_name(cell.sheet_id).to_string();
11129                engine.mirror_value_to_computed_overlay(
11130                    &sheet_name,
11131                    cell.coord.row() + 1,
11132                    cell.coord.col() + 1,
11133                    &v,
11134                );
11135            }
11136        }
11137        Ok(())
11138    }
11139}
11140
11141impl<R> Engine<R>
11142where
11143    R: EvaluationContext,
11144{
11145    fn resolve_shared_ref(
11146        &self,
11147        reference: &ReferenceType,
11148        current_sheet: &str,
11149    ) -> Result<formualizer_common::SheetRef<'static>, ExcelError> {
11150        use formualizer_common::{
11151            SheetCellRef as SharedCellRef, SheetLocator, SheetRangeRef as SharedRangeRef,
11152            SheetRef as SharedRef,
11153        };
11154
11155        // Preserve anchor flags from the parsed reference when possible.
11156        let sr = match reference {
11157            ReferenceType::Cell {
11158                sheet,
11159                row,
11160                col,
11161                row_abs,
11162                col_abs,
11163            } => {
11164                let row0 = row
11165                    .checked_sub(1)
11166                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11167                let col0 = col
11168                    .checked_sub(1)
11169                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11170                let sheet_loc = match sheet.as_deref() {
11171                    Some(name) => SheetLocator::from_name(name),
11172                    None => SheetLocator::Current,
11173                };
11174                let coord = formualizer_common::RelativeCoord::new(row0, col0, *row_abs, *col_abs);
11175                SharedRef::Cell(SharedCellRef::new(sheet_loc, coord))
11176            }
11177            ReferenceType::Range {
11178                sheet,
11179                start_row,
11180                start_col,
11181                end_row,
11182                end_col,
11183                start_row_abs,
11184                start_col_abs,
11185                end_row_abs,
11186                end_col_abs,
11187            } => {
11188                let sheet_loc = match sheet.as_deref() {
11189                    Some(name) => SheetLocator::from_name(name),
11190                    None => SheetLocator::Current,
11191                };
11192                let sr = start_row
11193                    .map(|r| {
11194                        r.checked_sub(1)
11195                            .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))
11196                    })
11197                    .transpose()?;
11198                let sc = start_col
11199                    .map(|c| {
11200                        c.checked_sub(1)
11201                            .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))
11202                    })
11203                    .transpose()?;
11204                let er = end_row
11205                    .map(|r| {
11206                        r.checked_sub(1)
11207                            .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))
11208                    })
11209                    .transpose()?;
11210                let ec = end_col
11211                    .map(|c| {
11212                        c.checked_sub(1)
11213                            .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))
11214                    })
11215                    .transpose()?;
11216                let range = SharedRangeRef::from_parts(
11217                    sheet_loc,
11218                    sr.map(|idx| formualizer_common::AxisBound::new(idx, *start_row_abs)),
11219                    sc.map(|idx| formualizer_common::AxisBound::new(idx, *start_col_abs)),
11220                    er.map(|idx| formualizer_common::AxisBound::new(idx, *end_row_abs)),
11221                    ec.map(|idx| formualizer_common::AxisBound::new(idx, *end_col_abs)),
11222                )
11223                .map_err(|_| ExcelError::new(ExcelErrorKind::Ref))?;
11224                SharedRef::Range(range)
11225            }
11226            _ => return Err(ExcelError::new(ExcelErrorKind::Ref)),
11227        };
11228
11229        let current_id = self
11230            .graph
11231            .sheet_id(current_sheet)
11232            .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11233
11234        let resolve_loc = |loc: SheetLocator<'_>| -> Result<SheetLocator<'static>, ExcelError> {
11235            match loc {
11236                SheetLocator::Current => Ok(SheetLocator::Id(current_id)),
11237                SheetLocator::Id(id) => Ok(SheetLocator::Id(id)),
11238                SheetLocator::Name(name) => {
11239                    let n = name.as_ref();
11240                    self.graph
11241                        .sheet_id(n)
11242                        .map(SheetLocator::Id)
11243                        .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))
11244                }
11245            }
11246        };
11247
11248        match sr {
11249            SharedRef::Cell(cell) => {
11250                let owned = cell.into_owned();
11251                let sheet = resolve_loc(owned.sheet)?;
11252                Ok(SharedRef::Cell(SharedCellRef::new(sheet, owned.coord)))
11253            }
11254            SharedRef::Range(range) => {
11255                let owned = range.into_owned();
11256                let sheet = resolve_loc(owned.sheet)?;
11257                Ok(SharedRef::Range(SharedRangeRef {
11258                    sheet,
11259                    start_row: owned.start_row,
11260                    start_col: owned.start_col,
11261                    end_row: owned.end_row,
11262                    end_col: owned.end_col,
11263                }))
11264            }
11265        }
11266    }
11267}
11268
11269// Implement the resolver traits for the Engine.
11270// This allows the interpreter to resolve references by querying the engine's graph.
11271impl<R> crate::traits::ReferenceResolver for Engine<R>
11272where
11273    R: EvaluationContext,
11274{
11275    fn resolve_cell_reference(
11276        &self,
11277        sheet: Option<&str>,
11278        row: u32,
11279        col: u32,
11280    ) -> Result<LiteralValue, ExcelError> {
11281        // This context-free trait method has no knowledge of the formula's
11282        // current sheet, so an unqualified (`None`) reference cannot be resolved
11283        // here. Previously this fell back to `default_sheet_name()`, which leaked
11284        // the reference onto an unrelated sheet (issue #110). Interpreter paths
11285        // already qualify references with the current sheet before reaching this
11286        // method (see `Interpreter::implicit_intersection_from_reference`), and
11287        // the sheet-aware scalar path goes through `resolve_cell_reference_value`
11288        // with an explicit `current_sheet`. Returning #REF! for an unqualified
11289        // reference here surfaces the missing context instead of silently
11290        // returning data from the wrong sheet.
11291        let Some(sheet_name) = sheet else {
11292            return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(
11293                "Unqualified cell reference resolved without sheet context".to_string(),
11294            ));
11295        };
11296        // Prefer engine's unified accessor which consults Arrow store for base values
11297        // and falls back to graph for formulas and stored values.
11298        if let Some(v) = self.get_cell_value(sheet_name, row, col) {
11299            Ok(v)
11300        } else {
11301            // Excel semantics: empty cell coerces to 0 in numeric contexts
11302            Ok(LiteralValue::Number(0.0))
11303        }
11304    }
11305}
11306
11307impl<R> crate::traits::RangeResolver for Engine<R>
11308where
11309    R: EvaluationContext,
11310{
11311    fn resolve_range_reference(
11312        &self,
11313        sheet: Option<&str>,
11314        sr: Option<u32>,
11315        sc: Option<u32>,
11316        er: Option<u32>,
11317        ec: Option<u32>,
11318    ) -> Result<Box<dyn crate::traits::Range>, ExcelError> {
11319        // For now, delegate range resolution to the external resolver.
11320        // A future optimization could be to handle this within the graph.
11321        self.resolver.resolve_range_reference(sheet, sr, sc, er, ec)
11322    }
11323}
11324
11325impl<R> crate::traits::NamedRangeResolver for Engine<R>
11326where
11327    R: EvaluationContext,
11328{
11329    fn resolve_named_range_reference(
11330        &self,
11331        name: &str,
11332    ) -> Result<Vec<Vec<LiteralValue>>, ExcelError> {
11333        self.resolver.resolve_named_range_reference(name)
11334    }
11335}
11336
11337impl<R> crate::traits::TableResolver for Engine<R>
11338where
11339    R: EvaluationContext,
11340{
11341    fn resolve_table_reference(
11342        &self,
11343        tref: &formualizer_parse::parser::TableReference,
11344    ) -> Result<Box<dyn crate::traits::Table>, ExcelError> {
11345        self.resolver.resolve_table_reference(tref)
11346    }
11347}
11348
11349impl<R> crate::traits::SourceResolver for Engine<R>
11350where
11351    R: EvaluationContext,
11352{
11353    fn source_scalar_version(&self, name: &str) -> Option<u64> {
11354        self.resolver.source_scalar_version(name)
11355    }
11356
11357    fn resolve_source_scalar(&self, name: &str) -> Result<LiteralValue, ExcelError> {
11358        self.resolver.resolve_source_scalar(name)
11359    }
11360
11361    fn source_table_version(&self, name: &str) -> Option<u64> {
11362        self.resolver.source_table_version(name)
11363    }
11364
11365    fn resolve_source_table(
11366        &self,
11367        name: &str,
11368    ) -> Result<Box<dyn crate::traits::Table>, ExcelError> {
11369        self.resolver.resolve_source_table(name)
11370    }
11371}
11372
11373// The Engine is a Resolver because it implements the constituent traits.
11374impl<R> crate::traits::Resolver for Engine<R> where R: EvaluationContext {}
11375
11376// The Engine provides functions by delegating to its internal resolver.
11377impl<R> crate::traits::FunctionProvider for Engine<R>
11378where
11379    R: EvaluationContext,
11380{
11381    fn get_function(
11382        &self,
11383        prefix: &str,
11384        name: &str,
11385    ) -> Option<std::sync::Arc<dyn crate::function::Function>> {
11386        self.resolver.get_function(prefix, name)
11387    }
11388}
11389
11390// Override EvaluationContext to provide thread pool access
11391impl<R> crate::traits::EvaluationContext for Engine<R>
11392where
11393    R: EvaluationContext,
11394{
11395    fn clock(&self) -> &dyn crate::timezone::ClockProvider {
11396        &self.clock
11397    }
11398
11399    fn thread_pool(&self) -> Option<&Arc<rayon::ThreadPool>> {
11400        self.thread_pool.as_ref()
11401    }
11402
11403    fn cancellation_token(&self) -> Option<Arc<std::sync::atomic::AtomicBool>> {
11404        self.active_cancel_flag.clone()
11405    }
11406
11407    fn chunk_hint(&self) -> Option<usize> {
11408        // Use a simple heuristic from configuration (stripe width * height) as a default hint.
11409        let hint =
11410            (self.config.stripe_height as usize).saturating_mul(self.config.stripe_width as usize);
11411        Some(hint.clamp(1024, 1 << 20)) // clamp between 1K and ~1M
11412    }
11413
11414    fn volatile_level(&self) -> crate::traits::VolatileLevel {
11415        self.config.volatile_level
11416    }
11417
11418    fn workbook_seed(&self) -> u64 {
11419        self.config.workbook_seed
11420    }
11421
11422    fn recalc_epoch(&self) -> u64 {
11423        self.recalc_epoch
11424    }
11425
11426    fn workbook_sheet_count(&self) -> Option<usize> {
11427        Some(self.graph.sheet_reg().active_len())
11428    }
11429
11430    fn sheet_index_by_name(&self, sheet: &str) -> Option<usize> {
11431        self.graph.sheet_reg().active_position(sheet)
11432    }
11433
11434    fn current_sheet_index(&self, current_sheet: &str) -> Option<usize> {
11435        self.sheet_index_by_name(current_sheet)
11436    }
11437
11438    fn inspect_reference(
11439        &self,
11440        reference: &ReferenceType,
11441        current_sheet: &str,
11442    ) -> Result<Option<ReferenceInfo>, ExcelError> {
11443        let sheet_info = |sheet_name: &str| -> Result<(SheetId, usize), ExcelError> {
11444            let sheet_id = self
11445                .graph
11446                .sheet_id(sheet_name)
11447                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11448            let sheet_index = self
11449                .graph
11450                .sheet_reg()
11451                .active_position_by_id(sheet_id)
11452                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11453            Ok((sheet_id, sheet_index))
11454        };
11455
11456        let cell_info =
11457            |sheet_name: &str, row: u32, col: u32| -> Result<ReferenceInfo, ExcelError> {
11458                let (sheet_id, sheet_index) = sheet_info(sheet_name)?;
11459                let row0 = row
11460                    .checked_sub(1)
11461                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11462                let col0 = col
11463                    .checked_sub(1)
11464                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11465                Ok(ReferenceInfo {
11466                    first_sheet_index: Some(sheet_index),
11467                    sheet_count: Some(1),
11468                    first_cell: Some(CellRef::new(sheet_id, Coord::new(row0, col0, true, true))),
11469                })
11470            };
11471
11472        let range_info = |sheet_name: &str,
11473                          start_row: Option<u32>,
11474                          start_col: Option<u32>|
11475         -> Result<ReferenceInfo, ExcelError> {
11476            let (sheet_id, sheet_index) = sheet_info(sheet_name)?;
11477            let row = start_row.unwrap_or(1);
11478            let col = start_col.unwrap_or(1);
11479            let row0 = row
11480                .checked_sub(1)
11481                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11482            let col0 = col
11483                .checked_sub(1)
11484                .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11485            Ok(ReferenceInfo {
11486                first_sheet_index: Some(sheet_index),
11487                sheet_count: Some(1),
11488                first_cell: Some(CellRef::new(sheet_id, Coord::new(row0, col0, true, true))),
11489            })
11490        };
11491
11492        let info = match reference {
11493            ReferenceType::Cell {
11494                sheet, row, col, ..
11495            } => {
11496                let sheet_name = sheet.as_deref().unwrap_or(current_sheet);
11497                cell_info(sheet_name, *row, *col)?
11498            }
11499            ReferenceType::Range {
11500                sheet,
11501                start_row,
11502                start_col,
11503                ..
11504            } => {
11505                let sheet_name = sheet.as_deref().unwrap_or(current_sheet);
11506                range_info(sheet_name, *start_row, *start_col)?
11507            }
11508            ReferenceType::Cell3D {
11509                sheet_first,
11510                sheet_last,
11511                row,
11512                col,
11513                ..
11514            } => {
11515                let first = cell_info(sheet_first, *row, *col)?;
11516                ReferenceInfo {
11517                    first_sheet_index: first.first_sheet_index,
11518                    sheet_count: self
11519                        .graph
11520                        .sheet_reg()
11521                        .active_span_len(sheet_first, sheet_last),
11522                    first_cell: first.first_cell,
11523                }
11524            }
11525            ReferenceType::Range3D {
11526                sheet_first,
11527                sheet_last,
11528                start_row,
11529                start_col,
11530                ..
11531            } => {
11532                let first = range_info(sheet_first, *start_row, *start_col)?;
11533                ReferenceInfo {
11534                    first_sheet_index: first.first_sheet_index,
11535                    sheet_count: self
11536                        .graph
11537                        .sheet_reg()
11538                        .active_span_len(sheet_first, sheet_last),
11539                    first_cell: first.first_cell,
11540                }
11541            }
11542            ReferenceType::NamedRange(name) => {
11543                let current_id = self
11544                    .graph
11545                    .sheet_id(current_sheet)
11546                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11547                let named = self
11548                    .graph
11549                    .resolve_name_entry(name, current_id)
11550                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11551                match &named.definition {
11552                    NamedDefinition::Cell(cell) => ReferenceInfo {
11553                        first_sheet_index: self
11554                            .graph
11555                            .sheet_reg()
11556                            .active_position_by_id(cell.sheet_id),
11557                        sheet_count: Some(1),
11558                        first_cell: Some(*cell),
11559                    },
11560                    NamedDefinition::Range(range) => ReferenceInfo {
11561                        first_sheet_index: self
11562                            .graph
11563                            .sheet_reg()
11564                            .active_position_by_id(range.start.sheet_id),
11565                        sheet_count: Some(1),
11566                        first_cell: Some(range.start),
11567                    },
11568                    NamedDefinition::Literal(_) | NamedDefinition::Formula { .. } => {
11569                        ReferenceInfo {
11570                            first_sheet_index: None,
11571                            sheet_count: None,
11572                            first_cell: None,
11573                        }
11574                    }
11575                }
11576            }
11577            ReferenceType::Table(tref) => {
11578                let table = self
11579                    .graph
11580                    .resolve_table_entry(&tref.name)
11581                    .ok_or_else(|| ExcelError::new(ExcelErrorKind::Ref))?;
11582                ReferenceInfo {
11583                    first_sheet_index: self
11584                        .graph
11585                        .sheet_reg()
11586                        .active_position_by_id(table.range.start.sheet_id),
11587                    sheet_count: Some(1),
11588                    first_cell: Some(table.range.start),
11589                }
11590            }
11591            ReferenceType::External(_) => return Err(ExcelError::new(ExcelErrorKind::Ref)),
11592        };
11593
11594        Ok(Some(info))
11595    }
11596
11597    fn formula_text_at_cell(&self, cell: CellRef) -> Result<Option<String>, ExcelError> {
11598        let sheet_name = self.graph.sheet_name(cell.sheet_id);
11599        if sheet_name.is_empty() {
11600            return Err(ExcelError::new(ExcelErrorKind::Ref));
11601        }
11602        let row = cell.coord.row() + 1;
11603        let col = cell.coord.col() + 1;
11604
11605        if let Some(entries) = self.staged_formulas.get(sheet_name)
11606            && let Some(text) = entries.get(row, col)
11607        {
11608            return Ok(Some(if text.starts_with('=') {
11609                text.to_owned()
11610            } else {
11611                format!("={text}")
11612            }));
11613        }
11614
11615        let Some(vertex) = self.graph.get_vertex_for_cell(&cell) else {
11616            return Ok(None);
11617        };
11618        let Some(ast) = self.graph.get_formula(vertex) else {
11619            return Ok(None);
11620        };
11621        Ok(Some(formualizer_parse::pretty::canonical_formula(&ast)))
11622    }
11623
11624    fn used_rows_for_columns(
11625        &self,
11626        sheet: &str,
11627        start_col: u32,
11628        end_col: u32,
11629    ) -> Option<(u32, u32)> {
11630        // Union Arrow-backed used-region with formula rows that have not been materialized yet.
11631        let sheet_id = self.graph.sheet_id(sheet)?;
11632        let snap = self.data_snapshot_id();
11633        if let Some(cached) = self.used_axis_bounds_cache.read().ok().and_then(|guard| {
11634            guard
11635                .as_ref()
11636                .and_then(|cache| cache.get_row_bounds(sheet_id, start_col, end_col, snap))
11637        }) {
11638            return cached;
11639        }
11640
11641        let arrow_bounds = self
11642            .sheet_store()
11643            .sheet(sheet)
11644            .and_then(|_| self.arrow_used_row_bounds(sheet, start_col, end_col));
11645        let formula_bounds = self.formula_row_bounds_for_columns(sheet, start_col, end_col);
11646        let computed = if let Some(bounds) = Self::union_used_bounds(arrow_bounds, formula_bounds) {
11647            Some(bounds)
11648        } else {
11649            let sc0 = start_col.saturating_sub(1);
11650            let ec0 = end_col.saturating_sub(1);
11651            self.graph
11652                .used_row_bounds_for_columns(sheet_id, sc0, ec0)
11653                .map(|(a0, b0)| (a0 + 1, b0 + 1))
11654        };
11655
11656        if let Ok(mut guard) = self.used_axis_bounds_cache.write() {
11657            guard
11658                .get_or_insert_with(|| UsedAxisBoundsCache::new(snap))
11659                .put_row_bounds(sheet_id, start_col, end_col, snap, computed);
11660        }
11661
11662        computed
11663    }
11664
11665    fn used_cols_for_rows(&self, sheet: &str, start_row: u32, end_row: u32) -> Option<(u32, u32)> {
11666        // Union Arrow-backed used-region with formula columns that have not been materialized yet.
11667        let sheet_id = self.graph.sheet_id(sheet)?;
11668        let snap = self.data_snapshot_id();
11669        if let Some(cached) = self.used_axis_bounds_cache.read().ok().and_then(|guard| {
11670            guard
11671                .as_ref()
11672                .and_then(|cache| cache.get_col_bounds(sheet_id, start_row, end_row, snap))
11673        }) {
11674            return cached;
11675        }
11676
11677        let arrow_bounds = self
11678            .sheet_store()
11679            .sheet(sheet)
11680            .and_then(|_| self.arrow_used_col_bounds(sheet, start_row, end_row));
11681        let formula_bounds = self.formula_col_bounds_for_rows(sheet, start_row, end_row);
11682        let computed = if let Some(bounds) = Self::union_used_bounds(arrow_bounds, formula_bounds) {
11683            Some(bounds)
11684        } else {
11685            let sr0 = start_row.saturating_sub(1);
11686            let er0 = end_row.saturating_sub(1);
11687            self.graph
11688                .used_col_bounds_for_rows(sheet_id, sr0, er0)
11689                .map(|(a0, b0)| (a0 + 1, b0 + 1))
11690        };
11691
11692        if let Ok(mut guard) = self.used_axis_bounds_cache.write() {
11693            guard
11694                .get_or_insert_with(|| UsedAxisBoundsCache::new(snap))
11695                .put_col_bounds(sheet_id, start_row, end_row, snap, computed);
11696        }
11697
11698        computed
11699    }
11700
11701    fn sheet_bounds(&self, sheet: &str) -> Option<(u32, u32)> {
11702        let _ = self.graph.sheet_id(sheet)?;
11703        // Excel-like upper bounds; we expose something finite but large.
11704        // Backends may override with real bounds.
11705        Some((1_048_576, 16_384)) // 1048576 rows, 16384 cols (XFD)
11706    }
11707
11708    fn data_snapshot_id(&self) -> u64 {
11709        self.snapshot_id.load(std::sync::atomic::Ordering::Relaxed)
11710    }
11711
11712    fn backend_caps(&self) -> crate::traits::BackendCaps {
11713        crate::traits::BackendCaps {
11714            streaming: true,
11715            used_region: true,
11716            write: false,
11717            tables: false,
11718            async_stream: false,
11719        }
11720    }
11721
11722    fn build_lookup_index(
11723        &self,
11724        view: &RangeView<'_>,
11725        axis: LookupAxis,
11726    ) -> Option<Arc<LookupIndex>> {
11727        self.build_lookup_index_impl(view, axis)
11728    }
11729
11730    // Flats removed
11731
11732    fn date_system(&self) -> crate::engine::DateSystem {
11733        self.config.date_system
11734    }
11735    /// New: resolve a reference into a RangeView (Phase 2 API)
11736    fn resolve_range_view<'c>(
11737        &'c self,
11738        reference: &ReferenceType,
11739        current_sheet: &str,
11740    ) -> Result<RangeView<'c>, ExcelError> {
11741        match reference {
11742            ReferenceType::External(ext) => {
11743                let name = ext.raw.as_str();
11744                match ext.kind {
11745                    formualizer_parse::parser::ExternalRefKind::Cell { .. } => {
11746                        let Some(source) = self.graph.resolve_source_scalar_entry(name) else {
11747                            return Err(ExcelError::new(ExcelErrorKind::Name)
11748                                .with_message(format!("Undefined name: {name}")));
11749                        };
11750                        let version = source
11751                            .version
11752                            .or_else(|| self.resolver.source_scalar_version(name));
11753                        let v = self.resolve_source_scalar_cached(name, version)?;
11754                        Ok(RangeView::from_owned_rows(
11755                            vec![vec![v]],
11756                            self.config.date_system,
11757                        ))
11758                    }
11759                    formualizer_parse::parser::ExternalRefKind::Range { .. } => {
11760                        let Some(source) = self.graph.resolve_source_table_entry(name) else {
11761                            return Err(ExcelError::new(ExcelErrorKind::Name)
11762                                .with_message(format!("Undefined table: {name}")));
11763                        };
11764                        let version = source
11765                            .version
11766                            .or_else(|| self.resolver.source_table_version(name));
11767                        let table = self.resolve_source_table_cached(name, version)?;
11768                        let spec = Some(formualizer_parse::parser::TableSpecifier::Data);
11769                        self.source_table_to_range_view(table.as_ref(), &spec)
11770                    }
11771                }
11772            }
11773            ReferenceType::Range { .. } => {
11774                let shared = self.resolve_shared_ref(reference, current_sheet)?;
11775                let formualizer_common::SheetRef::Range(range) = shared else {
11776                    return Err(ExcelError::new(ExcelErrorKind::Ref));
11777                };
11778                let sheet_id = match range.sheet {
11779                    formualizer_common::SheetLocator::Id(id) => id,
11780                    _ => return Err(ExcelError::new(ExcelErrorKind::Ref)),
11781                };
11782                let sheet_name = self.graph.sheet_name(sheet_id);
11783
11784                let bounded_range = if range.start_row.is_some()
11785                    && range.start_col.is_some()
11786                    && range.end_row.is_some()
11787                    && range.end_col.is_some()
11788                {
11789                    Some(RangeRef::try_from_shared(range.as_ref())?)
11790                } else {
11791                    None
11792                };
11793
11794                let mut sr = bounded_range
11795                    .as_ref()
11796                    .map(|r| r.start.coord.row() + 1)
11797                    .or_else(|| range.start_row.map(|b| b.index + 1));
11798                let mut sc = bounded_range
11799                    .as_ref()
11800                    .map(|r| r.start.coord.col() + 1)
11801                    .or_else(|| range.start_col.map(|b| b.index + 1));
11802                let mut er = bounded_range
11803                    .as_ref()
11804                    .map(|r| r.end.coord.row() + 1)
11805                    .or_else(|| range.end_row.map(|b| b.index + 1));
11806                let mut ec = bounded_range
11807                    .as_ref()
11808                    .map(|r| r.end.coord.col() + 1)
11809                    .or_else(|| range.end_col.map(|b| b.index + 1));
11810
11811                if sr.is_none() && er.is_none() {
11812                    // Full-column reference: anchor at row 1
11813                    let scv = sc.unwrap_or(1);
11814                    let ecv = ec.unwrap_or(scv);
11815                    sr = Some(1);
11816                    if let Some((_, max_r)) = self.used_rows_for_columns(sheet_name, scv, ecv) {
11817                        er = Some(max_r);
11818                    } else if let Some((max_rows, _)) = self.sheet_bounds(sheet_name) {
11819                        er = Some(self.config.max_open_ended_rows);
11820                    }
11821                }
11822                if sc.is_none() && ec.is_none() {
11823                    // Full-row reference: anchor at column 1
11824                    let srv = sr.unwrap_or(1);
11825                    let erv = er.unwrap_or(srv);
11826                    sc = Some(1);
11827                    if let Some((_, max_c)) = self.used_cols_for_rows(sheet_name, srv, erv) {
11828                        ec = Some(max_c);
11829                    } else if let Some((_, max_cols)) = self.sheet_bounds(sheet_name) {
11830                        ec = Some(self.config.max_open_ended_cols);
11831                    }
11832                }
11833                if sr.is_some() && er.is_none() {
11834                    let scv = sc.unwrap_or(1);
11835                    let ecv = ec.unwrap_or(scv);
11836                    if let Some((_, max_r)) = self.used_rows_for_columns(sheet_name, scv, ecv) {
11837                        er = Some(max_r);
11838                    } else if let Some((max_rows, _)) = self.sheet_bounds(sheet_name) {
11839                        er = Some(self.config.max_open_ended_rows);
11840                    }
11841                }
11842                if er.is_some() && sr.is_none() {
11843                    // Open start: anchor at row 1
11844                    sr = Some(1);
11845                }
11846                if sc.is_some() && ec.is_none() {
11847                    let srv = sr.unwrap_or(1);
11848                    let erv = er.unwrap_or(srv);
11849                    if let Some((_, max_c)) = self.used_cols_for_rows(sheet_name, srv, erv) {
11850                        ec = Some(max_c);
11851                    } else if let Some((_, max_cols)) = self.sheet_bounds(sheet_name) {
11852                        ec = Some(self.config.max_open_ended_cols);
11853                    }
11854                }
11855                if ec.is_some() && sc.is_none() {
11856                    // Open start: anchor at column 1
11857                    sc = Some(1);
11858                }
11859
11860                let sr = sr.unwrap_or(1);
11861                let sc = sc.unwrap_or(1);
11862                let er = er.unwrap_or(sr.saturating_sub(1));
11863                let ec = ec.unwrap_or(sc.saturating_sub(1));
11864
11865                if self.force_materialize_range_views {
11866                    if er < sr || ec < sc {
11867                        return Ok(RangeView::from_owned_rows(
11868                            Vec::new(),
11869                            self.config.date_system,
11870                        ));
11871                    }
11872                    let h = (er - sr + 1) as u64;
11873                    let w = (ec - sc + 1) as u64;
11874                    let cell_count = h.saturating_mul(w);
11875                    if cell_count <= self.config.spill.max_spill_cells as u64 {
11876                        let mut rows: Vec<Vec<LiteralValue>> = Vec::with_capacity(h as usize);
11877                        for r in sr..=er {
11878                            let mut rowv: Vec<LiteralValue> = Vec::with_capacity(w as usize);
11879                            for c in sc..=ec {
11880                                rowv.push(
11881                                    self.get_cell_value(sheet_name, r, c)
11882                                        .unwrap_or(LiteralValue::Empty),
11883                                );
11884                            }
11885                            rows.push(rowv);
11886                        }
11887                        return Ok(RangeView::from_owned_rows(rows, self.config.date_system));
11888                    }
11889                }
11890
11891                let Some(asheet) = self.sheet_store().sheet(sheet_name) else {
11892                    return Ok(RangeView::from_owned_rows(
11893                        Vec::new(),
11894                        self.config.date_system,
11895                    ));
11896                };
11897
11898                let rv = if er < sr || ec < sc {
11899                    asheet.range_view(1, 1, 0, 0)
11900                } else {
11901                    let sr0 = sr.saturating_sub(1) as usize;
11902                    let sc0 = sc.saturating_sub(1) as usize;
11903                    let er0 = er.saturating_sub(1) as usize;
11904                    let ec0 = ec.saturating_sub(1) as usize;
11905                    asheet.range_view(sr0, sc0, er0, ec0)
11906                };
11907
11908                Ok(rv)
11909            }
11910            ReferenceType::Cell { .. } => {
11911                let shared = self.resolve_shared_ref(reference, current_sheet)?;
11912                let formualizer_common::SheetRef::Cell(cell) = shared else {
11913                    return Err(ExcelError::new(ExcelErrorKind::Ref));
11914                };
11915                let addr = CellRef::try_from_shared(cell)?;
11916                let sheet_id = addr.sheet_id;
11917                let sheet_name = self.graph.sheet_name(sheet_id);
11918                let row = addr.coord.row() + 1;
11919                let col = addr.coord.col() + 1;
11920
11921                if self.force_materialize_range_views {
11922                    let v = self
11923                        .get_cell_value(sheet_name, row, col)
11924                        .unwrap_or(LiteralValue::Empty);
11925                    return Ok(RangeView::from_owned_rows(
11926                        vec![vec![v]],
11927                        self.config.date_system,
11928                    ));
11929                }
11930
11931                if let Some(asheet) = self.sheet_store().sheet(sheet_name) {
11932                    let r0 = row.saturating_sub(1) as usize;
11933                    let c0 = col.saturating_sub(1) as usize;
11934                    let rv = asheet.range_view(r0, c0, r0, c0);
11935                    Ok(rv)
11936                } else {
11937                    let v = self
11938                        .get_cell_value(sheet_name, row, col)
11939                        .unwrap_or(LiteralValue::Empty);
11940                    Ok(RangeView::from_owned_rows(
11941                        vec![vec![v]],
11942                        self.config.date_system,
11943                    ))
11944                }
11945            }
11946            ReferenceType::NamedRange(name) => {
11947                if let Some(current_id) = self.graph.sheet_id(current_sheet)
11948                    && let Some(named) = self.graph.resolve_name_entry(name, current_id)
11949                {
11950                    match &named.definition {
11951                        NamedDefinition::Cell(cell_ref) => {
11952                            let sheet_name = self.graph.sheet_name(cell_ref.sheet_id);
11953                            if self.force_materialize_range_views {
11954                                let v = self
11955                                    .get_cell_value(
11956                                        sheet_name,
11957                                        cell_ref.coord.row() + 1,
11958                                        cell_ref.coord.col() + 1,
11959                                    )
11960                                    .unwrap_or(LiteralValue::Empty);
11961                                return Ok(RangeView::from_owned_rows(
11962                                    vec![vec![v]],
11963                                    self.config.date_system,
11964                                ));
11965                            } else {
11966                                let asheet = self
11967                                    .sheet_store()
11968                                    .sheet(sheet_name)
11969                                    .expect("Arrow sheet missing for named cell");
11970                                let r0 = cell_ref.coord.row() as usize;
11971                                let c0 = cell_ref.coord.col() as usize;
11972                                let rv = asheet.range_view(r0, c0, r0, c0);
11973                                return Ok(rv);
11974                            }
11975                        }
11976                        NamedDefinition::Range(range_ref) => {
11977                            let sheet_name = self.graph.sheet_name(range_ref.start.sheet_id);
11978                            let sr = range_ref.start.coord.row() + 1;
11979                            let sc = range_ref.start.coord.col() + 1;
11980                            let er = range_ref.end.coord.row() + 1;
11981                            let ec = range_ref.end.coord.col() + 1;
11982                            if self.force_materialize_range_views {
11983                                let h = (er.saturating_sub(sr) + 1) as u64;
11984                                let w = (ec.saturating_sub(sc) + 1) as u64;
11985                                let cell_count = h.saturating_mul(w);
11986                                if cell_count <= self.config.spill.max_spill_cells as u64 {
11987                                    let mut rows: Vec<Vec<LiteralValue>> =
11988                                        Vec::with_capacity(h as usize);
11989                                    for r in sr..=er {
11990                                        let mut rowv: Vec<LiteralValue> =
11991                                            Vec::with_capacity(w as usize);
11992                                        for c in sc..=ec {
11993                                            rowv.push(
11994                                                self.get_cell_value(sheet_name, r, c)
11995                                                    .unwrap_or(LiteralValue::Empty),
11996                                            );
11997                                        }
11998                                        rows.push(rowv);
11999                                    }
12000                                    return Ok(RangeView::from_owned_rows(
12001                                        rows,
12002                                        self.config.date_system,
12003                                    ));
12004                                }
12005                            }
12006                            let asheet = self
12007                                .sheet_store()
12008                                .sheet(sheet_name)
12009                                .expect("Arrow sheet missing for named range");
12010                            let sr0 = range_ref.start.coord.row() as usize;
12011                            let sc0 = range_ref.start.coord.col() as usize;
12012                            let er0 = range_ref.end.coord.row() as usize;
12013                            let ec0 = range_ref.end.coord.col() as usize;
12014                            let rv = asheet.range_view(sr0, sc0, er0, ec0);
12015                            return Ok(rv);
12016                        }
12017                        NamedDefinition::Literal(v) => {
12018                            return Ok(RangeView::from_owned_rows(
12019                                vec![vec![v.clone()]],
12020                                self.config.date_system,
12021                            ));
12022                        }
12023                        NamedDefinition::Formula { .. } => {
12024                            if let Some(value) = self.graph.get_value(named.vertex) {
12025                                return Ok(RangeView::from_owned_rows(
12026                                    vec![vec![value]],
12027                                    self.config.date_system,
12028                                ));
12029                            }
12030                        }
12031                    }
12032                }
12033
12034                if let Some(source) = self.graph.resolve_source_scalar_entry(name) {
12035                    let version = source
12036                        .version
12037                        .or_else(|| self.resolver.source_scalar_version(name));
12038                    let v = self.resolve_source_scalar_cached(name, version)?;
12039                    return Ok(RangeView::from_owned_rows(
12040                        vec![vec![v]],
12041                        self.config.date_system,
12042                    ));
12043                }
12044
12045                let data = self.resolver.resolve_named_range_reference(name)?;
12046                Ok(RangeView::from_owned_rows(data, self.config.date_system))
12047            }
12048            ReferenceType::Table(tref) => {
12049                if let Some(table) = self.graph.resolve_table_entry(&tref.name) {
12050                    let sheet_name = self.graph.sheet_name(table.range.start.sheet_id);
12051                    let asheet = self
12052                        .sheet_store()
12053                        .sheet(sheet_name)
12054                        .expect("Arrow sheet missing for table reference");
12055
12056                    let sr0 = table.range.start.coord.row() as usize;
12057                    let sc0 = table.range.start.coord.col() as usize;
12058                    let er0 = table.range.end.coord.row() as usize;
12059                    let ec0 = table.range.end.coord.col() as usize;
12060
12061                    let has_totals = table.totals_row;
12062                    let has_headers = table.header_row;
12063                    let data_sr = if has_headers {
12064                        sr0.saturating_add(1)
12065                    } else {
12066                        sr0
12067                    };
12068                    let data_er = if has_totals {
12069                        er0.saturating_sub(1)
12070                    } else {
12071                        er0
12072                    };
12073
12074                    let select = |sr: usize, sc: usize, er: usize, ec: usize| {
12075                        if sr > er || sc > ec {
12076                            asheet.range_view(1, 1, 0, 0)
12077                        } else {
12078                            asheet.range_view(sr, sc, er, ec)
12079                        }
12080                    };
12081
12082                    let av = match &tref.specifier {
12083                        None => {
12084                            return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
12085                                "Table reference without specifier is unsupported".to_string(),
12086                            ));
12087                        }
12088                        Some(formualizer_parse::parser::TableSpecifier::Column(col)) => {
12089                            let Some(idx) = table.col_index(col) else {
12090                                return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(
12091                                    "Column refers to unknown table column".to_string(),
12092                                ));
12093                            };
12094                            let c0 = sc0 + idx;
12095                            select(data_sr, c0, data_er, c0)
12096                        }
12097                        Some(formualizer_parse::parser::TableSpecifier::ColumnRange(
12098                            start,
12099                            end,
12100                        )) => {
12101                            let Some(si) = table.col_index(start) else {
12102                                return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(
12103                                    "Column range refers to unknown column(s)".to_string(),
12104                                ));
12105                            };
12106                            let Some(ei) = table.col_index(end) else {
12107                                return Err(ExcelError::new(ExcelErrorKind::Ref).with_message(
12108                                    "Column range refers to unknown column(s)".to_string(),
12109                                ));
12110                            };
12111                            let (mut a, mut b) = (si, ei);
12112                            if a > b {
12113                                std::mem::swap(&mut a, &mut b);
12114                            }
12115                            let c_start = sc0 + a;
12116                            let c_end = sc0 + b;
12117                            select(data_sr, c_start, data_er, c_end)
12118                        }
12119                        Some(formualizer_parse::parser::TableSpecifier::All)
12120                        | Some(formualizer_parse::parser::TableSpecifier::SpecialItem(
12121                            formualizer_parse::parser::SpecialItem::All,
12122                        )) => select(sr0, sc0, er0, ec0),
12123                        Some(formualizer_parse::parser::TableSpecifier::Data)
12124                        | Some(formualizer_parse::parser::TableSpecifier::SpecialItem(
12125                            formualizer_parse::parser::SpecialItem::Data,
12126                        )) => select(data_sr, sc0, data_er, ec0),
12127                        Some(formualizer_parse::parser::TableSpecifier::Headers)
12128                        | Some(formualizer_parse::parser::TableSpecifier::SpecialItem(
12129                            formualizer_parse::parser::SpecialItem::Headers,
12130                        )) => {
12131                            if !has_headers {
12132                                asheet.range_view(1, 1, 0, 0)
12133                            } else {
12134                                select(sr0, sc0, sr0, ec0)
12135                            }
12136                        }
12137                        Some(formualizer_parse::parser::TableSpecifier::Totals)
12138                        | Some(formualizer_parse::parser::TableSpecifier::SpecialItem(
12139                            formualizer_parse::parser::SpecialItem::Totals,
12140                        )) => {
12141                            if !has_totals {
12142                                asheet.range_view(1, 1, 0, 0)
12143                            } else {
12144                                select(er0, sc0, er0, ec0)
12145                            }
12146                        }
12147                        Some(formualizer_parse::parser::TableSpecifier::SpecialItem(
12148                            formualizer_parse::parser::SpecialItem::ThisRow,
12149                        )) => {
12150                            return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
12151                                "@ (This Row) requires table-aware context; not yet supported"
12152                                    .to_string(),
12153                            ));
12154                        }
12155                        Some(formualizer_parse::parser::TableSpecifier::Row(_))
12156                        | Some(formualizer_parse::parser::TableSpecifier::Combination(_)) => {
12157                            return Err(ExcelError::new(ExcelErrorKind::NImpl).with_message(
12158                                "Complex structured references not yet supported".to_string(),
12159                            ));
12160                        }
12161                    };
12162
12163                    return Ok(av);
12164                }
12165
12166                if let Some(source) = self.graph.resolve_source_table_entry(&tref.name) {
12167                    let version = source
12168                        .version
12169                        .or_else(|| self.resolver.source_table_version(&tref.name));
12170                    let table = self.resolve_source_table_cached(&tref.name, version)?;
12171                    return self.source_table_to_range_view(table.as_ref(), &tref.specifier);
12172                }
12173
12174                // Fallback: materialize via Resolver::resolve_range_like tranche 1
12175                let boxed = self.resolve_range_like(&ReferenceType::Table(tref.clone()))?;
12176                let owned = boxed.materialise().into_owned();
12177                Ok(RangeView::from_owned_rows(owned, self.config.date_system))
12178            }
12179            ReferenceType::Cell3D { .. } | ReferenceType::Range3D { .. } => {
12180                Err(ExcelError::new(ExcelErrorKind::NImpl)
12181                    .with_message("3D references are not yet supported".to_string()))
12182            }
12183        }
12184    }
12185
12186    fn resolve_cell_reference_value(
12187        &self,
12188        sheet: Option<&str>,
12189        row: u32,
12190        col: u32,
12191        current_sheet: &str,
12192    ) -> Result<LiteralValue, ExcelError> {
12193        let sheet_name = sheet.unwrap_or(current_sheet);
12194        if self.graph.sheet_id(sheet_name).is_none() {
12195            return Err(ExcelError::new(ExcelErrorKind::Ref));
12196        }
12197        Ok(self
12198            .get_cell_value(sheet_name, row, col)
12199            .unwrap_or(LiteralValue::Empty))
12200    }
12201
12202    fn build_criteria_mask(
12203        &self,
12204        view: &RangeView<'_>,
12205        col_in_view: usize,
12206        pred: &crate::args::CriteriaPredicate,
12207    ) -> Option<std::sync::Arc<arrow_array::BooleanArray>> {
12208        if view.dims().1 == 0 {
12209            return None;
12210        }
12211        // If the view is logically open-ended but the backing sheet has no physical rows,
12212        // treat the mask as empty (0-len) rather than attempting to build a huge mask.
12213        let sheet_rows = view.sheet().nrows as usize;
12214        if sheet_rows == 0 || view.start_row() >= sheet_rows {
12215            return Some(std::sync::Arc::new(arrow_array::BooleanArray::new_null(0)));
12216        }
12217        compute_criteria_mask(view, col_in_view, pred)
12218    }
12219
12220    fn build_row_visibility_mask(
12221        &self,
12222        view: &RangeView<'_>,
12223        mode: VisibilityMaskMode,
12224    ) -> Option<std::sync::Arc<arrow_array::BooleanArray>> {
12225        self.build_row_visibility_mask_for_view(view, mode)
12226    }
12227}
12228
12229impl<R> Engine<R>
12230where
12231    R: EvaluationContext,
12232{
12233    fn clear_spill_projection_and_mirror(
12234        &mut self,
12235        anchor_vertex: VertexId,
12236        delta: Option<&mut DeltaCollector>,
12237    ) {
12238        let spill_cells = self
12239            .graph
12240            .spill_cells_for_anchor(anchor_vertex)
12241            .map(|cells| cells.to_vec())
12242            .unwrap_or_default();
12243        if spill_cells.is_empty() {
12244            return;
12245        }
12246
12247        if let Some(delta) = delta
12248            && delta.mode != DeltaMode::Off
12249        {
12250            let empty = LiteralValue::Empty;
12251            for cell in spill_cells.iter() {
12252                let sheet_name = self.graph.sheet_name(cell.sheet_id);
12253                let old = self
12254                    .get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
12255                    .unwrap_or(LiteralValue::Empty);
12256                if old != empty {
12257                    delta.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
12258                }
12259            }
12260        }
12261
12262        self.graph.clear_spill_region(anchor_vertex);
12263        if let Some(scope) = Self::formula_plane_region_from_cells(&spill_cells) {
12264            self.record_formula_plane_structural_change(scope);
12265        }
12266
12267        if self.config.arrow_storage_enabled
12268            && self.config.delta_overlay_enabled
12269            && self.config.write_formula_overlay_enabled
12270        {
12271            let empty = LiteralValue::Empty;
12272            for cell in spill_cells.iter() {
12273                let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
12274                self.mirror_value_to_computed_overlay(
12275                    &sheet_name,
12276                    cell.coord.row() + 1,
12277                    cell.coord.col() + 1,
12278                    &empty,
12279                );
12280            }
12281        }
12282    }
12283
12284    /// Apply the evaluation outcome for one cyclic SCC: stamp `#CIRC!` on its
12285    /// (optionally filtered) members via `stamp_cycle_error`.
12286    ///
12287    /// This is the single per-SCC application point used by every schedule
12288    /// consumer walking `Schedule::units` (pre-work for #112, where cyclic
12289    /// SCCs will gain runtime verdicts instead of an unconditional stamp).
12290    ///
12291    /// `dirty_filter` preserves the recalc-plan quirk: when `Some(dirty)`,
12292    /// only members present in the set are stamped.
12293    ///
12294    /// Returns the number of vertices stamped (0 when a filter excludes every
12295    /// member), so callers can keep their site-specific `cycle_errors`
12296    /// accounting.
12297    fn apply_cycle_outcome(
12298        &mut self,
12299        cycle: &[VertexId],
12300        mut delta: Option<&mut DeltaCollector>,
12301        dirty_filter: Option<&FxHashSet<VertexId>>,
12302    ) -> usize {
12303        let circ_error = LiteralValue::Error(
12304            ExcelError::new(ExcelErrorKind::Circ)
12305                .with_message("Circular dependency detected".to_string()),
12306        );
12307        let mut stamped = 0usize;
12308        for &vertex_id in cycle {
12309            if let Some(filter) = dirty_filter
12310                && !filter.contains(&vertex_id)
12311            {
12312                continue;
12313            }
12314            self.stamp_cycle_error(vertex_id, &circ_error, delta.as_deref_mut());
12315            stamped += 1;
12316        }
12317        stamped
12318    }
12319
12320    /// Stamp a vertex with `#CIRC!` as part of cycle handling.
12321    ///
12322    /// Unlike a bare `update_vertex_value`, this first tears down any spill the
12323    /// vertex previously anchored: it clears the spilled cells, releases the graph
12324    /// spill registry, drops any lingering region reservation, and mirrors the
12325    /// cleared cells into the computed overlay — the same teardown a normal scalar/
12326    /// error result performs (see `apply_non_array_result_from_parallel` /
12327    /// `clear_spill_projection_and_mirror`). Without this, a #CIRC stamp on a former
12328    /// spill anchor would leave stale spilled values and a reserved region behind
12329    /// (issue #111).
12330    ///
12331    /// When `delta` is provided, the cleared spill cells are recorded (by
12332    /// `clear_spill_projection_and_mirror`) and the anchor's own #CIRC change is
12333    /// recorded here, matching how other result paths emit deltas.
12334    fn stamp_cycle_error(
12335        &mut self,
12336        vertex_id: VertexId,
12337        circ_error: &LiteralValue,
12338        mut delta: Option<&mut DeltaCollector>,
12339    ) {
12340        // Tear down any previous spill projection/region before overwriting the anchor.
12341        if self.graph.spill_registry_has_anchor(vertex_id) {
12342            self.clear_spill_projection_and_mirror(vertex_id, delta.as_deref_mut());
12343        }
12344        // Drop any reservation that was never committed (defensive; normally released
12345        // on the prior successful commit).
12346        self.spill_mgr.release_owner(vertex_id);
12347
12348        // Record the anchor's own #CIRC delta, like other result paths.
12349        if let Some(d) = delta
12350            && d.mode != DeltaMode::Off
12351            && let Some(cell) = self.graph.get_cell_ref_for_vertex(vertex_id)
12352        {
12353            let sheet_name = self.graph.sheet_name(cell.sheet_id);
12354            let old = self
12355                .read_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
12356                .unwrap_or(LiteralValue::Empty);
12357            if old != *circ_error {
12358                d.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
12359            }
12360        }
12361
12362        self.graph
12363            .update_vertex_value(vertex_id, circ_error.clone());
12364        self.mirror_vertex_value_to_overlay(vertex_id, circ_error);
12365    }
12366
12367    /// Dispatch point for one `ScheduleUnit::Cycle` (RFC #112, Stage 2).
12368    ///
12369    /// * `CycleDetection::Static` — today's behavior, byte-for-byte: stamp
12370    ///   `#CIRC!` on the (optionally dirty-filtered) members.
12371    /// * `CycleDetection::Runtime` — evaluate the SCC via
12372    ///   [`Self::evaluate_scc_unit`]. The recalc-plan dirty quirk maps to:
12373    ///   no dirty member → skip the task entirely (values stand); any dirty
12374    ///   member → the whole SCC evaluates (an SCC cannot be partially
12375    ///   evaluated).
12376    ///
12377    /// Returns the number of `#CIRC!`-stamped vertices, so call sites can
12378    /// keep their `cycle_errors` accounting (`> 0` ⇒ count the unit).
12379    fn handle_cycle_unit(
12380        &mut self,
12381        cycle: &[VertexId],
12382        mut delta: Option<&mut DeltaCollector>,
12383        dirty_filter: Option<&FxHashSet<VertexId>>,
12384        cancel_flag: Option<&AtomicBool>,
12385    ) -> Result<usize, ExcelError> {
12386        match self.config.cycle.detection {
12387            CycleDetection::Static => {
12388                Ok(self.apply_cycle_outcome(cycle, delta.as_deref_mut(), dirty_filter))
12389            }
12390            CycleDetection::Runtime => {
12391                if let Some(filter) = dirty_filter
12392                    && !cycle.iter().any(|v| filter.contains(v))
12393                {
12394                    return Ok(0);
12395                }
12396                // Both policies share `evaluate_scc_unit`; they differ only
12397                // in the settle loop's live-cycle arm (Error stamps,
12398                // Iterate keeps passing — RFC #113).
12399                self.evaluate_scc_unit(cycle, delta, cancel_flag)
12400            }
12401        }
12402    }
12403
12404    /// Evaluate one statically-cyclic SCC under `CycleDetection::Runtime`
12405    /// (design doc `formualizer-stage2-scc-evaluation-design.md` §3; contract
12406    /// spec §3; Iterate policy arm per RFC #113).
12407    ///
12408    /// Phantom SCCs (live-acyclic) produce ordinary values under both
12409    /// policies; live cycles get `#CIRC!` with live-cycle-only blast radius
12410    /// under `CyclePolicy::Error`, or Excel-style iterative calculation
12411    /// (converge per spec §6 or cap at `max_iterations` passes) under
12412    /// `CyclePolicy::Iterate`. Runs sequentially on the
12413    /// coordinating thread; commits are write-through per member (no
12414    /// `ComputedWriteBuffer` — that buffer is scoped to layer evaluation and
12415    /// always flushed before a Cycle unit runs, G1), so later members' scalar
12416    /// *and* range reads observe earlier members' results through the overlay
12417    /// cascade. Deltas are recorded once per member at end of task (G11).
12418    ///
12419    /// Returns the number of vertices stamped `#CIRC!`.
12420    ///
12421    /// `pub(crate)` so tests can drive SCC shapes (e.g. name-vertex members)
12422    /// that ingest-time cycle rejection makes unreachable via public edits.
12423    pub(crate) fn evaluate_scc_unit(
12424        &mut self,
12425        cycle: &[VertexId],
12426        mut delta: Option<&mut DeltaCollector>,
12427        cancel_flag: Option<&AtomicBool>,
12428    ) -> Result<usize, ExcelError> {
12429        struct SccMember {
12430            vertex: VertexId,
12431            cell: Option<CellRef>,
12432        }
12433
12434        let task_start = crate::instant::FzInstant::now();
12435
12436        // ── 0. Member order (spec §7.13): cells ascending (sheet, row, col);
12437        // name vertices after, lexicographic by folded canonical name; any
12438        // other vertex kind (defensive — `get_evaluation_vertices` only emits
12439        // formula/name kinds) last by id, never evaluated.
12440        let mut cell_members: Vec<(VertexId, CellRef)> = Vec::new();
12441        let mut name_members: Vec<(VertexId, String)> = Vec::new();
12442        let mut other_members: Vec<VertexId> = Vec::new();
12443        for &v in cycle {
12444            match self.graph.get_vertex_kind(v) {
12445                VertexKind::FormulaScalar | VertexKind::FormulaArray => {
12446                    match self.graph.get_cell_ref(v) {
12447                        Some(cell) => cell_members.push((v, cell)),
12448                        None => other_members.push(v),
12449                    }
12450                }
12451                VertexKind::NamedScalar | VertexKind::NamedArray => {
12452                    match self.graph.name_key_for_vertex(v) {
12453                        Some(key) => name_members.push((v, key)),
12454                        None => other_members.push(v),
12455                    }
12456                }
12457                _ => other_members.push(v),
12458            }
12459        }
12460        cell_members.sort_unstable_by_key(|(_, c)| (c.sheet_id, c.coord.row(), c.coord.col()));
12461        name_members.sort_unstable_by(|(av, ak), (bv, bk)| ak.cmp(bk).then(av.cmp(bv)));
12462        other_members.sort_unstable();
12463
12464        let cell_refs: Vec<CellRef> = cell_members.iter().map(|(_, c)| *c).collect();
12465        let name_keys: Vec<String> = name_members.iter().map(|(_, k)| k.clone()).collect();
12466        let mut members: Vec<SccMember> = Vec::with_capacity(cycle.len());
12467        for (v, c) in &cell_members {
12468            members.push(SccMember {
12469                vertex: *v,
12470                cell: Some(*c),
12471            });
12472        }
12473        for (v, _) in &name_members {
12474            members.push(SccMember {
12475                vertex: *v,
12476                cell: None,
12477            });
12478        }
12479        for v in &other_members {
12480            members.push(SccMember {
12481                vertex: *v,
12482                cell: None,
12483            });
12484        }
12485        let n = members.len();
12486        // Indices addressable by the collector (cells + names); `other`
12487        // members can be neither edge sources nor targets.
12488        let recordable = cell_refs.len() + name_keys.len();
12489
12490        let circ_error = LiteralValue::Error(
12491            ExcelError::new(ExcelErrorKind::Circ)
12492                .with_message("Circular dependency detected".to_string()),
12493        );
12494
12495        // ── 0b. Spec-§4 persistence repair: structural edits clear computed
12496        // overlays wholesale (`clear_computed_overlay_after_row/_col`), but
12497        // an iterating member's committed value is cycle STATE, not a
12498        // recomputable cache — and in canonical mode the overlay is its ONLY
12499        // home. If the overlay entry vanished since the last recalc, re-seed
12500        // it from the end-of-recalc snapshot (`iterative_state_values`) so
12501        // pass-1 reads (scalar AND range, via the overlay cascade) observe
12502        // the persisted value instead of silently restarting at Empty→0.
12503        // (Found by the iterate edge corpus: inserting/deleting an unrelated
12504        // row reset accumulators, violating spec §4/§7.15.)
12505        if !self.iterative_state_values.is_empty() {
12506            let restore: Vec<(VertexId, LiteralValue)> = members
12507                .iter()
12508                .filter_map(|m| {
12509                    let cell = m.cell?;
12510                    let persisted = self.iterative_state_values.get(&m.vertex)?;
12511                    let sheet_name = self.graph.sheet_name(cell.sheet_id);
12512                    let overlay = self
12513                        .get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
12514                        .unwrap_or(LiteralValue::Empty);
12515                    if matches!(overlay, LiteralValue::Empty) {
12516                        Some((m.vertex, persisted.clone()))
12517                    } else {
12518                        None
12519                    }
12520                })
12521                .collect();
12522            for (vertex, value) in restore {
12523                self.mirror_vertex_value_to_overlay(vertex, &value);
12524            }
12525        }
12526
12527        // ── 1. Pre-task value snapshot (overlay-first for cells — G3; the
12528        // graph value map may be evicted in value-cache-disabled mode).
12529        let snapshot: Vec<LiteralValue> = members
12530            .iter()
12531            .map(|m| match m.cell {
12532                Some(cell) => {
12533                    let sheet_name = self.graph.sheet_name(cell.sheet_id);
12534                    self.get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
12535                        .unwrap_or(LiteralValue::Empty)
12536                }
12537                None => self
12538                    .graph
12539                    .get_value(m.vertex)
12540                    .unwrap_or(LiteralValue::Empty),
12541            })
12542            .collect();
12543
12544        // ── 2. Pre-scan: spill anchors (FormulaArray) are stamped `#CIRC!`
12545        // with full spill teardown (spec §7.9, #115) and excluded from
12546        // evaluation. They stay recordable edge TARGETS (readers see `#CIRC!`
12547        // and propagate). Non-evaluable defensive members are excluded too.
12548        let mut excluded = vec![false; n];
12549        let mut last_value = snapshot.clone();
12550        let mut stamped = 0usize;
12551        for (i, m) in members.iter().enumerate() {
12552            match self.graph.get_vertex_kind(m.vertex) {
12553                VertexKind::FormulaArray => {
12554                    // Deltas for the cleared spill-region cells (non-members)
12555                    // can only be recorded here; the anchor's own delta is
12556                    // covered by the end-of-task snapshot comparison (dedup).
12557                    self.stamp_cycle_error(m.vertex, &circ_error, delta.as_deref_mut());
12558                    excluded[i] = true;
12559                    last_value[i] = circ_error.clone();
12560                    stamped += 1;
12561                }
12562                VertexKind::FormulaScalar | VertexKind::NamedScalar | VertexKind::NamedArray => {}
12563                _ => excluded[i] = true,
12564            }
12565        }
12566
12567        let collector = LiveEdgeCollector::new_with_names(&cell_refs, &name_keys);
12568
12569        // Per-member live out-edges, refreshed whenever a member re-runs.
12570        let mut out_edges: Vec<Vec<u32>> = vec![Vec::new(); n];
12571        // Position of each member in the most recent pass (-1 = did not run).
12572        let mut pos: Vec<i64> = vec![-1; n];
12573        // Whether each member's committed value changed in the most recent pass.
12574        let mut changed = vec![false; n];
12575
12576        // Evaluate-and-commit one member; returns Ok(true) when the member was
12577        // stamped `#CIRC!` (array result — would-be spill anchor, spec §7.9).
12578        macro_rules! run_member {
12579            ($i:expr) => {{
12580                let i: usize = $i;
12581                let m = &members[i];
12582                if i < recordable {
12583                    collector.set_current(i as u32);
12584                }
12585                let value = {
12586                    let ctx = RecordingContext::new(&*self, &collector);
12587                    match self.evaluate_vertex_recorded(m.vertex, &ctx, &collector) {
12588                        Ok(v) => v,
12589                        Err(e) => LiteralValue::Error(e),
12590                    }
12591                };
12592                let is_cell_formula = m.cell.is_some();
12593                if is_cell_formula && matches!(value, LiteralValue::Array(_)) {
12594                    // A member that *would* spill inside an SCC gets the
12595                    // conservative §7.9 verdict. It has never spilled before
12596                    // (a prior spill would make it FormulaArray, pre-stamped
12597                    // above), so there is no projection to tear down.
12598                    self.stamp_cycle_error(m.vertex, &circ_error, None);
12599                    excluded[i] = true;
12600                    stamped += 1;
12601                    changed[i] = last_value[i] != circ_error;
12602                    last_value[i] = circ_error.clone();
12603                } else {
12604                    self.graph.update_vertex_value(m.vertex, value.clone());
12605                    self.mirror_vertex_value_to_overlay(m.vertex, &value);
12606                    // §7.14 invariant (G2): a formula member must never be
12607                    // shadowed by a user/delta overlay entry, or iteration
12608                    // reads would silently diverge from committed values.
12609                    #[cfg(debug_assertions)]
12610                    if let Some(cell) = m.cell {
12611                        let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
12612                        debug_assert!(
12613                            self.read_delta_overlay_cell(
12614                                &sheet_name,
12615                                cell.coord.row() + 1,
12616                                cell.coord.col() + 1
12617                            )
12618                            .is_none(),
12619                            "user overlay must never shadow a formula SCC member ({sheet_name}!r{}c{})",
12620                            cell.coord.row() + 1,
12621                            cell.coord.col() + 1
12622                        );
12623                    }
12624                    changed[i] = last_value[i] != value;
12625                    last_value[i] = value;
12626                }
12627            }};
12628        }
12629
12630        let check_cancel = |flag: Option<&AtomicBool>| -> Result<(), ExcelError> {
12631            if let Some(flag) = flag
12632                && flag.load(Ordering::Relaxed)
12633            {
12634                return Err(ExcelError::new(ExcelErrorKind::Cancelled)
12635                    .with_message("Evaluation cancelled during SCC evaluation".to_string()));
12636            }
12637            Ok(())
12638        };
12639
12640        // ── 3. Pass 1: all evaluable members in member order.
12641        check_cancel(cancel_flag)?;
12642        let mut passes = 1usize;
12643        {
12644            let mut p = 0i64;
12645            for i in 0..n {
12646                if excluded[i] {
12647                    continue;
12648                }
12649                run_member!(i);
12650                pos[i] = p;
12651                p += 1;
12652            }
12653        }
12654
12655        // ── 4. Settle loop (design doc §3 step 4; RFC #113 policy arm).
12656        //
12657        // Acyclic classifications settle stale readers exactly (identical
12658        // under both policies — phantom SCCs never iterate). A witnessed
12659        // live cycle dispatches on policy: `Error` stamps `#CIRC!` and
12660        // stops; `Iterate` keeps running full passes over all members in
12661        // member order until converged (spec §6) or capped at
12662        // `max_iterations` total passes. A live cycle that only appears
12663        // mid-settle takes the same arm, and a cycle that dissolves
12664        // mid-iteration falls back to exact acyclic settling.
12665        //
12666        // Defensive acyclic budget: the acyclic settle is monotone, so more
12667        // than |SCC| + 2 settle passes can only be a bug; cap hits stamp the
12668        // remainder and set telemetry. Tracked via `settle_passes` so
12669        // iteration passes (legitimately many) don't consume the budget.
12670        let policy = self.config.cycle.policy;
12671        let cap = n + 2;
12672        let mut witnessed_cycles = 0usize;
12673        let mut capped = false;
12674        // ── Iterate-policy state ──
12675        let mut iterating = false;
12676        let mut converged = false;
12677        // Values committed by the last *full* pass; `None` until the first
12678        // iteration pass runs (pass 1 has no predecessor to compare against)
12679        // and reset when a settle pass runs (no cross-kind comparisons).
12680        let mut prev_pass: Option<Vec<LiteralValue>> = None;
12681        // Final-round convergence stats (overwritten per round so the values
12682        // reported are the ones observed at stop).
12683        let mut iter_max_delta = 0f64;
12684        let mut iter_nan_converged = 0usize;
12685        // Acyclic stale-reader re-eval passes (defensive budget; under pure
12686        // Error flow `1 + settle_passes == passes`, preserving Stage-2
12687        // behavior exactly).
12688        let mut settle_passes = 0usize;
12689        loop {
12690            // Drain this pass's recordings; members that ran replace their
12691            // out-edge set, members that didn't keep last-known edges.
12692            let drained = collector.take_edges();
12693            for i in 0..n {
12694                if pos[i] >= 0 {
12695                    out_edges[i].clear();
12696                }
12697            }
12698            for (from, to) in drained {
12699                debug_assert!(
12700                    pos[from as usize] >= 0,
12701                    "edge from a member that did not run"
12702                );
12703                out_edges[from as usize].push(to);
12704            }
12705            let mut edges: Vec<(u32, u32)> = Vec::new();
12706            for (i, outs) in out_edges.iter().enumerate() {
12707                if excluded[i] {
12708                    continue;
12709                }
12710                for &t in outs {
12711                    edges.push((i as u32, t));
12712                }
12713            }
12714            edges.sort_unstable();
12715            edges.dedup();
12716
12717            let analysis = analyze_live_graph(n, &edges);
12718
12719            if analysis.cycle_count > 0 {
12720                // Classification repeats every iteration pass under
12721                // `Iterate`; record the widest single witness instead of
12722                // accumulating so the count stays "distinct live cycles".
12723                witnessed_cycles = witnessed_cycles.max(analysis.cycle_count);
12724                match policy {
12725                    CyclePolicy::Error => {
12726                        // POLICY (Error): stamp every member of a live cycle,
12727                        // then one settling pass over the remaining members in
12728                        // live-topological order so error propagation
12729                        // downstream is consistent (spec §3.4). Blast radius =
12730                        // live cycles only.
12731                        for i in 0..n {
12732                            if analysis.in_cycle[i] && !excluded[i] {
12733                                self.stamp_cycle_error(members[i].vertex, &circ_error, None);
12734                                excluded[i] = true;
12735                                last_value[i] = circ_error.clone();
12736                                stamped += 1;
12737                            }
12738                        }
12739                        check_cancel(cancel_flag)?;
12740                        let order: Vec<usize> = analysis
12741                            .topo
12742                            .iter()
12743                            .map(|&i| i as usize)
12744                            .filter(|&i| !excluded[i])
12745                            .collect();
12746                        if !order.is_empty() {
12747                            passes += 1;
12748                            for i in order {
12749                                run_member!(i);
12750                            }
12751                        }
12752                        break;
12753                    }
12754                    CyclePolicy::Iterate {
12755                        max_iterations,
12756                        max_change,
12757                    } => {
12758                        // POLICY (Iterate), spec §3.5/§6.
12759                        iterating = true;
12760
12761                        // Convergence test: the full pass that just completed
12762                        // vs the previous full pass, per the spec-§6 rules.
12763                        // `prev_pass` is `None` until an iteration pass has
12764                        // run — pass 1 has no predecessor, so no convergence
12765                        // test occurs before the second pass (spec §6).
12766                        if let Some(prev) = &prev_pass {
12767                            let mut round_max_delta = 0f64;
12768                            let mut round_nan = 0usize;
12769                            let mut all_converged = true;
12770                            for i in 0..n {
12771                                if excluded[i] {
12772                                    // Stamped mid-iteration (array result,
12773                                    // §7.9): the value is pinned and cannot
12774                                    // change again — trivially settled.
12775                                    continue;
12776                                }
12777                                let out = crate::engine::convergence::values_converged(
12778                                    &prev[i],
12779                                    &last_value[i],
12780                                    max_change,
12781                                    self.config.date_system,
12782                                );
12783                                if out.nan_converged {
12784                                    round_nan += 1;
12785                                }
12786                                if let Some(d) = out.abs_delta {
12787                                    round_max_delta = round_max_delta.max(d);
12788                                }
12789                                if !out.converged {
12790                                    all_converged = false;
12791                                }
12792                            }
12793                            // Overwrite (not max): telemetry reports the
12794                            // round observed at stop.
12795                            iter_max_delta = round_max_delta;
12796                            iter_nan_converged = round_nan;
12797                            if all_converged {
12798                                converged = true;
12799                                break;
12800                            }
12801                        }
12802
12803                        // ── Pass-counting reconciliation (spec §6/§7.6):
12804                        // `max_iterations` counts TOTAL passes, pass 1
12805                        // included, and pass 1 has already run by the time a
12806                        // live cycle is first witnessed here. The budget is
12807                        // therefore checked BEFORE evaluating anything more:
12808                        // with `max_iterations: 1` we stop right here — each
12809                        // member was evaluated exactly once this recalc (the
12810                        // Excel accumulator contract) and no convergence test
12811                        // ran (`prev_pass` is still `None`). Capping keeps
12812                        // the last committed values and is NOT an error
12813                        // (Excel parity); telemetry records it.
12814                        if passes >= max_iterations as usize {
12815                            capped = true;
12816                            break;
12817                        }
12818
12819                        check_cancel(cancel_flag)?;
12820                        // One more full pass over every evaluable member in
12821                        // member order (Gauss–Seidel: each commit is visible
12822                        // to later members within the pass). Live edges
12823                        // re-record — guards can flip near convergence
12824                        // (§7.3) — so classification repeats next time
12825                        // around, and a cycle that dissolves drops back to
12826                        // the exact acyclic settle below.
12827                        prev_pass = Some(last_value.clone());
12828                        for x in pos.iter_mut() {
12829                            *x = -1;
12830                        }
12831                        changed.fill(false);
12832                        passes += 1;
12833                        let mut p = 0i64;
12834                        for i in 0..n {
12835                            if excluded[i] {
12836                                continue;
12837                            }
12838                            run_member!(i);
12839                            pos[i] = p;
12840                            p += 1;
12841                        }
12842                        continue;
12843                    }
12844                }
12845            }
12846
12847            // Acyclic: find stale readers — members whose live read of `to`
12848            // happened before `to`'s value changed in the pass that just ran.
12849            let mut stale: Vec<usize> = Vec::new();
12850            for i in 0..n {
12851                if excluded[i] {
12852                    continue;
12853                }
12854                let is_stale = out_edges[i].iter().any(|&t| {
12855                    let t = t as usize;
12856                    changed[t] && (pos[i] < 0 || (pos[t] >= 0 && pos[i] < pos[t]))
12857                });
12858                if is_stale {
12859                    stale.push(i);
12860                }
12861            }
12862            if stale.is_empty() {
12863                break; // values exact — phantom SCC (or dissolved live cycle)
12864            }
12865            if 1 + settle_passes >= cap {
12866                // Defensive only; hitting this is a bug (loud telemetry).
12867                capped = true;
12868                for (i, m) in members.iter().enumerate() {
12869                    if !excluded[i] {
12870                        self.stamp_cycle_error(m.vertex, &circ_error, None);
12871                        excluded[i] = true;
12872                        last_value[i] = circ_error.clone();
12873                        stamped += 1;
12874                    }
12875                }
12876                break;
12877            }
12878
12879            check_cancel(cancel_flag)?;
12880            // Re-evaluate stale readers in live-topo order, recording fresh
12881            // edges (branches may flip on re-eval — spec §7.3 — which is why
12882            // classification repeats).
12883            // A settle pass is a partial sweep: drop the full-pass baseline
12884            // so a live cycle (re)appearing afterwards never compares values
12885            // across mixed pass kinds.
12886            prev_pass = None;
12887            let topo_pos = analysis.topo_positions();
12888            stale.sort_unstable_by_key(|&i| topo_pos[i]);
12889            for x in pos.iter_mut() {
12890                *x = -1;
12891            }
12892            changed.fill(false);
12893            passes += 1;
12894            settle_passes += 1;
12895            for (p, i) in stale.into_iter().enumerate() {
12896                run_member!(i);
12897                pos[i] = p as i64;
12898            }
12899        }
12900
12901        // Iteration that ended because the live cycle dissolved and the
12902        // acyclic settle reached exactness counts as converged (values are
12903        // exact, strictly better than threshold-converged). The defensive
12904        // settle cap (`capped` + stamping) is not.
12905        if iterating && !converged && !capped {
12906            converged = true;
12907        }
12908
12909        // ── 5. End of task: one delta per member whose final value differs
12910        // from the pre-task snapshot (spec §3 side-effect rule, G11).
12911        collector.clear_current();
12912        if let Some(d) = delta
12913            && d.mode != DeltaMode::Off
12914        {
12915            for (i, m) in members.iter().enumerate() {
12916                if let Some(cell) = m.cell
12917                    && last_value[i] != snapshot[i]
12918                {
12919                    d.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
12920                }
12921            }
12922        }
12923
12924        // Members of an SCC that iterated re-evaluate on EVERY recalc, like
12925        // Excel's circular cells: register them for the end-of-recalc
12926        // volatile-like redirty (see `pending_iterative_redirty`). Marking
12927        // any one member propagates around the (strongly connected) SCC and
12928        // to downstream dependents, but all members are registered so the
12929        // contract survives partial structural edits between recalcs.
12930        if iterating {
12931            self.pending_iterative_redirty
12932                .extend(members.iter().map(|m| m.vertex));
12933        }
12934
12935        {
12936            let t = &mut self.last_cycle_telemetry;
12937            t.static_sccs += 1;
12938            if witnessed_cycles == 0 && stamped == 0 && !capped {
12939                t.phantom_sccs += 1;
12940            }
12941            t.live_cycles_witnessed += witnessed_cycles;
12942            t.circ_cells_stamped += stamped;
12943            t.settle_passes_total += passes;
12944            t.max_passes_single_scc = t.max_passes_single_scc.max(passes);
12945            if iterating {
12946                t.iterated_sccs += 1;
12947                if converged {
12948                    t.converged_sccs += 1;
12949                }
12950                t.max_abs_delta_at_stop = t.max_abs_delta_at_stop.max(iter_max_delta);
12951                t.nan_converged += iter_nan_converged;
12952            }
12953            if capped {
12954                t.capped_sccs += 1;
12955            }
12956            t.elapsed_ms += task_start.elapsed().as_millis();
12957        }
12958
12959        Ok(stamped)
12960    }
12961
12962    /// Recorded sibling of [`Self::evaluate_vertex_immutable`]: evaluates one
12963    /// SCC member's AST via an [`Interpreter`] over a [`RecordingContext`] so
12964    /// reads that actually occur are captured as live edges. Value semantics
12965    /// must match `evaluate_vertex_immutable` exactly (including the missing-
12966    /// AST `Number(0.0)` quirk, G14); named Cell/Range/Literal definitions
12967    /// delegate to it after recording the definition region by hand (those
12968    /// reads bypass the context).
12969    fn evaluate_vertex_recorded(
12970        &self,
12971        vertex_id: VertexId,
12972        ctx: &RecordingContext<'_, R>,
12973        collector: &LiveEdgeCollector,
12974    ) -> Result<LiteralValue, ExcelError> {
12975        if !self.graph.vertex_exists(vertex_id) {
12976            return Err(ExcelError::new(formualizer_common::ExcelErrorKind::Ref)
12977                .with_message(format!("Vertex not found: {vertex_id:?}")));
12978        }
12979
12980        let kind = self.graph.get_vertex_kind(vertex_id);
12981        let sheet_id = self.graph.get_vertex_sheet_id(vertex_id);
12982
12983        match kind {
12984            VertexKind::FormulaScalar | VertexKind::FormulaArray => {
12985                let Some(ast_id) = self.graph.get_formula_id(vertex_id) else {
12986                    return Ok(LiteralValue::Number(0.0)); // G14 quirk
12987                };
12988                let sheet_name = self.graph.sheet_name(sheet_id);
12989                let cell_ref = self
12990                    .graph
12991                    .get_cell_ref(vertex_id)
12992                    .expect("cell ref for vertex");
12993                let interpreter = Interpreter::new_with_cell(ctx, sheet_name, cell_ref);
12994                interpreter
12995                    .evaluate_arena_ast(ast_id, self.graph.data_store(), self.graph.sheet_reg())
12996                    .map(|cv| cv.into_literal())
12997            }
12998            VertexKind::NamedScalar | VertexKind::NamedArray => {
12999                let named_range = self.graph.named_range_by_vertex(vertex_id).ok_or_else(|| {
13000                    ExcelError::new(ExcelErrorKind::Name)
13001                        .with_message("Named range metadata missing".to_string())
13002                })?;
13003
13004                match &named_range.definition {
13005                    NamedDefinition::Formula { ast, .. } => {
13006                        let context_sheet = match named_range.scope {
13007                            NameScope::Sheet(id) => id,
13008                            NameScope::Workbook => sheet_id,
13009                        };
13010                        let sheet_name = self.graph.sheet_name(context_sheet);
13011                        let cell_ref = self
13012                            .graph
13013                            .get_cell_ref(vertex_id)
13014                            .unwrap_or_else(|| self.graph.make_cell_ref(sheet_name, 0, 0));
13015                        let interpreter = Interpreter::new_with_cell(ctx, sheet_name, cell_ref);
13016                        if kind == VertexKind::NamedScalar {
13017                            interpreter.evaluate_ast(ast).map(|cv| cv.into_literal())
13018                        } else {
13019                            match interpreter.evaluate_ast(ast) {
13020                                Ok(cv) => match cv.into_literal() {
13021                                    v @ LiteralValue::Array(_) => Ok(v),
13022                                    other => Ok(LiteralValue::Array(vec![vec![other]])),
13023                                },
13024                                Err(err) => Ok(LiteralValue::Error(err)),
13025                            }
13026                        }
13027                    }
13028                    NamedDefinition::Cell(cell_ref) => {
13029                        // The definition is read via direct grid access in
13030                        // `evaluate_vertex_immutable`; record the live edge
13031                        // by hand before delegating.
13032                        collector.record_scalar(
13033                            cell_ref.sheet_id,
13034                            cell_ref.coord.row(),
13035                            cell_ref.coord.col(),
13036                        );
13037                        self.evaluate_vertex_immutable(vertex_id)
13038                    }
13039                    NamedDefinition::Range(range_ref) => {
13040                        if range_ref.start.sheet_id == range_ref.end.sheet_id {
13041                            collector.record_rect(
13042                                range_ref.start.sheet_id,
13043                                range_ref.start.coord.row(),
13044                                range_ref.start.coord.col(),
13045                                range_ref.end.coord.row(),
13046                                range_ref.end.coord.col(),
13047                            );
13048                        }
13049                        self.evaluate_vertex_immutable(vertex_id)
13050                    }
13051                    NamedDefinition::Literal(_) => self.evaluate_vertex_immutable(vertex_id),
13052                }
13053            }
13054            _ => self.evaluate_vertex_immutable(vertex_id),
13055        }
13056    }
13057
13058    /// Helper: commit spill via shim and mirror resulting cells into Arrow overlay when enabled.
13059    fn commit_spill_and_mirror(
13060        &mut self,
13061        anchor_vertex: VertexId,
13062        targets: &[CellRef],
13063        rows: Vec<Vec<LiteralValue>>,
13064        delta: Option<&mut DeltaCollector>,
13065        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
13066    ) -> Result<(), ExcelError> {
13067        let prev_spill_cells = self
13068            .graph
13069            .spill_cells_for_anchor(anchor_vertex)
13070            .map(|cells| cells.to_vec())
13071            .unwrap_or_default();
13072
13073        if let Some(delta) = delta
13074            && delta.mode != DeltaMode::Off
13075        {
13076            let target_set: std::collections::HashSet<CellRef, CoordBuildHasher> =
13077                targets.iter().copied().collect();
13078            let empty = LiteralValue::Empty;
13079
13080            // Clears (prev - targets)
13081            for cell in prev_spill_cells.iter() {
13082                if target_set.contains(cell) {
13083                    continue;
13084                }
13085                let sheet_name = self.graph.sheet_name(cell.sheet_id);
13086                let old = self
13087                    .get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
13088                    .unwrap_or(LiteralValue::Empty);
13089                if old != empty {
13090                    delta.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
13091                }
13092            }
13093
13094            // Writes (targets)
13095            if !targets.is_empty() && !rows.is_empty() && !rows[0].is_empty() {
13096                let width = rows[0].len();
13097                for (idx, cell) in targets.iter().enumerate() {
13098                    let r_off = idx / width;
13099                    let c_off = idx % width;
13100                    let new = rows
13101                        .get(r_off)
13102                        .and_then(|r| r.get(c_off))
13103                        .cloned()
13104                        .unwrap_or(LiteralValue::Empty);
13105                    let sheet_name = self.graph.sheet_name(cell.sheet_id);
13106                    let old = self
13107                        .get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
13108                        .unwrap_or(LiteralValue::Empty);
13109                    if old != new {
13110                        delta.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
13111                    }
13112                }
13113            } else {
13114                // Degenerate shapes: if we have targets but no rows, treat as writing Empty.
13115                for cell in targets.iter() {
13116                    let sheet_name = self.graph.sheet_name(cell.sheet_id);
13117                    let old = self
13118                        .get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
13119                        .unwrap_or(LiteralValue::Empty);
13120                    if !matches!(old, LiteralValue::Empty) {
13121                        delta.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
13122                    }
13123                }
13124            }
13125        }
13126
13127        // Commit via shim (releases locks). When the graph value cache is disabled (Arrow-canonical
13128        // values), plan/commit must consult Arrow storage to detect non-empty value blockers.
13129        let arrow_sheets = &self.arrow_sheets;
13130        self.spill_mgr.commit_array_with_value_probe(
13131            &mut self.graph,
13132            anchor_vertex,
13133            targets,
13134            rows.clone(),
13135            overwritable_formulas,
13136            |g, cell| {
13137                let sheet_name = g.sheet_name(cell.sheet_id);
13138                let asheet = arrow_sheets.sheet(sheet_name)?;
13139                let r0 = cell.coord.row() as usize;
13140                let c0 = cell.coord.col() as usize;
13141                let v = asheet.get_cell_value(r0, c0);
13142                if matches!(v, LiteralValue::Empty) {
13143                    None
13144                } else {
13145                    Some(v)
13146                }
13147            },
13148        )?;
13149
13150        if let Some(scope) = Self::formula_plane_region_from_cells(&prev_spill_cells) {
13151            self.record_formula_plane_structural_change(scope);
13152        }
13153        if let Some(scope) = Self::formula_plane_region_from_cells(targets) {
13154            self.record_formula_plane_structural_change(scope);
13155        }
13156
13157        if self.config.arrow_storage_enabled
13158            && self.config.delta_overlay_enabled
13159            && self.config.write_formula_overlay_enabled
13160        {
13161            if !prev_spill_cells.is_empty() {
13162                let target_set: std::collections::HashSet<CellRef, CoordBuildHasher> =
13163                    targets.iter().copied().collect();
13164                let empty = LiteralValue::Empty;
13165                for cell in prev_spill_cells.iter() {
13166                    if !target_set.contains(cell) {
13167                        let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
13168                        self.mirror_value_to_computed_overlay(
13169                            &sheet_name,
13170                            cell.coord.row() + 1,
13171                            cell.coord.col() + 1,
13172                            &empty,
13173                        );
13174                    }
13175                }
13176            }
13177
13178            for (idx, cell) in targets.iter().enumerate() {
13179                if rows.is_empty() || rows[0].is_empty() {
13180                    break;
13181                }
13182                let width = rows[0].len();
13183                let r_off = idx / width;
13184                let c_off = idx % width;
13185                let v = rows[r_off][c_off].clone();
13186                let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
13187                self.mirror_value_to_computed_overlay(
13188                    &sheet_name,
13189                    cell.coord.row() + 1,
13190                    cell.coord.col() + 1,
13191                    &v,
13192                );
13193            }
13194        }
13195        Ok(())
13196    }
13197}
13198
13199// ── Effects pipeline (ticket 603) ──────────────────────────────────────────
13200//
13201// Compute → Plan → Apply separation for evaluation side-effects.
13202
13203use crate::engine::effects::Effect;
13204use crate::engine::graph::editor::change_log::{ChangeEvent, ChangeLog, SpillSnapshot};
13205
13206impl<R> Engine<R>
13207where
13208    R: EvaluationContext,
13209{
13210    /// Plan effects for a single vertex after its value has been computed.
13211    ///
13212    /// This reads graph state but only performs lightweight mutations
13213    /// (`set_kind`, `spill_mgr.reserve`) that are needed for correctness
13214    /// during the planning phase.  Value-changing mutations are deferred to
13215    /// `apply_effect`.
13216    pub(crate) fn plan_vertex_effects(
13217        &mut self,
13218        vertex_id: VertexId,
13219        computed_value: LiteralValue,
13220        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
13221    ) -> Result<Vec<Effect>, ExcelError> {
13222        let kind = self.graph.get_vertex_kind(vertex_id);
13223        let is_formula = matches!(kind, VertexKind::FormulaScalar | VertexKind::FormulaArray);
13224
13225        // If this vertex's cell is currently covered by a spill from a different
13226        // anchor, ignore the computed result.  Formula vertices are exempt:
13227        // they must still evaluate so that overlapping spills produce #SPILL!.
13228        if !is_formula {
13229            if let Some(cell) = self.graph.get_cell_ref(vertex_id)
13230                && let Some(owner) = self.graph.spill_registry_anchor_for_cell(cell)
13231                && owner != vertex_id
13232            {
13233                return Ok(Vec::new());
13234            }
13235            // Non-formula vertices: store value as-is (arrays remain arrays; no spill).
13236            return Ok(vec![Effect::WriteCell {
13237                vertex_id,
13238                value: computed_value,
13239            }]);
13240        }
13241
13242        match computed_value {
13243            LiteralValue::Array(rows) => {
13244                self.plan_array_effects(vertex_id, rows, overwritable_formulas)
13245            }
13246            other => self.plan_scalar_effects(vertex_id, other),
13247        }
13248    }
13249
13250    /// Plan effects for a formula vertex that produced a scalar/error result.
13251    fn plan_scalar_effects(
13252        &self,
13253        vertex_id: VertexId,
13254        value: LiteralValue,
13255    ) -> Result<Vec<Effect>, ExcelError> {
13256        let has_spill = self
13257            .graph
13258            .spill_cells_for_anchor(vertex_id)
13259            .is_some_and(|c| !c.is_empty());
13260
13261        let mut effects = Vec::new();
13262        if has_spill {
13263            effects.push(Effect::SpillClear {
13264                anchor_vertex: vertex_id,
13265            });
13266        }
13267        effects.push(Effect::WriteCell { vertex_id, value });
13268        Ok(effects)
13269    }
13270
13271    /// Plan effects for a formula vertex that produced an array result.
13272    fn plan_array_effects(
13273        &mut self,
13274        vertex_id: VertexId,
13275        rows: Vec<Vec<LiteralValue>>,
13276        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
13277    ) -> Result<Vec<Effect>, ExcelError> {
13278        // Lightweight mutation needed for correct spill-blocking checks.
13279        self.graph.set_kind(vertex_id, VertexKind::FormulaArray);
13280
13281        let anchor = self
13282            .graph
13283            .get_cell_ref(vertex_id)
13284            .expect("cell ref for vertex");
13285        let sheet_id = anchor.sheet_id;
13286        let h = rows.len() as u32;
13287        let w = rows.first().map(|r| r.len()).unwrap_or(0) as u32;
13288
13289        // Hard cap to avoid vertex explosion from huge dynamic arrays.
13290        let spill_cells = (h as u64).saturating_mul(w as u64);
13291        if spill_cells > self.config.spill.max_spill_cells as u64 {
13292            return self.plan_spill_error_effects(vertex_id, "SpillTooLarge", h, w);
13293        }
13294
13295        // Bounds check to avoid out-of-range writes (align to AbsCoord capacity).
13296        const PACKED_MAX_ROW: u32 = 1_048_575;
13297        const PACKED_MAX_COL: u32 = 16_383;
13298        let end_row = anchor.coord.row().saturating_add(h).saturating_sub(1);
13299        let end_col = anchor.coord.col().saturating_add(w).saturating_sub(1);
13300        if end_row > PACKED_MAX_ROW || end_col > PACKED_MAX_COL {
13301            return self.plan_spill_error_effects(vertex_id, "Spill exceeds sheet bounds", h, w);
13302        }
13303
13304        let mut targets = Vec::new();
13305        for r in 0..h {
13306            for c in 0..w {
13307                targets.push(self.graph.make_cell_ref_internal(
13308                    sheet_id,
13309                    anchor.coord.row() + r,
13310                    anchor.coord.col() + c,
13311                ));
13312            }
13313        }
13314
13315        // Region lock via spill manager.
13316        match self.spill_mgr.reserve(
13317            vertex_id,
13318            anchor,
13319            SpillShape { rows: h, cols: w },
13320            SpillMeta {
13321                epoch: self.recalc_epoch,
13322                config: self.config.spill,
13323            },
13324        ) {
13325            Ok(()) => {
13326                // Validate spill region is available.
13327                if let Err(_e) = self.graph.plan_spill_region_allowing_formula_overwrite(
13328                    vertex_id,
13329                    &targets,
13330                    overwritable_formulas,
13331                ) {
13332                    return self.plan_spill_error_effects(vertex_id, "Spill blocked", h, w);
13333                }
13334
13335                // Arrow-canonical mode: graph planning cannot see non-empty value blockers because
13336                // cell values are not cached in the dependency graph. Consult Arrow storage to
13337                // detect occupied cells in the target region.
13338                if !self.graph.value_cache_enabled() {
13339                    let sheet_name = self.graph.sheet_name(sheet_id);
13340                    if let Some(asheet) = self.sheet_store().sheet(sheet_name) {
13341                        for cell in targets.iter() {
13342                            // Allow overwriting the anchor itself.
13343                            if *cell == anchor {
13344                                continue;
13345                            }
13346                            // Allow cells already owned by a spill (plan() validated spill ownership).
13347                            if self.graph.spill_registry_anchor_for_cell(*cell).is_some() {
13348                                continue;
13349                            }
13350                            // Skip formula blockers; plan() handled them (or allowed).
13351                            if let Some(&vid) = self.graph.get_vertex_id_for_address(cell)
13352                                && vid != vertex_id
13353                            {
13354                                match self.graph.get_vertex_kind(vid) {
13355                                    VertexKind::FormulaScalar | VertexKind::FormulaArray => {
13356                                        continue;
13357                                    }
13358                                    _ => {}
13359                                }
13360                            }
13361
13362                            let v = asheet.get_cell_value(
13363                                cell.coord.row() as usize,
13364                                cell.coord.col() as usize,
13365                            );
13366                            if !matches!(v, LiteralValue::Empty) {
13367                                return self.plan_spill_error_effects(
13368                                    vertex_id,
13369                                    "BlockedByValue",
13370                                    h,
13371                                    w,
13372                                );
13373                            }
13374                        }
13375                    }
13376                }
13377
13378                let top_left = rows
13379                    .first()
13380                    .and_then(|r| r.first())
13381                    .cloned()
13382                    .unwrap_or(LiteralValue::Empty);
13383
13384                let mut effects = Vec::new();
13385                // Clear previous spill if any.
13386                let has_prev = self
13387                    .graph
13388                    .spill_cells_for_anchor(vertex_id)
13389                    .is_some_and(|c| !c.is_empty());
13390                if has_prev {
13391                    effects.push(Effect::SpillClear {
13392                        anchor_vertex: vertex_id,
13393                    });
13394                }
13395                effects.push(Effect::SpillCommit {
13396                    anchor_vertex: vertex_id,
13397                    anchor_cell: anchor,
13398                    target_cells: targets,
13399                    values: rows,
13400                });
13401                effects.push(Effect::WriteCell {
13402                    vertex_id,
13403                    value: top_left,
13404                });
13405                Ok(effects)
13406            }
13407            Err(e) => {
13408                let msg = e.message.unwrap_or_else(|| "Spill blocked".to_string());
13409                self.plan_spill_error_effects(vertex_id, &msg, h, w)
13410            }
13411        }
13412    }
13413
13414    /// Build the effect list for a spill that failed validation.
13415    fn plan_spill_error_effects(
13416        &self,
13417        vertex_id: VertexId,
13418        message: &str,
13419        expected_rows: u32,
13420        expected_cols: u32,
13421    ) -> Result<Vec<Effect>, ExcelError> {
13422        let spill_err = ExcelError::new(ExcelErrorKind::Spill)
13423            .with_message(message)
13424            .with_extra(formualizer_common::ExcelErrorExtra::Spill {
13425                expected_rows,
13426                expected_cols,
13427            });
13428        let spill_val = LiteralValue::Error(spill_err);
13429
13430        let effects = vec![
13431            Effect::SpillClear {
13432                anchor_vertex: vertex_id,
13433            },
13434            Effect::WriteCell {
13435                vertex_id,
13436                value: spill_val,
13437            },
13438        ];
13439        Ok(effects)
13440    }
13441
13442    /// Apply a single effect, performing the actual graph mutations.
13443    pub(crate) fn apply_effect(
13444        &mut self,
13445        effect: &Effect,
13446        delta: Option<&mut DeltaCollector>,
13447        log: Option<&mut ChangeLog>,
13448    ) -> Result<(), ExcelError> {
13449        self.apply_effect_with_computed_writes(effect, delta, log, None)
13450    }
13451
13452    fn apply_effect_with_computed_writes(
13453        &mut self,
13454        effect: &Effect,
13455        delta: Option<&mut DeltaCollector>,
13456        log: Option<&mut ChangeLog>,
13457        computed_writes: Option<&mut ComputedWriteBuffer>,
13458    ) -> Result<(), ExcelError> {
13459        match effect {
13460            Effect::WriteCell { vertex_id, value } => {
13461                self.apply_write_cell(*vertex_id, value, delta, computed_writes)?;
13462            }
13463            Effect::SpillClear { anchor_vertex } => {
13464                self.apply_spill_clear(*anchor_vertex, delta, log, computed_writes)?;
13465            }
13466            Effect::SpillCommit {
13467                anchor_vertex,
13468                anchor_cell: _,
13469                target_cells,
13470                values,
13471            } => {
13472                self.apply_spill_commit(
13473                    *anchor_vertex,
13474                    target_cells,
13475                    values.clone(),
13476                    delta,
13477                    log,
13478                    computed_writes,
13479                )?;
13480            }
13481        }
13482        Ok(())
13483    }
13484
13485    /// Apply a WriteCell effect.
13486    fn apply_write_cell(
13487        &mut self,
13488        vertex_id: VertexId,
13489        value: &LiteralValue,
13490        delta: Option<&mut DeltaCollector>,
13491        mut computed_writes: Option<&mut ComputedWriteBuffer>,
13492    ) -> Result<(), ExcelError> {
13493        if let Some(d) = delta
13494            && d.mode != DeltaMode::Off
13495        {
13496            if let Some(buffer) = computed_writes.as_deref_mut() {
13497                self.flush_computed_write_buffer(buffer)?;
13498            }
13499            if let Some(cell) = self.graph.get_cell_ref_for_vertex(vertex_id) {
13500                let sheet_name = self.graph.sheet_name(cell.sheet_id);
13501                let old = self
13502                    .read_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
13503                    .unwrap_or(LiteralValue::Empty);
13504                if old != *value {
13505                    d.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
13506                }
13507            }
13508        }
13509        self.graph.update_vertex_value(vertex_id, value.clone());
13510        self.record_vertex_value_to_overlay(vertex_id, value, computed_writes)?;
13511        Ok(())
13512    }
13513
13514    /// Apply a SpillClear effect.
13515    fn apply_spill_clear(
13516        &mut self,
13517        anchor_vertex: VertexId,
13518        delta: Option<&mut DeltaCollector>,
13519        log: Option<&mut ChangeLog>,
13520        computed_writes: Option<&mut ComputedWriteBuffer>,
13521    ) -> Result<(), ExcelError> {
13522        if let Some(buffer) = computed_writes {
13523            self.flush_computed_write_buffer(buffer)?;
13524        }
13525
13526        let spill_cells = self
13527            .graph
13528            .spill_cells_for_anchor(anchor_vertex)
13529            .map(|cells| cells.to_vec())
13530            .unwrap_or_default();
13531        if spill_cells.is_empty() {
13532            return Ok(());
13533        }
13534
13535        // Snapshot for ChangeLog before clearing.
13536        let snapshot = if log.is_some() {
13537            self.snapshot_spill_for_anchor(anchor_vertex)
13538        } else {
13539            None
13540        };
13541
13542        // Record delta for cleared cells.
13543        if let Some(d) = delta
13544            && d.mode != DeltaMode::Off
13545        {
13546            let empty = LiteralValue::Empty;
13547            for cell in spill_cells.iter() {
13548                let sheet_name = self.graph.sheet_name(cell.sheet_id);
13549                let old = self
13550                    .get_cell_value(sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
13551                    .unwrap_or(LiteralValue::Empty);
13552                if old != empty {
13553                    d.record_cell(cell.sheet_id, cell.coord.row(), cell.coord.col());
13554                }
13555            }
13556        }
13557
13558        self.graph.clear_spill_region(anchor_vertex);
13559        if let Some(scope) = Self::formula_plane_region_from_cells(&spill_cells) {
13560            self.record_formula_plane_structural_change(scope);
13561        }
13562
13563        // Mirror Empty to Arrow overlay for cleared cells.
13564        if self.config.arrow_storage_enabled
13565            && self.config.delta_overlay_enabled
13566            && self.config.write_formula_overlay_enabled
13567        {
13568            let empty = LiteralValue::Empty;
13569            for cell in spill_cells.iter() {
13570                let sheet_name = self.graph.sheet_name(cell.sheet_id).to_string();
13571                self.mirror_value_to_computed_overlay(
13572                    &sheet_name,
13573                    cell.coord.row() + 1,
13574                    cell.coord.col() + 1,
13575                    &empty,
13576                );
13577            }
13578        }
13579
13580        // ChangeLog.
13581        if let Some(log) = log
13582            && let Some(old) = snapshot
13583        {
13584            log.record(ChangeEvent::SpillCleared {
13585                anchor: anchor_vertex,
13586                old,
13587            });
13588        }
13589        Ok(())
13590    }
13591
13592    /// Apply a SpillCommit effect.
13593    fn apply_spill_commit(
13594        &mut self,
13595        anchor_vertex: VertexId,
13596        target_cells: &[CellRef],
13597        values: Vec<Vec<LiteralValue>>,
13598        delta: Option<&mut DeltaCollector>,
13599        log: Option<&mut ChangeLog>,
13600        computed_writes: Option<&mut ComputedWriteBuffer>,
13601    ) -> Result<(), ExcelError> {
13602        if let Some(buffer) = computed_writes {
13603            self.flush_computed_write_buffer(buffer)?;
13604        }
13605
13606        // Snapshot for ChangeLog before commit.
13607        let old_snapshot = if log.is_some() {
13608            self.snapshot_spill_for_anchor(anchor_vertex)
13609        } else {
13610            None
13611        };
13612
13613        // Delegate to existing commit_spill_and_mirror for delta + overlay logic.
13614        self.commit_spill_and_mirror(
13615            anchor_vertex,
13616            target_cells,
13617            values.clone(),
13618            delta,
13619            None, // overwritable_formulas already validated in plan phase
13620        )?;
13621
13622        // ChangeLog.
13623        if let Some(log) = log {
13624            log.record(ChangeEvent::SpillCommitted {
13625                anchor: anchor_vertex,
13626                old: old_snapshot,
13627                new: SpillSnapshot {
13628                    target_cells: target_cells.to_vec(),
13629                    values,
13630                },
13631            });
13632        }
13633        Ok(())
13634    }
13635
13636    /// Snapshot a spill region for ChangeLog recording.
13637    ///
13638    /// Extracted from `VertexEditor::snapshot_spill_for_anchor` to be usable
13639    /// without creating a `VertexEditor`.
13640    fn snapshot_spill_for_anchor(&self, anchor: VertexId) -> Option<SpillSnapshot> {
13641        let cells = self.graph.spill_cells_for_anchor(anchor)?.to_vec();
13642        if cells.is_empty() {
13643            return None;
13644        }
13645
13646        let max = self.config.spill.max_spill_cells as usize;
13647        let mut cells = cells;
13648        if cells.len() > max {
13649            cells.truncate(max);
13650        }
13651
13652        let first = *cells.first().expect("non-empty spill cells");
13653        let sheet_name = self.graph.sheet_name(first.sheet_id).to_string();
13654        let row0 = first.coord.row();
13655        let col0 = first.coord.col();
13656
13657        let mut max_row = row0;
13658        let mut max_col = col0;
13659        let mut by_coord: FxHashMap<(u32, u32), LiteralValue> = FxHashMap::default();
13660        for cell in &cells {
13661            max_row = max_row.max(cell.coord.row());
13662            max_col = max_col.max(cell.coord.col());
13663            let v = self
13664                .get_cell_value(&sheet_name, cell.coord.row() + 1, cell.coord.col() + 1)
13665                .unwrap_or(LiteralValue::Empty);
13666            by_coord.insert((cell.coord.row(), cell.coord.col()), v);
13667        }
13668
13669        let rows = (max_row - row0 + 1) as usize;
13670        let cols = (max_col - col0 + 1) as usize;
13671        let mut values: Vec<Vec<LiteralValue>> = Vec::with_capacity(rows);
13672        for r in 0..rows {
13673            let mut row: Vec<LiteralValue> = Vec::with_capacity(cols);
13674            for c in 0..cols {
13675                row.push(
13676                    by_coord
13677                        .get(&(row0 + r as u32, col0 + c as u32))
13678                        .cloned()
13679                        .unwrap_or(LiteralValue::Empty),
13680                );
13681            }
13682            values.push(row);
13683        }
13684
13685        Some(SpillSnapshot {
13686            target_cells: cells,
13687            values,
13688        })
13689    }
13690
13691    fn flush_before_range_dependent_vertex(
13692        &mut self,
13693        vertex_id: VertexId,
13694        computed_writes: &mut ComputedWriteBuffer,
13695    ) -> Result<(), ExcelError> {
13696        if self.graph.get_range_dependencies(vertex_id).is_some() {
13697            self.flush_computed_write_buffer(computed_writes)?;
13698        }
13699        Ok(())
13700    }
13701
13702    fn plan_vertex_effects_with_computed_flush(
13703        &mut self,
13704        vertex_id: VertexId,
13705        computed_value: LiteralValue,
13706        overwritable_formulas: Option<&rustc_hash::FxHashSet<VertexId>>,
13707        computed_writes: &mut ComputedWriteBuffer,
13708    ) -> Result<Vec<Effect>, ExcelError> {
13709        if matches!(&computed_value, LiteralValue::Array(_)) {
13710            self.flush_computed_write_buffer(computed_writes)?;
13711        }
13712        self.plan_vertex_effects(vertex_id, computed_value, overwritable_formulas)
13713    }
13714
13715    // ── Layer evaluation via effects pipeline ──────────────────────────────
13716
13717    fn evaluate_small_layer_direct_effects(
13718        &mut self,
13719        layer: &super::scheduler::Layer,
13720        mut delta: Option<&mut DeltaCollector>,
13721        mut log: Option<&mut ChangeLog>,
13722        cancel_flag: Option<&AtomicBool>,
13723        cancel_check_every: usize,
13724        cancel_message: &'static str,
13725    ) -> Result<usize, ExcelError> {
13726        for (i, &vertex_id) in layer.vertices.iter().enumerate() {
13727            if cancel_check_every > 0
13728                && i % cancel_check_every == 0
13729                && cancel_flag.is_some_and(|flag| flag.load(Ordering::Relaxed))
13730            {
13731                return Err(ExcelError::new(ExcelErrorKind::Cancelled)
13732                    .with_message(cancel_message.to_string()));
13733            }
13734            let value = match self.evaluate_vertex_immutable(vertex_id) {
13735                Ok(v) => v,
13736                Err(e) => LiteralValue::Error(e),
13737            };
13738            let effects = self.plan_vertex_effects(vertex_id, value, None)?;
13739            for effect in &effects {
13740                self.apply_effect_with_computed_writes(
13741                    effect,
13742                    delta.as_deref_mut(),
13743                    log.as_deref_mut(),
13744                    None,
13745                )?;
13746            }
13747        }
13748        Ok(layer.vertices.len())
13749    }
13750
13751    /// Evaluate a layer sequentially using the effects pipeline.
13752    fn evaluate_layer_sequential_effects(
13753        &mut self,
13754        layer: &super::scheduler::Layer,
13755    ) -> Result<usize, ExcelError> {
13756        if layer.vertices.len() < COMPUTED_WRITE_COALESCING_MIN_LAYER_WIDTH {
13757            return self.evaluate_small_layer_direct_effects(
13758                layer,
13759                None,
13760                None,
13761                None,
13762                0,
13763                "Evaluation cancelled within layer",
13764            );
13765        }
13766
13767        let mut computed_writes = ComputedWriteBuffer::default();
13768        for &vertex_id in &layer.vertices {
13769            self.flush_before_range_dependent_vertex(vertex_id, &mut computed_writes)?;
13770            let value = match self.evaluate_vertex_immutable(vertex_id) {
13771                Ok(v) => v,
13772                Err(e) => LiteralValue::Error(e),
13773            };
13774            let effects = match self.plan_vertex_effects_with_computed_flush(
13775                vertex_id,
13776                value,
13777                None,
13778                &mut computed_writes,
13779            ) {
13780                Ok(effects) => effects,
13781                Err(e) => {
13782                    self.flush_computed_write_buffer(&mut computed_writes)?;
13783                    return Err(e);
13784                }
13785            };
13786            for effect in &effects {
13787                if let Err(e) = self.apply_effect_with_computed_writes(
13788                    effect,
13789                    None,
13790                    None,
13791                    Some(&mut computed_writes),
13792                ) {
13793                    self.flush_computed_write_buffer(&mut computed_writes)?;
13794                    return Err(e);
13795                }
13796            }
13797        }
13798        self.flush_computed_write_buffer(&mut computed_writes)?;
13799        Ok(layer.vertices.len())
13800    }
13801
13802    /// Evaluate a layer sequentially with delta collection via effects pipeline.
13803    fn evaluate_layer_sequential_with_delta_effects(
13804        &mut self,
13805        layer: &super::scheduler::Layer,
13806        delta: &mut DeltaCollector,
13807    ) -> Result<usize, ExcelError> {
13808        if layer.vertices.len() < COMPUTED_WRITE_COALESCING_MIN_LAYER_WIDTH {
13809            return self.evaluate_small_layer_direct_effects(
13810                layer,
13811                Some(delta),
13812                None,
13813                None,
13814                0,
13815                "Evaluation cancelled within layer",
13816            );
13817        }
13818
13819        let mut computed_writes = ComputedWriteBuffer::default();
13820        for &vertex_id in &layer.vertices {
13821            self.flush_before_range_dependent_vertex(vertex_id, &mut computed_writes)?;
13822            let value = match self.evaluate_vertex_immutable(vertex_id) {
13823                Ok(v) => v,
13824                Err(e) => LiteralValue::Error(e),
13825            };
13826            let effects = match self.plan_vertex_effects_with_computed_flush(
13827                vertex_id,
13828                value,
13829                None,
13830                &mut computed_writes,
13831            ) {
13832                Ok(effects) => effects,
13833                Err(e) => {
13834                    self.flush_computed_write_buffer(&mut computed_writes)?;
13835                    return Err(e);
13836                }
13837            };
13838            for effect in &effects {
13839                if let Err(e) = self.apply_effect_with_computed_writes(
13840                    effect,
13841                    Some(delta),
13842                    None,
13843                    Some(&mut computed_writes),
13844                ) {
13845                    self.flush_computed_write_buffer(&mut computed_writes)?;
13846                    return Err(e);
13847                }
13848            }
13849        }
13850        self.flush_computed_write_buffer(&mut computed_writes)?;
13851        Ok(layer.vertices.len())
13852    }
13853
13854    /// Evaluate a layer sequentially with cancellation support via effects pipeline.
13855    fn evaluate_layer_sequential_cancellable_effects(
13856        &mut self,
13857        layer: &super::scheduler::Layer,
13858        cancel_flag: &AtomicBool,
13859    ) -> Result<usize, ExcelError> {
13860        if layer.vertices.len() < COMPUTED_WRITE_COALESCING_MIN_LAYER_WIDTH {
13861            return self.evaluate_small_layer_direct_effects(
13862                layer,
13863                None,
13864                None,
13865                Some(cancel_flag),
13866                256,
13867                "Evaluation cancelled within layer",
13868            );
13869        }
13870
13871        let mut computed_writes = ComputedWriteBuffer::default();
13872        for (i, &vertex_id) in layer.vertices.iter().enumerate() {
13873            if i % 256 == 0 && cancel_flag.load(Ordering::Relaxed) {
13874                self.flush_computed_write_buffer(&mut computed_writes)?;
13875                return Err(ExcelError::new(ExcelErrorKind::Cancelled)
13876                    .with_message("Evaluation cancelled within layer".to_string()));
13877            }
13878            self.flush_before_range_dependent_vertex(vertex_id, &mut computed_writes)?;
13879            let value = match self.evaluate_vertex_immutable(vertex_id) {
13880                Ok(v) => v,
13881                Err(e) => LiteralValue::Error(e),
13882            };
13883            let effects = match self.plan_vertex_effects_with_computed_flush(
13884                vertex_id,
13885                value,
13886                None,
13887                &mut computed_writes,
13888            ) {
13889                Ok(effects) => effects,
13890                Err(e) => {
13891                    self.flush_computed_write_buffer(&mut computed_writes)?;
13892                    return Err(e);
13893                }
13894            };
13895            for effect in &effects {
13896                if let Err(e) = self.apply_effect_with_computed_writes(
13897                    effect,
13898                    None,
13899                    None,
13900                    Some(&mut computed_writes),
13901                ) {
13902                    self.flush_computed_write_buffer(&mut computed_writes)?;
13903                    return Err(e);
13904                }
13905            }
13906        }
13907        self.flush_computed_write_buffer(&mut computed_writes)?;
13908        Ok(layer.vertices.len())
13909    }
13910
13911    /// Evaluate a layer sequentially with more frequent cancellation for demand-driven eval.
13912    fn evaluate_layer_sequential_cancellable_demand_driven_effects(
13913        &mut self,
13914        layer: &super::scheduler::Layer,
13915        cancel_flag: &AtomicBool,
13916    ) -> Result<usize, ExcelError> {
13917        if layer.vertices.len() < COMPUTED_WRITE_COALESCING_MIN_LAYER_WIDTH {
13918            return self.evaluate_small_layer_direct_effects(
13919                layer,
13920                None,
13921                None,
13922                Some(cancel_flag),
13923                128,
13924                "Demand-driven evaluation cancelled within layer",
13925            );
13926        }
13927
13928        let mut computed_writes = ComputedWriteBuffer::default();
13929        for (i, &vertex_id) in layer.vertices.iter().enumerate() {
13930            if i % 128 == 0 && cancel_flag.load(Ordering::Relaxed) {
13931                self.flush_computed_write_buffer(&mut computed_writes)?;
13932                return Err(ExcelError::new(ExcelErrorKind::Cancelled)
13933                    .with_message("Demand-driven evaluation cancelled within layer".to_string()));
13934            }
13935            self.flush_before_range_dependent_vertex(vertex_id, &mut computed_writes)?;
13936            let value = match self.evaluate_vertex_immutable(vertex_id) {
13937                Ok(v) => v,
13938                Err(e) => LiteralValue::Error(e),
13939            };
13940            let effects = match self.plan_vertex_effects_with_computed_flush(
13941                vertex_id,
13942                value,
13943                None,
13944                &mut computed_writes,
13945            ) {
13946                Ok(effects) => effects,
13947                Err(e) => {
13948                    self.flush_computed_write_buffer(&mut computed_writes)?;
13949                    return Err(e);
13950                }
13951            };
13952            for effect in &effects {
13953                if let Err(e) = self.apply_effect_with_computed_writes(
13954                    effect,
13955                    None,
13956                    None,
13957                    Some(&mut computed_writes),
13958                ) {
13959                    self.flush_computed_write_buffer(&mut computed_writes)?;
13960                    return Err(e);
13961                }
13962            }
13963        }
13964        self.flush_computed_write_buffer(&mut computed_writes)?;
13965        Ok(layer.vertices.len())
13966    }
13967
13968    /// Evaluate a layer in parallel, applying via effects pipeline.
13969    fn evaluate_layer_parallel_effects(
13970        &mut self,
13971        layer: &super::scheduler::Layer,
13972    ) -> Result<usize, ExcelError> {
13973        use rayon::prelude::*;
13974
13975        let thread_pool = self.thread_pool.as_ref().unwrap().clone();
13976
13977        let mut phase1: Vec<VertexId> = Vec::new();
13978        let mut phase2: Vec<VertexId> = Vec::new();
13979        for &vid in &layer.vertices {
13980            if self.graph.get_range_dependencies(vid).is_some() {
13981                phase2.push(vid);
13982            } else {
13983                phase1.push(vid);
13984            }
13985        }
13986
13987        let inflight: rustc_hash::FxHashSet<VertexId> = layer.vertices.iter().copied().collect();
13988        let mut applied = 0usize;
13989
13990        for group in [&phase1[..], &phase2[..]] {
13991            if group.is_empty() {
13992                continue;
13993            }
13994            let mut computed_writes = ComputedWriteBuffer::default();
13995
13996            let results: Result<Vec<(VertexId, LiteralValue)>, ExcelError> =
13997                thread_pool.install(|| {
13998                    group
13999                        .par_iter()
14000                        .map(
14001                            |&vertex_id| match self.evaluate_vertex_immutable(vertex_id) {
14002                                Ok(v) => Ok((vertex_id, v)),
14003                                Err(e) => Ok((vertex_id, LiteralValue::Error(e))),
14004                            },
14005                        )
14006                        .collect()
14007                });
14008
14009            match results {
14010                Ok(vertex_results) => {
14011                    // Arrays first, then scalars — establishes spill regions before
14012                    // scalar results that might land inside a spilled region.
14013                    let mut arrays: Vec<(VertexId, LiteralValue)> = Vec::new();
14014                    let mut others: Vec<(VertexId, LiteralValue)> = Vec::new();
14015                    for (vertex_id, result) in vertex_results {
14016                        if matches!(result, LiteralValue::Array(_)) {
14017                            arrays.push((vertex_id, result));
14018                        } else {
14019                            others.push((vertex_id, result));
14020                        }
14021                    }
14022                    for (vertex_id, result) in arrays {
14023                        let effects = match self.plan_vertex_effects_with_computed_flush(
14024                            vertex_id,
14025                            result,
14026                            Some(&inflight),
14027                            &mut computed_writes,
14028                        ) {
14029                            Ok(effects) => effects,
14030                            Err(e) => {
14031                                self.flush_computed_write_buffer(&mut computed_writes)?;
14032                                return Err(e);
14033                            }
14034                        };
14035                        for effect in &effects {
14036                            if let Err(e) = self.apply_effect_with_computed_writes(
14037                                effect,
14038                                None,
14039                                None,
14040                                Some(&mut computed_writes),
14041                            ) {
14042                                self.flush_computed_write_buffer(&mut computed_writes)?;
14043                                return Err(e);
14044                            }
14045                        }
14046                        applied = applied.saturating_add(1);
14047                    }
14048                    // Make all array spill/top-left writes visible before scalar effects in this group.
14049                    self.flush_computed_write_buffer(&mut computed_writes)?;
14050                    for (vertex_id, result) in others {
14051                        let effects = match self.plan_vertex_effects_with_computed_flush(
14052                            vertex_id,
14053                            result,
14054                            Some(&inflight),
14055                            &mut computed_writes,
14056                        ) {
14057                            Ok(effects) => effects,
14058                            Err(e) => {
14059                                self.flush_computed_write_buffer(&mut computed_writes)?;
14060                                return Err(e);
14061                            }
14062                        };
14063                        for effect in &effects {
14064                            if let Err(e) = self.apply_effect_with_computed_writes(
14065                                effect,
14066                                None,
14067                                None,
14068                                Some(&mut computed_writes),
14069                            ) {
14070                                self.flush_computed_write_buffer(&mut computed_writes)?;
14071                                return Err(e);
14072                            }
14073                        }
14074                        applied = applied.saturating_add(1);
14075                    }
14076                    // Flush at the group boundary; phase1 must be visible before phase2.
14077                    self.flush_computed_write_buffer(&mut computed_writes)?;
14078                }
14079                Err(e) => {
14080                    self.flush_computed_write_buffer(&mut computed_writes)?;
14081                    return Err(e);
14082                }
14083            }
14084        }
14085
14086        Ok(applied)
14087    }
14088
14089    /// Evaluate a layer in parallel with delta collection via effects pipeline.
14090    fn evaluate_layer_parallel_with_delta_effects(
14091        &mut self,
14092        layer: &super::scheduler::Layer,
14093        delta: &mut DeltaCollector,
14094    ) -> Result<usize, ExcelError> {
14095        use rayon::prelude::*;
14096
14097        let thread_pool = self.thread_pool.as_ref().unwrap().clone();
14098
14099        let mut phase1: Vec<VertexId> = Vec::new();
14100        let mut phase2: Vec<VertexId> = Vec::new();
14101        for &vid in &layer.vertices {
14102            if self.graph.get_range_dependencies(vid).is_some() {
14103                phase2.push(vid);
14104            } else {
14105                phase1.push(vid);
14106            }
14107        }
14108
14109        let inflight: rustc_hash::FxHashSet<VertexId> = layer.vertices.iter().copied().collect();
14110        let mut applied = 0usize;
14111
14112        for group in [&phase1[..], &phase2[..]] {
14113            if group.is_empty() {
14114                continue;
14115            }
14116            let mut computed_writes = ComputedWriteBuffer::default();
14117            let results: Result<Vec<(VertexId, LiteralValue)>, ExcelError> =
14118                thread_pool.install(|| {
14119                    group
14120                        .par_iter()
14121                        .map(
14122                            |&vertex_id| match self.evaluate_vertex_immutable(vertex_id) {
14123                                Ok(v) => Ok((vertex_id, v)),
14124                                Err(e) => Ok((vertex_id, LiteralValue::Error(e))),
14125                            },
14126                        )
14127                        .collect()
14128                });
14129
14130            match results {
14131                Ok(vertex_results) => {
14132                    let mut arrays: Vec<(VertexId, LiteralValue)> = Vec::new();
14133                    let mut others: Vec<(VertexId, LiteralValue)> = Vec::new();
14134                    for (vertex_id, result) in vertex_results {
14135                        if matches!(result, LiteralValue::Array(_)) {
14136                            arrays.push((vertex_id, result));
14137                        } else {
14138                            others.push((vertex_id, result));
14139                        }
14140                    }
14141                    for (vertex_id, result) in arrays {
14142                        let effects = match self.plan_vertex_effects_with_computed_flush(
14143                            vertex_id,
14144                            result,
14145                            Some(&inflight),
14146                            &mut computed_writes,
14147                        ) {
14148                            Ok(effects) => effects,
14149                            Err(e) => {
14150                                self.flush_computed_write_buffer(&mut computed_writes)?;
14151                                return Err(e);
14152                            }
14153                        };
14154                        for effect in &effects {
14155                            if let Err(e) = self.apply_effect_with_computed_writes(
14156                                effect,
14157                                Some(delta),
14158                                None,
14159                                Some(&mut computed_writes),
14160                            ) {
14161                                self.flush_computed_write_buffer(&mut computed_writes)?;
14162                                return Err(e);
14163                            }
14164                        }
14165                        applied = applied.saturating_add(1);
14166                    }
14167                    self.flush_computed_write_buffer(&mut computed_writes)?;
14168                    for (vertex_id, result) in others {
14169                        let effects = match self.plan_vertex_effects_with_computed_flush(
14170                            vertex_id,
14171                            result,
14172                            Some(&inflight),
14173                            &mut computed_writes,
14174                        ) {
14175                            Ok(effects) => effects,
14176                            Err(e) => {
14177                                self.flush_computed_write_buffer(&mut computed_writes)?;
14178                                return Err(e);
14179                            }
14180                        };
14181                        for effect in &effects {
14182                            if let Err(e) = self.apply_effect_with_computed_writes(
14183                                effect,
14184                                Some(delta),
14185                                None,
14186                                Some(&mut computed_writes),
14187                            ) {
14188                                self.flush_computed_write_buffer(&mut computed_writes)?;
14189                                return Err(e);
14190                            }
14191                        }
14192                        applied = applied.saturating_add(1);
14193                    }
14194                    self.flush_computed_write_buffer(&mut computed_writes)?;
14195                }
14196                Err(e) => {
14197                    self.flush_computed_write_buffer(&mut computed_writes)?;
14198                    return Err(e);
14199                }
14200            }
14201        }
14202
14203        Ok(applied)
14204    }
14205
14206    /// Evaluate a layer in parallel with cancellation support via effects pipeline.
14207    fn evaluate_layer_parallel_cancellable_effects(
14208        &mut self,
14209        layer: &super::scheduler::Layer,
14210        cancel_flag: &AtomicBool,
14211    ) -> Result<usize, ExcelError> {
14212        use rayon::prelude::*;
14213
14214        let thread_pool = self.thread_pool.as_ref().unwrap().clone();
14215
14216        if cancel_flag.load(Ordering::Relaxed) {
14217            return Err(ExcelError::new(ExcelErrorKind::Cancelled)
14218                .with_message("Parallel evaluation cancelled before starting".to_string()));
14219        }
14220
14221        let mut phase1: Vec<VertexId> = Vec::new();
14222        let mut phase2: Vec<VertexId> = Vec::new();
14223        for &vid in &layer.vertices {
14224            if self.graph.get_range_dependencies(vid).is_some() {
14225                phase2.push(vid);
14226            } else {
14227                phase1.push(vid);
14228            }
14229        }
14230
14231        let inflight: rustc_hash::FxHashSet<VertexId> = layer.vertices.iter().copied().collect();
14232        let mut applied = 0usize;
14233
14234        for group in [&phase1[..], &phase2[..]] {
14235            if group.is_empty() {
14236                continue;
14237            }
14238            let mut computed_writes = ComputedWriteBuffer::default();
14239
14240            let results: Result<Vec<(VertexId, LiteralValue)>, ExcelError> =
14241                thread_pool.install(|| {
14242                    group
14243                        .par_iter()
14244                        .map(|&vertex_id| {
14245                            if cancel_flag.load(Ordering::Relaxed) {
14246                                return Err(ExcelError::new(ExcelErrorKind::Cancelled)
14247                                    .with_message(
14248                                        "Parallel evaluation cancelled during execution"
14249                                            .to_string(),
14250                                    ));
14251                            }
14252                            match self.evaluate_vertex_immutable(vertex_id) {
14253                                Ok(v) => Ok((vertex_id, v)),
14254                                Err(e) => Ok((vertex_id, LiteralValue::Error(e))),
14255                            }
14256                        })
14257                        .collect()
14258                });
14259
14260            match results {
14261                Ok(vertex_results) => {
14262                    let mut arrays: Vec<(VertexId, LiteralValue)> = Vec::new();
14263                    let mut others: Vec<(VertexId, LiteralValue)> = Vec::new();
14264                    for (vertex_id, result) in vertex_results {
14265                        if matches!(result, LiteralValue::Array(_)) {
14266                            arrays.push((vertex_id, result));
14267                        } else {
14268                            others.push((vertex_id, result));
14269                        }
14270                    }
14271                    for (vertex_id, result) in arrays {
14272                        let effects = match self.plan_vertex_effects_with_computed_flush(
14273                            vertex_id,
14274                            result,
14275                            Some(&inflight),
14276                            &mut computed_writes,
14277                        ) {
14278                            Ok(effects) => effects,
14279                            Err(e) => {
14280                                self.flush_computed_write_buffer(&mut computed_writes)?;
14281                                return Err(e);
14282                            }
14283                        };
14284                        for effect in &effects {
14285                            if let Err(e) = self.apply_effect_with_computed_writes(
14286                                effect,
14287                                None,
14288                                None,
14289                                Some(&mut computed_writes),
14290                            ) {
14291                                self.flush_computed_write_buffer(&mut computed_writes)?;
14292                                return Err(e);
14293                            }
14294                        }
14295                        applied = applied.saturating_add(1);
14296                    }
14297                    self.flush_computed_write_buffer(&mut computed_writes)?;
14298                    for (vertex_id, result) in others {
14299                        let effects = match self.plan_vertex_effects_with_computed_flush(
14300                            vertex_id,
14301                            result,
14302                            Some(&inflight),
14303                            &mut computed_writes,
14304                        ) {
14305                            Ok(effects) => effects,
14306                            Err(e) => {
14307                                self.flush_computed_write_buffer(&mut computed_writes)?;
14308                                return Err(e);
14309                            }
14310                        };
14311                        for effect in &effects {
14312                            if let Err(e) = self.apply_effect_with_computed_writes(
14313                                effect,
14314                                None,
14315                                None,
14316                                Some(&mut computed_writes),
14317                            ) {
14318                                self.flush_computed_write_buffer(&mut computed_writes)?;
14319                                return Err(e);
14320                            }
14321                        }
14322                        applied = applied.saturating_add(1);
14323                    }
14324                    self.flush_computed_write_buffer(&mut computed_writes)?;
14325                }
14326                Err(e) => {
14327                    self.flush_computed_write_buffer(&mut computed_writes)?;
14328                    return Err(e);
14329                }
14330            }
14331        }
14332
14333        Ok(applied)
14334    }
14335
14336    // ── Top-level evaluate_all_logged ───────────────────────────────────────
14337
14338    /// Evaluate all dirty/volatile vertices, recording effects into a ChangeLog.
14339    ///
14340    /// This is the same flow as `evaluate_all` but threads a ChangeLog through
14341    /// every effect application so that spill commits/clears are captured.
14342    pub fn evaluate_all_logged(&mut self, log: &mut ChangeLog) -> Result<EvalResult, ExcelError> {
14343        self.begin_evaluation_request();
14344        let _source_cache = self.source_cache_session();
14345        self.validate_deterministic_mode()?;
14346        if self.config.defer_graph_building {
14347            self.build_graph_all()?;
14348        }
14349        if self.graph.formula_authority().active_span_count() > 0 {
14350            return self.evaluate_authoritative_formula_plane_all();
14351        }
14352        self.reset_virtual_dep_telemetry_if_disabled();
14353        let start = crate::instant::FzInstant::now();
14354        let mut computed_vertices = 0;
14355        let mut cycle_errors = 0;
14356
14357        let mut replan_iterations = 0;
14358        const MAX_REPLAN: usize = 5;
14359        let mut telemetry = self
14360            .config
14361            .enable_virtual_dep_telemetry
14362            .then(|| self.start_virtual_dep_telemetry());
14363
14364        log.begin_compound(format!("evaluate_all(epoch={})", self.recalc_epoch));
14365
14366        loop {
14367            let to_evaluate = self.graph.get_evaluation_vertices();
14368            if to_evaluate.is_empty() {
14369                if let Some(t) = telemetry.as_mut()
14370                    && t.bailout_reason.is_none()
14371                {
14372                    t.bailout_reason = Some("no_work");
14373                }
14374                break;
14375            }
14376
14377            let (schedule, old_vdeps, meta) = self.create_evaluation_schedule(&to_evaluate)?;
14378            if let Some(t) = telemetry.as_mut() {
14379                Self::accumulate_schedule_meta(t, &meta);
14380            }
14381
14382            // Walk units in condensation order: stamp cycles at their
14383            // position, evaluate layers with ChangeLog recording.
14384            for &unit in &schedule.units {
14385                match unit {
14386                    ScheduleUnit::Cycle(i) => {
14387                        // Journal integration (design doc §4 last row): the
14388                        // ChangeLog in this path only records SpillClear /
14389                        // SpillCommit events; WriteCell effects are never
14390                        // logged (see `apply_write_cell`). Runtime SCC tasks
14391                        // write values directly and never spill (§7.9 stamps
14392                        // would-be anchors), and their spill *teardown* is the
14393                        // same unlogged `stamp_cycle_error` the Static path
14394                        // already uses here — so direct commits coexist with
14395                        // the journal cleanly, with identical semantics to
14396                        // Static. Pinned by `scc_runtime_cycles` tests.
14397                        if self.handle_cycle_unit(schedule.unit_cycle(i), None, None, None)? > 0 {
14398                            cycle_errors += 1;
14399                        }
14400                    }
14401                    ScheduleUnit::Layer(i) => {
14402                        computed_vertices +=
14403                            self.evaluate_layer_logged(schedule.unit_layer(i), log)?;
14404                    }
14405                }
14406            }
14407
14408            let changed_vertices = self.changed_virtual_dep_vertices(&to_evaluate, &old_vdeps);
14409            if let Some(t) = telemetry.as_mut() {
14410                t.changed_vdeps_total += changed_vertices.len();
14411            }
14412            self.graph.clear_dirty_flags(&to_evaluate);
14413            for v in &changed_vertices {
14414                self.graph.set_dirty(*v, true);
14415            }
14416
14417            if changed_vertices.is_empty() {
14418                if let Some(t) = telemetry.as_mut() {
14419                    t.bailout_reason = Some("converged");
14420                }
14421                break;
14422            }
14423            if replan_iterations >= MAX_REPLAN {
14424                if let Some(t) = telemetry.as_mut() {
14425                    t.bailout_reason = Some("max_replan");
14426                }
14427                break;
14428            }
14429            replan_iterations += 1;
14430        }
14431
14432        if let Some(mut t) = telemetry {
14433            t.replan_iterations = replan_iterations;
14434            self.last_virtual_dep_telemetry = t;
14435        }
14436
14437        log.end_compound();
14438
14439        self.redirty_for_next_recalc();
14440        self.recalc_epoch = self.recalc_epoch.wrapping_add(1);
14441
14442        Ok(EvalResult {
14443            computed_vertices,
14444            cycle_errors,
14445            elapsed: start.elapsed(),
14446        })
14447    }
14448
14449    /// Evaluate a single layer with ChangeLog recording.
14450    fn evaluate_layer_logged(
14451        &mut self,
14452        layer: &super::scheduler::Layer,
14453        log: &mut ChangeLog,
14454    ) -> Result<usize, ExcelError> {
14455        let mut computed_writes = ComputedWriteBuffer::default();
14456        for &vertex_id in &layer.vertices {
14457            self.flush_before_range_dependent_vertex(vertex_id, &mut computed_writes)?;
14458            let value = match self.evaluate_vertex_immutable(vertex_id) {
14459                Ok(v) => v,
14460                Err(e) => LiteralValue::Error(e),
14461            };
14462            let effects = match self.plan_vertex_effects_with_computed_flush(
14463                vertex_id,
14464                value,
14465                None,
14466                &mut computed_writes,
14467            ) {
14468                Ok(effects) => effects,
14469                Err(e) => {
14470                    self.flush_computed_write_buffer(&mut computed_writes)?;
14471                    return Err(e);
14472                }
14473            };
14474            for effect in &effects {
14475                if let Err(e) = self.apply_effect_with_computed_writes(
14476                    effect,
14477                    None,
14478                    Some(log),
14479                    Some(&mut computed_writes),
14480                ) {
14481                    self.flush_computed_write_buffer(&mut computed_writes)?;
14482                    return Err(e);
14483                }
14484            }
14485        }
14486        self.flush_computed_write_buffer(&mut computed_writes)?;
14487        Ok(layer.vertices.len())
14488    }
14489}