Skip to main content

sqry_core/graph/unified/bind/
plane.rs

1//! `BindingPlane<'g>` — the Phase 2 facade that wraps a `GraphSnapshot` and
2//! provides witness-bearing resolution, scope/alias/shadow accessors, and
3//! the `explain()` renderer.
4//!
5//! This is the primary Phase 2 public API. Phases 3-6 (derived analysis DB,
6//! query planner, rule layer, provenance/history) consume it as the stable
7//! binding-plane surface.
8//!
9//! # Canonical emission-path conventions (`resolve_shared` contract)
10//!
11//! The step vocabulary has two pairs of overlapping variants. When emitting
12//! steps inside `resolve_shared`, use the following canonical paths:
13//!
14//! **Visibility rejections** — emit `FilterByVisibility { candidate, reason }`
15//! (preferred). The `Rejected { reason: RejectionReason::PrivateVisibility }`
16//! variant is reserved for non-visibility-specific rejection contexts where the
17//! caller does not have a `VisibilityReason` value (e.g., generic catch-all
18//! rejection paths in future P2U work). Never emit both for the same candidate.
19//!
20//! **Shadow rejections** — emit `ShadowedBy { outer, inner, by_node }` when
21//! the resolver detects that an outer binding is superseded by an inner
22//! binding (the structured form). The `Rejected { reason: RejectionReason::Shadowed }`
23//! variant is for catch-all rejection contexts that do not have scope-pair
24//! information available. Prefer `ShadowedBy` whenever both scopes are known.
25//!
26//! These conventions exist so that downstream consumers (P2U09 T19, P2U10
27//! CLI `--explain`, Phases 3-6 rule layer) can match step variants
28//! deterministically without guessing which emission path was used.
29
30use crate::graph::unified::concurrent::GraphSnapshot;
31use crate::graph::unified::node::id::NodeId;
32use crate::graph::unified::resolution::{SymbolQuery, SymbolResolutionWitness};
33
34use super::alias::AliasEntry;
35use super::scope::provenance::{ScopeProvenance, ScopeStableId};
36use super::scope::tree::scope_chain;
37use super::scope::{Scope, ScopeId};
38use super::shadow::ShadowEntry;
39use super::witness::render::{WitnessRendering, render_witness};
40use super::witness::step::ResolutionStep;
41use super::{BindingResult, ResolvedBinding, SymbolClassification, classify_node};
42use crate::graph::unified::resolution::{SymbolCandidateBucket, SymbolResolutionOutcome};
43use crate::graph::unified::string::id::StringId;
44
45/// Combined resolution result: the existing `BindingResult` alongside the
46/// witness with its ordered step trace.
47///
48/// `BindingResolution` is the primary return type of [`BindingPlane::resolve`].
49/// Callers that only need the `BindingResult` can access `resolution.result`;
50/// callers that need the step trace can walk `resolution.witness.steps`.
51///
52/// Note: `BindingResolution` does not implement `Serialize`/`Deserialize`
53/// because `SymbolResolutionWitness` does not implement those traits (it
54/// contains `Vec<ResolutionStep>` which is Serialize, but the outer struct is
55/// not annotated). Use `result` for serializable output, or `witness.steps`
56/// for the step trace.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct BindingResolution {
59    /// The resolution result (bindings, outcome, normalized query).
60    pub result: BindingResult,
61    /// Ordered step trace from the resolver, including bucket probes,
62    /// scope entries, alias follows, shadow detections, and the final
63    /// `Chose` or `Unresolved` terminal.
64    pub witness: SymbolResolutionWitness,
65}
66
67/// Short-lived facade borrowing a `GraphSnapshot`.
68///
69/// Construct via [`GraphSnapshot::binding_plane`]. The facade provides the
70/// stable Phase 2 public API:
71///
72/// - [`BindingPlane::resolve`] — witness-bearing resolution entry point
73/// - [`BindingPlane::explain`] — renders a `BindingResolution` as text + JSON
74/// - Scope accessors: [`scope_of`], [`scope_chain`], [`scope`],
75///   [`scope_by_stable_id`], [`scope_provenance`]
76/// - Alias accessors: [`aliases_in`], [`resolve_alias`]
77/// - Shadow accessors: [`shadows_in`], [`effective_binding`]
78///
79/// # Usage from `CodeGraph`
80///
81/// ```rust,ignore
82/// // Two-line MVCC-safe pattern for CodeGraph callers:
83/// let snapshot = graph.snapshot();
84/// let plane = snapshot.binding_plane();
85/// let resolution = plane.resolve(&query);
86/// ```
87///
88/// # Usage from `ConcurrentCodeGraph`
89///
90/// ```rust,ignore
91/// // Three-line pattern for ConcurrentCodeGraph callers:
92/// let read_guard = concurrent.read();
93/// let snapshot = read_guard.snapshot();
94/// let plane = snapshot.binding_plane();
95/// let resolution = plane.resolve(&query);
96/// ```
97///
98/// The two/three-line idiom is intentional: `BindingPlane<'g>` borrows from a
99/// `GraphSnapshot` and the explicit snapshot handle makes the MVCC lifetime
100/// visible to the caller.
101pub struct BindingPlane<'g> {
102    snapshot: &'g GraphSnapshot,
103}
104
105impl<'g> BindingPlane<'g> {
106    /// Creates a new `BindingPlane` borrowing the given snapshot.
107    ///
108    /// Prefer `GraphSnapshot::binding_plane()` over calling this directly.
109    #[inline]
110    #[must_use]
111    pub fn new(snapshot: &'g GraphSnapshot) -> Self {
112        Self { snapshot }
113    }
114
115    /// Primary entry point — performs witness-bearing resolution and returns
116    /// the combined `BindingResolution` with both the `BindingResult` and the
117    /// ordered step trace.
118    ///
119    /// See module-level doc for the canonical emission-path conventions that
120    /// govern which step variants are emitted for visibility and shadow events.
121    #[must_use]
122    pub fn resolve(&self, query: &SymbolQuery<'_>) -> BindingResolution {
123        resolve_shared(query, self.snapshot)
124    }
125
126    /// Classifies a node as declaration / reference / import / ambiguous.
127    ///
128    /// Returns [`SymbolClassification::Unknown`] if `node_id` is not present
129    /// in the snapshot.
130    #[must_use]
131    pub fn classify(&self, node_id: NodeId) -> SymbolClassification {
132        let entry = match self.snapshot.get_node(node_id) {
133            Some(e) => e,
134            None => return SymbolClassification::Unknown,
135        };
136        classify_node(self.snapshot, node_id, entry.kind)
137    }
138
139    // ------------------------------------------------------------------
140    // Scope accessors
141    // ------------------------------------------------------------------
142
143    /// Returns the `ScopeId` of the scope whose `node` field matches
144    /// `node_id`, or `None` if no such scope is allocated.
145    ///
146    /// Uses `ScopeArena::iter()` for correct generational-index handling.
147    #[must_use]
148    pub fn scope_of(&self, node_id: NodeId) -> Option<ScopeId> {
149        self.snapshot
150            .scope_arena()
151            .iter()
152            .find(|(_, scope)| scope.node == node_id)
153            .map(|(id, _)| id)
154    }
155
156    /// Returns the scope chain for `scope_id` in innermost-first order.
157    ///
158    /// Returns an empty `Vec` if `scope_id` is `ScopeId::INVALID` or is not
159    /// present in the arena.
160    #[must_use]
161    pub fn scope_chain(&self, scope_id: ScopeId) -> Vec<ScopeId> {
162        scope_chain(self.snapshot.scope_arena(), scope_id)
163    }
164
165    /// Returns a reference to the `Scope` record for `scope_id`, or `None` if
166    /// the handle is invalid or stale.
167    #[must_use]
168    pub fn scope(&self, scope_id: ScopeId) -> Option<&Scope> {
169        self.snapshot.scope_arena().get(scope_id)
170    }
171
172    /// Looks up the live `ScopeId` for a stable scope identity.
173    ///
174    /// Returns `None` if no provenance record is registered for `stable`.
175    #[must_use]
176    pub fn scope_by_stable_id(&self, stable: ScopeStableId) -> Option<ScopeId> {
177        self.snapshot.scope_by_stable_id(stable)
178    }
179
180    /// Looks up the `ScopeProvenance` record for `scope_id`.
181    ///
182    /// Returns `None` if `scope_id` is invalid, stale, or has no provenance
183    /// record in the store.
184    #[must_use]
185    pub fn scope_provenance(&self, scope_id: ScopeId) -> Option<&ScopeProvenance> {
186        self.snapshot.scope_provenance(scope_id)
187    }
188
189    // ------------------------------------------------------------------
190    // Alias accessors
191    // ------------------------------------------------------------------
192
193    /// Returns all alias entries registered for `scope_id`.
194    ///
195    /// Returns an empty slice if `scope_id` has no entries in the alias table.
196    #[must_use]
197    pub fn aliases_in(&self, scope_id: ScopeId) -> &[AliasEntry] {
198        self.snapshot.alias_table().aliases_in(scope_id)
199    }
200
201    /// Resolves an alias for `symbol` in `scope_id`, returning the canonical
202    /// target symbol `StringId` if one is registered.
203    ///
204    /// Returns `None` if no alias is registered for `(scope_id, symbol)`.
205    #[must_use]
206    pub fn resolve_alias(&self, scope_id: ScopeId, symbol: StringId) -> Option<StringId> {
207        self.snapshot.alias_table().resolve_alias(scope_id, symbol)
208    }
209
210    // ------------------------------------------------------------------
211    // Shadow accessors
212    // ------------------------------------------------------------------
213
214    /// Returns all shadow entries registered for `scope_id`, sorted by byte
215    /// offset (ascending).
216    #[must_use]
217    pub fn shadows_in(&self, scope_id: ScopeId) -> Vec<&ShadowEntry> {
218        self.snapshot.shadow_table().shadows_in(scope_id)
219    }
220
221    /// Returns the effective binding for `symbol` in `scope_id` at
222    /// `byte_offset` — i.e., the innermost re-binding strictly before that
223    /// offset.
224    ///
225    /// Returns `None` if no binding for `(scope_id, symbol)` is in scope at
226    /// `byte_offset`.
227    #[must_use]
228    pub fn effective_binding(
229        &self,
230        scope_id: ScopeId,
231        symbol: StringId,
232        byte_offset: u32,
233    ) -> Option<NodeId> {
234        self.snapshot
235            .shadow_table()
236            .effective_binding(scope_id, symbol, byte_offset)
237    }
238
239    // ------------------------------------------------------------------
240    // Explain
241    // ------------------------------------------------------------------
242
243    /// Renders a `BindingResolution` as a human-readable numbered step list
244    /// plus a deterministic JSON value.
245    ///
246    /// The JSON shape is the stable external contract for the CLI `--explain`
247    /// output produced in P2U10. Changes to the JSON keys/structure are a
248    /// breaking public-API change.
249    #[must_use]
250    pub fn explain(&self, resolution: &BindingResolution) -> WitnessRendering {
251        render_witness(&resolution.witness)
252    }
253}
254
255// ---------------------------------------------------------------------------
256// resolve_shared — the shared implementation core
257// ---------------------------------------------------------------------------
258
259/// Shared helper extracted from the pre-P2U07 `BindingQuery::resolve()` body.
260///
261/// Called by both `BindingQuery::resolve()` (which returns
262/// `BindingResolution.result`) and `BindingPlane::resolve()` (which returns
263/// the full `BindingResolution`). This is the drift-proof contract that
264/// preserves `BindingQuery::resolve()`'s byte-equal output on its existing
265/// call sites — both public entry points delegate to the same code path.
266///
267/// # Canonical emission-path conventions
268///
269/// The step trace inside `witness.steps` uses these canonical paths:
270///
271/// - **Visibility rejections**: prefer `ResolutionStep::FilterByVisibility {
272///   candidate, reason }`. Use `Rejected { reason: PrivateVisibility }` only
273///   in catch-all contexts where no `VisibilityReason` is available.
274///
275/// - **Shadow rejections**: prefer `ResolutionStep::ShadowedBy { outer,
276///   inner, by_node }` when both enclosing scopes are known. Use `Rejected {
277///   reason: Shadowed }` only in catch-all contexts where scope-pair
278///   information is absent.
279///
280/// # Step emission
281///
282/// The step trace documents the resolver's internal work:
283/// 1. `ApplyResolutionMode` — the caller-supplied mode
284/// 2. `LookupInBucket` — for each bucket probed (ExactQualified,
285///    ExactSimple, CanonicalSuffix)
286/// 3. `ConsiderCandidate` — for each candidate in the winning bucket
287/// 4. Terminal: `Chose` (single winner), `Ambiguous` (multiple), or
288///    `Unresolved` (not found / file not indexed)
289///
290/// Scope entries, alias follows, and shadow detection steps are emitted by
291/// higher-level P2U work that instruments the scope-walk loop. At P2U07
292/// the emission covers the bucket probe and terminal steps; the scope-walk
293/// instrumentation is added in P2U08.
294pub(crate) fn resolve_shared(
295    query: &SymbolQuery<'_>,
296    snapshot: &GraphSnapshot,
297) -> BindingResolution {
298    // Delegate to resolve_symbol_with_witness, which calls
299    // find_symbol_candidates_with_witness internally and maps the candidate
300    // outcome to SymbolResolutionOutcome. The mapping is identical to the
301    // pre-P2U07 BindingQuery::resolve() body.
302    let mut witness = snapshot.resolve_symbol_with_witness(query);
303
304    // Emit step trace for the resolution work performed.
305    emit_resolution_steps(&mut witness, query);
306
307    // Build bindings from witness.candidates, same as the pre-P2U07 body.
308    let bindings: Vec<ResolvedBinding> = witness
309        .candidates
310        .iter()
311        .filter_map(|candidate| {
312            let entry = snapshot.get_node(candidate.node_id)?;
313            Some(ResolvedBinding {
314                node_id: candidate.node_id,
315                classification: classify_node(snapshot, candidate.node_id, entry.kind),
316                bucket: candidate.bucket,
317                kind: entry.kind,
318            })
319        })
320        .collect();
321
322    let result = BindingResult {
323        query: witness.normalized_query.clone(),
324        bindings,
325        outcome: witness.outcome.clone(),
326    };
327
328    BindingResolution { result, witness }
329}
330
331/// Populates `witness.steps` with the ordered step trace for the resolution
332/// that already ran (post-hoc emission).
333///
334/// The step emission documents:
335/// 1. `ApplyResolutionMode` — the mode used for this query
336/// 2. `LookupInBucket` — for each bucket probed until one with candidates
337/// 3. `ConsiderCandidate` — for each candidate in the winning bucket
338/// 4. Terminal step — `Chose`, `Ambiguous`, or `Unresolved`
339///
340/// This is a post-hoc approach: `resolve_symbol_with_witness` has already
341/// run and we reconstruct the step trace from the outcome/candidates fields.
342/// The pre-P2U07 `resolve_symbol_with_witness` already returns `steps:
343/// Vec::new()`, so populating it here is safe and additive.
344fn emit_resolution_steps(witness: &mut SymbolResolutionWitness, query: &SymbolQuery<'_>) {
345    use super::witness::step::UnresolvedReason;
346    use smallvec::SmallVec;
347
348    let steps = &mut witness.steps;
349
350    // Step 1: document the resolution mode applied.
351    steps.push(ResolutionStep::ApplyResolutionMode { mode: query.mode });
352
353    // Step 2: document bucket probes. The resolver tries ExactQualified →
354    // ExactSimple → CanonicalSuffix (if mode allows suffix). We infer
355    // which buckets were tried from the winning bucket and the outcome.
356    let winning_bucket = witness.selected_bucket;
357    match winning_bucket {
358        None => {
359            // No bucket produced candidates — all three (or two) were tried
360            // and came up empty.
361            steps.push(ResolutionStep::LookupInBucket {
362                bucket: SymbolCandidateBucket::ExactQualified,
363            });
364            steps.push(ResolutionStep::LookupInBucket {
365                bucket: SymbolCandidateBucket::ExactSimple,
366            });
367            if query.mode
368                == crate::graph::unified::resolution::ResolutionMode::AllowSuffixCandidates
369            {
370                steps.push(ResolutionStep::LookupInBucket {
371                    bucket: SymbolCandidateBucket::CanonicalSuffix,
372                });
373            }
374        }
375        Some(SymbolCandidateBucket::ExactQualified) => {
376            steps.push(ResolutionStep::LookupInBucket {
377                bucket: SymbolCandidateBucket::ExactQualified,
378            });
379        }
380        Some(SymbolCandidateBucket::ExactSimple) => {
381            steps.push(ResolutionStep::LookupInBucket {
382                bucket: SymbolCandidateBucket::ExactQualified,
383            });
384            steps.push(ResolutionStep::LookupInBucket {
385                bucket: SymbolCandidateBucket::ExactSimple,
386            });
387        }
388        Some(SymbolCandidateBucket::CanonicalSuffix) => {
389            steps.push(ResolutionStep::LookupInBucket {
390                bucket: SymbolCandidateBucket::ExactQualified,
391            });
392            steps.push(ResolutionStep::LookupInBucket {
393                bucket: SymbolCandidateBucket::ExactSimple,
394            });
395            steps.push(ResolutionStep::LookupInBucket {
396                bucket: SymbolCandidateBucket::CanonicalSuffix,
397            });
398        }
399    }
400
401    // Step 3: document each candidate considered.
402    for (rank, candidate) in witness.candidates.iter().enumerate() {
403        steps.push(ResolutionStep::ConsiderCandidate {
404            node: candidate.node_id,
405            rank: u16::try_from(rank).unwrap_or(u16::MAX),
406        });
407    }
408
409    // Step 4: emit terminal step based on outcome.
410    //
411    // For Unresolved steps the symbol StringId is read from
412    // `witness.symbol`, which was populated by a read-only interner lookup
413    // in `resolve_symbol_with_witness`. When the symbol was not found in the
414    // interner (i.e., truly not indexed), we fall back to StringId(0) so
415    // callers can still match on the step variant.
416    let unresolved_symbol = witness
417        .symbol
418        .unwrap_or_else(|| crate::graph::unified::string::id::StringId::new(0));
419
420    match &witness.outcome {
421        SymbolResolutionOutcome::Resolved(node_id) => {
422            // `Resolved` is only constructed when exactly one candidate
423            // exists (see `resolve_symbol_with_witness`). A debug assertion
424            // makes this invariant explicit; no TieBreak step is needed.
425            debug_assert_eq!(
426                witness.candidates.len(),
427                1,
428                "Resolved outcome must have exactly one candidate"
429            );
430            steps.push(ResolutionStep::Chose { node: *node_id });
431        }
432        SymbolResolutionOutcome::Ambiguous(candidates) => {
433            let mut sv: SmallVec<[NodeId; 4]> = SmallVec::new();
434            sv.extend_from_slice(candidates);
435            steps.push(ResolutionStep::Ambiguous { candidates: sv });
436        }
437        SymbolResolutionOutcome::NotFound => {
438            steps.push(ResolutionStep::Unresolved {
439                symbol: unresolved_symbol,
440                reason: UnresolvedReason::NotInAnyScope,
441            });
442        }
443        SymbolResolutionOutcome::FileNotIndexed => {
444            steps.push(ResolutionStep::Unresolved {
445                symbol: unresolved_symbol,
446                reason: UnresolvedReason::FileNotIndexed,
447            });
448        }
449    }
450}
451
452// ---------------------------------------------------------------------------
453// Tests
454// ---------------------------------------------------------------------------
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use crate::graph::node::Language;
460    use crate::graph::unified::concurrent::CodeGraph;
461    use crate::graph::unified::edge::kind::EdgeKind;
462    use crate::graph::unified::node::kind::NodeKind;
463    use crate::graph::unified::resolution::{FileScope, ResolutionMode, SymbolQuery};
464    use crate::graph::unified::storage::arena::NodeEntry;
465
466    // -------------------------------------------------------------------
467    // Test helper: build a minimal two-node graph with a Contains edge.
468    // -------------------------------------------------------------------
469    fn make_graph_with_function(sym: &str) -> CodeGraph {
470        let mut graph = CodeGraph::new();
471        let path = std::path::PathBuf::from("/plane-tests/test.rs");
472        let file_id = graph
473            .files_mut()
474            .register_with_language(&path, Some(Language::Rust))
475            .expect("register file");
476        let name = graph.strings_mut().intern(sym).expect("intern sym");
477        let qn = graph
478            .strings_mut()
479            .intern(&format!("crate::{sym}"))
480            .expect("intern qn");
481        let mod_name = graph.strings_mut().intern("root").expect("intern root");
482        let mod_qn = graph.strings_mut().intern("crate").expect("intern crate");
483        let mod_id = graph
484            .nodes_mut()
485            .alloc(
486                NodeEntry::new(NodeKind::Module, mod_name, file_id)
487                    .with_qualified_name(mod_qn)
488                    .with_byte_range(0, 100),
489            )
490            .expect("alloc mod");
491        graph
492            .indices_mut()
493            .add(mod_id, NodeKind::Module, mod_name, Some(mod_qn), file_id);
494        let fn_id = graph
495            .nodes_mut()
496            .alloc(
497                NodeEntry::new(NodeKind::Function, name, file_id)
498                    .with_qualified_name(qn)
499                    .with_byte_range(5, 80),
500            )
501            .expect("alloc fn");
502        graph
503            .indices_mut()
504            .add(fn_id, NodeKind::Function, name, Some(qn), file_id);
505        graph
506            .edges_mut()
507            .add_edge(mod_id, fn_id, EdgeKind::Contains, file_id);
508        graph
509    }
510
511    #[test]
512    fn plane_resolve_returns_binding_result_and_witness() {
513        let graph = make_graph_with_function("my_fn");
514        let snapshot = graph.snapshot();
515        let plane = snapshot.binding_plane();
516        let query = SymbolQuery {
517            symbol: "my_fn",
518            file_scope: FileScope::Any,
519            mode: ResolutionMode::AllowSuffixCandidates,
520        };
521        let resolution = plane.resolve(&query);
522
523        // BindingResult must have exactly one binding for "my_fn".
524        assert!(
525            !resolution.result.bindings.is_empty(),
526            "expected at least one binding"
527        );
528        assert_eq!(
529            resolution.result.bindings[0].classification,
530            SymbolClassification::Declaration,
531        );
532
533        // Witness must carry a non-empty step trace.
534        assert!(
535            !resolution.witness.steps.is_empty(),
536            "step trace must be non-empty after P2U07 emission"
537        );
538    }
539
540    #[test]
541    fn plane_resolve_not_found_emits_unresolved_step() {
542        let graph = make_graph_with_function("some_fn");
543        let snapshot = graph.snapshot();
544        let plane = snapshot.binding_plane();
545        let query = SymbolQuery {
546            symbol: "does_not_exist",
547            file_scope: FileScope::Any,
548            mode: ResolutionMode::Strict,
549        };
550        let resolution = plane.resolve(&query);
551
552        assert_eq!(resolution.result.outcome, SymbolResolutionOutcome::NotFound);
553        let has_unresolved = resolution.witness.steps.iter().any(|s| {
554            matches!(
555                s,
556                ResolutionStep::Unresolved {
557                    reason: super::super::witness::step::UnresolvedReason::NotInAnyScope,
558                    ..
559                }
560            )
561        });
562        assert!(
563            has_unresolved,
564            "expected Unresolved step for not-found query"
565        );
566    }
567
568    #[test]
569    fn plane_resolve_found_emits_chose_step() {
570        let graph = make_graph_with_function("chosen_fn");
571        let snapshot = graph.snapshot();
572        let plane = snapshot.binding_plane();
573        let query = SymbolQuery {
574            symbol: "chosen_fn",
575            file_scope: FileScope::Any,
576            mode: ResolutionMode::AllowSuffixCandidates,
577        };
578        let resolution = plane.resolve(&query);
579
580        let has_chose = resolution
581            .witness
582            .steps
583            .iter()
584            .any(|s| matches!(s, ResolutionStep::Chose { .. }));
585        assert!(has_chose, "expected Chose terminal step for resolved query");
586    }
587
588    #[test]
589    fn plane_explain_produces_non_empty_text_and_json() {
590        let graph = make_graph_with_function("explainable_fn");
591        let snapshot = graph.snapshot();
592        let plane = snapshot.binding_plane();
593        let query = SymbolQuery {
594            symbol: "explainable_fn",
595            file_scope: FileScope::Any,
596            mode: ResolutionMode::AllowSuffixCandidates,
597        };
598        let resolution = plane.resolve(&query);
599        let rendering = plane.explain(&resolution);
600
601        assert!(!rendering.text.is_empty(), "explain text must be non-empty");
602        assert!(
603            rendering.json.get("steps").is_some(),
604            "explain JSON must have a 'steps' field"
605        );
606    }
607
608    #[test]
609    fn binding_query_resolve_matches_plane_resolve_result() {
610        // Verify that BindingQuery::resolve() and BindingPlane::resolve().result
611        // return identical BindingResult values (byte-equality proof).
612        let graph = make_graph_with_function("parity_fn");
613        let snapshot = graph.snapshot();
614
615        let query_result = crate::graph::unified::bind::BindingQuery::new("parity_fn")
616            .file_scope(FileScope::Any)
617            .mode(ResolutionMode::AllowSuffixCandidates)
618            .resolve(&snapshot);
619
620        let plane_result = snapshot.binding_plane().resolve(&SymbolQuery {
621            symbol: "parity_fn",
622            file_scope: FileScope::Any,
623            mode: ResolutionMode::AllowSuffixCandidates,
624        });
625
626        assert_eq!(
627            query_result, plane_result.result,
628            "BindingQuery::resolve() and BindingPlane::resolve().result must be identical"
629        );
630    }
631
632    #[test]
633    fn scope_of_returns_none_for_unknown_node() {
634        let graph = make_graph_with_function("any_fn");
635        let snapshot = graph.snapshot();
636        let plane = snapshot.binding_plane();
637        // Use an invalid NodeId — scope_of must return None.
638        let invalid_id = NodeId::new(u32::MAX - 1, 99);
639        assert!(plane.scope_of(invalid_id).is_none());
640    }
641
642    #[test]
643    fn classify_returns_unknown_for_invalid_node() {
644        let graph = make_graph_with_function("any_fn2");
645        let snapshot = graph.snapshot();
646        let plane = snapshot.binding_plane();
647        let invalid_id = NodeId::new(u32::MAX - 2, 99);
648        assert_eq!(plane.classify(invalid_id), SymbolClassification::Unknown);
649    }
650
651    #[test]
652    fn step_trace_contains_apply_resolution_mode_first() {
653        let graph = make_graph_with_function("mode_fn");
654        let snapshot = graph.snapshot();
655        let plane = snapshot.binding_plane();
656        let query = SymbolQuery {
657            symbol: "mode_fn",
658            file_scope: FileScope::Any,
659            mode: ResolutionMode::Strict,
660        };
661        let resolution = plane.resolve(&query);
662
663        let first = resolution.witness.steps.first();
664        assert!(
665            matches!(
666                first,
667                Some(ResolutionStep::ApplyResolutionMode {
668                    mode: ResolutionMode::Strict
669                })
670            ),
671            "first step must be ApplyResolutionMode with the query mode"
672        );
673    }
674
675    #[test]
676    fn step_trace_exact_qualified_win_emits_single_bucket_step() {
677        // When the ExactQualified bucket wins, only one LookupInBucket step
678        // is emitted before the ConsiderCandidate/Chose steps.
679        let graph = make_graph_with_function("exact_fn");
680        let snapshot = graph.snapshot();
681        let plane = snapshot.binding_plane();
682
683        // Search by full qualified name so ExactQualified bucket wins.
684        let query = SymbolQuery {
685            symbol: "crate::exact_fn",
686            file_scope: FileScope::Any,
687            mode: ResolutionMode::AllowSuffixCandidates,
688        };
689        let resolution = plane.resolve(&query);
690
691        let bucket_steps: Vec<_> = resolution
692            .witness
693            .steps
694            .iter()
695            .filter(|s| matches!(s, ResolutionStep::LookupInBucket { .. }))
696            .collect();
697        // Exactly one bucket probe: ExactQualified won immediately.
698        assert_eq!(
699            bucket_steps.len(),
700            1,
701            "expected exactly one LookupInBucket step when ExactQualified wins"
702        );
703        assert!(matches!(
704            bucket_steps[0],
705            ResolutionStep::LookupInBucket {
706                bucket: SymbolCandidateBucket::ExactQualified
707            }
708        ));
709    }
710}