Skip to main content

majordomus_cli/devcontext/
mod.rs

1//! The context compiler: what a development session should be given, derived from the
2//! repository rather than composed by hand.
3//!
4//! # What this is for
5//!
6//! A session that is handed a large hand-written prompt is handed somebody's memory of the
7//! repository; a session that reads the repository from scratch on every request pays for
8//! discovery it has already paid for. This module is the third option: given an issue, a
9//! milestone, a free-text intent, some paths, or nothing at all, it derives the set of
10//! canonical objects that bear on the work, says why each one is in the set, says what it
11//! left out and why, and reports what the whole thing cost against a budget.
12//!
13//! # It is not a second context system
14//!
15//! `majordomus context` already assembles what a worker needs *now*: git, the active task,
16//! its profile, the directory contracts over its scope, the open questions, the decisions,
17//! the newest checkpoint, the most relevant handover, recent history — in authority order,
18//! within a line budget, printing what it dropped. That command is the session briefing and
19//! it stays the authority for it. What it cannot do is answer *about a piece of work* rather
20//! than about the present moment, and its output is text: its own JSON projection carries
21//! `sections[]` of `{id, lines, text}`, which is the assembled prose, not the selection.
22//!
23//! So this module keeps the same authority order — [`Tier`] is that order applied to
24//! objects instead of to sections — and changes three things:
25//!
26//! - **The unit is an object, not a section.** Every entry carries the canonical
27//!   identifier, the index's own provenance, the selector that reached it, the reason, the
28//!   confidence, the version and the cost. Nothing is flattened to text here; rendering a
29//!   prompt is a projection of this value and lives downstream of it.
30//! - **It is keyed on the work.** An issue, a milestone, an intent or a path, not only the
31//!   session that happens to be open.
32//! - **It deduplicates and explains.** The same ADR reached by four paths is one entry with
33//!   four recorded discoveries, and the answer says which paths those were.
34//!
35//! It also reuses rather than reimplements: the local records come from `continuity.state`,
36//! the typed relations from [`crate::graph::COMPOSED`] — the repository's own dependency
37//! graph, where every reference the layer declares is already resolved — and the objects
38//! from the index. There is no second relation table, no glob and no walk of the tree.
39//!
40//! # The performance contract
41//!
42//! Nothing here is built when a [`crate::capability::Context`] is composed. The compiler
43//! runs inside its handler, over values already in memory, for the reason
44//! [`crate::plan`] states about the plan: "`App::load` has a stated budget and
45//! 200 project records have no business inside it." Deriving the composed graph over this
46//! repository is milliseconds; building the index is seconds, and it is built once per
47//! process. The answer carries the index fingerprint, so an answer is valid exactly as long
48//! as the index it was computed from, and a caller can tell two trees apart without asking.
49//!
50//! # A call, end to end
51//!
52//! The whole module is three functions over one input type. [`compile`] answers *what
53//! should this session be given*, [`explain`] answers *why is this one thing in or out of
54//! that*, and [`policy`] answers *by what rules* — the last needing no request at all,
55//! because the rules do not depend on what is being asked about.
56//!
57//! The request below names nothing, which is the smallest legitimate one: the governance
58//! applies to every session whether or not anybody asked for it, so the answer is not empty.
59//!
60//! ```
61//! use majordomus_cli::devcontext::{compile, explain, policy, CompileInput, ExplainInput};
62//! use majordomus_cli::devcontext::{Standing, Tier};
63//! use majordomus_cli::synthetic::SyntheticRepository;
64//!
65//! let repo = SyntheticRepository::small().unwrap();
66//! let ctx = repo.context().unwrap();
67//!
68//! // what should a session here be given, with nothing asked for?
69//! let compiled = compile(&ctx, CompileInput::default()).unwrap();
70//! assert!(
71//!     compiled.selected.iter().any(|e| e.kind == "policy"),
72//!     "the governance applies without being asked for",
73//! );
74//! assert_eq!(compiled.budget.tiers.len(), Tier::ORDER.len());
75//!
76//! // and why is that entry in it? — judged under the same request, so "excluded for
77//! // budget" is a statement about a budget the caller can see
78//! let first = compiled.selected[0].uri.clone();
79//! let why = explain(&ctx, ExplainInput { uri: first.clone(), request: CompileInput::default() })
80//!     .unwrap();
81//! assert_eq!(why.standing, Standing::Selected);
82//! assert_eq!(why.uri, first);
83//! assert!(why.entry.is_some() && !why.detail.is_empty());
84//!
85//! // something the index does not hold is `unknown`, not an error
86//! let absent = explain(
87//!     &ctx,
88//!     ExplainInput { uri: "majordomus://rule/nothing-like-this@1".into(), ..Default::default() },
89//! )
90//! .unwrap();
91//! assert_eq!(absent.standing, Standing::Unknown);
92//!
93//! // by what rules? — readable without a request, because they do not depend on one
94//! let rules = policy(&ctx).unwrap();
95//! assert_eq!(rules.tiers.len(), Tier::ORDER.len());
96//! assert!(rules.edges.iter().any(|e| e.refused), "and it says what it will not follow");
97//! ```
98
99use std::collections::{BTreeMap, BTreeSet};
100
101use schemars::JsonSchema;
102use serde::{Deserialize, Serialize};
103
104use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
105use crate::capability::handler::{CapabilityError, Context};
106
107pub mod budget;
108pub mod model;
109pub mod select;
110
111pub use model::{
112    tokens_for, Budget, CommitReference, CompiledContext, Conflict, ContextEntry, Deduplicated,
113    Discovery, EntryProvenance, Excluded, ExclusionReason, GitContext, Seed, Selector, Tier,
114    TierSpend, BYTES_PER_TOKEN, DEFAULT_BUDGET_TOKENS, DEFAULT_MAX_DEPTH,
115};
116pub use select::{edge_policy, intent_terms, tier_for_kind, EdgePolicy};
117
118/// The lowest relevance the compiler offers unless the request says otherwise. Below it an
119/// entry is reached and reported as excluded rather than given.
120pub const DEFAULT_FLOOR: f64 = 0.25;
121
122/// What to compile a context about.
123///
124/// Every field is optional. A request that names nothing still gets an answer: the
125/// governance the layer applies to everything, and what the last session left — which is
126/// the smallest true context there is.
127///
128/// The seeds are additive, not alternative: `issue`, `milestone`, `uris`, `paths` and
129/// `intent` all contribute, and naming several is the normal case rather than a conflict.
130/// `intent` is the only one the compiler infers from, which is why every entry it produces
131/// carries [`Selector::IntentMatch`] and a confidence below one.
132///
133/// `deny_unknown_fields` is deliberate. This is the input of a served capability, so a
134/// misspelled field is a request that does not mean what its author thought; refusing it is
135/// the difference between a typo and a silently smaller answer.
136///
137/// ```
138/// use majordomus_cli::devcontext::CompileInput;
139/// use serde_json::json;
140///
141/// // the smallest legitimate request, which is not the same as no request
142/// let nothing: CompileInput = serde_json::from_value(json!({})).unwrap();
143/// assert_eq!(nothing, CompileInput::default());
144/// assert!(nothing.issue.is_none() && nothing.paths.is_empty());
145///
146/// // the seeds combine rather than compete
147/// let both: CompileInput = serde_json::from_value(json!({
148///     "issue": "I0301",
149///     "paths": ["apps/majordomus-cli/src"],
150///     "intent": "explain the budget",
151///     "budget_tokens": 4_000,
152/// })).unwrap();
153/// assert_eq!(both.issue.as_deref(), Some("I0301"));
154/// assert_eq!(both.paths, ["apps/majordomus-cli/src"]);
155/// assert_eq!(both.budget_tokens, Some(4_000));
156///
157/// // and a misspelled field is refused rather than quietly ignored
158/// let typo = serde_json::from_value::<CompileInput>(json!({"max_dept": 2}));
159/// assert!(typo.is_err(), "a typo must not become a smaller answer");
160/// ```
161#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
162#[serde(deny_unknown_fields)]
163pub struct CompileInput {
164    /// An issue id (`I0301`) or its canonical identifier. Its milestone, its dependencies,
165    /// its declared scope and the decisions over that scope follow from it.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub issue: Option<String>,
168    /// A milestone id or slug, or its canonical identifier.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub milestone: Option<String>,
171    /// What the session is trying to do, in words. The only input the compiler infers
172    /// from, and every entry it produces says so and carries a confidence below one.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub intent: Option<String>,
175    /// Repository-relative paths the work touches, added to whatever the seeds declare.
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    pub paths: Vec<String>,
178    /// Canonical identifiers to seed with directly, for a request about something that is
179    /// neither an issue nor a milestone.
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub uris: Vec<String>,
182    /// The ceiling in estimated tokens; [`DEFAULT_BUDGET_TOKENS`] when absent.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub budget_tokens: Option<u64>,
185    /// How far from a seed the walk goes; [`DEFAULT_MAX_DEPTH`] when absent.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub max_depth: Option<usize>,
188    /// Relevance below which an entry is reported rather than given; [`DEFAULT_FLOOR`]
189    /// when absent.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub floor: Option<f64>,
192    /// Every blocking rule of the layer, not only the ones the work reaches. Honest and
193    /// usually over budget: the answer then says `over_budget` rather than dropping what it
194    /// may not drop.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub all_blocking_rules: Option<bool>,
197}
198
199impl BenchmarkCases for CompileInput {
200    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
201        // the cases are this repository's own records, chosen by the same total order the
202        // index holds them in, so the benchmark measures a real answer rather than an
203        // empty one and does not go stale when a record is added
204        let first = |kind: &str| -> Option<String> {
205            ctx.index
206                .objects
207                .iter()
208                .filter(|o| o.kind == kind)
209                .map(|o| o.identity.clone())
210                .min()
211        };
212        let mut cases = vec![NamedCase::new("no-seed", CompileInput::default())];
213        if let Some(issue) = first("issue") {
214            cases.push(NamedCase::new(
215                "issue",
216                CompileInput {
217                    issue: Some(issue),
218                    ..Default::default()
219                },
220            ));
221        }
222        if let Some(milestone) = first("milestone") {
223            cases.push(NamedCase::new(
224                "milestone",
225                CompileInput {
226                    milestone: Some(milestone),
227                    ..Default::default()
228                },
229            ));
230        }
231        cases.push(NamedCase::new(
232            "intent",
233            CompileInput {
234                intent: Some("the context a session is given and the budget it costs".into()),
235                ..Default::default()
236            },
237        ));
238        cases
239    }
240}
241
242/// What one identifier's standing in a compiled context is.
243/// Where one identifier stands with respect to a compiled context.
244///
245/// The pair to be careful about is `NotReached` and `Unknown`, because they look alike in a
246/// rendering and mean opposite things. `NotReached` says the index holds the object and this
247/// request did not reach it — the request is what to change, and raising the depth or the
248/// budget may well bring it in. `Unknown` says the index holds nothing under that
249/// identifier: the request is fine and the identifier is wrong, or the object is not in
250/// this repository. Collapsing the two would send a reader to tune a request that was never
251/// the problem.
252///
253/// `Deduplicated` is the third one worth naming: the thing is *in* the answer, under another
254/// identifier, which is neither in nor out.
255///
256/// ```
257/// use majordomus_cli::devcontext::Standing;
258///
259/// // in the answer, one way or another
260/// let present = [Standing::Selected, Standing::Deduplicated];
261/// // reached and not given, so the request is what to change
262/// let request_bound = [Standing::Excluded, Standing::NotReached];
263/// for s in present {
264///     assert!(!request_bound.contains(&s));
265/// }
266///
267/// // the two that must not be confused: one is the request's fault, one is not
268/// assert_eq!(Standing::NotReached.as_str(), "not_reached");
269/// assert_ne!(Standing::NotReached, Standing::Unknown);
270/// assert_eq!(
271///     serde_json::to_value(Standing::NotReached).unwrap(),
272///     serde_json::json!(Standing::NotReached.as_str()),
273/// );
274/// ```
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
276#[serde(rename_all = "snake_case")]
277// `worktree` already answers with a `Standing` about a branch, and the OpenAPI document has
278// one component namespace: two enums under one name would be one component defined twice.
279#[schemars(rename = "DevContextStanding")]
280pub enum Standing {
281    /// In the answer.
282    Selected,
283    /// Reached and not given; the exclusion says why.
284    Excluded,
285    /// Folded into another identifier, or dropped for one.
286    Deduplicated,
287    /// The index holds it and this request never reached it.
288    NotReached,
289    /// The index holds nothing under that identifier.
290    Unknown,
291}
292
293impl Standing {
294    /// The word this standing is reported under.
295    ///
296    /// ```
297    /// use majordomus_cli::devcontext::Standing;
298    /// assert_eq!(Standing::NotReached.as_str(), "not_reached");
299    /// ```
300    pub fn as_str(self) -> &'static str {
301        match self {
302            Standing::Selected => "selected",
303            Standing::Excluded => "excluded",
304            Standing::Deduplicated => "deduplicated",
305            Standing::NotReached => "not_reached",
306            Standing::Unknown => "unknown",
307        }
308    }
309}
310
311/// The input of `devcontext.explain`: one identifier, and the request to judge it under.
312///
313/// The request is not optional context for the question — it is half of the question.
314/// "Why is this ADR not in my context" has no answer in the abstract: it is in or out of a
315/// *particular* compiled answer, and "excluded for budget" is only actionable when the
316/// budget it did not fit in is known. So the same fields `compile` takes are `flatten`ed in
317/// here, which also means the wire form is one flat object rather than a nested `request`,
318/// and the command line can share its flags.
319///
320/// ```
321/// use majordomus_cli::devcontext::ExplainInput;
322/// use serde_json::json;
323///
324/// // flattened: the request's fields sit beside `uri`, not under it
325/// let input: ExplainInput = serde_json::from_value(json!({
326///     "uri": "majordomus://adr/0052",
327///     "budget_tokens": 2_000,
328///     "max_depth": 1,
329/// })).unwrap();
330/// assert_eq!(input.uri, "majordomus://adr/0052");
331/// assert_eq!(input.request.budget_tokens, Some(2_000));
332/// assert_eq!(input.request.max_depth, Some(1));
333///
334/// // and the identifier alone is a complete question, judged under the default request
335/// let bare: ExplainInput = serde_json::from_value(json!({"uri": "majordomus://adr/0052"}))
336///     .unwrap();
337/// assert_eq!(bare.request, Default::default());
338/// ```
339#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
340#[serde(deny_unknown_fields)]
341pub struct ExplainInput {
342    /// The canonical identifier to explain.
343    pub uri: String,
344    /// The request it would be compiled under; the same fields as `devcontext.compile`.
345    #[serde(default, flatten)]
346    pub request: CompileInput,
347}
348
349impl BenchmarkCases for ExplainInput {
350    fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
351        let policy = ctx
352            .index
353            .objects
354            .iter()
355            .find(|o| o.kind == "policy")
356            .map(|o| o.uri.clone());
357        let mut cases = Vec::new();
358        if let Some(uri) = policy {
359            cases.push(NamedCase::new(
360                "policy",
361                ExplainInput {
362                    uri,
363                    request: CompileInput::default(),
364                },
365            ));
366        }
367        cases.push(NamedCase::new(
368            "absent",
369            ExplainInput {
370                uri: "majordomus://rule/nothing-like-this@1".into(),
371                request: CompileInput::default(),
372            },
373        ));
374        cases
375    }
376}
377
378/// Why one identifier is, or is not, in a compiled context.
379///
380/// `standing` says which of the five cases this is, and the three optional fields are the
381/// evidence for it: at most one of `entry`, `excluded` and `deduplicated` is populated, and
382/// which one is determined by the standing rather than independent of it. `detail` is the
383/// same answer in words, so the value is legible without a reader matching them up.
384///
385/// `budget` is always present, including when the answer is `unknown`, and that is the
386/// point of carrying it: the commonest reason to ask this question is an entry that did not
387/// fit, and an explanation that said "excluded for budget" without the budget would need a
388/// second call to act on.
389///
390/// ```
391/// use majordomus_cli::devcontext::{compile, explain, CompileInput, ExplainInput};
392/// use majordomus_cli::devcontext::{Explanation, Standing};
393/// use majordomus_cli::synthetic::SyntheticRepository;
394///
395/// let repo = SyntheticRepository::small().unwrap();
396/// let ctx = repo.context().unwrap();
397/// let compiled = compile(&ctx, CompileInput::default()).unwrap();
398/// let selected = compiled.selected[0].uri.clone();
399///
400/// // something in the answer: the entry is the evidence, and the other two are empty
401/// let e: Explanation =
402///     explain(&ctx, ExplainInput { uri: selected.clone(), ..Default::default() }).unwrap();
403/// assert_eq!(e.standing, Standing::Selected);
404/// assert_eq!(e.entry.as_ref().unwrap().uri, selected);
405/// assert!(e.excluded.is_none() && e.deduplicated.is_none());
406/// assert!(!e.detail.is_empty(), "the standing always reads as a sentence too");
407///
408/// // something this repository does not hold: an answer, not an error — and the budget
409/// // the judgement was made under is carried even here
410/// let absent: Explanation = explain(
411///     &ctx,
412///     ExplainInput { uri: "majordomus://adr/9999".into(), ..Default::default() },
413/// )
414/// .unwrap();
415/// assert_eq!(absent.standing, Standing::Unknown);
416/// assert!(absent.entry.is_none());
417/// assert_eq!(absent.budget.limit_tokens, compiled.budget.limit_tokens);
418/// ```
419#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
420pub struct Explanation {
421    /// What was asked about.
422    pub uri: String,
423    /// Where it stands.
424    pub standing: Standing,
425    /// The same, in words.
426    pub detail: String,
427    /// The entry, when it is in the answer.
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub entry: Option<ContextEntry>,
430    /// The exclusion, when it was reached and not given.
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub excluded: Option<Excluded>,
433    /// The collapse it was part of, when it was folded or dropped for another.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub deduplicated: Option<Deduplicated>,
436    /// The budget the judgement was made under, so that "excluded for budget" can be acted
437    /// on without a second call.
438    pub budget: Budget,
439}
440
441/// One tier, as the policy reports it: its place in the spend order, what it is for, and
442/// which kinds of object land in it.
443///
444/// `position` is 1-based and exists because the wire form has no inherent order a consumer
445/// can rely on — the [`Tier`] enum's `Ord` is a Rust fact, not a JSON one — so the rank is
446/// made explicit for the readers that are not this crate. `kinds` is the inverse of
447/// [`tier_for_kind`], published so that a caller can see where a kind will land without
448/// compiling an answer and looking.
449///
450/// ```
451/// use majordomus_cli::devcontext::{policy, tier_for_kind, Tier, TierRule};
452/// use majordomus_cli::synthetic::SyntheticRepository;
453///
454/// let repo = SyntheticRepository::small().unwrap();
455/// let ctx = repo.context().unwrap();
456/// let rules = policy(&ctx).unwrap();
457///
458/// // the first tier spent is the one that is never dropped, at position 1
459/// let first: &TierRule = &rules.tiers[0];
460/// assert_eq!(first.tier, Tier::Task);
461/// assert_eq!(first.position, 1, "1-based, so the order survives serialisation");
462///
463/// // every tier is reported, in the order the budget spends in, and explains itself
464/// assert_eq!(rules.tiers.len(), Tier::ORDER.len());
465/// for (i, r) in rules.tiers.iter().enumerate() {
466///     assert_eq!(r.position, i + 1);
467///     assert_eq!(r.meaning, r.tier.meaning());
468/// }
469///
470/// // and every kind is listed under the tier the classifier puts it in, exactly once —
471/// // so the published table and the function behind it cannot disagree
472/// let mut listed = 0usize;
473/// for r in &rules.tiers {
474///     for kind in &r.kinds {
475///         assert_eq!(tier_for_kind(kind), r.tier, "{kind} is listed under the wrong tier");
476///         listed += 1;
477///     }
478/// }
479/// let mut all: Vec<&str> = rules.tiers.iter().flat_map(|r| &r.kinds).map(String::as_str).collect();
480/// all.sort();
481/// all.dedup();
482/// assert_eq!(all.len(), listed, "a kind is listed under one tier only");
483/// ```
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
485pub struct TierRule {
486    /// The tier.
487    pub tier: Tier,
488    /// Its position in the order the budget spends in; 1 is spent first.
489    pub position: usize,
490    /// What it is for.
491    pub meaning: String,
492    /// The kinds of object that land in it.
493    pub kinds: Vec<String>,
494}
495
496/// One edge of the composed graph, and what the compiler does with it.
497///
498/// Every edge kind the graph declares gets a row, followed or not, which is the property
499/// that makes this projection worth reading: an edge added to the graph and not to the
500/// traversal table appears here as `refused` with a reason saying the compiler has no rule
501/// for it, rather than being silently unfollowed. A table that only listed the edges it
502/// followed could not tell you what it was ignoring.
503///
504/// `forward` and `reverse` are absent rather than zero when the edge is not followed that
505/// way, so "not followed" is one representation and not two. `means` is the graph's own
506/// account of the relation; `reason` is this compiler's account of why it is or is not
507/// context, in both directions.
508///
509/// ```
510/// use majordomus_cli::devcontext::{edge_policy, policy, EdgeRule};
511/// use majordomus_cli::synthetic::SyntheticRepository;
512///
513/// let repo = SyntheticRepository::small().unwrap();
514/// let ctx = repo.context().unwrap();
515/// let rules = policy(&ctx).unwrap();
516///
517/// // one row per edge kind the graph declares, followed or refused
518/// let edges: &[EdgeRule] = &rules.edges;
519/// assert!(!edges.is_empty(), "the graph declares edges");
520/// for e in edges {
521///     // the row always says what the relation is and what was decided about it
522///     assert!(!e.means.is_empty() && !e.reason.is_empty(), "{}", e.edge);
523///
524///     if e.refused {
525///         // not followed in either direction, and absent rather than zero
526///         assert!(e.forward.is_none() && e.reverse.is_none(), "{}", e.edge);
527///         assert!(edge_policy(&e.edge).is_none(), "{} is refused and in the table", e.edge);
528///     } else {
529///         // followed at least one way, at a weight the table declares
530///         assert!(e.forward.is_some() || e.reverse.is_some(), "{}", e.edge);
531///         let p = edge_policy(&e.edge).expect("a followed edge has a policy");
532///         assert_eq!(e.forward, (p.forward > 0.0).then_some(p.forward));
533///         assert_eq!(e.reverse, (p.reverse > 0.0).then_some(p.reverse));
534///     }
535/// }
536/// ```
537#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
538pub struct EdgeRule {
539    /// The edge kind, as the composed graph names it.
540    pub edge: String,
541    /// What the graph asserts by it.
542    pub means: String,
543    /// The relevance multiplier when followed source to target; absent when refused.
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    pub forward: Option<f64>,
546    /// The multiplier when followed target to source; absent when refused.
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub reverse: Option<f64>,
549    /// Why the target is context for the source, or why the edge is refused.
550    pub reason: String,
551    /// True when the compiler will not follow it in either direction.
552    pub refused: bool,
553}
554
555/// One selector, and whether what it produces was declared or inferred.
556///
557/// Two fields, and the second is the whole reason the projection exists: a caller consuming
558/// a compiled context needs to know which selectors it can trust as repository fact, and
559/// deriving that from the answer would mean inferring it from confidence values. Published
560/// here, it is a rule rather than a pattern a reader noticed.
561///
562/// ```
563/// use majordomus_cli::devcontext::{policy, Selector, SelectorRule};
564/// use majordomus_cli::synthetic::SyntheticRepository;
565///
566/// let repo = SyntheticRepository::small().unwrap();
567/// let ctx = repo.context().unwrap();
568/// let rules = policy(&ctx).unwrap();
569///
570/// // every selector is published, and each row agrees with the selector it names
571/// let selectors: &[SelectorRule] = &rules.selectors;
572/// assert_eq!(selectors.len(), Selector::ALL.len());
573/// for r in selectors {
574///     assert_eq!(r.declared, r.selector.declared());
575/// }
576///
577/// // and the inferring one is named, so the guessed half of an answer can be found
578/// let inferred: Vec<Selector> =
579///     selectors.iter().filter(|r| !r.declared).map(|r| r.selector).collect();
580/// assert_eq!(inferred, [Selector::IntentMatch]);
581/// ```
582#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
583pub struct SelectorRule {
584    /// The selector.
585    pub selector: Selector,
586    /// Declared by the repository, or inferred by the compiler.
587    pub declared: bool,
588}
589
590/// The compiler's own rules, so that a caller reads the decision instead of inferring it
591/// from an answer.
592///
593/// This is the answer to "why did I get that", asked once rather than reverse-engineered
594/// from every context. It is a projection of the tables and constants this module is built
595/// from — [`Tier::ORDER`], [`select::EDGES`], [`Selector::ALL`] and the four defaults
596/// — and holds no numbers of its own, so the published rules and the code that applies them
597/// cannot drift apart.
598///
599/// The defaults are here because they are what a request gets when it says nothing, and a
600/// caller tuning a request needs to know what it is tuning away from.
601///
602/// ```
603/// use majordomus_cli::devcontext::{policy, CompilerPolicy, Selector, Tier};
604/// use majordomus_cli::devcontext::{BYTES_PER_TOKEN, DEFAULT_BUDGET_TOKENS};
605/// use majordomus_cli::devcontext::{DEFAULT_FLOOR, DEFAULT_MAX_DEPTH};
606/// use majordomus_cli::graph;
607/// use majordomus_cli::synthetic::SyntheticRepository;
608///
609/// let repo = SyntheticRepository::small().unwrap();
610/// let ctx = repo.context().unwrap();
611/// let rules: CompilerPolicy = policy(&ctx).unwrap();
612///
613/// // it names the graph it reads relations from, rather than leaving it to be guessed
614/// assert_eq!(rules.schema, 1);
615/// assert_eq!(rules.graph, graph::COMPOSED);
616///
617/// // the three tables are complete
618/// assert_eq!(rules.tiers.len(), Tier::ORDER.len());
619/// assert_eq!(rules.selectors.len(), Selector::ALL.len());
620/// assert!(!rules.edges.is_empty());
621///
622/// // and the published defaults are the constants a silent request actually gets
623/// assert_eq!(rules.default_budget_tokens, DEFAULT_BUDGET_TOKENS);
624/// assert_eq!(rules.default_max_depth, DEFAULT_MAX_DEPTH);
625/// assert_eq!(rules.default_floor, DEFAULT_FLOOR);
626/// assert_eq!(rules.bytes_per_token, BYTES_PER_TOKEN);
627/// ```
628#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
629pub struct CompilerPolicy {
630    /// The format of this answer.
631    pub schema: u64,
632    /// The graph the relations are read from.
633    pub graph: String,
634    /// The tiers, in the order the budget spends in.
635    pub tiers: Vec<TierRule>,
636    /// Every edge kind the composed graph has, followed or refused.
637    pub edges: Vec<EdgeRule>,
638    /// The selectors, and which of them infer.
639    pub selectors: Vec<SelectorRule>,
640    /// The default ceiling in estimated tokens.
641    pub default_budget_tokens: u64,
642    /// The default depth limit.
643    pub default_max_depth: usize,
644    /// The default relevance floor.
645    pub default_floor: f64,
646    /// Bytes per estimated token.
647    pub bytes_per_token: u64,
648}
649
650// ---------------------------------------------------------------- compiling
651
652fn normalise(path: &str) -> Result<String, CapabilityError> {
653    let p = path.trim().trim_start_matches("./").trim_end_matches('/');
654    if p.is_empty() {
655        return Err(CapabilityError::InvalidInput(
656            "an empty path is not a scope".into(),
657        ));
658    }
659    if p.starts_with('/') || p.split('/').any(|s| s == "..") {
660        return Err(CapabilityError::InvalidInput(format!(
661            "'{path}' is not repository-relative; a path outside the repository is not context"
662        )));
663    }
664    Ok(p.to_string())
665}
666
667/// Resolve one short name to the canonical identifier of an object of `kind`.
668fn resolve_seed(
669    ctx: &Context,
670    kind: &str,
671    named: &str,
672    field: &str,
673) -> Result<Seed, CapabilityError> {
674    let named = named.trim();
675    if named.is_empty() {
676        return Err(CapabilityError::InvalidInput(format!(
677            "{field}: name one, or leave it out"
678        )));
679    }
680    let hit = ctx.index.objects.iter().find(|o| {
681        o.kind == kind
682            && (o.uri == named
683                || o.identity == named
684                || o.metadata.get("slug").and_then(|v| v.as_str()) == Some(named))
685    });
686    match hit {
687        Some(o) => Ok(Seed {
688            uri: o.uri.clone(),
689            kind: o.kind.clone(),
690            from: field.to_string(),
691            resolved: (o.identity != named).then(|| o.identity.clone()),
692        }),
693        None => Err(CapabilityError::NotFound(format!(
694            "no {kind} '{named}' in this repository; `objects.list` with kind `{kind}` names every one"
695        ))),
696    }
697}
698
699/// Compile a context. The one entry point; every projection calls this.
700///
701/// The whole call runs over values the process already holds — the index and the composed
702/// graph derived from it — so it reads no file and walks no tree, which is what lets it sit
703/// inside a handler rather than at load time.
704///
705/// It fails only on a request that cannot mean anything: a budget of zero, a relevance floor
706/// outside 0..=1, a path that is not repository-relative, or a named seed this repository
707/// does not hold. Everything else is an answer. In particular a request that names nothing
708/// is valid and does not produce an empty context — the governance applies to every session
709/// whether or not it was asked for.
710///
711/// The budget is a ceiling on what the compiler *chooses*, not on what it must keep: seeds,
712/// the policy and the scope are never dropped, so an answer can come back over budget and
713/// says so rather than omitting the thing it was compiled about.
714///
715/// ```
716/// use majordomus_cli::capability::CapabilityError;
717/// use majordomus_cli::devcontext::{compile, CompileInput, Tier};
718/// use majordomus_cli::synthetic::SyntheticRepository;
719///
720/// let repo = SyntheticRepository::small().unwrap();
721/// let ctx = repo.context().unwrap();
722///
723/// // the governance arrives without being asked for, and cannot be dropped
724/// let c = compile(&ctx, CompileInput::default()).unwrap();
725/// let required: Vec<&str> = c
726///     .selected
727///     .iter()
728///     .filter(|e| e.required)
729///     .map(|e| e.kind.as_str())
730///     .collect();
731/// assert!(required.contains(&"policy"), "what every session is held to: {required:?}");
732/// assert!(c.selected.iter().all(|e| e.tier >= Tier::Task));
733///
734/// // a tiny ceiling still keeps what may not be dropped, and says it went over
735/// let squeezed = compile(
736///     &ctx,
737///     CompileInput { budget_tokens: Some(1), ..Default::default() },
738/// )
739/// .unwrap();
740/// assert!(squeezed.selected.iter().any(|e| e.required));
741/// assert!(squeezed.budget.over_budget);
742/// assert_eq!(squeezed.budget.limit_tokens, 1);
743///
744/// // and the refusals are requests that could not mean anything
745/// assert!(matches!(
746///     compile(&ctx, CompileInput { budget_tokens: Some(0), ..Default::default() }),
747///     Err(CapabilityError::InvalidInput(_)),
748/// ));
749/// assert!(matches!(
750///     compile(&ctx, CompileInput { floor: Some(1.5), ..Default::default() }),
751///     Err(CapabilityError::InvalidInput(_)),
752/// ));
753/// assert!(matches!(
754///     compile(&ctx, CompileInput { paths: vec!["/etc".into()], ..Default::default() }),
755///     Err(CapabilityError::InvalidInput(_)),
756/// ));
757/// assert!(matches!(
758///     compile(&ctx, CompileInput { issue: Some("I9999".into()), ..Default::default() }),
759///     Err(CapabilityError::NotFound(_)),
760/// ));
761/// ```
762pub fn compile(ctx: &Context, input: CompileInput) -> Result<CompiledContext, CapabilityError> {
763    let index = ctx.index.as_ref();
764    let limit = input.budget_tokens.unwrap_or(DEFAULT_BUDGET_TOKENS);
765    if limit == 0 {
766        return Err(CapabilityError::InvalidInput(
767            "a budget of zero tokens buys nothing; leave it out for the default".into(),
768        ));
769    }
770    let max_depth = input.max_depth.unwrap_or(DEFAULT_MAX_DEPTH);
771    let floor = input.floor.unwrap_or(DEFAULT_FLOOR);
772    if !(0.0..=1.0).contains(&floor) {
773        return Err(CapabilityError::InvalidInput(format!(
774            "the relevance floor is between 0 and 1; got {floor}"
775        )));
776    }
777
778    // ---- the seeds
779    let mut seeds: Vec<Seed> = Vec::new();
780    if let Some(named) = &input.issue {
781        seeds.push(resolve_seed(ctx, "issue", named, "issue")?);
782    }
783    if let Some(named) = &input.milestone {
784        seeds.push(resolve_seed(ctx, "milestone", named, "milestone")?);
785    }
786    for uri in &input.uris {
787        let o = index
788            .objects
789            .iter()
790            .find(|o| o.uri == *uri)
791            .ok_or_else(|| {
792                CapabilityError::NotFound(format!("the index holds nothing under `{uri}`"))
793            })?;
794        seeds.push(Seed {
795            uri: o.uri.clone(),
796            kind: o.kind.clone(),
797            from: "uris".into(),
798            resolved: None,
799        });
800    }
801    let mut requested_paths = BTreeSet::new();
802    for p in &input.paths {
803        let p = normalise(p)?;
804        requested_paths.insert(p.clone());
805        seeds.push(Seed {
806            uri: p.clone(),
807            kind: "path".into(),
808            from: "paths".into(),
809            resolved: None,
810        });
811    }
812    if let Some(intent) = &input.intent {
813        if !intent.trim().is_empty() {
814            seeds.push(Seed {
815                uri: intent.trim().to_string(),
816                kind: "intent".into(),
817                from: "intent".into(),
818                resolved: None,
819            });
820        }
821    }
822
823    let object_seeds: BTreeSet<String> = seeds
824        .iter()
825        .filter(|s| s.kind != "path" && s.kind != "intent")
826        .map(|s| s.uri.clone())
827        .collect();
828
829    let terms = input
830        .intent
831        .as_deref()
832        .map(intent_terms)
833        .unwrap_or_default();
834
835    // whole: a selection over a prefix would miss every object sorted past MAX_NODES
836    let graph = crate::graph::composed_complete(&ctx.registry, index);
837
838    let request = select::Request {
839        seeds: object_seeds.iter().cloned().collect(),
840        paths: requested_paths,
841        terms,
842        max_depth,
843        floor,
844        all_blocking_rules: input.all_blocking_rules.unwrap_or(false),
845        branch: match &index.repository.git {
846            crate::git::GitState::Available(info) => info.branch.as_deref(),
847            _ => None,
848        },
849    };
850    let selection = select::select(ctx, &graph, &request);
851
852    // what stands in for what, from the graph's own edges rather than from a second read
853    let mut supersedes: BTreeMap<String, Vec<String>> = BTreeMap::new();
854    for e in &graph.edges {
855        if e.kind == "supersedes" {
856            supersedes
857                .entry(e.source.clone())
858                .or_default()
859                .push(e.target.clone());
860        }
861    }
862
863    let (spend, diagnostics) = budget::spend(index, selection, &object_seeds, limit, &supersedes);
864
865    // the commits the layer itself names, gathered from what was selected
866    let mut commits = Vec::new();
867    for entry in &spend.selected {
868        let Some(i) = index.objects.iter().position(|o| o.uri == entry.uri) else {
869            continue;
870        };
871        let Some(evidence) = index.objects[i]
872            .metadata
873            .get("evidence")
874            .and_then(|v| v.as_array())
875        else {
876            continue;
877        };
878        for e in evidence {
879            let Some(commit) = e.get("commit").and_then(|v| v.as_str()) else {
880                continue;
881            };
882            commits.push(CommitReference {
883                commit: commit.to_string(),
884                named_by: entry.uri.clone(),
885                covers: e.get("covers").and_then(|v| v.as_str()).map(str::to_string),
886            });
887        }
888    }
889    // One BTreeSet folds the repeats — a commit named twice by the same record is one fact
890    // — and the canonical order is what the answer is read in.
891    let mut commits: Vec<CommitReference> = commits
892        .into_iter()
893        .collect::<BTreeSet<_>>()
894        .into_iter()
895        .collect();
896    crate::order::canonical(&mut commits);
897
898    let git = match &index.repository.git {
899        crate::git::GitState::Available(info) => GitContext {
900            branch: info.branch.clone(),
901            head: info.head.clone(),
902            working_tree: info.working_tree.clone(),
903            commits,
904        },
905        crate::git::GitState::Unavailable { reason } => GitContext {
906            branch: None,
907            head: None,
908            working_tree: format!("unavailable: {reason}"),
909            commits,
910        },
911    };
912
913    Ok(CompiledContext {
914        schema: 1,
915        fingerprint: index.fingerprint.clone(),
916        git,
917        seeds,
918        selected: spend.selected,
919        excluded: spend.excluded,
920        deduplicated: spend.deduplicated,
921        conflicts: spend.conflicts,
922        budget: spend.budget,
923        diagnostics,
924    })
925}
926
927/// Why one identifier is or is not in the context a request compiles to.
928///
929/// It compiles the request and then looks the identifier up in the result, which is the
930/// only honest way to answer the question: the standing of a thing is a property of a
931/// particular answer, not of the thing. The cost is one compile per question, and the
932/// benefit is that "excluded for budget" is measured against the budget the reply carries
933/// rather than one inferred afterwards.
934///
935/// An identifier this repository does not hold is [`Standing::Unknown`] — an answer, not an
936/// error — because "the index holds nothing under that name" is exactly the fact the caller
937/// asked for. The only refusal is an empty identifier, which is not a question.
938///
939/// ```
940/// use majordomus_cli::capability::CapabilityError;
941/// use majordomus_cli::devcontext::{compile, explain, CompileInput, ExplainInput, Standing};
942/// use majordomus_cli::synthetic::SyntheticRepository;
943///
944/// let repo = SyntheticRepository::small().unwrap();
945/// let ctx = repo.context().unwrap();
946///
947/// // the standing is about one compiled answer, so it moves with the request
948/// let generous = compile(&ctx, CompileInput::default()).unwrap();
949/// let uri = generous.selected.last().unwrap().uri.clone();
950/// let selected = explain(&ctx, ExplainInput { uri: uri.clone(), ..Default::default() }).unwrap();
951/// assert_eq!(selected.standing, Standing::Selected);
952/// assert_eq!(selected.entry.as_ref().unwrap().uri, uri);
953///
954/// // an identifier nothing answers: reported as unknown rather than refused
955/// let unknown = explain(
956///     &ctx,
957///     ExplainInput { uri: "majordomus://rule/nothing-like-this@1".into(), ..Default::default() },
958/// )
959/// .unwrap();
960/// assert_eq!(unknown.standing, Standing::Unknown);
961/// assert!(!unknown.detail.is_empty(), "it says so in words too");
962///
963/// // and the one thing that is not a question
964/// assert!(matches!(
965///     explain(&ctx, ExplainInput { uri: "   ".into(), ..Default::default() }),
966///     Err(CapabilityError::InvalidInput(_)),
967/// ));
968/// ```
969pub fn explain(ctx: &Context, input: ExplainInput) -> Result<Explanation, CapabilityError> {
970    let uri = input.uri.trim().to_string();
971    if uri.is_empty() {
972        return Err(CapabilityError::InvalidInput(
973            "name the canonical identifier to explain".into(),
974        ));
975    }
976    let compiled = compile(ctx, input.request)?;
977    if let Some(entry) = compiled.selected.iter().find(|e| e.uri == uri) {
978        let ways: Vec<String> = entry
979            .discovered_by
980            .iter()
981            .map(|d| format!("{}: {}", d.selector.as_str(), d.reason))
982            .collect();
983        return Ok(Explanation {
984            uri,
985            standing: Standing::Selected,
986            detail: format!(
987                "in the {} tier at relevance {} and confidence {}, costing {} token(s); reached by {} path(s) — {}",
988                entry.tier.as_str(),
989                entry.relevance,
990                entry.confidence,
991                entry.cost_tokens,
992                entry.discovered_by.len(),
993                ways.join("; ")
994            ),
995            entry: Some(entry.clone()),
996            excluded: None,
997            deduplicated: None,
998            budget: compiled.budget,
999        });
1000    }
1001    if let Some(x) = compiled.excluded.iter().find(|e| e.uri == uri) {
1002        return Ok(Explanation {
1003            uri,
1004            standing: Standing::Excluded,
1005            detail: format!(
1006                "reached and not given: {} — {}",
1007                x.reason.as_str(),
1008                x.detail
1009            ),
1010            entry: None,
1011            excluded: Some(x.clone()),
1012            deduplicated: None,
1013            budget: compiled.budget,
1014        });
1015    }
1016    if let Some(d) = compiled
1017        .deduplicated
1018        .iter()
1019        .find(|d| d.dropped.contains(&uri))
1020    {
1021        return Ok(Explanation {
1022            uri,
1023            standing: Standing::Deduplicated,
1024            detail: format!("folded into `{}` by {}: {}", d.kept, d.key, d.detail),
1025            entry: None,
1026            excluded: None,
1027            deduplicated: Some(d.clone()),
1028            budget: compiled.budget,
1029        });
1030    }
1031    let known = ctx.index.objects.iter().any(|o| o.uri == uri);
1032    Ok(Explanation {
1033        uri: uri.clone(),
1034        standing: if known {
1035            Standing::NotReached
1036        } else {
1037            Standing::Unknown
1038        },
1039        detail: if known {
1040            format!("the index holds `{uri}` and no selector of this request reached it: no seed names it, no followed edge leads to it, no declared path contains it, and it is not governance")
1041        } else {
1042            format!("the index holds nothing under `{uri}`")
1043        },
1044        entry: None,
1045        excluded: None,
1046        deduplicated: None,
1047        budget: compiled.budget,
1048    })
1049}
1050
1051/// The compiler's rules: the tiers, every edge of the composed graph and what is done with
1052/// it, the selectors, and the defaults.
1053///
1054/// It takes no request, because the rules do not depend on what is being asked about. It
1055/// does take a context, and for one reason: the edge rows are enumerated from the graph
1056/// *this* repository derives, so an edge kind the graph gains shows up here as unjudged
1057/// rather than going quietly unfollowed. The answer is therefore a function of the
1058/// repository's graph and this module's tables, and of nothing else.
1059///
1060/// Pure over those two: the same tree gives the same answer every time, which is what makes
1061/// it safe to publish beside the compiled contexts it explains.
1062///
1063/// ```
1064/// use majordomus_cli::devcontext::{policy, Selector, Tier};
1065/// use majordomus_cli::synthetic::SyntheticRepository;
1066///
1067/// let repo = SyntheticRepository::small().unwrap();
1068/// let ctx = repo.context().unwrap();
1069///
1070/// let rules = policy(&ctx).unwrap();
1071/// assert_eq!(rules.tiers.len(), Tier::ORDER.len());
1072/// assert_eq!(rules.selectors.len(), Selector::ALL.len());
1073///
1074/// // every edge the graph declares is judged, one way or the other, with a reason
1075/// for e in &rules.edges {
1076///     assert!(!e.reason.is_empty(), "{} was neither followed nor explained", e.edge);
1077///     assert_eq!(e.refused, e.forward.is_none() && e.reverse.is_none());
1078/// }
1079///
1080/// // and asking twice over one tree gives one answer
1081/// let again = policy(&ctx).unwrap();
1082/// assert_eq!(again, rules);
1083/// ```
1084pub fn policy(ctx: &Context) -> Result<CompilerPolicy, CapabilityError> {
1085    let graph = crate::graph::composed_complete(&ctx.registry, ctx.index.as_ref());
1086    let mut kinds: BTreeMap<Tier, Vec<String>> = BTreeMap::new();
1087    for kind in graph.node_kinds.keys() {
1088        kinds
1089            .entry(tier_for_kind(kind))
1090            .or_default()
1091            .push(kind.clone());
1092    }
1093    let tiers = Tier::ORDER
1094        .iter()
1095        .enumerate()
1096        .map(|(i, t)| TierRule {
1097            tier: *t,
1098            position: i + 1,
1099            meaning: t.meaning().to_string(),
1100            kinds: kinds.get(t).cloned().unwrap_or_default(),
1101        })
1102        .collect();
1103
1104    // every edge the graph declares, judged: the table says which are followed, and the
1105    // refusals say why. An edge the graph gains and this table does not name is reported
1106    // as unjudged rather than silently unfollowed.
1107    let mut edges: Vec<EdgeRule> = Vec::new();
1108    for (edge, means) in &graph.edge_kinds {
1109        match edge_policy(edge) {
1110            Some(p) => edges.push(EdgeRule {
1111                edge: edge.clone(),
1112                means: means.clone(),
1113                forward: (p.forward > 0.0).then_some(p.forward),
1114                reverse: (p.reverse > 0.0).then_some(p.reverse),
1115                reason: format!("{}; reversed: {}", p.forward_reason, p.reverse_reason),
1116                refused: false,
1117            }),
1118            None => {
1119                let why = select::REFUSED
1120                    .iter()
1121                    .find(|(k, _)| k == edge)
1122                    .map(|(_, w)| (*w).to_string())
1123                    .unwrap_or_else(|| {
1124                        "the graph declares it and the compiler has no rule for it; it is not followed".into()
1125                    });
1126                edges.push(EdgeRule {
1127                    edge: edge.clone(),
1128                    means: means.clone(),
1129                    forward: None,
1130                    reverse: None,
1131                    reason: why,
1132                    refused: true,
1133                });
1134            }
1135        }
1136    }
1137
1138    Ok(CompilerPolicy {
1139        schema: 1,
1140        graph: crate::graph::COMPOSED.to_string(),
1141        tiers,
1142        edges,
1143        selectors: Selector::ALL
1144            .iter()
1145            .map(|s| SelectorRule {
1146                selector: *s,
1147                declared: s.declared(),
1148            })
1149            .collect(),
1150        default_budget_tokens: DEFAULT_BUDGET_TOKENS,
1151        default_max_depth: DEFAULT_MAX_DEPTH,
1152        default_floor: DEFAULT_FLOOR,
1153        bytes_per_token: BYTES_PER_TOKEN,
1154    })
1155}
1156
1157/// A repository with a plan in it, for the compiler's own tests.
1158///
1159/// [`crate::synthetic::SyntheticRepository`] is the shared generator and it deliberately
1160/// holds only a policy, some rules, some prompts and some documents — its shape is what the
1161/// scaling benchmarks measure, so a milestone added to it would move every baseline. This
1162/// fixture is its own thing: the smallest repository in which an issue belongs to a
1163/// milestone, a decision puts a rule in force, a later decision stands in for an earlier
1164/// one, a contract reaches a directory, and a closed session changed a file in scope.
1165#[cfg(test)]
1166mod fixture {
1167    use std::path::{Path, PathBuf};
1168    use std::sync::atomic::{AtomicU64, Ordering};
1169
1170    use crate::capability::{builtin, CapabilityRegistry, Context};
1171    use crate::discovery::{FileSystem, Sources};
1172    use crate::git::GitState;
1173    use crate::index::Index;
1174    use crate::metadata::KindSchema;
1175    use crate::repository::Repository;
1176    use crate::share::Share;
1177
1178    static SEQ: AtomicU64 = AtomicU64::new(0);
1179
1180    /// A generated repository on disk, removed when dropped.
1181    pub struct Planned {
1182        root: PathBuf,
1183    }
1184
1185    impl Drop for Planned {
1186        fn drop(&mut self) {
1187            let _ = std::fs::remove_dir_all(&self.root);
1188        }
1189    }
1190
1191    fn rule(id: &str, title: &str, class: &str, status: &str, extra: &str) -> String {
1192        format!(
1193            "---\nid: {id}\nversion: 1\nkind: rule\ntitle: {title}\ndescription: What {id} requires, in one sentence.\nstatement: Do what {id} says.\nstatus: {status}\nclass: {class}\n{extra}---\n\n# Rationale\n\nBecause the answer has to be checkable.\n\n# Required behaviour\n\nDo what the statement says.\n\n# Failure behaviour\n\nA reviewer decides it.\n\n# Verification\n\nReview.\n"
1194        )
1195    }
1196
1197    fn adr(id: &str, title: &str, extra: &str) -> String {
1198        format!(
1199            "---\nschema: adr/v1\nid: {id}\nkind: adr\ntitle: {title}\nstatus: accepted\ndate: 2026-09-10\ntags:\n  - context\n{extra}---\n\n## Context\n\nSomething had to be decided.\n\n## Decision\n\nIt was decided.\n\n## Consequences\n\nThings follow from it.\n"
1200        )
1201    }
1202
1203    fn contract(id: &str, title: &str, scope: &str, order: u32, extra: &str) -> String {
1204        format!(
1205            "---\nschema: context/v1\nid: {id}\nkind: context\ntitle: {title}\ndescription: What this directory is for.\nstatus: active\nscope: {scope}\nproviders: [\"*\"]\naudience: [human, agent]\ncomposition: extend\norder: {order}\n{extra}---\n\n## Purpose\n\nWhat this directory is for.\n\n## Structure\n\nWhat is in it.\n\n## Naming conventions\n\nWhat a new file here is called.\n"
1206        )
1207    }
1208
1209    const MANIFEST: &str = "schema: ai-repository/v1\nrepo:\n  path: repo\nlocal:\n  path: local\n  tracked: false\n  implicit_context: false\nsections:\n  policy: repo/policy.yaml\n  scope: repo/scope.yaml\n  profiles: repo/profiles\n  rules: repo/rules\n  knowledge: repo/knowledge\n  adrs: repo/adrs\n  sessions: repo/sessions\n  project: repo/project\ncontext:\n  documents: [README.md]\n";
1210
1211    const SOURCES: &str = "version: 1\nsources:\n  - id: context\n    kind: context\n    discovery: vcs\n    pathspec: ':(glob).ai/**/README.md'\n    required: true\n  - id: policy\n    kind: policy\n    discovery: vcs\n    pathspec: ':(glob).ai/repo/policy.yaml'\n    required: true\n  - id: scope\n    kind: scope\n    discovery: vcs\n    pathspec: ':(glob).ai/repo/scope.yaml'\n    required: false\n  - id: profile\n    kind: profile\n    discovery: vcs\n    pathspec: ':(glob).ai/repo/profiles/*.yaml'\n    required: true\n  - id: rule\n    kind: rule\n    discovery: vcs\n    pathspec: ':(glob).ai/repo/rules/**/*.md'\n    required: true\n  - id: milestone\n    kind: milestone\n    discovery: vcs\n    pathspec: ':(glob).ai/repo/project/milestones/*.yaml'\n    required: false\n  - id: issue\n    kind: issue\n    discovery: vcs\n    pathspec: ':(glob).ai/repo/project/issues/*.yaml'\n    required: false\n  - id: adr\n    kind: adr\n    discovery: vcs\n    pathspec: ':(glob).ai/repo/adrs/????-*.md'\n    required: false\n  - id: session\n    kind: session\n    discovery: vcs\n    pathspec: ':(glob).ai/repo/sessions/*.md'\n    required: false\n  - id: library\n    kind: implementation\n    discovery: vcs\n    pathspec: ':(glob)lib/*.sh'\n    required: false\n  - id: case\n    kind: test\n    discovery: vcs\n    pathspec: ':(glob)test/cases/*.sh'\n    required: false\n";
1212
1213    const SCOPE: &str = "version: 1\nin:\n  - .ai/manifest.yaml\n  - .ai/README.md\n  - .ai/repo/**\n  - lib/**\n  - test/**\nout:\n  paths: []\n  binary: true\n  max_bytes: 1048576\n";
1214
1215    impl Planned {
1216        /// Write the repository and return it.
1217        pub fn build() -> std::io::Result<Self> {
1218            let seq = SEQ.fetch_add(1, Ordering::SeqCst);
1219            let root = std::env::temp_dir()
1220                .join(format!("majordomus-planned-{}-{seq}", std::process::id()));
1221            std::fs::create_dir_all(&root)?;
1222            let me = Planned { root };
1223            me.write(".ai/manifest.yaml", MANIFEST)?;
1224            me.write(".ai/repo/knowledge/sources.yaml", SOURCES)?;
1225            me.write(".ai/repo/scope.yaml", SCOPE)?;
1226            me.write(
1227                ".ai/repo/policy.yaml",
1228                "version: 1\ncontext:\n  always_loaded_budget_lines: 150\n  builder_budget_lines: 300\n",
1229            )?;
1230            me.write(".ai/repo/profiles/implementation.yaml", "name: implementation\ndescription: The default.\ncapability: standard\neffort: medium\nverbosity: concise\npresentation: engineering\ncheckpoint_interval: 15m\n")?;
1231
1232            // the contracts: the layer's root, and one over the rules directory
1233            me.write(
1234                ".ai/README.md",
1235                &contract("ai.layer", "The layer", "subtree", 10, "tracks: [lib]\n"),
1236            )?;
1237            me.write(
1238                ".ai/repo/rules/README.md",
1239                &contract("ai.repo.rules", "Rules", "subtree", 100, ""),
1240            )?;
1241
1242            // the rules: one blocking, one that depends on it, one deprecated
1243            me.write(
1244                ".ai/repo/rules/project/alpha.v1.md",
1245                &rule(
1246                    "project.alpha",
1247                    "Alpha",
1248                    "blocking",
1249                    "active",
1250                    "depends_on: []\ntags: [context, budget]\n",
1251                ),
1252            )?;
1253            me.write(
1254                ".ai/repo/rules/project/beta.v1.md",
1255                &rule(
1256                    "project.beta",
1257                    "Beta",
1258                    "advisory",
1259                    "active",
1260                    "depends_on: [project.alpha]\ntags: [context]\n",
1261                ),
1262            )?;
1263            me.write(
1264                ".ai/repo/rules/project/gamma.v1.md",
1265                &rule(
1266                    "project.gamma",
1267                    "Gamma",
1268                    "advisory",
1269                    "deprecated",
1270                    "depends_on: [project.alpha]\ntags: [context]\n",
1271                ),
1272            )?;
1273
1274            // the decisions: one puts the blocking rule in force, a later one stands in for it
1275            me.write(
1276                ".ai/repo/adrs/0001-alpha.md",
1277                &adr(
1278                    "adr-0001",
1279                    "The first decision",
1280                    "related:\n  - rule:project.alpha\n",
1281                ),
1282            )?;
1283            me.write(
1284                ".ai/repo/adrs/0002-beta.md",
1285                &adr(
1286                    "adr-0002",
1287                    "The decision that stands in for the first",
1288                    "supersedes:\n  - adr-0001\nrelated:\n  - rule:project.beta\n",
1289                ),
1290            )?;
1291
1292            // the plan: one milestone, two issues, the second depending on the first
1293            me.write(".ai/repo/project/milestones/m1.yaml", "id: m1\ntitle: The milestone\nslug: the-milestone\norder: 0\npriority: p1\nproblem: \"A problem worth solving.\"\noutcome: \"The outcome once it is solved.\"\nacceptance_criteria:\n  - The outcome is reached\nvalidation:\n  - \"true\"\nevidence_required:\n  - proof\n")?;
1294            me.write(".ai/repo/project/issues/I0001.yaml", "id: I0001\nmilestone: m1\ntitle: The bounded piece of work\nslug: issue-I0001\npriority: p1\nprofile: implementation\nobjective: \"Do the bounded piece of work in lib.\"\nscope:\n  - lib\nacceptance_criteria:\n  - The work is done\nvalidation:\n  - \"true\"\nevidence_required:\n  - proof\nevidence:\n  - covers: proof\n    type: manual\n    command: \"true\"\n    result: \"it was done\"\n    commit: 0123456789abcdef0123456789abcdef01234567\n    recorded_at: 2026-09-10T00:00:00Z\n")?;
1295            me.write(".ai/repo/project/issues/I0002.yaml", "id: I0002\nmilestone: m1\ntitle: The piece that waits for the first\nslug: issue-I0002\npriority: p2\nprofile: implementation\nobjective: \"Do the second piece.\"\nscope:\n  - lib\ndepends_on: [I0001]\nacceptance_criteria:\n  - The work is done\nvalidation:\n  - \"true\"\nevidence_required:\n  - proof\n")?;
1296
1297            // a closed session that changed a file in the issue's scope
1298            me.write(".ai/repo/sessions/s1.md", "---\nschema: session/v1\nkind: session\nsession_id: s-20260909000000-aaaa\ntitle: What the last session did\nstarted_at: 2026-09-09T00:00:00Z\nclosed_at: 2026-09-09T01:00:00Z\noutcome: closed\nbranch: master\nhead: 0123456789abcdef0123456789abcdef01234567\nworking_tree: clean\nchanged_files:\n  - lib/thing.sh\n---\n\n# Session\n\nIt changed one file.\n")?;
1299
1300            // the code and the case the work is about
1301            me.write(
1302                "lib/thing.sh",
1303                "#!/usr/bin/env bash\n# the thing\nmj_thing() { :; }\n",
1304            )?;
1305            me.write(
1306                "test/cases/01_thing.sh",
1307                "#!/usr/bin/env bash\n# the case for the thing\nset -eu\n",
1308            )?;
1309            Ok(me)
1310        }
1311
1312        fn write(&self, rel: &str, content: &str) -> std::io::Result<()> {
1313            let p = self.root.join(rel);
1314            if let Some(dir) = p.parent() {
1315                std::fs::create_dir_all(dir)?;
1316            }
1317            std::fs::write(p, content)
1318        }
1319
1320        /// The index of this repository, discovered through the filesystem.
1321        pub fn index(&self) -> crate::error::Result<Index> {
1322            let repo = Repository::discover(&self.root)?;
1323            let sources = Sources::load(&repo)?;
1324            let share = Share::locate(Some(&crate::synthetic::crate_share()), &self.root)?;
1325            let schema = KindSchema::load(&share, &repo)?;
1326            let fs = FileSystem {
1327                excluded: vec![".git".into(), repo.local_path()],
1328            };
1329            let scope = crate::scope::Scope::load(&share, &repo)?;
1330            Index::build(
1331                &repo,
1332                &sources,
1333                &schema,
1334                &fs,
1335                GitState::Available(crate::git::GitInfo {
1336                    toplevel: self.root.clone(),
1337                    head: Some("0123456789abcdef0123456789abcdef01234567".into()),
1338                    branch: Some("master".into()),
1339                    working_tree: "clean".into(),
1340                }),
1341                scope,
1342            )
1343        }
1344
1345        /// A context over this repository.
1346        pub fn context(&self) -> crate::error::Result<std::sync::Arc<Context>> {
1347            let index = self.index()?;
1348            let registry = CapabilityRegistry::builder()
1349                .with_modules(builtin::modules())
1350                .with_index(&index)
1351                .build()
1352                .map_err(|errors| crate::error::Error::Registry { errors })?;
1353            Ok(std::sync::Arc::new(Context::new(
1354                std::sync::Arc::new(index),
1355                std::sync::Arc::new(registry),
1356            )))
1357        }
1358
1359        /// The repository root, for a test that changes the tree.
1360        pub fn root(&self) -> &Path {
1361            &self.root
1362        }
1363
1364        /// Change a file, so that the tree and every fingerprint move.
1365        pub fn touch(&self, rel: &str, suffix: &str) -> std::io::Result<()> {
1366            let text = std::fs::read_to_string(self.root.join(rel))?;
1367            self.write(rel, &format!("{text}{suffix}\n"))
1368        }
1369    }
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374    use super::fixture::Planned;
1375    use super::*;
1376
1377    fn planned() -> (Planned, std::sync::Arc<Context>) {
1378        let repo = Planned::build().expect("a repository");
1379        let ctx = repo.context().expect("a context");
1380        assert_eq!(
1381            ctx.index.errors(),
1382            0,
1383            "the fixture does not validate: {:?}",
1384            ctx.index.diagnostics
1385        );
1386        (repo, ctx)
1387    }
1388
1389    #[test]
1390    fn a_request_that_names_nothing_still_answers_with_the_governance() {
1391        let (_r, ctx) = planned();
1392        let c = compile(&ctx, CompileInput::default()).expect("an answer");
1393        assert_eq!(c.schema, 1);
1394        assert!(!c.fingerprint.is_empty(), "the answer names no tree");
1395        assert!(c.seeds.is_empty());
1396        let required: Vec<&str> = c
1397            .selected
1398            .iter()
1399            .filter(|e| e.required)
1400            .map(|e| e.kind.as_str())
1401            .collect();
1402        assert!(required.contains(&"policy"), "no policy: {required:?}");
1403        assert!(required.contains(&"scope"), "no scope: {required:?}");
1404        assert_eq!(c.budget.limit_tokens, DEFAULT_BUDGET_TOKENS);
1405        assert_eq!(c.budget.tiers.len(), Tier::ORDER.len());
1406        // and the answer says what tree it is true of
1407        assert_eq!(c.git.branch.as_deref(), Some("master"));
1408        assert_eq!(c.git.working_tree, "clean");
1409    }
1410
1411    #[test]
1412    fn an_issue_pulls_its_milestone_and_says_why() {
1413        let (_r, ctx) = planned();
1414        let c = compile(
1415            &ctx,
1416            CompileInput {
1417                issue: Some("I0001".into()),
1418                ..Default::default()
1419            },
1420        )
1421        .expect("an answer");
1422        let issue = c
1423            .selected
1424            .iter()
1425            .find(|e| e.kind == "issue" && e.uri.ends_with("I0001"))
1426            .expect("the seed is in its own answer");
1427        assert_eq!(issue.tier, Tier::Task);
1428        assert!(issue.required, "a seed may not be dropped for budget");
1429        assert_eq!(issue.discovered_by[0].selector, Selector::Seed);
1430        assert_eq!(issue.relevance, 1.0);
1431        assert_eq!(issue.provenance.source_class, "issue");
1432        assert_eq!(issue.facts.get("milestone").map(String::as_str), Some("m1"));
1433
1434        let milestone = c
1435            .selected
1436            .iter()
1437            .find(|e| e.kind == "milestone")
1438            .expect("the milestone the issue belongs to");
1439        let via = milestone
1440            .discovered_by
1441            .iter()
1442            .find(|d| d.edge.as_deref() == Some("belongs_to"))
1443            .expect("reached along the declared edge");
1444        assert_eq!(via.selector, Selector::Relation);
1445        assert_eq!(via.depth, 1);
1446        assert!(!via.reason.is_empty());
1447        assert_eq!(via.confidence, 1.0);
1448        assert_eq!(milestone.relevance, 0.95, "the edge's own weight");
1449
1450        // the second issue is reached backwards along `depends_on`, at a lower weight
1451        let second = c
1452            .selected
1453            .iter()
1454            .find(|e| e.uri.ends_with("I0002"))
1455            .expect("the issue that depends on the seed");
1456        assert!(second.relevance < issue.relevance);
1457
1458        // every entry carries the index's own provenance, a cost and a bounded confidence
1459        for e in &c.selected {
1460            assert!(!e.provenance.path.is_empty(), "{} has no path", e.uri);
1461            assert!(e.cost_tokens > 0, "{} costs nothing", e.uri);
1462            assert!(
1463                (0.0..=1.0).contains(&e.confidence),
1464                "{} has confidence {}",
1465                e.uri,
1466                e.confidence
1467            );
1468            assert!(
1469                !e.discovered_by.is_empty(),
1470                "{} is in the answer for no reason",
1471                e.uri
1472            );
1473        }
1474    }
1475
1476    #[test]
1477    fn a_milestone_reaches_the_issues_that_belong_to_it() {
1478        let (_r, ctx) = planned();
1479        let c = compile(
1480            &ctx,
1481            CompileInput {
1482                milestone: Some("m1".into()),
1483                budget_tokens: Some(1_000_000),
1484                ..Default::default()
1485            },
1486        )
1487        .expect("an answer");
1488        assert!(c.seeds.iter().any(|s| s.kind == "milestone"));
1489        let issues: Vec<&ContextEntry> = c.selected.iter().filter(|e| e.kind == "issue").collect();
1490        assert_eq!(issues.len(), 2, "both issues of the milestone");
1491        for i in &issues {
1492            let via = i
1493                .discovered_by
1494                .iter()
1495                .find(|d| d.edge.as_deref() == Some("belongs_to"))
1496                .expect("reached backwards along belongs_to");
1497            assert!(via.reason.contains("same milestone"), "{}", via.reason);
1498        }
1499        // a milestone's issues declare `lib`, so the code and the case under it come too
1500        assert!(
1501            c.selected.iter().any(|e| e.kind == "implementation"),
1502            "the milestone's issues declare a scope and nothing under it was selected"
1503        );
1504    }
1505
1506    #[test]
1507    fn a_seed_that_does_not_exist_says_so_rather_than_answering_emptily() {
1508        let (_r, ctx) = planned();
1509        match compile(
1510            &ctx,
1511            CompileInput {
1512                issue: Some("I9999".into()),
1513                ..Default::default()
1514            },
1515        ) {
1516            Err(CapabilityError::NotFound(m)) => assert!(m.contains("I9999"), "{m}"),
1517            other => panic!("{other:?}"),
1518        }
1519        match compile(
1520            &ctx,
1521            CompileInput {
1522                milestone: Some("nothing".into()),
1523                ..Default::default()
1524            },
1525        ) {
1526            Err(CapabilityError::NotFound(m)) => assert!(m.contains("milestone"), "{m}"),
1527            other => panic!("{other:?}"),
1528        }
1529        match compile(
1530            &ctx,
1531            CompileInput {
1532                uris: vec!["majordomus://rule/nothing@1".into()],
1533                ..Default::default()
1534            },
1535        ) {
1536            Err(CapabilityError::NotFound(m)) => assert!(m.contains("nothing"), "{m}"),
1537            other => panic!("{other:?}"),
1538        }
1539        match compile(
1540            &ctx,
1541            CompileInput {
1542                paths: vec!["../outside".into()],
1543                ..Default::default()
1544            },
1545        ) {
1546            Err(CapabilityError::InvalidInput(m)) => assert!(m.contains("outside"), "{m}"),
1547            other => panic!("{other:?}"),
1548        }
1549        match compile(
1550            &ctx,
1551            CompileInput {
1552                budget_tokens: Some(0),
1553                ..Default::default()
1554            },
1555        ) {
1556            Err(CapabilityError::InvalidInput(m)) => assert!(m.contains("zero"), "{m}"),
1557            other => panic!("{other:?}"),
1558        }
1559        match compile(
1560            &ctx,
1561            CompileInput {
1562                floor: Some(2.0),
1563                ..Default::default()
1564            },
1565        ) {
1566            Err(CapabilityError::InvalidInput(m)) => assert!(m.contains("floor"), "{m}"),
1567            other => panic!("{other:?}"),
1568        }
1569    }
1570
1571    #[test]
1572    fn the_order_is_total_and_the_same_answer_twice() {
1573        let (_r, ctx) = planned();
1574        let input = CompileInput {
1575            issue: Some("I0001".into()),
1576            intent: Some("the budget and the decision that put alpha in force".into()),
1577            budget_tokens: Some(1_000_000),
1578            ..Default::default()
1579        };
1580        let a = compile(&ctx, input.clone()).expect("an answer");
1581        let b = compile(&ctx, input).expect("the same answer");
1582        assert_eq!(a, b, "the compiler is not deterministic");
1583        for w in a.selected.windows(2) {
1584            let (x, y) = (&w[0], &w[1]);
1585            assert!(
1586                x.tier < y.tier
1587                    || (x.tier == y.tier && x.relevance > y.relevance)
1588                    || (x.tier == y.tier && x.relevance == y.relevance && x.uri < y.uri),
1589                "{} ({}/{}) is ordered before {} ({}/{})",
1590                x.uri,
1591                x.tier.as_str(),
1592                x.relevance,
1593                y.uri,
1594                y.tier.as_str(),
1595                y.relevance
1596            );
1597        }
1598    }
1599
1600    #[test]
1601    fn a_budget_that_cannot_hold_everything_says_what_it_dropped() {
1602        let (_r, ctx) = planned();
1603        let tight = compile(
1604            &ctx,
1605            CompileInput {
1606                issue: Some("I0001".into()),
1607                budget_tokens: Some(1),
1608                ..Default::default()
1609            },
1610        )
1611        .expect("an answer");
1612        assert!(
1613            tight
1614                .excluded
1615                .iter()
1616                .any(|e| e.reason == ExclusionReason::Budget),
1617            "a one-token budget dropped nothing"
1618        );
1619        for e in &tight.excluded {
1620            assert!(
1621                !e.detail.is_empty(),
1622                "{} was dropped without a reason",
1623                e.uri
1624            );
1625            assert!(e.cost_tokens > 0, "{} would have cost nothing", e.uri);
1626        }
1627        // what may not be dropped is still there, and the answer admits it went over
1628        assert!(tight.selected.iter().all(|e| e.required));
1629        assert!(tight.budget.required_tokens > 1);
1630        assert!(tight.budget.over_budget);
1631
1632        let loose = compile(
1633            &ctx,
1634            CompileInput {
1635                issue: Some("I0001".into()),
1636                budget_tokens: Some(1_000_000),
1637                ..Default::default()
1638            },
1639        )
1640        .expect("an answer");
1641        assert!(loose.selected.len() > tight.selected.len());
1642        assert!(!loose.budget.over_budget);
1643        assert_eq!(
1644            loose.budget.used_tokens + loose.budget.remaining_tokens,
1645            1_000_000
1646        );
1647        // and the per-tier spend adds up to what was used
1648        let summed: u64 = loose.budget.tiers.iter().map(|t| t.tokens).sum();
1649        assert_eq!(summed, loose.budget.used_tokens);
1650        let counted: usize = loose.budget.tiers.iter().map(|t| t.selected).sum();
1651        assert_eq!(counted, loose.selected.len());
1652    }
1653
1654    #[test]
1655    fn the_budget_spends_the_authority_order() {
1656        let (_r, ctx) = planned();
1657        // a budget large enough for the task tier and little else keeps the task tier
1658        let c = compile(
1659            &ctx,
1660            CompileInput {
1661                issue: Some("I0001".into()),
1662                budget_tokens: Some(400),
1663                ..Default::default()
1664            },
1665        )
1666        .expect("an answer");
1667        // First fit spends the tiers in order, so a later tier can only survive an earlier
1668        // tier's exclusion by being *cheaper* than what was dropped: the ceiling had room
1669        // for the small thing and not for the large one. A later, larger entry keeping its
1670        // place while an earlier one was dropped would mean the order was not spent.
1671        for dropped in c
1672            .excluded
1673            .iter()
1674            .filter(|e| e.reason == ExclusionReason::Budget)
1675        {
1676            for kept in c
1677                .selected
1678                .iter()
1679                .filter(|e| !e.required && e.tier > dropped.tier)
1680            {
1681                assert!(
1682                    kept.cost_tokens <= dropped.cost_tokens,
1683                    "{} ({}, {} tokens) was kept over {} ({}, {} tokens) from an earlier tier",
1684                    kept.uri,
1685                    kept.tier.as_str(),
1686                    kept.cost_tokens,
1687                    dropped.uri,
1688                    dropped.tier.as_str(),
1689                    dropped.cost_tokens
1690                );
1691            }
1692        }
1693        // and what the request was about is never what a budget drops
1694        assert!(c.selected.iter().any(|e| e.tier == Tier::Task));
1695        assert!(!c
1696            .excluded
1697            .iter()
1698            .any(|e| e.reason == ExclusionReason::Budget && e.tier == Tier::Task));
1699    }
1700
1701    #[test]
1702    fn the_same_object_reached_twice_is_one_entry_with_two_reasons() {
1703        let (_r, ctx) = planned();
1704        // the blocking rule is reachable as governance, backwards from the decision that
1705        // put it in force, and by the intent's own words
1706        let c = compile(
1707            &ctx,
1708            CompileInput {
1709                intent: Some("alpha budget context".into()),
1710                all_blocking_rules: Some(true),
1711                budget_tokens: Some(1_000_000),
1712                ..Default::default()
1713            },
1714        )
1715        .expect("an answer");
1716        let mut uris: Vec<&str> = c.selected.iter().map(|e| e.uri.as_str()).collect();
1717        let before = uris.len();
1718        uris.sort();
1719        uris.dedup();
1720        assert_eq!(before, uris.len(), "an identifier is in the answer twice");
1721
1722        let multi = c
1723            .selected
1724            .iter()
1725            .find(|e| e.discovered_by.len() > 1)
1726            .expect("nothing was reached twice; the fixture cannot prove deduplication");
1727        let record = c
1728            .deduplicated
1729            .iter()
1730            .find(|d| d.kept == multi.uri && d.key == "uri")
1731            .expect("a folded entry with no record of the fold");
1732        assert_eq!(record.paths, multi.discovered_by.len());
1733        assert!(
1734            record.detail.contains("discovery path"),
1735            "{}",
1736            record.detail
1737        );
1738        let selectors: BTreeSet<Selector> =
1739            multi.discovered_by.iter().map(|d| d.selector).collect();
1740        assert!(
1741            selectors.len() > 1,
1742            "{} was reached twice by one selector only",
1743            multi.uri
1744        );
1745        // the entry keeps the best relevance of its paths, not the last one
1746        let best = multi
1747            .discovered_by
1748            .iter()
1749            .map(|d| d.confidence)
1750            .fold(0.0_f64, f64::max);
1751        assert_eq!(multi.confidence, best);
1752    }
1753
1754    #[test]
1755    fn a_later_decision_stands_in_for_an_earlier_one_and_the_answer_says_so() {
1756        let (_r, ctx) = planned();
1757        let c = compile(
1758            &ctx,
1759            CompileInput {
1760                uris: vec!["majordomus://adr/adr-0002".into()],
1761                budget_tokens: Some(1_000_000),
1762                ..Default::default()
1763            },
1764        )
1765        .expect("an answer");
1766        let conflict = c
1767            .conflicts
1768            .iter()
1769            .find(|k| k.kind == "supersedes")
1770            .expect("two decisions in one answer and no conflict recorded");
1771        assert!(conflict.current.ends_with("adr-0002"));
1772        assert!(conflict.against.ends_with("adr-0001"));
1773        assert!(!c.selected.iter().any(|e| e.uri.ends_with("adr-0001")));
1774        let out = c
1775            .excluded
1776            .iter()
1777            .find(|e| e.uri.ends_with("adr-0001"))
1778            .expect("the superseded decision left the answer silently");
1779        assert_eq!(out.reason, ExclusionReason::Superseded);
1780        assert!(out.detail.contains("stands in for"), "{}", out.detail);
1781    }
1782
1783    #[test]
1784    fn what_the_layer_says_is_no_longer_current_is_left_out_with_the_reason() {
1785        let (_r, ctx) = planned();
1786        let c = compile(
1787            &ctx,
1788            CompileInput {
1789                uris: vec!["majordomus://rule/project.alpha@1".into()],
1790                budget_tokens: Some(1_000_000),
1791                ..Default::default()
1792            },
1793        )
1794        .expect("an answer");
1795        // the deprecated rule depends on the seed, so it is reached — and refused
1796        let out = c
1797            .excluded
1798            .iter()
1799            .find(|e| e.uri.contains("project.gamma"))
1800            .expect("the deprecated rule was reached and not reported");
1801        assert_eq!(out.reason, ExclusionReason::Stale);
1802        assert!(out.detail.contains("deprecated"), "{}", out.detail);
1803        assert!(!c.selected.iter().any(|e| e.uri.contains("project.gamma")));
1804
1805        // but a request that names it explicitly keeps it, and says it is stale
1806        let named = compile(
1807            &ctx,
1808            CompileInput {
1809                uris: vec!["majordomus://rule/project.gamma@1".into()],
1810                budget_tokens: Some(1_000_000),
1811                ..Default::default()
1812            },
1813        )
1814        .expect("an answer");
1815        assert!(named
1816            .selected
1817            .iter()
1818            .any(|e| e.uri.contains("project.gamma")));
1819        assert!(
1820            named
1821                .diagnostics
1822                .iter()
1823                .any(|d| d.code == "stale_seed" && d.message.contains("gamma")),
1824            "a stale seed was kept without a word about it: {:?}",
1825            named.diagnostics
1826        );
1827    }
1828
1829    #[test]
1830    fn an_inference_is_never_as_confident_as_a_declaration() {
1831        let (_r, ctx) = planned();
1832        let c = compile(
1833            &ctx,
1834            CompileInput {
1835                intent: Some("alpha".into()),
1836                budget_tokens: Some(1_000_000),
1837                ..Default::default()
1838            },
1839        )
1840        .expect("an answer");
1841        let inferred: Vec<&ContextEntry> = c
1842            .selected
1843            .iter()
1844            .filter(|e| e.discovered_by.iter().all(|d| !d.selector.declared()))
1845            .collect();
1846        assert!(
1847            !inferred.is_empty(),
1848            "the intent matched nothing; the test proves nothing"
1849        );
1850        for e in inferred {
1851            assert!(
1852                e.relevance < 1.0,
1853                "{} was inferred and is as relevant as a seed",
1854                e.uri
1855            );
1856            assert!(e.confidence <= 1.0);
1857            for d in &e.discovered_by {
1858                assert_eq!(d.selector, Selector::IntentMatch);
1859                assert!(d.reason.contains("intent"), "{}", d.reason);
1860            }
1861        }
1862    }
1863
1864    #[test]
1865    fn a_declared_path_selects_the_contracts_the_code_and_the_cases_over_it() {
1866        let (_r, ctx) = planned();
1867        let c = compile(
1868            &ctx,
1869            CompileInput {
1870                paths: vec!["lib".into()],
1871                budget_tokens: Some(1_000_000),
1872                ..Default::default()
1873            },
1874        )
1875        .expect("an answer");
1876        // the code under the path
1877        let code = c
1878            .selected
1879            .iter()
1880            .find(|e| e.kind == "implementation")
1881            .expect("nothing under the declared path");
1882        assert_eq!(code.tier, Tier::Source);
1883        assert_eq!(code.discovered_by[0].selector, Selector::ScopePath);
1884        assert!(code.discovered_by[0].reason.contains("lib"));
1885        // the contract that tracks it
1886        let contract = c
1887            .selected
1888            .iter()
1889            .find(|e| {
1890                e.kind == "context"
1891                    && e.discovered_by
1892                        .iter()
1893                        .any(|d| d.selector == Selector::Contract)
1894            })
1895            .expect("no directory contract over the declared path");
1896        assert_eq!(contract.tier, Tier::Governance);
1897        // and the path is recorded as a seed, so the answer says what it was about
1898        assert!(c.seeds.iter().any(|s| s.kind == "path" && s.uri == "lib"));
1899
1900        // a path inside the layer resolves its own directory's contract and its ancestors
1901        let inside = compile(
1902            &ctx,
1903            CompileInput {
1904                paths: vec![".ai/repo/rules".into()],
1905                budget_tokens: Some(1_000_000),
1906                ..Default::default()
1907            },
1908        )
1909        .expect("an answer");
1910        let ids: BTreeSet<&str> = inside
1911            .selected
1912            .iter()
1913            .filter(|e| e.kind == "context")
1914            .map(|e| e.uri.as_str())
1915            .collect();
1916        assert!(
1917            ids.iter().any(|u| u.ends_with("ai.repo.rules")),
1918            "the directory's own contract is missing: {ids:?}"
1919        );
1920        assert!(
1921            ids.iter().any(|u| u.ends_with("ai.layer")),
1922            "the ancestor's contract is missing: {ids:?}"
1923        );
1924    }
1925
1926    #[test]
1927    fn a_previous_session_that_touched_the_scope_is_knowledge() {
1928        let (_r, ctx) = planned();
1929        let c = compile(
1930            &ctx,
1931            CompileInput {
1932                issue: Some("I0001".into()),
1933                budget_tokens: Some(1_000_000),
1934                ..Default::default()
1935            },
1936        )
1937        .expect("an answer");
1938        let session = c
1939            .selected
1940            .iter()
1941            .find(|e| e.kind == "session")
1942            .expect("the recorded session that changed a file in scope");
1943        assert_eq!(session.tier, Tier::Knowledge);
1944        assert_eq!(session.discovered_by[0].selector, Selector::SessionState);
1945        assert!(
1946            session.discovered_by[0].reason.contains("changed"),
1947            "{}",
1948            session.discovered_by[0].reason
1949        );
1950    }
1951
1952    #[test]
1953    fn the_commits_the_layer_names_are_carried_with_the_record_that_named_them() {
1954        let (_r, ctx) = planned();
1955        let c = compile(
1956            &ctx,
1957            CompileInput {
1958                issue: Some("I0001".into()),
1959                budget_tokens: Some(1_000_000),
1960                ..Default::default()
1961            },
1962        )
1963        .expect("an answer");
1964        let commit = c
1965            .git
1966            .commits
1967            .iter()
1968            .find(|k| k.commit.starts_with("0123456789"))
1969            .expect("the issue's own evidence names a commit and the answer dropped it");
1970        assert!(commit.named_by.ends_with("I0001"));
1971        assert_eq!(commit.covers.as_deref(), Some("proof"));
1972    }
1973
1974    #[test]
1975    fn a_changed_tree_is_a_different_answer() {
1976        let repo = Planned::build().expect("a repository");
1977        let before = {
1978            let ctx = repo.context().expect("a context");
1979            compile(&ctx, CompileInput::default()).expect("an answer")
1980        };
1981        repo.touch(".ai/repo/rules/project/alpha.v1.md", "\nOne more line.")
1982            .expect("the tree moves");
1983        let after = {
1984            let ctx = repo.context().expect("a context");
1985            compile(&ctx, CompileInput::default()).expect("an answer")
1986        };
1987        assert_ne!(
1988            before.fingerprint, after.fingerprint,
1989            "the tree changed and the answer claims the same index"
1990        );
1991        assert!(repo.root().exists());
1992    }
1993
1994    #[test]
1995    fn explaining_covers_selected_excluded_deduplicated_and_never_reached() {
1996        let (_r, ctx) = planned();
1997        let base = CompileInput {
1998            issue: Some("I0001".into()),
1999            budget_tokens: Some(1_000_000),
2000            ..Default::default()
2001        };
2002        let compiled = compile(&ctx, base.clone()).expect("an answer");
2003        let chosen = compiled.selected[0].uri.clone();
2004        let e = explain(
2005            &ctx,
2006            ExplainInput {
2007                uri: chosen.clone(),
2008                request: base.clone(),
2009            },
2010        )
2011        .expect("an explanation");
2012        assert_eq!(e.standing, Standing::Selected);
2013        assert!(e.entry.is_some());
2014        assert!(e.detail.contains("reached by"), "{}", e.detail);
2015        assert_eq!(e.budget.limit_tokens, 1_000_000);
2016
2017        let unknown = explain(
2018            &ctx,
2019            ExplainInput {
2020                uri: "majordomus://rule/nothing-like-this@1".into(),
2021                request: base.clone(),
2022            },
2023        )
2024        .expect("an explanation");
2025        assert_eq!(unknown.standing, Standing::Unknown);
2026        assert!(
2027            unknown.detail.contains("holds nothing"),
2028            "{}",
2029            unknown.detail
2030        );
2031
2032        // excluded for budget, judged under a budget that cannot hold it
2033        let tight = CompileInput {
2034            issue: Some("I0001".into()),
2035            budget_tokens: Some(1),
2036            ..Default::default()
2037        };
2038        let dropped = compile(&ctx, tight.clone()).expect("an answer").excluded[0]
2039            .uri
2040            .clone();
2041        let x = explain(
2042            &ctx,
2043            ExplainInput {
2044                uri: dropped,
2045                request: tight,
2046            },
2047        )
2048        .expect("an explanation");
2049        assert_eq!(x.standing, Standing::Excluded);
2050        assert!(x.excluded.is_some());
2051        assert!(x.detail.contains("reached and not given"), "{}", x.detail);
2052
2053        // something the index holds that this request has no reason to reach
2054        let far = ctx.index.objects.iter().map(|o| o.uri.clone()).find(|u| {
2055            !compiled.selected.iter().any(|e| e.uri == *u)
2056                && !compiled.excluded.iter().any(|e| e.uri == *u)
2057                && !compiled.deduplicated.iter().any(|d| d.dropped.contains(u))
2058        });
2059        if let Some(uri) = far {
2060            let n = explain(&ctx, ExplainInput { uri, request: base }).expect("an explanation");
2061            assert_eq!(n.standing, Standing::NotReached);
2062            assert!(n.detail.contains("no selector"), "{}", n.detail);
2063        }
2064
2065        match explain(
2066            &ctx,
2067            ExplainInput {
2068                uri: "   ".into(),
2069                request: CompileInput::default(),
2070            },
2071        ) {
2072            Err(CapabilityError::InvalidInput(m)) => assert!(m.contains("identifier"), "{m}"),
2073            other => panic!("{other:?}"),
2074        }
2075    }
2076
2077    #[test]
2078    fn the_policy_judges_every_edge_the_graph_declares() {
2079        let (_r, ctx) = planned();
2080        let p = policy(&ctx).expect("the policy");
2081        assert_eq!(p.graph, crate::graph::COMPOSED);
2082        assert_eq!(p.tiers.len(), Tier::ORDER.len());
2083        assert_eq!(p.tiers[0].position, 1);
2084        assert_eq!(p.tiers[0].tier, Tier::Task);
2085        assert!(!p.edges.is_empty());
2086        let is_a = p.edges.iter().find(|e| e.edge == "is_a").expect("is_a");
2087        assert!(is_a.refused);
2088        assert!(is_a.reason.contains("whole layer"), "{}", is_a.reason);
2089        assert!(is_a.forward.is_none() && is_a.reverse.is_none());
2090        let dep = p
2091            .edges
2092            .iter()
2093            .find(|e| e.edge == "depends_on")
2094            .expect("depends_on");
2095        assert!(!dep.refused);
2096        assert_eq!(dep.forward, Some(0.9));
2097        assert!(!dep.means.is_empty());
2098        assert_eq!(p.selectors.len(), Selector::ALL.len());
2099        assert!(p.selectors.iter().any(|s| !s.declared));
2100        assert_eq!(p.default_budget_tokens, DEFAULT_BUDGET_TOKENS);
2101        assert_eq!(p.bytes_per_token, BYTES_PER_TOKEN);
2102        // every tier says which kinds land in it, and the layer's kinds are all placed
2103        let placed: usize = p.tiers.iter().map(|t| t.kinds.len()).sum();
2104        assert!(placed > 0);
2105    }
2106
2107    #[test]
2108    fn the_depth_limit_and_the_floor_both_narrow_the_answer() {
2109        let (_r, ctx) = planned();
2110        let deep = compile(
2111            &ctx,
2112            CompileInput {
2113                issue: Some("I0001".into()),
2114                max_depth: Some(4),
2115                budget_tokens: Some(1_000_000),
2116                ..Default::default()
2117            },
2118        )
2119        .expect("an answer");
2120        let shallow = compile(
2121            &ctx,
2122            CompileInput {
2123                issue: Some("I0001".into()),
2124                max_depth: Some(1),
2125                budget_tokens: Some(1_000_000),
2126                ..Default::default()
2127            },
2128        )
2129        .expect("an answer");
2130        assert!(
2131            shallow.selected.len() <= deep.selected.len(),
2132            "a shallower walk reached more"
2133        );
2134        assert!(shallow.selected.iter().all(|e| e.depth <= 1));
2135
2136        let strict = compile(
2137            &ctx,
2138            CompileInput {
2139                issue: Some("I0001".into()),
2140                floor: Some(0.9),
2141                budget_tokens: Some(1_000_000),
2142                ..Default::default()
2143            },
2144        )
2145        .expect("an answer");
2146        // the floor keeps out what the walk decayed below it; the seeds and the governance
2147        // are at 1.0 and stay
2148        assert!(strict.selected.iter().any(|e| e.relevance >= 0.9));
2149        assert!(strict.selected.len() < deep.selected.len());
2150    }
2151
2152    #[test]
2153    fn asking_for_every_blocking_rule_is_honest_about_the_cost() {
2154        let (_r, ctx) = planned();
2155        let some = compile(
2156            &ctx,
2157            CompileInput {
2158                budget_tokens: Some(1_000_000),
2159                ..Default::default()
2160            },
2161        )
2162        .expect("an answer");
2163        let all = compile(
2164            &ctx,
2165            CompileInput {
2166                all_blocking_rules: Some(true),
2167                budget_tokens: Some(1_000_000),
2168                ..Default::default()
2169            },
2170        )
2171        .expect("an answer");
2172        assert!(all.budget.used_tokens > some.budget.used_tokens);
2173        let rule = all
2174            .selected
2175            .iter()
2176            .find(|e| e.kind == "rule")
2177            .expect("no blocking rule when every one was asked for");
2178        assert_eq!(rule.tier, Tier::Governance);
2179        assert_eq!(
2180            rule.facts.get("class").map(String::as_str),
2181            Some("blocking")
2182        );
2183        assert_eq!(rule.discovered_by[0].selector, Selector::Governance);
2184        // an advisory rule is not governance the layer applies to everything
2185        assert!(!all
2186            .selected
2187            .iter()
2188            .any(
2189                |e| e.facts.get("class").map(String::as_str) == Some("advisory")
2190                    && e.discovered_by
2191                        .iter()
2192                        .all(|d| d.selector == Selector::Governance)
2193            ));
2194    }
2195}