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