Skip to main content

majordomus_cli/devcontext/
budget.rs

1//! Deduplication, conflict, deterministic order, and what the budget could afford.
2//!
3//! The three happen in that order and none of them re-reads the repository. What arrives is
4//! the [`Selection`] — one candidate per canonical identifier,
5//! already carrying every path that reached it — and what leaves is the answer, with an
6//! entry for everything kept and a reason for everything not.
7//!
8//! # Why deduplication is two rules and not one
9//!
10//! Structural duplication is the same identifier reached several ways, and it is already
11//! folded by the time this module runs: the interesting output is the *record* that it
12//! happened, so that "why is this ADR listed once when four things point at it" is
13//! answerable. Semantic duplication is two different identifiers standing for one thing —
14//! one file indexed under two kinds, or one rule at two versions — and that has to be
15//! decided, because keeping both would spend the budget twice on one fact and would leave
16//! the session holding two answers.
17//!
18//! # First fit, not truncation
19//!
20//! Entries are spent in one total order — tier, then relevance descending, then canonical
21//! identifier — and an entry that does not fit is excluded while the walk continues. A
22//! budget that stopped at the first thing too large would let one big file hide a hundred
23//! small relevant ones; first fit spends the ceiling and names everything it skipped, with
24//! what it would have cost, so raising the budget is an informed decision.
25//!
26//! That last paragraph is the one worth seeing run, because the difference between first
27//! fit and truncation is invisible in a report and total in effect. Below, the most
28//! relevant candidate costs twice the whole ceiling: a truncating budget would have
29//! stopped there and answered with nothing, and this one skips it, says what it would have
30//! cost, and carries on to spend the ceiling on what does fit.
31//!
32//! ```
33//! use std::collections::{BTreeMap, BTreeSet};
34//!
35//! use majordomus_cli::devcontext::budget::spend;
36//! use majordomus_cli::devcontext::select::{Candidate, Selection};
37//! use majordomus_cli::devcontext::{Discovery, ExclusionReason, Selector, Tier};
38//! use majordomus_cli::synthetic::SyntheticRepository;
39//!
40//! let repo = SyntheticRepository::small().unwrap();
41//! let index = repo.index().unwrap();
42//!
43//! // `bytes` as a fact rather than a file: the cost is the one thing this example pins
44//! let candidate = |uri: &str, relevance: f64, bytes: u64| Candidate {
45//!     uri: uri.into(),
46//!     kind: "knowledge".into(),
47//!     title: None,
48//!     tier: Tier::Knowledge,
49//!     relevance,
50//!     depth: 1,
51//!     discovered_by: vec![Discovery {
52//!         selector: Selector::Relation,
53//!         reason: "reached along a declared edge".into(),
54//!         via: None,
55//!         edge: Some("depends_on".into()),
56//!         depth: 1,
57//!         confidence: 1.0,
58//!         weight: 0.9,
59//!     }],
60//!     object: None,
61//!     path: None,
62//!     status: None,
63//!     facts: BTreeMap::from([("bytes".to_string(), bytes.to_string())]),
64//! };
65//!
66//! let mut candidates = BTreeMap::new();
67//! // 8000 bytes is 2000 tokens: twice the ceiling, and the more relevant of the two
68//! candidates.insert("big".to_string(), candidate("majordomus://knowledge/big", 0.9, 8_000));
69//! candidates.insert("small".to_string(), candidate("majordomus://knowledge/small", 0.5, 400));
70//! let selection = Selection { candidates, unresolved: Vec::new(), diagnostics: Vec::new() };
71//!
72//! let (s, _) = spend(&index, selection, &BTreeSet::new(), 1_000, &BTreeMap::new());
73//!
74//! // the walk did not stop at the thing that would not fit
75//! let kept: Vec<&str> = s.selected.iter().map(|e| e.uri.as_str()).collect();
76//! assert_eq!(kept, ["majordomus://knowledge/small"]);
77//!
78//! // and what it skipped is named, with what it would have cost
79//! assert_eq!(s.excluded.len(), 1);
80//! assert_eq!(s.excluded[0].uri, "majordomus://knowledge/big");
81//! assert_eq!(s.excluded[0].reason, ExclusionReason::Budget);
82//! assert_eq!(s.excluded[0].cost_tokens, 2_000, "so raising the ceiling is informed");
83//!
84//! // the ceiling was respected, and `used` is the sum of what is in the answer
85//! assert_eq!(s.budget.used_tokens, 100);
86//! assert_eq!(s.budget.remaining_tokens, 900);
87//! assert!(!s.budget.over_budget);
88//! ```
89
90use std::collections::{BTreeMap, BTreeSet};
91
92use crate::index::Index;
93
94use super::model::{
95    tokens_for, Budget, Conflict, ContextEntry, Deduplicated, EntryProvenance, Excluded,
96    ExclusionReason, Tier, TierSpend, BYTES_PER_TOKEN,
97};
98use super::select::{version_of, Candidate, Selection};
99
100/// Everything the budget decided, as four lists and the arithmetic behind them: what the
101/// session is given, what it is not and why, what collapsed into what, and what disagrees
102/// with what.
103///
104/// The four lists are disjoint views of one walk rather than four separate answers, and the
105/// one invariant that joins them to the budget is that `budget.used_tokens` is exactly the
106/// sum of `selected`'s costs — nothing is spent that is not in the answer, and nothing is in
107/// the answer that was not spent. An under-filled context is debugged from `excluded`, not
108/// by guessing, which is why it carries a reason and a cost per row.
109///
110/// Every field is populated on every call, including with nothing reached: the per-tier
111/// spend always has all six tiers, so a reader never has to distinguish "this tier was
112/// empty" from "this tier is missing from the answer".
113///
114/// ```
115/// use std::collections::{BTreeMap, BTreeSet};
116///
117/// use majordomus_cli::devcontext::budget::{spend, Spend};
118/// use majordomus_cli::devcontext::select::Selection;
119/// use majordomus_cli::devcontext::{Tier, DEFAULT_BUDGET_TOKENS};
120/// use majordomus_cli::model::Diagnostic;
121/// use majordomus_cli::synthetic::SyntheticRepository;
122///
123/// let repo = SyntheticRepository::small().unwrap();
124/// let index = repo.index().unwrap();
125/// let nothing = Selection {
126///     candidates: BTreeMap::new(),
127///     unresolved: Vec::new(),
128///     diagnostics: Vec::new(),
129/// };
130///
131/// // the answer and what could not be read, side by side and neither an error
132/// let (s, diagnostics): (Spend, Vec<Diagnostic>) = spend(
133///     &index, nothing, &BTreeSet::new(), DEFAULT_BUDGET_TOKENS, &BTreeMap::new(),
134/// );
135///
136/// // reaching nothing is an answer, not an error, and it is a complete one
137/// assert!(s.selected.is_empty() && s.excluded.is_empty());
138/// assert!(s.deduplicated.is_empty() && s.conflicts.is_empty());
139/// assert!(diagnostics.is_empty());
140///
141/// // the per-tier spend is always total, so no tier is merely absent
142/// let tiers: Vec<Tier> = s.budget.tiers.iter().map(|t| t.tier).collect();
143/// assert_eq!(tiers, Tier::ORDER.to_vec());
144///
145/// // and the invariant that ties the lists to the arithmetic
146/// let spent: u64 = s.selected.iter().map(|e| e.cost_tokens).sum();
147/// assert_eq!(s.budget.used_tokens, spent);
148/// assert_eq!(s.budget.remaining_tokens, DEFAULT_BUDGET_TOKENS);
149/// ```
150pub struct Spend {
151    /// What to give the session, in the order it was spent.
152    pub selected: Vec<ContextEntry>,
153    /// What was reached and not given.
154    pub excluded: Vec<Excluded>,
155    /// What collapsed into what.
156    pub deduplicated: Vec<Deduplicated>,
157    /// What does not agree with what.
158    pub conflicts: Vec<Conflict>,
159    /// The ceiling and the per-tier spend.
160    pub budget: Budget,
161}
162
163/// The status words that mean a thing is no longer current.
164const STALE: &[&str] = &["deprecated", "superseded", "rejected"];
165
166/// Is this entry one the budget may not drop? The task's own seeds, and the two objects
167/// that decide what every session is held to.
168fn required_of(c: &Candidate, seeds: &BTreeSet<String>) -> bool {
169    seeds.contains(&c.uri) || matches!(c.kind.as_str(), "policy" | "scope")
170}
171
172/// The bytes an entry costs. An object carries its own size; anything else is measured
173/// once, here, from the path the layer named — never by walking for it.
174fn bytes_of(c: &Candidate, index: &Index) -> u64 {
175    if let Some(i) = c.object {
176        return index.objects[i].provenance.bytes;
177    }
178    if let Some(b) = c.facts.get("bytes").and_then(|b| b.parse::<u64>().ok()) {
179        return b;
180    }
181    match &c.path {
182        Some(p) => std::fs::metadata(std::path::Path::new(&index.repository.root).join(p))
183            .map(|m| m.len())
184            .unwrap_or(0),
185        None => 0,
186    }
187}
188
189/// The kind that wins when one file is indexed under two: the specific one. `document` and
190/// `file` are what the layer falls back to when nothing more precise claimed a path, so
191/// they lose to anything that did.
192fn specificity(kind: &str) -> u8 {
193    match kind {
194        "file" => 0,
195        "document" => 1,
196        _ => 2,
197    }
198}
199
200/// The identity of a versioned object without its version: `project.x@2` is `project.x`.
201fn stem(uri: &str) -> (&str, Option<u32>) {
202    match uri.rsplit_once('@') {
203        Some((head, v)) => (head, v.parse().ok()),
204        None => (uri, None),
205    }
206}
207
208/// Compile the selection into the answer: fold, decide, order, spend.
209///
210/// The four stages run in that order and none of them re-reads the repository. `seeds` is
211/// what the request asked about, and it is the one input that can push the answer over its
212/// ceiling: a seed is never dropped for budget, and neither is the policy or the scope, so
213/// `limit` bounds what the budget *chooses* and not what it must keep. When the entries it
214/// may not drop cost more than the limit, the answer says `over_budget` rather than
215/// returning a context missing the thing it was compiled about.
216///
217/// `supersedes` maps an identifier to the identifiers it declares it supersedes, and is
218/// what turns two versions of one thing in the answer into one entry and a recorded
219/// conflict. The diagnostics that come back alongside are what could not be read — never an
220/// error, because an absent source is an answer.
221///
222/// ```
223/// use std::collections::{BTreeMap, BTreeSet};
224///
225/// use majordomus_cli::devcontext::budget::spend;
226/// use majordomus_cli::devcontext::select::{Candidate, Selection};
227/// use majordomus_cli::devcontext::{Discovery, Selector, Tier};
228/// use majordomus_cli::synthetic::SyntheticRepository;
229///
230/// let repo = SyntheticRepository::small().unwrap();
231/// let index = repo.index().unwrap();
232///
233/// let seed_uri = "majordomus://issue/I0301";
234/// let candidate = Candidate {
235///     uri: seed_uri.into(),
236///     kind: "issue".into(),
237///     title: Some("the thing being worked on".into()),
238///     tier: Tier::Task,
239///     relevance: 1.0,
240///     depth: 0,
241///     discovered_by: vec![Discovery {
242///         selector: Selector::Seed,
243///         reason: "named in the request".into(),
244///         via: None,
245///         edge: None,
246///         depth: 0,
247///         confidence: 1.0,
248///         weight: 1.0,
249///     }],
250///     object: None,
251///     path: None,
252///     status: None,
253///     // 40_000 bytes is 10_000 tokens, against a ceiling of 10
254///     facts: BTreeMap::from([("bytes".to_string(), "40000".to_string())]),
255/// };
256/// let selection = Selection {
257///     candidates: BTreeMap::from([(seed_uri.to_string(), candidate)]),
258///     unresolved: Vec::new(),
259///     diagnostics: Vec::new(),
260/// };
261/// let seeds = BTreeSet::from([seed_uri.to_string()]);
262///
263/// let (s, _) = spend(&index, selection, &seeds, 10, &BTreeMap::new());
264///
265/// // the seed survives a ceiling it does not fit in: a context that lost the thing it was
266/// // compiled about is not a smaller answer, it is the wrong one
267/// assert_eq!(s.selected.len(), 1);
268/// assert!(s.selected[0].required);
269/// assert!(s.excluded.is_empty());
270///
271/// // and the answer says so rather than pretending it stayed inside the budget
272/// assert!(s.budget.over_budget);
273/// assert_eq!(s.budget.used_tokens, 10_000);
274/// assert_eq!(s.budget.required_tokens, 10_000, "all of it is undroppable");
275/// assert_eq!(s.budget.remaining_tokens, 0, "floored, never negative");
276/// ```
277pub fn spend(
278    index: &Index,
279    sel: Selection,
280    seeds: &BTreeSet<String>,
281    limit: u64,
282    supersedes: &BTreeMap<String, Vec<String>>,
283) -> (Spend, Vec<crate::model::Diagnostic>) {
284    let mut diagnostics = sel.diagnostics;
285    let mut candidates = sel.candidates;
286    let mut deduplicated = Vec::new();
287    let mut conflicts = Vec::new();
288    let mut excluded: Vec<Excluded> = Vec::new();
289
290    // ---- structural: the record of what was already folded by identifier
291    for c in candidates.values() {
292        if c.discovered_by.len() < 2 {
293            continue;
294        }
295        let mut ways: Vec<String> = c
296            .discovered_by
297            .iter()
298            .map(|d| match &d.edge {
299                Some(e) => format!("{}({e})", d.selector.as_str()),
300                None => d.selector.as_str().to_string(),
301            })
302            .collect();
303        crate::order::canonical_strings(&mut ways);
304        ways.dedup();
305        deduplicated.push(Deduplicated {
306            kept: c.uri.clone(),
307            dropped: Vec::new(),
308            key: "uri".into(),
309            detail: format!(
310                "one object reached by {} discovery path(s): {}",
311                c.discovered_by.len(),
312                ways.join(", ")
313            ),
314            paths: c.discovered_by.len(),
315        });
316    }
317
318    // ---- semantic: one file indexed under two kinds
319    let mut by_path: BTreeMap<String, Vec<String>> = BTreeMap::new();
320    for c in candidates.values() {
321        if let Some(p) = &c.path {
322            by_path.entry(p.clone()).or_default().push(c.uri.clone());
323        }
324    }
325    let mut drop: BTreeSet<String> = BTreeSet::new();
326    for (path, uris) in &by_path {
327        if uris.len() < 2 {
328            continue;
329        }
330        // the most specific kind wins; a tie goes to the higher relevance, then to the
331        // identifier, so the choice is the same on every run
332        let mut ranked: Vec<&Candidate> = uris.iter().filter_map(|u| candidates.get(u)).collect();
333        ranked.sort_by(|a, b| {
334            specificity(&b.kind)
335                .cmp(&specificity(&a.kind))
336                .then(
337                    b.relevance
338                        .partial_cmp(&a.relevance)
339                        .unwrap_or(std::cmp::Ordering::Equal),
340                )
341                .then(a.uri.cmp(&b.uri))
342        });
343        let kept = ranked[0].uri.clone();
344        let lost: Vec<String> = ranked[1..].iter().map(|c| c.uri.clone()).collect();
345        for u in &lost {
346            drop.insert(u.clone());
347        }
348        deduplicated.push(Deduplicated {
349            kept: kept.clone(),
350            dropped: lost.clone(),
351            key: "path".into(),
352            detail: format!(
353                "`{path}` is indexed under {} identifiers; the most specific kind is kept",
354                uris.len()
355            ),
356            paths: 1,
357        });
358    }
359
360    // ---- semantic: one thing at two versions
361    let mut by_stem: BTreeMap<&str, Vec<(String, Option<u32>)>> = BTreeMap::new();
362    for uri in candidates.keys() {
363        let (s, v) = stem(uri);
364        if v.is_some() {
365            by_stem.entry(s).or_default().push((uri.clone(), v));
366        }
367    }
368    for (s, mut versions) in by_stem {
369        if versions.len() < 2 {
370            continue;
371        }
372        versions.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
373        let kept = versions[0].0.clone();
374        for (uri, _) in &versions[1..] {
375            drop.insert(uri.clone());
376            conflicts.push(Conflict {
377                current: kept.clone(),
378                against: uri.clone(),
379                kind: "version".into(),
380                detail: format!(
381                    "`{s}` is in the answer at two versions; the higher one is what applies"
382                ),
383            });
384        }
385        deduplicated.push(Deduplicated {
386            kept,
387            dropped: versions[1..].iter().map(|(u, _)| u.clone()).collect(),
388            key: "identity".into(),
389            detail: format!("`{s}` at {} versions", versions.len()),
390            paths: 1,
391        });
392    }
393
394    // ---- what stands in for what: a decision that supersedes another leaves the other out
395    for (later, earlier) in supersedes {
396        if !candidates.contains_key(later) {
397            continue;
398        }
399        for e in earlier {
400            if !candidates.contains_key(e) || seeds.contains(e) {
401                continue;
402            }
403            drop.insert(e.clone());
404            conflicts.push(Conflict {
405                current: later.clone(),
406                against: e.clone(),
407                kind: "supersedes".into(),
408                detail: format!("`{later}` stands in for `{e}`; the later decision is what holds"),
409            });
410        }
411    }
412
413    // the dropped ones leave the answer, and say so
414    for uri in &drop {
415        if let Some(c) = candidates.remove(uri) {
416            let bytes = bytes_of(&c, index);
417            let superseded = conflicts.iter().any(|k| &k.against == uri);
418            excluded.push(Excluded {
419                uri: c.uri.clone(),
420                kind: Some(c.kind.clone()),
421                tier: c.tier,
422                reason: if superseded {
423                    ExclusionReason::Superseded
424                } else {
425                    ExclusionReason::Unresolved
426                },
427                detail: match conflicts.iter().find(|k| &k.against == uri) {
428                    Some(k) => k.detail.clone(),
429                    None => "another identifier in the answer stands for the same file".into(),
430                },
431                cost_tokens: tokens_for(bytes),
432                relevance: c.relevance,
433            });
434        }
435    }
436
437    // ---- stale: what the object says about itself. A seed is kept however it is marked —
438    // the request asked for it — and the marking becomes a diagnostic instead.
439    let stale: Vec<String> = candidates
440        .values()
441        .filter(|c| c.status.as_deref().is_some_and(|s| STALE.contains(&s)))
442        .map(|c| c.uri.clone())
443        .collect();
444    for uri in stale {
445        let Some(c) = candidates.get(&uri) else {
446            continue;
447        };
448        let status = c.status.clone().unwrap_or_default();
449        if seeds.contains(&uri) {
450            diagnostics.push(crate::model::Diagnostic::warning(
451                "stale_seed",
452                c.path.clone(),
453                format!("`{uri}` is `{status}` and was named in the request; it is kept anyway"),
454            ));
455            continue;
456        }
457        let c = candidates.remove(&uri).expect("just read");
458        let bytes = bytes_of(&c, index);
459        excluded.push(Excluded {
460            uri,
461            kind: Some(c.kind),
462            tier: c.tier,
463            reason: ExclusionReason::Stale,
464            detail: format!("the object declares `status: {status}`"),
465            cost_tokens: tokens_for(bytes),
466            relevance: c.relevance,
467        });
468    }
469
470    // ---- the one total order, and the spend
471    let mut ordered: Vec<Candidate> = candidates.into_values().collect();
472    ordered.sort_by(|a, b| {
473        a.tier
474            .cmp(&b.tier)
475            .then(
476                b.relevance
477                    .partial_cmp(&a.relevance)
478                    .unwrap_or(std::cmp::Ordering::Equal),
479            )
480            .then(a.uri.cmp(&b.uri))
481    });
482
483    let mut selected: Vec<ContextEntry> = Vec::new();
484    let mut used = 0u64;
485    let mut required_tokens = 0u64;
486    for c in ordered {
487        let bytes = bytes_of(&c, index);
488        let cost = tokens_for(bytes);
489        let required = required_of(&c, seeds);
490        if required {
491            required_tokens += cost;
492        }
493        if !required && used + cost > limit {
494            excluded.push(Excluded {
495                uri: c.uri.clone(),
496                kind: Some(c.kind.clone()),
497                tier: c.tier,
498                reason: ExclusionReason::Budget,
499                detail: format!(
500                    "{cost} token(s) would not fit: {used} of {limit} already spent, {} left",
501                    limit.saturating_sub(used)
502                ),
503                cost_tokens: cost,
504                relevance: c.relevance,
505            });
506            continue;
507        }
508        used += cost;
509        let mut discovered_by = c.discovered_by;
510        discovered_by.sort_by(|a, b| {
511            b.confidence
512                .partial_cmp(&a.confidence)
513                .unwrap_or(std::cmp::Ordering::Equal)
514                .then(a.depth.cmp(&b.depth))
515                .then(a.selector.cmp(&b.selector))
516                .then(a.reason.cmp(&b.reason))
517        });
518        let confidence = discovered_by
519            .iter()
520            .map(|d| d.confidence)
521            .fold(0.0_f64, f64::max);
522        let (provenance, version) = match c.object {
523            Some(i) => {
524                let o = &index.objects[i];
525                (
526                    EntryProvenance {
527                        path: o.provenance.path.clone(),
528                        directory: o.provenance.directory.clone(),
529                        source_class: o.provenance.source_class.clone(),
530                        section: o.provenance.section.clone(),
531                        bytes,
532                    },
533                    version_of(o),
534                )
535            }
536            None => {
537                let path = c.path.clone().unwrap_or_else(|| c.uri.clone());
538                let directory = path
539                    .rsplit_once('/')
540                    .map(|(d, _)| d.to_string())
541                    .unwrap_or_else(|| ".".into());
542                (
543                    EntryProvenance {
544                        path,
545                        directory,
546                        // not discovered by a source class: named by the layer, or read
547                        // from this checkout's own local state
548                        source_class: if c.uri.starts_with("local:") {
549                            "local-state".into()
550                        } else {
551                            "named".into()
552                        },
553                        section: None,
554                        bytes,
555                    },
556                    c.facts.get("created_at").cloned(),
557                )
558            }
559        };
560        selected.push(ContextEntry {
561            uri: c.uri,
562            kind: c.kind,
563            title: c.title,
564            provenance,
565            tier: c.tier,
566            relevance: round(c.relevance),
567            confidence: round(confidence),
568            discovered_by,
569            depth: c.depth,
570            cost_tokens: cost,
571            cost_bytes: bytes,
572            version,
573            status: c.status,
574            required,
575            facts: c.facts,
576        });
577    }
578
579    crate::order::canonical(&mut excluded);
580    crate::order::canonical(&mut deduplicated);
581    crate::order::canonical(&mut conflicts);
582
583    let tiers = Tier::ORDER
584        .iter()
585        .map(|t| TierSpend {
586            tier: *t,
587            meaning: t.meaning().to_string(),
588            selected: selected.iter().filter(|e| e.tier == *t).count(),
589            excluded: excluded
590                .iter()
591                .filter(|e| e.tier == *t && e.reason == ExclusionReason::Budget)
592                .count(),
593            tokens: selected
594                .iter()
595                .filter(|e| e.tier == *t)
596                .map(|e| e.cost_tokens)
597                .sum(),
598            required: selected
599                .iter()
600                .filter(|e| e.tier == *t && e.required)
601                .count(),
602        })
603        .collect();
604
605    for (reference, detail) in sel.unresolved {
606        diagnostics.push(crate::model::Diagnostic::warning(
607            "unresolved_reference",
608            None,
609            format!("`{reference}` is named and not held: {detail}"),
610        ));
611    }
612    diagnostics.sort_by(|a, b| {
613        a.severity
614            .cmp(&b.severity)
615            .reverse()
616            .then(a.code.cmp(&b.code))
617            .then(a.message.cmp(&b.message))
618    });
619    diagnostics.dedup();
620
621    (
622        Spend {
623            selected,
624            excluded,
625            deduplicated,
626            conflicts,
627            budget: Budget {
628                limit_tokens: limit,
629                used_tokens: used,
630                required_tokens,
631                remaining_tokens: limit.saturating_sub(used),
632                // The answer is over budget when it *is* over budget. That happens only
633                // through the entries it may not drop — the seeds and the two objects every
634                // session is held to — because every other entry is refused the moment it
635                // would not fit. `required_tokens` is the explanation; this is the fact.
636                over_budget: used > limit,
637                bytes_per_token: BYTES_PER_TOKEN,
638                tiers,
639            },
640        },
641        diagnostics,
642    )
643}
644
645/// Three decimals: enough to order by, few enough that the same tree gives the same digits
646/// on every platform.
647fn round(v: f64) -> f64 {
648    (v * 1000.0).round() / 1000.0
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654
655    #[test]
656    fn a_version_is_split_off_the_identity() {
657        assert_eq!(
658            stem("majordomus://rule/project.x@2"),
659            ("majordomus://rule/project.x", Some(2))
660        );
661        assert_eq!(
662            stem("majordomus://adr/adr-0001"),
663            ("majordomus://adr/adr-0001", None)
664        );
665        // an @ with something unparseable after it is not a version
666        assert_eq!(stem("a@b").1, None);
667    }
668
669    #[test]
670    fn the_specific_kind_beats_the_fallback_kinds() {
671        assert!(specificity("rule") > specificity("document"));
672        assert!(specificity("document") > specificity("file"));
673        assert_eq!(specificity("adr"), specificity("issue"));
674    }
675
676    #[test]
677    fn rounding_is_stable_and_orderable() {
678        assert_eq!(round(0.8 * 0.85), 0.68);
679        assert_eq!(round(1.0), 1.0);
680        assert!(round(0.6666666) < round(0.6669999) + 0.001);
681    }
682}