Skip to main content

eidos_kernel/retrieval/
focus.rs

1use std::collections::{HashMap, HashSet};
2
3use serde::Serialize;
4
5use crate::schema::{Edge, EdgeBasis, Graph, Kind, Node, RelationProfile, core_relation_profile};
6
7use super::{
8    Confidence, Hit, ground, is_distinct_code_symbol_name,
9    scoring::{relation_phrase, reverse_relation_phrase},
10    terms_of,
11};
12
13#[derive(Serialize, Clone, Debug, Default)]
14pub struct Subgraph {
15    pub hits: Vec<Hit>,
16    pub expanded: Vec<String>,
17    pub edges: Vec<Edge>,
18    /// Where to ROUTE: the canonical front-door node (top ranked hit).
19    pub route: String,
20    /// What to READ, in order: hits re-ranked by RAW lexical relevance (most specific match
21    /// first — so an exact child page leads), then the expanded neighbourhood. Route ≠ this[0]
22    /// whenever a child out-matched its skill but the skill was promoted to the route.
23    pub context_order: Vec<String>,
24    /// Multi-anchor task interpretation. Present when the query is better explained as a set of
25    /// distinct facets connected by the graph than as a single-node route.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub compound: Option<CompoundSubgraph>,
28    /// Number of candidate context nodes omitted by the pack budget.
29    #[serde(default, skip_serializing_if = "is_zero_usize")]
30    pub omitted_context: usize,
31    /// Compact explanation of the top context candidates that were considered but not selected.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub omitted_context_candidates: Vec<OmittedContextCandidate>,
34}
35
36#[derive(Serialize, Clone, Debug, PartialEq)]
37pub struct CompoundSubgraph {
38    pub primary: String,
39    pub anchors: Vec<CompoundAnchor>,
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub omitted_anchors: Vec<CompoundOmittedAnchor>,
42    pub coverage: f64,
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub missing_terms: Vec<String>,
45}
46
47#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
48pub struct CompoundAnchor {
49    pub node_id: String,
50    pub matched_terms: Vec<String>,
51    pub confidence: Confidence,
52    pub score: i64,
53}
54
55#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
56pub struct CompoundOmittedAnchor {
57    pub node_id: String,
58    pub matched_terms: Vec<String>,
59    pub confidence: Confidence,
60    pub score: i64,
61    pub reason: String,
62}
63
64#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
65pub struct OmittedContextCandidate {
66    pub node_id: String,
67    pub score: i64,
68    pub reason: String,
69}
70
71fn is_zero_usize(value: &usize) -> bool {
72    *value == 0
73}
74
75/// A task-shaped context SEED must be lexically relevant — its raw lexical score within this
76/// fraction of the best anchor's. Resolved graph edges now carry code callers/callees, so
77/// multi-term tasks no longer need low-score lexical side doors to recover related code.
78const TASK_SEED_LEXICAL_RATIO: f64 = 0.5;
79/// Bare symbol lookups intentionally keep a wider context: agents usually need the symbol plus its
80/// overview docs and nearby code, and there are few query terms to distinguish task intent.
81const SYMBOL_SEED_LEXICAL_RATIO: f64 = 0.3;
82/// Default context-pack budget. This is intentionally larger than a chat-sized "answer" because
83/// workflow planning needs code + docs + tests, but small enough to avoid graph dumps on real repos.
84const DEFAULT_CONTEXT_BUDGET: usize = 48;
85/// Keep anchors tight. Too many same-query anchors usually means path/name noise rather than a
86/// better read plan; expansion will bring back strongly connected support when it is useful.
87const MAX_SEED_CONTEXT: usize = 16;
88/// Avoid one source file monopolising the pack when a large file has many section/symbol nodes.
89const MAX_ITEMS_PER_SOURCE: usize = 8;
90/// Keep omission transparency useful without turning focus into a graph dump.
91const MAX_OMITTED_CONTEXT_CANDIDATES: usize = 12;
92
93/// Ground, then expand along edges (bidirectional, depth/width-bounded BFS) to surface the
94/// connected evidence chain — the deterministic "associative reading" of AriGraph/SAGE.
95/// `limit` anchors, `depth` hops, `width` neighbours expanded per node per hop.
96pub fn ground_subgraph(
97    graph: &Graph,
98    query: &str,
99    limit: usize,
100    depth: usize,
101    width: usize,
102) -> Subgraph {
103    let hits = ground(graph, query, limit);
104    let top_is_confident = hits.first().is_some_and(|hit| {
105        matches!(
106            hit.confidence,
107            Confidence::Exact | Confidence::Strong | Confidence::Ambiguous
108        )
109    });
110
111    // Seeds = the route (always) + anchors whose RAW lexical relevance clears the ratio bar.
112    // Dominance-boosted-but-lexically-empty anchors are excluded, so their subtree never seeds.
113    let terms = terms_of(&query.to_lowercase());
114    let route = select_subgraph_route(&hits, graph, &terms);
115    let seed_ratio = if terms.len() <= 1 {
116        SYMBOL_SEED_LEXICAL_RATIO
117    } else {
118        TASK_SEED_LEXICAL_RATIO
119    };
120    let walk_references = should_walk_references(graph, &terms, hits.first());
121    let top_lexical = hits.iter().map(|h| h.lexical_score).max().unwrap_or(0) as f64;
122    let floor = (seed_ratio * top_lexical).ceil() as i64;
123    let route_is_anchored =
124        node_by_id(graph, &route).is_some_and(|node| route_term_count(node, &terms) > 0);
125    let mut seeds: Vec<&Hit> = hits
126        .iter()
127        .filter(|h| {
128            (h.id == route || h.lexical_score >= floor)
129                && (!top_is_confident
130                    || h.confidence != Confidence::Fallback
131                    || is_high_value_support_seed(graph, h, &terms))
132                && should_seed_hit(graph, h, &route, route_is_anchored, &terms)
133        })
134        .collect();
135    let mut seed_id_set = seeds
136        .iter()
137        .map(|hit| hit.id.as_str())
138        .collect::<HashSet<_>>();
139    for hit in &hits {
140        if seed_id_set.contains(hit.id.as_str()) || hit.lexical_score < floor {
141            continue;
142        }
143        if !is_connected_code_support_seed(graph, hit, &terms, &seed_id_set, walk_references) {
144            continue;
145        }
146        seed_id_set.insert(hit.id.as_str());
147        seeds.push(hit);
148    }
149    let seed_ids: Vec<String> = seeds.iter().map(|h| h.id.clone()).collect();
150
151    let mut visited: HashSet<String> = seed_ids.iter().cloned().collect();
152    let mut frontier: Vec<String> = seed_ids.clone();
153    let mut edges: Vec<Edge> = Vec::new();
154    let mut edge_seen: HashSet<(String, String, String)> = HashSet::new();
155    let node_by_id_map: HashMap<&str, &Node> = graph
156        .nodes
157        .iter()
158        .map(|node| (node.id.as_str(), node))
159        .collect();
160
161    for _ in 0..depth {
162        let mut next: Vec<String> = Vec::new();
163        for id in &frontier {
164            let mut neighbours: Vec<(&String, &Edge)> = Vec::new();
165            for e in &graph.edges {
166                if !is_trusted_edge(e, walk_references, &graph.relation_profiles) {
167                    continue;
168                }
169                if &e.from == id {
170                    neighbours.push((&e.to, e));
171                } else if &e.to == id {
172                    neighbours.push((&e.from, e));
173                }
174            }
175            neighbours.sort_by(|(a_id, a_edge), (b_id, b_edge)| {
176                walk_neighbour_score(
177                    id,
178                    b_id,
179                    b_edge,
180                    &terms,
181                    &node_by_id_map,
182                    &graph.relation_profiles,
183                )
184                .cmp(&walk_neighbour_score(
185                    id,
186                    a_id,
187                    a_edge,
188                    &terms,
189                    &node_by_id_map,
190                    &graph.relation_profiles,
191                ))
192                .then_with(|| a_id.cmp(b_id))
193            });
194            for (other, e) in neighbours.into_iter().take(width) {
195                if edge_seen.insert((e.from.clone(), e.to.clone(), e.relation.clone())) {
196                    edges.push(e.clone());
197                }
198                if visited.insert(other.clone()) {
199                    next.push(other.clone());
200                }
201            }
202        }
203        if next.is_empty() {
204            break;
205        }
206        frontier = next;
207    }
208
209    let seed_set: HashSet<&String> = seed_ids.iter().collect();
210    let mut expanded_all: Vec<String> = visited
211        .iter()
212        .filter(|id| !seed_set.contains(id))
213        .cloned()
214        .collect();
215    expanded_all.sort();
216    edges.sort_by(|a, b| (&a.from, &a.to, &a.relation).cmp(&(&b.from, &b.to, &b.relation)));
217
218    let compiled = compile_context_pack(
219        graph,
220        &route,
221        &seeds,
222        &expanded_all,
223        &edges,
224        &terms,
225        DEFAULT_CONTEXT_BUDGET,
226    );
227    let compound = detect_compound_subgraph(graph, &route, &hits, &edges, &terms);
228    let selected: HashSet<&String> = compiled.context_order.iter().collect();
229    edges.retain(|edge| selected.contains(&edge.from) && selected.contains(&edge.to));
230    let selected_seed_ids = seed_ids.iter().collect::<HashSet<_>>();
231    let expanded = compiled
232        .context_order
233        .iter()
234        .filter(|id| !selected_seed_ids.contains(*id))
235        .cloned()
236        .collect();
237
238    Subgraph {
239        hits,
240        expanded,
241        edges,
242        route,
243        context_order: compiled.context_order,
244        compound,
245        omitted_context: compiled.omitted,
246        omitted_context_candidates: compiled.omitted_candidates,
247    }
248}
249
250fn detect_compound_subgraph(
251    graph: &Graph,
252    route: &str,
253    hits: &[Hit],
254    edges: &[Edge],
255    terms: &[String],
256) -> Option<CompoundSubgraph> {
257    let terms = compound_terms(terms);
258    if terms.len() < 2 {
259        return None;
260    }
261
262    let node_by_id: HashMap<&str, &Node> = graph
263        .nodes
264        .iter()
265        .map(|node| (node.id.as_str(), node))
266        .collect();
267    let mut anchors = hits
268        .iter()
269        .filter_map(|hit| {
270            let node = node_by_id.get(hit.id.as_str())?;
271            if matches!(node.kind, Kind::Section | Kind::Unknown) {
272                return None;
273            }
274            let matched_terms = matched_compound_terms(node, &terms);
275            if matched_terms.is_empty() {
276                return None;
277            }
278            let relevant = !matches!(hit.confidence, Confidence::Fallback)
279                || matched_terms.len() >= 2
280                || hit.id == route;
281            relevant.then_some(CompoundAnchor {
282                node_id: hit.id.clone(),
283                matched_terms,
284                confidence: hit.confidence,
285                score: hit.score,
286            })
287        })
288        .collect::<Vec<_>>();
289    anchors.sort_by(|a, b| {
290        b.matched_terms
291            .len()
292            .cmp(&a.matched_terms.len())
293            .then_with(|| b.score.cmp(&a.score))
294            .then_with(|| a.node_id.cmp(&b.node_id))
295    });
296
297    let mut covered = HashSet::new();
298    let mut selected = Vec::new();
299    let mut omitted_anchors = Vec::new();
300    for anchor in anchors {
301        let adds_new_term = anchor
302            .matched_terms
303            .iter()
304            .any(|term| !covered.contains(term.as_str()));
305        if !adds_new_term && anchor.node_id != route {
306            record_omitted_anchor(&mut omitted_anchors, anchor, "covered_terms");
307            continue;
308        }
309        if selected.len() >= 6 {
310            record_omitted_anchor(&mut omitted_anchors, anchor, "anchor_limit");
311            continue;
312        }
313        for term in &anchor.matched_terms {
314            covered.insert(term.clone());
315        }
316        selected.push(anchor);
317    }
318    if selected.len() < 2 {
319        return None;
320    }
321    let distinct_anchor_terms = selected
322        .iter()
323        .flat_map(|anchor| anchor.matched_terms.iter().cloned())
324        .collect::<HashSet<_>>();
325    if distinct_anchor_terms.len() < 2 {
326        return None;
327    }
328
329    let primary = select_compound_primary(graph, route, &selected, edges, &node_by_id);
330    let missing_terms = terms
331        .iter()
332        .filter(|term| !distinct_anchor_terms.contains(*term))
333        .cloned()
334        .collect::<Vec<_>>();
335    let coverage = distinct_anchor_terms.len() as f64 / terms.len() as f64;
336
337    Some(CompoundSubgraph {
338        primary,
339        anchors: selected,
340        omitted_anchors,
341        coverage,
342        missing_terms,
343    })
344}
345
346fn record_omitted_anchor(
347    out: &mut Vec<CompoundOmittedAnchor>,
348    anchor: CompoundAnchor,
349    reason: &str,
350) {
351    if out.len() >= MAX_OMITTED_CONTEXT_CANDIDATES {
352        return;
353    }
354    out.push(CompoundOmittedAnchor {
355        node_id: anchor.node_id,
356        matched_terms: anchor.matched_terms,
357        confidence: anchor.confidence,
358        score: anchor.score,
359        reason: reason.to_string(),
360    });
361}
362
363fn compound_terms(terms: &[String]) -> Vec<String> {
364    let mut seen = HashSet::new();
365    terms
366        .iter()
367        .filter(|term| !is_compound_control_term(term))
368        .filter(|term| seen.insert((*term).clone()))
369        .cloned()
370        .collect()
371}
372
373fn is_compound_control_term(term: &str) -> bool {
374    matches!(
375        term,
376        "combine"
377            | "compared"
378            | "comparing"
379            | "comparison"
380            | "create"
381            | "draft"
382            | "explain"
383            | "from"
384            | "generate"
385            | "make"
386            | "prepare"
387            | "produce"
388            | "risk"
389            | "summarize"
390            | "anchor"
391            | "anchors"
392            | "candidate"
393            | "candidates"
394            | "target"
395            | "targets"
396            | "year"
397            | "versus"
398            | "vs"
399            | "write"
400    )
401}
402
403fn matched_compound_terms(node: &Node, terms: &[String]) -> Vec<String> {
404    let text = normalized_route_text(&format!(
405        "{} {} {} {} {}",
406        node.id,
407        node.title,
408        node.summary,
409        node.aliases.join(" "),
410        node.query_examples.join(" ")
411    ));
412    terms
413        .iter()
414        .filter(|term| text.contains(normalized_route_text(term).as_str()))
415        .cloned()
416        .collect()
417}
418
419fn select_compound_primary(
420    graph: &Graph,
421    route: &str,
422    anchors: &[CompoundAnchor],
423    edges: &[Edge],
424    node_by_id: &HashMap<&str, &Node>,
425) -> String {
426    let anchor_ids = anchors
427        .iter()
428        .map(|anchor| anchor.node_id.as_str())
429        .collect::<HashSet<_>>();
430    if node_by_id
431        .get(route)
432        .is_some_and(|node| matches!(node.kind, Kind::Agent | Kind::Skill))
433        && reachable_anchor_count(route, &anchor_ids, edges) >= 2
434    {
435        return route.to_string();
436    }
437
438    graph
439        .nodes
440        .iter()
441        .filter(|node| matches!(node.kind, Kind::Skill | Kind::Agent))
442        .filter_map(|node| {
443            let reachable = reachable_anchor_count(&node.id, &anchor_ids, edges);
444            (reachable >= 2).then_some((node, reachable))
445        })
446        .max_by(|(a_node, a_reachable), (b_node, b_reachable)| {
447            a_reachable
448                .cmp(b_reachable)
449                .then_with(|| {
450                    compound_primary_kind_priority(a_node.kind)
451                        .cmp(&compound_primary_kind_priority(b_node.kind))
452                })
453                .then_with(|| b_node.id.cmp(&a_node.id))
454        })
455        .map_or_else(|| route.to_string(), |(node, _)| node.id.clone())
456}
457
458fn reachable_anchor_count(start: &str, anchor_ids: &HashSet<&str>, edges: &[Edge]) -> usize {
459    let mut reached = HashSet::new();
460    if anchor_ids.contains(start) {
461        reached.insert(start.to_string());
462    }
463    let mut visited = HashSet::from([start.to_string()]);
464    let mut frontier = vec![start.to_string()];
465    for _ in 0..2 {
466        let mut next = Vec::new();
467        for id in &frontier {
468            for edge in edges {
469                let other = if edge.from == *id {
470                    Some(edge.to.as_str())
471                } else if edge.to == *id {
472                    Some(edge.from.as_str())
473                } else {
474                    None
475                };
476                let Some(other) = other else {
477                    continue;
478                };
479                if anchor_ids.contains(other) {
480                    reached.insert(other.to_string());
481                }
482                if visited.insert(other.to_string()) {
483                    next.push(other.to_string());
484                }
485            }
486        }
487        frontier = next;
488        if frontier.is_empty() {
489            break;
490        }
491    }
492    reached.len()
493}
494
495fn compound_primary_kind_priority(kind: Kind) -> usize {
496    match kind {
497        Kind::Skill => 3,
498        Kind::Agent => 2,
499        _ => 0,
500    }
501}
502
503fn walk_neighbour_score(
504    current_id: &str,
505    id: &str,
506    edge: &Edge,
507    terms: &[String],
508    node_by_id: &HashMap<&str, &Node>,
509    profiles: &std::collections::BTreeMap<String, RelationProfile>,
510) -> i64 {
511    let kind_score = node_by_id.get(id).map_or(0, |node| match node.kind {
512        // For task packs, type/trait references usually explain the shape of a change or
513        // report. Keep them ahead of same-source helper fanout when width is bounded.
514        Kind::Type | Kind::Trait => 30,
515        Kind::Function => 16,
516        Kind::Module => 12,
517        Kind::Skill | Kind::Agent => 10,
518        Kind::Doc | Kind::Section => 8,
519        Kind::Unknown => 0,
520    });
521    relation_context_score(&edge.relation, profiles)
522        + relation_query_match_score(current_id, edge, terms, profiles)
523        + node_query_support_score(id, terms, node_by_id)
524        + kind_score
525        + container_child_walk_bonus(current_id, edge, node_by_id)
526}
527
528fn container_child_walk_bonus(
529    current_id: &str,
530    edge: &Edge,
531    node_by_id: &HashMap<&str, &Node>,
532) -> i64 {
533    if edge.from != current_id {
534        return 0;
535    }
536    let Some(current) = node_by_id.get(current_id) else {
537        return 0;
538    };
539    match (current.kind, edge.relation.as_str()) {
540        (
541            Kind::Doc | Kind::Section | Kind::Skill | Kind::Agent | Kind::Module | Kind::Type,
542            crate::schema::relation::CONTAINS | crate::schema::relation::HAS_KNOWLEDGE,
543        ) => 34,
544        _ => 0,
545    }
546}
547
548fn is_trusted_edge(
549    edge: &Edge,
550    walk_references: bool,
551    profiles: &std::collections::BTreeMap<String, RelationProfile>,
552) -> bool {
553    use crate::schema::relation::{MENTIONS, REFERENCES};
554    let profile = profiles
555        .get(&edge.relation)
556        .cloned()
557        .or_else(|| core_relation_profile(&edge.relation));
558    if profile.as_ref().is_some_and(|profile| !profile.traversable) {
559        return false;
560    }
561    match edge.relation.as_str() {
562        MENTIONS => false,
563        REFERENCES => walk_references && edge.basis == EdgeBasis::Resolved,
564        _ => edge.basis == EdgeBasis::Resolved,
565    }
566}
567
568fn should_walk_references(graph: &Graph, terms: &[String], top_hit: Option<&Hit>) -> bool {
569    if terms.len() > 1 {
570        return true;
571    }
572    let Some(hit) = top_hit else {
573        return false;
574    };
575    if matches!(hit.confidence, Confidence::Weak | Confidence::Fallback) {
576        return false;
577    }
578    let Some(node) = graph.nodes.iter().find(|node| node.id == hit.id) else {
579        return false;
580    };
581    matches!(
582        node.kind,
583        Kind::Function | Kind::Type | Kind::Trait | Kind::Module
584    ) && is_distinct_code_symbol_name(&node.title.to_lowercase())
585}
586
587fn should_seed_hit(
588    graph: &Graph,
589    hit: &Hit,
590    route: &str,
591    route_is_anchored: bool,
592    terms: &[String],
593) -> bool {
594    if hit.id == route || !route_is_anchored {
595        return true;
596    }
597    node_by_id(graph, &hit.id).is_some_and(|node| {
598        if matches!(
599            node.kind,
600            Kind::Function | Kind::Type | Kind::Trait | Kind::Module
601        ) {
602            route_term_count(node, terms) > 0
603        } else {
604            support_term_count(node, terms) > 0
605        }
606    })
607}
608
609fn is_high_value_support_seed(graph: &Graph, hit: &Hit, terms: &[String]) -> bool {
610    node_by_id(graph, &hit.id).is_some_and(|node| {
611        has_receipt(node)
612            && match node.kind {
613                Kind::Doc | Kind::Section | Kind::Skill | Kind::Agent => {
614                    support_term_count(node, terms) >= 2
615                }
616                Kind::Function | Kind::Type | Kind::Trait | Kind::Module | Kind::Unknown => false,
617            }
618    })
619}
620
621fn is_connected_code_support_seed(
622    graph: &Graph,
623    hit: &Hit,
624    terms: &[String],
625    seed_ids: &HashSet<&str>,
626    walk_references: bool,
627) -> bool {
628    let Some(node) = node_by_id(graph, &hit.id) else {
629        return false;
630    };
631    if !matches!(
632        node.kind,
633        Kind::Function | Kind::Type | Kind::Trait | Kind::Module
634    ) || !has_receipt(node)
635        || support_term_count(node, terms) < 2
636    {
637        return false;
638    }
639    graph.edges.iter().any(|edge| {
640        is_trusted_edge(edge, walk_references, &graph.relation_profiles)
641            && ((edge.from == hit.id && seed_ids.contains(edge.to.as_str()))
642                || (edge.to == hit.id && seed_ids.contains(edge.from.as_str())))
643    })
644}
645
646fn select_subgraph_route(hits: &[Hit], graph: &Graph, terms: &[String]) -> String {
647    let Some(top) = hits.first() else {
648        return String::new();
649    };
650    if top.relation_anchor {
651        return top.id.clone();
652    }
653    if hit_has_exact_query_example(top)
654        && node_by_id(graph, &top.id).is_some_and(|node| route_term_count(node, terms) > 0)
655    {
656        return top.id.clone();
657    }
658    if let Some(code_route) = select_code_intent_route(hits, graph, terms) {
659        return code_route;
660    }
661    if matches!(
662        top.confidence,
663        Confidence::Exact | Confidence::Strong | Confidence::Ambiguous
664    ) && node_by_id(graph, &top.id).is_some_and(|node| route_term_count(node, terms) > 0)
665    {
666        return top.id.clone();
667    }
668
669    let node_by_id: HashMap<&str, &Node> = graph
670        .nodes
671        .iter()
672        .map(|node| (node.id.as_str(), node))
673        .collect();
674    hits.iter()
675        .filter_map(|hit| {
676            let node = node_by_id.get(hit.id.as_str())?;
677            let route_hits = route_term_count(node, terms);
678            (route_hits > 0).then_some((hit, route_hits))
679        })
680        .max_by(|(a_hit, a_route_hits), (b_hit, b_route_hits)| {
681            a_route_hits
682                .cmp(b_route_hits)
683                .then_with(|| a_hit.score.cmp(&b_hit.score))
684                .then_with(|| b_hit.id.cmp(&a_hit.id))
685        })
686        .map_or_else(|| top.id.clone(), |(hit, _)| hit.id.clone())
687}
688
689fn select_code_intent_route(hits: &[Hit], graph: &Graph, terms: &[String]) -> Option<String> {
690    if !query_has_code_action_intent(terms) {
691        return None;
692    }
693    let subject_terms = code_subject_terms(terms);
694    if subject_terms.is_empty() {
695        return None;
696    }
697    let node_by_id: HashMap<&str, &Node> = graph
698        .nodes
699        .iter()
700        .map(|node| (node.id.as_str(), node))
701        .collect();
702    let candidates = hits
703        .iter()
704        .filter_map(|hit| {
705            let node = node_by_id.get(hit.id.as_str())?;
706            if !is_code_route_kind(node.kind) {
707                return None;
708            }
709            let subject_hits = route_term_count(node, &subject_terms);
710            let exact_identifier_hits = code_identifier_term_count(node, &subject_terms);
711            (subject_hits > 0).then_some((hit, *node, exact_identifier_hits, subject_hits))
712        })
713        .collect::<Vec<_>>();
714    let min_score = candidates
715        .iter()
716        .map(|(hit, _, _, _)| hit.score)
717        .max()
718        .map(|score| ((score as f64) * 0.8).ceil() as i64)
719        .unwrap_or_default();
720
721    candidates
722        .into_iter()
723        .filter(|(hit, _, _, _)| hit.score >= min_score)
724        .max_by(
725            |(a_hit, a_node, a_exact_identifier_hits, a_subject_hits),
726             (b_hit, b_node, b_exact_identifier_hits, b_subject_hits)| {
727                a_exact_identifier_hits
728                    .cmp(b_exact_identifier_hits)
729                    .then_with(|| a_subject_hits.cmp(b_subject_hits))
730                    .then_with(|| a_hit.score.cmp(&b_hit.score))
731                    .then_with(|| a_hit.lexical_score.cmp(&b_hit.lexical_score))
732                    .then_with(|| {
733                        code_route_kind_priority(a_node.kind)
734                            .cmp(&code_route_kind_priority(b_node.kind))
735                    })
736                    .then_with(|| b_hit.id.cmp(&a_hit.id))
737            },
738        )
739        .map(|(hit, _, _, _)| hit.id.clone())
740}
741
742fn hit_has_exact_query_example(hit: &Hit) -> bool {
743    hit.why.iter().any(|why| why == "exact query_example match")
744}
745
746fn code_identifier_term_count(node: &Node, terms: &[String]) -> usize {
747    let title = normalized_route_text(&node.title);
748    let id_tail = node
749        .id
750        .rsplit("::")
751        .next()
752        .map(normalized_route_text)
753        .unwrap_or_default();
754    terms
755        .iter()
756        .map(|term| normalized_route_text(term))
757        .filter(|term| !term.is_empty() && (term == &title || term == &id_tail))
758        .count()
759}
760
761fn query_has_code_action_intent(terms: &[String]) -> bool {
762    terms.iter().any(|term| {
763        matches!(
764            term.as_str(),
765            "call"
766                | "caller"
767                | "callee"
768                | "change"
769                | "changing"
770                | "code"
771                | "definition"
772                | "function"
773                | "implement"
774                | "implementation"
775                | "implemented"
776                | "method"
777                | "modify"
778                | "source"
779                | "trace"
780                | "type"
781                | "usage"
782                | "use"
783                | "used"
784                | "uses"
785        )
786    })
787}
788
789fn code_subject_terms(terms: &[String]) -> Vec<String> {
790    terms
791        .iter()
792        .filter(|term| !is_code_intent_control_term(term))
793        .cloned()
794        .collect()
795}
796
797fn is_code_intent_control_term(term: &str) -> bool {
798    matches!(
799        term,
800        "call"
801            | "caller"
802            | "callee"
803            | "change"
804            | "changing"
805            | "code"
806            | "definition"
807            | "function"
808            | "implement"
809            | "implementation"
810            | "implemented"
811            | "method"
812            | "modify"
813            | "source"
814            | "trace"
815            | "type"
816            | "usage"
817            | "use"
818            | "used"
819            | "uses"
820    )
821}
822
823fn is_code_route_kind(kind: Kind) -> bool {
824    matches!(
825        kind,
826        Kind::Function | Kind::Type | Kind::Trait | Kind::Module
827    )
828}
829
830fn code_route_kind_priority(kind: Kind) -> usize {
831    match kind {
832        Kind::Function => 4,
833        Kind::Type | Kind::Trait => 3,
834        Kind::Module => 2,
835        _ => 0,
836    }
837}
838
839fn node_by_id<'a>(graph: &'a Graph, id: &str) -> Option<&'a Node> {
840    graph.nodes.iter().find(|node| node.id == id)
841}
842
843fn route_term_count(node: &Node, terms: &[String]) -> usize {
844    let title = normalized_route_text(&node.title);
845    let alias_hits = if matches!(
846        node.kind,
847        Kind::Function | Kind::Type | Kind::Trait | Kind::Module
848    ) {
849        0
850    } else {
851        node.aliases
852            .iter()
853            .map(|alias| normalized_route_text(alias))
854            .flat_map(|alias| {
855                let alias = alias.clone();
856                terms
857                    .iter()
858                    .filter(move |term| alias.contains(normalized_route_text(term).as_str()))
859            })
860            .count()
861    };
862    terms
863        .iter()
864        .filter(|term| title.contains(normalized_route_text(term).as_str()))
865        .count()
866        + alias_hits
867}
868
869fn support_term_count(node: &Node, terms: &[String]) -> usize {
870    let mut count = route_term_count(node, terms);
871    let supporting_text = normalized_route_text(&format!(
872        "{} {} {}",
873        node.summary,
874        node.query_examples.join(" "),
875        node.tags.join(" ")
876    ));
877    count += terms
878        .iter()
879        .filter(|term| supporting_text.contains(normalized_route_text(term).as_str()))
880        .count();
881    count
882}
883
884fn normalized_route_text(text: &str) -> String {
885    text.chars()
886        .filter(char::is_ascii_alphanumeric)
887        .flat_map(char::to_lowercase)
888        .collect()
889}
890
891#[derive(Debug, Clone)]
892struct CompiledContextPack {
893    context_order: Vec<String>,
894    omitted: usize,
895    omitted_candidates: Vec<OmittedContextCandidate>,
896}
897
898fn compile_context_pack(
899    graph: &Graph,
900    route: &str,
901    seeds: &[&Hit],
902    expanded: &[String],
903    edges: &[Edge],
904    terms: &[String],
905    budget: usize,
906) -> CompiledContextPack {
907    if budget == 0 {
908        return CompiledContextPack {
909            context_order: Vec::new(),
910            omitted: seeds.len() + expanded.len(),
911            omitted_candidates: seeds
912                .iter()
913                .map(|hit| OmittedContextCandidate {
914                    node_id: hit.id.clone(),
915                    score: hit.score,
916                    reason: "pack_budget".to_string(),
917                })
918                .chain(expanded.iter().map(|id| OmittedContextCandidate {
919                    node_id: id.clone(),
920                    score: 0,
921                    reason: "pack_budget".to_string(),
922                }))
923                .take(MAX_OMITTED_CONTEXT_CANDIDATES)
924                .collect(),
925        };
926    }
927
928    let node_by_id: HashMap<&str, &Node> = graph
929        .nodes
930        .iter()
931        .map(|node| (node.id.as_str(), node))
932        .collect();
933    let mut seen = HashSet::new();
934    let mut context_order = Vec::new();
935    let mut omitted_candidates = Vec::new();
936    let mut per_source: HashMap<String, usize> = HashMap::new();
937
938    let mut seed_order: Vec<&Hit> = seeds.to_vec();
939    seed_order.sort_by(|a, b| {
940        (b.id == route).cmp(&(a.id == route)).then_with(|| {
941            b.lexical_score
942                .cmp(&a.lexical_score)
943                .then_with(|| a.id.cmp(&b.id))
944        })
945    });
946    for (index, hit) in seed_order.into_iter().enumerate() {
947        if index >= MAX_SEED_CONTEXT {
948            record_omitted_candidate(&mut omitted_candidates, &hit.id, hit.score, "seed_limit");
949            continue;
950        }
951        let result = push_context_item(
952            &mut context_order,
953            &mut seen,
954            &mut per_source,
955            &node_by_id,
956            &hit.id,
957            budget,
958            true,
959        );
960        if let PushContextResult::Omitted(reason) = result {
961            record_omitted_candidate(&mut omitted_candidates, &hit.id, hit.score, reason);
962        }
963    }
964
965    let selected_seed_ids = context_order.iter().cloned().collect::<HashSet<_>>();
966    let mut candidates = expanded
967        .iter()
968        .filter(|id| !seen.contains(id.as_str()))
969        .map(|id| {
970            let score = context_candidate_score(
971                id,
972                &selected_seed_ids,
973                edges,
974                terms,
975                &node_by_id,
976                &graph.relation_profiles,
977            );
978            (id.clone(), score)
979        })
980        .collect::<Vec<_>>();
981    candidates.sort_by(|(a_id, a_score), (b_id, b_score)| {
982        b_score.cmp(a_score).then_with(|| a_id.cmp(b_id))
983    });
984    for (id, score) in candidates {
985        if context_order.len() >= budget {
986            record_omitted_candidate(&mut omitted_candidates, &id, score, "pack_budget");
987            break;
988        }
989        let result = push_context_item(
990            &mut context_order,
991            &mut seen,
992            &mut per_source,
993            &node_by_id,
994            &id,
995            budget,
996            false,
997        );
998        if let PushContextResult::Omitted(reason) = result {
999            record_omitted_candidate(&mut omitted_candidates, &id, score, reason);
1000        }
1001    }
1002
1003    let total_candidates = seeds.len() + expanded.len();
1004    CompiledContextPack {
1005        omitted: total_candidates.saturating_sub(context_order.len()),
1006        context_order,
1007        omitted_candidates,
1008    }
1009}
1010
1011enum PushContextResult {
1012    Selected,
1013    Omitted(&'static str),
1014}
1015
1016fn push_context_item(
1017    out: &mut Vec<String>,
1018    seen: &mut HashSet<String>,
1019    per_source: &mut HashMap<String, usize>,
1020    node_by_id: &HashMap<&str, &Node>,
1021    id: &str,
1022    budget: usize,
1023    force: bool,
1024) -> PushContextResult {
1025    if out.len() >= budget || seen.contains(id) {
1026        return PushContextResult::Omitted("pack_budget");
1027    }
1028    if !force && !node_by_id.get(id).is_some_and(|node| has_receipt(node)) {
1029        return PushContextResult::Omitted("missing_receipt");
1030    }
1031    let source_key = node_by_id
1032        .get(id)
1033        .map_or_else(|| "(unknown)".to_string(), |node| source_key(node));
1034    let count = per_source.get(&source_key).copied().unwrap_or(0);
1035    if !force && count >= MAX_ITEMS_PER_SOURCE {
1036        return PushContextResult::Omitted("source_cap");
1037    }
1038    seen.insert(id.to_string());
1039    *per_source.entry(source_key).or_default() += 1;
1040    out.push(id.to_string());
1041    PushContextResult::Selected
1042}
1043
1044fn record_omitted_candidate(
1045    out: &mut Vec<OmittedContextCandidate>,
1046    node_id: &str,
1047    score: i64,
1048    reason: &str,
1049) {
1050    if out.len() >= MAX_OMITTED_CONTEXT_CANDIDATES {
1051        return;
1052    }
1053    if out.iter().any(|candidate| candidate.node_id == node_id) {
1054        return;
1055    }
1056    out.push(OmittedContextCandidate {
1057        node_id: node_id.to_string(),
1058        score,
1059        reason: reason.to_string(),
1060    });
1061}
1062
1063fn has_receipt(node: &Node) -> bool {
1064    node.span.is_some() || !node.source_files.is_empty()
1065}
1066
1067fn context_candidate_score(
1068    id: &str,
1069    selected_seed_ids: &HashSet<String>,
1070    edges: &[Edge],
1071    terms: &[String],
1072    node_by_id: &HashMap<&str, &Node>,
1073    profiles: &std::collections::BTreeMap<String, RelationProfile>,
1074) -> i64 {
1075    let mut score = node_by_id
1076        .get(id)
1077        .map_or(0, |node| kind_context_score(node.kind));
1078    for edge in edges {
1079        let touches_candidate = edge.from == id || edge.to == id;
1080        if !touches_candidate {
1081            continue;
1082        }
1083        let other = if edge.from == id {
1084            &edge.to
1085        } else {
1086            &edge.from
1087        };
1088        let relation_score = relation_context_score(&edge.relation, profiles);
1089        let relation_query_score = relation_query_match_score(other, edge, terms, profiles);
1090        if selected_seed_ids.contains(other) {
1091            score += relation_score + relation_query_score + 40;
1092        } else {
1093            score += relation_score + relation_query_score;
1094        }
1095    }
1096    score += node_query_support_score(id, terms, node_by_id);
1097    score
1098}
1099
1100fn node_query_support_score(id: &str, terms: &[String], node_by_id: &HashMap<&str, &Node>) -> i64 {
1101    let Some(node) = node_by_id.get(id) else {
1102        return 0;
1103    };
1104    let support = support_term_count(node, terms) as i64;
1105    if support == 0 {
1106        return 0;
1107    }
1108    let multiplier = match node.kind {
1109        Kind::Function | Kind::Type | Kind::Trait | Kind::Module => 14,
1110        Kind::Doc | Kind::Section | Kind::Skill | Kind::Agent => 10,
1111        Kind::Unknown => 0,
1112    };
1113    support * multiplier
1114}
1115
1116fn relation_query_match_score(
1117    current_id: &str,
1118    edge: &Edge,
1119    terms: &[String],
1120    profiles: &std::collections::BTreeMap<String, RelationProfile>,
1121) -> i64 {
1122    if terms.is_empty() {
1123        return 0;
1124    }
1125    let phrase = if edge.from == current_id {
1126        relation_phrase(&edge.relation, profiles)
1127    } else if edge.to == current_id {
1128        reverse_relation_phrase(&edge.relation, profiles)
1129    } else {
1130        return 0;
1131    };
1132    let phrase_terms = terms_of(&phrase.to_lowercase());
1133    if phrase_terms.is_empty() {
1134        return 0;
1135    }
1136    let matched = phrase_terms
1137        .iter()
1138        .filter(|phrase_term| terms.iter().any(|term| term == *phrase_term))
1139        .count();
1140    if matched == 0 {
1141        return 0;
1142    }
1143    if matched == phrase_terms.len() {
1144        36
1145    } else {
1146        (matched as i64) * 12
1147    }
1148}
1149
1150fn kind_context_score(kind: Kind) -> i64 {
1151    match kind {
1152        Kind::Function | Kind::Type | Kind::Trait => 16,
1153        Kind::Module => 10,
1154        Kind::Skill | Kind::Agent => 8,
1155        Kind::Doc | Kind::Section => 6,
1156        Kind::Unknown => 0,
1157    }
1158}
1159
1160fn relation_context_score(
1161    relation: &str,
1162    profiles: &std::collections::BTreeMap<String, RelationProfile>,
1163) -> i64 {
1164    if let Some(profile) = profiles.get(relation) {
1165        return profile.context_score;
1166    }
1167    if let Some(profile) = core_relation_profile(relation) {
1168        return profile.context_score;
1169    }
1170    match relation {
1171        "depends_on" => 30,
1172        "produces" => 30,
1173        "consumes" => 28,
1174        "requires_contract" => 22,
1175        "uses_tool" => 22,
1176        "dispatches" => 22,
1177        "related_to" => 18,
1178        _ => 4,
1179    }
1180}
1181
1182fn source_key(node: &Node) -> String {
1183    if let Some(span) = &node.span {
1184        return span.path.clone();
1185    }
1186    node.source_files
1187        .first()
1188        .cloned()
1189        .unwrap_or_else(|| node.id.clone())
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194    use super::*;
1195
1196    #[test]
1197    fn focus_walks_resolved_references_but_not_lexical_references() {
1198        let graph = Graph {
1199            nodes: vec![
1200                test_node("fn.config::load_settings", "load_settings", Kind::Function),
1201                test_node(
1202                    "fn.worker::apply_settings",
1203                    "apply_settings",
1204                    Kind::Function,
1205                ),
1206                test_node("fn.local::maybe_settings", "maybe_settings", Kind::Function),
1207            ],
1208            edges: vec![
1209                Edge {
1210                    from: "fn.worker::apply_settings".to_string(),
1211                    to: "fn.config::load_settings".to_string(),
1212                    relation: crate::schema::relation::REFERENCES.to_string(),
1213                    evidence: "resolved reference".to_string(),
1214                    basis: EdgeBasis::Resolved,
1215                    ..Default::default()
1216                },
1217                Edge {
1218                    from: "fn.local::maybe_settings".to_string(),
1219                    to: "fn.config::load_settings".to_string(),
1220                    relation: crate::schema::relation::REFERENCES.to_string(),
1221                    evidence: "local lexical reference".to_string(),
1222                    basis: EdgeBasis::Lexical,
1223                    ..Default::default()
1224                },
1225            ],
1226            ..Default::default()
1227        };
1228
1229        let sg = ground_subgraph(&graph, "where is load_settings used", 1, 1, 8);
1230
1231        assert!(
1232            sg.expanded
1233                .contains(&"fn.worker::apply_settings".to_string())
1234        );
1235        assert!(
1236            !sg.expanded
1237                .contains(&"fn.local::maybe_settings".to_string())
1238        );
1239    }
1240
1241    #[test]
1242    fn focus_context_is_budgeted_but_keeps_high_value_neighbours() {
1243        let mut nodes = vec![
1244            test_node("fn.config::load_settings", "load_settings", Kind::Function),
1245            test_node("type.config::Settings", "Settings", Kind::Type),
1246        ];
1247        let mut edges = vec![Edge {
1248            from: "fn.config::load_settings".to_string(),
1249            to: "type.config::Settings".to_string(),
1250            relation: crate::schema::relation::REFERENCES.to_string(),
1251            evidence: "resolved type reference".to_string(),
1252            basis: EdgeBasis::Resolved,
1253            ..Default::default()
1254        }];
1255        for i in 0..80 {
1256            let id = format!("fn.noise::helper_{i}");
1257            nodes.push(test_node(&id, &format!("helper_{i}"), Kind::Function));
1258            edges.push(Edge {
1259                from: "fn.config::load_settings".to_string(),
1260                to: id,
1261                relation: crate::schema::relation::CONTAINS.to_string(),
1262                evidence: "wide container fanout".to_string(),
1263                basis: EdgeBasis::Resolved,
1264                ..Default::default()
1265            });
1266        }
1267        let graph = Graph {
1268            nodes,
1269            edges,
1270            ..Default::default()
1271        };
1272
1273        let sg = ground_subgraph(&graph, "load_settings", 1, 1, 100);
1274
1275        assert!(sg.context_order.len() <= DEFAULT_CONTEXT_BUDGET);
1276        assert!(sg.omitted_context > 0);
1277        assert!(
1278            sg.context_order
1279                .contains(&"type.config::Settings".to_string()),
1280            "budgeting should preserve high-value resolved neighbours"
1281        );
1282        assert!(
1283            sg.omitted_context_candidates
1284                .iter()
1285                .any(|candidate| candidate.reason == "pack_budget"
1286                    && candidate.node_id.starts_with("fn.noise::helper_")),
1287            "bounded focus should expose which candidate lost the pack budget: {:#?}",
1288            sg.omitted_context_candidates
1289        );
1290    }
1291
1292    #[test]
1293    fn focus_walk_prioritizes_type_references_over_lexical_neighbour_order() {
1294        let mut nodes = vec![
1295            test_node(
1296                "fn.engine::run_package_evals",
1297                "run_package_evals",
1298                Kind::Function,
1299            ),
1300            test_node(
1301                "type.engine::PackageEvalReport",
1302                "PackageEvalReport",
1303                Kind::Type,
1304            ),
1305        ];
1306        let mut edges = vec![Edge {
1307            from: "fn.engine::run_package_evals".to_string(),
1308            to: "type.engine::PackageEvalReport".to_string(),
1309            relation: crate::schema::relation::REFERENCES.to_string(),
1310            evidence: "resolved return type".to_string(),
1311            basis: EdgeBasis::Resolved,
1312            ..Default::default()
1313        }];
1314        for i in 0..12 {
1315            let id = format!("fn.alpha::helper_{i}");
1316            nodes.push(test_node(&id, &format!("helper_{i}"), Kind::Function));
1317            edges.push(Edge {
1318                from: "fn.engine::run_package_evals".to_string(),
1319                to: id,
1320                relation: crate::schema::relation::REFERENCES.to_string(),
1321                evidence: "resolved helper call".to_string(),
1322                basis: EdgeBasis::Resolved,
1323                ..Default::default()
1324            });
1325        }
1326        let graph = Graph {
1327            nodes,
1328            edges,
1329            ..Default::default()
1330        };
1331
1332        let sg = ground_subgraph(&graph, "run_package_evals", 1, 1, 4);
1333
1334        assert!(
1335            sg.context_order
1336                .contains(&"type.engine::PackageEvalReport".to_string()),
1337            "bounded expansion should keep the directly referenced report type"
1338        );
1339    }
1340
1341    #[test]
1342    fn focus_walk_prioritizes_container_children_before_external_references() {
1343        let nodes = vec![
1344            test_node("type.policy::RetryPolicy", "RetryPolicy", Kind::Type),
1345            test_node(
1346                "fn.policy::RetryPolicy::should_retry",
1347                "should_retry",
1348                Kind::Function,
1349            ),
1350            test_node("type.policy::Backoff", "Backoff", Kind::Type),
1351            test_node("fn.queue::Task::new", "new", Kind::Function),
1352            test_node(
1353                "fn.scheduler::Scheduler::submit_fire_and_forget",
1354                "submit_fire_and_forget",
1355                Kind::Function,
1356            ),
1357        ];
1358        let edges = vec![
1359            Edge {
1360                from: "type.policy::RetryPolicy".to_string(),
1361                to: "fn.policy::RetryPolicy::should_retry".to_string(),
1362                relation: crate::schema::relation::CONTAINS.to_string(),
1363                evidence: "method".to_string(),
1364                basis: EdgeBasis::Resolved,
1365                ..Default::default()
1366            },
1367            Edge {
1368                from: "type.policy::RetryPolicy".to_string(),
1369                to: "type.policy::Backoff".to_string(),
1370                relation: crate::schema::relation::REFERENCES.to_string(),
1371                evidence: "field type".to_string(),
1372                basis: EdgeBasis::Resolved,
1373                ..Default::default()
1374            },
1375            Edge {
1376                from: "fn.queue::Task::new".to_string(),
1377                to: "type.policy::RetryPolicy".to_string(),
1378                relation: crate::schema::relation::REFERENCES.to_string(),
1379                evidence: "constructor argument".to_string(),
1380                basis: EdgeBasis::Resolved,
1381                ..Default::default()
1382            },
1383            Edge {
1384                from: "fn.scheduler::Scheduler::submit_fire_and_forget".to_string(),
1385                to: "type.policy::RetryPolicy".to_string(),
1386                relation: crate::schema::relation::REFERENCES.to_string(),
1387                evidence: "policy constructor".to_string(),
1388                basis: EdgeBasis::Resolved,
1389                ..Default::default()
1390            },
1391        ];
1392        let graph = Graph {
1393            nodes,
1394            edges,
1395            ..Default::default()
1396        };
1397
1398        let sg = ground_subgraph(&graph, "RetryPolicy", 1, 1, 2);
1399
1400        assert!(
1401            sg.context_order
1402                .contains(&"fn.policy::RetryPolicy::should_retry".to_string()),
1403            "bounded expansion from a type should keep its own methods before external references"
1404        );
1405    }
1406
1407    #[test]
1408    fn focus_pack_skips_unreceipted_expanded_nodes() {
1409        let graph = Graph {
1410            nodes: vec![
1411                test_node("fn.config::load_settings", "load_settings", Kind::Function),
1412                Node {
1413                    id: "trait.Default".to_string(),
1414                    kind: Kind::Trait,
1415                    subkind: None,
1416                    title: "Default".to_string(),
1417                    summary: String::new(),
1418                    aliases: Vec::new(),
1419                    tags: Vec::new(),
1420                    query_examples: Vec::new(),
1421                    source_files: Vec::new(),
1422                    span: None,
1423                    partition: None,
1424                },
1425            ],
1426            edges: vec![Edge {
1427                from: "fn.config::load_settings".to_string(),
1428                to: "trait.Default".to_string(),
1429                relation: crate::schema::relation::IMPLEMENTS.to_string(),
1430                evidence: "synthetic trait implementation".to_string(),
1431                basis: EdgeBasis::Resolved,
1432                ..Default::default()
1433            }],
1434            ..Default::default()
1435        };
1436
1437        let sg = ground_subgraph(&graph, "load_settings", 1, 1, 8);
1438
1439        assert!(
1440            !sg.context_order.contains(&"trait.Default".to_string()),
1441            "expanded task context should stay receipt-backed"
1442        );
1443        assert!(sg.omitted_context > 0);
1444        assert!(
1445            sg.omitted_context_candidates
1446                .iter()
1447                .any(|candidate| candidate.node_id == "trait.Default"
1448                    && candidate.reason == "missing_receipt"),
1449            "focus should explain receipt-backed omissions: {:#?}",
1450            sg.omitted_context_candidates
1451        );
1452    }
1453
1454    #[test]
1455    fn focus_explains_source_cap_omissions() {
1456        let mut nodes = vec![test_node("doc.route", "Route", Kind::Doc)];
1457        let mut edges = Vec::new();
1458        for i in 0..12 {
1459            let id = format!("doc.route.section-{i}");
1460            nodes.push(Node {
1461                id: id.clone(),
1462                kind: Kind::Section,
1463                subkind: None,
1464                title: format!("Route Section {i}"),
1465                summary: "Route support section.".to_string(),
1466                aliases: Vec::new(),
1467                tags: Vec::new(),
1468                query_examples: Vec::new(),
1469                source_files: vec!["fixtures/route.md".to_string()],
1470                span: Some(crate::schema::Span {
1471                    path: "fixtures/route.md".to_string(),
1472                    start_line: i + 1,
1473                    end_line: i + 1,
1474                }),
1475                partition: None,
1476            });
1477            edges.push(Edge {
1478                from: "doc.route".to_string(),
1479                to: id,
1480                relation: crate::schema::relation::CONTAINS.to_string(),
1481                evidence: "section".to_string(),
1482                basis: EdgeBasis::Resolved,
1483                ..Default::default()
1484            });
1485        }
1486        let graph = Graph {
1487            nodes,
1488            edges,
1489            ..Default::default()
1490        };
1491
1492        let sg = ground_subgraph(&graph, "Route", 1, 1, 20);
1493
1494        assert!(
1495            sg.omitted_context_candidates
1496                .iter()
1497                .any(|candidate| candidate.reason == "source_cap"
1498                    && candidate.node_id.starts_with("doc.route.section-")),
1499            "focus should explain when same-source candidates are capped: {:#?}",
1500            sg.omitted_context_candidates
1501        );
1502    }
1503
1504    #[test]
1505    fn relation_profiles_can_disable_focus_traversal() {
1506        let graph = Graph {
1507            nodes: vec![
1508                test_node("doc.seed", "Seed", Kind::Doc),
1509                test_node("doc.external", "External", Kind::Doc),
1510            ],
1511            edges: vec![Edge {
1512                from: "doc.seed".to_string(),
1513                to: "doc.external".to_string(),
1514                relation: "catalogs".to_string(),
1515                evidence: "frontmatter".to_string(),
1516                basis: EdgeBasis::Resolved,
1517                ..Default::default()
1518            }],
1519            relation_profiles: [(
1520                "catalogs".to_string(),
1521                RelationProfile {
1522                    forward_phrase: "catalogs".to_string(),
1523                    reverse_phrase: "cataloged by".to_string(),
1524                    context_score: 30,
1525                    searchable: true,
1526                    traversable: false,
1527                    domain_kinds: Vec::new(),
1528                    range_kinds: Vec::new(),
1529                },
1530            )]
1531            .into_iter()
1532            .collect(),
1533        };
1534
1535        let sg = ground_subgraph(&graph, "Seed", 1, 1, 8);
1536
1537        assert!(!sg.context_order.contains(&"doc.external".to_string()));
1538    }
1539
1540    #[test]
1541    fn focus_walk_prefers_query_supporting_code_neighbour_when_width_is_tight() {
1542        let graph = Graph {
1543            nodes: vec![
1544                test_node(
1545                    "fn.claims::Engine::aether_claim_state",
1546                    "aether_claim_state",
1547                    Kind::Function,
1548                ),
1549                test_node_with_summary(
1550                    "fn.claims::State::ledger_parse_errors",
1551                    "ledger_parse_errors",
1552                    Kind::Function,
1553                    "Count of failed ledger lines; a broken ledger leaves the claim bridge blind.",
1554                ),
1555                test_node(
1556                    "fn.claims::Engine::vault_root",
1557                    "vault_root",
1558                    Kind::Function,
1559                ),
1560            ],
1561            edges: vec![
1562                Edge {
1563                    from: "fn.claims::Engine::aether_claim_state".to_string(),
1564                    to: "fn.claims::State::ledger_parse_errors".to_string(),
1565                    relation: crate::schema::relation::REFERENCES.to_string(),
1566                    evidence: "resolved reference".to_string(),
1567                    basis: EdgeBasis::Resolved,
1568                    ..Default::default()
1569                },
1570                Edge {
1571                    from: "fn.claims::Engine::aether_claim_state".to_string(),
1572                    to: "fn.claims::Engine::vault_root".to_string(),
1573                    relation: crate::schema::relation::REFERENCES.to_string(),
1574                    evidence: "resolved reference".to_string(),
1575                    basis: EdgeBasis::Resolved,
1576                    ..Default::default()
1577                },
1578            ],
1579            ..Default::default()
1580        };
1581
1582        let sg = ground_subgraph(&graph, "debug claim ledger aether_claim_state", 1, 1, 1);
1583
1584        assert!(
1585            sg.context_order
1586                .contains(&"fn.claims::State::ledger_parse_errors".to_string()),
1587            "task-matching code support should beat generic code neighbours under tight width"
1588        );
1589    }
1590
1591    #[test]
1592    fn connected_code_support_hit_can_seed_without_title_match() {
1593        let graph = Graph {
1594            nodes: vec![
1595                test_node(
1596                    "fn.claims::Engine::aether_claim_state",
1597                    "aether_claim_state",
1598                    Kind::Function,
1599                ),
1600                test_node_with_summary(
1601                    "fn.claims::State::ledger_parse_errors",
1602                    "ledger_parse_errors",
1603                    Kind::Function,
1604                    "Count of failed ledger lines; a broken ledger leaves the claim bridge blind.",
1605                ),
1606                test_node_with_summary(
1607                    "fn.misc::run",
1608                    "run",
1609                    Kind::Function,
1610                    "Debug claim ledger helper with no relation to the route.",
1611                ),
1612            ],
1613            edges: vec![Edge {
1614                from: "fn.claims::Engine::aether_claim_state".to_string(),
1615                to: "fn.claims::State::ledger_parse_errors".to_string(),
1616                relation: crate::schema::relation::REFERENCES.to_string(),
1617                evidence: "resolved reference".to_string(),
1618                basis: EdgeBasis::Resolved,
1619                ..Default::default()
1620            }],
1621            ..Default::default()
1622        };
1623
1624        let sg = ground_subgraph(&graph, "debug claim ledger aether_claim_state", 3, 0, 0);
1625
1626        assert!(
1627            sg.context_order
1628                .contains(&"fn.claims::State::ledger_parse_errors".to_string()),
1629            "connected support code should seed beside the chosen route"
1630        );
1631        assert!(
1632            !sg.context_order.contains(&"fn.misc::run".to_string()),
1633            "unconnected body-only code should remain out of the seed set"
1634        );
1635    }
1636
1637    #[test]
1638    fn focus_route_keeps_relation_anchored_source_over_named_target() {
1639        let graph = Graph {
1640            nodes: vec![
1641                test_node("doc.contract", "Deploy Contract", Kind::Doc),
1642                test_node(
1643                    "fn.release::check_deploy_readiness",
1644                    "check_deploy_readiness",
1645                    Kind::Function,
1646                ),
1647            ],
1648            edges: vec![Edge {
1649                from: "doc.contract".to_string(),
1650                to: "fn.release::check_deploy_readiness".to_string(),
1651                relation: "verifies".to_string(),
1652                evidence: "frontmatter".to_string(),
1653                basis: EdgeBasis::Resolved,
1654                ..Default::default()
1655            }],
1656            relation_profiles: [(
1657                "verifies".to_string(),
1658                RelationProfile::new("verifies", "verified by", 30),
1659            )]
1660            .into_iter()
1661            .collect(),
1662        };
1663
1664        let sg = ground_subgraph(&graph, "what verifies check_deploy_readiness", 5, 1, 4);
1665
1666        assert_eq!(
1667            sg.hits.first().map(|hit| hit.id.as_str()),
1668            Some("doc.contract")
1669        );
1670        assert_eq!(
1671            sg.route, "doc.contract",
1672            "focus routing should preserve a typed relation anchor instead of rerouting to the named endpoint"
1673        );
1674    }
1675
1676    #[test]
1677    fn focus_expansion_prefers_query_named_relation_when_width_is_tight() {
1678        let graph = Graph {
1679            nodes: vec![
1680                test_node("doc.gate", "Release Gate", Kind::Doc),
1681                test_node("doc.runbook", "Deploy Runbook", Kind::Doc),
1682                test_node("fn.release::check", "check", Kind::Function),
1683            ],
1684            edges: vec![
1685                Edge {
1686                    from: "doc.gate".to_string(),
1687                    to: "doc.runbook".to_string(),
1688                    relation: "blocks".to_string(),
1689                    evidence: "frontmatter".to_string(),
1690                    basis: EdgeBasis::Resolved,
1691                    ..Default::default()
1692                },
1693                Edge {
1694                    from: "doc.gate".to_string(),
1695                    to: "fn.release::check".to_string(),
1696                    relation: "verifies".to_string(),
1697                    evidence: "frontmatter".to_string(),
1698                    basis: EdgeBasis::Resolved,
1699                    ..Default::default()
1700                },
1701            ],
1702            relation_profiles: [
1703                (
1704                    "blocks".to_string(),
1705                    RelationProfile::new("blocks", "blocked by", 31),
1706                ),
1707                (
1708                    "verifies".to_string(),
1709                    RelationProfile::new("verifies", "verified by", 29),
1710                ),
1711            ]
1712            .into_iter()
1713            .collect(),
1714        };
1715
1716        let sg = ground_subgraph(&graph, "release gate verifies", 1, 1, 1);
1717
1718        assert!(
1719            sg.context_order.contains(&"fn.release::check".to_string()),
1720            "the relation named in the query should win bounded focus expansion"
1721        );
1722        assert!(
1723            !sg.context_order.contains(&"doc.runbook".to_string()),
1724            "a slightly higher static context_score should not beat the query's explicit relation intent"
1725        );
1726    }
1727
1728    #[test]
1729    fn focus_route_prefers_title_anchored_fallback_over_body_only_noise() {
1730        let graph = Graph {
1731            nodes: vec![
1732                test_node_with_summary(
1733                    "fn.tests::run",
1734                    "run",
1735                    Kind::Function,
1736                    "trace package enforcement eidos harness helper",
1737                ),
1738                test_node_with_summary(
1739                    "fn.engine::check_eval_gates",
1740                    "check_eval_gates",
1741                    Kind::Function,
1742                    "enforces configured package gates",
1743                ),
1744            ],
1745            edges: Vec::new(),
1746            ..Default::default()
1747        };
1748
1749        let sg = ground_subgraph(&graph, "trace package eval gate enforcement eidos", 2, 1, 8);
1750
1751        assert_eq!(sg.route, "fn.engine::check_eval_gates");
1752        assert_eq!(
1753            sg.context_order.first().map(String::as_str),
1754            Some("fn.engine::check_eval_gates")
1755        );
1756        assert!(
1757            !sg.context_order.contains(&"fn.tests::run".to_string()),
1758            "body-only fallback hits should stay in hits but not seed the focus pack"
1759        );
1760    }
1761
1762    #[test]
1763    fn focus_route_prefers_code_anchor_for_code_action_query() {
1764        let graph = Graph {
1765            nodes: vec![
1766                test_node_with_summary(
1767                    "skill.retry-policy",
1768                    "retry-policy",
1769                    Kind::Skill,
1770                    "Guide for retry flow changes and delay_ms safety.",
1771                ),
1772                test_node_with_summary(
1773                    "fn.policy::RetryPolicy::delay_ms",
1774                    "delay_ms",
1775                    Kind::Function,
1776                    "Computes the retry delay.",
1777                ),
1778            ],
1779            edges: Vec::new(),
1780            ..Default::default()
1781        };
1782        let hits = vec![
1783            Hit {
1784                id: "skill.retry-policy".to_string(),
1785                score: 120,
1786                lexical_score: 120,
1787                confidence: Confidence::Strong,
1788                why: Vec::new(),
1789                relation_matches: Vec::new(),
1790                anchor: 1.0,
1791                relation_anchor: false,
1792            },
1793            Hit {
1794                id: "fn.policy::RetryPolicy::delay_ms".to_string(),
1795                score: 60,
1796                lexical_score: 60,
1797                confidence: Confidence::Weak,
1798                why: Vec::new(),
1799                relation_matches: Vec::new(),
1800                anchor: 1.0,
1801                relation_anchor: false,
1802            },
1803        ];
1804
1805        let route = select_subgraph_route(
1806            &hits,
1807            &graph,
1808            &[
1809                "trace".to_string(),
1810                "delayms".to_string(),
1811                "used".to_string(),
1812                "retry".to_string(),
1813                "flow".to_string(),
1814            ],
1815        );
1816
1817        assert_eq!(route, "fn.policy::RetryPolicy::delay_ms");
1818    }
1819
1820    #[test]
1821    fn code_action_route_prefers_explicit_identifier_over_related_helper_terms() {
1822        let graph = Graph {
1823            nodes: vec![
1824                test_node(
1825                    "fn.billing::sync_invoice_batch",
1826                    "sync_invoice_batch",
1827                    Kind::Function,
1828                ),
1829                test_node(
1830                    "fn.billing::build_idempotency_key",
1831                    "build_idempotency_key",
1832                    Kind::Function,
1833                ),
1834            ],
1835            edges: Vec::new(),
1836            ..Default::default()
1837        };
1838        let hits = vec![
1839            Hit {
1840                id: "fn.billing::sync_invoice_batch".to_string(),
1841                score: 81,
1842                lexical_score: 81,
1843                confidence: Confidence::Strong,
1844                why: Vec::new(),
1845                relation_matches: Vec::new(),
1846                anchor: 1.0,
1847                relation_anchor: false,
1848            },
1849            Hit {
1850                id: "fn.billing::build_idempotency_key".to_string(),
1851                score: 36,
1852                lexical_score: 64,
1853                confidence: Confidence::Fallback,
1854                why: Vec::new(),
1855                relation_matches: Vec::new(),
1856                anchor: 0.8,
1857                relation_anchor: false,
1858            },
1859        ];
1860
1861        let route = select_subgraph_route(
1862            &hits,
1863            &graph,
1864            &[
1865                "trace".to_string(),
1866                "sync_invoice_batch".to_string(),
1867                "build".to_string(),
1868                "idempotency".to_string(),
1869                "key".to_string(),
1870                "billing".to_string(),
1871                "sync".to_string(),
1872            ],
1873        );
1874
1875        assert_eq!(route, "fn.billing::sync_invoice_batch");
1876    }
1877
1878    #[test]
1879    fn focus_route_keeps_exact_query_example_front_door_before_code_override() {
1880        let mut doc = test_node("doc.mcp-prompts", "MCP Prompt Workflows", Kind::Doc);
1881        doc.query_examples = vec!["how should coding agents use Eidos MCP prompts".to_string()];
1882        let graph = Graph {
1883            nodes: vec![
1884                doc,
1885                test_node(
1886                    "fn.eidos::mcp::start_coding_task",
1887                    "start_coding_task",
1888                    Kind::Function,
1889                ),
1890            ],
1891            edges: Vec::new(),
1892            ..Default::default()
1893        };
1894        let hits = vec![
1895            Hit {
1896                id: "doc.mcp-prompts".to_string(),
1897                score: 171,
1898                lexical_score: 171,
1899                confidence: Confidence::Ambiguous,
1900                why: vec!["exact query_example match".to_string()],
1901                relation_matches: Vec::new(),
1902                anchor: 1.0,
1903                relation_anchor: false,
1904            },
1905            Hit {
1906                id: "fn.eidos::mcp::start_coding_task".to_string(),
1907                score: 146,
1908                lexical_score: 146,
1909                confidence: Confidence::Strong,
1910                why: Vec::new(),
1911                relation_matches: Vec::new(),
1912                anchor: 1.0,
1913                relation_anchor: false,
1914            },
1915        ];
1916
1917        let route = select_subgraph_route(
1918            &hits,
1919            &graph,
1920            &[
1921                "code".to_string(),
1922                "agent".to_string(),
1923                "use".to_string(),
1924                "eidos".to_string(),
1925                "mcp".to_string(),
1926                "prompt".to_string(),
1927            ],
1928        );
1929
1930        assert_eq!(route, "doc.mcp-prompts");
1931    }
1932
1933    #[test]
1934    fn code_action_route_prefers_ranked_hit_before_loose_subject_count() {
1935        let graph = Graph {
1936            nodes: vec![
1937                test_node(
1938                    "fn.eidos::mcp::EidosMcpServer::deep_code_review",
1939                    "deep_code_review",
1940                    Kind::Function,
1941                ),
1942                test_node(
1943                    "fn.eidos::mcp_consent_guard::mcp_source_never_references_the_sigil_write_surface",
1944                    "mcp_source_never_references_the_sigil_write_surface",
1945                    Kind::Function,
1946                ),
1947            ],
1948            edges: Vec::new(),
1949            ..Default::default()
1950        };
1951        let hits = vec![
1952            Hit {
1953                id: "fn.eidos::mcp::EidosMcpServer::deep_code_review".to_string(),
1954                score: 160,
1955                lexical_score: 160,
1956                confidence: Confidence::Ambiguous,
1957                why: Vec::new(),
1958                relation_matches: Vec::new(),
1959                anchor: 1.0,
1960                relation_anchor: false,
1961            },
1962            Hit {
1963                id: "fn.eidos::mcp_consent_guard::mcp_source_never_references_the_sigil_write_surface"
1964                    .to_string(),
1965                score: 123,
1966                lexical_score: 123,
1967                confidence: Confidence::Strong,
1968                why: Vec::new(),
1969                relation_matches: Vec::new(),
1970                anchor: 1.0,
1971                relation_anchor: false,
1972            },
1973        ];
1974
1975        let route = select_subgraph_route(
1976            &hits,
1977            &graph,
1978            &[
1979                "review".to_string(),
1980                "eidos".to_string(),
1981                "mcp".to_string(),
1982                "prompt".to_string(),
1983                "surface".to_string(),
1984                "implementation".to_string(),
1985            ],
1986        );
1987
1988        assert_eq!(route, "fn.eidos::mcp::EidosMcpServer::deep_code_review");
1989    }
1990
1991    #[test]
1992    fn high_value_receipted_docs_can_seed_beside_confident_code() {
1993        let mut doc = test_node_with_summary(
1994            "doc.billing",
1995            "Billing Sync Runbook",
1996            Kind::Doc,
1997            "The idempotency key protects provider calls.",
1998        );
1999        doc.source_files.push("docs/billing.md".to_string());
2000        let graph = Graph {
2001            nodes: vec![doc],
2002            edges: Vec::new(),
2003            ..Default::default()
2004        };
2005        let hit = Hit {
2006            id: "doc.billing".to_string(),
2007            score: 37,
2008            lexical_score: 56,
2009            confidence: Confidence::Fallback,
2010            why: Vec::new(),
2011            relation_matches: Vec::new(),
2012            anchor: 0.5,
2013            relation_anchor: false,
2014        };
2015
2016        assert!(is_high_value_support_seed(
2017            &graph,
2018            &hit,
2019            &[
2020                "idempotency".to_string(),
2021                "key".to_string(),
2022                "billing".to_string(),
2023            ]
2024        ));
2025    }
2026
2027    #[test]
2028    fn code_action_route_requires_subject_match_not_just_control_word() {
2029        let graph = Graph {
2030            nodes: vec![
2031                test_node("doc.retry-policy", "Retry Policy", Kind::Doc),
2032                test_node("fn.misc::change", "change", Kind::Function),
2033            ],
2034            edges: Vec::new(),
2035            ..Default::default()
2036        };
2037        let hits = vec![
2038            Hit {
2039                id: "doc.retry-policy".to_string(),
2040                score: 80,
2041                lexical_score: 80,
2042                confidence: Confidence::Strong,
2043                why: Vec::new(),
2044                relation_matches: Vec::new(),
2045                anchor: 1.0,
2046                relation_anchor: false,
2047            },
2048            Hit {
2049                id: "fn.misc::change".to_string(),
2050                score: 70,
2051                lexical_score: 70,
2052                confidence: Confidence::Strong,
2053                why: Vec::new(),
2054                relation_matches: Vec::new(),
2055                anchor: 1.0,
2056                relation_anchor: false,
2057            },
2058        ];
2059
2060        let route = select_subgraph_route(
2061            &hits,
2062            &graph,
2063            &[
2064                "change".to_string(),
2065                "retry".to_string(),
2066                "policy".to_string(),
2067            ],
2068        );
2069
2070        assert_eq!(route, "doc.retry-policy");
2071    }
2072
2073    #[test]
2074    fn route_term_matching_ignores_identifier_separators() {
2075        let skill = test_node("skill.retry-policy", "retry-policy", Kind::Skill);
2076        let type_node = test_node("type.policy::RetryPolicy", "RetryPolicy", Kind::Type);
2077
2078        assert_eq!(route_term_count(&skill, &["retrypolicy".to_string()]), 1);
2079        assert_eq!(
2080            route_term_count(&type_node, &["retry_policy".to_string()]),
2081            1
2082        );
2083    }
2084
2085    #[test]
2086    fn docs_can_seed_from_supporting_text_when_route_is_anchored() {
2087        let graph = Graph {
2088            nodes: vec![
2089                test_node("skill.retry-policy", "retry-policy", Kind::Skill),
2090                test_node_with_summary(
2091                    "doc.architecture",
2092                    "System Architecture",
2093                    Kind::Doc,
2094                    "The retry flow explains where delay_ms is used.",
2095                ),
2096            ],
2097            edges: Vec::new(),
2098            ..Default::default()
2099        };
2100        let hit = Hit {
2101            id: "doc.architecture".to_string(),
2102            score: 30,
2103            lexical_score: 30,
2104            confidence: Confidence::Fallback,
2105            why: Vec::new(),
2106            relation_matches: Vec::new(),
2107            anchor: 0.0,
2108            relation_anchor: false,
2109        };
2110
2111        assert!(should_seed_hit(
2112            &graph,
2113            &hit,
2114            "skill.retry-policy",
2115            true,
2116            &["retry".to_string(), "flow".to_string()]
2117        ));
2118    }
2119
2120    #[test]
2121    fn compound_terms_ignore_vocabulary_target_control_words() {
2122        let terms = vec![
2123            "agent".to_string(),
2124            "workflow".to_string(),
2125            "target".to_string(),
2126            "candidates".to_string(),
2127            "anchor".to_string(),
2128        ];
2129
2130        assert_eq!(
2131            compound_terms(&terms),
2132            vec!["agent".to_string(), "workflow".to_string()]
2133        );
2134    }
2135
2136    #[test]
2137    fn compound_focus_surfaces_multi_anchor_primary_skill() {
2138        let graph = Graph {
2139            nodes: vec![
2140                test_node_with_summary(
2141                    "doc.forecast-2026",
2142                    "2026 Revenue Forecast",
2143                    Kind::Doc,
2144                    "Baseline forecast assumptions for fiscal year 2026.",
2145                ),
2146                test_node_with_summary(
2147                    "doc.seattle-clients",
2148                    "Seattle Client Segment Map",
2149                    Kind::Doc,
2150                    "Seattle client segmentation and expansion probability.",
2151                ),
2152                test_node_with_summary(
2153                    "skill.client-forecast",
2154                    "Client Forecast Analysis",
2155                    Kind::Skill,
2156                    "Combine forecast assumptions with client segment evidence.",
2157                ),
2158            ],
2159            edges: vec![
2160                Edge {
2161                    from: "skill.client-forecast".to_string(),
2162                    to: "doc.forecast-2026".to_string(),
2163                    relation: "depends_on".to_string(),
2164                    evidence: "frontmatter".to_string(),
2165                    basis: EdgeBasis::Resolved,
2166                    ..Default::default()
2167                },
2168                Edge {
2169                    from: "skill.client-forecast".to_string(),
2170                    to: "doc.seattle-clients".to_string(),
2171                    relation: "depends_on".to_string(),
2172                    evidence: "frontmatter".to_string(),
2173                    basis: EdgeBasis::Resolved,
2174                    ..Default::default()
2175                },
2176            ],
2177            ..Default::default()
2178        };
2179
2180        let sg = ground_subgraph(
2181            &graph,
2182            "forecast for year 2026 from Seattle clients",
2183            5,
2184            2,
2185            4,
2186        );
2187        let compound = sg.compound.expect("multi-facet query should be compound");
2188
2189        assert_eq!(compound.primary, "skill.client-forecast");
2190        assert!(compound.coverage >= 0.75, "compound={compound:#?}");
2191        assert!(
2192            compound
2193                .anchors
2194                .iter()
2195                .any(|anchor| anchor.node_id == "doc.forecast-2026"
2196                    && anchor.matched_terms.contains(&"2026".to_string()))
2197        );
2198        assert!(
2199            compound
2200                .anchors
2201                .iter()
2202                .any(|anchor| anchor.node_id == "doc.seattle-clients"
2203                    && anchor.matched_terms.contains(&"seattle".to_string()))
2204        );
2205    }
2206
2207    #[test]
2208    fn compound_focus_explains_covered_omitted_anchors() {
2209        let graph = Graph {
2210            nodes: vec![
2211                test_node_with_summary(
2212                    "doc.forecast-2026",
2213                    "2026 Revenue Forecast",
2214                    Kind::Doc,
2215                    "Baseline forecast assumptions for fiscal year 2026.",
2216                ),
2217                test_node_with_summary(
2218                    "doc.forecast-2026-alt",
2219                    "2026 Forecast Appendix",
2220                    Kind::Doc,
2221                    "Secondary forecast assumptions for fiscal year 2026.",
2222                ),
2223                test_node_with_summary(
2224                    "doc.seattle-clients",
2225                    "Seattle Client Segment Map",
2226                    Kind::Doc,
2227                    "Seattle client segmentation and expansion probability.",
2228                ),
2229                test_node_with_summary(
2230                    "skill.client-forecast",
2231                    "Client Forecast Analysis",
2232                    Kind::Skill,
2233                    "Combine forecast assumptions with client segment evidence.",
2234                ),
2235            ],
2236            edges: vec![
2237                Edge {
2238                    from: "skill.client-forecast".to_string(),
2239                    to: "doc.forecast-2026".to_string(),
2240                    relation: "depends_on".to_string(),
2241                    evidence: "frontmatter".to_string(),
2242                    basis: EdgeBasis::Resolved,
2243                    ..Default::default()
2244                },
2245                Edge {
2246                    from: "skill.client-forecast".to_string(),
2247                    to: "doc.forecast-2026-alt".to_string(),
2248                    relation: "depends_on".to_string(),
2249                    evidence: "frontmatter".to_string(),
2250                    basis: EdgeBasis::Resolved,
2251                    ..Default::default()
2252                },
2253                Edge {
2254                    from: "skill.client-forecast".to_string(),
2255                    to: "doc.seattle-clients".to_string(),
2256                    relation: "depends_on".to_string(),
2257                    evidence: "frontmatter".to_string(),
2258                    basis: EdgeBasis::Resolved,
2259                    ..Default::default()
2260                },
2261            ],
2262            ..Default::default()
2263        };
2264
2265        let sg = ground_subgraph(
2266            &graph,
2267            "forecast for year 2026 from Seattle clients",
2268            8,
2269            2,
2270            4,
2271        );
2272        let compound = sg.compound.expect("multi-facet query should be compound");
2273
2274        assert!(
2275            compound
2276                .omitted_anchors
2277                .iter()
2278                .any(|anchor| anchor.node_id == "doc.forecast-2026-alt"
2279                    && anchor.reason == "covered_terms"
2280                    && anchor.matched_terms.contains(&"2026".to_string())),
2281            "compound focus should expose skipped duplicate-facet anchors: {compound:#?}"
2282        );
2283    }
2284
2285    fn test_node(id: &str, title: &str, kind: Kind) -> Node {
2286        test_node_with_summary(id, title, kind, "")
2287    }
2288
2289    fn test_node_with_summary(id: &str, title: &str, kind: Kind, summary: &str) -> Node {
2290        Node {
2291            id: id.to_string(),
2292            kind,
2293            subkind: None,
2294            title: title.to_string(),
2295            summary: summary.to_string(),
2296            aliases: Vec::new(),
2297            tags: Vec::new(),
2298            query_examples: Vec::new(),
2299            source_files: vec![format!("fixtures/{title}.rs")],
2300            span: None,
2301            partition: None,
2302        }
2303    }
2304}