Skip to main content

formualizer_eval/engine/
mod.rs

1//! Formualizer Dependency Graph Engine
2//!
3//! Provides incremental formula evaluation with dependency tracking.
4
5pub mod arrow_ingest;
6pub mod cancel;
7pub(crate) mod convergence;
8pub mod effects;
9pub mod eval;
10pub mod eval_delta;
11pub mod formula_ingest;
12mod formula_source;
13pub(crate) mod fragmented_transaction;
14pub mod graph;
15pub mod ingest;
16pub mod ingest_builder;
17pub(crate) mod ingest_pipeline;
18pub mod inspect;
19pub mod journal;
20pub mod live_edges;
21pub mod live_graph;
22pub mod lookup_index_cache;
23pub mod plan;
24#[cfg(test)]
25mod plan_legacy_tests;
26pub mod range_view;
27pub(crate) mod refs;
28pub mod resource_ledger;
29pub mod resource_observability;
30pub(crate) mod result_finalization;
31pub mod row_visibility;
32pub mod scheduler;
33pub mod spill;
34mod target_preparation;
35pub(crate) mod used_extent;
36pub mod vertex;
37pub mod virtual_deps;
38
39// New SoA modules
40pub mod csr_edges;
41pub mod debug_views;
42pub mod delta_edges;
43pub mod interval_tree;
44pub mod named_range;
45pub mod sheet_index;
46pub mod sheet_registry;
47pub mod topo;
48pub mod vertex_store;
49
50// Phase 1: Arena modules
51pub mod arena;
52
53// Phase 1: Warmup configuration (kept for compatibility)
54pub mod tuning;
55
56#[cfg(test)]
57mod tests;
58
59pub use arena::AstNodeId;
60pub use cancel::CancelToken;
61pub use eval::{
62    CycleTelemetry, Engine, EngineAction, EngineBaselineStats, EvalResult, RecalcPlan,
63    SourceFormulaIngress, TableMetadata, VirtualDepTelemetry,
64};
65pub use eval_delta::{
66    DeltaMode, EvalDelta, EvalDeltaCompatibilityPolicy, EvalDeltaRecord, TARGET_EVAL_DELTA_VERSION,
67    TargetEvalDelta,
68};
69pub use formula_ingest::{FormulaIngestBatch, FormulaIngestRecord, FormulaIngestReport};
70#[doc(hidden)]
71pub use formula_source::{
72    DeferredFormulaPackage, DeferredFormulaReplay, DeferredReplayFormula,
73    ExplicitPartitionLegacyMembers, ExplicitSourceFamilyMembers, FormulaCompressedPreparation,
74    FormulaCompressedSourceBatch, FormulaCompressedSourceReport,
75    FormulaReplayCoordinateDisposition, FormulaReplayDisposition, FormulaReplayPartitionRouter,
76    MAX_EXPLICIT_SOURCE_FAMILY_MEMBERS, MAX_PARTITIONED_SOURCE_FAMILY_FRAGMENTS,
77    PartitionLegacyMember, PartitionLegacyMemberKind, PartitionReconciliation,
78    PartitionedSourceFormulaFamily, PlacementDomainTransport, SourceCoord, SourceFamilyId,
79    SourceFamilyMembers, SourceFormulaFamily, SourceFormulaOrder, SourceRect,
80};
81pub use journal::{ActionJournal, ArrowOp, ArrowUndoBatch, GraphUndoBatch};
82#[allow(deprecated)]
83pub use target_preparation::PrepareTargetsOptions;
84// Use SoA implementation
85pub use formualizer_common::{ResourceExhaustionDetail, ResourceExhaustionReason};
86pub use graph::snapshot::VertexSnapshot;
87pub use graph::{
88    ChangeEvent, DependencyGraph, DependencyRef, GraphBaselineStats, OperationSummary, StripeKey,
89    StripeType, block_index,
90};
91pub use resource_ledger::{
92    AdmissionResourceBudget, DeadlineResourceBudget, DiskScratchPolicy, EvaluationBudgets,
93    EvaluationIncompleteReason, EvaluationResourceConfigDiagnostic,
94    LegacyResourceConfigDisposition, OptimizationResourceBudget, ResourceEnvelope,
95    ResourceLedgerError, ResourceLedgerSnapshot, RetainedResourceBudget, ScratchResourceBudget,
96    SemanticResourceBudget, WorkResourceBudget,
97};
98// Internal accounting mechanism: reachable inside the crate, deliberately not
99// part of the published surface. See the type's documentation.
100pub(crate) use resource_ledger::ResourceLedger;
101pub use resource_observability::{
102    EvaluationRequestKind, EvaluationRequestOutcome, EvaluationRequestPhaseTimings,
103    EvaluationResourceBaselineStats, EvaluationResourceClass, EvaluationResourceLedgerRequestStats,
104    EvaluationResourceReason, EvaluationResourceRequestStats, FormulaDirtyLeaseOutcome,
105    FormulaPlaneRoute, FormulaPlaneRouteEvent, FormulaPlaneRoutePhase,
106    FormulaPlaneRouteTransitionReason, FormulaPlaneTopologyCacheOutcome,
107    FormulaPlaneTopologyRequestStats, FormulaPlaneTopologyStrategy,
108};
109pub use row_visibility::{RowVisibilitySource, VisibilityMaskMode};
110pub use scheduler::{Layer, Schedule, ScheduleUnit, Scheduler};
111pub use target_preparation::{
112    EvaluationTarget, OpaquePreparePolicy, OpaqueReason, PreparationOutcome, PreparationRevision,
113    PrepareScope, PreparedTargetGraphReport, RequestId, TableSelection, TargetEvalOptions,
114};
115pub use vertex::{VertexId, VertexKind};
116
117pub use graph::editor::{
118    DataUpdateSummary, EditorError, MetaUpdateSummary, RangeSummary, ShiftSummary, TransactionId,
119    VertexDataPatch, VertexEditor, VertexMeta, VertexMetaPatch,
120};
121
122pub use graph::editor::change_log::{ChangeLog, ChangeLogger, NullChangeLogger};
123
124#[doc(hidden)]
125pub mod fp8_parity_test_support {
126    use super::{Engine, EvalConfig};
127    use crate::engine::arena::CanonicalLabels;
128    use crate::formula_plane::dependency_summary::summarize_canonical_template;
129    use crate::formula_plane::producer::SpanReadSummary;
130    use crate::formula_plane::runtime::{PlacementDomain, ResultRegion};
131    use crate::formula_plane::template_canonical::{
132        CanonicalRejectReason, CanonicalTemplateFlag, canonicalize_template,
133    };
134    use crate::reference::{CellRef, Coord};
135    use crate::traits::EvaluationContext;
136    use formualizer_common::{ExcelError, LiteralValue};
137    use formualizer_parse::parser::{ASTNode, ASTNodeType, ReferenceType, parse};
138    use std::collections::BTreeSet;
139    use std::sync::Arc;
140
141    #[derive(Clone, Debug)]
142    pub struct Fp8ParityObservation {
143        pub formula: String,
144        pub placement: CellRef,
145        pub old_payload: String,
146        pub new_hash: u64,
147    }
148
149    pub fn default_config() -> EvalConfig {
150        EvalConfig::default()
151    }
152
153    pub fn parse_formula(formula: &str) -> ASTNode {
154        parse(formula).unwrap_or_else(|err| panic!("parse {formula}: {err}"))
155    }
156
157    pub fn cell(sheet_id: u16, row: u32, col: u32) -> CellRef {
158        CellRef::new(sheet_id, Coord::from_excel(row, col, true, true))
159    }
160
161    fn local_binding_declarations(ast: &ASTNode, out: &mut BTreeSet<String>) {
162        match &ast.node_type {
163            ASTNodeType::Function { name, args } => {
164                let canonical = name.rsplit('.').next().unwrap_or(name).to_ascii_uppercase();
165                let declaration_indices: Box<dyn Iterator<Item = usize>> = match canonical.as_str()
166                {
167                    "LET" => Box::new((0..args.len().saturating_sub(1)).step_by(2)),
168                    "LAMBDA" => Box::new(0..args.len().saturating_sub(1)),
169                    _ => Box::new(std::iter::empty()),
170                };
171                for index in declaration_indices {
172                    if let Some(ASTNode {
173                        node_type:
174                            ASTNodeType::Reference {
175                                reference: ReferenceType::NamedRange(name),
176                                ..
177                            },
178                        ..
179                    }) = args.get(index)
180                    {
181                        out.insert(name.to_ascii_uppercase());
182                    }
183                }
184                for arg in args {
185                    local_binding_declarations(arg, out);
186                }
187            }
188            ASTNodeType::UnaryOp { expr, .. } => local_binding_declarations(expr, out),
189            ASTNodeType::BinaryOp { left, right, .. } => {
190                local_binding_declarations(left, out);
191                local_binding_declarations(right, out);
192            }
193            ASTNodeType::Call { callee, args } => {
194                local_binding_declarations(callee, out);
195                for arg in args {
196                    local_binding_declarations(arg, out);
197                }
198            }
199            ASTNodeType::Array(rows) => {
200                for item in rows.iter().flatten() {
201                    local_binding_declarations(item, out);
202                }
203            }
204            _ => {}
205        }
206    }
207
208    pub fn assert_case<R: EvaluationContext>(
209        engine: &mut Engine<R>,
210        formula: &str,
211        placement: CellRef,
212    ) -> Fp8ParityObservation {
213        let parsed = parse_formula(formula);
214        assert_case_ast(engine, formula, parsed, placement)
215    }
216
217    pub fn assert_case_ast<R: EvaluationContext>(
218        engine: &mut Engine<R>,
219        formula: &str,
220        parsed: ASTNode,
221        placement: CellRef,
222    ) -> Fp8ParityObservation {
223        let mut local_declarations = BTreeSet::new();
224        local_binding_declarations(&parsed, &mut local_declarations);
225        let mut old_ast = parsed.clone();
226        let old_rewrite = engine
227            .graph
228            .rewrite_structured_references_for_cell(&mut old_ast, placement);
229        let old = old_rewrite.and_then(|_| old_path(engine, &old_ast, placement));
230
231        let new = {
232            let mut pipeline = engine.ingest_pipeline();
233            pipeline.ingest_formula(
234                crate::engine::ingest_pipeline::FormulaAstInput::Tree(parsed),
235                placement,
236                Some(Arc::<str>::from(formula)),
237            )
238        };
239
240        match (old, new) {
241            (Ok(old), Ok(new)) => {
242                let new_direct = sorted_cells(new.dep_plan.direct_cell_deps.clone());
243                assert_eq!(
244                    old.direct_cells, new_direct,
245                    "direct deps differ for {formula} at {placement:?}\nold={:?}\nnew={:?}",
246                    old.direct_cells, new_direct
247                );
248                assert_eq!(
249                    old.range_deps, new.dep_plan.range_deps,
250                    "range deps differ for {formula} at {placement:?}"
251                );
252                let old_unresolved_names: Vec<_> = old
253                    .unresolved_names
254                    .iter()
255                    .filter(|name| !local_declarations.contains(&name.to_ascii_uppercase()))
256                    .cloned()
257                    .collect();
258                assert_eq!(
259                    old_unresolved_names, new.dep_plan.named_refs,
260                    "unresolved names differ for {formula} at {placement:?}"
261                );
262                assert_eq!(
263                    old.volatile, new.dep_plan.volatile,
264                    "volatile flag differs for {formula} at {placement:?}"
265                );
266                assert_eq!(
267                    old.dynamic, new.dep_plan.dynamic,
268                    "dynamic flag differs for {formula} at {placement:?}"
269                );
270                let mut expected_labels = canonical_labels_from_old(&old.labels);
271                if old.dynamic {
272                    expected_labels.flags |= CanonicalLabels::FLAG_DYNAMIC;
273                }
274                assert_eq!(
275                    expected_labels.flags, new.labels.flags,
276                    "canonical label flags differ for {formula} at {placement:?}\nold={:?}\nnew={:#x}",
277                    old.labels.flags, new.labels.flags
278                );
279                assert_eq!(
280                    expected_labels.rejects, new.labels.rejects,
281                    "canonical label rejects differ for {formula} at {placement:?}\nold={:?}\nnew={:#x}",
282                    old.labels.reject_reasons, new.labels.rejects
283                );
284                // The passive summary oracle cannot resolve defined names, so
285                // a named formula that the ingest pipeline resolved to a
286                // concrete region is an intentional superset: old None / new
287                // Some is allowed exactly when the only blocking reason was a
288                // named reference.
289                let named_resolution_superset = old.summary_rejected_only_for_named_reference
290                    && old.read_summary_debug.is_none();
291                if !named_resolution_superset {
292                    assert_eq!(
293                        old.read_summary_debug,
294                        new.read_summary.as_ref().map(|s| format!("{s:?}")),
295                        "read summary differs for {formula} at {placement:?}"
296                    );
297                }
298                assert_eq!(new.formula_text.as_deref(), Some(formula));
299                assert_eq!(new.placement, placement);
300                Fp8ParityObservation {
301                    formula: formula.to_string(),
302                    placement,
303                    old_payload: old.payload,
304                    new_hash: new.canonical_hash,
305                }
306            }
307            (Err(old), Err(new)) => {
308                assert_eq!(
309                    old.kind.to_string(),
310                    new.kind.to_string(),
311                    "old and new errored differently for {formula} at {placement:?}: old={old:?} new={new:?}"
312                );
313                Fp8ParityObservation {
314                    formula: formula.to_string(),
315                    placement,
316                    old_payload: format!("ERR:{:?}", old.kind),
317                    new_hash: 0,
318                }
319            }
320            (Ok(_), Err(new)) => panic!(
321                "new pipeline errored but old path succeeded for {formula} at {placement:?}: {new:?}"
322            ),
323            (Err(old), Ok(_)) => panic!(
324                "old path errored but new pipeline succeeded for {formula} at {placement:?}: {old:?}"
325            ),
326        }
327    }
328
329    #[derive(Debug)]
330    struct OldOutput {
331        payload: String,
332        labels: crate::formula_plane::template_canonical::CanonicalTemplateLabels,
333        direct_cells: Vec<CellRef>,
334        range_deps: Vec<crate::reference::SharedRangeRef<'static>>,
335        unresolved_names: Vec<String>,
336        volatile: bool,
337        dynamic: bool,
338        read_summary_debug: Option<String>,
339        summary_rejected_only_for_named_reference: bool,
340    }
341
342    fn old_path<R: EvaluationContext>(
343        engine: &mut Engine<R>,
344        ast: &ASTNode,
345        placement: CellRef,
346    ) -> Result<OldOutput, ExcelError> {
347        let (_deps, ranges, placeholders, _named, unresolved_names) = engine
348            .graph
349            .fp8_parity_extract_dependencies_with_pending_names(ast, placement.sheet_id)?;
350        let volatile = engine.graph.fp8_parity_is_ast_volatile(ast);
351        let dynamic = engine.graph.is_ast_dynamic(ast);
352        let template =
353            canonicalize_template(ast, placement.coord.row() + 1, placement.coord.col() + 1);
354        let summary = summarize_canonical_template(&template);
355        let scalar_domain = PlacementDomain::row_run(
356            placement.sheet_id,
357            placement.coord.row(),
358            placement.coord.row(),
359            placement.coord.col(),
360        );
361        let result_region = ResultRegion::scalar_cells(scalar_domain);
362        let read_summary = SpanReadSummary::from_formula_summary(
363            placement.sheet_id,
364            &result_region,
365            &summary,
366            engine.graph.sheet_reg(),
367        )
368        .ok();
369        let summary_rejected_only_for_named_reference = !summary.reject_reasons.is_empty()
370            && summary.reject_reasons.iter().all(|reason| {
371                matches!(
372                    reason,
373                    crate::formula_plane::dependency_summary::DependencyRejectReason
374                        ::NamedRangeUnsupported { .. }
375                )
376            });
377        Ok(OldOutput {
378            payload: template.key.payload().to_string(),
379            labels: template.labels,
380            direct_cells: sorted_cells(placeholders),
381            range_deps: ranges,
382            unresolved_names,
383            volatile,
384            dynamic,
385            read_summary_debug: read_summary.as_ref().map(|s| format!("{s:?}")),
386            summary_rejected_only_for_named_reference,
387        })
388    }
389
390    fn sorted_cells(mut cells: Vec<CellRef>) -> Vec<CellRef> {
391        cells.sort();
392        cells.dedup();
393        cells
394    }
395
396    fn canonical_labels_from_old(
397        old: &crate::formula_plane::template_canonical::CanonicalTemplateLabels,
398    ) -> CanonicalLabels {
399        let mut labels = CanonicalLabels::default();
400        for flag in &old.flags {
401            labels.flags |= match flag {
402                CanonicalTemplateFlag::ParserVolatileFlag => CanonicalLabels::FLAG_VOLATILE,
403                CanonicalTemplateFlag::FunctionCall => CanonicalLabels::FLAG_CONTAINS_FUNCTION,
404                CanonicalTemplateFlag::CurrentSheetBinding => CanonicalLabels::FLAG_CURRENT_SHEET,
405                CanonicalTemplateFlag::ExplicitSheetBinding => CanonicalLabels::FLAG_EXPLICIT_SHEET,
406                CanonicalTemplateFlag::RelativeReferenceAxis => CanonicalLabels::FLAG_RELATIVE_ONLY,
407                CanonicalTemplateFlag::AbsoluteReferenceAxis => CanonicalLabels::FLAG_ABSOLUTE_ONLY,
408                CanonicalTemplateFlag::MixedAnchors => CanonicalLabels::FLAG_MIXED_ANCHORS,
409                CanonicalTemplateFlag::FiniteRangeReference => CanonicalLabels::FLAG_CONTAINS_RANGE,
410                CanonicalTemplateFlag::NamedReference => CanonicalLabels::FLAG_CONTAINS_NAME,
411            };
412        }
413        for reason in &old.reject_reasons {
414            labels.flags |= match reason {
415                CanonicalRejectReason::DynamicReferenceFunction { .. } => {
416                    CanonicalLabels::FLAG_DYNAMIC
417                }
418                CanonicalRejectReason::ParserVolatileFlag
419                | CanonicalRejectReason::VolatileFunction { .. } => CanonicalLabels::FLAG_VOLATILE,
420                CanonicalRejectReason::LocalEnvironmentFunction { .. } => {
421                    CanonicalLabels::FLAG_CONTAINS_LET_LAMBDA
422                }
423                CanonicalRejectReason::ArrayOrSpillFunction { .. }
424                | CanonicalRejectReason::ArrayLiteral => CanonicalLabels::FLAG_CONTAINS_ARRAY,
425                CanonicalRejectReason::StructuredReference { .. }
426                | CanonicalRejectReason::StructuredReferenceCurrentRow { .. } => {
427                    CanonicalLabels::FLAG_CONTAINS_TABLE
428                        | CanonicalLabels::FLAG_CONTAINS_STRUCTURED_REF
429                }
430                CanonicalRejectReason::OpenRangeReference { .. }
431                | CanonicalRejectReason::WholeAxisReference { .. } => {
432                    CanonicalLabels::FLAG_CONTAINS_RANGE
433                }
434                _ => 0,
435            };
436            labels.rejects |= match reason {
437                CanonicalRejectReason::InvalidPlacementAnchor { .. } => {
438                    CanonicalLabels::REJECT_INVALID_PLACEMENT_ANCHOR
439                }
440                CanonicalRejectReason::DynamicReferenceFunction { .. } => {
441                    CanonicalLabels::REJECT_DYNAMIC_REFERENCE
442                }
443                CanonicalRejectReason::UnknownOrCustomFunction { .. } => {
444                    CanonicalLabels::REJECT_UNKNOWN_OR_CUSTOM_FUNCTION
445                }
446                CanonicalRejectReason::LocalEnvironmentFunction { .. } => {
447                    CanonicalLabels::REJECT_LOCAL_ENVIRONMENT
448                }
449                CanonicalRejectReason::ParserVolatileFlag => {
450                    CanonicalLabels::REJECT_PARSER_VOLATILE_FLAG
451                }
452                CanonicalRejectReason::VolatileFunction { .. } => {
453                    CanonicalLabels::REJECT_VOLATILE_FUNCTION
454                }
455                CanonicalRejectReason::ReferenceReturningFunction { .. } => {
456                    CanonicalLabels::REJECT_REFERENCE_RETURNING_FUNCTION
457                }
458                CanonicalRejectReason::ArrayOrSpillFunction { .. } => {
459                    CanonicalLabels::REJECT_ARRAY_OR_SPILL_FUNCTION
460                }
461                CanonicalRejectReason::ArrayLiteral => CanonicalLabels::REJECT_ARRAY_LITERAL,
462                CanonicalRejectReason::SpillReference { .. } => {
463                    CanonicalLabels::REJECT_SPILL_REFERENCE
464                }
465                CanonicalRejectReason::SpillResultRegionOperator => {
466                    CanonicalLabels::REJECT_SPILL_RESULT_REGION_OPERATOR
467                }
468                CanonicalRejectReason::ImplicitIntersectionOperator => {
469                    CanonicalLabels::REJECT_IMPLICIT_INTERSECTION_OPERATOR
470                }
471                CanonicalRejectReason::CallExpression => CanonicalLabels::REJECT_CALL_EXPRESSION,
472                CanonicalRejectReason::StructuredReference { .. } => {
473                    CanonicalLabels::REJECT_STRUCTURED_REFERENCE
474                }
475                CanonicalRejectReason::StructuredReferenceCurrentRow { .. } => {
476                    CanonicalLabels::REJECT_STRUCTURED_REFERENCE_CURRENT_ROW
477                }
478                CanonicalRejectReason::ThreeDReference { .. } => {
479                    CanonicalLabels::REJECT_THREE_D_REFERENCE
480                }
481                CanonicalRejectReason::ExternalReference { .. } => {
482                    CanonicalLabels::REJECT_EXTERNAL_REFERENCE
483                }
484                CanonicalRejectReason::OpenRangeReference { .. } => {
485                    CanonicalLabels::REJECT_OPEN_RANGE_REFERENCE
486                }
487                CanonicalRejectReason::WholeAxisReference { .. } => {
488                    CanonicalLabels::REJECT_WHOLE_AXIS_REFERENCE
489                }
490                CanonicalRejectReason::UnsupportedReference { .. } => {
491                    CanonicalLabels::REJECT_UNSUPPORTED_REFERENCE
492                }
493                CanonicalRejectReason::FunctionContractUnsupported { .. }
494                | CanonicalRejectReason::ContextDependentFunction { .. } => {
495                    CanonicalLabels::REJECT_UNKNOWN_OR_CUSTOM_FUNCTION
496                }
497            };
498        }
499        labels
500    }
501
502    pub fn literal_number(value: f64) -> LiteralValue {
503        LiteralValue::Number(value)
504    }
505}
506
507// CalcObserver is defined below
508
509use crate::timezone::TimeZoneSpec;
510use crate::traits::EvaluationContext;
511use crate::traits::VolatileLevel;
512use chrono::{DateTime, Utc};
513use formualizer_common::error::{ExcelError, ExcelErrorKind};
514use std::collections::HashMap;
515
516impl<R: EvaluationContext> Engine<R> {
517    pub fn begin_bulk_ingest(&mut self) -> ingest_builder::BulkIngestBuilder<'_> {
518        ingest_builder::BulkIngestBuilder::new(&mut self.graph)
519    }
520
521    pub fn intern_formula_ast(&mut self, ast: &formualizer_parse::parser::ASTNode) -> AstNodeId {
522        self.graph.store_ast(ast)
523    }
524}
525
526/// 🔮 Scalability Hook: Performance monitoring trait for calculation observability
527pub trait CalcObserver: Send + Sync {
528    fn on_eval_start(&self, vertex_id: VertexId);
529    fn on_eval_complete(&self, vertex_id: VertexId, duration: std::time::Duration);
530    fn on_cycle_detected(&self, cycle: &[VertexId]);
531    fn on_dirty_propagation(&self, vertex_id: VertexId, affected_count: usize);
532}
533
534/// Default no-op observer
535impl CalcObserver for () {
536    fn on_eval_start(&self, _vertex_id: VertexId) {}
537    fn on_eval_complete(&self, _vertex_id: VertexId, _duration: std::time::Duration) {}
538    fn on_cycle_detected(&self, _cycle: &[VertexId]) {}
539    fn on_dirty_propagation(&self, _vertex_id: VertexId, _affected_count: usize) {}
540}
541
542/// Deterministic evaluation configuration.
543///
544/// When enabled, volatile sources (clock/timezone) are derived solely from this config.
545#[derive(Debug, Clone, PartialEq, Eq)]
546pub enum DeterministicMode {
547    /// Non-deterministic: uses the system clock.
548    Disabled {
549        /// Timezone used by volatile date/time builtins.
550        timezone: TimeZoneSpec,
551    },
552    /// Deterministic: uses a fixed timestamp in the provided timezone.
553    Enabled {
554        /// Fixed timestamp expressed in UTC.
555        timestamp_utc: DateTime<Utc>,
556        /// Timezone used to interpret `timestamp_utc` for NOW()/TODAY().
557        timezone: TimeZoneSpec,
558    },
559}
560
561impl Default for DeterministicMode {
562    fn default() -> Self {
563        Self::Disabled {
564            timezone: TimeZoneSpec::default(),
565        }
566    }
567}
568
569impl DeterministicMode {
570    pub fn is_enabled(&self) -> bool {
571        matches!(self, DeterministicMode::Enabled { .. })
572    }
573
574    pub fn timezone(&self) -> &TimeZoneSpec {
575        match self {
576            DeterministicMode::Disabled { timezone } => timezone,
577            DeterministicMode::Enabled { timezone, .. } => timezone,
578        }
579    }
580
581    pub fn validate(&self) -> Result<(), ExcelError> {
582        if let DeterministicMode::Enabled { timezone, .. } = self {
583            timezone
584                .validate_for_determinism()
585                .map_err(|msg| ExcelError::new(ExcelErrorKind::Value).with_message(msg))?;
586        }
587        Ok(())
588    }
589
590    pub fn build_clock(
591        &self,
592    ) -> Result<std::sync::Arc<dyn crate::timezone::ClockProvider>, ExcelError> {
593        self.validate()?;
594        Ok(match self {
595            #[cfg(feature = "system-clock")]
596            DeterministicMode::Disabled { timezone } => {
597                std::sync::Arc::new(crate::timezone::SystemClock::new(timezone.clone()))
598            }
599            #[cfg(not(feature = "system-clock"))]
600            DeterministicMode::Disabled { timezone: _ } => {
601                // Without the system-clock feature, Disabled mode falls back to a
602                // UTC epoch clock so the engine still initialises cleanly in portable
603                // wasm guests.  Callers that need real wall-clock time must inject a
604                // `ClockProvider` via `EvalConfig::clock`.
605                std::sync::Arc::new(crate::timezone::FixedClock::new(
606                    chrono::DateTime::UNIX_EPOCH,
607                    crate::timezone::TimeZoneSpec::Utc,
608                ))
609            }
610            DeterministicMode::Enabled {
611                timestamp_utc,
612                timezone,
613            } => std::sync::Arc::new(crate::timezone::FixedClock::new(
614                *timestamp_utc,
615                timezone.clone(),
616            )),
617        })
618    }
619}
620
621/// Policy for handling malformed formulas encountered during workbook ingest.
622#[derive(Debug, Clone, Copy, PartialEq, Eq)]
623pub enum FormulaParsePolicy {
624    /// Reject malformed formulas and fail the load/evaluation path.
625    Strict,
626    /// Convert malformed formulas into literal error formulas (`#ERROR!`).
627    CoerceToError,
628    /// Keep the backend-provided cached value and drop the formula.
629    KeepCachedValue,
630    /// Treat the original formula text as a plain text literal.
631    AsText,
632}
633
634/// Captured diagnostic for a malformed formula encountered during ingest/graph-build.
635#[derive(Debug, Clone, PartialEq, Eq)]
636pub struct FormulaParseDiagnostic {
637    pub sheet: String,
638    pub row: u32,
639    pub col: u32,
640    pub formula: String,
641    pub message: String,
642    pub policy: FormulaParsePolicy,
643}
644
645#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
646pub enum FormulaPlaneMode {
647    /// Disable FormulaPlane promotion/evaluation. This is the stable default;
648    /// span evaluation is explicitly opt-in through configuration.
649    #[default]
650    Off,
651    Shadow,
652    /// Experimental mode: accepted FormulaPlane spans are installed into
653    /// graph-owned authority and are not materialized as per-cell graph formulas.
654    AuthoritativeExperimental,
655}
656
657/// Storage policy for the private formula replay spool used while loading workbooks.
658#[derive(Debug, Clone, Copy, PartialEq, Eq)]
659pub enum FormulaSpoolDiskPolicy {
660    /// Spill to secure temporary storage after the configured memory prefix.
661    NativeSpill,
662    /// Never write formula replay data to a filesystem.
663    MemoryOnly,
664}
665
666/// Workbook ingest limits applied by loader backends before they materialize large sheets.
667#[derive(Debug, Clone, PartialEq, Eq)]
668pub struct WorkbookLoadLimits {
669    /// Hard cap for declared/logical sheet rows.
670    pub max_sheet_rows: u32,
671    /// Hard cap for declared/logical sheet columns.
672    pub max_sheet_cols: u32,
673    /// Hard cap for the rectangular logical area a backend may materialize.
674    pub max_sheet_logical_cells: u64,
675    /// Hard cap for formulas materialized by one FormulaPlane fallback.
676    pub max_formula_plane_fallback_cells: u64,
677    /// Sparse-sheet checks only trigger once a sheet reaches this many logical cells.
678    pub sparse_sheet_cell_threshold: u64,
679    /// Maximum allowed logical-to-populated-cell ratio once the sparse threshold is crossed.
680    pub max_sparse_cell_ratio: u64,
681    /// Maximum encoded formula replay bytes retained for one sheet.
682    pub max_formula_spool_bytes_per_sheet: u64,
683    /// Maximum encoded formula replay bytes produced across one workbook load.
684    pub max_formula_spool_bytes_per_workbook: u64,
685    /// Maximum number of native spill files created across one workbook load.
686    pub max_formula_spool_files_per_workbook: u32,
687    /// Encoded bytes retained in memory before native spill.
688    pub formula_spool_memory_prefix_bytes: u64,
689    /// Independent cap for a memory-only formula replay spool.
690    pub max_formula_spool_memory_bytes: u64,
691    /// Whether formula replay data may use secure native temporary storage.
692    pub formula_spool_disk_policy: FormulaSpoolDiskPolicy,
693}
694
695impl Default for WorkbookLoadLimits {
696    fn default() -> Self {
697        Self {
698            max_sheet_rows: 1_048_576,
699            max_sheet_cols: 16_384,
700            max_sheet_logical_cells: 128_000_000,
701            max_formula_plane_fallback_cells: 2_000_000,
702            sparse_sheet_cell_threshold: 250_000,
703            max_sparse_cell_ratio: 1_024,
704            max_formula_spool_bytes_per_sheet: 256 * 1024 * 1024,
705            max_formula_spool_bytes_per_workbook: 1024 * 1024 * 1024,
706            max_formula_spool_files_per_workbook: 1_024,
707            formula_spool_memory_prefix_bytes: 1024 * 1024,
708            max_formula_spool_memory_bytes: 16 * 1024 * 1024,
709            formula_spool_disk_policy: if cfg!(target_arch = "wasm32") {
710                FormulaSpoolDiskPolicy::MemoryOnly
711            } else {
712                FormulaSpoolDiskPolicy::NativeSpill
713            },
714        }
715    }
716}
717
718/// Configuration for the evaluation engine
719#[derive(Debug, Clone)]
720pub struct EvalConfig {
721    pub enable_parallel: bool,
722    pub max_threads: Option<usize>,
723    /// Deprecated. Maps to `evaluation_budgets.admission.graph_vertex_hard_limit` only when that
724    /// explicit field is unset.
725    pub max_vertices: Option<usize>,
726    /// Deprecated. Maps to `evaluation_budgets.deadline.max_elapsed` only when that explicit field
727    /// is unset.
728    pub max_eval_time: Option<std::time::Duration>,
729    /// Deprecated. Converts MiB to bytes and splits the result 50/50 between otherwise-unset
730    /// retained and scratch totals; an odd byte goes to retained. Each explicit total wins its own
731    /// conflict independently.
732    pub max_memory_mb: Option<usize>,
733    /// Explicit evaluation budgets. All fields are unset by default, preserving current behavior.
734    /// Deprecated resource fields fill only otherwise-unset destination fields and produce one
735    /// field-level diagnostic describing every mapping or conflict.
736    pub evaluation_budgets: EvaluationBudgets,
737
738    /// Default sheet name used when no sheet is provided.
739    pub default_sheet_name: String,
740
741    /// When false, resolve defined names case-insensitively (ASCII only).
742    ///
743    /// This matches Excel behavior for defined names.
744    pub case_sensitive_names: bool,
745
746    /// When false, resolve table names case-insensitively (ASCII only).
747    ///
748    /// This matches Excel behavior for native table (ListObject) names.
749    pub case_sensitive_tables: bool,
750
751    /// Stable workbook seed used for deterministic RNG composition
752    pub workbook_seed: u64,
753
754    /// Volatile granularity for RNG seeding and re-evaluation policy
755    pub volatile_level: VolatileLevel,
756
757    /// Deterministic evaluation configuration (clock/timezone injection).
758    pub deterministic_mode: DeterministicMode,
759
760    // Range handling configuration (Phase 5)
761    /// Ranges with size <= this limit are expanded into individual Cell dependencies
762    pub range_expansion_limit: usize,
763
764    /// Fallback maximum row bound for open-ended references (e.g. `A:A`, `A1:A`).
765    ///
766    /// This is only used when used-bounds cannot be determined.
767    pub max_open_ended_rows: u32,
768
769    /// Fallback maximum column bound for open-ended references (e.g. `1:1`, `A1:1`).
770    ///
771    /// This is only used when used-bounds cannot be determined.
772    pub max_open_ended_cols: u32,
773
774    /// Height of stripe blocks for dense range indexing
775    pub stripe_height: u32,
776    /// Width of stripe blocks for dense range indexing  
777    pub stripe_width: u32,
778    /// Enable block stripes for dense ranges (vs row/column stripes only)
779    pub enable_block_stripes: bool,
780
781    /// Spill behavior configuration (conflicts, bounds, buffering)
782    pub spill: SpillConfig,
783
784    /// Cycle handling configuration (detection mode + policy). Defaults to
785    /// `CycleDetection::Static` (today's stamp-every-static-SCC behavior);
786    /// `CycleDetection::Runtime` is opt-in (RFC #112).
787    pub cycle: CycleConfig,
788
789    /// Use dynamic topological ordering (Pearce-Kelly algorithm)
790    pub use_dynamic_topo: bool,
791    /// Maximum nodes to visit before falling back to full rebuild
792    pub pk_visit_budget: usize,
793    /// Operations between periodic rank compaction
794    pub pk_compaction_interval_ops: u64,
795    /// Maximum width for parallel evaluation layers
796    pub max_layer_width: Option<usize>,
797    /// If true, reject edge insertions that would create a cycle (skip adding that dependency).
798    /// If false, allow insertion and let scheduler handle cycles at evaluation time.
799    pub pk_reject_cycle_edges: bool,
800    /// Sheet index build strategy for bulk loads
801    pub sheet_index_mode: SheetIndexMode,
802
803    /// Warmup configuration for global pass planning (Phase 1)
804    pub warmup: tuning::WarmupConfig,
805
806    /// Enable Arrow-backed storage reads (Phase A)
807    pub arrow_storage_enabled: bool,
808    /// Enable delta overlay for Arrow sheets (Phase C)
809    pub delta_overlay_enabled: bool,
810
811    /// Mirror formula scalar results into Arrow overlay for Arrow-backed reads
812    /// This enables Arrow-only RangeView correctness without Hybrid fallback.
813    pub write_formula_overlay_enabled: bool,
814
815    /// Optional memory budget (in bytes) for formula/spill computed Arrow overlays.
816    ///
817    /// When set, the engine will compact computed overlays into base lanes when the
818    /// estimated usage exceeds this cap.
819    pub max_overlay_memory_bytes: Option<usize>,
820
821    /// Workbook date system: Excel 1900 (default) or 1904.
822    pub date_system: DateSystem,
823
824    /// Policy for malformed formulas encountered during ingest/graph-build.
825    pub formula_parse_policy: FormulaParsePolicy,
826
827    /// Defer dependency graph building: ingest values immediately but stage formulas
828    /// for on-demand graph construction during evaluation.
829    pub defer_graph_building: bool,
830
831    /// Enable virtual dependency convergence telemetry collection.
832    ///
833    /// When disabled, the engine avoids per-pass timing/edge-count bookkeeping.
834    pub enable_virtual_dep_telemetry: bool,
835
836    /// FormulaPlane ingest/planning mode. Defaults to `Off`; span evaluation is
837    /// explicitly opt-in while `AuthoritativeExperimental` remains experimental.
838    /// `Shadow` may report candidate span opportunities but must still materialize
839    /// every formula via the legacy graph path.
840    pub formula_plane_mode: FormulaPlaneMode,
841    /// Hard candidate bound for compiling mixed FormulaPlane topology.
842    pub max_formula_plane_cache_candidates: usize,
843    /// Hard relationship bound for compiled mixed FormulaPlane topology.
844    pub max_formula_plane_cache_edges: usize,
845    /// Hard byte estimate bound for compiled mixed FormulaPlane topology.
846    pub max_formula_plane_cache_bytes: usize,
847
848    /// Maximum bytes for the engine-side lookup-index cache.
849    pub lookup_index_cache_max_bytes: usize,
850}
851
852impl Default for EvalConfig {
853    fn default() -> Self {
854        Self {
855            enable_parallel: true,
856            max_threads: None,
857            max_vertices: None,
858            max_eval_time: None,
859            max_memory_mb: None,
860            evaluation_budgets: EvaluationBudgets::default(),
861
862            default_sheet_name: format!("Sheet{}", 1),
863
864            // Excel compatibility: identifiers are case-insensitive by default.
865            case_sensitive_names: false,
866            case_sensitive_tables: false,
867
868            // Deterministic RNG seed (matches traits default)
869            workbook_seed: 0xF0F0_D0D0_AAAA_5555,
870
871            // Volatile model default
872            volatile_level: VolatileLevel::Always,
873
874            deterministic_mode: DeterministicMode::default(),
875
876            // Range handling defaults (Phase 5)
877            range_expansion_limit: 64,
878            // Open-ended reference defaults (Excel max dimensions).
879            // Lower these to cap `A:A` / `1:1` when used-bounds are unknown.
880            max_open_ended_rows: 1_048_576,
881            max_open_ended_cols: 16_384,
882            stripe_height: 256,
883            stripe_width: 256,
884            enable_block_stripes: false,
885            spill: SpillConfig::default(),
886            cycle: CycleConfig::default(),
887
888            // Dynamic topology configuration
889            use_dynamic_topo: false, // Disabled by default for compatibility
890            pk_visit_budget: 50_000,
891            pk_compaction_interval_ops: 100_000,
892            max_layer_width: None,
893            pk_reject_cycle_edges: false,
894            sheet_index_mode: SheetIndexMode::Eager,
895            warmup: tuning::WarmupConfig::default(),
896            arrow_storage_enabled: true,
897            delta_overlay_enabled: true,
898            write_formula_overlay_enabled: true,
899            max_overlay_memory_bytes: None,
900            date_system: DateSystem::Excel1900,
901            formula_parse_policy: FormulaParsePolicy::Strict,
902            defer_graph_building: false,
903            enable_virtual_dep_telemetry: false,
904            formula_plane_mode: FormulaPlaneMode::Off,
905            max_formula_plane_cache_candidates: 100_000,
906            max_formula_plane_cache_edges: 100_000,
907            max_formula_plane_cache_bytes: 64 * 1024 * 1024,
908            lookup_index_cache_max_bytes: 64 * 1024 * 1024,
909        }
910    }
911}
912
913impl EvalConfig {
914    #[inline]
915    pub fn with_range_expansion_limit(mut self, limit: usize) -> Self {
916        self.range_expansion_limit = limit;
917        self
918    }
919
920    #[inline]
921    pub fn with_parallel(mut self, enable: bool) -> Self {
922        self.enable_parallel = enable;
923        self
924    }
925
926    #[inline]
927    pub fn with_block_stripes(mut self, enable: bool) -> Self {
928        self.enable_block_stripes = enable;
929        self
930    }
931
932    #[inline]
933    pub fn with_case_sensitive_names(mut self, enable: bool) -> Self {
934        self.case_sensitive_names = enable;
935        self
936    }
937
938    #[inline]
939    pub fn with_case_sensitive_tables(mut self, enable: bool) -> Self {
940        self.case_sensitive_tables = enable;
941        self
942    }
943
944    #[inline]
945    pub fn with_arrow_storage(mut self, enable: bool) -> Self {
946        self.arrow_storage_enabled = enable;
947        self
948    }
949
950    #[inline]
951    pub fn with_delta_overlay(mut self, enable: bool) -> Self {
952        self.delta_overlay_enabled = enable;
953        self
954    }
955
956    #[inline]
957    pub fn with_formula_overlay(mut self, enable: bool) -> Self {
958        self.write_formula_overlay_enabled = enable;
959        self
960    }
961
962    #[inline]
963    pub fn with_date_system(mut self, system: DateSystem) -> Self {
964        self.date_system = system;
965        self
966    }
967
968    #[inline]
969    pub fn with_formula_parse_policy(mut self, policy: FormulaParsePolicy) -> Self {
970        self.formula_parse_policy = policy;
971        self
972    }
973
974    #[inline]
975    pub fn with_virtual_dep_telemetry(mut self, enable: bool) -> Self {
976        self.enable_virtual_dep_telemetry = enable;
977        self
978    }
979
980    #[inline]
981    pub fn with_formula_plane_mode(mut self, mode: FormulaPlaneMode) -> Self {
982        self.formula_plane_mode = mode;
983        self
984    }
985
986    #[inline]
987    pub fn with_evaluation_budgets(mut self, budgets: EvaluationBudgets) -> Self {
988        self.evaluation_budgets = budgets;
989        self
990    }
991
992    /// Resolve explicit and deprecated resource settings without consulting ambient host state.
993    pub fn resolved_evaluation_budgets(&self) -> EvaluationBudgets {
994        resource_ledger::resolve_evaluation_budgets(
995            &self.evaluation_budgets,
996            self.max_vertices,
997            self.max_memory_mb,
998            self.max_eval_time,
999        )
1000        .budgets
1001    }
1002
1003    /// Set the cycle configuration.
1004    ///
1005    /// # Panics
1006    /// Panics when `cycle` is invalid (see [`CycleConfig::validate`]):
1007    /// `Iterate` with `detection: Static`, `max_iterations == 0`, or a
1008    /// negative/non-finite `max_change` are config errors rejected at build
1009    /// (spec §2). [`Engine::new`] re-validates for configs assembled via
1010    /// struct literals.
1011    #[inline]
1012    pub fn with_cycle(mut self, cycle: CycleConfig) -> Self {
1013        if let Err(msg) = cycle.validate() {
1014            panic!("invalid CycleConfig: {msg}");
1015        }
1016        self.cycle = cycle;
1017        self
1018    }
1019}
1020
1021/// Cycle handling configuration (spec: `formualizer-cycle-semantics-spec.md` §2).
1022///
1023/// Nested under [`EvalConfig`] like [`SpillConfig`]; flows through
1024/// `WorkbookConfig.eval` automatically.
1025#[derive(Debug, Clone, Copy, PartialEq, Default)]
1026pub struct CycleConfig {
1027    pub detection: CycleDetection,
1028    pub policy: CyclePolicy,
1029}
1030
1031impl CycleConfig {
1032    /// Runtime detection + Excel-default iterative calculation
1033    /// (`max_iterations: 100`, `max_change: 0.001`).
1034    pub fn iterate_excel_defaults() -> Self {
1035        Self {
1036            detection: CycleDetection::Runtime,
1037            policy: CyclePolicy::iterate_excel_defaults(),
1038        }
1039    }
1040
1041    /// Runtime detection + iterative calculation with explicit knobs.
1042    pub fn iterate(max_iterations: u32, max_change: f64) -> Self {
1043        Self {
1044            detection: CycleDetection::Runtime,
1045            policy: CyclePolicy::Iterate {
1046                max_iterations,
1047                max_change,
1048            },
1049        }
1050    }
1051
1052    /// Validate the configuration (spec §2). Invalid combinations are
1053    /// rejected at build: [`EvalConfig::with_cycle`] and engine construction
1054    /// both panic on `Err`.
1055    pub fn validate(&self) -> Result<(), String> {
1056        if let CyclePolicy::Iterate {
1057            max_iterations,
1058            max_change,
1059        } = self.policy
1060        {
1061            if self.detection == CycleDetection::Static {
1062                return Err(
1063                    "CyclePolicy::Iterate requires CycleDetection::Runtime (spec §2)".to_string(),
1064                );
1065            }
1066            if max_iterations == 0 {
1067                return Err("CyclePolicy::Iterate max_iterations must be >= 1".to_string());
1068            }
1069            if !max_change.is_finite() || max_change < 0.0 {
1070                return Err(format!(
1071                    "CyclePolicy::Iterate max_change must be finite and >= 0 (got {max_change})"
1072                ));
1073            }
1074        }
1075        Ok(())
1076    }
1077
1078    /// Whether ingest may accept formulas whose dependencies include the
1079    /// formula's own cell (`=B1+A1` in B1). Excel accepts these only with
1080    /// iterative calculation enabled; everywhere else the edit-time
1081    /// "Self-reference detected" rejection stands.
1082    #[inline]
1083    pub(crate) fn allows_self_dependency(&self) -> bool {
1084        self.detection == CycleDetection::Runtime
1085            && matches!(self.policy, CyclePolicy::Iterate { .. })
1086    }
1087}
1088
1089/// How statically-cyclic SCCs are treated at evaluation time.
1090#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1091pub enum CycleDetection {
1092    /// Today's behavior: every static SCC is stamped `#CIRC!`. Compat escape
1093    /// hatch; no live-edge machinery runs.
1094    #[default]
1095    Static,
1096    /// Static SCCs are candidates; members are evaluated with live-edge
1097    /// recording and only *live* cycles get the policy verdict. Phantom
1098    /// (live-acyclic) SCCs produce ordinary values (discussion #99).
1099    Runtime,
1100}
1101
1102/// What happens to witnessed (live) cycles under `CycleDetection::Runtime`.
1103#[derive(Debug, Clone, Copy, PartialEq, Default)]
1104pub enum CyclePolicy {
1105    /// Live cycles produce `#CIRC!`.
1106    #[default]
1107    Error,
1108    /// Excel-style iterative calculation (RFC #113, spec §3.5/§6):
1109    /// live cycles keep running full passes over all SCC members in member
1110    /// order (Gauss–Seidel: each result is committed before the next member
1111    /// runs) until every member converges per the spec-§6 rules or
1112    /// `max_iterations` total passes (pass 1 included) have run. Hitting the
1113    /// cap keeps the last values and is NOT an error (Excel parity);
1114    /// telemetry records `capped_sccs`.
1115    Iterate {
1116        /// Total passes per SCC per recalc, pass 1 included. `1` means each
1117        /// member evaluates exactly once per recalc (the Excel accumulator
1118        /// contract, spec §7.6); `0` is a config error.
1119        max_iterations: u32,
1120        /// Absolute per-member convergence threshold on f64 serial values
1121        /// (`|Δ| < max_change`, strict — Excel semantics). Negative or
1122        /// non-finite values are config errors.
1123        max_change: f64,
1124    },
1125}
1126
1127impl CyclePolicy {
1128    /// Excel's default iterative-calculation knobs.
1129    pub const EXCEL_DEFAULT_MAX_ITERATIONS: u32 = 100;
1130    /// Excel's default maximum-change threshold.
1131    pub const EXCEL_DEFAULT_MAX_CHANGE: f64 = 0.001;
1132
1133    /// `Iterate` with Excel's defaults (100 iterations, 0.001 max change).
1134    pub fn iterate_excel_defaults() -> Self {
1135        CyclePolicy::Iterate {
1136            max_iterations: Self::EXCEL_DEFAULT_MAX_ITERATIONS,
1137            max_change: Self::EXCEL_DEFAULT_MAX_CHANGE,
1138        }
1139    }
1140}
1141
1142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1143pub enum SheetIndexMode {
1144    /// Build full interval-tree based index during inserts (current behavior)
1145    Eager,
1146    /// Defer building any sheet index until first range query or explicit finalize
1147    Lazy,
1148    /// Use fast batch building (sorted arrays -> tree) when bulk loading, otherwise incremental
1149    FastBatch,
1150}
1151
1152pub use formualizer_common::DateSystem;
1153
1154/// Construct a new engine with the given resolver and configuration
1155pub fn new_engine<R>(resolver: R, config: EvalConfig) -> Engine<R>
1156where
1157    R: EvaluationContext + 'static,
1158{
1159    Engine::new(resolver, config)
1160}
1161
1162/// Configuration for spill behavior. Nested under EvalConfig to avoid bloating the top-level.
1163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1164pub struct SpillConfig {
1165    /// What to do when target region overlaps non-empty cells or other spills.
1166    pub conflict_policy: SpillConflictPolicy,
1167    /// Tiebreaker used when policy allows preemption or multiple anchors race.
1168    pub tiebreaker: SpillTiebreaker,
1169    /// Bounds handling when result exceeds sheet capacity.
1170    pub bounds_policy: SpillBoundsPolicy,
1171    /// Buffering approach for spill writes.
1172    pub buffer_mode: SpillBufferMode,
1173    /// Optional memory budget for shadow buffering in bytes.
1174    pub memory_budget_bytes: Option<u64>,
1175    /// Cancellation behavior while streaming rows.
1176    pub cancellation: SpillCancellationPolicy,
1177    /// Visibility policy for staged writes.
1178    pub visibility: SpillVisibility,
1179
1180    /// Hard cap on the number of cells a single spill may project.
1181    ///
1182    /// This prevents pathological vertex explosions from very large dynamic arrays.
1183    pub max_spill_cells: u32,
1184}
1185
1186impl Default for SpillConfig {
1187    fn default() -> Self {
1188        Self {
1189            conflict_policy: SpillConflictPolicy::Error,
1190            tiebreaker: SpillTiebreaker::FirstWins,
1191            bounds_policy: SpillBoundsPolicy::Strict,
1192            buffer_mode: SpillBufferMode::ShadowBuffer,
1193            memory_budget_bytes: None,
1194            cancellation: SpillCancellationPolicy::Cooperative,
1195            visibility: SpillVisibility::OnCommit,
1196            // Conservative: enough for common UI patterns, small enough to avoid graph blowups.
1197            max_spill_cells: 10_000,
1198        }
1199    }
1200}
1201
1202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1203pub enum SpillConflictPolicy {
1204    Error,
1205    Preempt,
1206}
1207
1208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1209pub enum SpillTiebreaker {
1210    FirstWins,
1211    EvaluationEpochAsc,
1212    AnchorAddressAsc,
1213    FunctionPriorityThenAddress,
1214}
1215
1216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1217pub enum SpillBoundsPolicy {
1218    Strict,
1219    Truncate,
1220}
1221
1222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1223pub enum SpillBufferMode {
1224    ShadowBuffer,
1225    PersistenceJournal,
1226}
1227
1228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1229pub enum SpillCancellationPolicy {
1230    Cooperative,
1231    Strict,
1232}
1233
1234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1235pub enum SpillVisibility {
1236    OnCommit,
1237    StagedLayer,
1238}
1239
1240/*
1241 * Scenario: Tombstone Registry for Missing Sheets
1242 * When a sheet is deleted, formulas pointing to it become "orphans."
1243 * Instead of losing the connection, we store the formula's VertexId
1244 * under the name of the missing sheet.
1245 *
1246 * Why it matters:
1247 * This allows Sheet Addition to remain O(1) for the general case,
1248 * while providing O(N_orphans) recovery for broken formulas.
1249 */
1250#[derive(Debug, Default)]
1251pub struct TombstoneRegistry {
1252    // Maps "SheetName" -> Vec<VertexId of formulas waiting for it>
1253    pub pending_references: HashMap<String, Vec<VertexId>>,
1254}
1255
1256impl TombstoneRegistry {
1257    /// Record that a vertex is waiting for a specific sheet name to appear.
1258    pub fn add_orphan(&mut self, sheet_name: String, vertex_id: VertexId) {
1259        self.pending_references
1260            .entry(sheet_name)
1261            .or_default()
1262            .push(vertex_id);
1263    }
1264
1265    /// Retrieve and remove all vertices waiting for a specific sheet name.
1266    pub fn take_orphans(&mut self, sheet_name: &str) -> Vec<VertexId> {
1267        self.pending_references
1268            .remove(sheet_name)
1269            .unwrap_or_default()
1270    }
1271}