majordomus_cli/devcontext/select.rs
1//! Which of the repository's objects a request reaches, and by which declared path.
2//!
3//! # Nothing here discovers anything
4//!
5//! Selection is a walk of two values the process already holds: the index, and the composed
6//! graph derived from it. The index was built once when the process loaded; the graph is
7//! [`crate::graph::COMPOSED`], the repository's own dependency graph, in which every typed
8//! reference the layer declares is already resolved to an edge. So there is no second
9//! relation table here, no glob, no read of a file and no walk of the working tree — which
10//! is what lets a request be answered inside a handler rather than at load time. The
11//! precedent and the reason are `crate::plan`'s: "`App::load` has a stated budget and 200
12//! project records have no business inside it."
13//!
14//! # The traversal policy is the only thing this module decides
15//!
16//! The graph says which things are related and how; it does not say which of those
17//! relations carry *context*. [`EDGES`] is that decision, and it is small on purpose: a
18//! weight per direction for each edge kind that carries context, and [`REFUSED`] for the
19//! kinds the compiler will not follow at all. `is_a` is refused because every object of a
20//! kind hangs off one node and following it would select the whole layer; `composes` and
21//! `projects` are refused because they describe this executable's own wiring rather than
22//! the repository being worked on. `policy` projects the table so that a caller can read
23//! the decision instead of inferring it from the answer.
24//!
25//! The two tables are complementary and not overlapping, and that is the invariant worth
26//! seeing: a refusal is an edge kind's *absence* from [`EDGES`], so [`edge_policy`]
27//! answering `None` is the refusal rather than a weight of zero standing for one. There is
28//! therefore no way to be in both tables, and no way to be followed at weight nothing.
29//!
30//! ```
31//! use majordomus_cli::devcontext::edge_policy;
32//! use majordomus_cli::devcontext::select::{EDGES, REFUSED};
33//!
34//! // everything in the table is followed, in at least one direction, at a real weight
35//! for e in EDGES {
36//! assert!(edge_policy(e.kind).is_some(), "{} is in the table", e.kind);
37//! assert!(e.forward > 0.0 || e.reverse > 0.0, "{} is followed at nothing", e.kind);
38//! }
39//!
40//! // and a refused kind is absent from it, with the reason the report prints
41//! let refused: Vec<&str> = REFUSED.iter().map(|(kind, _)| *kind).collect();
42//! assert_eq!(refused, ["is_a", "composes", "projects"]);
43//! for (kind, why) in REFUSED {
44//! assert!(edge_policy(kind).is_none(), "{kind} is both followed and refused");
45//! assert!(!why.is_empty(), "{kind} is refused and does not say why");
46//! }
47//! ```
48
49use std::collections::{BTreeMap, BTreeSet, VecDeque};
50
51use serde_json::Value;
52
53use crate::capability::handler::Context;
54use crate::graph::{Graph, Node};
55use crate::index::Index;
56use crate::model::Object;
57
58use super::model::{Discovery, Selector, Tier};
59
60/// Which way an edge is worth following, and what it means in each direction.
61///
62/// An edge of the composed graph has one kind and two readings, and the two are rarely
63/// worth the same. From an issue, the milestone it `belongs_to` is strong context; from the
64/// milestone, any one of its thirty-three issues is weak. So the weight is per direction,
65/// and so is the reason — which is prose a reader of the answer sees, not a comment here.
66///
67/// A weight is a multiplier on the relevance of the entry the edge was followed *from*, so
68/// relevance decays with distance and the rate of decay is the edge's own rather than a
69/// global constant.
70///
71/// ```
72/// use majordomus_cli::devcontext::{edge_policy, EdgePolicy};
73///
74/// // `belongs_to` is asymmetric, and the asymmetry is the point
75/// let belongs_to: &EdgePolicy = edge_policy("belongs_to").unwrap();
76/// assert!(
77/// belongs_to.forward > belongs_to.reverse,
78/// "an issue's milestone is context; a milestone's every issue is not",
79/// );
80/// assert_ne!(belongs_to.forward_reason, belongs_to.reverse_reason);
81///
82/// // `supersedes` is symmetric: either version is context for the other
83/// let supersedes: &EdgePolicy = edge_policy("supersedes").unwrap();
84/// assert_eq!(supersedes.forward, supersedes.reverse);
85///
86/// // and both readings of every followed kind carry words a reader of the answer sees
87/// for kind in ["depends_on", "governed_by", "tested_by"] {
88/// let e = edge_policy(kind).unwrap();
89/// assert!(!e.forward_reason.is_empty() && !e.reverse_reason.is_empty(), "{kind}");
90/// }
91/// ```
92#[derive(Debug, Clone, Copy)]
93pub struct EdgePolicy {
94 /// The edge kind, as the composed graph names it.
95 pub kind: &'static str,
96 /// Relevance multiplier when followed from source to target; `0.0` to refuse.
97 pub forward: f64,
98 /// Relevance multiplier when followed from target to source; `0.0` to refuse.
99 pub reverse: f64,
100 /// Why the target is context for the source.
101 pub forward_reason: &'static str,
102 /// Why the source is context for the target.
103 pub reverse_reason: &'static str,
104}
105
106/// Which of the composed graph's edges carry context, and how strongly.
107///
108/// A weight is a multiplier on the relevance of the entry the edge was followed from, so
109/// relevance decays with distance and the decay is the edge's own. An edge absent from this
110/// table is not followed at all, and `policy` reports it as refused with the reason.
111pub const EDGES: &[EdgePolicy] = &[
112 EdgePolicy {
113 kind: "belongs_to",
114 forward: 0.95,
115 reverse: 0.45,
116 forward_reason: "the milestone this work belongs to",
117 reverse_reason: "another issue of the same milestone",
118 },
119 EdgePolicy {
120 kind: "depends_on",
121 forward: 0.9,
122 reverse: 0.6,
123 forward_reason: "it declares a dependency on this",
124 reverse_reason: "this declares a dependency on it",
125 },
126 EdgePolicy {
127 kind: "governed_by",
128 forward: 0.9,
129 reverse: 0.5,
130 forward_reason: "it is governed by this",
131 reverse_reason: "this is governed by it",
132 },
133 EdgePolicy {
134 kind: "put_in_force",
135 forward: 0.8,
136 reverse: 0.85,
137 forward_reason: "the decision put this in force",
138 reverse_reason: "the decision that put it in force",
139 },
140 EdgePolicy {
141 kind: "supersedes",
142 forward: 0.7,
143 reverse: 0.7,
144 forward_reason: "it stands in for this",
145 reverse_reason: "this stands in for it",
146 },
147 EdgePolicy {
148 kind: "tracks",
149 forward: 0.7,
150 reverse: 0.9,
151 forward_reason: "the contract tracks this file",
152 reverse_reason: "the contract that tracks it",
153 },
154 EdgePolicy {
155 kind: "implemented_by",
156 forward: 0.85,
157 reverse: 0.85,
158 forward_reason: "the claim is implemented by this",
159 reverse_reason: "a claim this implements",
160 },
161 EdgePolicy {
162 kind: "tested_by",
163 forward: 0.85,
164 reverse: 0.85,
165 forward_reason: "the claim is proved by this case",
166 reverse_reason: "a claim this case proves",
167 },
168 EdgePolicy {
169 kind: "defined_in",
170 forward: 0.8,
171 reverse: 0.8,
172 forward_reason: "the claim is defined in this file",
173 reverse_reason: "a claim defined in it",
174 },
175 EdgePolicy {
176 kind: "exercises",
177 forward: 0.6,
178 reverse: 0.7,
179 forward_reason: "the scenario exercises this",
180 reverse_reason: "a scenario that exercises it",
181 },
182 EdgePolicy {
183 kind: "evidences",
184 forward: 0.6,
185 reverse: 0.6,
186 forward_reason: "it is evidence for this claim",
187 reverse_reason: "what stands as evidence for it",
188 },
189 EdgePolicy {
190 kind: "runs",
191 forward: 0.6,
192 reverse: 0.5,
193 forward_reason: "the scenario runs this command",
194 reverse_reason: "a scenario that runs it",
195 },
196 EdgePolicy {
197 kind: "related_to",
198 forward: 0.7,
199 reverse: 0.7,
200 forward_reason: "it names this as related",
201 reverse_reason: "it is named as related by this",
202 },
203 EdgePolicy {
204 kind: "serves",
205 forward: 0.5,
206 reverse: 0.4,
207 forward_reason: "the scenario serves this application",
208 reverse_reason: "a scenario that serves it",
209 },
210];
211
212/// The edges the compiler refuses to follow, and why. Read by `devcontext.policy` so that
213/// the refusal is inspectable state rather than an absence a reader has to notice.
214pub const REFUSED: &[(&str, &str)] = &[
215 (
216 "is_a",
217 "every object of a kind hangs off one node; following it would select the whole layer",
218 ),
219 (
220 "composes",
221 "this executable's own wiring, not the repository being worked on",
222 ),
223 (
224 "projects",
225 "this executable's own wiring, not the repository being worked on",
226 ),
227];
228
229/// The policy for one edge kind, or `None` when the compiler refuses to follow it.
230///
231/// ```
232/// use majordomus_cli::devcontext::edge_policy;
233/// assert!(edge_policy("depends_on").is_some());
234/// assert!(edge_policy("is_a").is_none());
235/// ```
236pub fn edge_policy(kind: &str) -> Option<&'static EdgePolicy> {
237 EDGES.iter().find(|e| e.kind == kind)
238}
239
240/// Which tier an object of this kind is spent from, once it is not a seed.
241///
242/// ```
243/// use majordomus_cli::devcontext::{tier_for_kind, Tier};
244/// assert_eq!(tier_for_kind("adr"), Tier::Decision);
245/// assert_eq!(tier_for_kind("test"), Tier::Source);
246/// assert_eq!(tier_for_kind("policy"), Tier::Governance);
247/// ```
248pub fn tier_for_kind(kind: &str) -> Tier {
249 match kind {
250 "issue" | "milestone" | "project" => Tier::Task,
251 "policy" | "scope" | "rule" | "context" | "profile" => Tier::Governance,
252 "implementation" | "test" | "file" | "command" => Tier::Source,
253 "adr" => Tier::Decision,
254 "session" | "session-context" | "knowledge" | "prompt" | "skill" => Tier::Knowledge,
255 _ => Tier::History,
256 }
257}
258
259// ---------------------------------------------------------------- candidates
260
261/// One thing the compiler reached, before any budget was spent on it. The compiler folds
262/// every path that reached the same identifier into one of these, which is where
263/// deduplication happens: the [`Candidate`] is the unit, and `discovered_by` is the
264/// evidence of how many ways there were to find it.
265///
266/// The three scalar fields are folded rather than overwritten as paths arrive, and each
267/// keeps the *best* value any path gave: the highest `relevance`, the shortest `depth`, and
268/// the strongest `tier`. Strongest means lowest, because [`Tier`] is ordered by authority —
269/// so a thing reached once as a seed and again three hops out along a weak edge is still a
270/// `Task`-tier entry at relevance 1 and depth 0. Nothing a later path says can demote it.
271///
272/// `object` and `path` are the two ways a candidate can have a body: `object` indexes into
273/// the index's own objects, and `path` names a file the layer pointed at without indexing.
274/// Both can be absent, for something the layer names and this repository does not hold.
275///
276/// ```
277/// use std::collections::BTreeMap;
278///
279/// use majordomus_cli::devcontext::select::Candidate;
280/// use majordomus_cli::devcontext::{Discovery, Selector, Tier};
281///
282/// let way = |selector: Selector, depth: usize, confidence: f64| Discovery {
283/// selector,
284/// reason: "for the sake of the example".into(),
285/// via: None,
286/// edge: None,
287/// depth,
288/// confidence,
289/// weight: 1.0,
290/// };
291///
292/// // one thing, found twice: named in the request, and again by matching the intent
293/// let c = Candidate {
294/// uri: "majordomus://adr/0052".into(),
295/// kind: "adr".into(),
296/// title: Some("The episode is the boundary".into()),
297/// tier: Tier::Task,
298/// relevance: 1.0,
299/// depth: 0,
300/// discovered_by: vec![
301/// way(Selector::Seed, 0, 1.0),
302/// way(Selector::IntentMatch, 2, 0.4),
303/// ],
304/// object: None,
305/// path: None,
306/// status: None,
307/// facts: BTreeMap::new(),
308/// };
309///
310/// // two recorded paths is the fact worth keeping, not something to hide
311/// assert_eq!(c.discovered_by.len(), 2);
312/// // and the entry is trusted as far as its *best* path, not averaged down by its worst
313/// assert_eq!(c.confidence(), 1.0);
314/// ```
315#[derive(Debug, Clone)]
316pub struct Candidate {
317 /// The canonical identifier.
318 pub uri: String,
319 /// The kind, as the layer declared it.
320 pub kind: String,
321 /// The title, when there is one.
322 pub title: Option<String>,
323 /// The tier its cost comes out of.
324 pub tier: Tier,
325 /// The best relevance any path gave it.
326 pub relevance: f64,
327 /// The shortest depth any path reached it at.
328 pub depth: usize,
329 /// Every path that reached it.
330 pub discovered_by: Vec<Discovery>,
331 /// The object, when the index holds one under this identifier.
332 pub object: Option<usize>,
333 /// The file the thing lives in, when it is not an object of the index.
334 pub path: Option<String>,
335 /// The status word the object declares.
336 pub status: Option<String>,
337 /// Facts worth carrying.
338 pub facts: BTreeMap<String, String>,
339}
340
341impl Candidate {
342 fn merge(&mut self, relevance: f64, depth: usize, tier: Tier, d: Discovery) {
343 if relevance > self.relevance {
344 self.relevance = relevance;
345 }
346 if depth < self.depth {
347 self.depth = depth;
348 }
349 // a seed stays a seed however else it was reached; otherwise the strongest
350 // authority wins, and Task is the strongest
351 if tier < self.tier {
352 self.tier = tier;
353 }
354 // A path is a *kind* of way in, not an edge traversal. Thirty-three issues of one
355 // milestone all reach that milestone along `belongs_to`, and recording that
356 // thirty-three times would say the answer had thirty-three reasons when it has
357 // one. So a discovery already recorded for this selector, edge and reason is kept
358 // rather than repeated, and replaced only by a shorter or stronger way in.
359 if let Some(existing) = self
360 .discovered_by
361 .iter_mut()
362 .find(|e| e.selector == d.selector && e.edge == d.edge && e.reason == d.reason)
363 {
364 if d.depth < existing.depth || (d.depth == existing.depth && d.weight > existing.weight)
365 {
366 *existing = d;
367 }
368 return;
369 }
370 self.discovered_by.push(d);
371 }
372
373 /// The confidence of the most trusted path that reached it: the maximum, not the mean.
374 ///
375 /// Averaging would be wrong in a way that matters. Confidence here is about the
376 /// *provenance of the selection* — 1.0 when the repository declared the path, less when
377 /// the compiler inferred it from words — and a thing the layer explicitly declares does
378 /// not become less certain because a weaker guess also found it. Finding more ways to
379 /// something can only raise this number.
380 ///
381 /// A candidate with no recorded path is 0.0 rather than an error, which is also right:
382 /// nothing vouches for it.
383 ///
384 /// ```
385 /// use std::collections::BTreeMap;
386 ///
387 /// use majordomus_cli::devcontext::select::Candidate;
388 /// use majordomus_cli::devcontext::{Discovery, Selector, Tier};
389 ///
390 /// let mut c = Candidate {
391 /// uri: "majordomus://rule/project.x@1".into(),
392 /// kind: "rule".into(),
393 /// title: None,
394 /// tier: Tier::Governance,
395 /// relevance: 0.6,
396 /// depth: 1,
397 /// discovered_by: Vec::new(),
398 /// object: None,
399 /// path: None,
400 /// status: None,
401 /// facts: BTreeMap::new(),
402 /// };
403 ///
404 /// // nothing vouches for it yet
405 /// assert_eq!(c.confidence(), 0.0);
406 ///
407 /// // an inferred path: the words of the intent occurred in its own words
408 /// c.discovered_by.push(Discovery {
409 /// selector: Selector::IntentMatch,
410 /// reason: "the intent's words occur in it".into(),
411 /// via: None,
412 /// edge: None,
413 /// depth: 1,
414 /// confidence: 0.4,
415 /// weight: 0.5,
416 /// });
417 /// assert_eq!(c.confidence(), 0.4);
418 ///
419 /// // and then a declared one, which the guess cannot dilute
420 /// c.discovered_by.push(Discovery {
421 /// selector: Selector::Governance,
422 /// reason: "a rule the layer applies to everything".into(),
423 /// via: None,
424 /// edge: None,
425 /// depth: 0,
426 /// confidence: 1.0,
427 /// weight: 1.0,
428 /// });
429 /// assert_eq!(c.confidence(), 1.0, "the best path decides, never the average");
430 /// ```
431 pub fn confidence(&self) -> f64 {
432 self.discovered_by
433 .iter()
434 .map(|d| d.confidence)
435 .fold(0.0_f64, f64::max)
436 }
437}
438
439/// The candidates a request reaches, and everything the walk learned on the way.
440///
441/// Keyed by canonical identifier, which is what makes structural deduplication a property
442/// of the type rather than a step: one identifier cannot be in here twice, so the question
443/// "was this reached more than once" is answered by a candidate's `discovered_by` and never
444/// by scanning for duplicates.
445///
446/// The other two fields are what the walk could not do, and neither is an error.
447/// `unresolved` is a name the layer declares and this repository holds nothing under — a
448/// dangling reference, which is a fact about the repository worth reporting. `diagnostics`
449/// is what could not be read. The budget turns both into rows of the answer rather than
450/// swallowing them.
451///
452/// ```
453/// use std::collections::BTreeMap;
454///
455/// use majordomus_cli::devcontext::select::{Candidate, Selection};
456/// use majordomus_cli::devcontext::{Discovery, Selector, Tier};
457///
458/// let uri = "majordomus://adr/0052";
459/// let candidate = Candidate {
460/// uri: uri.into(),
461/// kind: "adr".into(),
462/// title: None,
463/// tier: Tier::Decision,
464/// relevance: 0.8,
465/// depth: 1,
466/// discovered_by: vec![Discovery {
467/// selector: Selector::Relation,
468/// reason: "the decision in force over this scope".into(),
469/// via: Some("majordomus://issue/I0301".into()),
470/// edge: Some("governed_by".into()),
471/// depth: 1,
472/// confidence: 1.0,
473/// weight: 0.9,
474/// }],
475/// object: None,
476/// path: None,
477/// status: None,
478/// facts: BTreeMap::new(),
479/// };
480///
481/// let selection = Selection {
482/// candidates: BTreeMap::from([(uri.to_string(), candidate)]),
483/// // a reference the layer declares and nothing answers: reported, not dropped
484/// unresolved: vec![("majordomus://rule/gone@1".into(), "depends_on".into())],
485/// diagnostics: Vec::new(),
486/// };
487///
488/// // the identifier is the key, so one thing is one entry however many paths found it
489/// assert_eq!(selection.candidates.len(), 1);
490/// assert_eq!(selection.candidates[uri].discovered_by.len(), 1);
491/// assert!(selection.candidates.contains_key(uri));
492///
493/// // and a dangling reference survives to be reported as one
494/// assert_eq!(selection.unresolved[0].0, "majordomus://rule/gone@1");
495/// ```
496pub struct Selection {
497 /// By canonical identifier.
498 pub candidates: BTreeMap<String, Candidate>,
499 /// Names the layer declares that resolve to nothing this repository holds.
500 pub unresolved: Vec<(String, String)>,
501 /// What could not be read, never an error.
502 pub diagnostics: Vec<crate::model::Diagnostic>,
503}
504
505/// What the walk was asked to start from and how far it may go.
506///
507/// This is the *resolved* request, not the caller's. Every short name is already a canonical
508/// identifier, every path has been normalised, and the free-text intent has already become a
509/// set of terms by [`intent_terms`] — so the walk parses nothing and infers nothing about
510/// what it was asked. That separation is what keeps the inference in one place where its
511/// confidence can be labelled, instead of spread through the traversal.
512///
513/// `paths` is wider than what the caller named: it is the union of the request's own paths
514/// and the scope every seed declares, which is how naming an issue selects the contracts
515/// over the code that issue is about without the caller listing them.
516///
517/// ```
518/// use std::collections::BTreeSet;
519///
520/// use majordomus_cli::devcontext::select::Request;
521/// use majordomus_cli::devcontext::{intent_terms, DEFAULT_FLOOR, DEFAULT_MAX_DEPTH};
522///
523/// let branch = String::from("master");
524/// let req = Request {
525/// seeds: vec!["majordomus://issue/I0301".into()],
526/// // the request named one path; the issue's own declared scope added the other
527/// paths: BTreeSet::from(["apps/majordomus-cli/src".to_string(), "lib".to_string()]),
528/// terms: intent_terms("Explain the CONTEXT budget"),
529/// max_depth: DEFAULT_MAX_DEPTH,
530/// floor: DEFAULT_FLOOR,
531/// all_blocking_rules: false,
532/// branch: Some(&branch),
533/// };
534///
535/// // the intent arrives parsed: lowercased, deduplicated, and without the short words
536/// assert!(req.terms.contains("context") && req.terms.contains("budget"));
537/// assert!(!req.terms.contains("CONTEXT"), "the walk never re-parses the intent");
538/// assert!(!req.terms.contains("the"));
539///
540/// // and the branch is borrowed, because it belongs to the checkout and not to the request
541/// assert_eq!(req.branch, Some("master"));
542/// assert_eq!(req.seeds.len(), 1);
543/// ```
544pub struct Request<'a> {
545 /// The canonical identifiers the request named, already resolved.
546 pub seeds: Vec<String>,
547 /// The repository-relative paths the request named, plus every seed's declared scope.
548 pub paths: BTreeSet<String>,
549 /// The words of the intent, lowercased and deduplicated.
550 pub terms: BTreeSet<String>,
551 /// The furthest from a seed the walk goes.
552 pub max_depth: usize,
553 /// Relevance below this is reached and not offered.
554 pub floor: f64,
555 /// Every blocking rule of the layer, not only the ones a seed reaches.
556 pub all_blocking_rules: bool,
557 /// The branch the checkout is on, for selecting the sessions that ran on it.
558 pub branch: Option<&'a str>,
559}
560
561/// The words of a free-text intent, lowercased, three characters or more, deduplicated.
562///
563/// ```
564/// use majordomus_cli::devcontext::intent_terms;
565/// let t = intent_terms("Make the CONTEXT compiler explain a budget");
566/// assert!(t.contains("context"));
567/// assert!(t.contains("budget"));
568/// assert!(!t.contains("a"));
569/// ```
570pub fn intent_terms(intent: &str) -> BTreeSet<String> {
571 intent
572 .split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
573 .filter(|w| w.chars().count() >= 3)
574 .map(str::to_lowercase)
575 .filter(|w| !STOP_WORDS.contains(&w.as_str()))
576 .collect()
577}
578
579/// Words too common to mean anything about relevance. Short on purpose: a longer list is a
580/// language model, and this is a term filter.
581const STOP_WORDS: &[&str] = &[
582 "and", "are", "but", "for", "from", "has", "have", "into", "not", "one", "that", "the",
583 "their", "them", "then", "there", "this", "was", "were", "what", "when", "which", "will",
584 "with", "you", "your",
585];
586
587/// Is `path` the same as `under`, or below it?
588fn is_under(under: &str, path: &str) -> bool {
589 path == under || path.starts_with(&format!("{under}/"))
590}
591
592/// A metadata value as one line of text, for a fact or a term match.
593fn scalar(v: &Value) -> Option<String> {
594 match v {
595 Value::String(s) => Some(s.clone()),
596 Value::Number(n) => Some(n.to_string()),
597 Value::Bool(b) => Some(b.to_string()),
598 _ => None,
599 }
600}
601
602fn strings(v: &Value, key: &str) -> Vec<String> {
603 v.get(key)
604 .and_then(Value::as_array)
605 .map(|a| a.iter().filter_map(scalar).collect())
606 .unwrap_or_default()
607}
608
609/// The facts the compiler carries about an object: never its prose, only the short
610/// declared values a reader of the answer would otherwise have to open the file for.
611fn facts_of(o: &Object) -> BTreeMap<String, String> {
612 let mut out = BTreeMap::new();
613 for key in [
614 "class",
615 "status",
616 "priority",
617 "milestone",
618 "profile",
619 "date",
620 "version",
621 "updated_at",
622 "branch",
623 "head",
624 "task_id",
625 ] {
626 if let Some(v) = o.metadata.get(key).and_then(scalar) {
627 out.insert(key.to_string(), v);
628 }
629 }
630 let tags = strings(&o.metadata, "tags");
631 if !tags.is_empty() {
632 out.insert("tags".into(), tags.join(", "));
633 }
634 let scope = strings(&o.metadata, "scope");
635 if !scope.is_empty() {
636 out.insert("scope".into(), scope.join(", "));
637 }
638 out
639}
640
641/// The version or date an object states about itself, for the answer's `version` field.
642///
643/// One field, filled from whichever of four keys the object happens to declare, in a fixed
644/// order of preference: `updated_at`, `version`, `date`, `created_at`. The order is "when
645/// was this last true" before "which revision is it" before "when was it first written",
646/// because the answer's `version` exists so that a reader can spot a stale entry, and the
647/// freshest self-description is the one that serves that.
648///
649/// The kinds of the layer disagree about which key they carry — a rule has a `version`, an
650/// ADR a `date`, an issue an `updated_at` — so this is a fallback chain rather than a
651/// lookup. `None` is ordinary: plenty of objects say nothing about their own age.
652///
653/// ```
654/// use majordomus_cli::devcontext::select::version_of;
655/// use majordomus_cli::model::{Object, Provenance};
656/// use serde_json::json;
657///
658/// // the object as the index holds it; only its metadata differs between the cases
659/// let with = |metadata: serde_json::Value| -> Option<String> {
660/// let o = Object {
661/// kind: "rule".into(),
662/// identity: "project.x@2".into(),
663/// uri: "majordomus://rule/project.x@2".into(),
664/// title: None,
665/// description: None,
666/// metadata,
667/// body: String::new(),
668/// content: String::new(),
669/// media_type: "text/markdown",
670/// provenance: Provenance {
671/// path: ".ai/repo/rules/project/x.v2.md".into(),
672/// directory: ".ai/repo/rules/project".into(),
673/// source_class: "rule".into(),
674/// section: Some("rules".into()),
675/// bytes: 2048,
676/// member: None,
677/// },
678/// };
679/// version_of(&o)
680/// };
681///
682/// // the freshest self-description wins, whichever keys are present
683/// assert_eq!(
684/// with(json!({"updated_at": "2026-09-12", "version": 2, "created_at": "2026-08-01"})),
685/// Some("2026-09-12".into()),
686/// );
687/// assert_eq!(with(json!({"version": 2, "date": "2026-08-01"})), Some("2".into()));
688/// assert_eq!(with(json!({"date": "2026-08-01"})), Some("2026-08-01".into()));
689///
690/// // and saying nothing about its own age is ordinary, not a fault
691/// assert_eq!(with(json!({})), None);
692/// ```
693pub fn version_of(o: &Object) -> Option<String> {
694 for key in ["updated_at", "version", "date", "created_at"] {
695 if let Some(v) = o.metadata.get(key).and_then(scalar) {
696 return Some(v);
697 }
698 }
699 None
700}
701
702// ---------------------------------------------------------------- the walk
703
704/// Reach every candidate the request implies: the seeds, the graph around them, the paths
705/// they declare, the governance the layer applies, the session that ran before, and — when
706/// the request gave an intent — what its words match.
707///
708/// Nothing is read here. `graph` is [`crate::graph::COMPOSED`] already derived over the
709/// index, so every typed reference the layer declares is an edge before this function
710/// starts, and the walk is over two values the process is holding. That is what lets a
711/// request be answered inside a handler rather than at load time.
712///
713/// It returns a [`Selection`] and never an error: an intent that matches nothing, a seed
714/// whose scope is empty, a reference the layer declares and nothing answers — each of those
715/// is a fact about the repository, carried in `unresolved` or `diagnostics`, and not a
716/// failure of the call.
717///
718/// The governance selector is why a request that names nothing still gets an answer: the
719/// policy and the scope apply to everything, so they are reached without a seed.
720///
721/// ```
722/// use std::collections::BTreeSet;
723///
724/// use majordomus_cli::devcontext::select::{select, Request};
725/// use majordomus_cli::devcontext::{DEFAULT_FLOOR, DEFAULT_MAX_DEPTH};
726/// use majordomus_cli::graph;
727/// use majordomus_cli::synthetic::SyntheticRepository;
728///
729/// let repo = SyntheticRepository::small().unwrap();
730/// let ctx = repo.context().unwrap();
731/// let composed = graph::composed_complete(&ctx.registry, ctx.index.as_ref());
732///
733/// // a request that names nothing at all
734/// let empty = Request {
735/// seeds: Vec::new(),
736/// paths: BTreeSet::new(),
737/// terms: BTreeSet::new(),
738/// max_depth: DEFAULT_MAX_DEPTH,
739/// floor: DEFAULT_FLOOR,
740/// all_blocking_rules: false,
741/// branch: None,
742/// };
743/// let selection = select(&ctx, &composed, &empty);
744///
745/// // it still reaches the governance, because the governance applies without being asked for
746/// assert!(
747/// selection.candidates.values().any(|c| c.kind == "policy"),
748/// "a request that names nothing still gets what every session is held to",
749/// );
750///
751/// // and every candidate carries at least one recorded way in: nothing is in the
752/// // selection without something that says how it got there
753/// for c in selection.candidates.values() {
754/// assert!(!c.discovered_by.is_empty(), "{} arrived unexplained", c.uri);
755/// assert!(c.confidence() > 0.0, "{} is vouched for by nothing", c.uri);
756/// }
757/// ```
758pub fn select(ctx: &Context, graph: &Graph, req: &Request<'_>) -> Selection {
759 let index = ctx.index.as_ref();
760 let by_uri: BTreeMap<&str, usize> = index
761 .objects
762 .iter()
763 .enumerate()
764 .map(|(i, o)| (o.uri.as_str(), i))
765 .collect();
766 let nodes: BTreeMap<&str, &Node> = graph.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
767
768 let mut sel = Selection {
769 candidates: BTreeMap::new(),
770 unresolved: Vec::new(),
771 diagnostics: Vec::new(),
772 };
773
774 // 1. the seeds themselves: what was asked for, at relevance 1
775 for uri in &req.seeds {
776 let d = Discovery {
777 selector: Selector::Seed,
778 reason: "named in the request".into(),
779 via: None,
780 edge: None,
781 depth: 0,
782 confidence: 1.0,
783 weight: 1.0,
784 };
785 offer(&mut sel, index, &by_uri, &nodes, uri, Tier::Task, 1.0, 0, d);
786 }
787
788 // 2. the graph around them, breadth first, so the first time a thing is reached is by
789 // its shortest path and the walk visits each node once
790 walk(&mut sel, index, &by_uri, &nodes, graph, req);
791
792 // 3. the paths the work is about: the ones the request named, plus the scope the seeds
793 // and their immediate neighbours declare. An issue states its own scope, so a
794 // request that names an issue never has to restate it.
795 let mut effective = req.paths.clone();
796 for c in sel.candidates.values() {
797 if c.depth > 1 || !matches!(c.kind.as_str(), "issue" | "milestone") {
798 continue;
799 }
800 if let Some(i) = c.object {
801 for s in strings(&index.objects[i].metadata, "scope") {
802 let s = s.trim().trim_end_matches('/');
803 if !s.is_empty() && !s.starts_with('/') && !s.split('/').any(|p| p == "..") {
804 effective.insert(s.to_string());
805 }
806 }
807 }
808 }
809
810 // 4. what those paths reach: the code, the cases, and the contracts over them
811 paths(&mut sel, index, &by_uri, &nodes, &effective);
812
813 // 5. the governance the layer applies to everything it holds
814 governance(&mut sel, index, &by_uri, &nodes, req);
815
816 // 6. what ran before: the indexed session records of this branch or of these paths,
817 // and this checkout's own local records through `continuity.state`
818 sessions(&mut sel, index, &by_uri, &nodes, &effective, req.branch);
819 local_state(ctx, &mut sel);
820
821 // 7. the intent, which is the only inference in the whole selection
822 if !req.terms.is_empty() {
823 intent(&mut sel, index, &by_uri, &nodes, req);
824 }
825
826 // 8. a source *this context* names and the repository does not hold. The layer's own
827 // resolver answers it, and the answer is narrowed to the objects that were selected:
828 // a reference dangling somewhere else in the repository is a finding for `doctor`,
829 // not a thing wrong with this context. Reported, never raised — an absent source is
830 // an answer, and a context short of one that says so beats one quietly short of it.
831 let declared_here: BTreeSet<&str> = sel
832 .candidates
833 .values()
834 .filter_map(|c| c.path.as_deref())
835 .collect();
836 for u in crate::graph::unresolved_relations(&ctx.registry, &index.objects) {
837 if !declared_here.contains(u.declared_in.as_str()) {
838 continue;
839 }
840 sel.unresolved.push((
841 u.reference.clone(),
842 format!(
843 "{} names it under `{}`; {}",
844 u.declared_in, u.key, u.correction
845 ),
846 ));
847 }
848 // Deduplicated without being ordered. This list is consumed once, by the budget, which
849 // turns each entry into a diagnostic and sorts the diagnostics; no surface ever renders
850 // the sequence built here. Sorting to make `dedup()` work was an opinion about an order
851 // nobody reads, and two opinions about one collection is what `project.canonical-order`
852 // exists to prevent — so the duplicates go and the discovery order stays.
853 let mut seen = std::collections::HashSet::new();
854 sel.unresolved.retain(|entry| seen.insert(entry.clone()));
855
856 sel
857}
858
859/// Put a candidate in, or fold another discovery into the one already there.
860#[allow(clippy::too_many_arguments)]
861fn offer(
862 sel: &mut Selection,
863 index: &Index,
864 by_uri: &BTreeMap<&str, usize>,
865 nodes: &BTreeMap<&str, &Node>,
866 uri: &str,
867 tier: Tier,
868 relevance: f64,
869 depth: usize,
870 d: Discovery,
871) {
872 if let Some(existing) = sel.candidates.get_mut(uri) {
873 existing.merge(relevance, depth, tier, d);
874 return;
875 }
876 let object = by_uri.get(uri).copied();
877 let (kind, title, path, status, facts) = match object {
878 Some(i) => {
879 let o = &index.objects[i];
880 (
881 o.kind.clone(),
882 o.title.clone(),
883 Some(o.provenance.path.clone()),
884 o.metadata.get("status").and_then(scalar),
885 facts_of(o),
886 )
887 }
888 None => match nodes.get(uri) {
889 Some(n) => (
890 n.kind.clone(),
891 Some(n.label.clone()),
892 n.source.clone(),
893 n.status.clone(),
894 BTreeMap::new(),
895 ),
896 // a name with nothing behind it is not a candidate; it is an unresolved
897 // reference, and step 7 of `select` reports those from the graph's own resolver
898 None => return,
899 },
900 };
901 sel.candidates.insert(
902 uri.to_string(),
903 Candidate {
904 uri: uri.to_string(),
905 kind,
906 title,
907 tier,
908 relevance,
909 depth,
910 discovered_by: vec![d],
911 object,
912 path,
913 status,
914 facts,
915 },
916 );
917}
918
919/// The breadth-first walk over the edges [`EDGES`] allows.
920fn walk(
921 sel: &mut Selection,
922 index: &Index,
923 by_uri: &BTreeMap<&str, usize>,
924 nodes: &BTreeMap<&str, &Node>,
925 graph: &Graph,
926 req: &Request<'_>,
927) {
928 // adjacency in both directions, built once for the request
929 let mut out: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
930 let mut inc: BTreeMap<&str, Vec<(&str, &str)>> = BTreeMap::new();
931 for e in &graph.edges {
932 if edge_policy(&e.kind).is_none() {
933 continue;
934 }
935 out.entry(e.source.as_str())
936 .or_default()
937 .push((e.target.as_str(), e.kind.as_str()));
938 inc.entry(e.target.as_str())
939 .or_default()
940 .push((e.source.as_str(), e.kind.as_str()));
941 }
942
943 let mut queue: VecDeque<(String, f64, usize)> = req
944 .seeds
945 .iter()
946 .filter(|u| sel.candidates.contains_key(u.as_str()))
947 .map(|u| (u.clone(), 1.0, 0usize))
948 .collect();
949 let mut seen: BTreeSet<String> = req.seeds.iter().cloned().collect();
950
951 while let Some((from, relevance, depth)) = queue.pop_front() {
952 if depth >= req.max_depth {
953 continue;
954 }
955 let steps = [(&out, true), (&inc, false)];
956 for (adjacency, forward) in steps {
957 for (to, kind) in adjacency.get(from.as_str()).into_iter().flatten() {
958 let Some(policy) = edge_policy(kind) else {
959 continue;
960 };
961 let weight = if forward {
962 policy.forward
963 } else {
964 policy.reverse
965 };
966 if weight <= 0.0 {
967 continue;
968 }
969 let next = relevance * weight;
970 if next < req.floor {
971 continue;
972 }
973 let reason = if forward {
974 policy.forward_reason
975 } else {
976 policy.reverse_reason
977 };
978 let d = Discovery {
979 selector: Selector::Relation,
980 reason: reason.to_string(),
981 via: Some(from.clone()),
982 edge: Some(kind.to_string()),
983 depth: depth + 1,
984 confidence: 1.0,
985 weight,
986 };
987 let tier = nodes
988 .get(*to)
989 .map(|n| tier_for_kind(&n.kind))
990 .unwrap_or(Tier::History);
991 offer(
992 sel,
993 index,
994 by_uri,
995 nodes,
996 to,
997 tier,
998 next,
999 depth + 1,
1000 d.clone(),
1001 );
1002 if seen.insert(to.to_string()) {
1003 queue.push_back((to.to_string(), next, depth + 1));
1004 }
1005 }
1006 }
1007 }
1008}
1009
1010/// What the declared paths reach: the code and the cases under them, and the directory
1011/// contracts that reach them.
1012///
1013/// The contract half reads the same three declared fields `majordomus context resolve`
1014/// reads — a subtree document's directory, an explicit document's `paths`, a document's
1015/// `tracks` — and applies them the way that command does. It is the projection of one rule,
1016/// not a second enforcement of it: the command is the authority and the gate.
1017fn paths(
1018 sel: &mut Selection,
1019 index: &Index,
1020 by_uri: &BTreeMap<&str, usize>,
1021 nodes: &BTreeMap<&str, &Node>,
1022 scope: &BTreeSet<String>,
1023) {
1024 for path in scope {
1025 for (i, o) in index.objects.iter().enumerate() {
1026 // the code and the cases: an object whose own file lies under the path
1027 if matches!(o.kind.as_str(), "implementation" | "test")
1028 && is_under(path, &o.provenance.path)
1029 {
1030 let d = Discovery {
1031 selector: Selector::ScopePath,
1032 reason: format!("under `{path}`, which the work declares as its scope"),
1033 via: Some(path.clone()),
1034 edge: None,
1035 depth: 1,
1036 confidence: 1.0,
1037 weight: 0.8,
1038 };
1039 offer(sel, index, by_uri, nodes, &o.uri, Tier::Source, 0.8, 1, d);
1040 continue;
1041 }
1042 if o.kind != "context"
1043 || o.metadata.get("status").and_then(scalar).as_deref() == Some("deprecated")
1044 {
1045 continue;
1046 }
1047 let scope = o
1048 .metadata
1049 .get("scope")
1050 .and_then(scalar)
1051 .unwrap_or_else(|| "directory".into());
1052 let reason = if scope == "subtree" && is_under(&o.provenance.directory, path) {
1053 Some(format!(
1054 "the contract of `{}` reaches `{path}` (scope subtree)",
1055 o.provenance.directory
1056 ))
1057 } else if scope == "directory" && o.provenance.directory == *path {
1058 Some(format!("the contract of `{path}` itself (scope directory)"))
1059 } else if scope == "explicit"
1060 && strings(&o.metadata, "paths")
1061 .iter()
1062 .any(|p| is_under(p, path))
1063 {
1064 Some(format!("`{path}` is a declared path of this contract"))
1065 } else if strings(&o.metadata, "tracks")
1066 .iter()
1067 .any(|p| is_under(p, path) || is_under(path, p))
1068 {
1069 Some(format!("the contract tracks `{path}`"))
1070 } else {
1071 None
1072 };
1073 let Some(reason) = reason else { continue };
1074 let d = Discovery {
1075 selector: Selector::Contract,
1076 reason,
1077 via: Some(path.clone()),
1078 edge: None,
1079 depth: 1,
1080 confidence: 1.0,
1081 weight: 0.85,
1082 };
1083 let _ = i;
1084 offer(
1085 sel,
1086 index,
1087 by_uri,
1088 nodes,
1089 &o.uri,
1090 Tier::Governance,
1091 0.85,
1092 1,
1093 d,
1094 );
1095 }
1096 }
1097}
1098
1099/// The governance the layer applies to everything: the policy and the scope, which are one
1100/// object each and are never dropped, and — when the request asks for them — every blocking
1101/// rule rather than only the ones a seed reaches.
1102fn governance(
1103 sel: &mut Selection,
1104 index: &Index,
1105 by_uri: &BTreeMap<&str, usize>,
1106 nodes: &BTreeMap<&str, &Node>,
1107 req: &Request<'_>,
1108) {
1109 for o in &index.objects {
1110 let (relevance, reason) = match o.kind.as_str() {
1111 "policy" => (
1112 1.0,
1113 "the repository's policy: it decides what every session is held to".to_string(),
1114 ),
1115 "scope" => (
1116 1.0,
1117 "the repository's scope: what a worker reads and what it never reads".to_string(),
1118 ),
1119 "rule" if req.all_blocking_rules => {
1120 let blocking =
1121 o.metadata.get("class").and_then(scalar).as_deref() == Some("blocking");
1122 if !blocking {
1123 continue;
1124 }
1125 (
1126 0.6,
1127 "a blocking rule of the layer; the request asked for all of them".to_string(),
1128 )
1129 }
1130 _ => continue,
1131 };
1132 let d = Discovery {
1133 selector: Selector::Governance,
1134 reason,
1135 via: None,
1136 edge: None,
1137 depth: 0,
1138 confidence: 1.0,
1139 weight: relevance,
1140 };
1141 offer(
1142 sel,
1143 index,
1144 by_uri,
1145 nodes,
1146 &o.uri,
1147 Tier::Governance,
1148 relevance,
1149 0,
1150 d,
1151 );
1152 }
1153}
1154
1155/// The indexed session records that ran on this branch, and the ones that changed a file
1156/// under a declared path. Both facts are the record's own; nothing is inferred.
1157fn sessions(
1158 sel: &mut Selection,
1159 index: &Index,
1160 by_uri: &BTreeMap<&str, usize>,
1161 nodes: &BTreeMap<&str, &Node>,
1162 scope: &BTreeSet<String>,
1163 branch: Option<&str>,
1164) {
1165 for o in index.objects.iter().filter(|o| o.kind == "session") {
1166 let record_branch = o.metadata.get("branch").and_then(scalar);
1167 let same_branch = match (branch, record_branch.as_deref()) {
1168 (Some(a), Some(b)) => a == b,
1169 _ => false,
1170 };
1171 let touched: Vec<String> = strings(&o.metadata, "changed_files")
1172 .into_iter()
1173 .filter(|f| scope.iter().any(|p| is_under(p, f)))
1174 .collect();
1175 let (relevance, reason) = if !touched.is_empty() {
1176 (
1177 0.75,
1178 format!(
1179 "a recorded session that changed {} file(s) under the declared scope",
1180 touched.len()
1181 ),
1182 )
1183 } else if same_branch {
1184 (
1185 0.5,
1186 format!(
1187 "a recorded session of this branch ({})",
1188 record_branch.as_deref().unwrap_or("?")
1189 ),
1190 )
1191 } else {
1192 continue;
1193 };
1194 let d = Discovery {
1195 selector: Selector::SessionState,
1196 reason,
1197 via: None,
1198 edge: None,
1199 depth: 1,
1200 confidence: 1.0,
1201 weight: relevance,
1202 };
1203 offer(
1204 sel,
1205 index,
1206 by_uri,
1207 nodes,
1208 &o.uri,
1209 Tier::Knowledge,
1210 relevance,
1211 1,
1212 d,
1213 );
1214 }
1215}
1216
1217/// The local records of this checkout, read through `continuity.state` rather than by
1218/// opening the files: the handover the resolver chose, the newest checkpoint, and the
1219/// active task. They are not objects of the index — `.ai/local/` is this checkout's own
1220/// state and never shared context — so they enter under a `local:` identifier, the way the
1221/// composed graph names a file it does not hold as an object.
1222fn local_state(ctx: &Context, sel: &mut Selection) {
1223 let state = match ctx.execute("continuity.state", serde_json::json!({})) {
1224 Ok(v) => v,
1225 Err(e) => {
1226 sel.diagnostics.push(crate::model::Diagnostic::warning(
1227 "continuity",
1228 None,
1229 format!("the local continuity state could not be read: {e}"),
1230 ));
1231 return;
1232 }
1233 };
1234 let add = |sel: &mut Selection, key: &str, what: &str, relevance: f64| {
1235 let Some(record) = state.get(key).filter(|v| !v.is_null()) else {
1236 return;
1237 };
1238 let Some(path) = record.get("path").and_then(Value::as_str) else {
1239 return;
1240 };
1241 let mut facts = BTreeMap::new();
1242 for f in [
1243 "created_at",
1244 "branch",
1245 "head",
1246 "task_id",
1247 "matched",
1248 "divergence",
1249 "next_action",
1250 "working_tree",
1251 ] {
1252 if let Some(v) = record.get(f).and_then(scalar) {
1253 facts.insert(f.to_string(), v);
1254 }
1255 }
1256 let divergence = record.get("divergence").and_then(Value::as_str);
1257 let trustworthy = matches!(divergence, Some("exact") | Some("advanced") | None);
1258 if !trustworthy {
1259 sel.diagnostics.push(crate::model::Diagnostic::warning(
1260 "stale_record",
1261 Some(path.to_string()),
1262 format!(
1263 "the {what} was written against a different context ({}); trust git, not this record",
1264 divergence.unwrap_or("unknown")
1265 ),
1266 ));
1267 }
1268 let bytes = std::fs::metadata(std::path::Path::new(&ctx.index.repository.root).join(path))
1269 .map(|m| m.len())
1270 .unwrap_or(0);
1271 facts.insert("bytes".into(), bytes.to_string());
1272 let uri = format!("local:{path}");
1273 let reason = match record.get("matched").and_then(Value::as_str) {
1274 Some(m) => format!("the {what} the two-tier resolver chose ({m})"),
1275 None => format!("the {what} of this checkout"),
1276 };
1277 sel.candidates.insert(
1278 uri.clone(),
1279 Candidate {
1280 uri,
1281 kind: format!("local-{key}"),
1282 title: Some(path.to_string()),
1283 tier: Tier::Knowledge,
1284 relevance: if trustworthy {
1285 relevance
1286 } else {
1287 relevance / 2.0
1288 },
1289 depth: 0,
1290 discovered_by: vec![Discovery {
1291 selector: Selector::SessionState,
1292 reason,
1293 via: None,
1294 edge: None,
1295 depth: 0,
1296 confidence: if trustworthy { 1.0 } else { 0.5 },
1297 weight: relevance,
1298 }],
1299 object: None,
1300 path: Some(path.to_string()),
1301 status: divergence.map(str::to_string),
1302 facts,
1303 },
1304 );
1305 };
1306 add(sel, "handover", "handover the last session left", 0.95);
1307 add(sel, "checkpoint", "newest checkpoint", 0.8);
1308}
1309
1310/// What the intent's words match. This is the compiler's only inference, and it says so:
1311/// the confidence is the share of the intent's terms that occur in the object's own title,
1312/// description and tags, never in its body, so a long file cannot match by accident.
1313fn intent(
1314 sel: &mut Selection,
1315 index: &Index,
1316 by_uri: &BTreeMap<&str, usize>,
1317 nodes: &BTreeMap<&str, &Node>,
1318 req: &Request<'_>,
1319) {
1320 for o in &index.objects {
1321 let mut haystack = String::new();
1322 if let Some(t) = &o.title {
1323 haystack.push_str(t);
1324 haystack.push(' ');
1325 }
1326 if let Some(d) = &o.description {
1327 haystack.push_str(d);
1328 haystack.push(' ');
1329 }
1330 haystack.push_str(&o.identity);
1331 haystack.push(' ');
1332 for t in strings(&o.metadata, "tags") {
1333 haystack.push_str(&t);
1334 haystack.push(' ');
1335 }
1336 let haystack = haystack.to_lowercase();
1337 let hits: Vec<&String> = req.terms.iter().filter(|t| haystack.contains(*t)).collect();
1338 if hits.is_empty() {
1339 continue;
1340 }
1341 // the share of the intent that this object answers, capped: an inference never
1342 // outranks something the repository declared
1343 let confidence = (hits.len() as f64 / req.terms.len() as f64).min(1.0);
1344 let relevance = 0.6 * confidence;
1345 if relevance < req.floor {
1346 continue;
1347 }
1348 let mut named: Vec<&str> = hits.iter().map(|s| s.as_str()).collect();
1349 crate::order::canonical_strings(&mut named);
1350 let d = Discovery {
1351 selector: Selector::IntentMatch,
1352 reason: format!(
1353 "the intent's term(s) {} occur in its own title, description, identity or tags",
1354 named.join(", ")
1355 ),
1356 via: None,
1357 edge: None,
1358 depth: 1,
1359 confidence,
1360 weight: relevance,
1361 };
1362 offer(
1363 sel,
1364 index,
1365 by_uri,
1366 nodes,
1367 &o.uri,
1368 tier_for_kind(&o.kind),
1369 relevance,
1370 1,
1371 d,
1372 );
1373 }
1374}
1375
1376#[cfg(test)]
1377mod tests {
1378 use super::*;
1379
1380 #[test]
1381 fn the_hub_edges_are_refused_and_the_rest_have_a_direction() {
1382 for (kind, why) in REFUSED {
1383 assert!(
1384 edge_policy(kind).is_none(),
1385 "{kind} is refused and also followed"
1386 );
1387 assert!(!why.is_empty());
1388 }
1389 for e in EDGES {
1390 assert!(
1391 e.forward > 0.0 || e.reverse > 0.0,
1392 "{} is in the table and followed in neither direction",
1393 e.kind
1394 );
1395 assert!(e.forward <= 1.0 && e.reverse <= 1.0, "{} decays", e.kind);
1396 assert!(!e.forward_reason.is_empty() && !e.reverse_reason.is_empty());
1397 }
1398 // and the table names each edge once
1399 let mut kinds: Vec<&str> = EDGES.iter().map(|e| e.kind).collect();
1400 kinds.sort();
1401 let before = kinds.len();
1402 kinds.dedup();
1403 assert_eq!(before, kinds.len(), "an edge kind is in the table twice");
1404 }
1405
1406 #[test]
1407 fn the_intent_keeps_words_that_mean_something() {
1408 let t = intent_terms("The budget and the ADR that put it in force");
1409 assert!(t.contains("budget"));
1410 assert!(t.contains("adr"));
1411 assert!(t.contains("force"));
1412 assert!(!t.contains("the"), "a stop word survived");
1413 assert!(!t.contains("it"), "a two-letter word survived");
1414 // and it is a set, so a repeated word does not weigh twice
1415 assert_eq!(intent_terms("budget budget budget").len(), 1);
1416 }
1417
1418 #[test]
1419 fn every_kind_lands_in_a_tier_and_the_declared_ones_are_not_history() {
1420 assert_eq!(tier_for_kind("issue"), Tier::Task);
1421 assert_eq!(tier_for_kind("rule"), Tier::Governance);
1422 assert_eq!(tier_for_kind("implementation"), Tier::Source);
1423 assert_eq!(tier_for_kind("adr"), Tier::Decision);
1424 assert_eq!(tier_for_kind("knowledge"), Tier::Knowledge);
1425 assert_eq!(tier_for_kind("claim"), Tier::History);
1426 // an unknown kind is supporting evidence, never governance
1427 assert_eq!(tier_for_kind("something-new"), Tier::History);
1428 }
1429
1430 #[test]
1431 fn containment_is_by_segment_not_by_prefix() {
1432 assert!(is_under("lib", "lib/context.sh"));
1433 assert!(is_under("lib", "lib"));
1434 assert!(!is_under("lib", "library/x.sh"));
1435 assert!(!is_under("lib/a", "lib"));
1436 }
1437}