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 pub route: String,
20 pub context_order: Vec<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub compound: Option<CompoundSubgraph>,
28 #[serde(default, skip_serializing_if = "is_zero_usize")]
30 pub omitted_context: usize,
31 #[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
75const TASK_SEED_LEXICAL_RATIO: f64 = 0.5;
79const SYMBOL_SEED_LEXICAL_RATIO: f64 = 0.3;
82const DEFAULT_CONTEXT_BUDGET: usize = 48;
85const MAX_SEED_CONTEXT: usize = 16;
88const MAX_ITEMS_PER_SOURCE: usize = 8;
90const MAX_OMITTED_CONTEXT_CANDIDATES: usize = 12;
92
93pub 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 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 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}