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