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}