Skip to main content

majordomus_cli/economics/
claims.rs

1//! Claim governance for token economics: no number about tokens, context or cost reaches a
2//! reader unless the evidence produced it.
3//!
4//! Three checks, all deterministic and all run by `majordomus economics check`:
5//!
6//! - **Bound claims.** The methodology binds claims of `docs/CLAIMS.yaml` to metrics. A
7//!   bound claim may be `guaranteed` only while its metric stands where the binding says —
8//!   `verified` for sampled live evidence meeting the publication rule, `measured` for
9//!   deterministic evidence — and while every suite behind that metric is current. Evidence
10//!   that goes stale takes the guarantee with it.
11//! - **Unbound claims.** A `guaranteed` claim whose sentence says that tokens, context or
12//!   cost go down has to be bound to a metric, or nothing would ever check it. A claim id
13//!   that appears twice is refused too: a binding, a page and a gate would each read
14//!   whichever copy they found first.
15//! - **Unsupported quantities in prose.** Every hand-written document, page and claim
16//!   sentence is scanned for a quantity (a percentage, "N times fewer", "half the tokens") in
17//!   the same sentence as a word of the economics vocabulary. Repository prose is
18//!   hard-wrapped, so the scan reads paragraphs, not lines: a sentence wrapped over two lines
19//!   is one sentence. Numbers about tokens are generated from evidence into generated
20//!   artifacts; typed by hand, they are refused.
21//!
22//! The check fails closed. Declarations it cannot read, a claims matrix it cannot parse and
23//! a repository git cannot list are findings, never an empty and therefore clean result.
24//!
25//! ```
26//! use majordomus_cli::economics::claims::unsupported_quantity;
27//! assert!(unsupported_quantity("Majordomus saves 50% of tokens.").is_some());
28//! assert!(unsupported_quantity("Majordomus cuts cost by 3x.").is_some());
29//! assert!(unsupported_quantity("The suite takes 50% longer on Linux.").is_none(), "no economics word");
30//! assert!(unsupported_quantity("The context budget is 300 lines.").is_none(), "no quantity");
31//! ```
32
33use std::collections::{BTreeMap, BTreeSet};
34use std::path::Path;
35
36use schemars::JsonSchema;
37use serde::{Deserialize, Serialize};
38
39use super::model::{EconomicsFreshness, EconomicsMetric, EconomicsMetricStatus, EconomicsSummary};
40
41/// Words that make a quantity a statement about token economics.
42const VOCABULARY: &[&str] = &[
43    "token", "context", "cost", "costs", "spend", "spending", "cheaper", "saving", "savings",
44    "save", "saves", "bill", "billing", "usage", "prompt",
45];
46
47/// Words that say something went down. With the vocabulary they make a sentence a savings
48/// claim: a fraction word counts as a quantity only then, and a guaranteed claim that says
49/// it has to be bound to a metric. The inflections past the base list (`saved`, `lowers`,
50/// `halves`) are here because "it halves the token bill" says what "it cuts" says.
51const SAVING: &[&str] = &[
52    "reduce",
53    "reduces",
54    "reduced",
55    "reducing",
56    "reduction",
57    "save",
58    "saves",
59    "saved",
60    "saving",
61    "savings",
62    "cheaper",
63    "fewer",
64    "less",
65    "lower",
66    "lowers",
67    "lowered",
68    "cuts",
69    "cut",
70    "cutting",
71    "halve",
72    "halves",
73    "halved",
74    "halving",
75];
76
77/// Multiplier and fraction words. "The other half of the context" is not a claim, and
78/// "runs twice ... the provider's usage" is not one either: these are quantities only in a
79/// sentence that also says something went down, and only within [`NEAR`] words of a word of
80/// the vocabulary.
81const FRACTIONS: &[&str] = &[
82    "half", "halve", "halves", "halved", "halving", "twice", "double", "doubled", "doubles",
83    "triple", "tripled", "third", "thirds", "quarter", "quarters",
84];
85
86/// Fraction words that are also ordinals or nouns: after `the` they count ("the third
87/// session", "the last quarter") rather than divide, and are not a quantity.
88const ORDINALS: &[&str] = &["third", "quarter"];
89
90/// How many words apart a fraction word and the vocabulary may stand and still be read as
91/// one statement ("halves what a session spends").
92const NEAR: usize = 6;
93
94/// Characters that join the parts of a compound: `total-token` and `token-saving` are two
95/// words each, and each part counts.
96const DASHES: &[char] = &['-', '\u{2010}', '\u{2011}', '\u{2013}', '\u{2014}'];
97
98/// Hand-written text the scan reads: prose, pages, site data and the provider templates the
99/// instruction files are generated from. Generated files are skipped by provenance.
100const PROSE: &[&str] = &[
101    "README.md",
102    "AGENTS.md",
103    "CLAUDE.md",
104    "docs/*.md",
105    "docs/**/*.md",
106    "site/content-src/**",
107    "site/data/*.toml",
108    "site/templates/**",
109    ".ai/repo/**/*.md",
110    "share/providers/*.tmpl",
111    ".ai/repo/providers/*.tmpl",
112];
113
114/// Directories written entirely by `scripts/generate-site-data`; their sources, under
115/// `site/content-src/`, are scanned.
116const GENERATED_DIRS: &[&str] = &["site/data/generated/", "site/content/"];
117
118/// The manifest of every artifact `majordomus generate` writes, by path.
119const MANIFEST: &str = "docs/generated/artifacts.json";
120
121/// How a generator stamps the first line of what it writes.
122const STAMPS: &[&str] = &[
123    "<!-- generated by",
124    "# GENERATED FILE",
125    "<!-- GENERATED FILE",
126    "// GENERATED FILE",
127];
128
129/// The claims matrix, whose claim sentences are prose too.
130const CLAIMS: &str = "docs/CLAIMS.yaml";
131
132/// One thing the check refuses. `detail` names the remedy, so a finding can be acted on
133/// without reading the methodology. A finding about a claim or a file rather than a line
134/// carries no `line`, and its JSON omits the key. The kinds:
135///
136/// - `unsupported_quantity`: a number typed into hand-written prose next to the economics
137///   vocabulary.
138/// - `claim_unsupported`: a claim binding that does not hold — the bound claim is missing
139///   from `docs/CLAIMS.yaml`, or it is `guaranteed` while its metric does not stand where
140///   the binding says or its evidence is not current.
141/// - `claim_unbound`: a `guaranteed` claim says tokens, context or cost go down and no
142///   binding ties it to a metric.
143/// - `claim_duplicate`: one claim id appears more than once in `docs/CLAIMS.yaml`.
144/// - `declarations_unreadable`: the benchmark declarations cannot be read, so no binding
145///   can be shown to hold.
146/// - `claims_unreadable`: `docs/CLAIMS.yaml` exists and cannot be read or parsed.
147/// - `scan_failed`: the prose could not be listed or a file in it could not be read, so
148///   nothing can be said to be clean.
149///
150/// ```
151/// use majordomus_cli::economics::claims::EconomicsFinding;
152/// let finding = EconomicsFinding {
153///     kind: "claim_unsupported".into(),
154///     path: "docs/CLAIMS.yaml".into(),
155///     line: None,
156///     detail: "set the claim to planned, or record the evidence".into(),
157/// };
158/// let json = serde_json::to_value(&finding).unwrap();
159/// assert!(json.get("line").is_none(), "no line, no key");
160/// assert_eq!(serde_json::from_value::<EconomicsFinding>(json).unwrap(), finding);
161/// ```
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
163pub struct EconomicsFinding {
164    /// `unsupported_quantity`, `claim_unsupported`, `claim_unbound`, `claim_duplicate`,
165    /// `declarations_unreadable`, `claims_unreadable` or `scan_failed`.
166    pub kind: String,
167    /// Repository-relative path.
168    pub path: String,
169    /// 1-based line, when the finding is about a line.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub line: Option<usize>,
172    /// What was found, and what to do about it.
173    pub detail: String,
174}
175
176/// The answer of `economics.check`, the same over the CLI, HTTP and MCP: how much was read
177/// and every refusal. `ok` is true exactly when `findings` is empty. `scanned` counts the
178/// hand-written files read, `docs/CLAIMS.yaml` included and generated files excluded;
179/// `claims_checked` counts the methodology's claim bindings, whatever their status.
180///
181/// ```
182/// use majordomus_cli::economics::claims::{check, EconomicsCheckReport};
183/// let dir = tempfile::tempdir().unwrap();
184/// let git = std::process::Command::new("git")
185///     .env_remove("GIT_DIR").env_remove("GIT_WORK_TREE").env_remove("GIT_INDEX_FILE")
186///     .arg("-C").arg(dir.path()).args(["init", "-q"]).status().unwrap();
187/// assert!(git.success());
188/// let report: EconomicsCheckReport = check(dir.path());
189/// assert!(report.ok && report.findings.is_empty(), "an empty repository claims nothing");
190/// let json = serde_json::to_value(&report).unwrap();
191/// assert_eq!(json["claims_checked"], 0);
192/// assert_eq!(serde_json::from_value::<EconomicsCheckReport>(json).unwrap(), report);
193/// ```
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
195pub struct EconomicsCheckReport {
196    /// No finding.
197    pub ok: bool,
198    /// Files scanned.
199    pub scanned: usize,
200    /// Bound claims checked.
201    pub claims_checked: usize,
202    /// Every finding.
203    pub findings: Vec<EconomicsFinding>,
204}
205
206/// The input of `economics.check`: nothing to narrow; the check is over the repository.
207/// Unknown fields are refused rather than ignored, so a caller who tries to scope the check
208/// to a path learns that it cannot be scoped instead of reading a partial verdict as whole.
209///
210/// ```
211/// use majordomus_cli::economics::claims::EconomicsCheckInput;
212/// let input: EconomicsCheckInput = serde_json::from_str("{}").unwrap();
213/// assert_eq!(input, EconomicsCheckInput::default());
214/// let narrowed = serde_json::from_str::<EconomicsCheckInput>(r#"{"path":"docs"}"#);
215/// assert!(narrowed.is_err(), "the check is over the whole repository");
216/// ```
217#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
218#[serde(deny_unknown_fields)]
219pub struct EconomicsCheckInput {}
220
221/// Comparative words that make `3 x` or `3 ×` a multiplier rather than a count.
222const COMPARATIVES: &[&str] = &[
223    "fewer",
224    "less",
225    "cheaper",
226    "smaller",
227    "lower",
228    "more",
229    "faster",
230    "reduction",
231    "saving",
232    "savings",
233];
234
235/// Where `inner`, a slice of `outer`, starts in it.
236fn offset(outer: &str, inner: &str) -> usize {
237    inner.as_ptr() as usize - outer.as_ptr() as usize
238}
239
240/// Apostrophes a possessive or a contraction is written with.
241const APOSTROPHES: &[char] = &['\'', '\u{2019}'];
242
243/// The plain words of a sentence with where each starts: whitespace-separated tokens split
244/// into their parts at a dash, each part with surrounding punctuation trimmed, a possessive
245/// `'s` dropped, and lowercased. The dash is split first, so `tokens—40%` still has its
246/// word and `token's` is `token`. A part with other punctuation inside
247/// (`validate:context`, `context/v1`, `a.b`, `v1`) is an identifier, not a word, and never
248/// counts as vocabulary.
249fn words(sentence: &str) -> Vec<(usize, String)> {
250    let mut out = Vec::new();
251    for token in sentence.split_whitespace() {
252        for part in token.split(|c: char| DASHES.contains(&c)) {
253            let t = part.trim_matches(|c: char| !c.is_alphanumeric());
254            let t = t
255                .strip_suffix(['s', 'S'])
256                .and_then(|x| x.strip_suffix(APOSTROPHES))
257                .unwrap_or(t);
258            if t.is_empty()
259                || !t
260                    .chars()
261                    .all(|c| c.is_alphabetic() || APOSTROPHES.contains(&c))
262            {
263                continue;
264            }
265            out.push((offset(sentence, t), t.to_lowercase()));
266        }
267    }
268    out
269}
270
271fn vocabulary(word: &str) -> bool {
272    VOCABULARY
273        .iter()
274        .any(|v| word == *v || (word.starts_with(v) && word.len() <= v.len() + 1))
275}
276
277fn economic(sentence: &str) -> bool {
278    words(sentence).iter().any(|(_, w)| vocabulary(w))
279}
280
281/// Words that say cost went down with nothing else in the sentence: "sessions are cheaper"
282/// and "the savings are real" are about cost already.
283const SAVING_ALONE: &[&str] = &["cheaper", "savings"];
284
285/// A sentence that uses the economics vocabulary and says something went down. The two have
286/// to be two words: "saves" is in both lists, and "Majordomus saves every decision" says
287/// nothing about tokens, while "saves tokens" does. Only a word of [`SAVING_ALONE`] is both
288/// at once.
289fn saving_claim(sentence: &str) -> bool {
290    let w = words(sentence);
291    let saving: Vec<usize> = (0..w.len())
292        .filter(|&k| SAVING.contains(&w[k].1.as_str()))
293        .collect();
294    let vocab: Vec<usize> = (0..w.len()).filter(|&k| vocabulary(&w[k].1)).collect();
295    w.iter().any(|(_, x)| SAVING_ALONE.contains(&x.as_str()))
296        || saving.iter().any(|s| vocab.iter().any(|v| v != s))
297}
298
299/// Abbreviations whose full stop does not end a sentence: "cuts tokens, e.g. by 40%" is one
300/// sentence, and splitting it would hide the number from the word it is about.
301const ABBREVIATIONS: &[&str] = &[
302    "e.g.", "i.e.", "etc.", "vs.", "cf.", "approx.", "ca.", "incl.", "resp.", "viz.",
303];
304
305/// Whether the full stop at byte `i` of `text` closes one of the [`ABBREVIATIONS`] inside
306/// a sentence. `etc.` ends as many sentences as it continues, so it continues one only when
307/// the next word does not start with a capital.
308fn abbreviation(text: &str, i: usize) -> bool {
309    let word = text[..=i]
310        .rsplit(|c: char| c.is_whitespace() || matches!(c, '(' | '[' | '"' | '*' | '_' | '`'))
311        .next()
312        .unwrap_or("")
313        .to_lowercase();
314    let capital_next = text[i + 1..]
315        .trim_start()
316        .chars()
317        .next()
318        .is_some_and(char::is_uppercase);
319    ABBREVIATIONS.contains(&word.as_str()) && !(word == "etc." && capital_next)
320}
321
322/// The sentences of a text with where each starts: split after `.`, `!` or `?` followed by
323/// a space, or by closing quotes, brackets or emphasis and then a space, except after an
324/// abbreviation. A quantity and the vocabulary have to meet in one sentence to be a
325/// statement about token economics.
326fn sentence_spans(text: &str) -> Vec<(usize, &str)> {
327    let mut out = Vec::new();
328    let mut start = 0;
329    let b = text.as_bytes();
330    let mut i = 0;
331    while i < b.len() {
332        if matches!(b[i], b'.' | b'!' | b'?') && !(b[i] == b'.' && abbreviation(text, i)) {
333            let mut j = i + 1;
334            while j < b.len() && matches!(b[j], b'"' | b'\'' | b')' | b']' | b'*' | b'_') {
335                j += 1;
336            }
337            if b.get(j) == Some(&b' ') {
338                out.push((start, &text[start..j]));
339                start = j;
340                i = j;
341                continue;
342            }
343        }
344        i += 1;
345    }
346    out.push((start, &text[start..]));
347    out
348}
349
350/// Whether the digit at `i` continues something else: a word, an identifier or a decimal.
351/// Emphasis markers before a number (`**50%**`, `__50%__`) do not glue it to anything.
352fn glued(chars: &[(usize, char)], i: usize) -> bool {
353    let Some(&(_, prev)) = i.checked_sub(1).and_then(|k| chars.get(k)) else {
354        return false;
355    };
356    if prev.is_alphanumeric() || prev == '.' {
357        return true;
358    }
359    if prev == '_' {
360        let mut k = i - 1;
361        while k > 0 && matches!(chars[k - 1].1, '_' | '*') {
362            k -= 1;
363        }
364        return k > 0 && chars[k - 1].1.is_alphanumeric();
365    }
366    false
367}
368
369/// The first numeric quantity in a sentence and where it starts: a number followed by `%`,
370/// `%`, `percent`, `per cent`, `percentage points` or `pp`, or a multiplier (`3x`,
371/// `3 × cheaper`, `2 times fewer`).
372fn numeric_quantity(sentence: &str) -> Option<(usize, String)> {
373    let chars: Vec<(usize, char)> = sentence.char_indices().collect();
374    let at = |k: usize| chars.get(k).map(|&(_, c)| c);
375    let mut i = 0;
376    while i < chars.len() {
377        if !chars[i].1.is_ascii_digit() || glued(&chars, i) {
378            i += 1;
379            continue;
380        }
381        let start = i;
382        while at(i).is_some_and(|c| c.is_ascii_digit())
383            || (matches!(at(i), Some('.' | ',')) && at(i + 1).is_some_and(|c| c.is_ascii_digit()))
384        {
385            i += 1;
386        }
387        let number: String = chars[start..i].iter().map(|&(_, c)| c).collect();
388        let position = chars[start].0;
389        let attached = at(i);
390        let mut j = i;
391        while matches!(at(j), Some('*' | '_')) {
392            j += 1;
393        }
394        while at(j).is_some_and(char::is_whitespace) {
395            j += 1;
396        }
397        let rest: String = chars[j..]
398            .iter()
399            .map(|&(_, c)| c)
400            .collect::<String>()
401            .to_lowercase();
402        let next_word = |from: usize| -> String {
403            rest.chars()
404                .skip(from)
405                .collect::<String>()
406                .split_whitespace()
407                .next()
408                .unwrap_or("")
409                .trim_matches(|c: char| !c.is_alphanumeric())
410                .to_string()
411        };
412        let ends_word = |len: usize| rest.chars().nth(len).is_none_or(|c| !c.is_alphanumeric());
413        // `0 %}` closes a template tag; it is not a percentage
414        if (rest.starts_with('%') && !rest[1..].starts_with('}')) || rest.starts_with('\u{ff05}') {
415            return Some((position, format!("{number}%")));
416        }
417        if rest.starts_with("percentage point") {
418            return Some((position, format!("{number} percentage points")));
419        }
420        if rest.starts_with("percent") {
421            return Some((position, format!("{number} percent")));
422        }
423        if rest.starts_with("per cent") && ends_word(8) {
424            return Some((position, format!("{number} per cent")));
425        }
426        if rest.starts_with("pp") && ends_word(2) {
427            return Some((position, format!("{number} pp")));
428        }
429        if rest.starts_with('x') || rest.starts_with('×') {
430            let glued_sign = matches!(attached, Some('x') | Some('×')) && ends_word(1);
431            let compared = COMPARATIVES.contains(&next_word(1).as_str());
432            if glued_sign || compared {
433                return Some((position, format!("{number}x")));
434            }
435        }
436        if rest.starts_with("times ") && COMPARATIVES.contains(&next_word(5).as_str()) {
437            return Some((position, format!("{number} times")));
438        }
439    }
440    None
441}
442
443/// The first multiplier or fraction word of a sentence that says something went down, with
444/// a word of the vocabulary near it. "Both halves" and "two halves" are a noun, not a verb,
445/// and "the third" is an ordinal, not a fraction.
446fn fraction_quantity(sentence: &str) -> Option<(usize, String)> {
447    let w = words(sentence);
448    if !w.iter().any(|(_, x)| SAVING.contains(&x.as_str())) {
449        return None;
450    }
451    let before = |k: usize| k.checked_sub(1).map(|p| w[p].1.as_str());
452    let noun = |k: usize| {
453        (w[k].1 == "halves" && matches!(before(k), Some("both" | "two")))
454            || (ORDINALS.contains(&w[k].1.as_str()) && before(k) == Some("the"))
455    };
456    let near = |k: usize| {
457        w[k.saturating_sub(NEAR)..(k + NEAR + 1).min(w.len())]
458            .iter()
459            .any(|(_, x)| vocabulary(x))
460    };
461    (0..w.len())
462        .find(|&k| FRACTIONS.contains(&w[k].1.as_str()) && !noun(k) && near(k))
463        .map(|k| w[k].clone())
464}
465
466/// The first quantity of an economics sentence, numeric or a word, and where it starts.
467fn quantity(sentence: &str) -> Option<(usize, String)> {
468    if !economic(sentence) {
469        return None;
470    }
471    match (numeric_quantity(sentence), fraction_quantity(sentence)) {
472        (Some(a), Some(b)) => Some(if b.0 < a.0 { b } else { a }),
473        (a, b) => a.or(b),
474    }
475}
476
477/// The quantity in `line` that states something about token economics, if there is one: a
478/// number followed by `%`, `%`, `percent`, `per cent`, `percentage points` or `pp`, or a
479/// multiplier (`3x`, `3 × cheaper`, `2 times fewer`), in a sentence that also uses a plain
480/// word of the economics vocabulary; or a multiplier or fraction word (`half`, `twice`,
481/// `a third`) in a sentence that also says something went down. This reads one line as it
482/// stands; [`prose_quantities`] reads a whole file, with wrapped sentences joined.
483///
484/// ```
485/// use majordomus_cli::economics::claims::unsupported_quantity;
486/// assert_eq!(unsupported_quantity("Uses 3x fewer tokens.").as_deref(), Some("3x"));
487/// assert_eq!(unsupported_quantity("Cuts context by 60 percent.").as_deref(), Some("60 percent"));
488/// assert_eq!(unsupported_quantity("Cuts total-token use by **50%**.").as_deref(), Some("50%"));
489/// assert_eq!(unsupported_quantity("It halves the token bill.").as_deref(), Some("halves"));
490/// // a fraction with nothing going down is not a claim
491/// assert_eq!(unsupported_quantity("The other half of the context is rules."), None);
492/// // the number and the vocabulary have to meet in one sentence
493/// assert_eq!(unsupported_quantity("Coverage is 80%. Tokens are counted."), None);
494/// // an identifier is not a word of the vocabulary
495/// assert_eq!(unsupported_quantity("validate:context took 50% longer"), None);
496/// ```
497pub fn unsupported_quantity(line: &str) -> Option<String> {
498    sentence_spans(line)
499        .into_iter()
500        .find_map(|(_, s)| quantity(s))
501        .map(|(_, q)| q)
502}
503
504/// A quantity [`prose_quantities`] found: the 1-based line the quantity starts on, the
505/// quantity as written, and whether it stands in a fenced code block. A quantity in a code
506/// block is still a finding — a pasted benchmark output is a number about tokens a person
507/// typed into the document — and the flag lets the finding say where it is.
508///
509/// ```
510/// use majordomus_cli::economics::claims::{prose_quantities, ProseQuantity};
511/// let found = prose_quantities("README.md", "```\ntokens: 40% fewer\n```\n");
512/// assert_eq!(found, vec![ProseQuantity { line: 2, quantity: "40%".into(), code: true }]);
513/// ```
514#[derive(Debug, Clone, PartialEq, Eq)]
515pub struct ProseQuantity {
516    /// 1-based line the quantity starts on.
517    pub line: usize,
518    /// The quantity, as the finding names it (`40%`, `3x`, `half`).
519    pub quantity: String,
520    /// In a fenced code block.
521    pub code: bool,
522}
523
524/// How a file's lines group into paragraphs.
525#[derive(Clone, Copy, PartialEq, Eq)]
526enum Syntax {
527    /// Markdown and the templates written like it, HTML templates included.
528    Markdown,
529    /// TOML: every key starts a paragraph, and consecutive comment lines are one.
530    Toml,
531    /// YAML: only the `claim:` and `note:` values are prose.
532    Yaml,
533}
534
535fn syntax(path: &str) -> Syntax {
536    if path.ends_with(".toml") {
537        Syntax::Toml
538    } else if path.ends_with(".yaml") || path.ends_with(".yml") {
539        Syntax::Yaml
540    } else {
541        Syntax::Markdown
542    }
543}
544
545/// Lines read as one piece of prose, each with its 1-based number and the text it
546/// contributes.
547struct Paragraph<'a> {
548    lines: Vec<(usize, &'a str)>,
549    code: bool,
550}
551
552/// A fence opening or closing a code block: its character and its length.
553fn fence(t: &str) -> Option<(char, usize)> {
554    let c = t.chars().next().filter(|c| matches!(c, '`' | '~'))?;
555    let n = t.chars().take_while(|&x| x == c).count();
556    (n >= 3).then_some((c, n))
557}
558
559fn list_item(t: &str) -> bool {
560    if let Some(rest) = t.strip_prefix(['-', '*', '+']) {
561        return rest.starts_with(' ');
562    }
563    let digits = t.chars().take_while(char::is_ascii_digit).count();
564    digits > 0 && (t[digits..].starts_with(". ") || t[digits..].starts_with(") "))
565}
566
567fn heading(t: &str) -> bool {
568    let rest = t.trim_start_matches('#');
569    rest.len() < t.len() && (rest.is_empty() || rest.starts_with(' '))
570}
571
572fn thematic_break(t: &str) -> bool {
573    t.chars().filter(|c| !c.is_whitespace()).count() >= 3
574        && t.chars()
575            .all(|c| matches!(c, '-' | '*' | '_' | '=') || c.is_whitespace())
576}
577
578fn key_line(t: &str, separator: char) -> bool {
579    t.split_once(separator).is_some_and(|(k, v)| {
580        let k = k.trim();
581        !k.is_empty()
582            && k.chars()
583                .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '"'))
584            && (separator == '=' || v.is_empty() || v.starts_with(' '))
585    })
586}
587
588fn indent(line: &str) -> usize {
589    line.len() - line.trim_start().len()
590}
591
592/// The paragraphs of Markdown or TOML: consecutive non-blank lines joined. A fenced code
593/// block is a paragraph of its own; a table row, a heading and a TOML table header are
594/// each a paragraph of their own; a list item, a front-matter or TOML key and the first of
595/// a run of TOML comments start a new one.
596fn text_paragraphs(text: &str, syntax: Syntax) -> Vec<Paragraph<'_>> {
597    let mut out = Vec::new();
598    let mut current: Vec<(usize, &str)> = Vec::new();
599    let mut current_comment = false;
600    let mut code: Vec<(usize, &str)> = Vec::new();
601    let mut open: Option<(char, usize)> = None;
602    let mut front =
603        syntax == Syntax::Markdown && text.lines().next().map(str::trim_end) == Some("---");
604    fn flush<'a>(out: &mut Vec<Paragraph<'a>>, lines: &mut Vec<(usize, &'a str)>, code: bool) {
605        if !lines.is_empty() {
606            out.push(Paragraph {
607                lines: std::mem::take(lines),
608                code,
609            });
610        }
611    }
612    for (i, raw) in text.lines().enumerate() {
613        let n = i + 1;
614        let t = raw.trim();
615        if let Some((c, len)) = open {
616            if fence(t)
617                .is_some_and(|(c2, len2)| c2 == c && len2 >= len && t.chars().all(|x| x == c))
618            {
619                flush(&mut out, &mut code, true);
620                open = None;
621            } else if !t.is_empty() {
622                code.push((n, t));
623            }
624            continue;
625        }
626        if front {
627            if n > 1 && t == "---" {
628                flush(&mut out, &mut current, false);
629                front = false;
630            } else if n > 1 && !t.is_empty() {
631                if key_line(t, ':') || list_item(t) {
632                    flush(&mut out, &mut current, false);
633                }
634                current.push((n, t));
635            } else if t.is_empty() {
636                flush(&mut out, &mut current, false);
637            }
638            continue;
639        }
640        if t.is_empty() {
641            flush(&mut out, &mut current, false);
642            continue;
643        }
644        if syntax == Syntax::Markdown {
645            if let Some(f) = fence(t) {
646                flush(&mut out, &mut current, false);
647                open = Some(f);
648                continue;
649            }
650            if thematic_break(t) {
651                flush(&mut out, &mut current, false);
652                continue;
653            }
654            if t.starts_with('|') || heading(t) {
655                flush(&mut out, &mut current, false);
656                out.push(Paragraph {
657                    lines: vec![(n, t)],
658                    code: false,
659                });
660                continue;
661            }
662            if list_item(t) {
663                flush(&mut out, &mut current, false);
664            }
665            current.push((n, t));
666            continue;
667        }
668        // TOML
669        if let Some(comment) = t.strip_prefix('#') {
670            let comment = comment.trim();
671            if !current_comment || list_item(comment) {
672                flush(&mut out, &mut current, false);
673            }
674            current_comment = true;
675            if !comment.is_empty() {
676                current.push((n, comment));
677            }
678            continue;
679        }
680        if t.starts_with('[') && t.ends_with(']') && !t.contains('=') && !t.contains('"') {
681            flush(&mut out, &mut current, false);
682            current_comment = false;
683            continue;
684        }
685        if current_comment || key_line(t, '=') {
686            flush(&mut out, &mut current, false);
687        }
688        current_comment = false;
689        current.push((n, t));
690    }
691    flush(&mut out, &mut current, false);
692    flush(&mut out, &mut code, true);
693    out
694}
695
696/// The paragraphs of YAML prose: each `claim:` and `note:` value, with the lines a plain or
697/// block scalar continues on — the ones indented deeper than its key — joined to it.
698fn yaml_paragraphs(text: &str) -> Vec<Paragraph<'_>> {
699    let lines: Vec<&str> = text.lines().collect();
700    let mut out = Vec::new();
701    let mut i = 0;
702    while i < lines.len() {
703        let raw = lines[i];
704        let mut t = raw.trim_start();
705        let mut key_indent = indent(raw);
706        if let Some(rest) = t.strip_prefix("- ") {
707            key_indent += 2 + indent(rest);
708            t = rest.trim_start();
709        }
710        let value = t.strip_prefix("claim:").or_else(|| t.strip_prefix("note:"));
711        let Some(value) = value else {
712            i += 1;
713            continue;
714        };
715        let value = value.trim();
716        let mut paragraph = Vec::new();
717        if !value.is_empty() && !value.chars().all(|c| matches!(c, '>' | '|' | '-' | '+')) {
718            paragraph.push((i + 1, value));
719        }
720        i += 1;
721        while i < lines.len() {
722            let next = lines[i];
723            let nt = next.trim();
724            if nt.is_empty() || nt.starts_with('#') || indent(next) <= key_indent {
725                break;
726            }
727            paragraph.push((i + 1, nt));
728            i += 1;
729        }
730        if !paragraph.is_empty() {
731            out.push(Paragraph {
732                lines: paragraph,
733                code: false,
734            });
735        }
736    }
737    out
738}
739
740/// Every sentence of a paragraph with a quantity in it, as the line the quantity starts on
741/// and the quantity.
742fn paragraph_quantities(p: &Paragraph<'_>) -> Vec<ProseQuantity> {
743    let mut joined = String::new();
744    let mut starts: Vec<(usize, usize)> = Vec::new();
745    for &(n, text) in &p.lines {
746        if !joined.is_empty() {
747            joined.push(' ');
748        }
749        starts.push((joined.len(), n));
750        joined.push_str(text);
751    }
752    sentence_spans(&joined)
753        .into_iter()
754        .filter_map(|(at, s)| quantity(s).map(|(within, q)| (at + within, q)))
755        .map(|(position, quantity)| ProseQuantity {
756            line: starts
757                .iter()
758                .rev()
759                .find(|(start, _)| *start <= position)
760                .map_or(p.lines[0].0, |&(_, n)| n),
761            quantity,
762            code: p.code,
763        })
764        .collect()
765}
766
767/// Every unsupported quantity in the text of the file at `path`, read paragraph by
768/// paragraph so that a sentence hard-wrapped over several lines is read whole, with the line
769/// each quantity starts on. How lines group is decided by the file's kind: Markdown and
770/// templates join consecutive non-blank lines, with each fenced code block, table row and
771/// heading a paragraph of its own and each list item starting a new one; TOML starts a
772/// paragraph at every key; YAML reads only the `claim:` and `note:` values, with their
773/// continuation lines. [`unsupported_quantity`] is the sentence-level test applied.
774///
775/// ```
776/// use majordomus_cli::economics::claims::prose_quantities;
777/// let text = "# Pitch\n\nWith Majordomus a session spends\n40% fewer tokens than without.\n";
778/// let found = prose_quantities("docs/PITCH.md", text);
779/// assert_eq!((found[0].line, found[0].quantity.as_str()), (4, "40%"), "the line it starts on");
780/// // list items and table rows do not run into each other
781/// let text = "- Tokens are counted\n- 40% of tests are slow\n\n| token |\n| 40% |\n";
782/// assert!(prose_quantities("README.md", text).is_empty());
783/// // a claim wrapped in YAML is one sentence too
784/// let yaml = "claims:\n  - id: x\n    claim: Majordomus saves\n      40% of tokens\n    status: planned\n";
785/// assert_eq!(prose_quantities("docs/CLAIMS.yaml", yaml)[0].line, 4);
786/// ```
787pub fn prose_quantities(path: &str, text: &str) -> Vec<ProseQuantity> {
788    let paragraphs = match syntax(path) {
789        Syntax::Yaml => yaml_paragraphs(text),
790        s => text_paragraphs(text, s),
791    };
792    paragraphs.iter().flat_map(paragraph_quantities).collect()
793}
794
795/// The paths the generation manifest lists as generated artifacts. An absent or unreadable
796/// manifest exempts nothing, which scans more rather than less.
797fn generated_artifacts(root: &Path) -> BTreeSet<String> {
798    std::fs::read_to_string(root.join(MANIFEST))
799        .ok()
800        .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
801        .and_then(|v| {
802            v.get("artifacts").and_then(|a| a.as_array()).map(|a| {
803                a.iter()
804                    .filter_map(|x| x.get("path").and_then(|p| p.as_str()).map(str::to_string))
805                    .collect()
806            })
807        })
808        .unwrap_or_default()
809}
810
811/// Whether a file is generated, decided by provenance and never by what its prose says:
812/// listed in the generation manifest, under a directory the site generator owns entirely,
813/// or stamped by its generator on its first line. A hand-written paragraph that mentions
814/// "generated by" is still hand-written.
815fn is_generated(path: &str, text: &str, manifest: &BTreeSet<String>) -> bool {
816    let first = text
817        .lines()
818        .next()
819        .unwrap_or("")
820        .trim_start_matches('\u{feff}')
821        .trim_start();
822    manifest.contains(path)
823        || GENERATED_DIRS.iter().any(|g| path.starts_with(g))
824        || STAMPS.iter().any(|s| first.starts_with(s))
825}
826
827fn scan_failed(path: &str, detail: String) -> EconomicsFinding {
828    EconomicsFinding {
829        kind: "scan_failed".into(),
830        path: path.into(),
831        line: None,
832        detail,
833    }
834}
835
836/// Read a file as text for the scan: `Ok(None)` when it is not there to claim anything or is
837/// binary, and an error for anything else, which the scan reports rather than skips.
838fn read_text(path: &Path) -> Result<Option<String>, String> {
839    match std::fs::read(path) {
840        Ok(bytes) if bytes.contains(&0) => Ok(None),
841        Ok(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
842        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
843        Err(e) => Err(e.to_string()),
844    }
845}
846
847/// Scan the repository's hand-written prose for unsupported quantities, and return how many
848/// files were read with every finding.
849///
850/// Only tracked files are read: the prose set comes from `git ls-files`. A root git cannot
851/// list is a `scan_failed` finding, not an empty and therefore clean scan; so is a tracked
852/// file that exists and cannot be read. A generated file is skipped and not counted, and
853/// "generated" is decided by provenance only: listed in `docs/generated/artifacts.json`,
854/// under `site/data/generated/` or `site/content/`, or stamped by its generator on its first
855/// line. A quantity in a fenced code block is still a finding, and its detail says it is in
856/// one. In `docs/CLAIMS.yaml`, read whether tracked or not, only `claim:` and `note:` values
857/// count. Files are read paragraph by paragraph ([`prose_quantities`]).
858///
859/// ```
860/// use majordomus_cli::economics::claims::scan_prose;
861/// use std::process::Command;
862/// let dir = tempfile::tempdir().unwrap();
863/// let root = dir.path();
864/// std::fs::create_dir_all(root.join("docs/generated")).unwrap();
865/// std::fs::write(root.join("README.md"), "Install it.\nIt saves 40% of tokens.\n").unwrap();
866/// let stamp = "<!-- generated by majordomus economics -->\n";
867/// std::fs::write(root.join("docs/generated/economics.md"), format!("{stamp}It saves 40% of tokens.\n")).unwrap();
868/// std::fs::write(root.join("docs/generated/notes.md"), "It saves 40% of tokens.\n").unwrap();
869/// let git = |a: &[&str]| {
870///     Command::new("git")
871///         .env_remove("GIT_DIR").env_remove("GIT_WORK_TREE").env_remove("GIT_INDEX_FILE")
872///         .arg("-C").arg(root).args(a).output().unwrap()
873/// };
874/// git(&["init", "-q"]);
875/// git(&["add", "-A"]);
876/// let (scanned, findings) = scan_prose(root);
877/// assert_eq!(scanned, 2, "the stamped report is not hand-written prose");
878/// let at: Vec<_> = findings.iter().map(|f| (f.path.as_str(), f.line)).collect();
879/// assert_eq!(at, [("README.md", Some(2)), ("docs/generated/notes.md", Some(1))], "no stamp, no exemption");
880///
881/// let outside = tempfile::tempdir().unwrap();
882/// let (_, findings) = scan_prose(outside.path());
883/// assert_eq!(findings[0].kind, "scan_failed", "nothing listed is not nothing found");
884/// ```
885pub fn scan_prose(root: &Path) -> (usize, Vec<EconomicsFinding>) {
886    let mut scanned = 0;
887    let mut findings = Vec::new();
888    match crate::git::ls_files_any(root, PROSE) {
889        Err(e) => findings.push(scan_failed(
890            ".",
891            format!(
892                "git could not list the tracked prose ({e}): nothing was scanned, so nothing can be said to be free of typed numbers about tokens"
893            ),
894        )),
895        Ok(files) => {
896            let manifest = generated_artifacts(root);
897            for path in files {
898                let text = match read_text(&root.join(&path)) {
899                    Ok(Some(t)) => t,
900                    Ok(None) => continue,
901                    Err(e) => {
902                        findings.push(scan_failed(&path, format!("{path} could not be read ({e}), so it was not scanned")));
903                        continue;
904                    }
905                };
906                if is_generated(&path, &text, &manifest) {
907                    continue;
908                }
909                scanned += 1;
910                for q in prose_quantities(&path, &text) {
911                    findings.push(EconomicsFinding {
912                        kind: "unsupported_quantity".into(),
913                        path: path.clone(),
914                        line: Some(q.line),
915                        detail: format!(
916                            "{} next to the economics vocabulary{}: numbers about tokens, context or cost are generated from recorded evidence (majordomus economics), never typed",
917                            q.quantity,
918                            if q.code { " in a code block" } else { "" }
919                        ),
920                    });
921                }
922            }
923        }
924    }
925    // the claim sentences of the claims matrix are prose too
926    match read_text(&root.join(CLAIMS)) {
927        Ok(None) => {}
928        Err(e) => findings.push(scan_failed(
929            CLAIMS,
930            format!("{CLAIMS} could not be read ({e}), so its claim sentences were not scanned"),
931        )),
932        Ok(Some(text)) => {
933            scanned += 1;
934            for q in prose_quantities(CLAIMS, &text) {
935                findings.push(EconomicsFinding {
936                    kind: "unsupported_quantity".into(),
937                    path: CLAIMS.into(),
938                    line: Some(q.line),
939                    detail: format!(
940                        "{} in a claim sentence next to the economics vocabulary: a claim about tokens is bound to a metric in the economics methodology instead",
941                        q.quantity
942                    ),
943                });
944            }
945        }
946    }
947    (scanned, findings)
948}
949
950/// One claim of the claims matrix, as the checks need it.
951struct Claim {
952    id: String,
953    status: String,
954    sentence: String,
955}
956
957/// The claims matrix as the checks read it: the claims in order, and the line of every
958/// `id:` in the claims list by id, for findings a person can jump to.
959struct Matrix {
960    claims: Vec<Claim>,
961    lines: BTreeMap<String, Vec<usize>>,
962}
963
964/// Read `docs/CLAIMS.yaml`: `Ok(None)` when there is no claims matrix, an error when there is
965/// one and it cannot be read or parsed.
966fn read_claims(root: &Path) -> Result<Option<Matrix>, String> {
967    let text = match std::fs::read_to_string(root.join(CLAIMS)) {
968        Ok(t) => t,
969        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
970        Err(e) => return Err(e.to_string()),
971    };
972    let map = crate::metadata::yaml::parse_mapping(&text)?;
973    let field = |c: &serde_json::Value, k: &str| {
974        c.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string()
975    };
976    let claims = match map.get("claims") {
977        None => Vec::new(),
978        Some(v) => v
979            .as_array()
980            .ok_or("`claims` is not a list")?
981            .iter()
982            .map(|c| Claim {
983                id: field(c, "id"),
984                status: field(c, "status"),
985                sentence: field(c, "claim"),
986            })
987            .collect(),
988    };
989    // Only the `id:` of a claim itself counts, at the indentation of the claims list's
990    // items: an `id:` in a list nested inside a claim is someone else's.
991    let mut lines: BTreeMap<String, Vec<usize>> = BTreeMap::new();
992    let mut inside = false;
993    let mut item: Option<usize> = None;
994    for (i, raw) in text.lines().enumerate() {
995        if !raw.starts_with([' ', '-', '#']) && !raw.trim().is_empty() {
996            inside = raw.split(" #").next().unwrap_or("").trim_end() == "claims:";
997            item = None;
998            continue;
999        }
1000        let mut t = raw.trim_start();
1001        let mut key = indent(raw);
1002        if let Some(rest) = t.strip_prefix("- ") {
1003            key += 2 + indent(rest);
1004            t = rest.trim_start();
1005            item.get_or_insert(key);
1006        }
1007        if let (true, Some(id)) = (inside && item == Some(key), t.strip_prefix("id:")) {
1008            let id = id
1009                .split(" #")
1010                .next()
1011                .unwrap_or("")
1012                .trim()
1013                .trim_matches(['\'', '"']);
1014            lines.entry(id.to_string()).or_default().push(i + 1);
1015        }
1016    }
1017    Ok(Some(Matrix { claims, lines }))
1018}
1019
1020fn claim_finding(kind: &str, line: Option<usize>, detail: String) -> EconomicsFinding {
1021    EconomicsFinding {
1022        kind: kind.into(),
1023        path: CLAIMS.into(),
1024        line,
1025        detail,
1026    }
1027}
1028
1029/// The status of a metric as its JSON spells it (`not_measured`), or `absent`.
1030fn status_name(metric: Option<&EconomicsMetric>) -> String {
1031    metric
1032        .and_then(|m| serde_json::to_value(m.status).ok())
1033        .and_then(|v| v.as_str().map(str::to_string))
1034        .unwrap_or_else(|| "absent".into())
1035}
1036
1037/// The suites a metric rests on that are not current, with their freshness; every suite
1038/// the metric names has to be in the summary and current, and a metric that names none
1039/// rests on nothing.
1040fn not_current(metric: &EconomicsMetric, summary: &EconomicsSummary) -> Vec<String> {
1041    let names: Vec<&str> = metric
1042        .suite
1043        .split(',')
1044        .map(str::trim)
1045        .filter(|s| !s.is_empty())
1046        .collect();
1047    if names.is_empty() {
1048        return vec!["no suite".into()];
1049    }
1050    names
1051        .into_iter()
1052        .filter_map(|n| match summary.suites.iter().find(|s| s.id == n) {
1053            Some(s) if s.freshness == EconomicsFreshness::Current => None,
1054            Some(s) => Some(format!(
1055                "{n} {}",
1056                serde_json::to_value(s.freshness)
1057                    .ok()
1058                    .and_then(|v| v.as_str().map(str::to_string))
1059                    .unwrap_or_default()
1060            )),
1061            None => Some(format!("{n} undeclared")),
1062        })
1063        .collect()
1064}
1065
1066/// Check the claims matrix against the methodology's bindings and `summary`, and return how
1067/// many bindings the methodology declares with every finding.
1068///
1069/// A binding whose claim `docs/CLAIMS.yaml` does not have is a finding. A claim that is not
1070/// `guaranteed` promises nothing and is left alone. A guaranteed claim stands only while its
1071/// metric does — `verified` with a publishable verdict, or `measured` (or better) — and
1072/// while every suite behind the metric is current. A guaranteed claim whose sentence says
1073/// tokens, context or cost go down and that no binding names is `claim_unbound`, whether or
1074/// not a methodology is declared; a claim id used twice is `claim_duplicate`. Declarations
1075/// that cannot be read are `declarations_unreadable`, one finding per error, and a claims
1076/// matrix that cannot be parsed is `claims_unreadable`: the check fails closed.
1077///
1078/// ```
1079/// use majordomus_cli::economics::{claims::check_claims, summarize};
1080/// let dir = tempfile::tempdir().unwrap();
1081/// let summary = summarize(dir.path(), &Default::default());
1082/// let (checked, findings) = check_claims(dir.path(), &summary);
1083/// assert_eq!(checked, 0, "no methodology, no bindings");
1084/// assert!(findings.is_empty());
1085///
1086/// // with no methodology, a guaranteed savings claim has nothing to stand on
1087/// std::fs::create_dir(dir.path().join("docs")).unwrap();
1088/// let claims = "claims:\n  - id: cheap\n    claim: Majordomus reduces token spend\n    status: guaranteed\n";
1089/// std::fs::write(dir.path().join("docs/CLAIMS.yaml"), claims).unwrap();
1090/// let (_, findings) = check_claims(dir.path(), &summary);
1091/// assert_eq!((findings[0].kind.as_str(), findings[0].line), ("claim_unbound", Some(2)));
1092/// ```
1093pub fn check_claims(root: &Path, summary: &EconomicsSummary) -> (usize, Vec<EconomicsFinding>) {
1094    let mut findings = Vec::new();
1095    let bindings = match super::load(root) {
1096        Ok(Some(decl)) => Some(decl.methodology.claims),
1097        Ok(None) => Some(Vec::new()),
1098        Err(errors) => {
1099            let errors = if errors.is_empty() {
1100                vec!["the declarations could not be read".to_string()]
1101            } else {
1102                errors
1103            };
1104            for e in errors {
1105                let path = e
1106                    .split_once(": ")
1107                    .map(|(p, _)| p)
1108                    .filter(|p| p.starts_with(super::DIR))
1109                    .unwrap_or(super::DIR)
1110                    .to_string();
1111                findings.push(EconomicsFinding {
1112                    kind: "declarations_unreadable".into(),
1113                    path,
1114                    line: None,
1115                    detail: format!("{e}: the claim bindings cannot be read, so no bound claim can be shown to stand"),
1116                });
1117            }
1118            None
1119        }
1120    };
1121    let Matrix { claims, lines } = match read_claims(root) {
1122        Ok(Some(m)) => m,
1123        Ok(None) => Matrix {
1124            claims: Vec::new(),
1125            lines: BTreeMap::new(),
1126        },
1127        Err(e) => {
1128            findings.push(claim_finding(
1129                "claims_unreadable",
1130                None,
1131                format!("{CLAIMS} could not be read ({e}), so no claim in it can be checked"),
1132            ));
1133            return (bindings.map_or(0, |b| b.len()), findings);
1134        }
1135    };
1136    let line_of = |id: &str, nth: usize| lines.get(id).and_then(|l| l.get(nth)).copied();
1137
1138    let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
1139    for c in &claims {
1140        let count = seen.entry(c.id.as_str()).or_default();
1141        *count += 1;
1142        if *count > 1 {
1143            findings.push(claim_finding(
1144                "claim_duplicate",
1145                line_of(&c.id, *count - 1),
1146                format!(
1147                    "claim id {} appears more than once in {CLAIMS}: a binding, a page and a gate would each read whichever copy they found first; give each claim its own id",
1148                    c.id
1149                ),
1150            ));
1151        }
1152    }
1153
1154    let Some(bindings) = bindings else {
1155        return (0, findings);
1156    };
1157    let guaranteed = |id: &str| {
1158        claims
1159            .iter()
1160            .any(|c| c.id == id && c.status == "guaranteed")
1161    };
1162    for b in &bindings {
1163        if !claims.iter().any(|c| c.id == b.claim) {
1164            findings.push(claim_finding(
1165                "claim_unsupported",
1166                None,
1167                format!(
1168                    "the methodology binds claim {} to {}, and {CLAIMS} has no such claim",
1169                    b.claim, b.metric
1170                ),
1171            ));
1172            continue;
1173        }
1174        if !guaranteed(&b.claim) {
1175            continue;
1176        }
1177        let metric = summary.metrics.iter().find(|m| m.id == b.metric);
1178        let stale = metric.map(|m| not_current(m, summary)).unwrap_or_default();
1179        let stands = match (b.requires.as_str(), metric.map(|m| m.status)) {
1180            ("verified", Some(EconomicsMetricStatus::Verified)) => {
1181                summary.verdict.publishable && stale.is_empty()
1182            }
1183            (
1184                "measured",
1185                Some(EconomicsMetricStatus::Measured | EconomicsMetricStatus::Verified),
1186            ) => stale.is_empty(),
1187            _ => false,
1188        };
1189        if !stands {
1190            let mut why = Vec::new();
1191            if !stale.is_empty() {
1192                why.push(format!("evidence not current: {}", stale.join(", ")));
1193            }
1194            if b.requires == "verified" && !summary.verdict.publishable {
1195                why.push("verdict not publishable".to_string());
1196            }
1197            let why = if why.is_empty() {
1198                String::new()
1199            } else {
1200                format!(" ({})", why.join("; "))
1201            };
1202            findings.push(claim_finding(
1203                "claim_unsupported",
1204                line_of(&b.claim, 0),
1205                format!(
1206                    "claim {} is guaranteed, but its metric {} is {} and must be {} and current{why}: set the claim to planned, or record the evidence",
1207                    b.claim,
1208                    b.metric,
1209                    status_name(metric),
1210                    b.requires
1211                ),
1212            ));
1213        }
1214    }
1215
1216    let mut reported = BTreeSet::new();
1217    for c in &claims {
1218        let bound = bindings.iter().any(|b| b.claim == c.id);
1219        let says_saving = sentence_spans(&c.sentence)
1220            .into_iter()
1221            .any(|(_, s)| saving_claim(s));
1222        if c.status == "guaranteed" && says_saving && !bound && reported.insert(c.id.as_str()) {
1223            findings.push(claim_finding(
1224                "claim_unbound",
1225                line_of(&c.id, 0),
1226                format!(
1227                    "claim {} is guaranteed and says tokens, context or cost go down, and no binding in {}/methodology.yaml ties it to a metric: bind it, or set it to planned",
1228                    c.id,
1229                    super::DIR
1230                ),
1231            ));
1232        }
1233    }
1234    (bindings.len(), findings)
1235}
1236
1237/// Run every check over the repository at `root`: the prose scan of [`scan_prose`], and the
1238/// claims of [`check_claims`] against a summary computed afresh by
1239/// [`crate::economics::summarize`], never a cached one. It never fails as a call: what
1240/// cannot be read is a finding, and the report is `ok` exactly when no check found anything.
1241///
1242/// ```
1243/// use majordomus_cli::economics::claims::check;
1244/// let dir = tempfile::tempdir().unwrap();
1245/// let git = std::process::Command::new("git")
1246///     .env_remove("GIT_DIR").env_remove("GIT_WORK_TREE").env_remove("GIT_INDEX_FILE")
1247///     .arg("-C").arg(dir.path()).args(["init", "-q"]).status().unwrap();
1248/// assert!(git.success());
1249/// std::fs::create_dir(dir.path().join("docs")).unwrap();
1250/// let claims = "claims:\n  - id: savings\n    claim: Majordomus saves 40% of tokens.\n";
1251/// std::fs::write(dir.path().join("docs/CLAIMS.yaml"), claims).unwrap();
1252/// let report = check(dir.path());
1253/// assert!(!report.ok);
1254/// assert_eq!(report.findings[0].kind, "unsupported_quantity");
1255/// assert_eq!(report.findings[0].line, Some(3));
1256/// ```
1257pub fn check(root: &Path) -> EconomicsCheckReport {
1258    let summary = super::summarize(root, &Default::default());
1259    let (scanned, mut findings) = scan_prose(root);
1260    let (claims_checked, more) = check_claims(root, &summary);
1261    findings.extend(more);
1262    EconomicsCheckReport {
1263        ok: findings.is_empty(),
1264        scanned,
1265        claims_checked,
1266        findings,
1267    }
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272    use super::*;
1273
1274    #[test]
1275    fn the_shapes_a_savings_claim_takes_are_all_caught() {
1276        for line in [
1277            "Majordomus saves 50% of tokens",
1278            "cuts token spend by 44.2 %",
1279            "uses 3x fewer tokens",
1280            "3 × cheaper per task",
1281            "reduces context by 60 percent",
1282            "tokens: 2 times fewer",
1283            "Majordomus reduces the amount of context loaded into each coding session by 50%.",
1284            "| context reduction | 61.4% |",
1285            "total-token use falls by 30%",
1286            "a token-saving of 30%",
1287            "cuts context by 60 per cent",
1288            "token usage fell 12 percentage points",
1289            "token usage fell 12pp",
1290            "token usage fell 12 pp.",
1291            "saves 50% of tokens",
1292            "saves **50%** of tokens",
1293            "saves __50%__ of tokens",
1294            "saves 50\u{a0}% of tokens",
1295            "halves the token bill",
1296            "context is cut to a third",
1297            "uses half the tokens, and fewer calls",
1298            "twice as many tokens are saved",
1299            "two thirds of the tokens are saved",
1300            "Majordomus cuts tokens, e.g. by 40% on bug fixes.",
1301            "the prompt's size falls 30%",
1302            "cost\u{2014}40% lower",
1303        ] {
1304            assert!(unsupported_quantity(line).is_some(), "missed: {line}");
1305        }
1306    }
1307
1308    #[test]
1309    fn a_savings_claim_needs_a_word_of_each_list_unless_one_word_says_both() {
1310        assert!(saving_claim("Majordomus saves tokens"));
1311        assert!(saving_claim(
1312            "The compiler reduces the context a session loads"
1313        ));
1314        assert!(
1315            saving_claim("Sessions are cheaper"),
1316            "cheaper is about cost already"
1317        );
1318        assert!(saving_claim("The savings are real"));
1319        assert!(
1320            !saving_claim("Majordomus saves every decision it records"),
1321            "saves alone says nothing about tokens"
1322        );
1323        assert!(
1324            !saving_claim("Context is counted in tokens"),
1325            "nothing goes down"
1326        );
1327    }
1328
1329    #[test]
1330    fn ordinary_numbers_are_not_claims() {
1331        for line in [
1332            "The briefing is capped at 60 lines of context.",
1333            "coverage must stay above 80% for the crate",
1334            "a 5x5 grid of tokens",
1335            "context/v1 documents",
1336            "I0305 plans token telemetry",
1337            "The hex digest 3x7f of the token is stable.",
1338            "there is no `0.x` hatch at all, and breaking changes cost a major",
1339            "phase       648 ms     1 x  validate:context",
1340            "The operator asked for coverage near 100 percent. It costs something.",
1341            "The other half of the context is rules.",
1342            "The token is read twice.",
1343            "a_50% token identifier",
1344            "12 ppm of tokens",
1345            "{%- if s.segments | length > 0 %} token {% endif %}",
1346            "It runs twice, without Majordomus and with it, and the provider's usage is recorded; savings are refused.",
1347            "The test reads both halves of the prompt and fails when fewer are declared.",
1348            "The third session reduces nothing about how tokens are counted.",
1349            "Tokens are counted, etc. Coverage is 80%.",
1350        ] {
1351            assert!(unsupported_quantity(line).is_none(), "false positive: {line}");
1352        }
1353    }
1354
1355    #[test]
1356    fn a_sentence_wrapped_over_lines_is_one_sentence_reported_where_its_quantity_starts() {
1357        let found = prose_quantities(
1358            "a.md",
1359            "Majordomus cuts the token\nbill by 40% on each task.\n",
1360        );
1361        assert_eq!(
1362            found,
1363            vec![ProseQuantity {
1364                line: 2,
1365                quantity: "40%".into(),
1366                code: false
1367            }]
1368        );
1369        let found = prose_quantities(
1370            "a.md",
1371            "It uses 40% fewer\nof the tokens a session would.\n",
1372        );
1373        assert_eq!(found[0].line, 1, "the quantity starts on the first line");
1374        let found = prose_quantities("a.md", "Coverage is 80%.\nTokens are counted.\n");
1375        assert!(found.is_empty(), "two sentences stay two: {found:?}");
1376        let found = prose_quantities("a.md", "Tokens are counted\n\n40% of runs are slow.\n");
1377        assert!(
1378            found.is_empty(),
1379            "a blank line ends the paragraph: {found:?}"
1380        );
1381    }
1382
1383    #[test]
1384    fn blocks_do_not_run_into_the_prose_around_them() {
1385        let text = "Tokens are counted\n```\n40% done\n```\n# Tokens\n40% of runs\n- tokens\n- 40% of runs\n";
1386        assert!(
1387            prose_quantities("a.md", text).is_empty(),
1388            "{:?}",
1389            prose_quantities("a.md", text)
1390        );
1391        let text = "Intro\n```sh\n# tokens\n# 40% fewer\n```\n";
1392        assert_eq!(
1393            prose_quantities("a.md", text),
1394            vec![ProseQuantity {
1395                line: 4,
1396                quantity: "40%".into(),
1397                code: true
1398            }],
1399            "a fenced block is a paragraph of its own"
1400        );
1401        let toml = "# Tokens are what\n# a session spends: 40% fewer.\n[hero]\ntitle = \"tokens\"\nlead = \"40% faster\"\n";
1402        let found = prose_quantities("site/data/x.toml", toml);
1403        assert_eq!(found.len(), 1, "comments join, keys do not: {found:?}");
1404        assert_eq!(found[0].line, 2);
1405        let front = "---\ntitle: tokens\nsummary: 40% faster\n---\nBody.\n";
1406        assert!(
1407            prose_quantities("a.md", front).is_empty(),
1408            "each front-matter key is its own"
1409        );
1410    }
1411
1412    #[test]
1413    fn a_generated_file_is_known_by_provenance_not_by_its_prose() {
1414        let manifest: BTreeSet<String> = ["AGENTS.md".to_string()].into();
1415        assert!(is_generated("AGENTS.md", "hand-written?", &manifest));
1416        assert!(is_generated("site/content/x.md", "", &manifest));
1417        assert!(is_generated(
1418            "docs/generated/x.md",
1419            "<!-- GENERATED FILE — DO NOT EDIT\n",
1420            &manifest
1421        ));
1422        assert!(is_generated("x.rs", "// GENERATED FILE\n", &manifest));
1423        assert!(
1424            !is_generated("docs/generated/x.md", "# Report\n", &manifest),
1425            "the directory alone says nothing"
1426        );
1427        assert!(
1428            !is_generated(
1429                "docs/NOTES.md",
1430                "# Notes\n\nThis was generated by hand.\n",
1431                &manifest
1432            ),
1433            "a prose line is not a stamp"
1434        );
1435        assert!(
1436            !is_generated(
1437                "docs/NOTES.md",
1438                "# Notes\n<!-- generated by x -->\n",
1439                &manifest
1440            ),
1441            "only the first line"
1442        );
1443    }
1444
1445    /// A methodology with no evidence recorded under it, binding three claims: one the claims
1446    /// matrix guarantees, one it plans, and one it does not carry at all.
1447    const METHODOLOGY: &str = "\
1448schema: economics-methodology/v1
1449version: 1
1450title: fixture
1451question: how many tokens
1452unit: tokens per task
1453primary_metric: effective_token_reduction
1454classes: []
1455variants: []
1456success: []
1457pairing:
1458  key: [suite, task, repetition]
1459  comparable: []
1460  valid: both runs succeeded
1461statistics:
1462  per_pair: reduction
1463  location: median
1464  interval: bootstrap
1465  confidence_bp: 9500
1466  resamples: 100
1467  seed: 1
1468  min_pairs_for_interval: 5
1469publication:
1470  min_valid_pairs: 30
1471  min_categories: 4
1472  min_pairs_per_category: 5
1473  min_repetitions: 3
1474  min_valid_pair_rate_bp: 8000
1475  max_interval_width_bp: 2000
1476  require_current: true
1477outliers:
1478  rule: no run is excluded
1479claims:
1480  - claim: guaranteed-early
1481    metric: effective_token_reduction
1482    requires: verified
1483  - claim: still-planned
1484    metric: effective_token_reduction
1485    requires: verified
1486  - claim: never-written
1487    metric: context_reduction_ratio
1488    requires: measured
1489";
1490
1491    fn git(root: &Path, args: &[&str]) {
1492        let status = std::process::Command::new("git")
1493            .env_remove("GIT_DIR")
1494            .env_remove("GIT_WORK_TREE")
1495            .env_remove("GIT_INDEX_FILE")
1496            .arg("-C")
1497            .arg(root)
1498            .args(args)
1499            .status()
1500            .unwrap();
1501        assert!(status.success(), "git {args:?}");
1502    }
1503
1504    fn repository(claims: &str) -> tempfile::TempDir {
1505        let dir = tempfile::tempdir().unwrap();
1506        let economics = dir.path().join(crate::economics::DIR);
1507        std::fs::create_dir_all(&economics).unwrap();
1508        std::fs::write(economics.join("methodology.yaml"), METHODOLOGY).unwrap();
1509        std::fs::create_dir_all(dir.path().join("docs")).unwrap();
1510        std::fs::write(dir.path().join("docs/CLAIMS.yaml"), claims).unwrap();
1511        git(dir.path(), &["init", "-q"]);
1512        dir
1513    }
1514
1515    #[test]
1516    fn a_guaranteed_claim_without_standing_evidence_is_refused() {
1517        let dir = repository(
1518            "claims:\n  - id: guaranteed-early\n    status: guaranteed\n  \
1519             - id: still-planned\n    status: planned\n",
1520        );
1521        let summary = crate::economics::summarize(dir.path(), &Default::default());
1522        assert!(
1523            summary.present,
1524            "the fixture must load: {:?}",
1525            summary.diagnostics
1526        );
1527        let (checked, findings) = check_claims(dir.path(), &summary);
1528        assert_eq!(
1529            checked, 3,
1530            "every binding is counted, whatever its claim's status"
1531        );
1532        let details: Vec<&str> = findings.iter().map(|f| f.detail.as_str()).collect();
1533        assert_eq!(findings.len(), 2, "{details:?}");
1534        assert!(findings.iter().all(|f| f.kind == "claim_unsupported"));
1535        assert!(details
1536            .iter()
1537            .any(|d| d.starts_with("claim guaranteed-early is guaranteed")));
1538        assert_eq!(
1539            findings
1540                .iter()
1541                .find(|f| f.detail.contains("guaranteed-early"))
1542                .unwrap()
1543                .line,
1544            Some(2),
1545            "the claim's own line"
1546        );
1547        assert!(details
1548            .iter()
1549            .any(|d| d.contains("claim never-written") && d.contains("has no such claim")));
1550        assert!(
1551            !details.iter().any(|d| d.contains("still-planned")),
1552            "a planned claim promises nothing and is left alone"
1553        );
1554    }
1555
1556    #[test]
1557    fn a_verified_metric_on_stale_evidence_does_not_hold_a_guarantee() {
1558        let dir = repository(
1559            "claims:\n  - id: guaranteed-early\n    status: guaranteed\n  \
1560             - id: still-planned\n    status: planned\n  - id: never-written\n    status: planned\n",
1561        );
1562        let suite = [
1563            "schema: economics-suite/v1",
1564            "id: pilot",
1565            "version: 1",
1566            "kind: live",
1567            "title: Pilot",
1568            "control: baseline",
1569            "treatment: majordomus",
1570            "freshness_inputs: [methodology.yaml]",
1571            "",
1572        ]
1573        .join("\n");
1574        let suites = dir.path().join(crate::economics::DIR).join("suites");
1575        std::fs::create_dir_all(&suites).unwrap();
1576        std::fs::write(suites.join("pilot.yaml"), suite).unwrap();
1577        let mut summary = crate::economics::summarize(dir.path(), &Default::default());
1578        assert!(summary.present, "{:?}", summary.diagnostics);
1579        // what a publishable verdict over evidence that has since gone stale would look like
1580        summary.verdict.publishable = true;
1581        for m in &mut summary.metrics {
1582            if m.id == crate::economics::EFFECTIVE_TOKEN_REDUCTION {
1583                m.status = EconomicsMetricStatus::Verified;
1584            }
1585        }
1586        let pilot = summary.suites.iter_mut().find(|s| s.id == "pilot").unwrap();
1587        pilot.freshness = EconomicsFreshness::Stale;
1588        let (_, findings) = check_claims(dir.path(), &summary);
1589        let refused = findings
1590            .iter()
1591            .find(|f| f.detail.contains("guaranteed-early"));
1592        assert!(
1593            refused.is_some_and(|f| f.detail.contains("(evidence not current: pilot stale)")),
1594            "{findings:?}"
1595        );
1596        summary
1597            .suites
1598            .iter_mut()
1599            .find(|s| s.id == "pilot")
1600            .unwrap()
1601            .freshness = EconomicsFreshness::Current;
1602        let (_, findings) = check_claims(dir.path(), &summary);
1603        assert!(
1604            findings.is_empty(),
1605            "verified, publishable and current stands: {findings:?}"
1606        );
1607    }
1608
1609    #[test]
1610    fn an_unbound_or_duplicated_savings_claim_is_refused() {
1611        let dir = repository(
1612            "claims:\n  - id: guaranteed-early\n    status: planned\n  - id: still-planned\n    status: planned\n  \
1613             - id: never-written\n    status: planned\n  \
1614             - id: cheaper-sessions\n    claim: Sessions spend fewer tokens with the compiler\n    status: guaranteed\n  \
1615             - id: counted\n    claim: Context is counted in tokens\n    status: guaranteed\n  \
1616             - id: planned-saving\n    claim: Majordomus will reduce token spend\n    status: planned\n  \
1617             - id: counted\n    claim: again\n    status: planned\n",
1618        );
1619        let summary = crate::economics::summarize(dir.path(), &Default::default());
1620        let (_, findings) = check_claims(dir.path(), &summary);
1621        let kinds: Vec<(&str, Option<usize>)> =
1622            findings.iter().map(|f| (f.kind.as_str(), f.line)).collect();
1623        assert_eq!(
1624            kinds,
1625            [("claim_duplicate", Some(17)), ("claim_unbound", Some(8))],
1626            "{findings:?}"
1627        );
1628        assert!(findings[1]
1629            .detail
1630            .starts_with("claim cheaper-sessions is guaranteed"));
1631    }
1632
1633    #[test]
1634    fn a_finding_names_the_line_of_the_claim_not_of_an_id_nested_in_it() {
1635        let dir = repository(
1636            "claims:\n  - id: guaranteed-early\n    status: planned\n    evidence:\n      - id: cheap\n  \
1637             - id: still-planned\n    status: planned\n  - id: never-written\n    status: planned\n  \
1638             - id: cheap\n    claim: Sessions spend fewer tokens\n    status: guaranteed\n",
1639        );
1640        let summary = crate::economics::summarize(dir.path(), &Default::default());
1641        let (_, findings) = check_claims(dir.path(), &summary);
1642        let kinds: Vec<(&str, Option<usize>)> =
1643            findings.iter().map(|f| (f.kind.as_str(), f.line)).collect();
1644        assert_eq!(kinds, [("claim_unbound", Some(10))], "{findings:?}");
1645    }
1646
1647    #[test]
1648    fn declarations_that_cannot_be_read_fail_closed() {
1649        let dir = repository("claims: []\n");
1650        std::fs::write(
1651            dir.path()
1652                .join(crate::economics::DIR)
1653                .join("methodology.yaml"),
1654            "schema: x\n",
1655        )
1656        .unwrap();
1657        let summary = crate::economics::summarize(dir.path(), &Default::default());
1658        let (checked, findings) = check_claims(dir.path(), &summary);
1659        assert_eq!(checked, 0);
1660        assert_eq!(findings.len(), 1, "{findings:?}");
1661        assert_eq!(findings[0].kind, "declarations_unreadable");
1662        assert_eq!(
1663            findings[0].path,
1664            format!("{}/methodology.yaml", crate::economics::DIR)
1665        );
1666
1667        std::fs::write(dir.path().join("docs/CLAIMS.yaml"), "claims:\n\tbad\n").unwrap();
1668        let (_, findings) = check_claims(dir.path(), &summary);
1669        assert!(
1670            findings.iter().any(|f| f.kind == "claims_unreadable"),
1671            "{findings:?}"
1672        );
1673    }
1674
1675    #[test]
1676    fn check_joins_every_check_and_is_ok_only_without_findings() {
1677        let dir = repository(
1678            "claims:\n  - id: guaranteed-early\n    status: planned\n  \
1679             - id: still-planned\n    status: planned\n",
1680        );
1681        let report = check(dir.path());
1682        assert_eq!((report.scanned, report.claims_checked), (1, 3));
1683        assert_eq!(
1684            report.findings.len(),
1685            1,
1686            "only the unwritten claim: {:?}",
1687            report.findings
1688        );
1689        assert!(!report.ok);
1690    }
1691
1692    #[test]
1693    fn a_repository_git_cannot_list_is_not_a_clean_one() {
1694        let dir = tempfile::tempdir().unwrap();
1695        let (scanned, findings) = scan_prose(dir.path());
1696        assert_eq!(scanned, 0);
1697        assert_eq!(findings.len(), 1);
1698        assert_eq!(
1699            (findings[0].kind.as_str(), findings[0].path.as_str()),
1700            ("scan_failed", ".")
1701        );
1702    }
1703}