Skip to main content

majordomus_cli/devcontext/
model.rs

1//! What a compiled development context is made of.
2//!
3//! Every type here is a value, not a rendering. A [`ContextEntry`] carries the canonical
4//! identifier of the thing it selects, where that thing came from, which selector reached
5//! it and why, how confident the compiler is, how much of the budget it costs — and never
6//! the prose. The prose belongs to the object, which `objects.get` serves whole; copying it
7//! here would put the layer in the answer twice and would flatten the structure into text
8//! before anything has decided what to keep.
9//!
10//! ```
11//! use majordomus_cli::devcontext::{Selector, Tier};
12//!
13//! // the tiers are a total order, and the order is the budget's order
14//! assert!(Tier::Task < Tier::Governance);
15//! assert!(Tier::Governance < Tier::History);
16//!
17//! // a selector says whether what it produced was declared or inferred
18//! assert!(Selector::Relation.declared());
19//! assert!(!Selector::IntentMatch.declared());
20//! ```
21
22use std::collections::BTreeMap;
23
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26
27/// Where an entry sits in the order the budget spends in.
28///
29/// The order is this repository's own: `lib/context.sh` assembles its sections "in
30/// authority order, highest first, because a worker that runs out of budget must lose the
31/// least reliable evidence rather than the most: git, then the task and its policy, then
32/// blockers, then authored records, then event history." These six tiers are that
33/// sentence, applied to objects instead of to sections.
34///
35/// The derived `Ord` is load-bearing: the variants are declared in authority order, so
36/// `<` means "spent before" and "harder to drop", and the budget's ordering is a plain
37/// `sort` rather than a table mapping tiers to ranks. A variant added in the wrong place
38/// would silently re-rank the whole answer, which is why the declaration order is the one
39/// thing about this enum to be careful with.
40///
41/// ```
42/// use majordomus_cli::devcontext::Tier;
43///
44/// // the declaration order *is* the order the budget spends in
45/// let mut sorted = Tier::ORDER.to_vec();
46/// sorted.sort();
47/// assert_eq!(sorted, Tier::ORDER.to_vec());
48///
49/// // so the strongest authority is the smallest, and it is the one never dropped
50/// assert_eq!(Tier::ORDER.iter().copied().min(), Some(Tier::Task));
51/// assert!(Tier::Task < Tier::Governance && Tier::Governance < Tier::History);
52///
53/// // and the serialised word is the lowercase one the report is read under
54/// assert_eq!(
55///     serde_json::to_value(Tier::Governance).unwrap(),
56///     serde_json::json!(Tier::Governance.as_str()),
57/// );
58/// ```
59#[derive(
60    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
61)]
62#[serde(rename_all = "lowercase")]
63pub enum Tier {
64    /// What was asked for: the issue, its milestone, the paths named, the intent. Never
65    /// dropped — a context that lost the thing it was compiled about is not a smaller
66    /// answer, it is the wrong one.
67    Task,
68    /// What the work will be judged against: the policy, the scope, the directory
69    /// contracts that reach the named paths, and the rules. A blocking rule is never
70    /// dropped.
71    Governance,
72    /// The code and the cases the work touches.
73    Source,
74    /// The architectural decisions already in force over it.
75    Decision,
76    /// What a previous session left: handovers, session records, curated knowledge.
77    Knowledge,
78    /// Supporting evidence: use cases, claims, features, and the rest of the layer.
79    History,
80}
81
82impl Tier {
83    /// Every tier, in the order the budget spends in.
84    ///
85    /// ```
86    /// use majordomus_cli::devcontext::Tier;
87    /// assert_eq!(Tier::ORDER.len(), 6);
88    /// assert_eq!(Tier::ORDER[0], Tier::Task);
89    /// ```
90    pub const ORDER: &'static [Tier] = &[
91        Tier::Task,
92        Tier::Governance,
93        Tier::Source,
94        Tier::Decision,
95        Tier::Knowledge,
96        Tier::History,
97    ];
98
99    /// The word this tier is reported under.
100    ///
101    /// ```
102    /// use majordomus_cli::devcontext::Tier;
103    /// assert_eq!(Tier::Governance.as_str(), "governance");
104    /// ```
105    pub fn as_str(self) -> &'static str {
106        match self {
107            Tier::Task => "task",
108            Tier::Governance => "governance",
109            Tier::Source => "source",
110            Tier::Decision => "decision",
111            Tier::Knowledge => "knowledge",
112            Tier::History => "history",
113        }
114    }
115
116    /// What this tier is for, in one line, so that a reader of the answer never has to
117    /// find this file.
118    ///
119    /// This is carried into the answer — [`TierSpend::meaning`](super::TierSpend) is this
120    /// string — rather than left for a consumer to look up, because the per-tier spend is
121    /// read by people who are deciding what to raise the budget for and "knowledge: 4
122    /// excluded" is not actionable without knowing what the tier holds.
123    ///
124    /// ```
125    /// use majordomus_cli::devcontext::Tier;
126    ///
127    /// // it explains the tier rather than naming it, so it is never just the word again
128    /// for t in Tier::ORDER {
129    ///     assert!(t.meaning().len() > t.as_str().len(), "{} restates itself", t.as_str());
130    /// }
131    ///
132    /// // and it says what is actually in there
133    /// assert!(Tier::Governance.meaning().contains("policy"));
134    /// assert!(Tier::Decision.meaning().contains("architectural decisions"));
135    /// ```
136    pub fn meaning(self) -> &'static str {
137        match self {
138            Tier::Task => "what was asked for: the issue, its milestone, the paths named, the intent",
139            Tier::Governance => "what the work is judged against: the policy, the scope, the directory contracts, the rules",
140            Tier::Source => "the code and the cases the work touches",
141            Tier::Decision => "the architectural decisions already in force over it",
142            Tier::Knowledge => "what a previous session left: handovers, session records, curated knowledge",
143            Tier::History => "supporting evidence: use cases, claims, features, and the rest of the layer",
144        }
145    }
146}
147
148/// What reached an entry. This is the provenance of the *selection*, and it is a different
149/// fact from the provenance of the file: `Relation` says the compiler followed an edge the
150/// layer declares, `IntentMatch` says it guessed from words. An answer that did not keep
151/// them apart could not say which half of itself to trust.
152///
153/// The division that matters is [`Selector::declared`], and it is deliberately lopsided:
154/// six of the seven selectors follow something the repository states, and exactly one
155/// guesses. That ratio is the design. Inference is confined to a single selector so that
156/// the inferred part of any answer can be found, weighed and ignored, rather than being
157/// mixed through the rest at unmarked strength.
158///
159/// ```
160/// use majordomus_cli::devcontext::Selector;
161///
162/// // exactly one selector infers, and the answer can always be split on it
163/// let inferred: Vec<&str> = Selector::ALL
164///     .iter()
165///     .filter(|s| !s.declared())
166///     .map(|s| s.as_str())
167///     .collect();
168/// assert_eq!(inferred, ["intent_match"]);
169///
170/// // every selector has its own word, so a reason is never ambiguous
171/// let mut words: Vec<&str> = Selector::ALL.iter().map(|s| s.as_str()).collect();
172/// let before = words.len();
173/// words.sort();
174/// words.dedup();
175/// assert_eq!(words.len(), before);
176///
177/// // and the word is the serialised form
178/// assert_eq!(
179///     serde_json::to_value(Selector::ScopePath).unwrap(),
180///     serde_json::json!("scope_path"),
181/// );
182/// ```
183#[derive(
184    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
185)]
186#[serde(rename_all = "snake_case")]
187pub enum Selector {
188    /// Named in the request.
189    Seed,
190    /// Reached along a typed edge of the composed graph.
191    Relation,
192    /// Its path lies under a path the request or a seed declares.
193    ScopePath,
194    /// A directory contract that reaches one of those paths.
195    Contract,
196    /// A rule or policy the layer applies to everything.
197    Governance,
198    /// A recorded session of this branch or of these paths, the handover the resolver
199    /// chose, the newest checkpoint.
200    SessionState,
201    /// Inferred: the words of the intent occur in the object's own words.
202    IntentMatch,
203}
204
205impl Selector {
206    /// The word this selector is reported under.
207    ///
208    /// ```
209    /// use majordomus_cli::devcontext::Selector;
210    /// assert_eq!(Selector::ScopePath.as_str(), "scope_path");
211    /// ```
212    pub fn as_str(self) -> &'static str {
213        match self {
214            Selector::Seed => "seed",
215            Selector::Relation => "relation",
216            Selector::ScopePath => "scope_path",
217            Selector::Contract => "contract",
218            Selector::Governance => "governance",
219            Selector::SessionState => "session_state",
220            Selector::IntentMatch => "intent_match",
221        }
222    }
223
224    /// Did the repository state this, or did the compiler infer it? A declared selector
225    /// carries confidence 1.0; an inferred one carries less, and says how much.
226    ///
227    /// ```
228    /// use majordomus_cli::devcontext::Selector;
229    /// assert!(Selector::Contract.declared());
230    /// assert!(!Selector::IntentMatch.declared());
231    /// ```
232    pub fn declared(self) -> bool {
233        !matches!(self, Selector::IntentMatch)
234    }
235
236    /// Every selector, in a stable order.
237    pub const ALL: &'static [Selector] = &[
238        Selector::Seed,
239        Selector::Relation,
240        Selector::ScopePath,
241        Selector::Contract,
242        Selector::Governance,
243        Selector::SessionState,
244        Selector::IntentMatch,
245    ];
246}
247
248/// One way an entry was reached: which selector, why, how far from a seed, and how much
249/// the compiler trusts it. An entry keeps every one of these, because "five discovery
250/// paths found the same ADR" is the interesting fact, not a thing to hide.
251///
252/// A `Discovery` is a *kind* of way in, not one traversal. Thirty-three issues of one
253/// milestone all reach that milestone along `belongs_to`, and recording that thirty-three
254/// times would say the answer had thirty-three reasons when it has one — so a path already
255/// recorded for the same selector, edge and reason is kept rather than repeated.
256///
257/// `via` and `edge` are present exactly when they mean something: a seed was not reached
258/// from anything and followed no edge, so both are absent and are skipped on the wire
259/// rather than serialised as null. `confidence` is 1.0 for every declared selector and less
260/// only for [`Selector::IntentMatch`]; `weight` is what this particular path contributes to
261/// relevance, which is a different quantity — how much it counts, against how sure it is.
262///
263/// ```
264/// use majordomus_cli::devcontext::{Discovery, Selector};
265/// use serde_json::json;
266///
267/// // the request named it: nothing to come via, no edge followed, depth zero
268/// let seed: Discovery = serde_json::from_value(json!({
269///     "selector": "seed",
270///     "reason": "named in the request",
271///     "depth": 0,
272///     "confidence": 1.0,
273///     "weight": 1.0,
274/// })).unwrap();
275/// assert_eq!(seed.selector, Selector::Seed);
276/// assert!(seed.via.is_none() && seed.edge.is_none());
277/// assert!(seed.selector.declared() && seed.confidence == 1.0);
278///
279/// // the wire form omits them rather than carrying nulls a reader must skip
280/// let wire = serde_json::to_value(&seed).unwrap();
281/// assert!(wire.get("via").is_none() && wire.get("edge").is_none());
282///
283/// // a relation says both where it came from and which edge it followed
284/// let walked: Discovery = serde_json::from_value(json!({
285///     "selector": "relation",
286///     "reason": "the decision in force over this scope",
287///     "via": "majordomus://issue/I0301",
288///     "edge": "governed_by",
289///     "depth": 1,
290///     "confidence": 1.0,
291///     "weight": 0.9,
292/// })).unwrap();
293/// assert_eq!(walked.edge.as_deref(), Some("governed_by"));
294/// assert_eq!(walked.via.as_deref(), Some("majordomus://issue/I0301"));
295///
296/// // and a guess says so, in the one field that distinguishes the two halves of an answer
297/// let guessed: Discovery = serde_json::from_value(json!({
298///     "selector": "intent_match",
299///     "reason": "the intent's words occur in it",
300///     "depth": 2,
301///     "confidence": 0.4,
302///     "weight": 0.4,
303/// })).unwrap();
304/// assert!(!guessed.selector.declared() && guessed.confidence < 1.0);
305/// ```
306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
307pub struct Discovery {
308    /// What reached it.
309    pub selector: Selector,
310    /// Why it was included, in words a person reads without this file.
311    pub reason: String,
312    /// The entry this one was reached from, when it was reached from one.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub via: Option<String>,
315    /// The edge kind followed, for `Relation`.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub edge: Option<String>,
318    /// Hops from a seed; a seed is 0.
319    pub depth: usize,
320    /// 1.0 when the repository declared this path, less when it was inferred.
321    pub confidence: f64,
322    /// What this path contributes to the entry's relevance.
323    pub weight: f64,
324}
325
326/// Where the selected thing lives, as the index knows it. Nothing here is authored: every
327/// field is the index's own [`crate::model::Provenance`], carried rather than restated.
328///
329/// It is a copy of the index's provenance and not a re-derivation of it, which is the
330/// property worth relying on: no path here was constructed by this module, so a path in an
331/// answer is a path the index discovered, and the two cannot disagree about where something
332/// lives.
333///
334/// `bytes` is the measured half of an entry's cost — the estimate on top of it is
335/// [`tokens_for`] — so this is also where the budget's arithmetic starts.
336///
337/// ```
338/// use majordomus_cli::devcontext::{tokens_for, EntryProvenance};
339/// use serde_json::json;
340///
341/// let p: EntryProvenance = serde_json::from_value(json!({
342///     "path": ".ai/repo/adrs/0052-the-episode-is-the-boundary.md",
343///     "directory": ".ai/repo/adrs",
344///     "source_class": "adr",
345///     "section": "adrs",
346///     "bytes": 8_192,
347/// })).unwrap();
348///
349/// // the size is what the cost is computed from, and the estimate says so of itself
350/// assert_eq!(tokens_for(p.bytes), 2_048);
351///
352/// // a file under no manifest section — a root README, say — carries none rather than ""
353/// let rootish: EntryProvenance = serde_json::from_value(json!({
354///     "path": "README.md",
355///     "directory": ".",
356///     "source_class": "readme",
357///     "bytes": 4_000,
358/// })).unwrap();
359/// assert!(rootish.section.is_none());
360/// assert_eq!(rootish.directory, ".", "the root is `.`, not the empty string");
361/// ```
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
363pub struct EntryProvenance {
364    /// Repository-relative path, forward slashes.
365    pub path: String,
366    /// The directory it sits in.
367    pub directory: String,
368    /// The `sources.yaml` class that discovered it.
369    pub source_class: String,
370    /// The manifest section it falls under, when it falls under one.
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub section: Option<String>,
373    /// Size of the file in bytes.
374    pub bytes: u64,
375}
376
377/// One thing the compiler decided a session should be given: what it is, how it was
378/// reached, what it costs, and how far the compiler trusts it.
379///
380/// Notably, it does **not** carry the prose. The identifier is here and the body is not,
381/// because the body belongs to the object and `objects.get` serves it whole; copying it
382/// would put the layer in the answer twice and flatten the structure into text before
383/// anything had decided what to keep. An entry is a decision about an object, not a copy of
384/// one.
385///
386/// Three of the scalars are derived from `discovered_by` rather than independent of it, and
387/// a reader can check them: `confidence` is the highest of any path's, `depth` the shortest,
388/// and a `discovered_by` of length above one is the evidence that deduplication happened.
389/// `required` is the veto — a blocking rule, the policy, the scope, or a seed — and it is
390/// the one field that overrides the budget.
391///
392/// ```
393/// use majordomus_cli::devcontext::{tokens_for, ContextEntry, Selector, Tier};
394/// use serde_json::json;
395///
396/// // one ADR, reached twice: once along a declared edge and once by matching the intent
397/// let e: ContextEntry = serde_json::from_value(json!({
398///     "uri": "majordomus://adr/0052",
399///     "kind": "adr",
400///     "title": "The episode is the boundary",
401///     "provenance": {
402///         "path": ".ai/repo/adrs/0052.md", "directory": ".ai/repo/adrs",
403///         "source_class": "adr", "section": "adrs", "bytes": 8_192,
404///     },
405///     "tier": "decision",
406///     "relevance": 0.82,
407///     "confidence": 1.0,
408///     "discovered_by": [
409///         {"selector": "relation", "reason": "in force over this scope",
410///          "via": "majordomus://issue/I0301", "edge": "governed_by",
411///          "depth": 1, "confidence": 1.0, "weight": 0.9},
412///         {"selector": "intent_match", "reason": "the intent's words occur in it",
413///          "depth": 2, "confidence": 0.4, "weight": 0.4},
414///     ],
415///     "depth": 1,
416///     "cost_tokens": 2_048,
417///     "cost_bytes": 8_192,
418///     "version": "2026-09-11",
419///     "status": "accepted",
420///     "required": false,
421/// })).unwrap();
422///
423/// assert_eq!(e.tier, Tier::Decision);
424///
425/// // the cost is the estimate over the measured bytes, and the answer carries both halves
426/// assert_eq!(e.cost_tokens, tokens_for(e.cost_bytes));
427///
428/// // the derived scalars agree with the paths they are derived from
429/// let best = e.discovered_by.iter().map(|d| d.confidence).fold(0.0_f64, f64::max);
430/// assert_eq!(e.confidence, best);
431/// assert_eq!(e.depth, e.discovered_by.iter().map(|d| d.depth).min().unwrap());
432///
433/// // two paths for one entry: the record that four things pointing here is one entry
434/// assert_eq!(e.discovered_by.len(), 2);
435/// assert_eq!(e.discovered_by[0].selector, Selector::Relation, "best path first");
436///
437/// // and the prose is not here: the identifier is how a caller gets the body
438/// assert!(e.uri.starts_with("majordomus://"));
439/// ```
440#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
441pub struct ContextEntry {
442    /// The canonical identifier: `majordomus://<kind>/<identity>`, unique in the answer.
443    pub uri: String,
444    /// The kind the layer declared for it.
445    pub kind: String,
446    /// Its title, when it has one.
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub title: Option<String>,
449    /// Where it lives.
450    pub provenance: EntryProvenance,
451    /// Which tier's budget it is spent from.
452    pub tier: Tier,
453    /// 0.0 to 1.0, deterministic for a given tree and request. The order within a tier.
454    pub relevance: f64,
455    /// The highest confidence of any path that reached it.
456    pub confidence: f64,
457    /// Every path that reached it, best first. Length above one is the evidence that
458    /// deduplication happened.
459    pub discovered_by: Vec<Discovery>,
460    /// The shortest number of hops from a seed.
461    pub depth: usize,
462    /// Estimated tokens: the file's bytes divided by [`BYTES_PER_TOKEN`], never fewer
463    /// than one. An estimate, said to be one.
464    pub cost_tokens: u64,
465    /// The file's bytes, the measured half of the cost.
466    pub cost_bytes: u64,
467    /// The version or date the object states about itself, when it states one: a rule's
468    /// version, an ADR's date, an issue's `updated_at`.
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub version: Option<String>,
471    /// The status word the object declares, when it declares one.
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub status: Option<String>,
474    /// True when a blocking rule, a policy or the scope: never dropped for budget.
475    pub required: bool,
476    /// Facts worth carrying about this entry that are not the object's prose — a rule's
477    /// class, an issue's milestone, the commits an issue's evidence names.
478    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
479    pub facts: BTreeMap<String, String>,
480}
481
482/// Why something the compiler selected did not survive.
483///
484/// The six divide into three that a caller can act on by asking again — `Budget`,
485/// `Relevance` and `Depth` are all consequences of the request's own limits, so raising the
486/// limit brings the entry back — and three that are facts about the repository:
487/// `Stale` and `Superseded` mean the layer itself says this is not the current thing, and
488/// `Unresolved` means the layer names something it does not hold, which is a dangling
489/// reference somebody should fix.
490///
491/// ```
492/// use majordomus_cli::devcontext::ExclusionReason as Why;
493///
494/// // the ones a different request would change, against the ones it would not
495/// let request_bound = [Why::Budget, Why::Relevance, Why::Depth];
496/// let repository_bound = [Why::Stale, Why::Superseded, Why::Unresolved];
497/// for r in repository_bound {
498///     assert!(!request_bound.contains(&r), "{} is not the caller's to fix", r.as_str());
499/// }
500///
501/// // and the word is the serialised form, snake_case for the two-word one
502/// assert_eq!(Why::Superseded.as_str(), "superseded");
503/// assert_eq!(
504///     serde_json::to_value(Why::Unresolved).unwrap(),
505///     serde_json::json!(Why::Unresolved.as_str()),
506/// );
507/// ```
508#[derive(
509    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
510)]
511#[serde(rename_all = "snake_case")]
512pub enum ExclusionReason {
513    /// The budget ran out before its tier was filled.
514    Budget,
515    /// Its relevance fell below the floor the request set.
516    Relevance,
517    /// It is beyond the depth the request allows.
518    Depth,
519    /// The object says it is deprecated or superseded.
520    Stale,
521    /// A newer version of the same thing is in the answer.
522    Superseded,
523    /// The layer names it and holds nothing under that name.
524    Unresolved,
525}
526
527impl ExclusionReason {
528    /// The word this reason is reported under.
529    ///
530    /// ```
531    /// use majordomus_cli::devcontext::ExclusionReason;
532    /// assert_eq!(ExclusionReason::Budget.as_str(), "budget");
533    /// ```
534    pub fn as_str(self) -> &'static str {
535        match self {
536            ExclusionReason::Budget => "budget",
537            ExclusionReason::Relevance => "relevance",
538            ExclusionReason::Depth => "depth",
539            ExclusionReason::Stale => "stale",
540            ExclusionReason::Superseded => "superseded",
541            ExclusionReason::Unresolved => "unresolved",
542        }
543    }
544}
545
546/// One thing the compiler reached and did not give. Excluded is not the same as absent:
547/// an under-filled context is debugged from this list, not by guessing.
548///
549/// `cost_tokens` is why this type exists in the shape it does. A budget that reported only
550/// *that* it dropped things would leave a caller guessing at a new ceiling; one that reports
551/// what each omission would have cost turns "raise the budget" into arithmetic. `detail`
552/// carries the same in words, so the row is legible without the reader doing that
553/// arithmetic themselves.
554///
555/// The ordering puts the tier first as an explicit rank, because the tier is the precedence
556/// the budget spent in; inside a tier a reader is looking for a reason, and the identifier
557/// ends the key so the list is total and the same tree always prints it the same way.
558///
559/// ```
560/// use majordomus_cli::devcontext::{Excluded, ExclusionReason, Tier};
561/// use majordomus_cli::order::canonical;
562/// use serde_json::json;
563///
564/// let row = |uri: &str, tier: &str, reason: &str, cost: u64| -> Excluded {
565///     serde_json::from_value(json!({
566///         "uri": uri, "tier": tier, "reason": reason,
567///         "detail": "as measured", "cost_tokens": cost, "relevance": 0.4,
568///     })).unwrap()
569/// };
570///
571/// let mut excluded = vec![
572///     row("majordomus://knowledge/b", "history", "budget", 900),
573///     row("majordomus://adr/0001", "decision", "superseded", 300),
574///     row("majordomus://knowledge/a", "history", "budget", 1_200),
575/// ];
576/// canonical(&mut excluded);
577///
578/// // the tier ranks first, so the decision tier precedes history whatever the reason says
579/// assert_eq!(excluded[0].tier, Tier::Decision);
580/// assert_eq!(excluded[0].reason, ExclusionReason::Superseded);
581/// // then the reason, then the identifier — which makes the order total
582/// let rest: Vec<&str> = excluded[1..].iter().map(|e| e.uri.as_str()).collect();
583/// assert_eq!(rest, ["majordomus://knowledge/a", "majordomus://knowledge/b"]);
584///
585/// // and the row a caller acts on says what it would have cost to keep
586/// let biggest = excluded.iter().max_by_key(|e| e.cost_tokens).unwrap();
587/// assert_eq!(biggest.cost_tokens, 1_200);
588/// ```
589#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
590pub struct Excluded {
591    /// The canonical identifier, or the name the layer used when nothing resolves.
592    pub uri: String,
593    /// Its kind, when it resolved to something.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub kind: Option<String>,
596    /// The tier it would have been spent from.
597    pub tier: Tier,
598    /// Why it is not in the answer.
599    pub reason: ExclusionReason,
600    /// The same, in words.
601    pub detail: String,
602    /// What it would have cost, so that a reader can decide whether to raise the budget.
603    pub cost_tokens: u64,
604    /// What its relevance was.
605    pub relevance: f64,
606}
607
608/// The tier is the precedence the budget spends in, so it is the explicit rank; inside a
609/// tier a reader looks for a reason, and the identifier ends the key so the list is total.
610impl crate::order::Ordered for Excluded {
611    fn order_key(&self) -> crate::order::OrderKey<'_> {
612        crate::order::OrderKey::plain(self.reason.as_str(), &self.uri).ranked(self.tier as i64)
613    }
614}
615
616/// Two names, one thing. The compiler keeps one entry and records the collapse here, so
617/// that "why is this ADR listed once when four things point at it" has an answer that is
618/// state rather than a log line.
619///
620/// One type covers two different collapses, and `dropped` is how a reader tells them apart.
621/// **Structural** collapse is the same identifier reached several ways: nothing was
622/// discarded, so `dropped` is empty and `paths` is the count that makes the row worth
623/// printing — this is the record that four things pointing at one ADR produced one entry.
624/// **Semantic** collapse is two different identifiers standing for one thing, one file
625/// indexed under two kinds or one rule at two versions: there `dropped` names what lost,
626/// because something really was discarded and a reader is entitled to know what.
627///
628/// ```
629/// use majordomus_cli::devcontext::Deduplicated;
630/// use serde_json::json;
631///
632/// // structural: one identifier, four ways in, nothing discarded
633/// let structural: Deduplicated = serde_json::from_value(json!({
634///     "kept": "majordomus://adr/0052",
635///     "dropped": [],
636///     "key": "uri",
637///     "detail": "one object reached by 4 discovery path(s)",
638///     "paths": 4,
639/// })).unwrap();
640/// assert!(structural.dropped.is_empty(), "nothing lost; this is a record, not a decision");
641/// assert!(structural.paths > 1, "which is what makes the row worth printing");
642/// assert_eq!(structural.key, "uri");
643///
644/// // semantic: two identifiers for one rule, and the older version lost
645/// let semantic: Deduplicated = serde_json::from_value(json!({
646///     "kept": "majordomus://rule/project.x@2",
647///     "dropped": ["majordomus://rule/project.x@1"],
648///     "key": "identity",
649///     "detail": "one rule at two versions; the newer is in the answer",
650///     "paths": 1,
651/// })).unwrap();
652/// assert_eq!(semantic.dropped, ["majordomus://rule/project.x@1"]);
653/// assert_ne!(semantic.kept, semantic.dropped[0], "two names, and one of them lost");
654/// ```
655#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
656pub struct Deduplicated {
657    /// The entry that survived.
658    pub kept: String,
659    /// What was folded into it, or dropped for it.
660    pub dropped: Vec<String>,
661    /// What made them the same thing: `uri`, `path` or `identity`.
662    pub key: String,
663    /// The same, in words.
664    pub detail: String,
665    /// How many discovery paths reached the surviving entry.
666    pub paths: usize,
667}
668
669/// Filed under what made the two the same thing, then by the entry that survived — which
670/// is unique, because one entry is kept once under one key.
671impl crate::order::Ordered for Deduplicated {
672    fn order_key(&self) -> crate::order::OrderKey<'_> {
673        crate::order::OrderKey::grouped(&self.key, &self.kept, &self.kept)
674    }
675}
676
677/// Two things in the answer that do not agree, named rather than silently ordered.
678///
679/// The compiler could have resolved every disagreement by keeping one side, and for
680/// deduplication it does. A conflict is what is left when both sides are legitimately in
681/// the answer and they still do not agree — so the resolution belongs to the session
682/// reading it, and the compiler's job is to make sure the disagreement is visible rather
683/// than implied by the order of two rows.
684///
685/// `current` is the side the compiler treats as current, which is a statement about
686/// recency, not correctness: `supersedes` means the layer declared one to replace the other,
687/// `version` means two versions of one thing are both here.
688///
689/// ```
690/// use majordomus_cli::devcontext::Conflict;
691/// use majordomus_cli::order::canonical;
692/// use serde_json::json;
693///
694/// let row = |current: &str, against: &str, kind: &str| -> Conflict {
695///     serde_json::from_value(json!({
696///         "current": current, "against": against, "kind": kind,
697///         "detail": "both are in the answer and they disagree",
698///     })).unwrap()
699/// };
700///
701/// let mut conflicts = vec![
702///     row("majordomus://rule/project.x@2", "majordomus://rule/project.x@1", "version"),
703///     row("majordomus://adr/0052", "majordomus://adr/0031", "supersedes"),
704/// ];
705/// canonical(&mut conflicts);
706///
707/// // filed under the kind of disagreement, so like reads with like
708/// let kinds: Vec<&str> = conflicts.iter().map(|c| c.kind.as_str()).collect();
709/// assert_eq!(kinds, ["supersedes", "version"]);
710///
711/// // and both sides are always named: the pair is the fact, not the winner alone
712/// for c in &conflicts {
713///     assert_ne!(c.current, c.against);
714///     assert!(!c.detail.is_empty());
715/// }
716/// ```
717#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
718pub struct Conflict {
719    /// The entry the compiler treats as current.
720    pub current: String,
721    /// The entry it stands against.
722    pub against: String,
723    /// What kind of disagreement: `supersedes` or `version`.
724    pub kind: String,
725    /// The same, in words.
726    pub detail: String,
727}
728
729/// Filed under the kind of disagreement, then the current entry, and ended by the entry it
730/// stands against — the pair is what makes one conflict distinct from another under a kind.
731impl crate::order::Ordered for Conflict {
732    fn order_key(&self) -> crate::order::OrderKey<'_> {
733        crate::order::OrderKey::grouped(&self.kind, &self.current, &self.against)
734    }
735}
736
737/// What one tier cost, how much of it survived, and how much of that could not have been
738/// dropped.
739///
740/// There is no per-tier ceiling, which is worth saying because the name suggests one: the
741/// budget is a single pot spent in tier order, and this is the *report* of what each tier
742/// took from it, not an allowance it was given. A tier with many exclusions is therefore
743/// not a tier that hit its own limit — it is a tier that was reached after the pot had been
744/// spent by the tiers above it.
745///
746/// `meaning` is carried so that a reader deciding what to raise the budget for does not
747/// have to look up what `knowledge` holds; `required` is the part of `selected` the budget
748/// had no say over.
749///
750/// ```
751/// use majordomus_cli::devcontext::{Tier, TierSpend};
752/// use serde_json::json;
753///
754/// let t: TierSpend = serde_json::from_value(json!({
755///     "tier": "governance",
756///     "meaning": Tier::Governance.meaning(),
757///     "selected": 9,
758///     "excluded": 2,
759///     "tokens": 6_400,
760///     "required": 3,
761/// })).unwrap();
762///
763/// assert_eq!(t.tier, Tier::Governance);
764/// // the undroppable part is a part of what survived, never a separate count
765/// assert!(t.required <= t.selected);
766/// // and the row explains its own tier, so "2 excluded" is actionable
767/// assert_eq!(t.meaning, Tier::Governance.meaning());
768/// assert!(t.meaning.contains("policy"));
769/// ```
770#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
771pub struct TierSpend {
772    /// The tier.
773    pub tier: Tier,
774    /// What it is for.
775    pub meaning: String,
776    /// How many entries survived in it.
777    pub selected: usize,
778    /// How many were dropped from it for budget.
779    pub excluded: usize,
780    /// Tokens the survivors cost.
781    pub tokens: u64,
782    /// Entries in it that cannot be dropped.
783    pub required: usize,
784}
785
786/// The budget, and what became of it.
787///
788/// `over_budget` is the field to read and the one most likely to be misread. It does not
789/// mean the compiler overspent by accident: every entry the budget has a say over is refused
790/// the moment it would not fit, so the only way past the ceiling is through the entries that
791/// may not be dropped — the seeds, the policy, the scope, a blocking rule. So
792/// `over_budget` means *the things this context cannot do without already cost more than you
793/// allowed*, and `required_tokens` is the explanation of it. The honest answer to that is to
794/// say so, not to return a context missing the thing it was compiled about.
795///
796/// `remaining_tokens` is floored at zero rather than going negative, so it is safe to
797/// display; the overspend is read from `used_tokens` against `limit_tokens`, which is why
798/// both are carried.
799///
800/// ```
801/// use majordomus_cli::devcontext::{Budget, Tier, BYTES_PER_TOKEN};
802/// use serde_json::json;
803///
804/// let tier = |name: &str, tokens: u64, required: usize| json!({
805///     "tier": name, "meaning": "as this tier is for", "selected": 1,
806///     "excluded": 0, "tokens": tokens, "required": required,
807/// });
808///
809/// // the undroppable entries alone cost more than the caller allowed
810/// let b: Budget = serde_json::from_value(json!({
811///     "limit_tokens": 1_000,
812///     "used_tokens": 2_500,
813///     "required_tokens": 2_500,
814///     "remaining_tokens": 0,
815///     "over_budget": true,
816///     "bytes_per_token": BYTES_PER_TOKEN,
817///     "tiers": [tier("task", 2_000, 1), tier("governance", 500, 1)],
818/// })).unwrap();
819///
820/// assert!(b.over_budget);
821/// assert!(b.used_tokens > b.limit_tokens, "the overspend is read from these two");
822/// assert_eq!(b.required_tokens, b.used_tokens, "all of it was undroppable");
823/// assert_eq!(b.remaining_tokens, 0, "floored, so it is safe to display");
824///
825/// // the divisor is carried so a caller measuring tokens differently can convert
826/// // rather than guess what the estimate meant
827/// assert_eq!(b.bytes_per_token, BYTES_PER_TOKEN);
828///
829/// // and the tiers are reported in the order the budget spends in
830/// let order: Vec<Tier> = b.tiers.iter().map(|t| t.tier).collect();
831/// assert_eq!(order, [Tier::Task, Tier::Governance]);
832/// assert!(order.windows(2).all(|w| w[0] < w[1]));
833/// ```
834#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
835pub struct Budget {
836    /// The ceiling this answer was compiled under, in estimated tokens.
837    pub limit_tokens: u64,
838    /// What the survivors cost.
839    pub used_tokens: u64,
840    /// What the entries that cannot be dropped cost. Above the limit, the answer is
841    /// over-budget and says so rather than dropping what it must keep.
842    pub required_tokens: u64,
843    /// `limit - used`, floored at zero.
844    pub remaining_tokens: u64,
845    /// True when the required entries alone exceed the limit.
846    pub over_budget: bool,
847    /// Bytes per token, the divisor the estimate uses.
848    pub bytes_per_token: u64,
849    /// Per tier, in the order the budget spends in.
850    pub tiers: Vec<TierSpend>,
851}
852
853/// The head this answer was compiled against. A compiled context is only true of one tree,
854/// and these three fields plus the index fingerprint are what say which.
855///
856/// `branch` and `head` are optional because a checkout can legitimately have neither — a
857/// detached head, or a tree that is not a git repository at all — and the absence is carried
858/// rather than faked. `working_tree` is not optional, because there is always something to
859/// say: when git cannot be reached it carries `unavailable: <reason>` instead of a status,
860/// so a reader is never left unable to tell "clean" from "not asked".
861///
862/// `commits` is derived from the selected entries' own records and never from a log walk.
863/// That is the difference between "the commits this work says it produced" and "what
864/// happened to be committed recently", and only the first is context.
865///
866/// ```
867/// use majordomus_cli::devcontext::GitContext;
868/// use serde_json::json;
869///
870/// // a tree the compiler could read
871/// let known: GitContext = serde_json::from_value(json!({
872///     "branch": "master",
873///     "head": "cd04012f1b1dc21fd493e2be14938ffdfaccc8fa",
874///     "working_tree": "clean",
875/// })).unwrap();
876/// assert_eq!(known.branch.as_deref(), Some("master"));
877/// assert!(known.commits.is_empty(), "no selected record named a commit");
878///
879/// // and one it could not: the status field still says something a reader can act on
880/// let unknown: GitContext = serde_json::from_value(json!({
881///     "working_tree": "unavailable: not a git repository",
882/// })).unwrap();
883/// assert!(unknown.head.is_none() && unknown.branch.is_none());
884/// assert!(
885///     unknown.working_tree.starts_with("unavailable: "),
886///     "never silently indistinguishable from clean",
887/// );
888/// ```
889#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
890pub struct GitContext {
891    /// The branch, when the checkout is on one.
892    #[serde(default, skip_serializing_if = "Option::is_none")]
893    pub branch: Option<String>,
894    /// The head commit.
895    #[serde(default, skip_serializing_if = "Option::is_none")]
896    pub head: Option<String>,
897    /// `clean`, `dirty`, or what git said.
898    pub working_tree: String,
899    /// Commits the selected entries name as their own evidence, newest record first.
900    /// Derived from the layer's records, never from a log walk.
901    #[serde(default, skip_serializing_if = "Vec::is_empty")]
902    pub commits: Vec<CommitReference>,
903}
904
905/// One commit the layer itself names, and the record that named it.
906///
907/// The pair is the fact, not the commit alone. One commit named by three records is three
908/// references, because each record is a separate claim about what that commit was for, and
909/// folding them would lose the attribution — which is the only reason to carry a commit here
910/// at all. So `named_by` ends the ordering key, and two references to one commit both
911/// survive.
912///
913/// ```
914/// use majordomus_cli::devcontext::CommitReference;
915/// use majordomus_cli::order::canonical;
916/// use serde_json::json;
917///
918/// let by = |commit: &str, record: &str| -> CommitReference {
919///     serde_json::from_value(json!({"commit": commit, "named_by": record})).unwrap()
920/// };
921///
922/// let mut commits = vec![
923///     by("cd04012", "majordomus://session/b"),
924///     by("cd04012", "majordomus://session/a"),
925///     by("5e31dd4", "majordomus://issue/I0301"),
926/// ];
927/// canonical(&mut commits);
928///
929/// // filed under the commit, which is what a reader looks for
930/// let order: Vec<&str> = commits.iter().map(|c| c.commit.as_str()).collect();
931/// assert_eq!(order, ["5e31dd4", "cd04012", "cd04012"]);
932///
933/// // and the record ends the key, so one commit named twice stays two facts
934/// assert_eq!(commits[1].named_by, "majordomus://session/a");
935/// assert_eq!(commits[2].named_by, "majordomus://session/b");
936///
937/// // `covers` is what the record said the commit was for, when it said
938/// let with_reason = by("cd04012", "majordomus://session/a");
939/// assert!(with_reason.covers.is_none(), "absent rather than an empty sentence");
940/// ```
941#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
942pub struct CommitReference {
943    /// The commit as the record wrote it.
944    pub commit: String,
945    /// The entry that named it.
946    pub named_by: String,
947    /// What the record says the commit was for.
948    #[serde(default, skip_serializing_if = "Option::is_none")]
949    pub covers: Option<String>,
950}
951
952/// The commit is what a reader looks for; the entry that named it ends the key, because one
953/// commit is named by several records and each of those is a separate fact.
954impl crate::order::Ordered for CommitReference {
955    fn order_key(&self) -> crate::order::OrderKey<'_> {
956        crate::order::OrderKey::plain(&self.commit, &self.named_by)
957    }
958}
959
960/// What the request asked to compile a context about.
961///
962/// A seed is the request echoed back after resolution, and it is in the answer so that a
963/// caller can see what their short name turned into. `from` names the request field it came
964/// from — `issue`, `milestone`, `uris`, `paths`, `intent` — which is how the same identifier
965/// arriving two ways stays two seeds rather than one.
966///
967/// `resolved` is populated only when resolution actually changed something: name a
968/// milestone by its slug and it carries the canonical identity, name it by that identity and
969/// there was nothing to resolve, so the field is absent. A path and an intent are not
970/// resolved against the index at all — their `uri` is the literal the request gave.
971///
972/// ```
973/// use majordomus_cli::devcontext::Seed;
974/// use serde_json::json;
975///
976/// // named by a slug: the answer says what it became
977/// let by_slug: Seed = serde_json::from_value(json!({
978///     "uri": "majordomus://milestone/M04",
979///     "kind": "milestone",
980///     "from": "milestone",
981///     "resolved": "M04",
982/// })).unwrap();
983/// assert_eq!(by_slug.resolved.as_deref(), Some("M04"));
984///
985/// // named canonically: nothing was resolved, so nothing is reported
986/// let canonical: Seed = serde_json::from_value(json!({
987///     "uri": "majordomus://issue/I0301",
988///     "kind": "issue",
989///     "from": "issue",
990/// })).unwrap();
991/// assert!(canonical.resolved.is_none());
992///
993/// // and a path is carried verbatim: its `uri` is not a canonical identifier at all
994/// let path: Seed = serde_json::from_value(json!({
995///     "uri": "apps/majordomus-cli/src",
996///     "kind": "path",
997///     "from": "paths",
998/// })).unwrap();
999/// assert!(!path.uri.starts_with("majordomus://"));
1000/// assert_eq!(path.from, "paths", "which request field it came from");
1001/// ```
1002#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1003pub struct Seed {
1004    /// The canonical identifier of the seed, or the literal the request gave for an
1005    /// intent or a path.
1006    pub uri: String,
1007    /// `issue`, `milestone`, `path`, `intent`, or the kind of the object named.
1008    pub kind: String,
1009    /// Where it came from: which field of the request.
1010    pub from: String,
1011    /// What the compiler resolved it to, when the request named something by a short name.
1012    #[serde(default, skip_serializing_if = "Option::is_none")]
1013    pub resolved: Option<String>,
1014}
1015
1016/// A compiled development context: what to give a session, what was left out, what
1017/// collapsed into what, and the budget it was all decided under.
1018///
1019/// This is the canonical form. Rendering it as a prompt is a downstream projection of this
1020/// value and is not performed here — the structure is what lets a caller re-budget, re-order
1021/// or explain the selection without recompiling it.
1022///
1023/// Two invariants hold over every answer, and they are what make it safe to cache and to
1024/// compare. `fingerprint` is over the index this was compiled from, so the same request
1025/// against the same fingerprint is the same answer and a different fingerprint is a
1026/// different tree — a caller can tell two trees apart without asking. And
1027/// `budget.used_tokens` is exactly the sum of `selected`'s costs, so the arithmetic in the
1028/// budget describes the list beside it rather than a walk that has since been filtered.
1029///
1030/// ```
1031/// use majordomus_cli::devcontext::{compile, CompileInput, CompiledContext};
1032/// use majordomus_cli::devcontext::{Tier, DEFAULT_BUDGET_TOKENS};
1033/// use majordomus_cli::synthetic::SyntheticRepository;
1034///
1035/// let repo = SyntheticRepository::small().unwrap();
1036/// let ctx = repo.context().unwrap();
1037///
1038/// let c: CompiledContext = compile(&ctx, CompileInput::default()).unwrap();
1039///
1040/// // the answer says which tree it is true of, and nothing else has to be asked
1041/// assert_eq!(c.schema, 1);
1042/// assert!(!c.fingerprint.is_empty(), "an answer that names no tree is not one");
1043///
1044/// // the budget describes the list beside it
1045/// let spent: u64 = c.selected.iter().map(|e| e.cost_tokens).sum();
1046/// assert_eq!(c.budget.used_tokens, spent);
1047/// assert_eq!(c.budget.limit_tokens, DEFAULT_BUDGET_TOKENS);
1048///
1049/// // every tier is reported, so an empty tier is visibly empty rather than missing
1050/// assert_eq!(c.budget.tiers.len(), Tier::ORDER.len());
1051///
1052/// // a request that named nothing has no seeds and is still an answer
1053/// assert!(c.seeds.is_empty());
1054///
1055/// // and recompiling the same request over the same tree gives the same answer
1056/// let again: CompiledContext = compile(&ctx, CompileInput::default()).unwrap();
1057/// assert_eq!(again.fingerprint, c.fingerprint);
1058/// assert_eq!(again.selected.len(), c.selected.len());
1059/// ```
1060#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1061pub struct CompiledContext {
1062    /// The format of this answer.
1063    pub schema: u64,
1064    /// The index this was compiled from, over every object's path and content. Two answers
1065    /// with the same fingerprint and the same request are the same answer; a different
1066    /// fingerprint is a different tree.
1067    pub fingerprint: String,
1068    /// The head the answer is true of.
1069    pub git: GitContext,
1070    /// What was asked for.
1071    pub seeds: Vec<Seed>,
1072    /// What to give the session, in the order the budget spent: tier, then relevance, then
1073    /// canonical identifier.
1074    pub selected: Vec<ContextEntry>,
1075    /// What was reached and not given, with the reason for each.
1076    pub excluded: Vec<Excluded>,
1077    /// What collapsed into what.
1078    pub deduplicated: Vec<Deduplicated>,
1079    /// What does not agree with what.
1080    pub conflicts: Vec<Conflict>,
1081    /// The budget and its per-tier spend.
1082    pub budget: Budget,
1083    /// What the compiler could not read: a knowledge source the layer names and does not
1084    /// hold, a session record that no longer describes this checkout. Never an error: an
1085    /// absent source is an answer.
1086    pub diagnostics: Vec<crate::model::Diagnostic>,
1087}
1088
1089/// Bytes of a file per estimated token. Four is the working figure for English prose and
1090/// for the YAML and Markdown this layer is made of; the answer says which divisor it used
1091/// so that a caller measuring differently can convert rather than guess.
1092pub const BYTES_PER_TOKEN: u64 = 4;
1093
1094/// The default ceiling, in estimated tokens.
1095///
1096/// It is a token budget, not the shell builder's line budget: an entry here is a whole
1097/// object, and objects differ in size by two orders of magnitude, so lines would ration
1098/// the wrong quantity. The figure is the same order as the shell builder's 300 lines once
1099/// bodies are counted, and the request overrides it.
1100pub const DEFAULT_BUDGET_TOKENS: u64 = 24_000;
1101
1102/// The furthest the compiler walks from a seed unless the request says otherwise.
1103pub const DEFAULT_MAX_DEPTH: usize = 3;
1104
1105/// Estimated tokens for a file of this many bytes: never fewer than one, so that an entry
1106/// always costs something.
1107///
1108/// ```
1109/// use majordomus_cli::devcontext::tokens_for;
1110/// assert_eq!(tokens_for(0), 1);
1111/// assert_eq!(tokens_for(4), 1);
1112/// assert_eq!(tokens_for(4001), 1001);
1113/// ```
1114pub fn tokens_for(bytes: u64) -> u64 {
1115    bytes.div_ceil(BYTES_PER_TOKEN).max(1)
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120    use super::*;
1121
1122    #[test]
1123    fn the_tiers_are_the_order_the_budget_spends_in() {
1124        // every tier appears exactly once, sorted, and each says what it is for
1125        let mut sorted = Tier::ORDER.to_vec();
1126        sorted.sort();
1127        assert_eq!(sorted, Tier::ORDER.to_vec());
1128        for t in Tier::ORDER {
1129            assert!(!t.meaning().is_empty(), "{} says nothing", t.as_str());
1130            assert!(!t.as_str().is_empty());
1131        }
1132    }
1133
1134    #[test]
1135    fn a_selector_says_whether_it_declared_or_guessed() {
1136        let inferred: Vec<&str> = Selector::ALL
1137            .iter()
1138            .filter(|s| !s.declared())
1139            .map(|s| s.as_str())
1140            .collect();
1141        assert_eq!(inferred, vec!["intent_match"]);
1142        // and every selector has a distinct word
1143        let mut words: Vec<&str> = Selector::ALL.iter().map(|s| s.as_str()).collect();
1144        words.sort();
1145        let before = words.len();
1146        words.dedup();
1147        assert_eq!(words.len(), before);
1148    }
1149
1150    #[test]
1151    fn the_token_estimate_never_costs_nothing() {
1152        assert_eq!(tokens_for(0), 1);
1153        assert_eq!(tokens_for(1), 1);
1154        assert_eq!(tokens_for(8), 2);
1155        assert!(tokens_for(u64::MAX) > 0);
1156    }
1157}