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