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        CellAddress::new(address.sheet.clone(), address.row, address.column).map_err(|error| {
889            InspectError::InvalidAddress {
890                message: error.to_string(),
891            }
892        })?;
893        let Some(sheet_id) = self.graph.sheet_id(&address.sheet) else {
894            return Err(InspectError::SheetNotFound {
895                sheet: address.sheet.clone(),
896            });
897        };
898        let canonical = CellAddress {
899            sheet: self.graph.sheet_name(sheet_id).to_string(),
900            row: address.row,
901            column: address.column,
902        };
903        Ok((
904            CellKey {
905                sheet_id,
906                row0: address.row - 1,
907                col0: address.column - 1,
908            },
909            canonical,
910        ))
911    }
912
913    fn canonical_area(&self, area: &RangeArea) -> Result<(SheetId, RangeArea), InspectError> {
914        RangeArea::new(
915            area.sheet.clone(),
916            area.start_row,
917            area.start_column,
918            area.end_row,
919            area.end_column,
920        )
921        .map_err(|error| InspectError::InvalidAddress {
922            message: error.to_string(),
923        })?;
924        let Some(sheet_id) = self.graph.sheet_id(&area.sheet) else {
925            return Err(InspectError::SheetNotFound {
926                sheet: area.sheet.clone(),
927            });
928        };
929        Ok((
930            sheet_id,
931            RangeArea {
932                sheet: self.graph.sheet_name(sheet_id).to_string(),
933                start_row: area.start_row,
934                start_column: area.start_column,
935                end_row: area.end_row,
936                end_column: area.end_column,
937            },
938        ))
939    }
940
941    fn address_for_key(&self, key: CellKey) -> CellAddress {
942        CellAddress {
943            sheet: self.graph.sheet_name(key.sheet_id).to_string(),
944            row: key.row0 + 1,
945            column: key.col0 + 1,
946        }
947    }
948
949    fn key_for_vertex(&self, vertex: VertexId) -> Option<CellKey> {
950        if !matches!(
951            self.graph.get_vertex_kind(vertex),
952            VertexKind::FormulaScalar | VertexKind::FormulaArray
953        ) {
954            return None;
955        }
956        let cell = self.graph.get_cell_ref(vertex)?;
957        Some(CellKey {
958            sheet_id: cell.sheet_id,
959            row0: cell.coord.row(),
960            col0: cell.coord.col(),
961        })
962    }
963
964    fn resolve_semantic_area(&self, area: &RangeArea) -> Option<RangeAddress> {
965        let extent = resolve_used_extent(
966            OpenRangeBounds {
967                start_row: area.start_row,
968                start_column: area.start_column,
969                end_row: area.end_row,
970                end_column: area.end_column,
971            },
972            ExtentPolicy::Semantic,
973            |first, last| self.semantic_used_rows_for_columns(&area.sheet, first, last),
974            |first, last| self.semantic_used_cols_for_rows(&area.sheet, first, last),
975        )?;
976        Some(Self::range_from_extent(&area.sheet, extent))
977    }
978
979    fn range_from_extent(sheet: &str, extent: ResolvedExtent) -> RangeAddress {
980        RangeAddress {
981            sheet: sheet.to_string(),
982            start_row: extent.start_row,
983            start_col: extent.start_column,
984            end_row: extent.end_row,
985            end_col: extent.end_column,
986        }
987    }
988
989    fn snapshot_for_key(
990        &self,
991        key: CellKey,
992        include_value: bool,
993    ) -> Result<CellSnapshot, InspectError> {
994        let source = self.inspect_source();
995        let formula = source.formula_at(key)?;
996        let address = self.address_for_key(key);
997        let cached_value = self.read_cell_value(&address.sheet, address.row, address.column);
998        let (canonical_formula, volatile, staleness) = match formula {
999            Some(view) => {
1000                // `read_cell_value` maps LiteralValue::Empty to `None`, which
1001                // would classify an evaluated Empty result as NeverEvaluated.
1002                // No current builtin caches a true Empty (blank-derived formula
1003                // results are normalized), so that state is currently unreachable.
1004                let staleness = if cached_value.is_none() {
1005                    Staleness::NeverEvaluated
1006                } else if view.dirty {
1007                    Staleness::Dirty
1008                } else {
1009                    Staleness::Current
1010                };
1011                (
1012                    Some(formualizer_parse::pretty::canonical_formula(&view.ast)),
1013                    view.volatile,
1014                    staleness,
1015                )
1016            }
1017            None => (None, false, Staleness::Current),
1018        };
1019        let spill = source.spill_role(key).map(|role| match role {
1020            InternalSpillRole::Anchor { extent } => SpillRole::Anchor { extent },
1021            InternalSpillRole::Member { anchor } => SpillRole::Member {
1022                anchor: self.address_for_key(anchor),
1023            },
1024        });
1025        Ok(CellSnapshot {
1026            address,
1027            formula: canonical_formula,
1028            value: include_value.then_some(cached_value).flatten(),
1029            value_included: include_value,
1030            staleness,
1031            volatile,
1032            spill,
1033        })
1034    }
1035
1036    /// Read result presence, currentness and committed spill ownership without
1037    /// reconstructing or pretty-printing a formula AST. Does not evaluate,
1038    /// prepare dependency state, or materialize compressed formula members.
1039    pub fn inspect_cell_result(&self, cell: &CellAddress) -> Result<CellResultState, InspectError> {
1040        let (key, address) = self.canonical_cell(cell)?;
1041        let staged = self
1042            .get_staged_formula_text(&address.sheet, address.row, address.column)
1043            .is_some();
1044        let source = self.inspect_source();
1045        let vertex = self.graph.get_vertex_for_cell(&source.cell_ref(key));
1046        let has_formula = staged || vertex.is_some_and(|v| self.graph.has_formula(v));
1047        let value = self.read_cell_value(&address.sheet, address.row, address.column);
1048        let staleness = if !has_formula {
1049            Staleness::Current
1050        } else if value.is_none() {
1051            Staleness::NeverEvaluated
1052        } else if staged || vertex.is_some_and(|v| self.graph.is_dirty(v)) {
1053            Staleness::Dirty
1054        } else {
1055            Staleness::Current
1056        };
1057        let spill = source.spill_role(key).map(|role| match role {
1058            InternalSpillRole::Anchor { extent } => SpillRole::Anchor { extent },
1059            InternalSpillRole::Member { anchor } => SpillRole::Member {
1060                anchor: self.address_for_key(anchor),
1061            },
1062        });
1063        Ok(CellResultState {
1064            address,
1065            has_formula,
1066            value,
1067            staleness,
1068            spill,
1069        })
1070    }
1071
1072    /// Inspect one cell without evaluating or preparing workbook state.
1073    pub fn inspect_cell(
1074        &self,
1075        cell: &CellAddress,
1076        options: &SnapshotOptions,
1077    ) -> Result<CellSnapshotReport, InspectError> {
1078        let (key, _) = self.canonical_cell(cell)?;
1079        Ok(CellSnapshotReport {
1080            stamp: self.inspect_stamp(),
1081            cell: self.snapshot_for_key(key, options.include_values)?,
1082        })
1083    }
1084
1085    fn resolve_name(&self, key: CellKey, name: &str) -> NameResolution {
1086        let Some(named) = self.graph.resolve_name_entry(name, key.sheet_id) else {
1087            return NameResolution::Unresolved;
1088        };
1089        match &named.definition {
1090            NamedDefinition::Cell(cell) => NameResolution::Cell(CellAddress {
1091                sheet: self.graph.sheet_name(cell.sheet_id).to_string(),
1092                row: cell.coord.row() + 1,
1093                column: cell.coord.col() + 1,
1094            }),
1095            NamedDefinition::Range(range) => {
1096                let resolved = RangeAddress {
1097                    sheet: self.graph.sheet_name(range.start.sheet_id).to_string(),
1098                    start_row: range.start.coord.row() + 1,
1099                    start_col: range.start.coord.col() + 1,
1100                    end_row: range.end.coord.row() + 1,
1101                    end_col: range.end.coord.col() + 1,
1102                };
1103                NameResolution::Range {
1104                    declared: RangeArea::from_finite(&resolved),
1105                    resolved: Some(resolved),
1106                }
1107            }
1108            NamedDefinition::Literal(value) => NameResolution::Literal(value.clone()),
1109            NamedDefinition::Formula { ast, .. } => NameResolution::Formula {
1110                formula: formualizer_parse::pretty::canonical_formula(ast),
1111                value: self.graph.get_value(named.vertex),
1112            },
1113        }
1114    }
1115
1116    fn resolve_table_area(
1117        &self,
1118        key: CellKey,
1119        table_ref: &TableReference,
1120    ) -> Option<(String, String, RangeAddress)> {
1121        let metadata = self.table_metadata(&table_ref.name)?;
1122        let canonical_name = metadata.name.clone();
1123        let specifier = table_ref
1124            .specifier
1125            .as_ref()
1126            .map(ToString::to_string)
1127            .unwrap_or_default();
1128        let mut start_row = metadata.start_row;
1129        let mut end_row = metadata.end_row;
1130        let mut start_col = metadata.start_col;
1131        let mut end_col = metadata.end_col;
1132        let data_start = start_row + u32::from(metadata.header_row);
1133        let data_end = end_row.saturating_sub(u32::from(metadata.totals_row));
1134
1135        fn col_index(headers: &[String], name: &str) -> Option<u32> {
1136            headers
1137                .iter()
1138                .position(|header| header.eq_ignore_ascii_case(name))
1139                .and_then(|index| u32::try_from(index).ok())
1140        }
1141
1142        match table_ref.specifier.as_ref()? {
1143            TableSpecifier::All | TableSpecifier::SpecialItem(SpecialItem::All) => {}
1144            TableSpecifier::Data | TableSpecifier::SpecialItem(SpecialItem::Data) => {
1145                start_row = data_start;
1146                end_row = data_end;
1147            }
1148            TableSpecifier::Headers | TableSpecifier::SpecialItem(SpecialItem::Headers) => {
1149                if !metadata.header_row {
1150                    return None;
1151                }
1152                end_row = start_row;
1153            }
1154            TableSpecifier::Totals | TableSpecifier::SpecialItem(SpecialItem::Totals) => {
1155                if !metadata.totals_row {
1156                    return None;
1157                }
1158                start_row = end_row;
1159            }
1160            TableSpecifier::Column(name) => {
1161                let index = col_index(&metadata.headers, name)?;
1162                start_col += index;
1163                end_col = start_col;
1164                start_row = data_start;
1165                end_row = data_end;
1166            }
1167            TableSpecifier::ColumnRange(first, last) => {
1168                let mut first = col_index(&metadata.headers, first)?;
1169                let mut last = col_index(&metadata.headers, last)?;
1170                if first > last {
1171                    std::mem::swap(&mut first, &mut last);
1172                }
1173                start_col += first;
1174                end_col = metadata.start_col + last;
1175                start_row = data_start;
1176                end_row = data_end;
1177            }
1178            TableSpecifier::SpecialItem(SpecialItem::ThisRow)
1179            | TableSpecifier::Row(formualizer_parse::parser::TableRowSpecifier::Current) => {
1180                let row = key.row0 + 1;
1181                if row < data_start || row > data_end {
1182                    return None;
1183                }
1184                start_row = row;
1185                end_row = row;
1186            }
1187            TableSpecifier::Row(_) => return None,
1188            TableSpecifier::Combination(parts) => {
1189                let mut this_row = false;
1190                let mut selected_column: Option<(u32, u32)> = None;
1191                for part in parts {
1192                    match part.as_ref() {
1193                        TableSpecifier::SpecialItem(SpecialItem::ThisRow) => this_row = true,
1194                        TableSpecifier::Column(name) => {
1195                            let column = col_index(&metadata.headers, name)?;
1196                            selected_column = Some((column, column));
1197                        }
1198                        TableSpecifier::ColumnRange(first, last) => {
1199                            let mut first = col_index(&metadata.headers, first)?;
1200                            let mut last = col_index(&metadata.headers, last)?;
1201                            if first > last {
1202                                std::mem::swap(&mut first, &mut last);
1203                            }
1204                            selected_column = Some((first, last));
1205                        }
1206                        TableSpecifier::Data | TableSpecifier::SpecialItem(SpecialItem::Data) => {
1207                            start_row = data_start;
1208                            end_row = data_end;
1209                        }
1210                        TableSpecifier::Headers
1211                        | TableSpecifier::SpecialItem(SpecialItem::Headers) => {
1212                            if !metadata.header_row {
1213                                return None;
1214                            }
1215                            end_row = start_row;
1216                        }
1217                        TableSpecifier::Totals
1218                        | TableSpecifier::SpecialItem(SpecialItem::Totals) => {
1219                            if !metadata.totals_row {
1220                                return None;
1221                            }
1222                            start_row = end_row;
1223                        }
1224                        TableSpecifier::All | TableSpecifier::SpecialItem(SpecialItem::All) => {}
1225                        TableSpecifier::Row(_) | TableSpecifier::Combination(_) => return None,
1226                    }
1227                }
1228                if this_row {
1229                    let row = key.row0 + 1;
1230                    if row < data_start || row > data_end {
1231                        return None;
1232                    }
1233                    start_row = row;
1234                    end_row = row;
1235                }
1236                if let Some((first, last)) = selected_column {
1237                    start_col = metadata.start_col + first;
1238                    end_col = metadata.start_col + last;
1239                    if !this_row {
1240                        start_row = data_start;
1241                        end_row = data_end;
1242                    }
1243                }
1244            }
1245        }
1246        if start_row > end_row || start_col > end_col {
1247            return None;
1248        }
1249        Some((
1250            canonical_name,
1251            specifier,
1252            RangeAddress {
1253                sheet: metadata.sheet,
1254                start_row,
1255                start_col,
1256                end_row,
1257                end_col,
1258            },
1259        ))
1260    }
1261
1262    fn own_reference(
1263        &self,
1264        key: CellKey,
1265        reference: refs::SemanticReference<'_>,
1266    ) -> SemanticReference {
1267        match reference {
1268            refs::SemanticReference::Cell(cell) => {
1269                let sheet_id = cell
1270                    .sheet
1271                    .name()
1272                    .and_then(|name| self.graph.sheet_id(name))
1273                    .unwrap_or(key.sheet_id);
1274                SemanticReference::Cell(CellAddress {
1275                    sheet: self.graph.sheet_name(sheet_id).to_string(),
1276                    row: cell.row,
1277                    column: cell.col,
1278                })
1279            }
1280            refs::SemanticReference::FiniteRange(range)
1281            | refs::SemanticReference::OpenRange(range) => {
1282                let sheet_id = range
1283                    .sheet
1284                    .name()
1285                    .and_then(|name| self.graph.sheet_id(name))
1286                    .unwrap_or(key.sheet_id);
1287                let declared = RangeArea {
1288                    sheet: self.graph.sheet_name(sheet_id).to_string(),
1289                    start_row: range.start_row,
1290                    start_column: range.start_col,
1291                    end_row: range.end_row,
1292                    end_column: range.end_col,
1293                };
1294                let resolved = self.resolve_semantic_area(&declared);
1295                let cell_count = resolved.as_ref().map_or(0, |range| {
1296                    u64::from(range.width()) * u64::from(range.height())
1297                });
1298                SemanticReference::Range {
1299                    declared,
1300                    resolved,
1301                    cell_count,
1302                }
1303            }
1304            refs::SemanticReference::Name(name) => SemanticReference::Name {
1305                name: name.to_string(),
1306                resolution: self.resolve_name(key, name),
1307            },
1308            refs::SemanticReference::Table(table) => {
1309                if let Some((name, specifier, resolved)) = self.resolve_table_area(key, table) {
1310                    SemanticReference::Table {
1311                        name,
1312                        specifier,
1313                        resolved,
1314                    }
1315                } else {
1316                    SemanticReference::Unsupported {
1317                        text: ReferenceType::Table(table.clone()).to_string(),
1318                        reason: "structured reference could not be resolved at this placement"
1319                            .to_string(),
1320                    }
1321                }
1322            }
1323            refs::SemanticReference::ExternalSource(external) => SemanticReference::External {
1324                raw: external.raw.clone(),
1325            },
1326            refs::SemanticReference::ThreeDimensional(reference) => {
1327                SemanticReference::Unsupported {
1328                    text: reference.to_string(),
1329                    reason: "3D references are not supported by phase-1 introspection".to_string(),
1330                }
1331            }
1332            refs::SemanticReference::Unsupported(reference) => SemanticReference::Unsupported {
1333                text: reference.to_string(),
1334                reason: "reference form is unsupported by introspection".to_string(),
1335            },
1336        }
1337    }
1338
1339    fn collect_precedents(
1340        &self,
1341        key: CellKey,
1342        max_links: u32,
1343        work: &mut WorkBudget,
1344    ) -> Result<(Vec<Precedent>, TruncationReport), InspectError> {
1345        struct Collector<'a, R> {
1346            engine: &'a Engine<R>,
1347            key: CellKey,
1348            max_links: usize,
1349            work: &'a mut WorkBudget,
1350            precedents: Vec<Precedent>,
1351            truncated: bool,
1352        }
1353        impl<R: EvaluationContext> ReferenceVisitor for Collector<'_, R> {
1354            fn visit(&mut self, reference: refs::SemanticReference<'_>) -> bool {
1355                if !self.work.charge() {
1356                    self.truncated = true;
1357                    return false;
1358                }
1359                let reference = self.engine.own_reference(self.key, reference);
1360                if self
1361                    .precedents
1362                    .iter()
1363                    .any(|existing| existing.reference == reference)
1364                {
1365                    return true;
1366                }
1367                if self.precedents.len() >= self.max_links {
1368                    self.truncated = true;
1369                    return false;
1370                }
1371                self.precedents.push(Precedent {
1372                    reference,
1373                    provenance: Provenance::Declared,
1374                });
1375                true
1376            }
1377        }
1378
1379        let source = self.inspect_source();
1380        let mut collector = Collector {
1381            engine: self,
1382            key,
1383            max_links: max_links as usize,
1384            work,
1385            precedents: Vec::new(),
1386            truncated: false,
1387        };
1388        source.visit_declared_references(key, &mut collector)?;
1389        let truncation = if collector.truncated {
1390            TruncationReport {
1391                incomplete: true,
1392                omitted: Some(OmittedCount::AtLeast(1)),
1393            }
1394        } else {
1395            TruncationReport::default()
1396        };
1397        Ok((collector.precedents, truncation))
1398    }
1399
1400    /// Return source-ordered, first-occurrence-deduplicated declared formula
1401    /// references for a cell.
1402    pub fn precedents(
1403        &self,
1404        cell: &CellAddress,
1405        options: &PrecedentOptions,
1406    ) -> Result<PrecedentReport, InspectError> {
1407        let (key, canonical) = self.canonical_cell(cell)?;
1408        let mut work = WorkBudget::new(options.max_work);
1409        let (precedents, truncation) =
1410            self.collect_precedents(key, options.max_links, &mut work)?;
1411        Ok(PrecedentReport {
1412            stamp: self.inspect_stamp(),
1413            cell: canonical,
1414            precedents,
1415            truncation,
1416        })
1417    }
1418
1419    fn dependency_state_available(&self) -> Result<(), InspectError> {
1420        if self.has_staged_formulas() {
1421            Err(InspectError::DependencyStateUnavailable {
1422                reason: InspectionUnavailableReason::DeferredDependencyGraph,
1423            })
1424        } else {
1425            Ok(())
1426        }
1427    }
1428
1429    fn spill_query_members(&self, key: CellKey) -> Vec<CellKey> {
1430        let source = self.inspect_source();
1431        let Some(InternalSpillRole::Anchor { .. }) = source.spill_role(key) else {
1432            return vec![key];
1433        };
1434        let cell_ref = CellRef::new(key.sheet_id, Coord::new(key.row0, key.col0, true, true));
1435        let Some(vertex) = self.graph.get_vertex_for_cell(&cell_ref) else {
1436            return vec![key];
1437        };
1438        self.graph
1439            .spill_cells_for_anchor(vertex)
1440            .unwrap_or(&[])
1441            .iter()
1442            .map(|member| CellKey {
1443                sheet_id: member.sheet_id,
1444                row0: member.coord.row(),
1445                col0: member.coord.col(),
1446            })
1447            .collect()
1448    }
1449
1450    fn collect_dependents(
1451        &self,
1452        key: CellKey,
1453        max_results: u32,
1454        work: &mut WorkBudget,
1455    ) -> Result<(Vec<Dependent>, TruncationReport), InspectError> {
1456        self.dependency_state_available()?;
1457        let source = self.inspect_source();
1458        let mut found: FxHashMap<CellAddress, Vec<CellAddress>> = FxHashMap::default();
1459        let mut incomplete = false;
1460        let max_results = max_results as usize;
1461
1462        let spill_anchor_query = matches!(
1463            source.spill_role(key),
1464            Some(InternalSpillRole::Anchor { .. })
1465        );
1466        let members = self.spill_query_members(key);
1467        for member in members {
1468            if !work.charge() {
1469                incomplete = true;
1470                break;
1471            }
1472            let via = self.address_for_key(member);
1473            let mut record = |dependent_key: CellKey| {
1474                let address = self.address_for_key(dependent_key);
1475                if let Some(via_members) = found.get_mut(&address) {
1476                    if spill_anchor_query && !via_members.contains(&via) {
1477                        via_members.push(via.clone());
1478                    }
1479                    return true;
1480                }
1481                found.insert(
1482                    address,
1483                    if spill_anchor_query {
1484                        vec![via.clone()]
1485                    } else {
1486                        Vec::new()
1487                    },
1488                );
1489                true
1490            };
1491
1492            let member_ref = CellRef::new(
1493                member.sheet_id,
1494                Coord::new(member.row0, member.col0, true, true),
1495            );
1496            // Under the authority: the direct readers through text-origin
1497            // edges (legacy's in-edges and covering range readers, which
1498            // exclude name- and table-mediated readers).
1499            {
1500                let _ = &member_ref;
1501                let complete = self
1502                    .graph
1503                    .authority_visit_text_dependents(
1504                        (member.sheet_id, member.row0, member.col0),
1505                        &mut work.remaining,
1506                        &mut |(sheet_id, row0, col0)| {
1507                            record(CellKey {
1508                                sheet_id,
1509                                row0,
1510                                col0,
1511                            })
1512                        },
1513                    )
1514                    .map_err(|_| InspectError::DependencyStateUnavailable {
1515                        reason: InspectionUnavailableReason::DependencyAuthorityUnavailable,
1516                    })?;
1517                if !complete {
1518                    incomplete = true;
1519                    break;
1520                }
1521            }
1522        }
1523
1524        let mut dependents: Vec<_> = found
1525            .into_iter()
1526            .map(|(cell, mut via)| {
1527                via.sort_by(address_cmp);
1528                via.dedup();
1529                Dependent { cell, via }
1530            })
1531            .collect();
1532        dependents.sort_by(|left, right| address_cmp(&left.cell, &right.cell));
1533        let known_omitted_dependent = dependents.len() > max_results;
1534        if known_omitted_dependent {
1535            dependents.truncate(max_results);
1536            incomplete = true;
1537        }
1538        Ok((
1539            dependents,
1540            if incomplete {
1541                TruncationReport {
1542                    incomplete: true,
1543                    omitted: known_omitted_dependent.then_some(OmittedCount::AtLeast(1)),
1544                }
1545            } else {
1546                TruncationReport::default()
1547            },
1548        ))
1549    }
1550
1551    /// Return direct and compressed-range readers of a cell. Discovery is
1552    /// bounded before candidate materialization.
1553    pub fn dependents(
1554        &self,
1555        cell: &CellAddress,
1556        options: &DependentsOptions,
1557    ) -> Result<DependentsReport, InspectError> {
1558        let (key, canonical) = self.canonical_cell(cell)?;
1559        let mut work = WorkBudget::new(options.max_work);
1560        let (dependents, truncation) =
1561            self.collect_dependents(key, options.max_results, &mut work)?;
1562        Ok(DependentsReport {
1563            stamp: self.inspect_stamp(),
1564            cell: canonical,
1565            dependents,
1566            truncation,
1567        })
1568    }
1569
1570    fn target_addresses(reference: &SemanticReference) -> Option<&RangeAddress> {
1571        match reference {
1572            SemanticReference::Range { resolved, .. } => resolved.as_ref(),
1573            SemanticReference::Name {
1574                resolution: NameResolution::Range { resolved, .. },
1575                ..
1576            } => resolved.as_ref(),
1577            SemanticReference::Table { resolved, .. } => Some(resolved),
1578            _ => None,
1579        }
1580    }
1581
1582    fn target_cell(reference: &SemanticReference) -> Option<&CellAddress> {
1583        match reference {
1584            SemanticReference::Cell(cell) => Some(cell),
1585            SemanticReference::Name {
1586                resolution: NameResolution::Cell(cell),
1587                ..
1588            } => Some(cell),
1589            _ => None,
1590        }
1591    }
1592
1593    /// Classify cycles from the completed materialized graph, independent of
1594    /// BFS discovery order. An edge `source -> target` is a cycle edge exactly
1595    /// when `target` can reach `source` through materialized links.
1596    fn classify_cycle_dispositions(nodes: &mut [TraceNode]) {
1597        let adjacency: Vec<Vec<usize>> = nodes
1598            .iter()
1599            .map(|node| {
1600                node.links
1601                    .iter()
1602                    .flat_map(|link| link.targets.iter())
1603                    .map(|target| target.node.0 as usize)
1604                    .collect()
1605            })
1606            .collect();
1607        let mut reachability = vec![vec![false; nodes.len()]; nodes.len()];
1608        for start in 0..nodes.len() {
1609            let mut stack = adjacency[start].clone();
1610            while let Some(next) = stack.pop() {
1611                if reachability[start][next] {
1612                    continue;
1613                }
1614                reachability[start][next] = true;
1615                stack.extend(adjacency[next].iter().copied());
1616            }
1617        }
1618
1619        for (source, node) in nodes.iter_mut().enumerate() {
1620            for target in node
1621                .links
1622                .iter_mut()
1623                .flat_map(|link| link.targets.iter_mut())
1624            {
1625                let target_index = target.node.0 as usize;
1626                if source == target_index || reachability[target_index][source] {
1627                    target.disposition = LinkDisposition::Cycle;
1628                } else if target.disposition == LinkDisposition::Cycle {
1629                    target.disposition = LinkDisposition::Convergent;
1630                }
1631            }
1632        }
1633    }
1634
1635    /// Build a bounded response-local BFS DAG.
1636    pub fn trace(
1637        &self,
1638        roots: &[CellAddress],
1639        options: &TraceOptions,
1640    ) -> Result<TraceGraph, InspectError> {
1641        if roots.is_empty() {
1642            return Err(InspectError::InvalidOptions {
1643                message: "trace requires at least one root".to_string(),
1644            });
1645        }
1646        let canonical_roots: Vec<_> = roots
1647            .iter()
1648            .map(|root| self.canonical_cell(root))
1649            .collect::<Result<_, _>>()?;
1650        let mut unique_roots = FxHashMap::default();
1651        for (key, canonical) in &canonical_roots {
1652            unique_roots.entry(canonical.clone()).or_insert(*key);
1653        }
1654        if unique_roots.len() > options.max_nodes as usize {
1655            return Err(InspectError::InvalidOptions {
1656                message: format!(
1657                    "max_nodes ({}) must hold all {} unique roots",
1658                    options.max_nodes,
1659                    unique_roots.len()
1660                ),
1661            });
1662        }
1663        if options.direction == TraceDirection::Dependents {
1664            self.dependency_state_available()?;
1665        }
1666
1667        let mut nodes = Vec::new();
1668        let mut node_by_address: FxHashMap<CellAddress, TraceNodeId> = FxHashMap::default();
1669        let mut root_ids = Vec::new();
1670        let mut queue = VecDeque::new();
1671        let mut parents: HashMap<TraceNodeId, Option<TraceNodeId>> = HashMap::new();
1672        let mut truncation = TruncationReport::default();
1673
1674        // Admit every unique request root before expansion so `roots[i]`
1675        // always corresponds to the caller's `roots[i]`.
1676        for (key, canonical) in canonical_roots {
1677            if let Some(&id) = node_by_address.get(&canonical) {
1678                root_ids.push(id);
1679                continue;
1680            }
1681            let id = TraceNodeId(nodes.len() as u32);
1682            nodes.push(TraceNode {
1683                id,
1684                cell: self.snapshot_for_key(key, options.include_values)?,
1685                links: Vec::new(),
1686            });
1687            node_by_address.insert(canonical, id);
1688            root_ids.push(id);
1689            queue.push_back((id, key, 0u32));
1690            parents.insert(id, None);
1691        }
1692
1693        let mut work = WorkBudget::new(options.max_work);
1694        let mut links_used = 0u32;
1695        let mut range_members_used = 0u32;
1696
1697        while let Some((source_id, source_key, depth)) = queue.pop_front() {
1698            let can_follow = depth < options.max_depth;
1699            let mut links = Vec::new();
1700            if options.direction == TraceDirection::Precedents {
1701                if let Some(InternalSpillRole::Member { anchor }) =
1702                    self.inspect_source().spill_role(source_key)
1703                {
1704                    if links_used < options.max_links {
1705                        links_used += 1;
1706                        let anchor_address = self.address_for_key(anchor);
1707                        let mut link = TraceLink {
1708                            reference: SemanticReference::Cell(anchor_address.clone()),
1709                            kind: TraceLinkKind::SpillAnchor,
1710                            targets: Vec::new(),
1711                            omitted: None,
1712                        };
1713                        self.attach_cell_target(
1714                            anchor_address,
1715                            source_id,
1716                            anchor,
1717                            can_follow,
1718                            depth,
1719                            options,
1720                            false,
1721                            &mut nodes,
1722                            &mut node_by_address,
1723                            &mut parents,
1724                            &mut queue,
1725                            &mut link,
1726                            &mut truncation,
1727                        )?;
1728                        links.push(link);
1729                    } else {
1730                        truncation.incomplete = true;
1731                        merge_omitted(&mut truncation.omitted, OmittedCount::AtLeast(1));
1732                    }
1733                } else {
1734                    let available_links = options.max_links.saturating_sub(links_used);
1735                    let (precedents, local_truncation) =
1736                        self.collect_precedents(source_key, available_links, &mut work)?;
1737                    if local_truncation.incomplete {
1738                        truncation.incomplete = true;
1739                        if let Some(omitted) = local_truncation.omitted {
1740                            merge_omitted(&mut truncation.omitted, omitted);
1741                        }
1742                    }
1743                    for precedent in precedents {
1744                        links_used += 1;
1745                        let mut link = TraceLink {
1746                            reference: precedent.reference,
1747                            kind: TraceLinkKind::Formula {
1748                                provenance: precedent.provenance,
1749                            },
1750                            targets: Vec::new(),
1751                            omitted: None,
1752                        };
1753                        if let Some(cell) = Self::target_cell(&link.reference).cloned() {
1754                            let (target_key, canonical) = self.canonical_cell(&cell)?;
1755                            self.attach_cell_target(
1756                                canonical,
1757                                source_id,
1758                                target_key,
1759                                can_follow,
1760                                depth,
1761                                options,
1762                                false,
1763                                &mut nodes,
1764                                &mut node_by_address,
1765                                &mut parents,
1766                                &mut queue,
1767                                &mut link,
1768                                &mut truncation,
1769                            )?;
1770                        } else if let Some(range) = Self::target_addresses(&link.reference).cloned()
1771                        {
1772                            self.attach_range_targets(
1773                                &range,
1774                                source_id,
1775                                can_follow,
1776                                depth,
1777                                options,
1778                                &mut range_members_used,
1779                                &mut nodes,
1780                                &mut node_by_address,
1781                                &mut parents,
1782                                &mut queue,
1783                                &mut link,
1784                                &mut truncation,
1785                            )?;
1786                        }
1787                        links.push(link);
1788                    }
1789                }
1790            } else {
1791                let available = options.max_links.saturating_sub(links_used);
1792                let (dependents, local_truncation) =
1793                    self.collect_dependents(source_key, available, &mut work)?;
1794                if local_truncation.incomplete {
1795                    truncation.incomplete = true;
1796                    if let Some(omitted) = local_truncation.omitted {
1797                        merge_omitted(&mut truncation.omitted, omitted);
1798                    }
1799                }
1800                for dependent in dependents {
1801                    links_used += 1;
1802                    let spill_reader = dependent
1803                        .via
1804                        .iter()
1805                        .any(|via| via != &self.address_for_key(source_key));
1806                    let (target_key, canonical) = self.canonical_cell(&dependent.cell)?;
1807                    let mut link = TraceLink {
1808                        reference: SemanticReference::Cell(canonical.clone()),
1809                        kind: if spill_reader {
1810                            TraceLinkKind::SpillReader
1811                        } else {
1812                            TraceLinkKind::Formula {
1813                                provenance: Provenance::Declared,
1814                            }
1815                        },
1816                        targets: Vec::new(),
1817                        omitted: None,
1818                    };
1819                    self.attach_cell_target(
1820                        canonical,
1821                        source_id,
1822                        target_key,
1823                        can_follow,
1824                        depth,
1825                        options,
1826                        false,
1827                        &mut nodes,
1828                        &mut node_by_address,
1829                        &mut parents,
1830                        &mut queue,
1831                        &mut link,
1832                        &mut truncation,
1833                    )?;
1834                    links.push(link);
1835                }
1836            }
1837            nodes[source_id.0 as usize].links = links;
1838        }
1839
1840        Self::classify_cycle_dispositions(&mut nodes);
1841
1842        Ok(TraceGraph {
1843            stamp: self.inspect_stamp(),
1844            direction: options.direction,
1845            roots: root_ids,
1846            nodes,
1847            truncation,
1848        })
1849    }
1850
1851    #[allow(clippy::too_many_arguments)]
1852    fn attach_cell_target(
1853        &self,
1854        address: CellAddress,
1855        source_id: TraceNodeId,
1856        key: CellKey,
1857        can_follow: bool,
1858        depth: u32,
1859        options: &TraceOptions,
1860        defer_missing_omission: bool,
1861        nodes: &mut Vec<TraceNode>,
1862        node_by_address: &mut FxHashMap<CellAddress, TraceNodeId>,
1863        parents: &mut HashMap<TraceNodeId, Option<TraceNodeId>>,
1864        queue: &mut VecDeque<(TraceNodeId, CellKey, u32)>,
1865        link: &mut TraceLink,
1866        truncation: &mut TruncationReport,
1867    ) -> Result<(), InspectError> {
1868        if let Some(&target_id) = node_by_address.get(&address) {
1869            link.targets.push(TraceLinkTarget {
1870                node: target_id,
1871                // The completed-graph reachability post-pass resolves Cycle
1872                // versus Convergent without depending on the BFS parent tree.
1873                disposition: LinkDisposition::Convergent,
1874            });
1875            return Ok(());
1876        }
1877        if nodes.len() >= options.max_nodes as usize {
1878            link.omitted = Some(OmittedCount::AtLeast(1));
1879            truncation.incomplete = true;
1880            if !defer_missing_omission {
1881                merge_omitted(&mut truncation.omitted, OmittedCount::AtLeast(1));
1882            }
1883            return Ok(());
1884        }
1885        let target_id = TraceNodeId(nodes.len() as u32);
1886        nodes.push(TraceNode {
1887            id: target_id,
1888            cell: self.snapshot_for_key(key, options.include_values)?,
1889            links: Vec::new(),
1890        });
1891        node_by_address.insert(address, target_id);
1892        parents.insert(target_id, Some(source_id));
1893        link.targets.push(TraceLinkTarget {
1894            node: target_id,
1895            disposition: if can_follow {
1896                LinkDisposition::Expanded
1897            } else {
1898                LinkDisposition::Elided
1899            },
1900        });
1901        if can_follow {
1902            queue.push_back((target_id, key, depth + 1));
1903        } else {
1904            // The target is represented, but its unvisited outgoing links are
1905            // unknown without doing the depth-elided work.
1906            truncation.incomplete = true;
1907        }
1908        Ok(())
1909    }
1910
1911    #[allow(clippy::too_many_arguments)]
1912    fn attach_range_targets(
1913        &self,
1914        range: &RangeAddress,
1915        source_id: TraceNodeId,
1916        can_follow: bool,
1917        depth: u32,
1918        options: &TraceOptions,
1919        range_members_used: &mut u32,
1920        nodes: &mut Vec<TraceNode>,
1921        node_by_address: &mut FxHashMap<CellAddress, TraceNodeId>,
1922        parents: &mut HashMap<TraceNodeId, Option<TraceNodeId>>,
1923        queue: &mut VecDeque<(TraceNodeId, CellKey, u32)>,
1924        link: &mut TraceLink,
1925        truncation: &mut TruncationReport,
1926    ) -> Result<(), InspectError> {
1927        let total = u64::from(range.width()) * u64::from(range.height());
1928        let mut attached = 0u64;
1929        let mut compressed_ancestors = Vec::new();
1930        let mut cursor = Some(source_id);
1931        while let Some(ancestor) = cursor {
1932            let address = &nodes[ancestor.0 as usize].cell.address;
1933            if range.sheet == address.sheet
1934                && range.start_row <= address.row
1935                && address.row <= range.end_row
1936                && range.start_col <= address.column
1937                && address.column <= range.end_col
1938            {
1939                compressed_ancestors.push((ancestor, address.row, address.column));
1940            }
1941            cursor = parents.get(&ancestor).copied().flatten();
1942        }
1943
1944        // Preserve compressed cycle semantics even if the global member budget
1945        // is exhausted or an ancestor occurs late in a large row-major range.
1946        for (ancestor, _, _) in &compressed_ancestors {
1947            link.targets.push(TraceLinkTarget {
1948                node: *ancestor,
1949                // The completed-graph reachability post-pass resolves this
1950                // provisional revisit disposition.
1951                disposition: LinkDisposition::Convergent,
1952            });
1953            attached += 1;
1954        }
1955
1956        'rows: for row in range.start_row..=range.end_row {
1957            for column in range.start_col..=range.end_col {
1958                if compressed_ancestors
1959                    .iter()
1960                    .any(|(_, ancestor_row, ancestor_column)| {
1961                        row == *ancestor_row && column == *ancestor_column
1962                    })
1963                {
1964                    continue;
1965                }
1966                if *range_members_used >= options.range_member_budget {
1967                    break 'rows;
1968                }
1969                *range_members_used += 1;
1970                let address = CellAddress {
1971                    sheet: range.sheet.clone(),
1972                    row,
1973                    column,
1974                };
1975                let (key, canonical) = self.canonical_cell(&address)?;
1976                let before = link.targets.len();
1977                self.attach_cell_target(
1978                    canonical,
1979                    source_id,
1980                    key,
1981                    can_follow,
1982                    depth,
1983                    options,
1984                    true,
1985                    nodes,
1986                    node_by_address,
1987                    parents,
1988                    queue,
1989                    link,
1990                    truncation,
1991                )?;
1992                if link.targets.len() > before {
1993                    attached += 1;
1994                } else if link.omitted.is_some() {
1995                    break 'rows;
1996                }
1997            }
1998        }
1999        if attached < total {
2000            let omitted = OmittedCount::Exact(total - attached);
2001            link.omitted = Some(omitted);
2002            truncation.incomplete = true;
2003            merge_omitted(&mut truncation.omitted, omitted);
2004        }
2005        Ok(())
2006    }
2007
2008    /// Return one row-major owned page over the semantic finite extent.
2009    pub fn range_page(
2010        &self,
2011        area: &RangeArea,
2012        options: &RangePageOptions,
2013    ) -> Result<RangePage, InspectError> {
2014        if options.limit == 0 {
2015            return Err(InspectError::InvalidOptions {
2016                message: "range page limit must be at least one".to_string(),
2017            });
2018        }
2019        let stamp = self.inspect_stamp();
2020        if let Some(expected) = options.expected_stamp
2021            && expected != stamp
2022        {
2023            return Err(InspectError::RevisionMismatch {
2024                expected,
2025                actual: stamp,
2026            });
2027        }
2028        let (_, declared) = self.canonical_area(area)?;
2029        let resolved = self.resolve_semantic_area(&declared);
2030        let total = resolved.as_ref().map_or(0, |range| {
2031            u64::from(range.width()) * u64::from(range.height())
2032        });
2033        let start = options.offset.min(total);
2034        let end = start.saturating_add(u64::from(options.limit)).min(total);
2035        let mut items = Vec::new();
2036        items
2037            .try_reserve((end - start) as usize)
2038            .map_err(|_| InspectError::ResourceExhausted {
2039                resource: "range page items",
2040            })?;
2041        if let Some(range) = &resolved {
2042            let width = u64::from(range.width());
2043            for offset in start..end {
2044                let row = range.start_row + u32::try_from(offset / width).unwrap_or(u32::MAX);
2045                let column = range.start_col + u32::try_from(offset % width).unwrap_or(u32::MAX);
2046                let (key, _) = self.canonical_cell(&CellAddress {
2047                    sheet: range.sheet.clone(),
2048                    row,
2049                    column,
2050                })?;
2051                items.push(self.snapshot_for_key(key, options.include_values)?);
2052            }
2053        }
2054        Ok(RangePage {
2055            stamp,
2056            declared,
2057            resolved,
2058            total,
2059            offset: options.offset,
2060            items,
2061            next_offset: (end < total).then_some(end),
2062        })
2063    }
2064}