Skip to main content

formualizer_eval/engine/
inspect.rs

1//! Read-only, engine-native workbook introspection.
2//!
3//! Reports in this module are owned semantic snapshots. Inspection performs no
4//! semantic mutation: it never evaluates, prepares dependency state, creates
5//! placeholder vertices, or marks cells dirty. It may warm snapshot-guarded
6//! performance caches such as the row-bounds cache. Reports are plane-independent:
7//! legacy and authoritative FormulaPlane engines return field-identical semantic
8//! reports for identical logical workbook state, apart from their state stamps.
9//! Two exceptions apply: (a) after structural edits and before re-evaluation,
10//! per-cell staleness may be more conservative ([`Staleness::Dirty`]) under
11//! FormulaPlane authority than legacy; and (b) reports produced under a binding
12//! `max_work` budget are representation-dependent in how much they discover.
13
14use std::collections::{HashMap, VecDeque};
15use std::error::Error;
16use std::fmt;
17
18use formualizer_common::{
19    CellAddress, ExcelError, ExcelErrorKind, LiteralValue, RangeAddress, RangeArea, SheetId,
20};
21use formualizer_parse::parser::{
22    ASTNode, ReferenceType, SpecialItem, TableReference, TableSpecifier,
23};
24use rustc_hash::FxHashMap;
25
26use crate::engine::named_range::NamedDefinition;
27use crate::engine::refs;
28use crate::engine::used_extent::{
29    ExtentPolicy, OpenRangeBounds, ResolvedExtent, resolve_used_extent,
30};
31use crate::engine::{Engine, VertexId, VertexKind};
32use crate::reference::{CellRef, Coord};
33use crate::traits::EvaluationContext;
34
35const DEFAULT_MAX_LINKS: u32 = 256;
36const DEFAULT_MAX_WORK: u64 = 100_000;
37
38/// Correlates a report with the engine mutation and recalculation state from
39/// which it was copied.
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
42pub struct StateStamp {
43    pub mutation_revision: u64,
44    pub recalc_epoch: u64,
45}
46
47#[cfg_attr(feature = "serde", derive(serde::Serialize))]
48#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
49#[non_exhaustive]
50pub enum Staleness {
51    Current,
52    Dirty,
53    NeverEvaluated,
54}
55
56/// A last-evaluation spill-registry fact.
57///
58/// Consumers must interpret [`SpillRole::Member`] and [`SpillRole::Anchor`]
59/// together with the anchor cell's [`CellSnapshot::staleness`]. A dirty anchor
60/// can retain its prior evaluated extent until recalculation.
61#[cfg_attr(feature = "serde", derive(serde::Serialize))]
62#[derive(Clone, Debug, Eq, PartialEq, Hash)]
63#[non_exhaustive]
64pub enum SpillRole {
65    Anchor { extent: RangeAddress },
66    Member { anchor: CellAddress },
67}
68
69#[cfg_attr(feature = "serde", derive(serde::Serialize))]
70#[derive(Clone, Debug, PartialEq)]
71#[non_exhaustive]
72pub struct CellSnapshot {
73    pub address: CellAddress,
74    pub formula: Option<String>,
75    pub value: Option<LiteralValue>,
76    pub value_included: bool,
77    pub staleness: Staleness,
78    pub volatile: bool,
79    /// Last-evaluation spill role; read it together with the anchor's
80    /// [`CellSnapshot::staleness`], especially when the anchor is dirty.
81    pub spill: Option<SpillRole>,
82}
83
84/// AST-free result inspection for validated output projection.
85///
86/// `has_formula` includes staged and compressed-family formulas. This does not
87/// certify successful ingestion of a particular source expression: callers must
88/// still reconcile source coordinates and parse-coercion diagnostics. Empty
89/// values retain the same absent/NeverEvaluated semantics as [`CellSnapshot`].
90/// A member's currentness does not certify its anchor; validate that anchor too.
91#[cfg_attr(feature = "serde", derive(serde::Serialize))]
92#[derive(Clone, Debug, PartialEq)]
93#[non_exhaustive]
94pub struct CellResultState {
95    pub address: CellAddress,
96    pub has_formula: bool,
97    pub value: Option<LiteralValue>,
98    pub staleness: Staleness,
99    pub spill: Option<SpillRole>,
100}
101
102#[cfg_attr(feature = "serde", derive(serde::Serialize))]
103#[derive(Clone, Debug, PartialEq)]
104#[non_exhaustive]
105pub struct CellSnapshotReport {
106    pub stamp: StateStamp,
107    pub cell: CellSnapshot,
108}
109
110#[cfg_attr(feature = "serde", derive(serde::Serialize))]
111#[derive(Clone, Debug, PartialEq)]
112#[non_exhaustive]
113pub enum NameResolution {
114    Cell(CellAddress),
115    Range {
116        declared: RangeArea,
117        resolved: Option<RangeAddress>,
118    },
119    Literal(LiteralValue),
120    Formula {
121        formula: String,
122        value: Option<LiteralValue>,
123    },
124    Unresolved,
125}
126
127#[cfg_attr(feature = "serde", derive(serde::Serialize))]
128#[derive(Clone, Debug, PartialEq)]
129#[non_exhaustive]
130pub enum SemanticReference {
131    Cell(CellAddress),
132    Range {
133        declared: RangeArea,
134        resolved: Option<RangeAddress>,
135        cell_count: u64,
136    },
137    Name {
138        name: String,
139        resolution: NameResolution,
140    },
141    Table {
142        name: String,
143        specifier: String,
144        resolved: RangeAddress,
145    },
146    External {
147        raw: String,
148    },
149    Unsupported {
150        text: String,
151        reason: String,
152    },
153}
154
155#[cfg_attr(feature = "serde", derive(serde::Serialize))]
156#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
157#[non_exhaustive]
158pub enum Provenance {
159    Declared,
160    Observed,
161}
162
163#[cfg_attr(feature = "serde", derive(serde::Serialize))]
164#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
165#[non_exhaustive]
166pub enum TraceLinkKind {
167    Formula { provenance: Provenance },
168    SpillAnchor,
169    SpillReader,
170}
171
172#[cfg_attr(feature = "serde", derive(serde::Serialize))]
173#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
174#[non_exhaustive]
175pub enum LinkDisposition {
176    Expanded,
177    Convergent,
178    Cycle,
179    Elided,
180}
181
182#[cfg_attr(feature = "serde", derive(serde::Serialize))]
183#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
184#[non_exhaustive]
185pub enum OmittedCount {
186    Exact(u64),
187    AtLeast(u64),
188}
189
190#[cfg_attr(feature = "serde", derive(serde::Serialize))]
191#[derive(Clone, Debug, Default, Eq, PartialEq)]
192#[non_exhaustive]
193pub struct TruncationReport {
194    pub incomplete: bool,
195    /// `None` with `incomplete == true` means that the omitted count is
196    /// unknown. `AtLeast(k)` is emitted only for witnessed `k >= 1` omissions;
197    /// `Exact(k)` remains an exact known count.
198    pub omitted: Option<OmittedCount>,
199}
200
201#[cfg_attr(feature = "serde", derive(serde::Serialize))]
202#[derive(Clone, Debug, PartialEq)]
203#[non_exhaustive]
204pub struct Precedent {
205    pub reference: SemanticReference,
206    pub provenance: Provenance,
207}
208
209#[cfg_attr(feature = "serde", derive(serde::Serialize))]
210#[derive(Clone, Debug, PartialEq)]
211#[non_exhaustive]
212pub struct PrecedentReport {
213    pub stamp: StateStamp,
214    pub cell: CellAddress,
215    pub precedents: Vec<Precedent>,
216    pub truncation: TruncationReport,
217}
218
219#[cfg_attr(feature = "serde", derive(serde::Serialize))]
220#[derive(Clone, Debug, Eq, PartialEq)]
221#[non_exhaustive]
222pub struct Dependent {
223    pub cell: CellAddress,
224    /// Spill member addresses through which this reader was discovered.
225    /// Empty for an ordinary dependent query; only spill-anchor queries
226    /// populate this vector.
227    pub via: Vec<CellAddress>,
228}
229
230#[cfg_attr(feature = "serde", derive(serde::Serialize))]
231#[derive(Clone, Debug, Eq, PartialEq)]
232#[non_exhaustive]
233pub struct DependentsReport {
234    pub stamp: StateStamp,
235    pub cell: CellAddress,
236    pub dependents: Vec<Dependent>,
237    pub truncation: TruncationReport,
238}
239
240#[cfg_attr(feature = "serde", derive(serde::Serialize))]
241#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
242pub struct TraceNodeId(pub u32);
243
244#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
245#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
246#[non_exhaustive]
247pub enum TraceDirection {
248    Precedents,
249    Dependents,
250}
251
252#[cfg_attr(feature = "serde", derive(serde::Serialize))]
253#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
254#[non_exhaustive]
255pub struct TraceLinkTarget {
256    pub node: TraceNodeId,
257    pub disposition: LinkDisposition,
258}
259
260#[cfg_attr(feature = "serde", derive(serde::Serialize))]
261#[derive(Clone, Debug, PartialEq)]
262#[non_exhaustive]
263pub struct TraceLink {
264    pub reference: SemanticReference,
265    pub kind: TraceLinkKind,
266    pub targets: Vec<TraceLinkTarget>,
267    pub omitted: Option<OmittedCount>,
268}
269
270#[cfg_attr(feature = "serde", derive(serde::Serialize))]
271#[derive(Clone, Debug, PartialEq)]
272#[non_exhaustive]
273pub struct TraceNode {
274    pub id: TraceNodeId,
275    pub cell: CellSnapshot,
276    pub links: Vec<TraceLink>,
277}
278
279#[cfg_attr(feature = "serde", derive(serde::Serialize))]
280#[derive(Clone, Debug, PartialEq)]
281#[non_exhaustive]
282pub struct TraceGraph {
283    pub stamp: StateStamp,
284    pub direction: TraceDirection,
285    /// One response-local node id per requested root, in request order.
286    /// Duplicate roots retain repeated ids while sharing one materialized node.
287    pub roots: Vec<TraceNodeId>,
288    pub nodes: Vec<TraceNode>,
289    pub truncation: TruncationReport,
290}
291
292#[cfg_attr(feature = "serde", derive(serde::Serialize))]
293#[derive(Clone, Debug, PartialEq)]
294#[non_exhaustive]
295pub struct RangePage {
296    pub stamp: StateStamp,
297    pub declared: RangeArea,
298    pub resolved: Option<RangeAddress>,
299    pub total: u64,
300    /// Echoes the requested offset, even when it lies beyond `total`; it is not
301    /// a clamped item position.
302    pub offset: u64,
303    pub items: Vec<CellSnapshot>,
304    pub next_offset: Option<u64>,
305}
306
307/// Capacity minima that must hold the request's own anchors are hard errors:
308/// trace requires max_nodes >= unique roots (and nonempty roots); range_page
309/// requires limit >= 1. All expansion budgets (max_depth, max_links, max_work,
310/// range_member_budget, max_results) accept zero and degrade to in-band
311/// truncation.
312#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
313#[derive(Clone, Copy, Debug, Eq, PartialEq)]
314pub struct SnapshotOptions {
315    pub include_values: bool,
316}
317
318impl Default for SnapshotOptions {
319    fn default() -> Self {
320        Self {
321            include_values: true,
322        }
323    }
324}
325
326impl SnapshotOptions {
327    pub fn with_include_values(mut self, include_values: bool) -> Self {
328        self.include_values = include_values;
329        self
330    }
331}
332
333/// Capacity minima that must hold the request's own anchors are hard errors:
334/// trace requires max_nodes >= unique roots (and nonempty roots); range_page
335/// requires limit >= 1. All expansion budgets (max_depth, max_links, max_work,
336/// range_member_budget, max_results) accept zero and degrade to in-band
337/// truncation.
338#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
339#[derive(Clone, Copy, Debug, Eq, PartialEq)]
340pub struct PrecedentOptions {
341    pub max_links: u32,
342    pub max_work: u64,
343}
344
345impl Default for PrecedentOptions {
346    fn default() -> Self {
347        Self {
348            max_links: DEFAULT_MAX_LINKS,
349            max_work: DEFAULT_MAX_WORK,
350        }
351    }
352}
353
354impl PrecedentOptions {
355    pub fn with_max_links(mut self, max_links: u32) -> Self {
356        self.max_links = max_links;
357        self
358    }
359
360    pub fn with_max_work(mut self, max_work: u64) -> Self {
361        self.max_work = max_work;
362        self
363    }
364}
365
366/// Capacity minima that must hold the request's own anchors are hard errors:
367/// trace requires max_nodes >= unique roots (and nonempty roots); range_page
368/// requires limit >= 1. All expansion budgets (max_depth, max_links, max_work,
369/// range_member_budget, max_results) accept zero and degrade to in-band
370/// truncation.
371#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
372#[derive(Clone, Copy, Debug, Eq, PartialEq)]
373pub struct DependentsOptions {
374    /// Maximum returned dependents. When discovery finds more candidates, the
375    /// address-least `max_results` candidates are retained in canonical sheet,
376    /// row, column order. Discovery remains independently bounded by
377    /// [`DependentsOptions::max_work`].
378    pub max_results: u32,
379    pub max_work: u64,
380}
381
382impl Default for DependentsOptions {
383    fn default() -> Self {
384        Self {
385            max_results: 256,
386            max_work: DEFAULT_MAX_WORK,
387        }
388    }
389}
390
391impl DependentsOptions {
392    pub fn with_max_results(mut self, max_results: u32) -> Self {
393        self.max_results = max_results;
394        self
395    }
396
397    pub fn with_max_work(mut self, max_work: u64) -> Self {
398        self.max_work = max_work;
399        self
400    }
401}
402
403/// Capacity minima that must hold the request's own anchors are hard errors:
404/// trace requires max_nodes >= unique roots (and nonempty roots); range_page
405/// requires limit >= 1. All expansion budgets (max_depth, max_links, max_work,
406/// range_member_budget, max_results) accept zero and degrade to in-band
407/// truncation.
408#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
409#[derive(Clone, Copy, Debug, Eq, PartialEq)]
410pub struct TraceOptions {
411    pub direction: TraceDirection,
412    /// Maximum expansion depth. Depth `N` can still materialize elided target
413    /// nodes at depth `N + 1`, charged against `max_nodes`.
414    pub max_depth: u32,
415    /// Global node capacity. It must be at least the number of unique roots;
416    /// all roots are admitted before expansion and duplicates share a node.
417    pub max_nodes: u32,
418    pub max_links: u32,
419    pub max_work: u64,
420    pub range_member_budget: u32,
421    pub include_values: bool,
422}
423
424impl Default for TraceOptions {
425    fn default() -> Self {
426        Self {
427            direction: TraceDirection::Precedents,
428            max_depth: 6,
429            max_nodes: 512,
430            max_links: 1_024,
431            max_work: DEFAULT_MAX_WORK,
432            range_member_budget: 256,
433            include_values: true,
434        }
435    }
436}
437
438impl TraceOptions {
439    pub fn with_direction(mut self, direction: TraceDirection) -> Self {
440        self.direction = direction;
441        self
442    }
443
444    pub fn with_max_depth(mut self, max_depth: u32) -> Self {
445        self.max_depth = max_depth;
446        self
447    }
448
449    pub fn with_max_nodes(mut self, max_nodes: u32) -> Self {
450        self.max_nodes = max_nodes;
451        self
452    }
453
454    pub fn with_max_links(mut self, max_links: u32) -> Self {
455        self.max_links = max_links;
456        self
457    }
458
459    pub fn with_max_work(mut self, max_work: u64) -> Self {
460        self.max_work = max_work;
461        self
462    }
463
464    pub fn with_range_member_budget(mut self, range_member_budget: u32) -> Self {
465        self.range_member_budget = range_member_budget;
466        self
467    }
468
469    pub fn with_include_values(mut self, include_values: bool) -> Self {
470        self.include_values = include_values;
471        self
472    }
473}
474
475/// Capacity minima that must hold the request's own anchors are hard errors:
476/// trace requires max_nodes >= unique roots (and nonempty roots); range_page
477/// requires limit >= 1. All expansion budgets (max_depth, max_links, max_work,
478/// range_member_budget, max_results) accept zero and degrade to in-band
479/// truncation.
480#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
481#[derive(Clone, Copy, Debug, Eq, PartialEq)]
482pub struct RangePageOptions {
483    pub offset: u64,
484    pub limit: u32,
485    pub include_values: bool,
486    pub expected_stamp: Option<StateStamp>,
487}
488
489impl Default for RangePageOptions {
490    fn default() -> Self {
491        Self {
492            offset: 0,
493            limit: 100,
494            include_values: true,
495            expected_stamp: None,
496        }
497    }
498}
499
500impl RangePageOptions {
501    pub fn with_offset(mut self, offset: u64) -> Self {
502        self.offset = offset;
503        self
504    }
505
506    pub fn with_limit(mut self, limit: u32) -> Self {
507        self.limit = limit;
508        self
509    }
510
511    pub fn with_include_values(mut self, include_values: bool) -> Self {
512        self.include_values = include_values;
513        self
514    }
515
516    pub fn with_expected_stamp(mut self, expected_stamp: StateStamp) -> Self {
517        self.expected_stamp = Some(expected_stamp);
518        self
519    }
520}
521
522#[cfg_attr(feature = "serde", derive(serde::Serialize))]
523#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
524#[non_exhaustive]
525pub enum InspectionUnavailableReason {
526    DeferredDependencyGraph,
527    /// The dependency authority is not synced with the workbook or failed
528    /// (a typed evaluation error reports why).
529    DependencyAuthorityUnavailable,
530}
531
532#[cfg_attr(feature = "serde", derive(serde::Serialize))]
533#[derive(Clone, Debug, Eq, PartialEq)]
534#[non_exhaustive]
535pub enum InspectError {
536    SheetNotFound {
537        sheet: String,
538    },
539    InvalidAddress {
540        message: String,
541    },
542    InvalidOptions {
543        message: String,
544    },
545    DependencyStateUnavailable {
546        reason: InspectionUnavailableReason,
547    },
548    RevisionMismatch {
549        expected: StateStamp,
550        actual: StateStamp,
551    },
552    /// currently unreachable in practice; reserved for bounded-allocation paths
553    ResourceExhausted {
554        resource: &'static str,
555    },
556}
557
558impl fmt::Display for InspectError {
559    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
560        match self {
561            Self::SheetNotFound { sheet } => write!(f, "sheet not found: {sheet}"),
562            Self::InvalidAddress { message } => write!(f, "invalid address: {message}"),
563            Self::InvalidOptions { message } => write!(f, "invalid inspection options: {message}"),
564            Self::DependencyStateUnavailable { reason } => {
565                write!(f, "dependency state unavailable: {reason:?}")
566            }
567            Self::RevisionMismatch { expected, actual } => write!(
568                f,
569                "inspection revision mismatch: expected {expected:?}, actual {actual:?}"
570            ),
571            Self::ResourceExhausted { resource } => {
572                write!(f, "inspection resource exhausted: {resource}")
573            }
574        }
575    }
576}
577
578impl Error for InspectError {}
579
580#[cfg_attr(feature = "serde", derive(serde::Serialize))]
581#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
582pub(crate) struct CellKey {
583    sheet_id: SheetId,
584    row0: u32,
585    col0: u32,
586}
587
588#[derive(Clone, Debug)]
589pub(crate) struct FormulaView {
590    ast: ASTNode,
591    volatile: bool,
592    dirty: bool,
593}
594
595#[derive(Clone, Debug)]
596pub(crate) enum InternalSpillRole {
597    Anchor { extent: RangeAddress },
598    Member { anchor: CellKey },
599}
600
601#[derive(Clone, Copy, Debug, Eq, PartialEq)]
602pub(crate) enum QueryCompleteness {
603    Complete,
604    Incomplete,
605}
606
607#[derive(Clone, Copy, Debug)]
608pub(crate) struct WorkBudget {
609    remaining: u64,
610}
611
612impl WorkBudget {
613    fn new(limit: u64) -> Self {
614        Self { remaining: limit }
615    }
616
617    fn charge(&mut self) -> bool {
618        if self.remaining == 0 {
619            false
620        } else {
621            self.remaining -= 1;
622            true
623        }
624    }
625}
626
627pub(crate) trait ReferenceVisitor {
628    /// `false` requests an immediate, successful early stop.
629    fn visit(&mut self, reference: refs::SemanticReference<'_>) -> bool;
630}
631
632pub(crate) trait DependentVisitor {
633    /// `false` requests an immediate, successful early stop.
634    fn visit(&mut self, dependent: CellKey) -> bool;
635}
636
637/// Formula-authority seam for introspection. The legacy implementation lands
638/// here; another authority can implement the same address-semantic visitor
639/// contract without moving DTO construction or traversal rules.
640pub(crate) trait InspectSource {
641    fn formula_at(&self, cell: CellKey) -> Result<Option<FormulaView>, InspectError>;
642    fn visit_declared_references(
643        &self,
644        cell: CellKey,
645        visitor: &mut dyn ReferenceVisitor,
646    ) -> Result<(), InspectError>;
647    fn visit_dependents_covering(
648        &self,
649        cell: CellKey,
650        budget: &mut WorkBudget,
651        visitor: &mut dyn DependentVisitor,
652    ) -> Result<QueryCompleteness, InspectError>;
653    fn spill_role(&self, cell: CellKey) -> Option<InternalSpillRole>;
654}
655
656struct LegacyInspectSource<'a, R> {
657    engine: &'a Engine<R>,
658}
659
660impl<R: EvaluationContext> LegacyInspectSource<'_, R> {
661    fn cell_ref(&self, key: CellKey) -> CellRef {
662        CellRef::new(key.sheet_id, Coord::new(key.row0, key.col0, true, true))
663    }
664}
665
666fn visit_formula_ast_references(
667    ast: &ASTNode,
668    visitor: &mut dyn ReferenceVisitor,
669) -> Result<(), InspectError> {
670    struct Context<'a> {
671        visitor: &'a mut dyn ReferenceVisitor,
672        stopped: bool,
673    }
674    fn local_bindings(_: &Context<'_>, name: &str, _: usize) -> refs::LocalBindingStyle {
675        match name
676            .rsplit('.')
677            .next()
678            .unwrap_or(name)
679            .to_ascii_uppercase()
680            .as_str()
681        {
682            "LET" => refs::LocalBindingStyle::LocalBindingPairs,
683            "LAMBDA" => refs::LocalBindingStyle::LambdaParameters,
684            _ => refs::LocalBindingStyle::None,
685        }
686    }
687    fn consume(
688        context: &mut Context<'_>,
689        reference: refs::SemanticReference<'_>,
690    ) -> Result<(), ExcelError> {
691        if context.visitor.visit(reference) {
692            Ok(())
693        } else {
694            context.stopped = true;
695            Err(ExcelError::new(ExcelErrorKind::NImpl)
696                .with_message("inspection visitor requested stop"))
697        }
698    }
699
700    let mut context = Context {
701        visitor,
702        stopped: false,
703    };
704    let result = refs::visit_tree_references(ast, &mut context, local_bindings, consume);
705    if context.stopped {
706        Ok(())
707    } else {
708        result.map_err(|error| InspectError::InvalidAddress {
709            message: error.to_string(),
710        })
711    }
712}
713
714impl<R: EvaluationContext> InspectSource for LegacyInspectSource<'_, R> {
715    fn formula_at(&self, cell: CellKey) -> Result<Option<FormulaView>, InspectError> {
716        let sheet = self.engine.graph.sheet_name(cell.sheet_id);
717        let row = cell.row0 + 1;
718        let col = cell.col0 + 1;
719        if let Some(text) = self.engine.get_staged_formula_text(sheet, row, col) {
720            // Imported OOXML formula text normally omits '='. Without it the
721            // parser intentionally interprets the input as a literal cell value.
722            let text = if text.starts_with('=') {
723                text
724            } else {
725                format!("={text}")
726            };
727            let ast =
728                formualizer_parse::parse(&text).map_err(|error| InspectError::InvalidAddress {
729                    message: format!("staged formula at {sheet}!R{row}C{col} is invalid: {error}"),
730                })?;
731            let volatile = self.engine.graph.fp8_parity_is_ast_volatile(&ast);
732            return Ok(Some(FormulaView {
733                ast,
734                volatile,
735                dirty: true,
736            }));
737        }
738
739        let cell_ref = self.cell_ref(cell);
740        let Some(vertex) = self.engine.graph.get_vertex_for_cell(&cell_ref) else {
741            return Ok(None);
742        };
743        let Some(ast) = self.engine.graph.get_formula(vertex) else {
744            return Ok(None);
745        };
746        Ok(Some(FormulaView {
747            ast,
748            volatile: self.engine.graph.is_volatile(vertex),
749            dirty: self.engine.graph.is_dirty(vertex),
750        }))
751    }
752
753    fn visit_declared_references(
754        &self,
755        cell: CellKey,
756        visitor: &mut dyn ReferenceVisitor,
757    ) -> Result<(), InspectError> {
758        let Some(formula) = self.formula_at(cell)? else {
759            return Ok(());
760        };
761
762        visit_formula_ast_references(&formula.ast, visitor)
763    }
764
765    fn visit_dependents_covering(
766        &self,
767        cell: CellKey,
768        budget: &mut WorkBudget,
769        visitor: &mut dyn DependentVisitor,
770    ) -> Result<QueryCompleteness, InspectError> {
771        // Legacy's stripe readers exist only in oracle builds; the authority
772        // path (`collect_dependents`) does not come here.
773        #[cfg(not(any(test, feature = "legacy_oracle")))]
774        let complete = {
775            let _ = (cell, budget, visitor);
776            true
777        };
778        #[cfg(any(test, feature = "legacy_oracle"))]
779        let complete = self.engine.graph.visit_range_dependents_covering_bounded(
780            cell.sheet_id,
781            cell.row0,
782            cell.col0,
783            &mut budget.remaining,
784            &mut |vertex| {
785                self.engine
786                    .graph
787                    .get_cell_ref(vertex)
788                    .is_none_or(|cell_ref| {
789                        visitor.visit(CellKey {
790                            sheet_id: cell_ref.sheet_id,
791                            row0: cell_ref.coord.row(),
792                            col0: cell_ref.coord.col(),
793                        })
794                    })
795            },
796        );
797        Ok(if complete {
798            QueryCompleteness::Complete
799        } else {
800            QueryCompleteness::Incomplete
801        })
802    }
803
804    fn spill_role(&self, cell: CellKey) -> Option<InternalSpillRole> {
805        let cell_ref = self.cell_ref(cell);
806        if let Some(vertex) = self.engine.graph.get_vertex_for_cell(&cell_ref)
807            && let Some(cells) = self.engine.graph.spill_cells_for_anchor(vertex)
808        {
809            let mut bounds: Option<(u32, u32, u32, u32)> = None;
810            for member in cells {
811                bounds = Some(match bounds {
812                    None => (
813                        member.coord.row(),
814                        member.coord.col(),
815                        member.coord.row(),
816                        member.coord.col(),
817                    ),
818                    Some((sr, sc, er, ec)) => (
819                        sr.min(member.coord.row()),
820                        sc.min(member.coord.col()),
821                        er.max(member.coord.row()),
822                        ec.max(member.coord.col()),
823                    ),
824                });
825            }
826            if let Some((sr, sc, er, ec)) = bounds {
827                let sheet = self.engine.graph.sheet_name(cell.sheet_id).to_string();
828                return Some(InternalSpillRole::Anchor {
829                    extent: RangeAddress {
830                        sheet,
831                        start_row: sr + 1,
832                        start_col: sc + 1,
833                        end_row: er + 1,
834                        end_col: ec + 1,
835                    },
836                });
837            }
838        }
839        let anchor = self.engine.graph.spill_registry_anchor_for_cell(cell_ref)?;
840        let anchor_ref = self.engine.graph.get_cell_ref(anchor)?;
841        Some(InternalSpillRole::Member {
842            anchor: CellKey {
843                sheet_id: anchor_ref.sheet_id,
844                row0: anchor_ref.coord.row(),
845                col0: anchor_ref.coord.col(),
846            },
847        })
848    }
849}
850
851fn merge_omitted(target: &mut Option<OmittedCount>, addition: OmittedCount) {
852    *target = Some(match (target.take(), addition) {
853        (None, value) => value,
854        (Some(OmittedCount::Exact(a)), OmittedCount::Exact(b)) => {
855            OmittedCount::Exact(a.saturating_add(b))
856        }
857        (Some(OmittedCount::Exact(a)), OmittedCount::AtLeast(b))
858        | (Some(OmittedCount::AtLeast(a)), OmittedCount::Exact(b))
859        | (Some(OmittedCount::AtLeast(a)), OmittedCount::AtLeast(b)) => {
860            OmittedCount::AtLeast(a.saturating_add(b))
861        }
862    });
863}
864
865fn address_cmp(left: &CellAddress, right: &CellAddress) -> std::cmp::Ordering {
866    left.sheet
867        .cmp(&right.sheet)
868        .then_with(|| left.row.cmp(&right.row))
869        .then_with(|| left.column.cmp(&right.column))
870}
871
872impl<R: EvaluationContext> Engine<R> {
873    fn inspect_stamp(&self) -> StateStamp {
874        StateStamp {
875            mutation_revision: self.inspection_mutation_revision(),
876            recalc_epoch: self.recalc_epoch,
877        }
878    }
879
880    fn inspect_source(&self) -> LegacyInspectSource<'_, R> {
881        LegacyInspectSource { engine: self }
882    }
883
884    fn canonical_cell(
885        &self,
886        address: &CellAddress,
887    ) -> Result<(CellKey, CellAddress), InspectError> {
888        // The constructor validates coordinates only; do not clone the sheet
889        // name merely to discard this temporary validation address.
890        CellAddress::new(String::new(), address.row, address.column).map_err(|error| {
891            InspectError::InvalidAddress {
892                message: error.to_string(),
893            }
894        })?;
895        let Some(sheet_id) = self.graph.sheet_id(&address.sheet) else {
896            return Err(InspectError::SheetNotFound {
897                sheet: address.sheet.clone(),
898            });
899        };
900        let canonical = CellAddress {
901            sheet: self.graph.sheet_name(sheet_id).to_string(),
902            row: address.row,
903            column: address.column,
904        };
905        Ok((
906            CellKey {
907                sheet_id,
908                row0: address.row - 1,
909                col0: address.column - 1,
910            },
911            canonical,
912        ))
913    }
914
915    fn canonical_area(&self, area: &RangeArea) -> Result<(SheetId, RangeArea), InspectError> {
916        RangeArea::new(
917            area.sheet.clone(),
918            area.start_row,
919            area.start_column,
920            area.end_row,
921            area.end_column,
922        )
923        .map_err(|error| InspectError::InvalidAddress {
924            message: error.to_string(),
925        })?;
926        let Some(sheet_id) = self.graph.sheet_id(&area.sheet) else {
927            return Err(InspectError::SheetNotFound {
928                sheet: area.sheet.clone(),
929            });
930        };
931        Ok((
932            sheet_id,
933            RangeArea {
934                sheet: self.graph.sheet_name(sheet_id).to_string(),
935                start_row: area.start_row,
936                start_column: area.start_column,
937                end_row: area.end_row,
938                end_column: area.end_column,
939            },
940        ))
941    }
942
943    fn address_for_key(&self, key: CellKey) -> CellAddress {
944        CellAddress {
945            sheet: self.graph.sheet_name(key.sheet_id).to_string(),
946            row: key.row0 + 1,
947            column: key.col0 + 1,
948        }
949    }
950
951    fn key_for_vertex(&self, vertex: VertexId) -> Option<CellKey> {
952        if !matches!(
953            self.graph.get_vertex_kind(vertex),
954            VertexKind::FormulaScalar | VertexKind::FormulaArray
955        ) {
956            return None;
957        }
958        let cell = self.graph.get_cell_ref(vertex)?;
959        Some(CellKey {
960            sheet_id: cell.sheet_id,
961            row0: cell.coord.row(),
962            col0: cell.coord.col(),
963        })
964    }
965
966    fn resolve_semantic_area(&self, area: &RangeArea) -> Option<RangeAddress> {
967        let extent = resolve_used_extent(
968            OpenRangeBounds {
969                start_row: area.start_row,
970                start_column: area.start_column,
971                end_row: area.end_row,
972                end_column: area.end_column,
973            },
974            ExtentPolicy::Semantic,
975            |first, last| self.semantic_used_rows_for_columns(&area.sheet, first, last),
976            |first, last| self.semantic_used_cols_for_rows(&area.sheet, first, last),
977        )?;
978        Some(Self::range_from_extent(&area.sheet, extent))
979    }
980
981    fn range_from_extent(sheet: &str, extent: ResolvedExtent) -> RangeAddress {
982        RangeAddress {
983            sheet: sheet.to_string(),
984            start_row: extent.start_row,
985            start_col: extent.start_column,
986            end_row: extent.end_row,
987            end_col: extent.end_column,
988        }
989    }
990
991    fn snapshot_for_key(
992        &self,
993        key: CellKey,
994        include_value: bool,
995    ) -> Result<CellSnapshot, InspectError> {
996        let source = self.inspect_source();
997        let formula = source.formula_at(key)?;
998        let address = self.address_for_key(key);
999        let cached_value = self.read_cell_value(&address.sheet, address.row, address.column);
1000        let (canonical_formula, volatile, staleness) = match formula {
1001            Some(view) => {
1002                // `read_cell_value` maps LiteralValue::Empty to `None`, which
1003                // would classify an evaluated Empty result as NeverEvaluated.
1004                // No current builtin caches a true Empty (blank-derived formula
1005                // results are normalized), so that state is currently unreachable.
1006                let staleness = if cached_value.is_none() {
1007                    Staleness::NeverEvaluated
1008                } else if view.dirty {
1009                    Staleness::Dirty
1010                } else {
1011                    Staleness::Current
1012                };
1013                (
1014                    Some(formualizer_parse::pretty::canonical_formula(&view.ast)),
1015                    view.volatile,
1016                    staleness,
1017                )
1018            }
1019            None => (None, false, Staleness::Current),
1020        };
1021        let spill = source.spill_role(key).map(|role| match role {
1022            InternalSpillRole::Anchor { extent } => SpillRole::Anchor { extent },
1023            InternalSpillRole::Member { anchor } => SpillRole::Member {
1024                anchor: self.address_for_key(anchor),
1025            },
1026        });
1027        Ok(CellSnapshot {
1028            address,
1029            formula: canonical_formula,
1030            value: include_value.then_some(cached_value).flatten(),
1031            value_included: include_value,
1032            staleness,
1033            volatile,
1034            spill,
1035        })
1036    }
1037
1038    /// Whether a prepared formula calls SUBTOTAL or AGGREGATE. Reads the
1039    /// arena's precomputed subtree bits, including grouped family templates;
1040    /// does not reconstruct, render or evaluate the formula. Coordinates are
1041    /// one-based; unknown cells and unprepared formulas return false.
1042    #[doc(hidden)]
1043    pub fn formula_calls_visibility_reducer(&self, sheet: &str, row: u32, col: u32) -> bool {
1044        let Some(sheet_id) = self.graph.sheet_id(sheet) else {
1045            return false;
1046        };
1047        let (Some(row), Some(col)) = (row.checked_sub(1), col.checked_sub(1)) else {
1048            return false;
1049        };
1050        let cell = CellRef::new(sheet_id, Coord::new(row, col, true, true));
1051        self.graph
1052            .get_vertex_for_cell(&cell)
1053            .and_then(|vertex| self.graph.formula_view(vertex))
1054            .is_some_and(|view| {
1055                self.graph
1056                    .data_store()
1057                    .ast_calls_visibility_reducer(view.template)
1058            })
1059    }
1060
1061    /// Read result presence, currentness and committed spill ownership without
1062    /// reconstructing or pretty-printing a formula AST. Does not evaluate,
1063    /// prepare dependency state, or materialize compressed formula members.
1064    pub fn inspect_cell_result(&self, cell: &CellAddress) -> Result<CellResultState, InspectError> {
1065        let (key, address) = self.canonical_cell(cell)?;
1066        let staged = self
1067            .get_staged_formula_text(&address.sheet, address.row, address.column)
1068            .is_some();
1069        let source = self.inspect_source();
1070        let vertex = self.graph.get_vertex_for_cell(&source.cell_ref(key));
1071        let has_formula = staged || vertex.is_some_and(|v| self.graph.has_formula(v));
1072        let value = self.read_cell_value(&address.sheet, address.row, address.column);
1073        let staleness = if !has_formula {
1074            Staleness::Current
1075        } else if value.is_none() {
1076            Staleness::NeverEvaluated
1077        } else if staged || vertex.is_some_and(|v| self.graph.is_dirty(v)) {
1078            Staleness::Dirty
1079        } else {
1080            Staleness::Current
1081        };
1082        let spill = source.spill_role(key).map(|role| match role {
1083            InternalSpillRole::Anchor { extent } => SpillRole::Anchor { extent },
1084            InternalSpillRole::Member { anchor } => SpillRole::Member {
1085                anchor: self.address_for_key(anchor),
1086            },
1087        });
1088        Ok(CellResultState {
1089            address,
1090            has_formula,
1091            value,
1092            staleness,
1093            spill,
1094        })
1095    }
1096
1097    /// Inspect one cell without evaluating or preparing workbook state.
1098    pub fn inspect_cell(
1099        &self,
1100        cell: &CellAddress,
1101        options: &SnapshotOptions,
1102    ) -> Result<CellSnapshotReport, InspectError> {
1103        let (key, _) = self.canonical_cell(cell)?;
1104        Ok(CellSnapshotReport {
1105            stamp: self.inspect_stamp(),
1106            cell: self.snapshot_for_key(key, options.include_values)?,
1107        })
1108    }
1109
1110    fn resolve_name(&self, key: CellKey, name: &str) -> NameResolution {
1111        let Some(named) = self.graph.resolve_name_entry(name, key.sheet_id) else {
1112            return NameResolution::Unresolved;
1113        };
1114        match &named.definition {
1115            NamedDefinition::Cell(cell) => NameResolution::Cell(CellAddress {
1116                sheet: self.graph.sheet_name(cell.sheet_id).to_string(),
1117                row: cell.coord.row() + 1,
1118                column: cell.coord.col() + 1,
1119            }),
1120            NamedDefinition::Range(range) => {
1121                let resolved = RangeAddress {
1122                    sheet: self.graph.sheet_name(range.start.sheet_id).to_string(),
1123                    start_row: range.start.coord.row() + 1,
1124                    start_col: range.start.coord.col() + 1,
1125                    end_row: range.end.coord.row() + 1,
1126                    end_col: range.end.coord.col() + 1,
1127                };
1128                NameResolution::Range {
1129                    declared: RangeArea::from_finite(&resolved),
1130                    resolved: Some(resolved),
1131                }
1132            }
1133            NamedDefinition::Literal(value) => NameResolution::Literal(value.clone()),
1134            NamedDefinition::Formula { ast, .. } => NameResolution::Formula {
1135                formula: formualizer_parse::pretty::canonical_formula(ast),
1136                value: self.graph.get_value(named.vertex),
1137            },
1138        }
1139    }
1140
1141    fn resolve_table_area(
1142        &self,
1143        key: CellKey,
1144        table_ref: &TableReference,
1145    ) -> Option<(String, String, RangeAddress)> {
1146        let metadata = self.table_metadata(&table_ref.name)?;
1147        let canonical_name = metadata.name.clone();
1148        let specifier = table_ref
1149            .specifier
1150            .as_ref()
1151            .map(ToString::to_string)
1152            .unwrap_or_default();
1153        let mut start_row = metadata.start_row;
1154        let mut end_row = metadata.end_row;
1155        let mut start_col = metadata.start_col;
1156        let mut end_col = metadata.end_col;
1157        let data_start = start_row + u32::from(metadata.header_row);
1158        let data_end = end_row.saturating_sub(u32::from(metadata.totals_row));
1159
1160        fn col_index(headers: &[String], name: &str) -> Option<u32> {
1161            headers
1162                .iter()
1163                .position(|header| header.eq_ignore_ascii_case(name))
1164                .and_then(|index| u32::try_from(index).ok())
1165        }
1166
1167        match table_ref.specifier.as_ref()? {
1168            TableSpecifier::All | TableSpecifier::SpecialItem(SpecialItem::All) => {}
1169            TableSpecifier::Data | TableSpecifier::SpecialItem(SpecialItem::Data) => {
1170                start_row = data_start;
1171                end_row = data_end;
1172            }
1173            TableSpecifier::Headers | TableSpecifier::SpecialItem(SpecialItem::Headers) => {
1174                if !metadata.header_row {
1175                    return None;
1176                }
1177                end_row = start_row;
1178            }
1179            TableSpecifier::Totals | TableSpecifier::SpecialItem(SpecialItem::Totals) => {
1180                if !metadata.totals_row {
1181                    return None;
1182                }
1183                start_row = end_row;
1184            }
1185            TableSpecifier::Column(name) => {
1186                let index = col_index(&metadata.headers, name)?;
1187                start_col += index;
1188                end_col = start_col;
1189                start_row = data_start;
1190                end_row = data_end;
1191            }
1192            TableSpecifier::ColumnRange(first, last) => {
1193                let mut first = col_index(&metadata.headers, first)?;
1194                let mut last = col_index(&metadata.headers, last)?;
1195                if first > last {
1196                    std::mem::swap(&mut first, &mut last);
1197                }
1198                start_col += first;
1199                end_col = metadata.start_col + last;
1200                start_row = data_start;
1201                end_row = data_end;
1202            }
1203            TableSpecifier::SpecialItem(SpecialItem::ThisRow)
1204            | TableSpecifier::Row(formualizer_parse::parser::TableRowSpecifier::Current) => {
1205                let row = key.row0 + 1;
1206                if row < data_start || row > data_end {
1207                    return None;
1208                }
1209                start_row = row;
1210                end_row = row;
1211            }
1212            TableSpecifier::Row(_) => return None,
1213            TableSpecifier::Combination(parts) => {
1214                let mut this_row = false;
1215                let mut selected_column: Option<(u32, u32)> = None;
1216                for part in parts {
1217                    match part.as_ref() {
1218                        TableSpecifier::SpecialItem(SpecialItem::ThisRow) => this_row = true,
1219                        TableSpecifier::Column(name) => {
1220                            let column = col_index(&metadata.headers, name)?;
1221                            selected_column = Some((column, column));
1222                        }
1223                        TableSpecifier::ColumnRange(first, last) => {
1224                            let mut first = col_index(&metadata.headers, first)?;
1225                            let mut last = col_index(&metadata.headers, last)?;
1226                            if first > last {
1227                                std::mem::swap(&mut first, &mut last);
1228                            }
1229                            selected_column = Some((first, last));
1230                        }
1231                        TableSpecifier::Data | TableSpecifier::SpecialItem(SpecialItem::Data) => {
1232                            start_row = data_start;
1233                            end_row = data_end;
1234                        }
1235                        TableSpecifier::Headers
1236                        | TableSpecifier::SpecialItem(SpecialItem::Headers) => {
1237                            if !metadata.header_row {
1238                                return None;
1239                            }
1240                            end_row = start_row;
1241                        }
1242                        TableSpecifier::Totals
1243                        | TableSpecifier::SpecialItem(SpecialItem::Totals) => {
1244                            if !metadata.totals_row {
1245                                return None;
1246                            }
1247                            start_row = end_row;
1248                        }
1249                        TableSpecifier::All | TableSpecifier::SpecialItem(SpecialItem::All) => {}
1250                        TableSpecifier::Row(_) | TableSpecifier::Combination(_) => return None,
1251                    }
1252                }
1253                if this_row {
1254                    let row = key.row0 + 1;
1255                    if row < data_start || row > data_end {
1256                        return None;
1257                    }
1258                    start_row = row;
1259                    end_row = row;
1260                }
1261                if let Some((first, last)) = selected_column {
1262                    start_col = metadata.start_col + first;
1263                    end_col = metadata.start_col + last;
1264                    if !this_row {
1265                        start_row = data_start;
1266                        end_row = data_end;
1267                    }
1268                }
1269            }
1270        }
1271        if start_row > end_row || start_col > end_col {
1272            return None;
1273        }
1274        Some((
1275            canonical_name,
1276            specifier,
1277            RangeAddress {
1278                sheet: metadata.sheet,
1279                start_row,
1280                start_col,
1281                end_row,
1282                end_col,
1283            },
1284        ))
1285    }
1286
1287    fn own_reference(
1288        &self,
1289        key: CellKey,
1290        reference: refs::SemanticReference<'_>,
1291    ) -> SemanticReference {
1292        match reference {
1293            refs::SemanticReference::Cell(cell) => {
1294                let sheet_id = cell
1295                    .sheet
1296                    .name()
1297                    .and_then(|name| self.graph.sheet_id(name))
1298                    .unwrap_or(key.sheet_id);
1299                SemanticReference::Cell(CellAddress {
1300                    sheet: self.graph.sheet_name(sheet_id).to_string(),
1301                    row: cell.row,
1302                    column: cell.col,
1303                })
1304            }
1305            refs::SemanticReference::FiniteRange(range)
1306            | refs::SemanticReference::OpenRange(range) => {
1307                let sheet_id = range
1308                    .sheet
1309                    .name()
1310                    .and_then(|name| self.graph.sheet_id(name))
1311                    .unwrap_or(key.sheet_id);
1312                let declared = RangeArea {
1313                    sheet: self.graph.sheet_name(sheet_id).to_string(),
1314                    start_row: range.start_row,
1315                    start_column: range.start_col,
1316                    end_row: range.end_row,
1317                    end_column: range.end_col,
1318                };
1319                let resolved = self.resolve_semantic_area(&declared);
1320                let cell_count = resolved.as_ref().map_or(0, |range| {
1321                    u64::from(range.width()) * u64::from(range.height())
1322                });
1323                SemanticReference::Range {
1324                    declared,
1325                    resolved,
1326                    cell_count,
1327                }
1328            }
1329            refs::SemanticReference::Name(name) => SemanticReference::Name {
1330                name: name.to_string(),
1331                resolution: self.resolve_name(key, name),
1332            },
1333            refs::SemanticReference::Table(table) => {
1334                if let Some((name, specifier, resolved)) = self.resolve_table_area(key, table) {
1335                    SemanticReference::Table {
1336                        name,
1337                        specifier,
1338                        resolved,
1339                    }
1340                } else {
1341                    SemanticReference::Unsupported {
1342                        text: ReferenceType::Table(table.clone()).to_string(),
1343                        reason: "structured reference could not be resolved at this placement"
1344                            .to_string(),
1345                    }
1346                }
1347            }
1348            refs::SemanticReference::ExternalSource(external) => SemanticReference::External {
1349                raw: external.raw.clone(),
1350            },
1351            refs::SemanticReference::ThreeDimensional(reference) => {
1352                SemanticReference::Unsupported {
1353                    text: reference.to_string(),
1354                    reason: "3D references are not supported by phase-1 introspection".to_string(),
1355                }
1356            }
1357            refs::SemanticReference::Unsupported(reference) => SemanticReference::Unsupported {
1358                text: reference.to_string(),
1359                reason: "reference form is unsupported by introspection".to_string(),
1360            },
1361        }
1362    }
1363
1364    fn collect_precedents(
1365        &self,
1366        key: CellKey,
1367        max_links: u32,
1368        work: &mut WorkBudget,
1369    ) -> Result<(Vec<Precedent>, TruncationReport), InspectError> {
1370        struct Collector<'a, R> {
1371            engine: &'a Engine<R>,
1372            key: CellKey,
1373            max_links: usize,
1374            work: &'a mut WorkBudget,
1375            precedents: Vec<Precedent>,
1376            truncated: bool,
1377        }
1378        impl<R: EvaluationContext> ReferenceVisitor for Collector<'_, R> {
1379            fn visit(&mut self, reference: refs::SemanticReference<'_>) -> bool {
1380                if !self.work.charge() {
1381                    self.truncated = true;
1382                    return false;
1383                }
1384                let reference = self.engine.own_reference(self.key, reference);
1385                if self
1386                    .precedents
1387                    .iter()
1388                    .any(|existing| existing.reference == reference)
1389                {
1390                    return true;
1391                }
1392                if self.precedents.len() >= self.max_links {
1393                    self.truncated = true;
1394                    return false;
1395                }
1396                self.precedents.push(Precedent {
1397                    reference,
1398                    provenance: Provenance::Declared,
1399                });
1400                true
1401            }
1402        }
1403
1404        let source = self.inspect_source();
1405        let mut collector = Collector {
1406            engine: self,
1407            key,
1408            max_links: max_links as usize,
1409            work,
1410            precedents: Vec::new(),
1411            truncated: false,
1412        };
1413        source.visit_declared_references(key, &mut collector)?;
1414        let truncation = if collector.truncated {
1415            TruncationReport {
1416                incomplete: true,
1417                omitted: Some(OmittedCount::AtLeast(1)),
1418            }
1419        } else {
1420            TruncationReport::default()
1421        };
1422        Ok((collector.precedents, truncation))
1423    }
1424
1425    /// Return source-ordered, first-occurrence-deduplicated declared formula
1426    /// references for a cell.
1427    pub fn precedents(
1428        &self,
1429        cell: &CellAddress,
1430        options: &PrecedentOptions,
1431    ) -> Result<PrecedentReport, InspectError> {
1432        let (key, canonical) = self.canonical_cell(cell)?;
1433        let mut work = WorkBudget::new(options.max_work);
1434        let (precedents, truncation) =
1435            self.collect_precedents(key, options.max_links, &mut work)?;
1436        Ok(PrecedentReport {
1437            stamp: self.inspect_stamp(),
1438            cell: canonical,
1439            precedents,
1440            truncation,
1441        })
1442    }
1443
1444    fn dependency_state_available(&self) -> Result<(), InspectError> {
1445        if self.has_staged_formulas() {
1446            Err(InspectError::DependencyStateUnavailable {
1447                reason: InspectionUnavailableReason::DeferredDependencyGraph,
1448            })
1449        } else {
1450            Ok(())
1451        }
1452    }
1453
1454    fn spill_query_members(&self, key: CellKey) -> Vec<CellKey> {
1455        let source = self.inspect_source();
1456        let Some(InternalSpillRole::Anchor { .. }) = source.spill_role(key) else {
1457            return vec![key];
1458        };
1459        let cell_ref = CellRef::new(key.sheet_id, Coord::new(key.row0, key.col0, true, true));
1460        let Some(vertex) = self.graph.get_vertex_for_cell(&cell_ref) else {
1461            return vec![key];
1462        };
1463        self.graph
1464            .spill_cells_for_anchor(vertex)
1465            .unwrap_or(&[])
1466            .iter()
1467            .map(|member| CellKey {
1468                sheet_id: member.sheet_id,
1469                row0: member.coord.row(),
1470                col0: member.coord.col(),
1471            })
1472            .collect()
1473    }
1474
1475    fn collect_dependents(
1476        &self,
1477        key: CellKey,
1478        max_results: u32,
1479        work: &mut WorkBudget,
1480    ) -> Result<(Vec<Dependent>, TruncationReport), InspectError> {
1481        self.dependency_state_available()?;
1482        let source = self.inspect_source();
1483        let mut found: FxHashMap<CellAddress, Vec<CellAddress>> = FxHashMap::default();
1484        let mut incomplete = false;
1485        let max_results = max_results as usize;
1486
1487        let spill_anchor_query = matches!(
1488            source.spill_role(key),
1489            Some(InternalSpillRole::Anchor { .. })
1490        );
1491        let members = self.spill_query_members(key);
1492        for member in members {
1493            if !work.charge() {
1494                incomplete = true;
1495                break;
1496            }
1497            let via = self.address_for_key(member);
1498            let mut record = |dependent_key: CellKey| {
1499                let address = self.address_for_key(dependent_key);
1500                if let Some(via_members) = found.get_mut(&address) {
1501                    if spill_anchor_query && !via_members.contains(&via) {
1502                        via_members.push(via.clone());
1503                    }
1504                    return true;
1505                }
1506                found.insert(
1507                    address,
1508                    if spill_anchor_query {
1509                        vec![via.clone()]
1510                    } else {
1511                        Vec::new()
1512                    },
1513                );
1514                true
1515            };
1516
1517            let member_ref = CellRef::new(
1518                member.sheet_id,
1519                Coord::new(member.row0, member.col0, true, true),
1520            );
1521            // Under the authority: the direct readers through text-origin
1522            // edges (legacy's in-edges and covering range readers, which
1523            // exclude name- and table-mediated readers).
1524            {
1525                let _ = &member_ref;
1526                let complete = self
1527                    .graph
1528                    .authority_visit_text_dependents(
1529                        (member.sheet_id, member.row0, member.col0),
1530                        &mut work.remaining,
1531                        &mut |(sheet_id, row0, col0)| {
1532                            record(CellKey {
1533                                sheet_id,
1534                                row0,
1535                                col0,
1536                            })
1537                        },
1538                    )
1539                    .map_err(|_| InspectError::DependencyStateUnavailable {
1540                        reason: InspectionUnavailableReason::DependencyAuthorityUnavailable,
1541                    })?;
1542                if !complete {
1543                    incomplete = true;
1544                    break;
1545                }
1546            }
1547        }
1548
1549        let mut dependents: Vec<_> = found
1550            .into_iter()
1551            .map(|(cell, mut via)| {
1552                via.sort_by(address_cmp);
1553                via.dedup();
1554                Dependent { cell, via }
1555            })
1556            .collect();
1557        dependents.sort_by(|left, right| address_cmp(&left.cell, &right.cell));
1558        let known_omitted_dependent = dependents.len() > max_results;
1559        if known_omitted_dependent {
1560            dependents.truncate(max_results);
1561            incomplete = true;
1562        }
1563        Ok((
1564            dependents,
1565            if incomplete {
1566                TruncationReport {
1567                    incomplete: true,
1568                    omitted: known_omitted_dependent.then_some(OmittedCount::AtLeast(1)),
1569                }
1570            } else {
1571                TruncationReport::default()
1572            },
1573        ))
1574    }
1575
1576    /// Return direct and compressed-range readers of a cell. Discovery is
1577    /// bounded before candidate materialization.
1578    pub fn dependents(
1579        &self,
1580        cell: &CellAddress,
1581        options: &DependentsOptions,
1582    ) -> Result<DependentsReport, InspectError> {
1583        let (key, canonical) = self.canonical_cell(cell)?;
1584        let mut work = WorkBudget::new(options.max_work);
1585        let (dependents, truncation) =
1586            self.collect_dependents(key, options.max_results, &mut work)?;
1587        Ok(DependentsReport {
1588            stamp: self.inspect_stamp(),
1589            cell: canonical,
1590            dependents,
1591            truncation,
1592        })
1593    }
1594
1595    fn target_addresses(reference: &SemanticReference) -> Option<&RangeAddress> {
1596        match reference {
1597            SemanticReference::Range { resolved, .. } => resolved.as_ref(),
1598            SemanticReference::Name {
1599                resolution: NameResolution::Range { resolved, .. },
1600                ..
1601            } => resolved.as_ref(),
1602            SemanticReference::Table { resolved, .. } => Some(resolved),
1603            _ => None,
1604        }
1605    }
1606
1607    fn target_cell(reference: &SemanticReference) -> Option<&CellAddress> {
1608        match reference {
1609            SemanticReference::Cell(cell) => Some(cell),
1610            SemanticReference::Name {
1611                resolution: NameResolution::Cell(cell),
1612                ..
1613            } => Some(cell),
1614            _ => None,
1615        }
1616    }
1617
1618    /// Classify cycles from the completed materialized graph, independent of
1619    /// BFS discovery order. An edge `source -> target` is a cycle edge exactly
1620    /// when `target` can reach `source` through materialized links.
1621    fn classify_cycle_dispositions(nodes: &mut [TraceNode]) {
1622        let adjacency: Vec<Vec<usize>> = nodes
1623            .iter()
1624            .map(|node| {
1625                node.links
1626                    .iter()
1627                    .flat_map(|link| link.targets.iter())
1628                    .map(|target| target.node.0 as usize)
1629                    .collect()
1630            })
1631            .collect();
1632        let mut reachability = vec![vec![false; nodes.len()]; nodes.len()];
1633        for start in 0..nodes.len() {
1634            let mut stack = adjacency[start].clone();
1635            while let Some(next) = stack.pop() {
1636                if reachability[start][next] {
1637                    continue;
1638                }
1639                reachability[start][next] = true;
1640                stack.extend(adjacency[next].iter().copied());
1641            }
1642        }
1643
1644        for (source, node) in nodes.iter_mut().enumerate() {
1645            for target in node
1646                .links
1647                .iter_mut()
1648                .flat_map(|link| link.targets.iter_mut())
1649            {
1650                let target_index = target.node.0 as usize;
1651                if source == target_index || reachability[target_index][source] {
1652                    target.disposition = LinkDisposition::Cycle;
1653                } else if target.disposition == LinkDisposition::Cycle {
1654                    target.disposition = LinkDisposition::Convergent;
1655                }
1656            }
1657        }
1658    }
1659
1660    /// Build a bounded response-local BFS DAG.
1661    pub fn trace(
1662        &self,
1663        roots: &[CellAddress],
1664        options: &TraceOptions,
1665    ) -> Result<TraceGraph, InspectError> {
1666        if roots.is_empty() {
1667            return Err(InspectError::InvalidOptions {
1668                message: "trace requires at least one root".to_string(),
1669            });
1670        }
1671        let canonical_roots: Vec<_> = roots
1672            .iter()
1673            .map(|root| self.canonical_cell(root))
1674            .collect::<Result<_, _>>()?;
1675        let mut unique_roots = FxHashMap::default();
1676        for (key, canonical) in &canonical_roots {
1677            unique_roots.entry(canonical.clone()).or_insert(*key);
1678        }
1679        if unique_roots.len() > options.max_nodes as usize {
1680            return Err(InspectError::InvalidOptions {
1681                message: format!(
1682                    "max_nodes ({}) must hold all {} unique roots",
1683                    options.max_nodes,
1684                    unique_roots.len()
1685                ),
1686            });
1687        }
1688        if options.direction == TraceDirection::Dependents {
1689            self.dependency_state_available()?;
1690        }
1691
1692        let mut nodes = Vec::new();
1693        let mut node_by_address: FxHashMap<CellAddress, TraceNodeId> = FxHashMap::default();
1694        let mut root_ids = Vec::new();
1695        let mut queue = VecDeque::new();
1696        let mut parents: HashMap<TraceNodeId, Option<TraceNodeId>> = HashMap::new();
1697        let mut truncation = TruncationReport::default();
1698
1699        // Admit every unique request root before expansion so `roots[i]`
1700        // always corresponds to the caller's `roots[i]`.
1701        for (key, canonical) in canonical_roots {
1702            if let Some(&id) = node_by_address.get(&canonical) {
1703                root_ids.push(id);
1704                continue;
1705            }
1706            let id = TraceNodeId(nodes.len() as u32);
1707            nodes.push(TraceNode {
1708                id,
1709                cell: self.snapshot_for_key(key, options.include_values)?,
1710                links: Vec::new(),
1711            });
1712            node_by_address.insert(canonical, id);
1713            root_ids.push(id);
1714            queue.push_back((id, key, 0u32));
1715            parents.insert(id, None);
1716        }
1717
1718        let mut work = WorkBudget::new(options.max_work);
1719        let mut links_used = 0u32;
1720        let mut range_members_used = 0u32;
1721
1722        while let Some((source_id, source_key, depth)) = queue.pop_front() {
1723            let can_follow = depth < options.max_depth;
1724            let mut links = Vec::new();
1725            if options.direction == TraceDirection::Precedents {
1726                if let Some(InternalSpillRole::Member { anchor }) =
1727                    self.inspect_source().spill_role(source_key)
1728                {
1729                    if links_used < options.max_links {
1730                        links_used += 1;
1731                        let anchor_address = self.address_for_key(anchor);
1732                        let mut link = TraceLink {
1733                            reference: SemanticReference::Cell(anchor_address.clone()),
1734                            kind: TraceLinkKind::SpillAnchor,
1735                            targets: Vec::new(),
1736                            omitted: None,
1737                        };
1738                        self.attach_cell_target(
1739                            anchor_address,
1740                            source_id,
1741                            anchor,
1742                            can_follow,
1743                            depth,
1744                            options,
1745                            false,
1746                            &mut nodes,
1747                            &mut node_by_address,
1748                            &mut parents,
1749                            &mut queue,
1750                            &mut link,
1751                            &mut truncation,
1752                        )?;
1753                        links.push(link);
1754                    } else {
1755                        truncation.incomplete = true;
1756                        merge_omitted(&mut truncation.omitted, OmittedCount::AtLeast(1));
1757                    }
1758                } else {
1759                    let available_links = options.max_links.saturating_sub(links_used);
1760                    let (precedents, local_truncation) =
1761                        self.collect_precedents(source_key, available_links, &mut work)?;
1762                    if local_truncation.incomplete {
1763                        truncation.incomplete = true;
1764                        if let Some(omitted) = local_truncation.omitted {
1765                            merge_omitted(&mut truncation.omitted, omitted);
1766                        }
1767                    }
1768                    for precedent in precedents {
1769                        links_used += 1;
1770                        let mut link = TraceLink {
1771                            reference: precedent.reference,
1772                            kind: TraceLinkKind::Formula {
1773                                provenance: precedent.provenance,
1774                            },
1775                            targets: Vec::new(),
1776                            omitted: None,
1777                        };
1778                        if let Some(cell) = Self::target_cell(&link.reference).cloned() {
1779                            let (target_key, canonical) = self.canonical_cell(&cell)?;
1780                            self.attach_cell_target(
1781                                canonical,
1782                                source_id,
1783                                target_key,
1784                                can_follow,
1785                                depth,
1786                                options,
1787                                false,
1788                                &mut nodes,
1789                                &mut node_by_address,
1790                                &mut parents,
1791                                &mut queue,
1792                                &mut link,
1793                                &mut truncation,
1794                            )?;
1795                        } else if let Some(range) = Self::target_addresses(&link.reference).cloned()
1796                        {
1797                            self.attach_range_targets(
1798                                &range,
1799                                source_id,
1800                                can_follow,
1801                                depth,
1802                                options,
1803                                &mut range_members_used,
1804                                &mut nodes,
1805                                &mut node_by_address,
1806                                &mut parents,
1807                                &mut queue,
1808                                &mut link,
1809                                &mut truncation,
1810                            )?;
1811                        }
1812                        links.push(link);
1813                    }
1814                }
1815            } else {
1816                let available = options.max_links.saturating_sub(links_used);
1817                let (dependents, local_truncation) =
1818                    self.collect_dependents(source_key, available, &mut work)?;
1819                if local_truncation.incomplete {
1820                    truncation.incomplete = true;
1821                    if let Some(omitted) = local_truncation.omitted {
1822                        merge_omitted(&mut truncation.omitted, omitted);
1823                    }
1824                }
1825                for dependent in dependents {
1826                    links_used += 1;
1827                    let spill_reader = dependent
1828                        .via
1829                        .iter()
1830                        .any(|via| via != &self.address_for_key(source_key));
1831                    let (target_key, canonical) = self.canonical_cell(&dependent.cell)?;
1832                    let mut link = TraceLink {
1833                        reference: SemanticReference::Cell(canonical.clone()),
1834                        kind: if spill_reader {
1835                            TraceLinkKind::SpillReader
1836                        } else {
1837                            TraceLinkKind::Formula {
1838                                provenance: Provenance::Declared,
1839                            }
1840                        },
1841                        targets: Vec::new(),
1842                        omitted: None,
1843                    };
1844                    self.attach_cell_target(
1845                        canonical,
1846                        source_id,
1847                        target_key,
1848                        can_follow,
1849                        depth,
1850                        options,
1851                        false,
1852                        &mut nodes,
1853                        &mut node_by_address,
1854                        &mut parents,
1855                        &mut queue,
1856                        &mut link,
1857                        &mut truncation,
1858                    )?;
1859                    links.push(link);
1860                }
1861            }
1862            nodes[source_id.0 as usize].links = links;
1863        }
1864
1865        Self::classify_cycle_dispositions(&mut nodes);
1866
1867        Ok(TraceGraph {
1868            stamp: self.inspect_stamp(),
1869            direction: options.direction,
1870            roots: root_ids,
1871            nodes,
1872            truncation,
1873        })
1874    }
1875
1876    #[allow(clippy::too_many_arguments)]
1877    fn attach_cell_target(
1878        &self,
1879        address: CellAddress,
1880        source_id: TraceNodeId,
1881        key: CellKey,
1882        can_follow: bool,
1883        depth: u32,
1884        options: &TraceOptions,
1885        defer_missing_omission: bool,
1886        nodes: &mut Vec<TraceNode>,
1887        node_by_address: &mut FxHashMap<CellAddress, TraceNodeId>,
1888        parents: &mut HashMap<TraceNodeId, Option<TraceNodeId>>,
1889        queue: &mut VecDeque<(TraceNodeId, CellKey, u32)>,
1890        link: &mut TraceLink,
1891        truncation: &mut TruncationReport,
1892    ) -> Result<(), InspectError> {
1893        if let Some(&target_id) = node_by_address.get(&address) {
1894            link.targets.push(TraceLinkTarget {
1895                node: target_id,
1896                // The completed-graph reachability post-pass resolves Cycle
1897                // versus Convergent without depending on the BFS parent tree.
1898                disposition: LinkDisposition::Convergent,
1899            });
1900            return Ok(());
1901        }
1902        if nodes.len() >= options.max_nodes as usize {
1903            link.omitted = Some(OmittedCount::AtLeast(1));
1904            truncation.incomplete = true;
1905            if !defer_missing_omission {
1906                merge_omitted(&mut truncation.omitted, OmittedCount::AtLeast(1));
1907            }
1908            return Ok(());
1909        }
1910        let target_id = TraceNodeId(nodes.len() as u32);
1911        nodes.push(TraceNode {
1912            id: target_id,
1913            cell: self.snapshot_for_key(key, options.include_values)?,
1914            links: Vec::new(),
1915        });
1916        node_by_address.insert(address, target_id);
1917        parents.insert(target_id, Some(source_id));
1918        link.targets.push(TraceLinkTarget {
1919            node: target_id,
1920            disposition: if can_follow {
1921                LinkDisposition::Expanded
1922            } else {
1923                LinkDisposition::Elided
1924            },
1925        });
1926        if can_follow {
1927            queue.push_back((target_id, key, depth + 1));
1928        } else {
1929            // The target is represented, but its unvisited outgoing links are
1930            // unknown without doing the depth-elided work.
1931            truncation.incomplete = true;
1932        }
1933        Ok(())
1934    }
1935
1936    #[allow(clippy::too_many_arguments)]
1937    fn attach_range_targets(
1938        &self,
1939        range: &RangeAddress,
1940        source_id: TraceNodeId,
1941        can_follow: bool,
1942        depth: u32,
1943        options: &TraceOptions,
1944        range_members_used: &mut u32,
1945        nodes: &mut Vec<TraceNode>,
1946        node_by_address: &mut FxHashMap<CellAddress, TraceNodeId>,
1947        parents: &mut HashMap<TraceNodeId, Option<TraceNodeId>>,
1948        queue: &mut VecDeque<(TraceNodeId, CellKey, u32)>,
1949        link: &mut TraceLink,
1950        truncation: &mut TruncationReport,
1951    ) -> Result<(), InspectError> {
1952        let total = u64::from(range.width()) * u64::from(range.height());
1953        let mut attached = 0u64;
1954        let mut compressed_ancestors = Vec::new();
1955        let mut cursor = Some(source_id);
1956        while let Some(ancestor) = cursor {
1957            let address = &nodes[ancestor.0 as usize].cell.address;
1958            if range.sheet == address.sheet
1959                && range.start_row <= address.row
1960                && address.row <= range.end_row
1961                && range.start_col <= address.column
1962                && address.column <= range.end_col
1963            {
1964                compressed_ancestors.push((ancestor, address.row, address.column));
1965            }
1966            cursor = parents.get(&ancestor).copied().flatten();
1967        }
1968
1969        // Preserve compressed cycle semantics even if the global member budget
1970        // is exhausted or an ancestor occurs late in a large row-major range.
1971        for (ancestor, _, _) in &compressed_ancestors {
1972            link.targets.push(TraceLinkTarget {
1973                node: *ancestor,
1974                // The completed-graph reachability post-pass resolves this
1975                // provisional revisit disposition.
1976                disposition: LinkDisposition::Convergent,
1977            });
1978            attached += 1;
1979        }
1980
1981        'rows: for row in range.start_row..=range.end_row {
1982            for column in range.start_col..=range.end_col {
1983                if compressed_ancestors
1984                    .iter()
1985                    .any(|(_, ancestor_row, ancestor_column)| {
1986                        row == *ancestor_row && column == *ancestor_column
1987                    })
1988                {
1989                    continue;
1990                }
1991                if *range_members_used >= options.range_member_budget {
1992                    break 'rows;
1993                }
1994                *range_members_used += 1;
1995                let address = CellAddress {
1996                    sheet: range.sheet.clone(),
1997                    row,
1998                    column,
1999                };
2000                let (key, canonical) = self.canonical_cell(&address)?;
2001                let before = link.targets.len();
2002                self.attach_cell_target(
2003                    canonical,
2004                    source_id,
2005                    key,
2006                    can_follow,
2007                    depth,
2008                    options,
2009                    true,
2010                    nodes,
2011                    node_by_address,
2012                    parents,
2013                    queue,
2014                    link,
2015                    truncation,
2016                )?;
2017                if link.targets.len() > before {
2018                    attached += 1;
2019                } else if link.omitted.is_some() {
2020                    break 'rows;
2021                }
2022            }
2023        }
2024        if attached < total {
2025            let omitted = OmittedCount::Exact(total - attached);
2026            link.omitted = Some(omitted);
2027            truncation.incomplete = true;
2028            merge_omitted(&mut truncation.omitted, omitted);
2029        }
2030        Ok(())
2031    }
2032
2033    /// Return one row-major owned page over the semantic finite extent.
2034    pub fn range_page(
2035        &self,
2036        area: &RangeArea,
2037        options: &RangePageOptions,
2038    ) -> Result<RangePage, InspectError> {
2039        if options.limit == 0 {
2040            return Err(InspectError::InvalidOptions {
2041                message: "range page limit must be at least one".to_string(),
2042            });
2043        }
2044        let stamp = self.inspect_stamp();
2045        if let Some(expected) = options.expected_stamp
2046            && expected != stamp
2047        {
2048            return Err(InspectError::RevisionMismatch {
2049                expected,
2050                actual: stamp,
2051            });
2052        }
2053        let (_, declared) = self.canonical_area(area)?;
2054        let resolved = self.resolve_semantic_area(&declared);
2055        let total = resolved.as_ref().map_or(0, |range| {
2056            u64::from(range.width()) * u64::from(range.height())
2057        });
2058        let start = options.offset.min(total);
2059        let end = start.saturating_add(u64::from(options.limit)).min(total);
2060        let mut items = Vec::new();
2061        items
2062            .try_reserve((end - start) as usize)
2063            .map_err(|_| InspectError::ResourceExhausted {
2064                resource: "range page items",
2065            })?;
2066        if let Some(range) = &resolved {
2067            let width = u64::from(range.width());
2068            for offset in start..end {
2069                let row = range.start_row + u32::try_from(offset / width).unwrap_or(u32::MAX);
2070                let column = range.start_col + u32::try_from(offset % width).unwrap_or(u32::MAX);
2071                let (key, _) = self.canonical_cell(&CellAddress {
2072                    sheet: range.sheet.clone(),
2073                    row,
2074                    column,
2075                })?;
2076                items.push(self.snapshot_for_key(key, options.include_values)?);
2077            }
2078        }
2079        Ok(RangePage {
2080            stamp,
2081            declared,
2082            resolved,
2083            total,
2084            offset: options.offset,
2085            items,
2086            next_offset: (end < total).then_some(end),
2087        })
2088    }
2089}