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