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            // Imported OOXML formula text normally omits '='. Without it the
752            // parser intentionally interprets the input as a literal cell value.
753            let text = if text.starts_with('=') {
754                text
755            } else {
756                format!("={text}")
757            };
758            let ast =
759                formualizer_parse::parse(&text).map_err(|error| InspectError::InvalidAddress {
760                    message: format!("staged formula at {sheet}!R{row}C{col} is invalid: {error}"),
761                })?;
762            let volatile = self.engine.graph.fp8_parity_is_ast_volatile(&ast);
763            return Ok(Some(FormulaView {
764                ast,
765                volatile,
766                dirty: true,
767            }));
768        }
769
770        let cell_ref = self.cell_ref(cell);
771        let Some(vertex) = self.engine.graph.get_vertex_for_cell(&cell_ref) else {
772            return Ok(None);
773        };
774        let Some(ast) = self.engine.graph.get_formula(vertex) else {
775            return Ok(None);
776        };
777        Ok(Some(FormulaView {
778            ast,
779            volatile: self.engine.graph.is_volatile(vertex),
780            dirty: self.engine.graph.is_dirty(vertex),
781        }))
782    }
783
784    fn visit_declared_references(
785        &self,
786        cell: CellKey,
787        visitor: &mut dyn ReferenceVisitor,
788    ) -> Result<(), InspectError> {
789        let Some(formula) = self.formula_at(cell)? else {
790            return Ok(());
791        };
792
793        visit_formula_ast_references(&formula.ast, visitor)
794    }
795
796    fn visit_dependents_covering(
797        &self,
798        cell: CellKey,
799        budget: &mut WorkBudget,
800        visitor: &mut dyn DependentVisitor,
801    ) -> Result<QueryCompleteness, InspectError> {
802        let complete = self.engine.graph.visit_range_dependents_covering_bounded(
803            cell.sheet_id,
804            cell.row0,
805            cell.col0,
806            &mut budget.remaining,
807            &mut |vertex| {
808                self.engine
809                    .graph
810                    .get_cell_ref(vertex)
811                    .is_none_or(|cell_ref| {
812                        visitor.visit(CellKey {
813                            sheet_id: cell_ref.sheet_id,
814                            row0: cell_ref.coord.row(),
815                            col0: cell_ref.coord.col(),
816                        })
817                    })
818            },
819        );
820        Ok(if complete {
821            QueryCompleteness::Complete
822        } else {
823            QueryCompleteness::Incomplete
824        })
825    }
826
827    fn spill_role(&self, cell: CellKey) -> Option<InternalSpillRole> {
828        let cell_ref = self.cell_ref(cell);
829        if let Some(vertex) = self.engine.graph.get_vertex_for_cell(&cell_ref)
830            && let Some(cells) = self.engine.graph.spill_cells_for_anchor(vertex)
831        {
832            let mut bounds: Option<(u32, u32, u32, u32)> = None;
833            for member in cells {
834                bounds = Some(match bounds {
835                    None => (
836                        member.coord.row(),
837                        member.coord.col(),
838                        member.coord.row(),
839                        member.coord.col(),
840                    ),
841                    Some((sr, sc, er, ec)) => (
842                        sr.min(member.coord.row()),
843                        sc.min(member.coord.col()),
844                        er.max(member.coord.row()),
845                        ec.max(member.coord.col()),
846                    ),
847                });
848            }
849            if let Some((sr, sc, er, ec)) = bounds {
850                let sheet = self.engine.graph.sheet_name(cell.sheet_id).to_string();
851                return Some(InternalSpillRole::Anchor {
852                    extent: RangeAddress {
853                        sheet,
854                        start_row: sr + 1,
855                        start_col: sc + 1,
856                        end_row: er + 1,
857                        end_col: ec + 1,
858                    },
859                });
860            }
861        }
862        let anchor = self.engine.graph.spill_registry_anchor_for_cell(cell_ref)?;
863        let anchor_ref = self.engine.graph.get_cell_ref(anchor)?;
864        Some(InternalSpillRole::Member {
865            anchor: CellKey {
866                sheet_id: anchor_ref.sheet_id,
867                row0: anchor_ref.coord.row(),
868                col0: anchor_ref.coord.col(),
869            },
870        })
871    }
872}
873
874/// Inspection adapter at the same graph-owned formula-authority router used by
875/// evaluation and `Engine::get_cell`. Active span placements are answered from
876/// FormulaPlane; overlays, rejected formulas, and the legacy tail delegate to
877/// `LegacyInspectSource`.
878struct FormulaPlaneInspectSource<'a, R> {
879    engine: &'a Engine<R>,
880    legacy: LegacyInspectSource<'a, R>,
881}
882
883impl<'a, R: EvaluationContext> FormulaPlaneInspectSource<'a, R> {
884    fn new(engine: &'a Engine<R>) -> Self {
885        Self {
886            engine,
887            legacy: LegacyInspectSource { engine },
888        }
889    }
890
891    fn cell_ref(&self, key: CellKey) -> CellRef {
892        CellRef::new(key.sheet_id, Coord::new(key.row0, key.col0, true, true))
893    }
894
895    fn span_placement(&self, key: CellKey) -> Option<(FormulaSpanRef, PlacementCoord)> {
896        // Defensive even though Shadow currently retains no active spans: this
897        // gate also protects dependent-index routing if Shadow ever retains
898        // spans or consumer-read entries.
899        if self.engine.config.formula_plane_mode != FormulaPlaneMode::AuthoritativeExperimental {
900            return None;
901        }
902        let placement = PlacementCoord::new(key.sheet_id, key.row0, key.col0);
903        let legacy_vertex = self.engine.graph.get_vertex_for_cell(&self.cell_ref(key));
904        let handle = self
905            .engine
906            .graph
907            .formula_authority()
908            .plane
909            .resolve_formula_at(placement, legacy_vertex);
910        match handle.resolution {
911            FormulaResolution::SpanPlacement {
912                span, placement, ..
913            } => Some((span, placement)),
914            FormulaResolution::StagedFormula { .. }
915            | FormulaResolution::Overlay(_)
916            | FormulaResolution::LegacyVertex(_)
917            | FormulaResolution::Empty
918            | FormulaResolution::Stale => None,
919        }
920    }
921
922    fn dirty_domain_contains(dirty: &ProducerDirtyDomain, placement: PlacementCoord) -> bool {
923        match dirty {
924            ProducerDirtyDomain::Whole => true,
925            ProducerDirtyDomain::Cells(cells) => cells.contains(&RegionKey::from(placement)),
926            ProducerDirtyDomain::Regions(regions) => {
927                let key = RegionKey::from(placement);
928                regions.iter().any(|region| region.contains_key(key))
929            }
930        }
931    }
932
933    fn span_placement_is_dirty(&self, span_ref: FormulaSpanRef, placement: PlacementCoord) -> bool {
934        if self
935            .engine
936            .graph
937            .pending_formula_dirty_whole_spans()
938            .any(|pending| pending == span_ref)
939        {
940            return true;
941        }
942        if self
943            .engine
944            .graph
945            .pending_formula_dirty_span_regions()
946            .any(|(pending, region)| {
947                pending == span_ref && region.contains_key(RegionKey::from(placement))
948            })
949        {
950            return true;
951        }
952
953        let changed = self
954            .engine
955            .graph
956            .pending_formula_dirty_regions()
957            .collect::<Vec<_>>();
958        if changed.is_empty() {
959            return false;
960        }
961        let authority = self.engine.graph.formula_authority();
962        let closure = compute_dirty_closure(&authority.consumer_reads, changed, |producer| {
963            authority.producer_results.producer_result_region(producer)
964        });
965        if closure.incomplete {
966            return true;
967        }
968        let producer = FormulaProducerId::Span(span_ref.id);
969        closure.work.iter().any(|work| {
970            work.producer == producer && Self::dirty_domain_contains(&work.dirty, placement)
971        }) || closure
972            .fallbacks
973            .iter()
974            .any(|fallback| fallback.consumer == producer)
975    }
976
977    fn instantiate_axis(projection: AxisProjection, placement: u32) -> Option<(u32, bool)> {
978        match projection {
979            AxisProjection::Relative { offset } => {
980                let value = i64::from(placement).checked_add(offset)?;
981                let value = u32::try_from(value).ok()?.checked_add(1)?;
982                Some((value, false))
983            }
984            AxisProjection::Absolute { index } => index.checked_add(1).map(|value| (value, true)),
985        }
986    }
987
988    fn instantiated_span_references(
989        &self,
990        span_ref: FormulaSpanRef,
991        placement: PlacementCoord,
992    ) -> Option<Vec<ReferenceType>> {
993        let authority = self.engine.graph.formula_authority();
994        let span = authority.plane.spans.get(span_ref)?;
995        let summary = authority
996            .plane
997            .span_read_summaries
998            .get(span.read_summary_id?)?;
999        let mut references = Vec::with_capacity(summary.dependencies.len());
1000        for dependency in &summary.dependencies {
1001            let sheet = Some(
1002                self.engine
1003                    .graph
1004                    .sheet_name(dependency.read_region.sheet_id())
1005                    .to_string(),
1006            );
1007            let reference = match dependency.projection {
1008                DirtyProjectionRule::AffineCell { row, col } => {
1009                    let (row, row_abs) = Self::instantiate_axis(row, placement.row)?;
1010                    let (col, col_abs) = Self::instantiate_axis(col, placement.col)?;
1011                    ReferenceType::Cell {
1012                        sheet,
1013                        row,
1014                        col,
1015                        row_abs,
1016                        col_abs,
1017                    }
1018                }
1019                DirtyProjectionRule::AffineRange {
1020                    row_start,
1021                    row_end,
1022                    col_start,
1023                    col_end,
1024                } => {
1025                    let (start_row, start_row_abs) =
1026                        Self::instantiate_axis(row_start, placement.row)?;
1027                    let (end_row, end_row_abs) = Self::instantiate_axis(row_end, placement.row)?;
1028                    let (start_col, start_col_abs) =
1029                        Self::instantiate_axis(col_start, placement.col)?;
1030                    let (end_col, end_col_abs) = Self::instantiate_axis(col_end, placement.col)?;
1031                    ReferenceType::Range {
1032                        sheet,
1033                        start_row: Some(start_row),
1034                        start_col: Some(start_col),
1035                        end_row: Some(end_row),
1036                        end_col: Some(end_col),
1037                        start_row_abs,
1038                        start_col_abs,
1039                        end_row_abs,
1040                        end_col_abs,
1041                    }
1042                }
1043                DirtyProjectionRule::WholeColumnRange { col_start, col_end } => {
1044                    let (start_col, start_col_abs) =
1045                        Self::instantiate_axis(col_start, placement.col)?;
1046                    let (end_col, end_col_abs) = Self::instantiate_axis(col_end, placement.col)?;
1047                    ReferenceType::Range {
1048                        sheet,
1049                        start_row: None,
1050                        start_col: Some(start_col),
1051                        end_row: None,
1052                        end_col: Some(end_col),
1053                        start_row_abs: true,
1054                        start_col_abs,
1055                        end_row_abs: true,
1056                        end_col_abs,
1057                    }
1058                }
1059                // WholeResult is scheduler-only and does not retain declared
1060                // reference shape. The caller uses the AST fallback instead.
1061                DirtyProjectionRule::WholeResult => return None,
1062            };
1063            references.push(reference);
1064        }
1065        #[cfg(test)]
1066        record_formula_plane_template_path();
1067        Some(references)
1068    }
1069
1070    fn visit_formula_plane_dependents(
1071        &self,
1072        cell: CellKey,
1073        budget: &mut WorkBudget,
1074        visitor: &mut dyn DependentVisitor,
1075    ) -> QueryCompleteness {
1076        if self.engine.config.formula_plane_mode != FormulaPlaneMode::AuthoritativeExperimental {
1077            return QueryCompleteness::Complete;
1078        }
1079        let authority = self.engine.graph.formula_authority();
1080        let candidate_limit = usize::try_from(budget.remaining).unwrap_or(usize::MAX);
1081        let query = authority.consumer_reads.query_changed_region_bounded(
1082            Region::point(cell.sheet_id, cell.row0, cell.col0),
1083            candidate_limit,
1084        );
1085        let mut query = match query {
1086            BoundedRegionQueryResult::Complete(query) => query,
1087            BoundedRegionQueryResult::Incomplete {
1088                observed_candidates,
1089            } => {
1090                budget.remaining = budget.remaining.saturating_sub(observed_candidates as u64);
1091                return QueryCompleteness::Incomplete;
1092            }
1093        };
1094        budget.remaining = budget
1095            .remaining
1096            .saturating_sub(query.stats.candidate_count as u64);
1097        query.matches.sort_by(|left, right| {
1098            let key = |producer| {
1099                let FormulaProducerId::Span(span_id) = producer else {
1100                    return None;
1101                };
1102                let span_ref = authority.plane.spans.current_ref(span_id)?;
1103                let span = authority.plane.spans.get(span_ref)?;
1104                let placement = span.domain.iter().next()?;
1105                Some((
1106                    self.engine.graph.sheet_name(placement.sheet_id),
1107                    placement.row,
1108                    placement.col,
1109                ))
1110            };
1111            key(left.value.consumer).cmp(&key(right.value.consumer))
1112        });
1113
1114        for matched in query.matches {
1115            let FormulaProducerId::Span(span_id) = matched.value.consumer else {
1116                continue;
1117            };
1118            let Some(span_ref) = authority.plane.spans.current_ref(span_id) else {
1119                continue;
1120            };
1121            let Some(span) = authority.plane.spans.get(span_ref) else {
1122                continue;
1123            };
1124            let whole = ProducerDirtyDomain::Whole;
1125            let dirty = match &matched.value.dirty {
1126                ProjectionResult::Exact(dirty) | ProjectionResult::Conservative { dirty, .. } => {
1127                    dirty
1128                }
1129                ProjectionResult::NoIntersection => continue,
1130                ProjectionResult::Unsupported(_) => &whole,
1131            };
1132            for placement in span.domain.iter() {
1133                if !Self::dirty_domain_contains(dirty, placement) {
1134                    continue;
1135                }
1136                if !budget.charge()
1137                    || !visitor.visit(CellKey {
1138                        sheet_id: placement.sheet_id,
1139                        row0: placement.row,
1140                        col0: placement.col,
1141                    })
1142                {
1143                    return QueryCompleteness::Incomplete;
1144                }
1145            }
1146        }
1147        QueryCompleteness::Complete
1148    }
1149}
1150
1151impl<R: EvaluationContext> InspectSource for FormulaPlaneInspectSource<'_, R> {
1152    fn formula_at(&self, cell: CellKey) -> Result<Option<FormulaView>, InspectError> {
1153        let Some((span_ref, placement)) = self.span_placement(cell) else {
1154            return self.legacy.formula_at(cell);
1155        };
1156        let sheet = self.engine.graph.sheet_name(cell.sheet_id);
1157        // Reuse the per-placement reconstruction used by the public cell read
1158        // path, keeping canonical text and structural relocation identical.
1159        let ast = self
1160            .engine
1161            .get_cell(sheet, cell.row0 + 1, cell.col0 + 1)
1162            .and_then(|(ast, _)| ast);
1163        Ok(ast.map(|ast| FormulaView {
1164            ast,
1165            // Canonical admission rejects CanonicalRejectReason::VolatileFunction,
1166            // using the same function-registry volatility capability as legacy.
1167            volatile: false,
1168            dirty: self.span_placement_is_dirty(span_ref, placement),
1169        }))
1170    }
1171
1172    fn visit_declared_references(
1173        &self,
1174        cell: CellKey,
1175        visitor: &mut dyn ReferenceVisitor,
1176    ) -> Result<(), InspectError> {
1177        let Some((span_ref, placement)) = self.span_placement(cell) else {
1178            return self.legacy.visit_declared_references(cell, visitor);
1179        };
1180        let Some(references) = self.instantiated_span_references(span_ref, placement) else {
1181            #[cfg(test)]
1182            record_formula_plane_ast_fallback_path();
1183            // Missing/stale summaries and WholeResult cannot answer a per-cell
1184            // shape query; reconstruct and walk the FormulaPlane AST instead.
1185            let Some(formula) = self.formula_at(cell)? else {
1186                return Ok(());
1187            };
1188            return visit_formula_ast_references(&formula.ast, visitor);
1189        };
1190        for reference in &references {
1191            if !visitor.visit(refs::classify(reference)) {
1192                break;
1193            }
1194        }
1195        Ok(())
1196    }
1197
1198    fn visit_dependents_covering(
1199        &self,
1200        cell: CellKey,
1201        budget: &mut WorkBudget,
1202        visitor: &mut dyn DependentVisitor,
1203    ) -> Result<QueryCompleteness, InspectError> {
1204        if self
1205            .legacy
1206            .visit_dependents_covering(cell, budget, visitor)?
1207            == QueryCompleteness::Incomplete
1208        {
1209            return Ok(QueryCompleteness::Incomplete);
1210        }
1211        Ok(self.visit_formula_plane_dependents(cell, budget, visitor))
1212    }
1213
1214    fn spill_role(&self, cell: CellKey) -> Option<InternalSpillRole> {
1215        // FormulaPlane rejects spill-capable formulas; spill facts remain in
1216        // the graph-owned last-evaluation registry for both authorities.
1217        self.legacy.spill_role(cell)
1218    }
1219}
1220
1221fn merge_omitted(target: &mut Option<OmittedCount>, addition: OmittedCount) {
1222    *target = Some(match (target.take(), addition) {
1223        (None, value) => value,
1224        (Some(OmittedCount::Exact(a)), OmittedCount::Exact(b)) => {
1225            OmittedCount::Exact(a.saturating_add(b))
1226        }
1227        (Some(OmittedCount::Exact(a)), OmittedCount::AtLeast(b))
1228        | (Some(OmittedCount::AtLeast(a)), OmittedCount::Exact(b))
1229        | (Some(OmittedCount::AtLeast(a)), OmittedCount::AtLeast(b)) => {
1230            OmittedCount::AtLeast(a.saturating_add(b))
1231        }
1232    });
1233}
1234
1235fn address_cmp(left: &CellAddress, right: &CellAddress) -> std::cmp::Ordering {
1236    left.sheet
1237        .cmp(&right.sheet)
1238        .then_with(|| left.row.cmp(&right.row))
1239        .then_with(|| left.column.cmp(&right.column))
1240}
1241
1242impl<R: EvaluationContext> Engine<R> {
1243    fn inspect_stamp(&self) -> StateStamp {
1244        StateStamp {
1245            mutation_revision: self.inspection_mutation_revision(),
1246            recalc_epoch: self.recalc_epoch,
1247        }
1248    }
1249
1250    fn inspect_source(&self) -> FormulaPlaneInspectSource<'_, R> {
1251        FormulaPlaneInspectSource::new(self)
1252    }
1253
1254    fn canonical_cell(
1255        &self,
1256        address: &CellAddress,
1257    ) -> Result<(CellKey, CellAddress), InspectError> {
1258        CellAddress::new(address.sheet.clone(), address.row, address.column).map_err(|error| {
1259            InspectError::InvalidAddress {
1260                message: error.to_string(),
1261            }
1262        })?;
1263        let Some(sheet_id) = self.graph.sheet_id(&address.sheet) else {
1264            return Err(InspectError::SheetNotFound {
1265                sheet: address.sheet.clone(),
1266            });
1267        };
1268        let canonical = CellAddress {
1269            sheet: self.graph.sheet_name(sheet_id).to_string(),
1270            row: address.row,
1271            column: address.column,
1272        };
1273        Ok((
1274            CellKey {
1275                sheet_id,
1276                row0: address.row - 1,
1277                col0: address.column - 1,
1278            },
1279            canonical,
1280        ))
1281    }
1282
1283    fn canonical_area(&self, area: &RangeArea) -> Result<(SheetId, RangeArea), InspectError> {
1284        RangeArea::new(
1285            area.sheet.clone(),
1286            area.start_row,
1287            area.start_column,
1288            area.end_row,
1289            area.end_column,
1290        )
1291        .map_err(|error| InspectError::InvalidAddress {
1292            message: error.to_string(),
1293        })?;
1294        let Some(sheet_id) = self.graph.sheet_id(&area.sheet) else {
1295            return Err(InspectError::SheetNotFound {
1296                sheet: area.sheet.clone(),
1297            });
1298        };
1299        Ok((
1300            sheet_id,
1301            RangeArea {
1302                sheet: self.graph.sheet_name(sheet_id).to_string(),
1303                start_row: area.start_row,
1304                start_column: area.start_column,
1305                end_row: area.end_row,
1306                end_column: area.end_column,
1307            },
1308        ))
1309    }
1310
1311    fn address_for_key(&self, key: CellKey) -> CellAddress {
1312        CellAddress {
1313            sheet: self.graph.sheet_name(key.sheet_id).to_string(),
1314            row: key.row0 + 1,
1315            column: key.col0 + 1,
1316        }
1317    }
1318
1319    fn key_for_vertex(&self, vertex: VertexId) -> Option<CellKey> {
1320        if !matches!(
1321            self.graph.get_vertex_kind(vertex),
1322            VertexKind::FormulaScalar | VertexKind::FormulaArray
1323        ) {
1324            return None;
1325        }
1326        let cell = self.graph.get_cell_ref(vertex)?;
1327        Some(CellKey {
1328            sheet_id: cell.sheet_id,
1329            row0: cell.coord.row(),
1330            col0: cell.coord.col(),
1331        })
1332    }
1333
1334    fn resolve_semantic_area(&self, area: &RangeArea) -> Option<RangeAddress> {
1335        let extent = resolve_used_extent(
1336            OpenRangeBounds {
1337                start_row: area.start_row,
1338                start_column: area.start_column,
1339                end_row: area.end_row,
1340                end_column: area.end_column,
1341            },
1342            ExtentPolicy::Semantic,
1343            |first, last| self.semantic_used_rows_for_columns(&area.sheet, first, last),
1344            |first, last| self.semantic_used_cols_for_rows(&area.sheet, first, last),
1345        )?;
1346        Some(Self::range_from_extent(&area.sheet, extent))
1347    }
1348
1349    fn range_from_extent(sheet: &str, extent: ResolvedExtent) -> RangeAddress {
1350        RangeAddress {
1351            sheet: sheet.to_string(),
1352            start_row: extent.start_row,
1353            start_col: extent.start_column,
1354            end_row: extent.end_row,
1355            end_col: extent.end_column,
1356        }
1357    }
1358
1359    fn snapshot_for_key(
1360        &self,
1361        key: CellKey,
1362        include_value: bool,
1363    ) -> Result<CellSnapshot, InspectError> {
1364        let source = self.inspect_source();
1365        let formula = source.formula_at(key)?;
1366        let address = self.address_for_key(key);
1367        let cached_value = self.read_cell_value(&address.sheet, address.row, address.column);
1368        let (canonical_formula, volatile, staleness) = match formula {
1369            Some(view) => {
1370                // `read_cell_value` maps LiteralValue::Empty to `None`, which
1371                // would classify an evaluated Empty result as NeverEvaluated.
1372                // No current builtin caches a true Empty (blank-derived formula
1373                // results are normalized), so that state is currently unreachable.
1374                let staleness = if cached_value.is_none() {
1375                    Staleness::NeverEvaluated
1376                } else if view.dirty {
1377                    Staleness::Dirty
1378                } else {
1379                    Staleness::Current
1380                };
1381                (
1382                    Some(formualizer_parse::pretty::canonical_formula(&view.ast)),
1383                    view.volatile,
1384                    staleness,
1385                )
1386            }
1387            None => (None, false, Staleness::Current),
1388        };
1389        let spill = source.spill_role(key).map(|role| match role {
1390            InternalSpillRole::Anchor { extent } => SpillRole::Anchor { extent },
1391            InternalSpillRole::Member { anchor } => SpillRole::Member {
1392                anchor: self.address_for_key(anchor),
1393            },
1394        });
1395        Ok(CellSnapshot {
1396            address,
1397            formula: canonical_formula,
1398            value: include_value.then_some(cached_value).flatten(),
1399            value_included: include_value,
1400            staleness,
1401            volatile,
1402            spill,
1403        })
1404    }
1405
1406    /// Inspect one cell without evaluating or preparing workbook state.
1407    pub fn inspect_cell(
1408        &self,
1409        cell: &CellAddress,
1410        options: &SnapshotOptions,
1411    ) -> Result<CellSnapshotReport, InspectError> {
1412        let (key, _) = self.canonical_cell(cell)?;
1413        Ok(CellSnapshotReport {
1414            stamp: self.inspect_stamp(),
1415            cell: self.snapshot_for_key(key, options.include_values)?,
1416        })
1417    }
1418
1419    fn resolve_name(&self, key: CellKey, name: &str) -> NameResolution {
1420        let Some(named) = self.graph.resolve_name_entry(name, key.sheet_id) else {
1421            return NameResolution::Unresolved;
1422        };
1423        match &named.definition {
1424            NamedDefinition::Cell(cell) => NameResolution::Cell(CellAddress {
1425                sheet: self.graph.sheet_name(cell.sheet_id).to_string(),
1426                row: cell.coord.row() + 1,
1427                column: cell.coord.col() + 1,
1428            }),
1429            NamedDefinition::Range(range) => {
1430                let resolved = RangeAddress {
1431                    sheet: self.graph.sheet_name(range.start.sheet_id).to_string(),
1432                    start_row: range.start.coord.row() + 1,
1433                    start_col: range.start.coord.col() + 1,
1434                    end_row: range.end.coord.row() + 1,
1435                    end_col: range.end.coord.col() + 1,
1436                };
1437                NameResolution::Range {
1438                    declared: RangeArea::from_finite(&resolved),
1439                    resolved: Some(resolved),
1440                }
1441            }
1442            NamedDefinition::Literal(value) => NameResolution::Literal(value.clone()),
1443            NamedDefinition::Formula { ast, .. } => NameResolution::Formula {
1444                formula: formualizer_parse::pretty::canonical_formula(ast),
1445                value: self.graph.get_value(named.vertex),
1446            },
1447        }
1448    }
1449
1450    fn resolve_table_area(
1451        &self,
1452        key: CellKey,
1453        table_ref: &TableReference,
1454    ) -> Option<(String, String, RangeAddress)> {
1455        let metadata = self.table_metadata(&table_ref.name)?;
1456        let canonical_name = metadata.name.clone();
1457        let specifier = table_ref
1458            .specifier
1459            .as_ref()
1460            .map(ToString::to_string)
1461            .unwrap_or_default();
1462        let mut start_row = metadata.start_row;
1463        let mut end_row = metadata.end_row;
1464        let mut start_col = metadata.start_col;
1465        let mut end_col = metadata.end_col;
1466        let data_start = start_row + u32::from(metadata.header_row);
1467        let data_end = end_row.saturating_sub(u32::from(metadata.totals_row));
1468
1469        fn col_index(headers: &[String], name: &str) -> Option<u32> {
1470            headers
1471                .iter()
1472                .position(|header| header.eq_ignore_ascii_case(name))
1473                .and_then(|index| u32::try_from(index).ok())
1474        }
1475
1476        match table_ref.specifier.as_ref()? {
1477            TableSpecifier::All | TableSpecifier::SpecialItem(SpecialItem::All) => {}
1478            TableSpecifier::Data | TableSpecifier::SpecialItem(SpecialItem::Data) => {
1479                start_row = data_start;
1480                end_row = data_end;
1481            }
1482            TableSpecifier::Headers | TableSpecifier::SpecialItem(SpecialItem::Headers) => {
1483                if !metadata.header_row {
1484                    return None;
1485                }
1486                end_row = start_row;
1487            }
1488            TableSpecifier::Totals | TableSpecifier::SpecialItem(SpecialItem::Totals) => {
1489                if !metadata.totals_row {
1490                    return None;
1491                }
1492                start_row = end_row;
1493            }
1494            TableSpecifier::Column(name) => {
1495                let index = col_index(&metadata.headers, name)?;
1496                start_col += index;
1497                end_col = start_col;
1498                start_row = data_start;
1499                end_row = data_end;
1500            }
1501            TableSpecifier::ColumnRange(first, last) => {
1502                let mut first = col_index(&metadata.headers, first)?;
1503                let mut last = col_index(&metadata.headers, last)?;
1504                if first > last {
1505                    std::mem::swap(&mut first, &mut last);
1506                }
1507                start_col += first;
1508                end_col = metadata.start_col + last;
1509                start_row = data_start;
1510                end_row = data_end;
1511            }
1512            TableSpecifier::SpecialItem(SpecialItem::ThisRow)
1513            | TableSpecifier::Row(formualizer_parse::parser::TableRowSpecifier::Current) => {
1514                let row = key.row0 + 1;
1515                if row < data_start || row > data_end {
1516                    return None;
1517                }
1518                start_row = row;
1519                end_row = row;
1520            }
1521            TableSpecifier::Row(_) => return None,
1522            TableSpecifier::Combination(parts) => {
1523                let mut this_row = false;
1524                let mut selected_column: Option<(u32, u32)> = None;
1525                for part in parts {
1526                    match part.as_ref() {
1527                        TableSpecifier::SpecialItem(SpecialItem::ThisRow) => this_row = true,
1528                        TableSpecifier::Column(name) => {
1529                            let column = col_index(&metadata.headers, name)?;
1530                            selected_column = Some((column, column));
1531                        }
1532                        TableSpecifier::ColumnRange(first, last) => {
1533                            let mut first = col_index(&metadata.headers, first)?;
1534                            let mut last = col_index(&metadata.headers, last)?;
1535                            if first > last {
1536                                std::mem::swap(&mut first, &mut last);
1537                            }
1538                            selected_column = Some((first, last));
1539                        }
1540                        TableSpecifier::Data | TableSpecifier::SpecialItem(SpecialItem::Data) => {
1541                            start_row = data_start;
1542                            end_row = data_end;
1543                        }
1544                        TableSpecifier::Headers
1545                        | TableSpecifier::SpecialItem(SpecialItem::Headers) => {
1546                            if !metadata.header_row {
1547                                return None;
1548                            }
1549                            end_row = start_row;
1550                        }
1551                        TableSpecifier::Totals
1552                        | TableSpecifier::SpecialItem(SpecialItem::Totals) => {
1553                            if !metadata.totals_row {
1554                                return None;
1555                            }
1556                            start_row = end_row;
1557                        }
1558                        TableSpecifier::All | TableSpecifier::SpecialItem(SpecialItem::All) => {}
1559                        TableSpecifier::Row(_) | TableSpecifier::Combination(_) => return None,
1560                    }
1561                }
1562                if this_row {
1563                    let row = key.row0 + 1;
1564                    if row < data_start || row > data_end {
1565                        return None;
1566                    }
1567                    start_row = row;
1568                    end_row = row;
1569                }
1570                if let Some((first, last)) = selected_column {
1571                    start_col = metadata.start_col + first;
1572                    end_col = metadata.start_col + last;
1573                    if !this_row {
1574                        start_row = data_start;
1575                        end_row = data_end;
1576                    }
1577                }
1578            }
1579        }
1580        if start_row > end_row || start_col > end_col {
1581            return None;
1582        }
1583        Some((
1584            canonical_name,
1585            specifier,
1586            RangeAddress {
1587                sheet: metadata.sheet,
1588                start_row,
1589                start_col,
1590                end_row,
1591                end_col,
1592            },
1593        ))
1594    }
1595
1596    fn own_reference(
1597        &self,
1598        key: CellKey,
1599        reference: refs::SemanticReference<'_>,
1600    ) -> SemanticReference {
1601        match reference {
1602            refs::SemanticReference::Cell(cell) => {
1603                let sheet_id = cell
1604                    .sheet
1605                    .name()
1606                    .and_then(|name| self.graph.sheet_id(name))
1607                    .unwrap_or(key.sheet_id);
1608                SemanticReference::Cell(CellAddress {
1609                    sheet: self.graph.sheet_name(sheet_id).to_string(),
1610                    row: cell.row,
1611                    column: cell.col,
1612                })
1613            }
1614            refs::SemanticReference::FiniteRange(range)
1615            | refs::SemanticReference::OpenRange(range) => {
1616                let sheet_id = range
1617                    .sheet
1618                    .name()
1619                    .and_then(|name| self.graph.sheet_id(name))
1620                    .unwrap_or(key.sheet_id);
1621                let declared = RangeArea {
1622                    sheet: self.graph.sheet_name(sheet_id).to_string(),
1623                    start_row: range.start_row,
1624                    start_column: range.start_col,
1625                    end_row: range.end_row,
1626                    end_column: range.end_col,
1627                };
1628                let resolved = self.resolve_semantic_area(&declared);
1629                let cell_count = resolved.as_ref().map_or(0, |range| {
1630                    u64::from(range.width()) * u64::from(range.height())
1631                });
1632                SemanticReference::Range {
1633                    declared,
1634                    resolved,
1635                    cell_count,
1636                }
1637            }
1638            refs::SemanticReference::Name(name) => SemanticReference::Name {
1639                name: name.to_string(),
1640                resolution: self.resolve_name(key, name),
1641            },
1642            refs::SemanticReference::Table(table) => {
1643                if let Some((name, specifier, resolved)) = self.resolve_table_area(key, table) {
1644                    SemanticReference::Table {
1645                        name,
1646                        specifier,
1647                        resolved,
1648                    }
1649                } else {
1650                    SemanticReference::Unsupported {
1651                        text: ReferenceType::Table(table.clone()).to_string(),
1652                        reason: "structured reference could not be resolved at this placement"
1653                            .to_string(),
1654                    }
1655                }
1656            }
1657            refs::SemanticReference::ExternalSource(external) => SemanticReference::External {
1658                raw: external.raw.clone(),
1659            },
1660            refs::SemanticReference::ThreeDimensional(reference) => {
1661                SemanticReference::Unsupported {
1662                    text: reference.to_string(),
1663                    reason: "3D references are not supported by phase-1 introspection".to_string(),
1664                }
1665            }
1666            refs::SemanticReference::Unsupported(reference) => SemanticReference::Unsupported {
1667                text: reference.to_string(),
1668                reason: "reference form is unsupported by introspection".to_string(),
1669            },
1670        }
1671    }
1672
1673    fn collect_precedents(
1674        &self,
1675        key: CellKey,
1676        max_links: u32,
1677        work: &mut WorkBudget,
1678    ) -> Result<(Vec<Precedent>, TruncationReport), InspectError> {
1679        struct Collector<'a, R> {
1680            engine: &'a Engine<R>,
1681            key: CellKey,
1682            max_links: usize,
1683            work: &'a mut WorkBudget,
1684            precedents: Vec<Precedent>,
1685            truncated: bool,
1686        }
1687        impl<R: EvaluationContext> ReferenceVisitor for Collector<'_, R> {
1688            fn visit(&mut self, reference: refs::SemanticReference<'_>) -> bool {
1689                if !self.work.charge() {
1690                    self.truncated = true;
1691                    return false;
1692                }
1693                let reference = self.engine.own_reference(self.key, reference);
1694                if self
1695                    .precedents
1696                    .iter()
1697                    .any(|existing| existing.reference == reference)
1698                {
1699                    return true;
1700                }
1701                if self.precedents.len() >= self.max_links {
1702                    self.truncated = true;
1703                    return false;
1704                }
1705                self.precedents.push(Precedent {
1706                    reference,
1707                    provenance: Provenance::Declared,
1708                });
1709                true
1710            }
1711        }
1712
1713        let source = self.inspect_source();
1714        let mut collector = Collector {
1715            engine: self,
1716            key,
1717            max_links: max_links as usize,
1718            work,
1719            precedents: Vec::new(),
1720            truncated: false,
1721        };
1722        source.visit_declared_references(key, &mut collector)?;
1723        let truncation = if collector.truncated {
1724            TruncationReport {
1725                incomplete: true,
1726                omitted: Some(OmittedCount::AtLeast(1)),
1727            }
1728        } else {
1729            TruncationReport::default()
1730        };
1731        Ok((collector.precedents, truncation))
1732    }
1733
1734    /// Return source-ordered, first-occurrence-deduplicated declared formula
1735    /// references for a cell.
1736    pub fn precedents(
1737        &self,
1738        cell: &CellAddress,
1739        options: &PrecedentOptions,
1740    ) -> Result<PrecedentReport, InspectError> {
1741        let (key, canonical) = self.canonical_cell(cell)?;
1742        let mut work = WorkBudget::new(options.max_work);
1743        let (precedents, truncation) =
1744            self.collect_precedents(key, options.max_links, &mut work)?;
1745        Ok(PrecedentReport {
1746            stamp: self.inspect_stamp(),
1747            cell: canonical,
1748            precedents,
1749            truncation,
1750        })
1751    }
1752
1753    fn dependency_state_available(&self) -> Result<(), InspectError> {
1754        if self.has_staged_formulas() {
1755            Err(InspectError::DependencyStateUnavailable {
1756                reason: InspectionUnavailableReason::DeferredDependencyGraph,
1757            })
1758        } else {
1759            Ok(())
1760        }
1761    }
1762
1763    fn spill_query_members(&self, key: CellKey) -> Vec<CellKey> {
1764        let source = self.inspect_source();
1765        let Some(InternalSpillRole::Anchor { .. }) = source.spill_role(key) else {
1766            return vec![key];
1767        };
1768        let cell_ref = CellRef::new(key.sheet_id, Coord::new(key.row0, key.col0, true, true));
1769        let Some(vertex) = self.graph.get_vertex_for_cell(&cell_ref) else {
1770            return vec![key];
1771        };
1772        self.graph
1773            .spill_cells_for_anchor(vertex)
1774            .unwrap_or(&[])
1775            .iter()
1776            .map(|member| CellKey {
1777                sheet_id: member.sheet_id,
1778                row0: member.coord.row(),
1779                col0: member.coord.col(),
1780            })
1781            .collect()
1782    }
1783
1784    fn collect_dependents(
1785        &self,
1786        key: CellKey,
1787        max_results: u32,
1788        work: &mut WorkBudget,
1789    ) -> Result<(Vec<Dependent>, TruncationReport), InspectError> {
1790        self.dependency_state_available()?;
1791        let source = self.inspect_source();
1792        let mut found: FxHashMap<CellAddress, Vec<CellAddress>> = FxHashMap::default();
1793        let mut incomplete = false;
1794        let max_results = max_results as usize;
1795
1796        let spill_anchor_query = matches!(
1797            source.spill_role(key),
1798            Some(InternalSpillRole::Anchor { .. })
1799        );
1800        let members = self.spill_query_members(key);
1801        for member in members {
1802            if !work.charge() {
1803                incomplete = true;
1804                break;
1805            }
1806            let via = self.address_for_key(member);
1807            let mut record = |dependent_key: CellKey| {
1808                let address = self.address_for_key(dependent_key);
1809                if let Some(via_members) = found.get_mut(&address) {
1810                    if spill_anchor_query && !via_members.contains(&via) {
1811                        via_members.push(via.clone());
1812                    }
1813                    return true;
1814                }
1815                found.insert(
1816                    address,
1817                    if spill_anchor_query {
1818                        vec![via.clone()]
1819                    } else {
1820                        Vec::new()
1821                    },
1822                );
1823                true
1824            };
1825
1826            let member_ref = CellRef::new(
1827                member.sheet_id,
1828                Coord::new(member.row0, member.col0, true, true),
1829            );
1830            if let Some(vertex) = self.graph.get_vertex_for_cell(&member_ref) {
1831                let complete = self.graph.visit_direct_dependents_bounded(
1832                    vertex,
1833                    &mut work.remaining,
1834                    &mut |dependent| self.key_for_vertex(dependent).is_none_or(&mut record),
1835                );
1836                if !complete {
1837                    incomplete = true;
1838                    break;
1839                }
1840            }
1841
1842            struct Visitor<'a, F>(&'a mut F);
1843            impl<F: FnMut(CellKey) -> bool> DependentVisitor for Visitor<'_, F> {
1844                fn visit(&mut self, dependent: CellKey) -> bool {
1845                    (self.0)(dependent)
1846                }
1847            }
1848            let mut visitor = Visitor(&mut record);
1849            if source.visit_dependents_covering(member, work, &mut visitor)?
1850                == QueryCompleteness::Incomplete
1851            {
1852                incomplete = true;
1853                break;
1854            }
1855        }
1856
1857        let mut dependents: Vec<_> = found
1858            .into_iter()
1859            .map(|(cell, mut via)| {
1860                via.sort_by(address_cmp);
1861                via.dedup();
1862                Dependent { cell, via }
1863            })
1864            .collect();
1865        dependents.sort_by(|left, right| address_cmp(&left.cell, &right.cell));
1866        let known_omitted_dependent = dependents.len() > max_results;
1867        if known_omitted_dependent {
1868            dependents.truncate(max_results);
1869            incomplete = true;
1870        }
1871        Ok((
1872            dependents,
1873            if incomplete {
1874                TruncationReport {
1875                    incomplete: true,
1876                    omitted: known_omitted_dependent.then_some(OmittedCount::AtLeast(1)),
1877                }
1878            } else {
1879                TruncationReport::default()
1880            },
1881        ))
1882    }
1883
1884    /// Return direct and compressed-range readers of a cell. Discovery is
1885    /// bounded before candidate materialization.
1886    pub fn dependents(
1887        &self,
1888        cell: &CellAddress,
1889        options: &DependentsOptions,
1890    ) -> Result<DependentsReport, InspectError> {
1891        let (key, canonical) = self.canonical_cell(cell)?;
1892        let mut work = WorkBudget::new(options.max_work);
1893        let (dependents, truncation) =
1894            self.collect_dependents(key, options.max_results, &mut work)?;
1895        Ok(DependentsReport {
1896            stamp: self.inspect_stamp(),
1897            cell: canonical,
1898            dependents,
1899            truncation,
1900        })
1901    }
1902
1903    fn target_addresses(reference: &SemanticReference) -> Option<&RangeAddress> {
1904        match reference {
1905            SemanticReference::Range { resolved, .. } => resolved.as_ref(),
1906            SemanticReference::Name {
1907                resolution: NameResolution::Range { resolved, .. },
1908                ..
1909            } => resolved.as_ref(),
1910            SemanticReference::Table { resolved, .. } => Some(resolved),
1911            _ => None,
1912        }
1913    }
1914
1915    fn target_cell(reference: &SemanticReference) -> Option<&CellAddress> {
1916        match reference {
1917            SemanticReference::Cell(cell) => Some(cell),
1918            SemanticReference::Name {
1919                resolution: NameResolution::Cell(cell),
1920                ..
1921            } => Some(cell),
1922            _ => None,
1923        }
1924    }
1925
1926    /// Classify cycles from the completed materialized graph, independent of
1927    /// BFS discovery order. An edge `source -> target` is a cycle edge exactly
1928    /// when `target` can reach `source` through materialized links.
1929    fn classify_cycle_dispositions(nodes: &mut [TraceNode]) {
1930        let adjacency: Vec<Vec<usize>> = nodes
1931            .iter()
1932            .map(|node| {
1933                node.links
1934                    .iter()
1935                    .flat_map(|link| link.targets.iter())
1936                    .map(|target| target.node.0 as usize)
1937                    .collect()
1938            })
1939            .collect();
1940        let mut reachability = vec![vec![false; nodes.len()]; nodes.len()];
1941        for start in 0..nodes.len() {
1942            let mut stack = adjacency[start].clone();
1943            while let Some(next) = stack.pop() {
1944                if reachability[start][next] {
1945                    continue;
1946                }
1947                reachability[start][next] = true;
1948                stack.extend(adjacency[next].iter().copied());
1949            }
1950        }
1951
1952        for (source, node) in nodes.iter_mut().enumerate() {
1953            for target in node
1954                .links
1955                .iter_mut()
1956                .flat_map(|link| link.targets.iter_mut())
1957            {
1958                let target_index = target.node.0 as usize;
1959                if source == target_index || reachability[target_index][source] {
1960                    target.disposition = LinkDisposition::Cycle;
1961                } else if target.disposition == LinkDisposition::Cycle {
1962                    target.disposition = LinkDisposition::Convergent;
1963                }
1964            }
1965        }
1966    }
1967
1968    /// Build a bounded response-local BFS DAG.
1969    pub fn trace(
1970        &self,
1971        roots: &[CellAddress],
1972        options: &TraceOptions,
1973    ) -> Result<TraceGraph, InspectError> {
1974        if roots.is_empty() {
1975            return Err(InspectError::InvalidOptions {
1976                message: "trace requires at least one root".to_string(),
1977            });
1978        }
1979        let canonical_roots: Vec<_> = roots
1980            .iter()
1981            .map(|root| self.canonical_cell(root))
1982            .collect::<Result<_, _>>()?;
1983        let mut unique_roots = FxHashMap::default();
1984        for (key, canonical) in &canonical_roots {
1985            unique_roots.entry(canonical.clone()).or_insert(*key);
1986        }
1987        if unique_roots.len() > options.max_nodes as usize {
1988            return Err(InspectError::InvalidOptions {
1989                message: format!(
1990                    "max_nodes ({}) must hold all {} unique roots",
1991                    options.max_nodes,
1992                    unique_roots.len()
1993                ),
1994            });
1995        }
1996        if options.direction == TraceDirection::Dependents {
1997            self.dependency_state_available()?;
1998        }
1999
2000        let mut nodes = Vec::new();
2001        let mut node_by_address: FxHashMap<CellAddress, TraceNodeId> = FxHashMap::default();
2002        let mut root_ids = Vec::new();
2003        let mut queue = VecDeque::new();
2004        let mut parents: HashMap<TraceNodeId, Option<TraceNodeId>> = HashMap::new();
2005        let mut truncation = TruncationReport::default();
2006
2007        // Admit every unique request root before expansion so `roots[i]`
2008        // always corresponds to the caller's `roots[i]`.
2009        for (key, canonical) in canonical_roots {
2010            if let Some(&id) = node_by_address.get(&canonical) {
2011                root_ids.push(id);
2012                continue;
2013            }
2014            let id = TraceNodeId(nodes.len() as u32);
2015            nodes.push(TraceNode {
2016                id,
2017                cell: self.snapshot_for_key(key, options.include_values)?,
2018                links: Vec::new(),
2019            });
2020            node_by_address.insert(canonical, id);
2021            root_ids.push(id);
2022            queue.push_back((id, key, 0u32));
2023            parents.insert(id, None);
2024        }
2025
2026        let mut work = WorkBudget::new(options.max_work);
2027        let mut links_used = 0u32;
2028        let mut range_members_used = 0u32;
2029
2030        while let Some((source_id, source_key, depth)) = queue.pop_front() {
2031            let can_follow = depth < options.max_depth;
2032            let mut links = Vec::new();
2033            if options.direction == TraceDirection::Precedents {
2034                if let Some(InternalSpillRole::Member { anchor }) =
2035                    self.inspect_source().spill_role(source_key)
2036                {
2037                    if links_used < options.max_links {
2038                        links_used += 1;
2039                        let anchor_address = self.address_for_key(anchor);
2040                        let mut link = TraceLink {
2041                            reference: SemanticReference::Cell(anchor_address.clone()),
2042                            kind: TraceLinkKind::SpillAnchor,
2043                            targets: Vec::new(),
2044                            omitted: None,
2045                        };
2046                        self.attach_cell_target(
2047                            anchor_address,
2048                            source_id,
2049                            anchor,
2050                            can_follow,
2051                            depth,
2052                            options,
2053                            false,
2054                            &mut nodes,
2055                            &mut node_by_address,
2056                            &mut parents,
2057                            &mut queue,
2058                            &mut link,
2059                            &mut truncation,
2060                        )?;
2061                        links.push(link);
2062                    } else {
2063                        truncation.incomplete = true;
2064                        merge_omitted(&mut truncation.omitted, OmittedCount::AtLeast(1));
2065                    }
2066                } else {
2067                    let available_links = options.max_links.saturating_sub(links_used);
2068                    let (precedents, local_truncation) =
2069                        self.collect_precedents(source_key, available_links, &mut work)?;
2070                    if local_truncation.incomplete {
2071                        truncation.incomplete = true;
2072                        if let Some(omitted) = local_truncation.omitted {
2073                            merge_omitted(&mut truncation.omitted, omitted);
2074                        }
2075                    }
2076                    for precedent in precedents {
2077                        links_used += 1;
2078                        let mut link = TraceLink {
2079                            reference: precedent.reference,
2080                            kind: TraceLinkKind::Formula {
2081                                provenance: precedent.provenance,
2082                            },
2083                            targets: Vec::new(),
2084                            omitted: None,
2085                        };
2086                        if let Some(cell) = Self::target_cell(&link.reference).cloned() {
2087                            let (target_key, canonical) = self.canonical_cell(&cell)?;
2088                            self.attach_cell_target(
2089                                canonical,
2090                                source_id,
2091                                target_key,
2092                                can_follow,
2093                                depth,
2094                                options,
2095                                false,
2096                                &mut nodes,
2097                                &mut node_by_address,
2098                                &mut parents,
2099                                &mut queue,
2100                                &mut link,
2101                                &mut truncation,
2102                            )?;
2103                        } else if let Some(range) = Self::target_addresses(&link.reference).cloned()
2104                        {
2105                            self.attach_range_targets(
2106                                &range,
2107                                source_id,
2108                                can_follow,
2109                                depth,
2110                                options,
2111                                &mut range_members_used,
2112                                &mut nodes,
2113                                &mut node_by_address,
2114                                &mut parents,
2115                                &mut queue,
2116                                &mut link,
2117                                &mut truncation,
2118                            )?;
2119                        }
2120                        links.push(link);
2121                    }
2122                }
2123            } else {
2124                let available = options.max_links.saturating_sub(links_used);
2125                let (dependents, local_truncation) =
2126                    self.collect_dependents(source_key, available, &mut work)?;
2127                if local_truncation.incomplete {
2128                    truncation.incomplete = true;
2129                    if let Some(omitted) = local_truncation.omitted {
2130                        merge_omitted(&mut truncation.omitted, omitted);
2131                    }
2132                }
2133                for dependent in dependents {
2134                    links_used += 1;
2135                    let spill_reader = dependent
2136                        .via
2137                        .iter()
2138                        .any(|via| via != &self.address_for_key(source_key));
2139                    let (target_key, canonical) = self.canonical_cell(&dependent.cell)?;
2140                    let mut link = TraceLink {
2141                        reference: SemanticReference::Cell(canonical.clone()),
2142                        kind: if spill_reader {
2143                            TraceLinkKind::SpillReader
2144                        } else {
2145                            TraceLinkKind::Formula {
2146                                provenance: Provenance::Declared,
2147                            }
2148                        },
2149                        targets: Vec::new(),
2150                        omitted: None,
2151                    };
2152                    self.attach_cell_target(
2153                        canonical,
2154                        source_id,
2155                        target_key,
2156                        can_follow,
2157                        depth,
2158                        options,
2159                        false,
2160                        &mut nodes,
2161                        &mut node_by_address,
2162                        &mut parents,
2163                        &mut queue,
2164                        &mut link,
2165                        &mut truncation,
2166                    )?;
2167                    links.push(link);
2168                }
2169            }
2170            nodes[source_id.0 as usize].links = links;
2171        }
2172
2173        Self::classify_cycle_dispositions(&mut nodes);
2174
2175        Ok(TraceGraph {
2176            stamp: self.inspect_stamp(),
2177            direction: options.direction,
2178            roots: root_ids,
2179            nodes,
2180            truncation,
2181        })
2182    }
2183
2184    #[allow(clippy::too_many_arguments)]
2185    fn attach_cell_target(
2186        &self,
2187        address: CellAddress,
2188        source_id: TraceNodeId,
2189        key: CellKey,
2190        can_follow: bool,
2191        depth: u32,
2192        options: &TraceOptions,
2193        defer_missing_omission: bool,
2194        nodes: &mut Vec<TraceNode>,
2195        node_by_address: &mut FxHashMap<CellAddress, TraceNodeId>,
2196        parents: &mut HashMap<TraceNodeId, Option<TraceNodeId>>,
2197        queue: &mut VecDeque<(TraceNodeId, CellKey, u32)>,
2198        link: &mut TraceLink,
2199        truncation: &mut TruncationReport,
2200    ) -> Result<(), InspectError> {
2201        if let Some(&target_id) = node_by_address.get(&address) {
2202            link.targets.push(TraceLinkTarget {
2203                node: target_id,
2204                // The completed-graph reachability post-pass resolves Cycle
2205                // versus Convergent without depending on the BFS parent tree.
2206                disposition: LinkDisposition::Convergent,
2207            });
2208            return Ok(());
2209        }
2210        if nodes.len() >= options.max_nodes as usize {
2211            link.omitted = Some(OmittedCount::AtLeast(1));
2212            truncation.incomplete = true;
2213            if !defer_missing_omission {
2214                merge_omitted(&mut truncation.omitted, OmittedCount::AtLeast(1));
2215            }
2216            return Ok(());
2217        }
2218        let target_id = TraceNodeId(nodes.len() as u32);
2219        nodes.push(TraceNode {
2220            id: target_id,
2221            cell: self.snapshot_for_key(key, options.include_values)?,
2222            links: Vec::new(),
2223        });
2224        node_by_address.insert(address, target_id);
2225        parents.insert(target_id, Some(source_id));
2226        link.targets.push(TraceLinkTarget {
2227            node: target_id,
2228            disposition: if can_follow {
2229                LinkDisposition::Expanded
2230            } else {
2231                LinkDisposition::Elided
2232            },
2233        });
2234        if can_follow {
2235            queue.push_back((target_id, key, depth + 1));
2236        } else {
2237            // The target is represented, but its unvisited outgoing links are
2238            // unknown without doing the depth-elided work.
2239            truncation.incomplete = true;
2240        }
2241        Ok(())
2242    }
2243
2244    #[allow(clippy::too_many_arguments)]
2245    fn attach_range_targets(
2246        &self,
2247        range: &RangeAddress,
2248        source_id: TraceNodeId,
2249        can_follow: bool,
2250        depth: u32,
2251        options: &TraceOptions,
2252        range_members_used: &mut u32,
2253        nodes: &mut Vec<TraceNode>,
2254        node_by_address: &mut FxHashMap<CellAddress, TraceNodeId>,
2255        parents: &mut HashMap<TraceNodeId, Option<TraceNodeId>>,
2256        queue: &mut VecDeque<(TraceNodeId, CellKey, u32)>,
2257        link: &mut TraceLink,
2258        truncation: &mut TruncationReport,
2259    ) -> Result<(), InspectError> {
2260        let total = u64::from(range.width()) * u64::from(range.height());
2261        let mut attached = 0u64;
2262        let mut compressed_ancestors = Vec::new();
2263        let mut cursor = Some(source_id);
2264        while let Some(ancestor) = cursor {
2265            let address = &nodes[ancestor.0 as usize].cell.address;
2266            if range.sheet == address.sheet
2267                && range.start_row <= address.row
2268                && address.row <= range.end_row
2269                && range.start_col <= address.column
2270                && address.column <= range.end_col
2271            {
2272                compressed_ancestors.push((ancestor, address.row, address.column));
2273            }
2274            cursor = parents.get(&ancestor).copied().flatten();
2275        }
2276
2277        // Preserve compressed cycle semantics even if the global member budget
2278        // is exhausted or an ancestor occurs late in a large row-major range.
2279        for (ancestor, _, _) in &compressed_ancestors {
2280            link.targets.push(TraceLinkTarget {
2281                node: *ancestor,
2282                // The completed-graph reachability post-pass resolves this
2283                // provisional revisit disposition.
2284                disposition: LinkDisposition::Convergent,
2285            });
2286            attached += 1;
2287        }
2288
2289        'rows: for row in range.start_row..=range.end_row {
2290            for column in range.start_col..=range.end_col {
2291                if compressed_ancestors
2292                    .iter()
2293                    .any(|(_, ancestor_row, ancestor_column)| {
2294                        row == *ancestor_row && column == *ancestor_column
2295                    })
2296                {
2297                    continue;
2298                }
2299                if *range_members_used >= options.range_member_budget {
2300                    break 'rows;
2301                }
2302                *range_members_used += 1;
2303                let address = CellAddress {
2304                    sheet: range.sheet.clone(),
2305                    row,
2306                    column,
2307                };
2308                let (key, canonical) = self.canonical_cell(&address)?;
2309                let before = link.targets.len();
2310                self.attach_cell_target(
2311                    canonical,
2312                    source_id,
2313                    key,
2314                    can_follow,
2315                    depth,
2316                    options,
2317                    true,
2318                    nodes,
2319                    node_by_address,
2320                    parents,
2321                    queue,
2322                    link,
2323                    truncation,
2324                )?;
2325                if link.targets.len() > before {
2326                    attached += 1;
2327                } else if link.omitted.is_some() {
2328                    break 'rows;
2329                }
2330            }
2331        }
2332        if attached < total {
2333            let omitted = OmittedCount::Exact(total - attached);
2334            link.omitted = Some(omitted);
2335            truncation.incomplete = true;
2336            merge_omitted(&mut truncation.omitted, omitted);
2337        }
2338        Ok(())
2339    }
2340
2341    /// Return one row-major owned page over the semantic finite extent.
2342    pub fn range_page(
2343        &self,
2344        area: &RangeArea,
2345        options: &RangePageOptions,
2346    ) -> Result<RangePage, InspectError> {
2347        if options.limit == 0 {
2348            return Err(InspectError::InvalidOptions {
2349                message: "range page limit must be at least one".to_string(),
2350            });
2351        }
2352        let stamp = self.inspect_stamp();
2353        if let Some(expected) = options.expected_stamp
2354            && expected != stamp
2355        {
2356            return Err(InspectError::RevisionMismatch {
2357                expected,
2358                actual: stamp,
2359            });
2360        }
2361        let (_, declared) = self.canonical_area(area)?;
2362        let resolved = self.resolve_semantic_area(&declared);
2363        let total = resolved.as_ref().map_or(0, |range| {
2364            u64::from(range.width()) * u64::from(range.height())
2365        });
2366        let start = options.offset.min(total);
2367        let end = start.saturating_add(u64::from(options.limit)).min(total);
2368        let mut items = Vec::new();
2369        items
2370            .try_reserve((end - start) as usize)
2371            .map_err(|_| InspectError::ResourceExhausted {
2372                resource: "range page items",
2373            })?;
2374        if let Some(range) = &resolved {
2375            let width = u64::from(range.width());
2376            for offset in start..end {
2377                let row = range.start_row + u32::try_from(offset / width).unwrap_or(u32::MAX);
2378                let column = range.start_col + u32::try_from(offset % width).unwrap_or(u32::MAX);
2379                let (key, _) = self.canonical_cell(&CellAddress {
2380                    sheet: range.sheet.clone(),
2381                    row,
2382                    column,
2383                })?;
2384                items.push(self.snapshot_for_key(key, options.include_values)?);
2385            }
2386        }
2387        Ok(RangePage {
2388            stamp,
2389            declared,
2390            resolved,
2391            total,
2392            offset: options.offset,
2393            items,
2394            next_offset: (end < total).then_some(end),
2395        })
2396    }
2397}