Skip to main content

majordomus_cli/economics/
mod.rs

1//! Token economics, measured rather than claimed.
2//!
3//! The question is the one `.ai/repo/benchmarks/economics/methodology.yaml` states: for the
4//! same task, from the same repository state, with the same model and the same acceptance
5//! tests, how many tokens does a coding session consume with Majordomus installed and
6//! without it? This module is the one calculator that answers it. The CLI, the HTTP API,
7//! MCP, the Cockpit, the generated report and the public site are projections of
8//! [`summarize`]; none of them computes a number of its own.
9//!
10//! The data flow:
11//!
12//! ```text
13//! methodology.yaml, suites/*.yaml, tasks/*/task.yaml    declared
14//!          │
15//!   runner (live sessions) ─► runs/<suite>/<run>.json   raw facts: provider usage, gates
16//!   context (no model)     ─► runs/context/<rev>.json   raw facts: counted tokens
17//!          │
18//!   summarize ─► pairs ─► per-pair reductions ─► distributions, intervals ─► verdict
19//! ```
20//!
21//! Nothing derived is stored. A pair, a metric and a verdict are recomputed from the raw
22//! records on every read, so the records never need rewriting when the derivation changes,
23//! and a changed derivation is a methodology version, not an edit to history.
24//!
25//! What the verdict may say is decided by the methodology's publication rule, not by the
26//! caller: below it, the statement is that no verified total-token claim exists, with the
27//! preliminary observation labelled as such. The rule is evaluated for the primary metric
28//! over all the evidence and for nothing else, so only that metric, unnarrowed, can ever
29//! be `verified`.
30//!
31//! What a generated artifact publishes comes from [`summarize_tracked`], which reads only
32//! the records git tracks, so that two checkouts of one commit generate the same bytes.
33//!
34//! ```
35//! use majordomus_cli::economics::{summarize, model::EconomicsQuery};
36//! let dir = tempfile::tempdir().unwrap();
37//! let s = summarize(dir.path(), &EconomicsQuery::default());
38//! assert!(!s.present);
39//! assert!(!s.verdict.publishable);
40//! assert!(s.verdict.statement.starts_with("No verified total-token-savings claim"));
41//! ```
42
43pub mod claims;
44pub mod context;
45pub mod model;
46pub mod report;
47pub mod runner;
48pub mod stats;
49pub mod usage;
50
51use std::collections::{BTreeMap, BTreeSet};
52use std::path::{Path, PathBuf};
53
54use serde::Serialize;
55use sha2::{Digest, Sha256};
56
57use crate::metadata::yaml;
58
59use model::*;
60use stats::{bootstrap_median, bootstrap_median_clustered, distribution, percent_text, reduction};
61
62/// Where the declarations and the recorded evidence live, relative to the repository root.
63pub const DIR: &str = ".ai/repo/benchmarks/economics";
64
65/// The schema a live run record carries.
66pub const RUN_SCHEMA: &str = "economics-run/v1";
67/// The schema a context run record carries.
68pub const CONTEXT_RUN_SCHEMA: &str = "economics-context-run/v1";
69
70/// Every declaration of the economics subsystem, read from [`DIR`].
71///
72/// Only [`load`] builds one, and only when every file it names was read: a value of this
73/// type is a complete benchmark, never one with a task silently missing. Suites and tasks
74/// are keyed by their declared id, so iteration order is the id order, not the file order.
75///
76/// ```
77/// use majordomus_cli::economics::{load, Declarations};
78/// let dir = tempfile::tempdir().unwrap();
79/// # use majordomus_cli::economics::DIR;
80/// # let decl_dir = dir.path().join(DIR);
81/// # std::fs::create_dir_all(decl_dir.join("suites")).unwrap();
82/// # std::fs::write(decl_dir.join("methodology.yaml"), [
83/// #     "schema: economics-methodology/v1", "version: 1", "title: t", "question: q",
84/// #     "unit: tokens", "primary_metric: effective_token_reduction",
85/// #     "classes: []", "variants: []", "success: []",
86/// #     "pairing:", "  key: []", "  comparable: []", "  valid: v",
87/// #     "statistics:", "  per_pair: p", "  location: median", "  interval: bootstrap",
88/// #     "  confidence_bp: 9500", "  resamples: 100", "  seed: 1", "  min_pairs_for_interval: 5",
89/// #     "publication:", "  min_valid_pairs: 30", "  min_categories: 4",
90/// #     "  min_pairs_per_category: 5", "  min_repetitions: 3", "  min_valid_pair_rate_bp: 8000",
91/// #     "  max_interval_width_bp: 2000", "  require_current: true",
92/// #     "outliers:", "  rule: none", "",
93/// # ].join("\n")).unwrap();
94/// # std::fs::write(decl_dir.join("suites/pilot.yaml"), [
95/// #     "schema: economics-suite/v1", "id: pilot", "version: 1", "kind: live", "title: Pilot",
96/// #     "control: baseline", "treatment: majordomus", "freshness_inputs: [methodology.yaml]", "",
97/// # ].join("\n")).unwrap();
98/// // a methodology and one live suite `pilot` declaring no task yet
99/// let decl: Declarations = load(dir.path()).unwrap().expect("a methodology is declared");
100/// assert_eq!(decl.methodology.version, 1);
101/// assert_eq!(decl.suites.keys().collect::<Vec<_>>(), ["pilot"]);
102/// assert!(decl.tasks.is_empty());
103/// ```
104#[derive(Debug, Clone)]
105pub struct Declarations {
106    /// The methodology.
107    pub methodology: EconomicsMethodology,
108    /// The suites, by id.
109    pub suites: BTreeMap<String, EconomicsSuite>,
110    /// The tasks, by id.
111    pub tasks: BTreeMap<String, EconomicsTask>,
112}
113
114fn read_yaml<T: serde::de::DeserializeOwned>(root: &Path, path: &Path) -> Result<T, String> {
115    let shown = path
116        .strip_prefix(root)
117        .unwrap_or(path)
118        .display()
119        .to_string();
120    let text = std::fs::read_to_string(path).map_err(|e| format!("{shown}: {e}"))?;
121    yaml::parse_into(&text).map_err(|e| format!("{shown}: {e}"))
122}
123
124/// A directory's entries in canonical order, by name.
125fn sorted_entries(dir: &Path) -> Vec<PathBuf> {
126    let mut out: Vec<Entry> = std::fs::read_dir(dir)
127        .into_iter()
128        .flatten()
129        .flatten()
130        .map(|e| Entry {
131            name: e.file_name().to_string_lossy().into_owned(),
132            path: e.path(),
133        })
134        .collect();
135    crate::order::canonical(&mut out);
136    out.into_iter().map(|e| e.path).collect()
137}
138
139/// A directory entry, ordered by its name. The path is kept as the filesystem gave it, so a
140/// name that is not UTF-8 is still read from where it is; only its place in the order is
141/// taken from its lossy spelling.
142struct Entry {
143    name: String,
144    path: PathBuf,
145}
146
147impl crate::order::Ordered for Entry {
148    fn order_key(&self) -> crate::order::OrderKey<'_> {
149        crate::order::OrderKey::plain(&self.name, &self.name)
150    }
151}
152
153/// Read every declaration under `root`. `Ok(None)` when no methodology is declared, which
154/// is the ordinary state of a repository that does not benchmark itself; an error names
155/// every file that could not be read, because a benchmark with a silently missing task is
156/// a different benchmark.
157///
158/// ```
159/// use majordomus_cli::economics::{load, DIR};
160/// let dir = tempfile::tempdir().unwrap();
161/// assert!(load(dir.path()).unwrap().is_none(), "no methodology: nothing is benchmarked");
162///
163/// std::fs::create_dir_all(dir.path().join(DIR)).unwrap();
164/// std::fs::write(dir.path().join(DIR).join("methodology.yaml"), "schema: x\n").unwrap();
165/// let errors = load(dir.path()).unwrap_err();
166/// assert_eq!(errors.len(), 1);
167/// assert!(errors[0].starts_with(&format!("{DIR}/methodology.yaml: ")), "{}", errors[0]);
168/// ```
169pub fn load(root: &Path) -> Result<Option<Declarations>, Vec<String>> {
170    let dir = root.join(DIR);
171    let methodology_path = dir.join("methodology.yaml");
172    if !methodology_path.is_file() {
173        return Ok(None);
174    }
175    let mut errors = Vec::new();
176    let methodology = match read_yaml::<EconomicsMethodology>(root, &methodology_path) {
177        Ok(m) => Some(m),
178        Err(e) => {
179            errors.push(e);
180            None
181        }
182    };
183    let mut suites = BTreeMap::new();
184    for p in sorted_entries(&dir.join("suites")) {
185        if p.extension().and_then(|e| e.to_str()) != Some("yaml") {
186            continue;
187        }
188        match read_yaml::<EconomicsSuite>(root, &p) {
189            Ok(s) => {
190                suites.insert(s.id.clone(), s);
191            }
192            Err(e) => errors.push(e),
193        }
194    }
195    let mut tasks = BTreeMap::new();
196    for p in sorted_entries(&dir.join("tasks")) {
197        let file = p.join("task.yaml");
198        if !file.is_file() {
199            continue;
200        }
201        match read_yaml::<EconomicsTask>(root, &file) {
202            Ok(t) => {
203                tasks.insert(t.id.clone(), t);
204            }
205            Err(e) => errors.push(e),
206        }
207    }
208    for s in suites.values() {
209        for t in &s.tasks {
210            if !tasks.contains_key(t) {
211                errors.push(format!(
212                    "suite {} names task {t}, which is not declared",
213                    s.id
214                ));
215            }
216        }
217    }
218    match (methodology, errors.is_empty()) {
219        (Some(methodology), true) => Ok(Some(Declarations {
220            methodology,
221            suites,
222            tasks,
223        })),
224        _ => Err(errors),
225    }
226}
227
228/// A digest of the tracked files under `pathspecs`: each file's path and the SHA-256 of its
229/// content as it is on disk now, in git's index order. Recorded with every run, and
230/// recomputed on every read, it is what makes evidence current or stale.
231///
232/// ```
233/// use majordomus_cli::economics::inputs_digest;
234/// use std::process::Command;
235/// let dir = tempfile::tempdir().unwrap();
236/// let git = |a: &[&str]| Command::new("git").arg("-C").arg(dir.path()).args(a).output().unwrap();
237/// git(&["init", "-q"]);
238/// std::fs::write(dir.path().join("a.txt"), "one").unwrap();
239/// git(&["add", "-A"]);
240/// let before = inputs_digest(dir.path(), &["a.txt".to_string()]).unwrap();
241/// std::fs::write(dir.path().join("a.txt"), "two").unwrap();
242/// assert_ne!(before, inputs_digest(dir.path(), &["a.txt".to_string()]).unwrap());
243/// ```
244pub fn inputs_digest(root: &Path, pathspecs: &[String]) -> Result<String, String> {
245    let specs: Vec<&str> = pathspecs.iter().map(String::as_str).collect();
246    let files = crate::git::ls_files_any(root, &specs).map_err(|e| e.to_string())?;
247    let mut h = Sha256::new();
248    for f in &files {
249        let bytes = std::fs::read(root.join(f)).unwrap_or_default();
250        h.update(f.as_bytes());
251        h.update([0]);
252        h.update(crate::policy::sha256_bytes_hex(&bytes).as_bytes());
253        h.update(b"\n");
254    }
255    Ok(format!("{:x}", h.finalize()))
256}
257
258/// The revision evidence is recorded at: HEAD, and whether the working tree differs from it
259/// anywhere outside the evidence directory. The records a suite writes are changes to the
260/// working tree too, and a measurement must not call its own output a dirty tree.
261///
262/// ```
263/// use majordomus_cli::economics::{revision, DIR};
264/// let dir = tempfile::tempdir().unwrap();
265/// # let git = |a: &[&str]| {
266/// #     let out = std::process::Command::new("git")
267/// #         .env_remove("GIT_DIR").env_remove("GIT_WORK_TREE").env_remove("GIT_INDEX_FILE")
268/// #         .arg("-C").arg(dir.path()).args(a).output().unwrap();
269/// #     assert!(out.status.success(), "git {a:?}");
270/// #     String::from_utf8(out.stdout).unwrap()
271/// # };
272/// git(&["init", "-q"]);
273/// assert!(revision(dir.path()).is_err(), "no commit yet, so no HEAD to record against");
274/// std::fs::write(dir.path().join("a.txt"), "one").unwrap();
275/// git(&["add", "-A"]);
276/// git(&["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false",
277///     "commit", "-qm", "one"]);
278/// let clean = revision(dir.path()).unwrap();
279/// assert_eq!((clean.commit.as_str(), clean.dirty), (git(&["rev-parse", "HEAD"]).trim(), false));
280///
281/// std::fs::create_dir_all(dir.path().join(DIR).join("runs")).unwrap();
282/// std::fs::write(dir.path().join(DIR).join("runs/r1.json"), "{}").unwrap();
283/// assert!(!revision(dir.path()).unwrap().dirty, "its own records do not dirty the tree");
284/// std::fs::write(dir.path().join("a.txt"), "two").unwrap();
285/// assert!(revision(dir.path()).unwrap().dirty);
286/// ```
287pub fn revision(root: &Path) -> Result<EconomicsRevision, String> {
288    let head = crate::git::read_only(root)
289        .args(["rev-parse", "--verify", "-q", "HEAD"])
290        .output()
291        .map_err(|e| format!("git: {e}"))?;
292    if !head.status.success() {
293        return Err("git: no HEAD to record evidence against".into());
294    }
295    let status = crate::git::read_only(root)
296        .args(["status", "--porcelain", "--untracked-files=all", "--", "."])
297        .arg(format!(":(exclude){DIR}/runs"))
298        .output()
299        .map_err(|e| format!("git: {e}"))?;
300    Ok(EconomicsRevision {
301        commit: String::from_utf8_lossy(&head.stdout).trim().to_string(),
302        dirty: !String::from_utf8_lossy(&status.stdout).trim().is_empty(),
303    })
304}
305
306/// Every live run recorded under `root` for `suite`, sorted by id, with the files that
307/// could not be read named.
308///
309/// Only `*.json` files are records. A suite with no directory yet has recorded nothing,
310/// which is not an error; a record that does not parse, carries another schema, or reuses a
311/// run id is named in the second list instead of being dropped.
312///
313/// ```
314/// use majordomus_cli::economics::{load_runs, DIR};
315/// let dir = tempfile::tempdir().unwrap();
316/// let (runs, errors) = load_runs(dir.path(), "pilot");
317/// assert!(runs.is_empty() && errors.is_empty(), "nothing recorded is not an error");
318///
319/// let recorded = dir.path().join(DIR).join("runs/pilot");
320/// std::fs::create_dir_all(&recorded).unwrap();
321/// std::fs::write(recorded.join("notes.txt"), "not a record").unwrap();
322/// std::fs::write(recorded.join("r1.json"), "{").unwrap();
323/// let (runs, errors) = load_runs(dir.path(), "pilot");
324/// assert!(runs.is_empty());
325/// assert_eq!(errors.len(), 1, "the broken record is named, the text file is ignored");
326/// assert!(errors[0].starts_with(&format!("{DIR}/runs/pilot/r1.json: ")));
327/// ```
328pub fn load_runs(root: &Path, suite: &str) -> (Vec<(String, EconomicsRun)>, Vec<String>) {
329    load_runs_from(root, suite, Source::Directory)
330}
331
332/// Where run records are read from. A person asking for a summary wants every record on
333/// disk, the ones not yet committed included; a generated artifact must be the same on
334/// every checkout of one commit, so it reads only the records git tracks.
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336enum Source {
337    /// Every file in the suite's run directory.
338    Directory,
339    /// Only the files of that directory git tracks, read from the working tree.
340    Tracked,
341}
342
343/// The files of `runs/<suite>/` the source admits, in canonical order. Only the directory's
344/// own entries, never a nested one, in both sources, so the two differ only in what git
345/// tracks.
346fn record_files(root: &Path, suite: &str, source: Source) -> Result<Vec<PathBuf>, String> {
347    let dir = root.join(DIR).join("runs").join(suite);
348    match source {
349        Source::Directory => Ok(sorted_entries(&dir)),
350        Source::Tracked => {
351            let rel = format!("{DIR}/runs/{suite}");
352            let tracked = crate::git::ls_files_any(root, &[rel.as_str()])
353                .map_err(|e| format!("{rel}: the tracked records could not be listed: {e}"))?;
354            let mut own: Vec<String> = tracked
355                .into_iter()
356                .filter(|f| Path::new(f.as_str()).parent() == Some(Path::new(&rel)))
357                .collect();
358            crate::order::canonical(&mut own);
359            Ok(own.iter().map(|f| root.join(f)).collect())
360        }
361    }
362}
363
364fn load_runs_from(
365    root: &Path,
366    suite: &str,
367    source: Source,
368) -> (Vec<(String, EconomicsRun)>, Vec<String>) {
369    let mut runs = Vec::new();
370    let mut errors = Vec::new();
371    let files = match record_files(root, suite, source) {
372        Ok(files) => files,
373        Err(e) => return (runs, vec![e]),
374    };
375    for p in files {
376        if p.extension().and_then(|e| e.to_str()) != Some("json") {
377            continue;
378        }
379        let rel = p
380            .strip_prefix(root)
381            .unwrap_or(&p)
382            .to_string_lossy()
383            .to_string();
384        match std::fs::read_to_string(&p)
385            .map_err(|e| e.to_string())
386            .and_then(|t| serde_json::from_str::<EconomicsRun>(&t).map_err(|e| e.to_string()))
387        {
388            Ok(r) if r.schema == RUN_SCHEMA => runs.push((rel, r)),
389            Ok(r) => errors.push(format!("{rel}: schema {} is not {RUN_SCHEMA}", r.schema)),
390            Err(e) => errors.push(format!("{rel}: {e}")),
391        }
392    }
393    crate::order::canonical(&mut runs);
394    let mut seen = BTreeSet::new();
395    for (rel, r) in &runs {
396        if !seen.insert(r.id.clone()) {
397            errors.push(format!("{rel}: run id {} is recorded twice", r.id));
398        }
399    }
400    (runs, errors)
401}
402
403/// A run record, with the path it was read from, orders by the run's id; two records of one
404/// id — the duplicate the loader reports — by their paths, so the order is total and the
405/// record reported is the later one on every machine.
406impl crate::order::Ordered for (String, EconomicsRun) {
407    fn order_key(&self) -> crate::order::OrderKey<'_> {
408        crate::order::OrderKey::plain(&self.1.id, &self.0)
409    }
410}
411
412/// Every context measurement recorded under `root` for `suite`, oldest first.
413///
414/// The order is by `measured_at`, then commit, never by file name, so the last entry is
415/// the measurement the summary reports. A record under another schema is named in the
416/// second list rather than read as if it were this one.
417///
418/// ```
419/// use majordomus_cli::economics::{load_context_runs, CONTEXT_RUN_SCHEMA, DIR};
420/// let dir = tempfile::tempdir().unwrap();
421/// let recorded = dir.path().join(DIR).join("runs/context");
422/// std::fs::create_dir_all(&recorded).unwrap();
423/// # let record = |schema: &str, at: &str| serde_json::json!({
424/// #     "schema": schema, "suite": "context", "suite_version": 1, "methodology": 1,
425/// #     "repository": {"commit": "abc", "dirty": false}, "majordomus_version": "0.0.0",
426/// #     "tokenizer": {"encoding": "o200k_base", "implementation": "tiktoken"},
427/// #     "inputs_digest": "d", "budget_tokens": 8000, "measured_at": at, "seeds": []
428/// # }).to_string();
429/// std::fs::write(recorded.join("a.json"), record(CONTEXT_RUN_SCHEMA, "2026-09-24")).unwrap();
430/// std::fs::write(recorded.join("b.json"), record(CONTEXT_RUN_SCHEMA, "2026-09-23")).unwrap();
431/// std::fs::write(recorded.join("c.json"), record("economics-context-run/v0", "2026-09-22"))
432///     .unwrap();
433/// let (runs, errors) = load_context_runs(dir.path(), "context");
434/// let at: Vec<&str> = runs.iter().map(|r| r.measured_at.as_str()).collect();
435/// assert_eq!(at, ["2026-09-23", "2026-09-24"], "oldest first, whatever the file names");
436/// assert_eq!(errors.len(), 1, "the record of another schema is named, not read");
437/// ```
438pub fn load_context_runs(root: &Path, suite: &str) -> (Vec<EconomicsContextRun>, Vec<String>) {
439    load_context_runs_from(root, suite, Source::Directory)
440}
441
442fn load_context_runs_from(
443    root: &Path,
444    suite: &str,
445    source: Source,
446) -> (Vec<EconomicsContextRun>, Vec<String>) {
447    let mut runs = Vec::new();
448    let mut errors = Vec::new();
449    let files = match record_files(root, suite, source) {
450        Ok(files) => files,
451        Err(e) => return (runs, vec![e]),
452    };
453    for p in files {
454        if p.extension().and_then(|e| e.to_str()) != Some("json") {
455            continue;
456        }
457        let rel = p
458            .strip_prefix(root)
459            .unwrap_or(&p)
460            .to_string_lossy()
461            .to_string();
462        match std::fs::read_to_string(&p)
463            .map_err(|e| e.to_string())
464            .and_then(|t| {
465                serde_json::from_str::<EconomicsContextRun>(&t).map_err(|e| e.to_string())
466            }) {
467            Ok(r) if r.schema == CONTEXT_RUN_SCHEMA => runs.push(r),
468            Ok(r) => errors.push(format!(
469                "{rel}: schema {} is not {CONTEXT_RUN_SCHEMA}",
470                r.schema
471            )),
472            Err(e) => errors.push(format!("{rel}: {e}")),
473        }
474    }
475    crate::order::canonical(&mut runs);
476    (runs, errors)
477}
478
479// ---------------------------------------------------------------- pairing
480
481/// Judge two runs as the methodology's pairing rule does, the outlier rule aside: a missing
482/// side, then comparability, then the success gates, then usage.
483fn judge(
484    control: Option<&EconomicsRun>,
485    treatment: Option<&EconomicsRun>,
486) -> (EconomicsPairStatus, Vec<String>) {
487    let (Some(c), Some(t)) = (control, treatment) else {
488        let side = if control.is_none() {
489            "control"
490        } else {
491            "treatment"
492        };
493        return (
494            EconomicsPairStatus::Missing,
495            vec![format!("the {side} run is not recorded")],
496        );
497    };
498    let mut differ = Vec::new();
499    let mut check = |what: &str, a: &str, b: &str| {
500        if a != b {
501            differ.push(format!("{what} differs: {a} against {b}"));
502        }
503    };
504    check(
505        "suite version",
506        &c.suite_version.to_string(),
507        &t.suite_version.to_string(),
508    );
509    check("requested model", &c.model_requested, &t.model_requested);
510    // the model asked for is not always the model that answered: a harness may route part
511    // of a session elsewhere, and two runs answered by different models are not one task
512    check(
513        "reported models",
514        &c.models_reported.join(", "),
515        &t.models_reported.join(", "),
516    );
517    check("fixture digest", &c.fixture_digest, &t.fixture_digest);
518    check("harness version", &c.harness.version, &t.harness.version);
519    check(
520        "configuration digest",
521        &c.configuration_digest,
522        &t.configuration_digest,
523    );
524    check(
525        "repository revision",
526        &c.repository.commit,
527        &t.repository.commit,
528    );
529    check(
530        "session count",
531        &c.sessions.len().to_string(),
532        &t.sessions.len().to_string(),
533    );
534    if !differ.is_empty() {
535        return (EconomicsPairStatus::Incomparable, differ);
536    }
537    let failed = |r: &EconomicsRun| -> Vec<String> {
538        r.outcome
539            .checks
540            .iter()
541            .filter(|k| !k.passed)
542            .map(|k| format!("{} {}: {}", r.variant, k.id, k.detail))
543            .collect()
544    };
545    match (c.outcome.completed, t.outcome.completed) {
546        (false, false) => {
547            let mut r = failed(c);
548            r.extend(failed(t));
549            return (EconomicsPairStatus::BothFailed, r);
550        }
551        (false, true) => return (EconomicsPairStatus::ControlFailed, failed(c)),
552        (true, false) => return (EconomicsPairStatus::TreatmentFailed, failed(t)),
553        (true, true) => {}
554    }
555    let mut missing = Vec::new();
556    for r in [c, t] {
557        if usage::totals(r).is_none() {
558            missing.push(format!("{} reported no usage for a session", r.id));
559        }
560    }
561    if !missing.is_empty() {
562        return (EconomicsPairStatus::UsageUnavailable, missing);
563    }
564    (EconomicsPairStatus::Valid, Vec::new())
565}
566
567/// The pair's status, its reasons, and the status it would have without the outlier rule.
568/// A pair with both sides recorded and a side the methodology excludes is `Excluded`, with
569/// the exclusion's reasons first and whatever else was wrong with it after them.
570fn pair_status(
571    methodology: &EconomicsMethodology,
572    control: Option<&EconomicsRun>,
573    treatment: Option<&EconomicsRun>,
574) -> (EconomicsPairStatus, Vec<String>, EconomicsPairStatus) {
575    let (underlying, reasons) = judge(control, treatment);
576    if underlying == EconomicsPairStatus::Missing {
577        return (underlying, reasons, underlying);
578    }
579    let ids: Vec<&str> = [control, treatment]
580        .into_iter()
581        .flatten()
582        .map(|r| r.id.as_str())
583        .collect();
584    let mut excluded: Vec<String> = methodology
585        .outliers
586        .excluded
587        .iter()
588        .filter(|x| ids.contains(&x.run.as_str()))
589        .map(|x| format!("{} excluded: {}", x.run, x.reason))
590        .collect();
591    if excluded.is_empty() {
592        return (underlying, reasons, underlying);
593    }
594    excluded.extend(reasons);
595    (EconomicsPairStatus::Excluded, excluded, underlying)
596}
597
598fn last_session_tokens(r: &EconomicsRun) -> Option<u64> {
599    r.sessions
600        .last()
601        .and_then(usage::session_totals)
602        .map(|t| t.total)
603}
604
605/// A pair as the calculator holds it: the pair, the status beneath an exclusion, and the
606/// model its runs asked for, which a query narrows by.
607struct Judged {
608    pair: EconomicsPair,
609    /// What the pair would be without the outlier rule: `Valid` for an excluded pair that
610    /// would otherwise count, which is what the including-excluded figure is computed over.
611    underlying: EconomicsPairStatus,
612    model: String,
613}
614
615/// Form every pair a live suite declares (each task, each repetition) from the runs
616/// recorded for it, and judge each. A declared pair nobody ran is `missing`, not absent.
617///
618/// Runs recorded under another methodology version or for another suite are ignored, and a
619/// suite that does not name both a control and a treatment arm forms no pair at all.
620///
621/// ```
622/// use majordomus_cli::economics::{load, pairs};
623/// use majordomus_cli::economics::model::EconomicsPairStatus;
624/// let dir = tempfile::tempdir().unwrap();
625/// # use majordomus_cli::economics::DIR;
626/// # let decl_dir = dir.path().join(DIR);
627/// # std::fs::create_dir_all(decl_dir.join("suites")).unwrap();
628/// # std::fs::write(decl_dir.join("methodology.yaml"), [
629/// #     "schema: economics-methodology/v1", "version: 1", "title: t", "question: q",
630/// #     "unit: tokens", "primary_metric: effective_token_reduction",
631/// #     "classes: []", "variants: []", "success: []",
632/// #     "pairing:", "  key: []", "  comparable: []", "  valid: v",
633/// #     "statistics:", "  per_pair: p", "  location: median", "  interval: bootstrap",
634/// #     "  confidence_bp: 9500", "  resamples: 100", "  seed: 1", "  min_pairs_for_interval: 5",
635/// #     "publication:", "  min_valid_pairs: 30", "  min_categories: 4",
636/// #     "  min_pairs_per_category: 5", "  min_repetitions: 3", "  min_valid_pair_rate_bp: 8000",
637/// #     "  max_interval_width_bp: 2000", "  require_current: true",
638/// #     "outliers:", "  rule: none", "",
639/// # ].join("\n")).unwrap();
640/// # std::fs::write(decl_dir.join("suites/pilot.yaml"), [
641/// #     "schema: economics-suite/v1", "id: pilot", "version: 1", "kind: live", "title: Pilot",
642/// #     "control: baseline", "treatment: majordomus", "freshness_inputs: [methodology.yaml]", "",
643/// # ].join("\n")).unwrap();
644/// // a methodology and one live suite `pilot` with a control and a treatment arm
645/// let decl = load(dir.path()).unwrap().unwrap();
646/// let mut suite = decl.suites["pilot"].clone();
647/// suite.tasks = vec!["vat-rounding".into()];
648/// suite.repetitions = Some(2);
649/// let declared = pairs(&decl, &suite, &[]);
650/// assert_eq!(declared.len(), 2, "each task times each repetition, run or not");
651/// assert!(declared.iter().all(|p| p.status == EconomicsPairStatus::Missing));
652/// suite.control = None;
653/// assert!(pairs(&decl, &suite, &[]).is_empty(), "without both arms nothing is paired");
654/// ```
655pub fn pairs(
656    decl: &Declarations,
657    suite: &EconomicsSuite,
658    runs: &[EconomicsRun],
659) -> Vec<EconomicsPair> {
660    judged_pairs(decl, suite, runs)
661        .into_iter()
662        .map(|j| j.pair)
663        .collect()
664}
665
666fn judged_pairs(decl: &Declarations, suite: &EconomicsSuite, runs: &[EconomicsRun]) -> Vec<Judged> {
667    let (Some(control), Some(treatment)) = (&suite.control, &suite.treatment) else {
668        return Vec::new();
669    };
670    let current: Vec<&EconomicsRun> = runs
671        .iter()
672        .filter(|r| r.methodology == decl.methodology.version && r.suite == suite.id)
673        .collect();
674    let mut keys: BTreeSet<(String, u32)> = BTreeSet::new();
675    for t in &suite.tasks {
676        for rep in 1..=suite.repetitions.unwrap_or(1) {
677            keys.insert((t.clone(), rep));
678        }
679    }
680    for r in &current {
681        keys.insert((r.task.clone(), r.repetition));
682    }
683    let find = |task: &str, variant: &str, rep: u32| {
684        current
685            .iter()
686            .copied()
687            .find(|r| r.task == task && r.variant == variant && r.repetition == rep)
688    };
689    keys.into_iter()
690        .map(|(task, rep)| {
691            let c = find(&task, control, rep);
692            let t = find(&task, treatment, rep);
693            let (status, reasons, underlying) = pair_status(&decl.methodology, c, t);
694            let declared = decl.tasks.get(&task);
695            let cu = c.and_then(usage::totals);
696            let tu = t.and_then(usage::totals);
697            // an excluded pair that would otherwise be valid keeps its reductions, so the
698            // exclusion can be shown both ways; every metric but the including-excluded one
699            // reads valid pairs only
700            let computable = underlying == EconomicsPairStatus::Valid;
701            let both = |f: &dyn Fn(&EconomicsUsage) -> Option<u64>| -> Option<f64> {
702                if !computable {
703                    return None;
704                }
705                reduction(f(cu.as_ref()?)?, f(tu.as_ref()?)?)
706            };
707            let multi = declared.map(|d| d.sessions.len()).unwrap_or(1) > 1;
708            let continuation = if computable && multi {
709                c.zip(t)
710                    .and_then(|(c, t)| reduction(last_session_tokens(c)?, last_session_tokens(t)?))
711            } else {
712                None
713            };
714            let model = c
715                .or(t)
716                .map(|r| r.model_requested.clone())
717                .or_else(|| suite.model.clone())
718                .unwrap_or_default();
719            let pair = EconomicsPair {
720                suite: suite.id.clone(),
721                task: task.clone(),
722                category: declared
723                    .map(|d| d.category.clone())
724                    .unwrap_or_else(|| "undeclared".into()),
725                sessions: declared.map(|d| d.sessions.len()).unwrap_or(1),
726                repetition: rep,
727                control: c.map(|r| r.id.clone()),
728                treatment: t.map(|r| r.id.clone()),
729                status,
730                reasons,
731                token_reduction: both(&|u| Some(u.total)),
732                cost_reduction: both(&|u| u.cost_microusd),
733                tool_call_reduction: both(&|u| Some(u.tool_calls)),
734                continuation_reduction: continuation,
735                first_request_overhead: if computable {
736                    let a = c
737                        .and_then(|r| r.sessions.first())
738                        .and_then(usage::first_request_input);
739                    let b = t
740                        .and_then(|r| r.sessions.first())
741                        .and_then(usage::first_request_input);
742                    a.zip(b).map(|(a, b)| b as i64 - a as i64)
743                } else {
744                    None
745                },
746                control_usage: cu,
747                treatment_usage: tu,
748            };
749            Judged {
750                pair,
751                underlying,
752                model,
753            }
754        })
755        .collect()
756}
757
758// ---------------------------------------------------------------- freshness
759
760fn freshness(
761    root: &Path,
762    decl: &Declarations,
763    suite: &EconomicsSuite,
764    recorded: &[(u32, String)],
765) -> (EconomicsFreshness, Option<String>) {
766    if recorded.is_empty() {
767        return (EconomicsFreshness::NoEvidence, None);
768    }
769    let other: Vec<u32> = recorded
770        .iter()
771        .map(|(m, _)| *m)
772        .filter(|m| *m != decl.methodology.version)
773        .collect::<BTreeSet<_>>()
774        .into_iter()
775        .collect();
776    if other.len() == recorded.len() || recorded.iter().all(|(m, _)| *m != decl.methodology.version)
777    {
778        return (
779            EconomicsFreshness::Incompatible,
780            Some(format!(
781                "recorded under methodology {:?}; the current methodology is {}",
782                other, decl.methodology.version
783            )),
784        );
785    }
786    let now = match inputs_digest(root, &suite.freshness_inputs) {
787        Ok(d) => d,
788        Err(e) => {
789            return (
790                EconomicsFreshness::Stale,
791                Some(format!("the inputs could not be read: {e}")),
792            )
793        }
794    };
795    let stale = recorded
796        .iter()
797        .filter(|(m, d)| *m == decl.methodology.version && *d != now)
798        .count();
799    if stale > 0 {
800        return (
801            EconomicsFreshness::Stale,
802            Some(format!(
803                "{stale} record(s) were measured against inputs that have changed since: {}",
804                suite.freshness_inputs.join(", ")
805            )),
806        );
807    }
808    (EconomicsFreshness::Current, None)
809}
810
811// ---------------------------------------------------------------- metrics
812
813struct MetricSpec<'a> {
814    id: &'a str,
815    title: &'a str,
816    class: EconomicsClass,
817    inputs: Option<EconomicsClass>,
818    unit: &'a str,
819    formula: &'a str,
820    not: Option<&'a str>,
821}
822
823fn metric(spec: MetricSpec, suite: &str) -> EconomicsMetric {
824    EconomicsMetric {
825        id: spec.id.into(),
826        title: spec.title.into(),
827        class: spec.class,
828        inputs: spec.inputs,
829        unit: spec.unit.into(),
830        formula: spec.formula.into(),
831        not: spec.not.map(str::to_string),
832        value: None,
833        status: EconomicsMetricStatus::NotMeasured,
834        n: 0,
835        distribution: None,
836        interval: None,
837        suite: suite.into(),
838        evidence: Vec::new(),
839        warnings: Vec::new(),
840    }
841}
842
843/// The metric every headline is about.
844pub const EFFECTIVE_TOKEN_REDUCTION: &str = "effective_token_reduction";
845/// The primary metric with the outlier rule's exclusions put back: reported beside it when
846/// the methodology excludes a run, never instead of it, and never verified.
847pub const EFFECTIVE_TOKEN_REDUCTION_INCLUDING_EXCLUDED: &str =
848    "effective_token_reduction.including_excluded";
849/// Context selection, counted: never total savings.
850pub const CONTEXT_REDUCTION: &str = "context_reduction_ratio";
851/// The warning the primary metric carries when a query narrowed it.
852pub const NARROWED: &str =
853    "narrowed by the query: the publication rule is evaluated over all evidence, not over this slice";
854
855/// The warning of a metric the publication rule was never evaluated for.
856const SECONDARY: &str = "preliminary: the publication rule is evaluated for effective_token_reduction only, so this metric is a result, never a verified claim";
857
858/// The metrics whose evidence is runs rather than pairs.
859const RUN_BASED: [&str; 3] = [
860    "completion_rate.",
861    "tokens_per_completed_task.",
862    "transcript_resume_avoided.",
863];
864
865/// One sampled value: the evidence key it is listed under, and the cluster (the suite and
866/// task) whose other repetitions it is not independent of.
867struct Sample {
868    key: String,
869    cluster: String,
870    value: f64,
871}
872
873/// How an interval resamples its values.
874#[derive(Debug, Clone, Copy, PartialEq, Eq)]
875enum Resampling {
876    /// Whole tasks, because the repetitions of one task share its fixture and difficulty.
877    Tasks,
878    /// Each value on its own: the context suite's seeds, each a separate compilation.
879    Independent,
880}
881
882fn cluster_of(p: &EconomicsPair) -> String {
883    format!("{}/{}", p.suite, p.task)
884}
885
886/// The cluster-bootstrap interval of the median over samples, clustered by task.
887fn task_interval(
888    samples: &[Sample],
889    policy: &EconomicsStatisticsPolicy,
890) -> Option<EconomicsInterval> {
891    let mut clusters: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
892    for s in samples {
893        clusters
894            .entry(s.cluster.as_str())
895            .or_default()
896            .push(s.value);
897    }
898    let clusters: Vec<Vec<f64>> = clusters.into_values().collect();
899    bootstrap_median_clustered(
900        &clusters,
901        policy.confidence_bp,
902        policy.resamples,
903        policy.seed,
904        policy.min_pairs_for_interval,
905    )
906}
907
908/// Why an interval over `n` values of `tasks` distinct tasks is absent, in the words a
909/// statement uses.
910fn no_interval(n: usize, policy: &EconomicsStatisticsPolicy) -> &'static str {
911    if n < policy.min_pairs_for_interval.max(2) {
912        "no interval: too few pairs"
913    } else {
914        "no interval: too few tasks, and the repetitions of one task are not independent"
915    }
916}
917
918/// A metric's value, distribution and interval from its samples, with its status: none
919/// without a value, `verified` only when the caller says the publication rule was evaluated
920/// for exactly this metric and met, `preliminary` otherwise.
921fn sampled(
922    mut m: EconomicsMetric,
923    samples: &[Sample],
924    policy: &EconomicsStatisticsPolicy,
925    resampling: Resampling,
926    verified: bool,
927) -> EconomicsMetric {
928    let v: Vec<f64> = samples.iter().map(|s| s.value).collect();
929    m.n = v.len();
930    m.evidence = samples.iter().map(|s| s.key.clone()).collect();
931    m.distribution = distribution(&v);
932    m.value = m.distribution.as_ref().map(|d| d.median);
933    m.interval = match resampling {
934        Resampling::Tasks => task_interval(samples, policy),
935        Resampling::Independent => bootstrap_median(
936            &v,
937            policy.confidence_bp,
938            policy.resamples,
939            policy.seed,
940            policy.min_pairs_for_interval,
941        ),
942    };
943    m.status = match (m.n, verified) {
944        (0, _) => EconomicsMetricStatus::NotMeasured,
945        (_, true) => EconomicsMetricStatus::Verified,
946        _ => EconomicsMetricStatus::Preliminary,
947    };
948    if m.n > 0 && m.interval.is_none() {
949        let tasks = samples
950            .iter()
951            .map(|s| s.cluster.as_str())
952            .collect::<BTreeSet<_>>()
953            .len();
954        if resampling == Resampling::Tasks && m.n >= policy.min_pairs_for_interval.max(2) {
955            m.warnings.push(format!(
956                "{} value(s) from {tasks} task(s): too few tasks for an interval, because the repetitions of one task are not independent",
957                m.n
958            ));
959        } else {
960            m.warnings.push(format!(
961                "{} value(s): too few for an interval (the methodology computes one from {})",
962                m.n, policy.min_pairs_for_interval
963            ));
964        }
965    }
966    m
967}
968
969/// A metric the publication rule was never evaluated for says so whenever it has a value.
970fn secondary(mut m: EconomicsMetric) -> EconomicsMetric {
971    if m.n > 0 {
972        m.warnings.push(SECONDARY.into());
973    }
974    m
975}
976
977fn pair_key(p: &EconomicsPair) -> String {
978    format!("{}/{}/r{}", p.suite, p.task, p.repetition)
979}
980
981/// A non-negative quantity (a width) as a percentage, unsigned.
982fn width_text(x: f64) -> String {
983    percent_text(x).trim_start_matches(['+', '-']).to_string()
984}
985
986/// An interval bound as a percentage: a minus sign when it is negative, no plus sign.
987fn bound_text(x: f64) -> String {
988    percent_text(x).trim_start_matches('+').to_string()
989}
990
991/// How the median moved, in a sentence's words. The verb follows the sign as it prints,
992/// so a median that rounds to zero is `unchanged`, never `reduced by 0.0%`.
993fn change_words(median: f64) -> String {
994    let shown = percent_text(median);
995    if let Some(x) = shown.strip_prefix('+') {
996        format!("reduced median total token consumption by {x}")
997    } else if let Some(x) = shown.strip_prefix('-') {
998        format!("increased median total token consumption by {x}")
999    } else {
1000        "left median total token consumption unchanged".into()
1001    }
1002}
1003
1004/// The change and its interval, the interval stated in the same direction as the words: a
1005/// median increase is followed by an interval of increases, never by negative reductions.
1006fn change_with_interval(median: f64, interval: Option<&EconomicsInterval>, none: &str) -> String {
1007    let words = change_words(median);
1008    let Some(i) = interval else {
1009        return format!("{words} ({none})");
1010    };
1011    let range = if percent_text(median).starts_with('-') {
1012        format!(
1013            "an increase of {} to {}",
1014            bound_text(-i.high),
1015            bound_text(-i.low)
1016        )
1017    } else {
1018        format!(
1019            "a reduction of {} to {}",
1020            bound_text(i.low),
1021            bound_text(i.high)
1022        )
1023    };
1024    format!("{words} ({} CI: {range})", stats::level_text(i.level_bp))
1025}
1026
1027/// The least-repeated declared task and variant of every live suite.
1028struct RepetitionFloor {
1029    reps: u32,
1030    of: String,
1031}
1032
1033/// Every threshold of the publication rule the live evidence does not meet. Empty means a
1034/// quantitative total-token claim may be made.
1035fn unmet(
1036    policy: &EconomicsPublicationPolicy,
1037    valid: &[&EconomicsPair],
1038    attempted: usize,
1039    floor: Option<&RepetitionFloor>,
1040    interval: Option<&EconomicsInterval>,
1041    all_current: bool,
1042    dirty: usize,
1043) -> Vec<String> {
1044    let mut out = Vec::new();
1045    if valid.len() < policy.min_valid_pairs {
1046        out.push(format!(
1047            "{} valid pair(s); the rule asks for {}",
1048            valid.len(),
1049            policy.min_valid_pairs
1050        ));
1051    }
1052    let mut per_category: BTreeMap<&str, usize> = BTreeMap::new();
1053    for p in valid {
1054        *per_category.entry(p.category.as_str()).or_insert(0) += 1;
1055    }
1056    let categories = per_category
1057        .values()
1058        .filter(|n| **n >= policy.min_pairs_per_category)
1059        .count();
1060    if categories < policy.min_categories {
1061        out.push(format!(
1062            "{categories} categor(ies) with at least {} valid pairs; the rule asks for {}",
1063            policy.min_pairs_per_category, policy.min_categories
1064        ));
1065    }
1066    match floor {
1067        None => out.push(format!(
1068            "no declared task and variant to repeat; the rule asks for {} repetition(s) of each",
1069            policy.min_repetitions
1070        )),
1071        Some(f) if f.reps < policy.min_repetitions => out.push(format!(
1072            "{} repetition(s) of {}, the least repeated; the rule asks for {} of every task and variant",
1073            f.reps, f.of, policy.min_repetitions
1074        )),
1075        Some(_) => {}
1076    }
1077    if let Some(rate) = (valid.len() * 10_000).checked_div(attempted) {
1078        if rate < policy.min_valid_pair_rate_bp as usize {
1079            out.push(format!(
1080                "{} of {attempted} attempted pairs are valid; the rule asks for {} in 10000",
1081                valid.len(),
1082                policy.min_valid_pair_rate_bp
1083            ));
1084        }
1085    }
1086    match interval {
1087        None => out.push("no interval: too few valid pairs, or too few tasks among them".into()),
1088        Some(i) if (i.high - i.low) * 10_000.0 > policy.max_interval_width_bp as f64 => {
1089            out.push(format!(
1090                "the interval is {} wide; the rule allows {} in 10000",
1091                width_text(i.high - i.low),
1092                policy.max_interval_width_bp
1093            ))
1094        }
1095        _ => {}
1096    }
1097    if policy.require_current && !all_current {
1098        out.push(
1099            "the evidence is not current: a measured mechanism changed since it was recorded"
1100                .into(),
1101        );
1102    }
1103    if dirty > 0 {
1104        out.push(format!(
1105            "{dirty} run(s) were recorded from a working tree with uncommitted changes"
1106        ));
1107    }
1108    out
1109}
1110
1111fn matches(q: &EconomicsQuery, p: &EconomicsPair, model: &str) -> bool {
1112    q.suite.as_ref().is_none_or(|s| &p.suite == s)
1113        && q.category.as_ref().is_none_or(|c| &p.category == c)
1114        && q.task.as_ref().is_none_or(|t| &p.task == t)
1115        && q.model.as_ref().is_none_or(|m| m == model)
1116}
1117
1118/// The runs of `variant` among the sides of `pairs`, each with its pair.
1119fn runs_of<'a>(
1120    pairs: &[&'a EconomicsPair],
1121    run_of: &BTreeMap<(&str, &str), &'a EconomicsRun>,
1122    variant: &str,
1123) -> Vec<(&'a EconomicsPair, &'a EconomicsRun)> {
1124    let mut out = Vec::new();
1125    for p in pairs {
1126        for id in [&p.control, &p.treatment].into_iter().flatten() {
1127            if let Some(r) = run_of.get(&(p.suite.as_str(), id.as_str())) {
1128                if r.variant == variant {
1129                    out.push((*p, *r));
1130                }
1131            }
1132        }
1133    }
1134    out
1135}
1136
1137fn empty(statement: &str, diagnostics: Vec<String>) -> EconomicsSummary {
1138    EconomicsSummary {
1139        present: false,
1140        methodology: None,
1141        question: None,
1142        primary_metric: None,
1143        verdict: EconomicsVerdict {
1144            publishable: false,
1145            statement: statement.into(),
1146            unmet: Vec::new(),
1147        },
1148        metrics: Vec::new(),
1149        suites: Vec::new(),
1150        pairs: Vec::new(),
1151        segments: Vec::new(),
1152        context: None,
1153        history: Vec::new(),
1154        variants: Vec::new(),
1155        hypotheses: Vec::new(),
1156        publication: None,
1157        diagnostics,
1158    }
1159}
1160
1161/// The statement made when there is nothing to make one from.
1162pub const NO_CLAIM: &str = "No verified total-token-savings claim is available";
1163
1164/// The string a value serialises to, as every JSON surface spells it:
1165/// `EconomicsMetricStatus::NotMeasured` is `not_measured`. A renderer that prints an enum
1166/// uses this rather than `Debug`, so that a page and the API never spell one state two
1167/// ways. A value that does not serialise to a string is given as its JSON text.
1168///
1169/// ```
1170/// use majordomus_cli::economics::wire;
1171/// use majordomus_cli::economics::model::{EconomicsFreshness, EconomicsMetricStatus};
1172/// assert_eq!(wire(&EconomicsMetricStatus::NotMeasured), "not_measured");
1173/// assert_eq!(wire(&EconomicsFreshness::NoEvidence), "no_evidence");
1174/// assert_eq!(wire(&3), "3");
1175/// ```
1176pub fn wire<T: Serialize>(value: &T) -> String {
1177    match serde_json::to_value(value) {
1178        Ok(serde_json::Value::String(s)) => s,
1179        Ok(other) => other.to_string(),
1180        Err(e) => format!("(not serialisable: {e})"),
1181    }
1182}
1183
1184/// A context suite and what it recorded.
1185struct ContextEvidence<'a> {
1186    suite: &'a EconomicsSuite,
1187    runs: Vec<EconomicsContextRun>,
1188    freshness: EconomicsFreshness,
1189}
1190
1191/// The one calculator. Reads the declarations and every recorded run under `root`, forms
1192/// and judges the pairs, derives every metric with its class, distribution and interval,
1193/// and states the strongest thing the publication rule allows. Never fails: what cannot be
1194/// read is a diagnostic, and a repository with no methodology gets `present: false`.
1195///
1196/// Every record file on disk is read, the ones not yet committed included, so that a
1197/// person sees what they just recorded; [`summarize_tracked`] is the same calculation over
1198/// the committed records only.
1199///
1200/// ```
1201/// use majordomus_cli::economics::{summarize, model::EconomicsQuery, DIR, NO_CLAIM};
1202/// let dir = tempfile::tempdir().unwrap();
1203/// let none = summarize(dir.path(), &EconomicsQuery::default());
1204/// assert!(!none.present && none.diagnostics.is_empty(), "not benchmarking is not a fault");
1205///
1206/// std::fs::create_dir_all(dir.path().join(DIR)).unwrap();
1207/// std::fs::write(dir.path().join(DIR).join("methodology.yaml"), "schema: x\n").unwrap();
1208/// let broken = summarize(dir.path(), &EconomicsQuery::default());
1209/// assert!(!broken.present && !broken.verdict.publishable);
1210/// assert!(broken.verdict.statement.starts_with(NO_CLAIM));
1211/// assert_eq!(broken.diagnostics.len(), 1, "what cannot be read is reported, not raised");
1212/// ```
1213pub fn summarize(root: &Path, query: &EconomicsQuery) -> EconomicsSummary {
1214    summarize_from(root, query, Source::Directory)
1215}
1216
1217/// [`summarize`] over the run records git tracks and nothing else: what `majordomus
1218/// generate` publishes. A generated artifact must be the same on every checkout of one
1219/// commit, and a record someone ran locally and never committed would make it depend on
1220/// whose checkout generated it. Tracked records are read from the working tree; a record
1221/// that is on disk and untracked is not read at all, not even to report it.
1222///
1223/// ```
1224/// use majordomus_cli::economics::{summarize, summarize_tracked, DIR};
1225/// let dir = tempfile::tempdir().unwrap();
1226/// # let decl_dir = dir.path().join(DIR);
1227/// # std::fs::create_dir_all(decl_dir.join("suites")).unwrap();
1228/// # std::fs::write(decl_dir.join("methodology.yaml"), [
1229/// #     "schema: economics-methodology/v1", "version: 1", "title: t", "question: q",
1230/// #     "unit: tokens", "primary_metric: effective_token_reduction",
1231/// #     "classes: []", "variants: []", "success: []",
1232/// #     "pairing:", "  key: []", "  comparable: []", "  valid: v",
1233/// #     "statistics:", "  per_pair: p", "  location: median", "  interval: bootstrap",
1234/// #     "  confidence_bp: 9500", "  resamples: 100", "  seed: 1", "  min_pairs_for_interval: 5",
1235/// #     "publication:", "  min_valid_pairs: 30", "  min_categories: 4",
1236/// #     "  min_pairs_per_category: 5", "  min_repetitions: 3", "  min_valid_pair_rate_bp: 8000",
1237/// #     "  max_interval_width_bp: 2000", "  require_current: true",
1238/// #     "outliers:", "  rule: none", "",
1239/// # ].join("\n")).unwrap();
1240/// # std::fs::write(decl_dir.join("suites/pilot.yaml"), [
1241/// #     "schema: economics-suite/v1", "id: pilot", "version: 1", "kind: live", "title: Pilot",
1242/// #     "control: baseline", "treatment: majordomus", "freshness_inputs: [methodology.yaml]", "",
1243/// # ].join("\n")).unwrap();
1244/// # let status = std::process::Command::new("git")
1245/// #     .env_remove("GIT_DIR").env_remove("GIT_WORK_TREE").env_remove("GIT_INDEX_FILE")
1246/// #     .arg("-C").arg(dir.path()).args(["init", "-q"]).status().unwrap();
1247/// # assert!(status.success());
1248/// // a git repository declaring one live suite `pilot`; one record, never committed
1249/// let recorded = dir.path().join(DIR).join("runs/pilot");
1250/// std::fs::create_dir_all(&recorded).unwrap();
1251/// std::fs::write(recorded.join("r1.json"), "{").unwrap();
1252/// let q = Default::default();
1253/// assert_eq!(summarize(dir.path(), &q).diagnostics.len(), 1, "a person sees the local record");
1254/// assert!(summarize_tracked(dir.path(), &q).diagnostics.is_empty(), "generation does not");
1255/// ```
1256pub fn summarize_tracked(root: &Path, query: &EconomicsQuery) -> EconomicsSummary {
1257    summarize_from(root, query, Source::Tracked)
1258}
1259
1260fn summarize_from(root: &Path, query: &EconomicsQuery, source: Source) -> EconomicsSummary {
1261    let decl = match load(root) {
1262        Ok(Some(d)) => d,
1263        Ok(None) => {
1264            return empty(
1265                &format!("{NO_CLAIM}: no benchmark methodology is declared in this repository."),
1266                Vec::new(),
1267            )
1268        }
1269        Err(errors) => {
1270            return empty(
1271                &format!("{NO_CLAIM}: the benchmark declarations could not be read."),
1272                errors,
1273            )
1274        }
1275    };
1276    let m = &decl.methodology;
1277    let mut diagnostics = Vec::new();
1278    let mut suite_views = Vec::new();
1279    let mut all_pairs: Vec<Judged> = Vec::new();
1280    let mut live_runs: Vec<EconomicsRun> = Vec::new();
1281    let mut context_runs: Vec<ContextEvidence> = Vec::new();
1282    let mut all_current = true;
1283    let mut floor: Option<RepetitionFloor> = None;
1284    let mut history = Vec::new();
1285
1286    for suite in decl.suites.values() {
1287        match suite.kind.as_str() {
1288            "live" => {
1289                let (runs, errors) = load_runs_from(root, &suite.id, source);
1290                diagnostics.extend(errors);
1291                let runs: Vec<EconomicsRun> = runs.into_iter().map(|(_, r)| r).collect();
1292                let recorded: Vec<(u32, String)> = runs
1293                    .iter()
1294                    .map(|r| (r.methodology, r.inputs_digest.clone()))
1295                    .collect();
1296                let (fresh, detail) = freshness(root, &decl, suite, &recorded);
1297                if !matches!(
1298                    fresh,
1299                    EconomicsFreshness::Current | EconomicsFreshness::NoEvidence
1300                ) {
1301                    all_current = false;
1302                }
1303                let judged = judged_pairs(&decl, suite, &runs);
1304                let mut counts = EconomicsPairCounts {
1305                    declared: suite.tasks.len() * suite.repetitions.unwrap_or(1) as usize,
1306                    ..Default::default()
1307                };
1308                for p in judged.iter().map(|j| &j.pair) {
1309                    if p.control.is_some() || p.treatment.is_some() {
1310                        counts.attempted += 1;
1311                    }
1312                    match p.status {
1313                        EconomicsPairStatus::Valid => counts.valid += 1,
1314                        EconomicsPairStatus::ControlFailed => counts.control_failed += 1,
1315                        EconomicsPairStatus::TreatmentFailed => counts.treatment_failed += 1,
1316                        EconomicsPairStatus::BothFailed => counts.both_failed += 1,
1317                        _ if p.control.is_some() || p.treatment.is_some() => counts.other += 1,
1318                        _ => {}
1319                    }
1320                }
1321                // the rule asks for repetitions of every task and variant: count, for each,
1322                // the distinct repetitions that ran under this methodology (a failed run
1323                // ran), and keep the least; tasks in id order, so that a tie names the same
1324                // task however the suite lists them
1325                for task in suite.tasks.iter().collect::<BTreeSet<_>>() {
1326                    for variant in [&suite.control, &suite.treatment].into_iter().flatten() {
1327                        let reps = runs
1328                            .iter()
1329                            .filter(|r| r.methodology == m.version && r.suite == suite.id)
1330                            .filter(|r| &r.task == task && &r.variant == variant)
1331                            .map(|r| r.repetition)
1332                            .collect::<BTreeSet<_>>()
1333                            .len() as u32;
1334                        if floor.as_ref().is_none_or(|f| reps < f.reps) {
1335                            floor = Some(RepetitionFloor {
1336                                reps,
1337                                of: format!("{task} ({variant}) in suite {}", suite.id),
1338                            });
1339                        }
1340                    }
1341                }
1342                // one history point per revision the suite's valid pairs were recorded at
1343                let mut by_rev: BTreeMap<String, Vec<f64>> = BTreeMap::new();
1344                for p in judged
1345                    .iter()
1346                    .map(|j| &j.pair)
1347                    .filter(|p| p.status == EconomicsPairStatus::Valid)
1348                {
1349                    if let Some(r) = runs.iter().find(|r| Some(&r.id) == p.control.as_ref()) {
1350                        if let Some(x) = p.token_reduction {
1351                            by_rev
1352                                .entry(r.repository.commit.clone())
1353                                .or_default()
1354                                .push(x);
1355                        }
1356                    }
1357                }
1358                for (rev, v) in by_rev {
1359                    let at = runs
1360                        .iter()
1361                        .filter(|r| r.repository.commit == rev)
1362                        .map(|r| r.finished_at.clone())
1363                        .max()
1364                        .unwrap_or_default();
1365                    if let Some(d) = distribution(&v) {
1366                        history.push(EconomicsHistoryPoint {
1367                            suite: suite.id.clone(),
1368                            methodology: m.version,
1369                            revision: rev,
1370                            at,
1371                            metric: EFFECTIVE_TOKEN_REDUCTION.into(),
1372                            value: d.median,
1373                            n: d.n,
1374                        });
1375                    }
1376                }
1377                suite_views.push(EconomicsSuiteView {
1378                    id: suite.id.clone(),
1379                    kind: suite.kind.clone(),
1380                    version: suite.version,
1381                    title: suite.title.clone(),
1382                    model: suite.model.clone(),
1383                    freshness: fresh,
1384                    freshness_detail: detail,
1385                    runs: runs.len(),
1386                    pairs: counts,
1387                    revisions: runs
1388                        .iter()
1389                        .map(|r| r.repository.commit.clone())
1390                        .collect::<BTreeSet<_>>()
1391                        .into_iter()
1392                        .collect(),
1393                    harnesses: runs
1394                        .iter()
1395                        .map(|r| format!("{} {}", r.harness.name, r.harness.version))
1396                        .collect::<BTreeSet<_>>()
1397                        .into_iter()
1398                        .collect(),
1399                    models_reported: runs
1400                        .iter()
1401                        .flat_map(|r| r.models_reported.clone())
1402                        .collect::<BTreeSet<_>>()
1403                        .into_iter()
1404                        .collect(),
1405                });
1406                all_pairs.extend(judged);
1407                live_runs.extend(runs);
1408            }
1409            "context" => {
1410                let (runs, errors) = load_context_runs_from(root, &suite.id, source);
1411                diagnostics.extend(errors);
1412                let recorded: Vec<(u32, String)> = runs
1413                    .iter()
1414                    .map(|r| (r.methodology, r.inputs_digest.clone()))
1415                    .collect();
1416                let latest_only: Vec<(u32, String)> =
1417                    recorded.last().cloned().into_iter().collect();
1418                let (fresh, detail) = freshness(root, &decl, suite, &latest_only);
1419                for r in &runs {
1420                    let v: Vec<f64> = r
1421                        .seeds
1422                        .iter()
1423                        .filter_map(|s| reduction(s.candidate_tokens, s.selected_tokens))
1424                        .collect();
1425                    if let Some(d) = distribution(&v) {
1426                        history.push(EconomicsHistoryPoint {
1427                            suite: suite.id.clone(),
1428                            methodology: r.methodology,
1429                            revision: r.repository.commit.clone(),
1430                            at: r.measured_at.clone(),
1431                            metric: CONTEXT_REDUCTION.into(),
1432                            value: d.median,
1433                            n: d.n,
1434                        });
1435                    }
1436                }
1437                suite_views.push(EconomicsSuiteView {
1438                    id: suite.id.clone(),
1439                    kind: suite.kind.clone(),
1440                    version: suite.version,
1441                    title: suite.title.clone(),
1442                    model: None,
1443                    freshness: fresh,
1444                    freshness_detail: detail,
1445                    runs: runs.len(),
1446                    pairs: EconomicsPairCounts::default(),
1447                    revisions: runs
1448                        .iter()
1449                        .map(|r| r.repository.commit.clone())
1450                        .collect::<BTreeSet<_>>()
1451                        .into_iter()
1452                        .collect(),
1453                    harnesses: Vec::new(),
1454                    models_reported: Vec::new(),
1455                });
1456                context_runs.push(ContextEvidence {
1457                    suite,
1458                    runs,
1459                    freshness: fresh,
1460                });
1461            }
1462            other => diagnostics.push(format!("suite {}: unknown kind {other:?}", suite.id)),
1463        }
1464    }
1465
1466    // a run is found by its suite and id: ids are unique within a suite, not across them
1467    let mut run_of: BTreeMap<(&str, &str), &EconomicsRun> = BTreeMap::new();
1468    for r in &live_runs {
1469        run_of.entry((r.suite.as_str(), r.id.as_str())).or_insert(r);
1470    }
1471    let token_samples = |ps: &[&EconomicsPair]| -> Vec<Sample> {
1472        ps.iter()
1473            .filter_map(|p| {
1474                p.token_reduction.map(|x| Sample {
1475                    key: pair_key(p),
1476                    cluster: cluster_of(p),
1477                    value: x,
1478                })
1479            })
1480            .collect()
1481    };
1482
1483    // ------------------------------------------------ the verdict, over all live evidence
1484    let valid_all: Vec<&EconomicsPair> = all_pairs
1485        .iter()
1486        .map(|j| &j.pair)
1487        .filter(|p| p.status == EconomicsPairStatus::Valid)
1488        .collect();
1489    let attempted = all_pairs
1490        .iter()
1491        .filter(|j| j.pair.control.is_some() || j.pair.treatment.is_some())
1492        .count();
1493    let samples_all = token_samples(&valid_all);
1494    let reductions_all: Vec<f64> = samples_all.iter().map(|s| s.value).collect();
1495    let interval_all = task_interval(&samples_all, &m.statistics);
1496    let dirty = live_runs.iter().filter(|r| r.repository.dirty).count();
1497    let unmet_list = unmet(
1498        &m.publication,
1499        &valid_all,
1500        attempted,
1501        floor.as_ref(),
1502        interval_all.as_ref(),
1503        all_current,
1504        dirty,
1505    );
1506    let publishable = unmet_list.is_empty() && !valid_all.is_empty();
1507    // what the statement names is what the figure rests on: the runs of valid pairs
1508    let valid_runs: Vec<&EconomicsRun> = valid_all
1509        .iter()
1510        .flat_map(|p| {
1511            [&p.control, &p.treatment]
1512                .into_iter()
1513                .flatten()
1514                .map(move |id| (p.suite.as_str(), id.as_str()))
1515        })
1516        .filter_map(|k| run_of.get(&k).copied())
1517        .collect();
1518    let models: Vec<String> = valid_runs
1519        .iter()
1520        .flat_map(|r| r.models_reported.clone())
1521        .collect::<BTreeSet<_>>()
1522        .into_iter()
1523        .collect();
1524    let models_text = if models.is_empty() {
1525        "a model the harness did not name".to_string()
1526    } else {
1527        models.join(", ")
1528    };
1529    let revisions: BTreeSet<&str> = valid_runs
1530        .iter()
1531        .map(|r| r.repository.commit.as_str())
1532        .collect();
1533    let tasks: BTreeSet<(&str, &str)> = valid_all
1534        .iter()
1535        .map(|p| (p.suite.as_str(), p.task.as_str()))
1536        .collect();
1537    let categories: BTreeSet<&str> = valid_all.iter().map(|p| p.category.as_str()).collect();
1538    let none = no_interval(reductions_all.len(), &m.statistics);
1539    let mut statement = match (distribution(&reductions_all), publishable) {
1540        (None, _) => format!(
1541            "{NO_CLAIM}: no valid matched pair of live runs is recorded under methodology {}.",
1542            m.version
1543        ),
1544        (Some(d), true) => format!(
1545            "Across methodology {}, {} valid matched pairs across {} tasks in {} categories, at revision(s) {}, using {}, Majordomus {}, with every run passing the same acceptance tests.",
1546            m.version,
1547            d.n,
1548            tasks.len(),
1549            categories.len(),
1550            revisions.iter().map(|r| &r[..r.len().min(12)]).collect::<Vec<_>>().join(", "),
1551            models_text,
1552            change_with_interval(d.median, interval_all.as_ref(), none),
1553        ),
1554        (Some(d), false) => format!(
1555            "{NO_CLAIM}. Preliminary observation, below the publication rule: over {} valid matched pair(s) across {} task(s) in {} categor(ies), using {}, Majordomus {}.",
1556            d.n,
1557            tasks.len(),
1558            categories.len(),
1559            models_text,
1560            change_with_interval(d.median, interval_all.as_ref(), none),
1561        ),
1562    };
1563    let excluded_ids: BTreeSet<&str> = m.outliers.excluded.iter().map(|x| x.run.as_str()).collect();
1564    let excluded_runs: BTreeSet<(&str, &str)> = all_pairs
1565        .iter()
1566        .flat_map(|j| {
1567            [&j.pair.control, &j.pair.treatment]
1568                .into_iter()
1569                .flatten()
1570                .map(move |id| (j.pair.suite.as_str(), id.as_str()))
1571        })
1572        .filter(|(_, id)| excluded_ids.contains(id))
1573        .collect();
1574    if !excluded_runs.is_empty() {
1575        statement.push_str(&format!(
1576            " {} run(s) excluded by the methodology's outlier rule are left out",
1577            excluded_runs.len()
1578        ));
1579        let with_excluded: Vec<&EconomicsPair> = all_pairs
1580            .iter()
1581            .filter(|j| j.underlying == EconomicsPairStatus::Valid)
1582            .map(|j| &j.pair)
1583            .collect();
1584        let v: Vec<f64> = token_samples(&with_excluded)
1585            .into_iter()
1586            .map(|s| s.value)
1587            .collect();
1588        match distribution(&v) {
1589            Some(d) => statement.push_str(&format!(
1590                "; with them included, over {} pair(s), Majordomus {} (preliminary).",
1591                d.n,
1592                change_words(d.median)
1593            )),
1594            None => statement.push('.'),
1595        }
1596    }
1597
1598    // ------------------------------------------------ narrowed pairs and metrics
1599    let narrowed = query.suite.is_some()
1600        || query.category.is_some()
1601        || query.task.is_some()
1602        || query.model.is_some();
1603    let pairs_q: Vec<&Judged> = all_pairs
1604        .iter()
1605        .filter(|j| matches(query, &j.pair, &j.model))
1606        .collect();
1607    let valid: Vec<&EconomicsPair> = pairs_q
1608        .iter()
1609        .map(|j| &j.pair)
1610        .filter(|p| p.status == EconomicsPairStatus::Valid)
1611        .collect();
1612    let values = |f: &dyn Fn(&EconomicsPair) -> Option<f64>| -> Vec<Sample> {
1613        valid
1614            .iter()
1615            .filter_map(|p| {
1616                f(p).map(|x| Sample {
1617                    key: pair_key(p),
1618                    cluster: cluster_of(p),
1619                    value: x,
1620                })
1621            })
1622            .collect()
1623    };
1624    let live_suite = decl
1625        .suites
1626        .values()
1627        .filter(|s| s.kind == "live")
1628        .map(|s| s.id.clone())
1629        .collect::<Vec<_>>()
1630        .join(",");
1631    let mut metrics = Vec::new();
1632    // the publication rule is evaluated for this metric over all the evidence, and for
1633    // nothing else: it is verified only unnarrowed and only when the rule is met
1634    let mut primary = sampled(
1635        metric(
1636            MetricSpec {
1637                id: EFFECTIVE_TOKEN_REDUCTION,
1638                title: "Effective total-token reduction",
1639                class: EconomicsClass::Derived,
1640                inputs: Some(EconomicsClass::Observed),
1641                unit: "ratio",
1642                formula: "median over valid pairs of 1 - treatment_total_tokens / control_total_tokens, where total tokens are all input (uncached, cache write, cache read) and all output the provider reported for every session of the run",
1643                not: Some("not context reduction: this is everything the provider reported for the whole task, not what one mechanism selected"),
1644            },
1645            &live_suite,
1646        ),
1647        &values(&|p| p.token_reduction),
1648        &m.statistics,
1649        Resampling::Tasks,
1650        publishable && !narrowed,
1651    );
1652    if primary.n > 0 && !publishable {
1653        primary
1654            .warnings
1655            .push("preliminary: the evidence is below the publication rule".into());
1656    }
1657    if narrowed {
1658        primary.warnings.push(NARROWED.into());
1659    }
1660    if !valid.is_empty() {
1661        primary.warnings.push(
1662            "cache reads are counted in total tokens at full weight; the cost reduction weighs them by price".into(),
1663        );
1664    }
1665    metrics.push(primary);
1666    if !m.outliers.excluded.is_empty() {
1667        let put_back: Vec<&EconomicsPair> = pairs_q
1668            .iter()
1669            .filter(|j| j.underlying == EconomicsPairStatus::Valid)
1670            .map(|j| &j.pair)
1671            .collect();
1672        let excluded_pairs = put_back
1673            .iter()
1674            .filter(|p| p.status == EconomicsPairStatus::Excluded)
1675            .count();
1676        let mut including = secondary(sampled(
1677            metric(
1678                MetricSpec {
1679                    id: EFFECTIVE_TOKEN_REDUCTION_INCLUDING_EXCLUDED,
1680                    title: "Effective total-token reduction, excluded runs included",
1681                    class: EconomicsClass::Derived,
1682                    inputs: Some(EconomicsClass::Observed),
1683                    unit: "ratio",
1684                    formula: "the formula of effective_token_reduction, over the valid pairs and the pairs the outlier rule excluded that would otherwise be valid",
1685                    not: Some("not the primary metric: the methodology excludes these runs, and this figure is reported beside the primary one so that the exclusion's effect is visible"),
1686                },
1687                &live_suite,
1688            ),
1689            &token_samples(&put_back),
1690            &m.statistics,
1691            Resampling::Tasks,
1692            false,
1693        ));
1694        including.warnings.push(format!(
1695            "includes {excluded_pairs} pair(s) the outlier rule excluded; every other metric leaves them out"
1696        ));
1697        metrics.push(including);
1698    }
1699    let mut cost = secondary(sampled(
1700        metric(
1701            MetricSpec {
1702                id: "cost_reduction",
1703                title: "Cost reduction, as the harness priced it",
1704                class: EconomicsClass::Derived,
1705                inputs: Some(EconomicsClass::Observed),
1706                unit: "ratio",
1707                formula: "median over valid pairs of 1 - treatment_cost / control_cost, where cost is the harness's own total_cost_usd at run time",
1708                not: Some("not a bill: the harness prices usage from its own table at run time, and historical runs are never re-priced"),
1709            },
1710            &live_suite,
1711        ),
1712        &values(&|p| p.cost_reduction),
1713        &m.statistics,
1714        Resampling::Tasks,
1715        false,
1716    ));
1717    // the harnesses that priced the pairs this figure rests on, and no other
1718    let harnesses: BTreeSet<String> = valid
1719        .iter()
1720        .flat_map(|p| {
1721            [&p.control, &p.treatment]
1722                .into_iter()
1723                .flatten()
1724                .map(move |id| (p.suite.as_str(), id.as_str()))
1725        })
1726        .filter_map(|k| run_of.get(&k))
1727        .map(|r| format!("{} {}", r.harness.name, r.harness.version))
1728        .collect();
1729    if !harnesses.is_empty() {
1730        cost.warnings.push(format!(
1731            "priced by {}",
1732            harnesses.into_iter().collect::<Vec<_>>().join(", ")
1733        ));
1734    }
1735    metrics.push(cost);
1736    metrics.push(secondary(sampled(
1737        metric(
1738            MetricSpec {
1739                id: "tool_call_reduction",
1740                title: "Tool-call reduction",
1741                class: EconomicsClass::Derived,
1742                inputs: Some(EconomicsClass::Observed),
1743                unit: "ratio",
1744                formula: "median over valid pairs of 1 - treatment_tool_calls / control_tool_calls",
1745                not: Some("not a token metric"),
1746            },
1747            &live_suite,
1748        ),
1749        &values(&|p| p.tool_call_reduction),
1750        &m.statistics,
1751        Resampling::Tasks,
1752        false,
1753    )));
1754    metrics.push(secondary(sampled(
1755        metric(
1756            MetricSpec {
1757                id: "continuation_reduction",
1758                title: "Continuation-session token reduction",
1759                class: EconomicsClass::Derived,
1760                inputs: Some(EconomicsClass::Observed),
1761                unit: "ratio",
1762                formula: "median over valid multi-session pairs of 1 - the treatment's last-session total tokens / the control's",
1763                not: Some("only the session that continued the work, not the whole task"),
1764            },
1765            &live_suite,
1766        ),
1767        &values(&|p| p.continuation_reduction),
1768        &m.statistics,
1769        Resampling::Tasks,
1770        false,
1771    )));
1772    metrics.push(secondary(sampled(
1773        metric(
1774            MetricSpec {
1775                id: "first_request_overhead",
1776                title: "What the treatment adds before the model acts",
1777                class: EconomicsClass::Derived,
1778                inputs: Some(EconomicsClass::Observed),
1779                unit: "tokens",
1780                formula: "median over valid pairs of the treatment's first-request input tokens minus the control's: the instruction files and hook output a session is given before it has done anything",
1781                not: Some("not the whole overhead: what the treatment reads later because its instructions say to is in the total, not here"),
1782            },
1783            &live_suite,
1784        ),
1785        &values(&|p| p.first_request_overhead.map(|x| x as f64)),
1786        &m.statistics,
1787        Resampling::Tasks,
1788        false,
1789    )));
1790    // the run-based metrics read the same population as the pairs: the query's slice, and
1791    // only runs whose pair can be compared at all
1792    let comparable: Vec<&EconomicsPair> = pairs_q
1793        .iter()
1794        .map(|j| &j.pair)
1795        .filter(|p| {
1796            !matches!(
1797                p.status,
1798                EconomicsPairStatus::Missing
1799                    | EconomicsPairStatus::Incomparable
1800                    | EconomicsPairStatus::Excluded
1801            )
1802        })
1803        .collect();
1804    for variant in &m.variants {
1805        let recorded = runs_of(&comparable, &run_of, &variant.id);
1806        let completed = recorded.iter().filter(|(_, r)| r.outcome.completed).count();
1807        let mut rate = metric(
1808            MetricSpec {
1809                id: &format!("completion_rate.{}", variant.id),
1810                title: &format!("Completion rate, {}", variant.title),
1811                class: EconomicsClass::Derived,
1812                inputs: Some(EconomicsClass::Observed),
1813                unit: "ratio",
1814                formula: "runs that passed every success gate / runs recorded, over the runs of pairs whose two sides were both recorded, are comparable and are not excluded; a failed run counts, which is the point",
1815                not: Some("not a quality score: a run passes the gates or it does not"),
1816            },
1817            &live_suite,
1818        );
1819        if !recorded.is_empty() {
1820            rate.n = recorded.len();
1821            rate.value = Some(stats::round(completed as f64 / recorded.len() as f64));
1822            rate.status = EconomicsMetricStatus::Preliminary;
1823            rate.evidence = recorded.iter().map(|(_, r)| r.id.clone()).collect();
1824        }
1825        metrics.push(secondary(rate));
1826        let in_valid = runs_of(&valid, &run_of, &variant.id);
1827        let per_task: Vec<Sample> = in_valid
1828            .iter()
1829            .filter_map(|(p, r)| {
1830                usage::totals(r).map(|u| Sample {
1831                    key: r.id.clone(),
1832                    cluster: cluster_of(p),
1833                    value: u.total as f64,
1834                })
1835            })
1836            .collect();
1837        metrics.push(secondary(sampled(
1838            metric(
1839                MetricSpec {
1840                    id: &format!("tokens_per_completed_task.{}", variant.id),
1841                    title: &format!("Total tokens per completed task, {}", variant.title),
1842                    class: EconomicsClass::Observed,
1843                    inputs: None,
1844                    unit: "tokens",
1845                    formula: "median over the runs of valid pairs of the total tokens the provider reported for the run, so that both arms describe the same tasks",
1846                    not: None,
1847                },
1848                &live_suite,
1849            ),
1850            &per_task,
1851            &m.statistics,
1852            Resampling::Tasks,
1853            false,
1854        )));
1855        let resume: Vec<Sample> = in_valid
1856            .iter()
1857            .filter(|(_, r)| r.sessions.len() > 1)
1858            .filter_map(|(p, r)| {
1859                let prev = r.sessions.get(r.sessions.len() - 2)?;
1860                let last_input = prev.requests.last().map(|q| {
1861                    q.input_tokens + q.cache_creation_input_tokens + q.cache_read_input_tokens
1862                })?;
1863                let first = usage::first_request_input(r.sessions.last()?)?;
1864                Some(Sample {
1865                    key: r.id.clone(),
1866                    cluster: cluster_of(p),
1867                    value: last_input as f64 - first as f64,
1868                })
1869            })
1870            .collect();
1871        let mut cf = sampled(
1872            metric(
1873                MetricSpec {
1874                    id: &format!("transcript_resume_avoided.{}", variant.id),
1875                    title: &format!("Context not re-sent by starting the next session fresh, {}", variant.title),
1876                    class: EconomicsClass::Counterfactual,
1877                    inputs: Some(EconomicsClass::Observed),
1878                    unit: "tokens",
1879                    formula: "over the runs of valid pairs, the previous session's last-request input minus the next session's first-request input: what resuming the previous transcript would have re-sent at the start, against what a fresh session was given",
1880                    not: Some("not a saving of Majordomus: both arms start the next session fresh; this is the modelled cost of resuming a transcript instead"),
1881                },
1882                &live_suite,
1883            ),
1884            &resume,
1885            &m.statistics,
1886            Resampling::Tasks,
1887            false,
1888        );
1889        cf.warnings
1890            .push("counterfactual: modelled from observed values, never a measured saving".into());
1891        metrics.push(cf);
1892    }
1893
1894    // ------------------------------------------------ the context suite
1895    let mut context_view = None;
1896    for evidence in &context_runs {
1897        let suite = evidence.suite;
1898        let mut ratio = metric(
1899            MetricSpec {
1900                id: CONTEXT_REDUCTION,
1901                title: "Context reduction by the context compiler",
1902                class: EconomicsClass::Derived,
1903                inputs: Some(EconomicsClass::Counted),
1904                unit: "ratio",
1905                formula: "median over seeds of 1 - selected_tokens / candidate_tokens, where candidates are the distinct files the compiler judged relevant to the work (selected, or left out only for budget) and selected are those it put in the budget, counted with the named tokenizer; files it judged irrelevant are reported, never divided by",
1906                not: Some("not total token savings: it says what the compiler selected from what it found relevant, not what a session consumed, and a session remains free to read anything"),
1907            },
1908            &suite.id,
1909        );
1910        let Some(latest) = evidence.runs.last() else {
1911            metrics.push(ratio);
1912            continue;
1913        };
1914        // a record under another methodology measured something else: it is shown as a
1915        // record, and no value is taken from it
1916        let compatible = latest.methodology == m.version;
1917        let mut standing: Vec<String> = Vec::new();
1918        if compatible {
1919            if evidence.freshness == EconomicsFreshness::Stale {
1920                standing.push("stale: a measured mechanism changed since this record".into());
1921            }
1922            if latest.repository.dirty {
1923                standing.push(
1924                    "dirty: this record was measured from a working tree with uncommitted changes"
1925                        .into(),
1926                );
1927            }
1928        }
1929        let incompatible = format!(
1930            "incompatible: the latest record was measured under methodology {}; the current methodology is {}",
1931            latest.methodology, m.version
1932        );
1933        let refused = (!latest.refused.is_empty()).then(|| {
1934            format!(
1935                "{} seed(s) refused by the compiler were not measured",
1936                latest.refused.len()
1937            )
1938        });
1939        let per_seed: Vec<Sample> = if compatible {
1940            latest
1941                .seeds
1942                .iter()
1943                .filter_map(|s| {
1944                    reduction(s.candidate_tokens, s.selected_tokens).map(|r| Sample {
1945                        key: s.seed.clone(),
1946                        cluster: s.seed.clone(),
1947                        value: r,
1948                    })
1949                })
1950                .collect()
1951        } else {
1952            Vec::new()
1953        };
1954        ratio = sampled(
1955            ratio,
1956            &per_seed,
1957            &m.statistics,
1958            Resampling::Independent,
1959            false,
1960        );
1961        if ratio.n > 0 {
1962            ratio.status = EconomicsMetricStatus::Measured;
1963            ratio.warnings.push(format!(
1964                "tokens counted with {} ({}), which is not the tokenizer of every model",
1965                latest.tokenizer.encoding, latest.tokenizer.implementation
1966            ));
1967            ratio.warnings.push(
1968                "the candidates are what the compiler's own graph walk judged relevant; a worker without the compiler would not necessarily have read them, so this ratio describes the selection, not a session's saving".into(),
1969            );
1970            ratio.warnings.extend(standing.iter().cloned());
1971        } else if compatible {
1972            ratio
1973                .warnings
1974                .push("not measured: no seed of the latest record yields a ratio".into());
1975        } else {
1976            ratio.warnings.push(incompatible.clone());
1977        }
1978        ratio.warnings.extend(refused.iter().cloned());
1979        metrics.push(ratio);
1980        let sum =
1981            |f: &dyn Fn(&EconomicsContextSeed) -> u64| latest.seeds.iter().map(f).sum::<u64>();
1982        let mut excluded: BTreeMap<String, u64> = BTreeMap::new();
1983        for s in &latest.seeds {
1984            for (k, v) in &s.excluded_tokens {
1985                *excluded.entry(k.clone()).or_insert(0) += v;
1986            }
1987        }
1988        let selected = sum(&|s| s.selected_tokens);
1989        let estimated = sum(&|s| s.estimated_selected_tokens);
1990        let mut model_error = metric(
1991            MetricSpec {
1992                id: "context_cost_model_error",
1993                title: "Error of the compiler's bytes-over-four cost model",
1994                class: EconomicsClass::Derived,
1995                inputs: Some(EconomicsClass::Counted),
1996                unit: "ratio",
1997                formula: "counted selected tokens / the compiler's own estimate of them - 1, over all seeds",
1998                not: Some("not a saving: it says how far the budget's unit is from counted tokens"),
1999            },
2000            &suite.id,
2001        );
2002        if compatible && estimated > 0 {
2003            model_error.value = Some(stats::round(selected as f64 / estimated as f64 - 1.0));
2004            model_error.n = latest.seeds.len();
2005            model_error.status = EconomicsMetricStatus::Measured;
2006            model_error.evidence = latest.seeds.iter().map(|s| s.seed.clone()).collect();
2007            model_error.warnings.extend(standing.iter().cloned());
2008        } else if compatible {
2009            model_error
2010                .warnings
2011                .push("not measured: the latest record estimates no selected tokens".into());
2012        } else {
2013            model_error.warnings.push(incompatible);
2014        }
2015        model_error.warnings.extend(refused);
2016        metrics.push(model_error);
2017        context_view = Some(EconomicsContextView {
2018            revision: latest.repository.commit.clone(),
2019            measured_at: latest.measured_at.clone(),
2020            tokenizer: latest.tokenizer.clone(),
2021            seeds: latest.seeds.len(),
2022            candidate_tokens: sum(&|s| s.candidate_tokens),
2023            considered_tokens: sum(&|s| s.considered_tokens),
2024            selected_tokens: selected,
2025            estimated_selected_tokens: estimated,
2026            excluded_tokens: excluded,
2027            seeds_over_budget_counted: latest
2028                .seeds
2029                .iter()
2030                .filter(|s| s.selected_tokens > latest.budget_tokens)
2031                .count(),
2032            budget_tokens: latest.budget_tokens,
2033        });
2034    }
2035    if context_runs.is_empty() {
2036        metrics.push(metric(
2037            MetricSpec {
2038                id: CONTEXT_REDUCTION,
2039                title: "Context reduction by the context compiler",
2040                class: EconomicsClass::Derived,
2041                inputs: Some(EconomicsClass::Counted),
2042                unit: "ratio",
2043                formula: "median over seeds of 1 - selected_tokens / candidate_tokens",
2044                not: Some("not total token savings"),
2045            },
2046            "context",
2047        ));
2048    }
2049
2050    // ------------------------------------------------ segments
2051    let mut segments = Vec::new();
2052    for (dimension, key) in [
2053        (
2054            "category",
2055            &(|p: &EconomicsPair| p.category.clone()) as &dyn Fn(&EconomicsPair) -> String,
2056        ),
2057        ("sessions", &|p: &EconomicsPair| {
2058            format!("{} session(s)", p.sessions)
2059        }),
2060        ("complexity", &|p: &EconomicsPair| {
2061            decl.tasks
2062                .get(&p.task)
2063                .map(|t| t.complexity.clone())
2064                .unwrap_or_default()
2065        }),
2066    ] {
2067        let mut groups: BTreeMap<String, Vec<f64>> = BTreeMap::new();
2068        for p in &valid {
2069            if let Some(x) = p.token_reduction {
2070                groups.entry(key(p)).or_default().push(x);
2071            }
2072        }
2073        for (value, v) in groups {
2074            segments.push(EconomicsSegment {
2075                dimension: dimension.into(),
2076                value,
2077                n: v.len(),
2078                token_reduction: distribution(&v),
2079            });
2080        }
2081    }
2082    crate::order::canonical(&mut history);
2083
2084    EconomicsSummary {
2085        present: true,
2086        methodology: Some(m.version),
2087        question: Some(m.question.clone()),
2088        primary_metric: Some(m.primary_metric.clone()),
2089        verdict: EconomicsVerdict {
2090            publishable,
2091            statement,
2092            unmet: unmet_list,
2093        },
2094        metrics,
2095        suites: suite_views,
2096        pairs: pairs_q.iter().map(|j| j.pair.clone()).collect(),
2097        segments,
2098        context: context_view,
2099        history,
2100        variants: m.variants.clone(),
2101        hypotheses: m.hypotheses.clone(),
2102        publication: Some(m.publication.clone()),
2103        diagnostics,
2104    }
2105}
2106
2107/// Explain one metric: its value, formula and class, and every pair, run, suite and
2108/// exclusion it rests on, with the commands that reproduce it. `Err` names the metrics that
2109/// exist when `metric` is not one of them. A repository that declares no methodology has no
2110/// metric at all, and is answered with `present: false` and the statement the summary makes,
2111/// for any id: never an error, and never a number.
2112///
2113/// A metric over pairs lists the valid pairs it rests on and the invalid pairs of the same
2114/// suites, so that what was left out is in view; a metric over runs (completion rate,
2115/// tokens per completed task, transcript resume) lists every pair one of its runs belongs
2116/// to, failed ones included; a metric over context seeds lists no pair.
2117///
2118/// ```
2119/// use majordomus_cli::economics::{explain, EFFECTIVE_TOKEN_REDUCTION};
2120/// use majordomus_cli::economics::model::EconomicsMetricStatus;
2121/// let dir = tempfile::tempdir().unwrap();
2122/// # use majordomus_cli::economics::DIR;
2123/// # let decl_dir = dir.path().join(DIR);
2124/// # std::fs::create_dir_all(decl_dir.join("suites")).unwrap();
2125/// # std::fs::write(decl_dir.join("methodology.yaml"), [
2126/// #     "schema: economics-methodology/v1", "version: 1", "title: t", "question: q",
2127/// #     "unit: tokens", "primary_metric: effective_token_reduction",
2128/// #     "classes: []", "variants: []", "success: []",
2129/// #     "pairing:", "  key: []", "  comparable: []", "  valid: v",
2130/// #     "statistics:", "  per_pair: p", "  location: median", "  interval: bootstrap",
2131/// #     "  confidence_bp: 9500", "  resamples: 100", "  seed: 1", "  min_pairs_for_interval: 5",
2132/// #     "publication:", "  min_valid_pairs: 30", "  min_categories: 4",
2133/// #     "  min_pairs_per_category: 5", "  min_repetitions: 3", "  min_valid_pair_rate_bp: 8000",
2134/// #     "  max_interval_width_bp: 2000", "  require_current: true",
2135/// #     "outliers:", "  rule: none", "",
2136/// # ].join("\n")).unwrap();
2137/// # std::fs::write(decl_dir.join("suites/pilot.yaml"), [
2138/// #     "schema: economics-suite/v1", "id: pilot", "version: 1", "kind: live", "title: Pilot",
2139/// #     "control: baseline", "treatment: majordomus", "freshness_inputs: [methodology.yaml]", "",
2140/// # ].join("\n")).unwrap();
2141/// // a methodology and one live suite `pilot`, with no run recorded
2142/// let e = explain(dir.path(), EFFECTIVE_TOKEN_REDUCTION).unwrap();
2143/// assert_eq!(e.metric.unwrap().status, EconomicsMetricStatus::NotMeasured);
2144/// assert!(e.reproduce.iter().any(|c| c.starts_with("majordomus economics run --suite pilot")));
2145/// let refused = explain(dir.path(), "tokens_saved").unwrap_err();
2146/// assert!(refused.contains(EFFECTIVE_TOKEN_REDUCTION), "the refusal lists what exists");
2147///
2148/// // a repository that benchmarks nothing: an answer, not a refusal, and no number in it
2149/// let bare = tempfile::tempdir().unwrap();
2150/// let none = explain(bare.path(), EFFECTIVE_TOKEN_REDUCTION).unwrap();
2151/// assert!(!none.present && none.metric.is_none() && none.methodology.is_none());
2152/// assert!(none.statement.unwrap().contains("no benchmark methodology is declared"));
2153/// ```
2154pub fn explain(root: &Path, metric: &str) -> Result<EconomicsExplanation, String> {
2155    let summary = summarize(root, &EconomicsQuery::default());
2156    if !summary.present {
2157        return Ok(EconomicsExplanation {
2158            present: false,
2159            statement: Some(summary.verdict.statement),
2160            metric: None,
2161            class_meaning: String::new(),
2162            methodology: None,
2163            suites: Vec::new(),
2164            pairs: Vec::new(),
2165            excluded: Vec::new(),
2166            variants: Vec::new(),
2167            reproduce: Vec::new(),
2168        });
2169    }
2170    let Some(found) = summary.metrics.iter().find(|x| x.id == metric).cloned() else {
2171        let known: Vec<&str> = summary.metrics.iter().map(|x| x.id.as_str()).collect();
2172        return Err(format!(
2173            "no metric {metric:?}; the metrics are: {}",
2174            known.join(", ")
2175        ));
2176    };
2177    let decl = load(root).ok().flatten();
2178    let class_meaning = decl
2179        .as_ref()
2180        .and_then(|d| d.methodology.classes.iter().find(|c| c.id == found.class))
2181        .map(|c| c.meaning.clone())
2182        .unwrap_or_default();
2183    let evidence: BTreeSet<&str> = found.evidence.iter().map(String::as_str).collect();
2184    let of_suite = |s: &str| found.suite.split(',').any(|x| x == s);
2185    let pairs: Vec<EconomicsPair> = if found.class == EconomicsClass::Derived
2186        && found.inputs == Some(EconomicsClass::Counted)
2187    {
2188        Vec::new()
2189    } else if RUN_BASED.iter().any(|p| found.id.starts_with(p)) {
2190        summary
2191            .pairs
2192            .iter()
2193            .filter(|p| of_suite(&p.suite))
2194            .filter(|p| {
2195                [&p.control, &p.treatment]
2196                    .into_iter()
2197                    .flatten()
2198                    .any(|id| evidence.contains(id.as_str()))
2199            })
2200            .cloned()
2201            .collect()
2202    } else {
2203        summary
2204            .pairs
2205            .iter()
2206            .filter(|p| {
2207                evidence.contains(pair_key(p).as_str())
2208                    || (p.status != EconomicsPairStatus::Valid && of_suite(&p.suite))
2209            })
2210            .cloned()
2211            .collect()
2212    };
2213    let suites: Vec<EconomicsSuiteView> = summary
2214        .suites
2215        .iter()
2216        .filter(|s| of_suite(&s.id))
2217        .cloned()
2218        .collect();
2219    let reproduce = suites
2220        .iter()
2221        .map(|s| match s.kind.as_str() {
2222            "live" => format!("majordomus economics run --suite {}   # live: needs a provider credential and spends usage", s.id),
2223            _ => format!("majordomus economics measure --suite {}   # deterministic: no model is called", s.id),
2224        })
2225        .chain(std::iter::once(format!("majordomus economics explain {metric}")))
2226        .collect();
2227    Ok(EconomicsExplanation {
2228        present: true,
2229        statement: None,
2230        class_meaning,
2231        methodology: summary.methodology,
2232        suites,
2233        pairs,
2234        excluded: decl
2235            .map(|d| d.methodology.outliers.excluded)
2236            .unwrap_or_default(),
2237        variants: summary.variants.clone(),
2238        reproduce,
2239        metric: Some(found),
2240    })
2241}
2242
2243/// Every recorded live run, narrowed by the query, with its derived totals: the raw facts
2244/// behind every number, one call away.
2245///
2246/// Only live suites are read, and a query naming one suite reads no other suite's records,
2247/// so a broken record elsewhere is not reported against it.
2248///
2249/// ```
2250/// use majordomus_cli::economics::{runs, model::EconomicsRunsQuery, DIR};
2251/// let dir = tempfile::tempdir().unwrap();
2252/// assert_eq!(runs(dir.path(), &EconomicsRunsQuery::default()).count, 0);
2253/// # let decl_dir = dir.path().join(DIR);
2254/// # std::fs::create_dir_all(decl_dir.join("suites")).unwrap();
2255/// # std::fs::write(decl_dir.join("methodology.yaml"), [
2256/// #     "schema: economics-methodology/v1", "version: 1", "title: t", "question: q",
2257/// #     "unit: tokens", "primary_metric: effective_token_reduction",
2258/// #     "classes: []", "variants: []", "success: []",
2259/// #     "pairing:", "  key: []", "  comparable: []", "  valid: v",
2260/// #     "statistics:", "  per_pair: p", "  location: median", "  interval: bootstrap",
2261/// #     "  confidence_bp: 9500", "  resamples: 100", "  seed: 1", "  min_pairs_for_interval: 5",
2262/// #     "publication:", "  min_valid_pairs: 30", "  min_categories: 4",
2263/// #     "  min_pairs_per_category: 5", "  min_repetitions: 3", "  min_valid_pair_rate_bp: 8000",
2264/// #     "  max_interval_width_bp: 2000", "  require_current: true",
2265/// #     "outliers:", "  rule: none", "",
2266/// # ].join("\n")).unwrap();
2267/// # std::fs::write(decl_dir.join("suites/pilot.yaml"), [
2268/// #     "schema: economics-suite/v1", "id: pilot", "version: 1", "kind: live", "title: Pilot",
2269/// #     "control: baseline", "treatment: majordomus", "freshness_inputs: [methodology.yaml]", "",
2270/// # ].join("\n")).unwrap();
2271/// // a methodology and one live suite `pilot`, whose only record is unreadable
2272/// let recorded = dir.path().join(DIR).join("runs/pilot");
2273/// std::fs::create_dir_all(&recorded).unwrap();
2274/// std::fs::write(recorded.join("r1.json"), "{").unwrap();
2275/// let all = runs(dir.path(), &EconomicsRunsQuery::default());
2276/// assert_eq!((all.count, all.diagnostics.len()), (0, 1));
2277/// let other = EconomicsRunsQuery { suite: Some("other".into()), ..Default::default() };
2278/// assert!(runs(dir.path(), &other).diagnostics.is_empty());
2279/// ```
2280pub fn runs(root: &Path, q: &EconomicsRunsQuery) -> EconomicsRunList {
2281    let decl = match load(root) {
2282        Ok(Some(d)) => d,
2283        Ok(None) => {
2284            return EconomicsRunList {
2285                count: 0,
2286                runs: Vec::new(),
2287                diagnostics: Vec::new(),
2288            }
2289        }
2290        Err(e) => {
2291            return EconomicsRunList {
2292                count: 0,
2293                runs: Vec::new(),
2294                diagnostics: e,
2295            }
2296        }
2297    };
2298    let mut out = Vec::new();
2299    let mut diagnostics = Vec::new();
2300    for suite in decl.suites.values().filter(|s| s.kind == "live") {
2301        if q.suite.as_ref().is_some_and(|x| x != &suite.id) {
2302            continue;
2303        }
2304        let (runs, errors) = load_runs(root, &suite.id);
2305        diagnostics.extend(errors);
2306        for (path, r) in runs {
2307            if q.task.as_ref().is_some_and(|t| t != &r.task)
2308                || q.variant.as_ref().is_some_and(|v| v != &r.variant)
2309            {
2310                continue;
2311            }
2312            out.push(EconomicsRunView {
2313                usage: usage::totals(&r),
2314                id: r.id,
2315                suite: r.suite,
2316                task: r.task,
2317                variant: r.variant,
2318                repetition: r.repetition,
2319                model: r.model_requested,
2320                revision: r.repository.commit,
2321                completed: r.outcome.completed,
2322                checks: r.outcome.checks,
2323                sessions: r.sessions.len(),
2324                path,
2325            });
2326        }
2327    }
2328    crate::order::canonical(&mut out);
2329    EconomicsRunList {
2330        count: out.len(),
2331        runs: out,
2332        diagnostics,
2333    }
2334}