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}