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