Skip to main content

weavatrix_rust_refactor/
resolve.rs

1//! Turning what an agent typed into exactly one graph node.
2//!
3//! Resolution is deliberately narrow. An exact id wins; a label wins only when it is unique.
4//! An ambiguous label resolves to nothing rather than to the first match, because picking one
5//! silently is how a refactor edits the wrong symbol — the same trap the benchmark caught, where
6//! two files declared the same name and only one of them was the target.
7
8use weavatrix_graph::{Graph, NodeIndex};
9
10/// Resolves `query` to one node, or `None` when it matches nothing or more than one thing.
11#[must_use]
12pub fn resolve_symbol(graph: &Graph, query: &str) -> Option<NodeIndex> {
13    let indexed = |slot: usize| NodeIndex::new(u32::try_from(slot).unwrap_or(u32::MAX));
14    let nodes = graph.nodes();
15    if let Some(slot) = nodes.iter().position(|node| node.id.as_str() == query) {
16        return Some(indexed(slot));
17    }
18    let mut matches = nodes
19        .iter()
20        .enumerate()
21        .filter(|(_, node)| node.label == query || node.id.as_str().ends_with(query));
22    let first = matches.next()?;
23    if matches.next().is_some() {
24        return None;
25    }
26    Some(indexed(first.0))
27}
28
29/// Every id the query could have meant, for the refusal that asks for an exact one.
30///
31/// Without this list the refusal costs an extra round trip: the agent has to run a graph query —
32/// measured at ~26 KB of response — to learn ids the resolver already saw and threw away. The
33/// list is capped because an agent disambiguates between a handful, not a hundred.
34#[must_use]
35pub fn candidate_ids(graph: &Graph, query: &str) -> Vec<String> {
36    const MOST: usize = 8;
37    graph
38        .nodes()
39        .iter()
40        .filter(|node| node.label == query || node.id.as_str().ends_with(query))
41        .map(|node| node.id.as_str().to_owned())
42        .take(MOST)
43        .collect()
44}
45
46#[cfg(test)]
47mod tests {
48    use super::resolve_symbol;
49    use crate::test_support::fixture_state;
50
51    #[test]
52    fn an_exact_id_resolves() {
53        let state = fixture_state();
54        let Some(first) = state
55            .graph()
56            .nodes()
57            .first()
58            .map(|node| node.id.as_str().to_owned())
59        else {
60            return;
61        };
62        assert!(resolve_symbol(state.graph(), &first).is_some());
63    }
64
65    #[test]
66    fn an_unknown_query_resolves_to_nothing() {
67        let state = fixture_state();
68        assert!(resolve_symbol(state.graph(), "definitely::not::here").is_none());
69    }
70
71    #[test]
72    fn an_ambiguous_label_refuses_rather_than_guessing() {
73        let state = fixture_state();
74        let graph = state.graph();
75        // A label carried by two nodes must not resolve; editing the wrong one is the failure
76        // mode this whole product exists to prevent.
77        let mut seen = std::collections::BTreeMap::<&str, usize>::new();
78        for node in graph.nodes() {
79            *seen.entry(node.label.as_str()).or_default() += 1;
80        }
81        if let Some((label, _)) = seen.iter().find(|(_, count)| **count > 1) {
82            assert!(
83                resolve_symbol(graph, label).is_none(),
84                "ambiguous label {label} must not resolve"
85            );
86        }
87    }
88}