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