Skip to main content

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}