Skip to main content

majordomus_cli/economics/
model.rs

1//! The typed records of the economics subsystem: the declarations it reads (methodology,
2//! suites, tasks), the raw facts it records (runs), and the answer it derives (summary).
3//!
4//! Raw facts and derived answers are different types on purpose. A run holds what the
5//! provider reported and what the success gates found, and nothing computed from them; a
6//! pair, a metric and a summary are recomputed from runs on every read, so a change to how
7//! a number is derived never needs the runs rewritten.
8//!
9//! Every public type is prefixed `Economics`: the OpenAPI document keeps one flat namespace
10//! of schema names, and `Token*` and `Evidence*` already belong to other subsystems.
11//!
12//! ```
13//! use majordomus_cli::economics::model::EconomicsClass;
14//! assert!(EconomicsClass::Observed.is_measurement());
15//! assert!(!EconomicsClass::Counterfactual.is_measurement());
16//! assert_eq!(EconomicsClass::Derived.weakest(EconomicsClass::Estimated), EconomicsClass::Estimated);
17//! ```
18
19use std::collections::BTreeMap;
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24// ---------------------------------------------------------------- measurement classes
25
26/// How a number came to be. The classes are ordered from strongest to weakest, and a value
27/// computed from several inputs is never stronger than the weakest of them.
28///
29/// ```
30/// use majordomus_cli::economics::model::EconomicsClass;
31/// assert_eq!(serde_json::to_value(EconomicsClass::Counterfactual).unwrap(), "counterfactual");
32/// let parsed: EconomicsClass = serde_json::from_value("observed".into()).unwrap();
33/// assert!(parsed < EconomicsClass::Estimated, "a stronger class sorts first");
34/// ```
35#[derive(
36    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
37)]
38#[serde(rename_all = "snake_case")]
39pub enum EconomicsClass {
40    /// Returned by the provider or its harness at run time and recorded unmodified.
41    Observed,
42    /// Computed deterministically from bytes on disk with a named, pinned tokenizer.
43    Counted,
44    /// Arithmetic over observed or counted values.
45    Derived,
46    /// A number standing in for one nobody measured. Never compared, never published.
47    Estimated,
48    /// What a mechanism would have avoided, modelled rather than observed.
49    Counterfactual,
50}
51
52impl EconomicsClass {
53    /// The weaker of two classes: what a value computed from both may call itself.
54    ///
55    /// ```
56    /// use majordomus_cli::economics::model::EconomicsClass::*;
57    /// assert_eq!(Observed.weakest(Counted), Counted);
58    /// assert_eq!(Counterfactual.weakest(Observed), Counterfactual);
59    /// ```
60    pub fn weakest(self, other: EconomicsClass) -> EconomicsClass {
61        self.max(other)
62    }
63
64    /// Whether a value of this class is a measurement a comparison may rest on:
65    /// observed, counted, or derived from those. Estimates and counterfactuals are not.
66    ///
67    /// ```
68    /// use majordomus_cli::economics::model::EconomicsClass;
69    /// assert!(EconomicsClass::Counted.is_measurement());
70    /// assert!(!EconomicsClass::Estimated.is_measurement());
71    /// ```
72    pub fn is_measurement(self) -> bool {
73        matches!(
74            self,
75            EconomicsClass::Observed | EconomicsClass::Counted | EconomicsClass::Derived
76        )
77    }
78
79    /// The word a surface prints for this class.
80    ///
81    /// ```
82    /// assert_eq!(majordomus_cli::economics::model::EconomicsClass::Counted.word(), "counted");
83    /// ```
84    pub fn word(self) -> &'static str {
85        match self {
86            EconomicsClass::Observed => "observed",
87            EconomicsClass::Counted => "counted",
88            EconomicsClass::Derived => "derived",
89            EconomicsClass::Estimated => "estimated",
90            EconomicsClass::Counterfactual => "counterfactual",
91        }
92    }
93}
94
95// ---------------------------------------------------------------- declarations
96
97/// One measurement class as the methodology defines it.
98///
99/// ```
100/// use majordomus_cli::economics::model::{EconomicsClass, EconomicsClassDefinition};
101/// let d: EconomicsClassDefinition = serde_json::from_value(serde_json::json!({
102///     "id": "counted",
103///     "meaning": "Computed deterministically from bytes on disk with a pinned tokenizer."
104/// }))
105/// .unwrap();
106/// assert_eq!(d.id, EconomicsClass::Counted);
107/// let unknown = serde_json::json!({ "id": "guessed", "meaning": "A class nobody declared." });
108/// assert!(serde_json::from_value::<EconomicsClassDefinition>(unknown).is_err());
109/// ```
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
111pub struct EconomicsClassDefinition {
112    /// The class.
113    pub id: EconomicsClass,
114    /// What it means, in the methodology's words.
115    pub meaning: String,
116}
117
118/// One arm of the experiment: what the session runs with, and what it runs without.
119///
120/// ```
121/// use majordomus_cli::economics::model::EconomicsVariant;
122/// let v: EconomicsVariant = serde_json::from_value(serde_json::json!({
123///     "id": "baseline", "role": "control", "title": "Without Majordomus",
124///     "description": "The fixture repository as a competent team keeps it."
125/// }))
126/// .unwrap();
127/// assert_eq!(v.role, "control");
128/// assert!(v.excludes.is_empty(), "an arm that names no exclusion excludes nothing");
129/// ```
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
131pub struct EconomicsVariant {
132    /// `baseline`, `majordomus`, ...
133    pub id: String,
134    /// `control` or `treatment`.
135    pub role: String,
136    /// A short name.
137    pub title: String,
138    /// Exactly what the arm consists of.
139    pub description: String,
140    /// What the arm deliberately does not include.
141    #[serde(default)]
142    pub excludes: Vec<String>,
143}
144
145/// One gate a run must pass to count as a success.
146///
147/// ```
148/// use majordomus_cli::economics::model::EconomicsGateDefinition;
149/// let g: EconomicsGateDefinition = serde_json::from_value(serde_json::json!({
150///     "id": "tests_kept", "meaning": "No test file the fixture started with was deleted."
151/// }))
152/// .unwrap();
153/// assert_eq!(g.id, "tests_kept");
154/// let unexplained = serde_json::json!({ "id": "visible_tests" });
155/// assert!(serde_json::from_value::<EconomicsGateDefinition>(unexplained).is_err());
156/// ```
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
158pub struct EconomicsGateDefinition {
159    /// `visible_tests`, `acceptance_tests`, `tests_kept`.
160    pub id: String,
161    /// What passing it means.
162    pub meaning: String,
163}
164
165/// How runs are paired and when a pair is comparable and valid.
166///
167/// ```
168/// use majordomus_cli::economics::model::EconomicsPairing;
169/// let p: EconomicsPairing = serde_json::from_value(serde_json::json!({
170///     "key": ["suite", "task", "repetition"],
171///     "comparable": ["methodology version", "requested model", "fixture digest"],
172///     "valid": "Both runs succeeded and both report provider usage."
173/// }))
174/// .unwrap();
175/// assert!(p.key.iter().any(|k| k == "repetition"), "a repetition pairs with its own number");
176/// ```
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
178pub struct EconomicsPairing {
179    /// The fields two runs share to be one pair.
180    pub key: Vec<String>,
181    /// The fields two runs must agree on to be compared at all.
182    pub comparable: Vec<String>,
183    /// When a comparable pair is valid.
184    pub valid: String,
185}
186
187/// How per-pair results are aggregated into a headline value and its interval. Every
188/// parameter of the bootstrap is declared here, its generator seed included, so the same
189/// runs always give the same interval.
190///
191/// ```
192/// use majordomus_cli::economics::model::EconomicsStatisticsPolicy;
193/// let s: EconomicsStatisticsPolicy = serde_json::from_value(serde_json::json!({
194///     "per_pair": "1 - treatment / control on total tokens", "location": "median",
195///     "interval": "bootstrap percentile", "confidence_bp": 9500, "resamples": 10000,
196///     "seed": 20260924, "min_pairs_for_interval": 5
197/// }))
198/// .unwrap();
199/// assert_eq!(f64::from(s.confidence_bp) / 100.0, 95.0, "basis points, not percent");
200/// assert_eq!(serde_json::to_value(&s).unwrap()["seed"], 20260924);
201/// ```
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
203pub struct EconomicsStatisticsPolicy {
204    /// The per-pair formula, in words.
205    pub per_pair: String,
206    /// The location statistic reported as the headline.
207    pub location: String,
208    /// How the interval is computed.
209    pub interval: String,
210    /// Confidence level in basis points: 9500 is 95 %.
211    pub confidence_bp: u32,
212    /// Bootstrap resamples.
213    pub resamples: u32,
214    /// The seed of the bootstrap's generator, so that the interval is reproducible.
215    pub seed: u64,
216    /// Below this many valid pairs no interval is computed at all.
217    pub min_pairs_for_interval: usize,
218}
219
220/// When a quantitative total-token claim may be published. Every threshold must hold.
221///
222/// ```
223/// use majordomus_cli::economics::model::EconomicsPublicationPolicy;
224/// let rule = serde_json::json!({
225///     "min_valid_pairs": 30, "min_categories": 4, "min_pairs_per_category": 5,
226///     "min_repetitions": 3, "min_valid_pair_rate_bp": 8000, "max_interval_width_bp": 2000,
227///     "require_current": true
228/// });
229/// let p: EconomicsPublicationPolicy = serde_json::from_value(rule.clone()).unwrap();
230/// assert!(p.require_current && p.min_valid_pairs == 30);
231/// let mut lax = rule;
232/// lax.as_object_mut().unwrap().remove("require_current");
233/// assert!(serde_json::from_value::<EconomicsPublicationPolicy>(lax).is_err(), "no default");
234/// ```
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
236pub struct EconomicsPublicationPolicy {
237    /// Valid matched pairs, at least.
238    pub min_valid_pairs: usize,
239    /// Task categories with enough pairs, at least.
240    pub min_categories: usize,
241    /// Pairs a category needs to count towards `min_categories`.
242    pub min_pairs_per_category: usize,
243    /// Repetitions of every task and variant, at least.
244    pub min_repetitions: u32,
245    /// Valid pairs over attempted pairs, in basis points, at least.
246    pub min_valid_pair_rate_bp: u32,
247    /// Width of the interval, in basis points of reduction, at most.
248    pub max_interval_width_bp: u32,
249    /// Whether the evidence must be current (its mechanism unchanged since it was recorded).
250    pub require_current: bool,
251}
252
253/// The rule for excluding runs, and the runs it excluded.
254///
255/// ```
256/// use majordomus_cli::economics::model::EconomicsOutlierPolicy;
257/// let o: EconomicsOutlierPolicy =
258///     serde_json::from_value(serde_json::json!({ "rule": "No run is excluded." })).unwrap();
259/// assert!(o.excluded.is_empty(), "a rule with no list excludes nothing");
260/// ```
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
262pub struct EconomicsOutlierPolicy {
263    /// The rule, stated before any analysis.
264    pub rule: String,
265    /// Every excluded run, with its reason.
266    #[serde(default)]
267    pub excluded: Vec<EconomicsExclusion>,
268}
269
270/// One run excluded under the outlier rule.
271///
272/// ```
273/// use majordomus_cli::economics::model::{EconomicsExclusion, EconomicsOutlierPolicy};
274/// let o: EconomicsOutlierPolicy = serde_json::from_value(serde_json::json!({
275///     "rule": "A run is excluded only by naming it here with a reason.",
276///     "excluded": [{ "run": "vat-rounding--baseline--r2", "reason": "provider outage" }]
277/// }))
278/// .unwrap();
279/// let first: &EconomicsExclusion = &o.excluded[0];
280/// assert_eq!(first.run, "vat-rounding--baseline--r2");
281/// let unexplained = serde_json::json!({ "run": "vat-rounding--baseline--r1" });
282/// assert!(serde_json::from_value::<EconomicsExclusion>(unexplained).is_err());
283/// ```
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
285pub struct EconomicsExclusion {
286    /// The run's id.
287    pub run: String,
288    /// Why.
289    pub reason: String,
290}
291
292/// A statement the benchmark exists to test. Never a result.
293///
294/// ```
295/// use majordomus_cli::economics::model::EconomicsHypothesis;
296/// let h: EconomicsHypothesis = serde_json::from_value(serde_json::json!({
297///     "id": "continuity",
298///     "statement": "Work that spans sessions gains the most.",
299///     "status": "untested"
300/// }))
301/// .unwrap();
302/// assert_eq!(h.status, "untested");
303/// assert_eq!(serde_json::to_value(&h).unwrap()["id"], "continuity");
304/// ```
305#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
306pub struct EconomicsHypothesis {
307    /// A slug.
308    pub id: String,
309    /// What is hypothesised.
310    pub statement: String,
311    /// `untested`, `supported`, `refuted` — set by a person reading the evidence.
312    pub status: String,
313}
314
315/// A published claim bound to the metric that must support it.
316///
317/// ```
318/// use majordomus_cli::economics::model::EconomicsClaimBinding;
319/// let b: EconomicsClaimBinding = serde_json::from_value(serde_json::json!({
320///     "claim": "token-savings-measured",
321///     "metric": "effective_token_reduction",
322///     "requires": "verified"
323/// }))
324/// .unwrap();
325/// assert_eq!((b.metric.as_str(), b.requires.as_str()), ("effective_token_reduction", "verified"));
326/// ```
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
328pub struct EconomicsClaimBinding {
329    /// The claim's id in `docs/CLAIMS.yaml`.
330    pub claim: String,
331    /// The metric's id.
332    pub metric: String,
333    /// `verified` (sampled evidence meeting the publication rule) or `measured`
334    /// (deterministic evidence, current).
335    pub requires: String,
336}
337
338/// One entry of the methodology's change log.
339///
340/// ```
341/// use majordomus_cli::economics::model::EconomicsMethodologyChange;
342/// let first = serde_json::json!({ "version": 1, "why": "The first methodology." });
343/// let c: EconomicsMethodologyChange = serde_json::from_value(first).unwrap();
344/// assert_eq!(c.version, 1);
345/// let silent = serde_json::json!({ "version": 2 });
346/// let refused = serde_json::from_value::<EconomicsMethodologyChange>(silent);
347/// assert!(refused.is_err(), "a change says why");
348/// ```
349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
350pub struct EconomicsMethodologyChange {
351    /// The version it introduced.
352    pub version: u32,
353    /// Why.
354    pub why: String,
355}
356
357/// `.ai/repo/benchmarks/economics/methodology.yaml`: what is measured, how, and when a
358/// result may be published.
359///
360/// ```
361/// use majordomus_cli::economics::model::EconomicsMethodology;
362/// let m: EconomicsMethodology = serde_json::from_value(serde_json::json!({
363///     "schema": "economics-methodology/v1", "version": 1, "title": "Token economics",
364///     "question": "How many tokens does a session consume with and without Majordomus?",
365///     "unit": "tokens per completed task", "primary_metric": "effective_token_reduction",
366///     "classes": [], "variants": [], "success": [],
367///     "pairing": { "key": ["task"], "comparable": [], "valid": "both succeeded" },
368///     "statistics": { "per_pair": "1 - t / c", "location": "median", "interval": "bootstrap",
369///         "confidence_bp": 9500, "resamples": 1000, "seed": 1, "min_pairs_for_interval": 5 },
370///     "publication": { "min_valid_pairs": 30, "min_categories": 4, "min_pairs_per_category": 5,
371///         "min_repetitions": 3, "min_valid_pair_rate_bp": 8000, "max_interval_width_bp": 2000,
372///         "require_current": true },
373///     "outliers": { "rule": "No run is excluded." }
374/// }))
375/// .unwrap();
376/// assert_eq!(m.statistics.confidence_bp, 9500);
377/// assert!(m.hypotheses.is_empty() && m.claims.is_empty() && m.changes.is_empty());
378/// ```
379#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
380pub struct EconomicsMethodology {
381    /// `economics-methodology/v1`.
382    pub schema: String,
383    /// The methodology version. Evidence under another version is never pooled.
384    pub version: u32,
385    /// A name.
386    pub title: String,
387    /// The question the benchmark answers.
388    pub question: String,
389    /// The unit results are stated in.
390    pub unit: String,
391    /// The metric a headline is about.
392    pub primary_metric: String,
393    /// The measurement classes.
394    pub classes: Vec<EconomicsClassDefinition>,
395    /// The arms of the experiment.
396    pub variants: Vec<EconomicsVariant>,
397    /// The success gates.
398    pub success: Vec<EconomicsGateDefinition>,
399    /// Pairing.
400    pub pairing: EconomicsPairing,
401    /// Aggregation.
402    pub statistics: EconomicsStatisticsPolicy,
403    /// The publication threshold.
404    pub publication: EconomicsPublicationPolicy,
405    /// Outliers.
406    pub outliers: EconomicsOutlierPolicy,
407    /// What the benchmark tests.
408    #[serde(default)]
409    pub hypotheses: Vec<EconomicsHypothesis>,
410    /// The claims of `docs/CLAIMS.yaml` that rest on a metric, and the standing the metric
411    /// must have before the claim may be `guaranteed`.
412    #[serde(default)]
413    pub claims: Vec<EconomicsClaimBinding>,
414    /// The change log.
415    #[serde(default)]
416    pub changes: Vec<EconomicsMethodologyChange>,
417}
418
419/// A suite, as `.ai/repo/benchmarks/economics/suites/<id>.yaml` declares it: what is run,
420/// with which model and arms, and which tracked paths decide whether its evidence is still
421/// current. A `live` suite fills the session fields and a `context` suite the seed and
422/// tokenizer fields; the other kind's fields stay absent and are not written back.
423///
424/// ```
425/// use majordomus_cli::economics::model::EconomicsSuite;
426/// let s: EconomicsSuite = serde_json::from_value(serde_json::json!({
427///     "schema": "economics-suite/v1", "id": "context", "version": 1, "kind": "context",
428///     "title": "Context selection, counted", "tokenizer": "o200k_base",
429///     "freshness_inputs": ["apps/majordomus-cli/src/devcontext"]
430/// }))
431/// .unwrap();
432/// assert!(s.model.is_none() && s.tasks.is_empty(), "a context suite runs no model");
433/// let back = serde_json::to_value(&s).unwrap();
434/// assert!(back.get("model").is_none(), "an absent live field is not written as null");
435/// ```
436#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
437pub struct EconomicsSuite {
438    /// `economics-suite/v1`.
439    pub schema: String,
440    /// The suite's id.
441    pub id: String,
442    /// The suite's own version.
443    pub version: u32,
444    /// `live` (model sessions) or `context` (deterministic, no model).
445    pub kind: String,
446    /// A name.
447    pub title: String,
448    /// The provider, for a live suite.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub provider: Option<String>,
451    /// The harness that runs sessions, for a live suite.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub harness: Option<String>,
454    /// The model requested, for a live suite.
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub model: Option<String>,
457    /// Repetitions of every task and variant.
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub repetitions: Option<u32>,
460    /// The control variant's id.
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub control: Option<String>,
463    /// The treatment variant's id.
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub treatment: Option<String>,
466    /// The spending cap the harness is given per session.
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub max_budget_usd_per_session: Option<u32>,
469    /// The task ids, for a live suite.
470    #[serde(default)]
471    pub tasks: Vec<String>,
472    /// Where the seeds come from, for a context suite.
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub seeds: Option<String>,
475    /// The tokenizer, for a context suite.
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub tokenizer: Option<String>,
478    /// The tracked paths whose content decides whether recorded evidence is still current.
479    pub freshness_inputs: Vec<String>,
480}
481
482/// One session of a task: the prompt file it starts from.
483///
484/// ```
485/// use majordomus_cli::economics::model::EconomicsTaskSession;
486/// let s: EconomicsTaskSession =
487///     serde_json::from_value(serde_json::json!({ "prompt": "prompt-1.md" })).unwrap();
488/// assert_eq!(s.prompt, "prompt-1.md");
489/// assert!(serde_json::from_value::<EconomicsTaskSession>(serde_json::json!({})).is_err());
490/// ```
491#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
492pub struct EconomicsTaskSession {
493    /// The prompt file, relative to the task's directory.
494    pub prompt: String,
495}
496
497/// A task, as `.ai/repo/benchmarks/economics/tasks/<id>/task.yaml` declares it: the fixture
498/// a run starts from, the prompt of each session in order, and the hidden acceptance tests
499/// and verify command that decide whether the run succeeded.
500///
501/// ```
502/// use majordomus_cli::economics::model::EconomicsTask;
503/// let t: EconomicsTask = serde_json::from_value(serde_json::json!({
504///     "schema": "economics-task/v1", "id": "recurring-invoices", "title": "Recurring invoices",
505///     "category": "multi-session", "complexity": "medium", "source": "Work across sessions.",
506///     "fixture": "test/fixtures/economics/ledgerlite",
507///     "acceptance": "test/fixtures/economics/acceptance/recurring-invoices",
508///     "reference": "test/fixtures/economics/reference/recurring-invoices.patch",
509///     "verify": "python3 -m unittest discover -s tests -q", "scope": ["ledgerlite", "tests"],
510///     "sessions": [{ "prompt": "prompt-1.md" }, { "prompt": "prompt-2.md" }]
511/// }))
512/// .unwrap();
513/// assert_eq!(t.sessions.len(), 2);
514/// assert!(t.setup.is_none(), "a task with no setup patch starts from the bare fixture");
515/// ```
516#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
517pub struct EconomicsTask {
518    /// `economics-task/v1`.
519    pub schema: String,
520    /// The task's id.
521    pub id: String,
522    /// A name.
523    pub title: String,
524    /// The category results are segmented by.
525    pub category: String,
526    /// `small`, `medium`, `large`.
527    pub complexity: String,
528    /// The development pattern it stands for.
529    pub source: String,
530    /// The fixture repository the task starts from.
531    pub fixture: String,
532    /// A patch applied to the fixture before the first session.
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub setup: Option<String>,
535    /// The hidden acceptance tests, copied in after the last session.
536    pub acceptance: String,
537    /// A reference solution, which proves the acceptance tests can be satisfied.
538    pub reference: String,
539    /// The command whose exit decides the test gates.
540    pub verify: String,
541    /// Where the task is expected to change files.
542    pub scope: Vec<String>,
543    /// The sessions, in order.
544    pub sessions: Vec<EconomicsTaskSession>,
545}
546
547// ---------------------------------------------------------------- raw facts
548
549/// The harness that ran a session, as it identified itself.
550///
551/// ```
552/// use majordomus_cli::economics::model::EconomicsHarness;
553/// let a = EconomicsHarness { name: "claude-code".into(), version: "2.0.1".into() };
554/// let b = EconomicsHarness { version: "2.0.2".into(), ..a.clone() };
555/// assert_ne!(a, b, "another version of the harness is another harness");
556/// assert_eq!(serde_json::to_value(&a).unwrap()["name"], "claude-code");
557/// ```
558#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
559pub struct EconomicsHarness {
560    /// `claude-code`.
561    pub name: String,
562    /// Its version, as `--version` printed it.
563    pub version: String,
564}
565
566/// The token usage of one provider request, provider-native field names kept.
567///
568/// A request is one model call. Claude Code writes one event per content block of a
569/// message and repeats the message's usage on each, so a request is identified by its
570/// message id and counted once.
571///
572/// ```
573/// use majordomus_cli::economics::model::EconomicsRequest;
574/// let r: EconomicsRequest = serde_json::from_value(serde_json::json!({
575///     "model": "claude-sonnet-5", "input_tokens": 12, "cache_creation_input_tokens": 3000,
576///     "cache_read_input_tokens": 18000, "output_tokens": 400
577/// }))
578/// .unwrap();
579/// assert_eq!(r.input_tokens + r.cache_creation_input_tokens + r.cache_read_input_tokens, 21012);
580/// assert!(serde_json::to_value(&r).unwrap().get("thinking_tokens").is_none(), "unreported");
581/// ```
582#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
583pub struct EconomicsRequest {
584    /// The model that answered.
585    pub model: String,
586    /// `input_tokens`: input not read from or written to the cache.
587    pub input_tokens: u64,
588    /// `cache_creation_input_tokens`: input written to the cache.
589    pub cache_creation_input_tokens: u64,
590    /// `cache_read_input_tokens`: input read from the cache.
591    pub cache_read_input_tokens: u64,
592    /// `output_tokens`, reasoning included.
593    pub output_tokens: u64,
594    /// `output_tokens_details.thinking_tokens`, when the provider reported it.
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub thinking_tokens: Option<u64>,
597}
598
599/// A model's usage over a whole session, as the harness totalled it (`modelUsage`),
600/// including models the harness called on its own account.
601///
602/// ```
603/// use majordomus_cli::economics::model::EconomicsModelTotal;
604/// let t: EconomicsModelTotal = serde_json::from_value(serde_json::json!({
605///     "model": "claude-haiku-5", "input_tokens": 900, "cache_creation_input_tokens": 0,
606///     "cache_read_input_tokens": 0, "output_tokens": 60, "cost_microusd": 1250
607/// }))
608/// .unwrap();
609/// assert_eq!(t.cost_microusd, Some(1250), "0.00125 USD, kept as an integer");
610/// ```
611#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
612pub struct EconomicsModelTotal {
613    /// The model.
614    pub model: String,
615    /// `inputTokens`.
616    pub input_tokens: u64,
617    /// `cacheCreationInputTokens`.
618    pub cache_creation_input_tokens: u64,
619    /// `cacheReadInputTokens`.
620    pub cache_read_input_tokens: u64,
621    /// `outputTokens`.
622    pub output_tokens: u64,
623    /// `costUSD`, in millionths of a dollar: the harness's own projection from its price
624    /// table at run time, not a bill.
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub cost_microusd: Option<u64>,
627}
628
629/// A model's totals order by the model's name, which the provider reports once per run.
630impl crate::order::Ordered for EconomicsModelTotal {
631    fn order_key(&self) -> crate::order::OrderKey<'_> {
632        crate::order::OrderKey::plain(&self.model, &self.model)
633    }
634}
635
636/// What a session did before its first edit: the cost of getting oriented.
637///
638/// ```
639/// use majordomus_cli::economics::model::EconomicsOrientation;
640/// let none = EconomicsOrientation::default();
641/// assert!(!none.edited && none.requests == 0 && none.tokens == 0);
642/// let o = EconomicsOrientation {
643///     edited: true, requests: 4, tool_calls: 6, reads: 4, searches: 1, tokens: 52_000,
644/// };
645/// assert!(o.reads + o.searches <= o.tool_calls, "reads and searches are tool calls");
646/// ```
647#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
648pub struct EconomicsOrientation {
649    /// Whether the session edited anything at all.
650    pub edited: bool,
651    /// Provider requests before the first edit.
652    pub requests: u64,
653    /// Tool calls before the first edit.
654    pub tool_calls: u64,
655    /// File reads before the first edit.
656    pub reads: u64,
657    /// Searches (grep, glob, find) before the first edit.
658    pub searches: u64,
659    /// Total tokens of the requests before the first edit.
660    pub tokens: u64,
661}
662
663/// One session of a run: one harness invocation on one prompt, with every provider request
664/// it made, the harness's own totals and the tools it called. A count the harness did not
665/// report is absent rather than zero.
666///
667/// ```
668/// use std::collections::BTreeMap;
669/// use majordomus_cli::economics::model::{EconomicsOrientation, EconomicsSession};
670/// let s = EconomicsSession {
671///     index: 1, prompt_sha256: "0".repeat(64), duration_ms: 42_000, turns: None,
672///     ended: "success".into(), is_error: false, requests: vec![], models: vec![],
673///     reported_cost_microusd: None, tools: BTreeMap::from([("Read".into(), 3)]),
674///     orientation: EconomicsOrientation::default(),
675/// };
676/// let json = serde_json::to_value(&s).unwrap();
677/// assert!(json.get("turns").is_none(), "an unreported count is absent, not zero");
678/// assert_eq!(json["tools"]["Read"], 3);
679/// ```
680#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
681pub struct EconomicsSession {
682    /// 1-based.
683    pub index: u32,
684    /// SHA-256 of the prompt text. The text is in the task's prompt file, not here.
685    pub prompt_sha256: String,
686    /// Wall-clock milliseconds.
687    pub duration_ms: u64,
688    /// Turns, as the harness counted them.
689    #[serde(default, skip_serializing_if = "Option::is_none")]
690    pub turns: Option<u64>,
691    /// How the session ended, as the harness reported it (`success`, `error_max_budget_usd`, ...).
692    pub ended: String,
693    /// Whether the harness reported an error.
694    pub is_error: bool,
695    /// Every request, deduplicated by message id, in order.
696    pub requests: Vec<EconomicsRequest>,
697    /// The harness's per-model totals.
698    pub models: Vec<EconomicsModelTotal>,
699    /// The harness's `total_cost_usd`, in millionths of a dollar.
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub reported_cost_microusd: Option<u64>,
702    /// Tool calls by tool name.
703    pub tools: BTreeMap<String, u64>,
704    /// The session's orientation cost.
705    pub orientation: EconomicsOrientation,
706}
707
708/// One success gate's verdict on a run.
709///
710/// ```
711/// use majordomus_cli::economics::model::EconomicsCheck;
712/// let c = EconomicsCheck { id: "tests_kept".into(), passed: true, detail: "0 deleted".into() };
713/// let back: EconomicsCheck = serde_json::from_value(serde_json::to_value(&c).unwrap()).unwrap();
714/// assert_eq!(back, c);
715/// ```
716#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
717pub struct EconomicsCheck {
718    /// The gate's id.
719    pub id: String,
720    /// Whether it passed.
721    pub passed: bool,
722    /// What was found.
723    pub detail: String,
724}
725
726/// Whether the run did the task.
727///
728/// ```
729/// use majordomus_cli::economics::model::{EconomicsCheck, EconomicsOutcome};
730/// let gate = |id: &str, passed| EconomicsCheck { id: id.into(), passed, detail: String::new() };
731/// let o = EconomicsOutcome {
732///     completed: false,
733///     checks: vec![gate("visible_tests", true), gate("acceptance_tests", false)],
734///     changed_files: vec!["ledgerlite/vat.py".into()],
735/// };
736/// assert_eq!(o.completed, o.checks.iter().all(|c| c.passed), "completed: every gate passed");
737/// ```
738#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
739pub struct EconomicsOutcome {
740    /// Every gate passed.
741    pub completed: bool,
742    /// Each gate's verdict.
743    pub checks: Vec<EconomicsCheck>,
744    /// Files the sessions changed, added or deleted.
745    pub changed_files: Vec<String>,
746}
747
748/// The repository revision a run was recorded at.
749///
750/// ```
751/// use majordomus_cli::economics::model::EconomicsRevision;
752/// let r: EconomicsRevision =
753///     serde_json::from_value(serde_json::json!({ "commit": "8d37d011ef", "dirty": true }))
754///         .unwrap();
755/// assert!(r.dirty, "a run recorded over uncommitted changes says so");
756/// ```
757#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
758pub struct EconomicsRevision {
759    /// The commit.
760    pub commit: String,
761    /// Whether the working tree differed from it.
762    pub dirty: bool,
763}
764
765/// One recorded run: a task, one variant, one repetition, every session of it. Raw facts
766/// only; nothing here is derived from another field.
767///
768/// ```
769/// use majordomus_cli::economics::model::EconomicsRun;
770/// let run: EconomicsRun = serde_json::from_value(serde_json::json!({
771///     "schema": "economics-run/v1", "id": "vat-rounding--baseline--r1", "suite": "pilot",
772///     "suite_version": 1, "methodology": 1, "task": "vat-rounding", "variant": "baseline",
773///     "repetition": 1, "provider": "anthropic",
774///     "harness": { "name": "claude-code", "version": "2.0.1" },
775///     "model_requested": "claude-sonnet-5", "models_reported": ["claude-sonnet-5"],
776///     "repository": { "commit": "8d37d011ef", "dirty": false }, "majordomus_version": "0.7.0",
777///     "fixture_digest": "f0", "inputs_digest": "i0", "configuration_digest": "c0",
778///     "started_at": "2026-09-24T10:00:00Z", "finished_at": "2026-09-24T10:05:00Z",
779///     "sessions": [], "outcome": { "completed": false, "checks": [], "changed_files": [] }
780/// }))
781/// .unwrap();
782/// assert_eq!(run.id, format!("{}--{}--r{}", run.task, run.variant, run.repetition));
783/// ```
784#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
785pub struct EconomicsRun {
786    /// `economics-run/v1`.
787    pub schema: String,
788    /// `<task>--<variant>--r<repetition>`.
789    pub id: String,
790    /// The suite.
791    pub suite: String,
792    /// The suite's version.
793    pub suite_version: u32,
794    /// The methodology version.
795    pub methodology: u32,
796    /// The task.
797    pub task: String,
798    /// The variant.
799    pub variant: String,
800    /// 1-based.
801    pub repetition: u32,
802    /// The provider.
803    pub provider: String,
804    /// The harness.
805    pub harness: EconomicsHarness,
806    /// The model the harness was asked for.
807    pub model_requested: String,
808    /// The models the harness reported using, sorted.
809    pub models_reported: Vec<String>,
810    /// The repository revision.
811    pub repository: EconomicsRevision,
812    /// The Majordomus version the treatment was installed from.
813    pub majordomus_version: String,
814    /// Digest of the fixture, setup patch and task declaration the run started from.
815    pub fixture_digest: String,
816    /// Digest of the suite's freshness inputs when the run was recorded.
817    pub inputs_digest: String,
818    /// Digest of the harness invocation (flags, settings), the prompt excepted.
819    pub configuration_digest: String,
820    /// When the first session started, UTC.
821    pub started_at: String,
822    /// When the verification finished, UTC.
823    pub finished_at: String,
824    /// The sessions.
825    pub sessions: Vec<EconomicsSession>,
826    /// The success gates' verdict.
827    pub outcome: EconomicsOutcome,
828}
829
830/// Token counts of one context-compilation seed.
831///
832/// ```
833/// use majordomus_cli::economics::model::EconomicsContextSeed;
834/// let s: EconomicsContextSeed = serde_json::from_value(serde_json::json!({
835///     "seed": "I0042", "candidates": 12, "candidate_bytes": 44000, "candidate_tokens": 11000,
836///     "considered": 90, "considered_tokens": 160000, "selected": 9, "selected_bytes": 32800,
837///     "selected_tokens": 8200, "estimated_selected_tokens": 8200,
838///     "excluded_tokens": { "budget": 2800 }, "unresolved": 0, "over_budget": false
839/// }))
840/// .unwrap();
841/// assert_eq!(s.selected_tokens + s.excluded_tokens["budget"], s.candidate_tokens);
842/// assert_eq!(s.estimated_selected_tokens, s.selected_bytes / 4, "the compiler's own estimate");
843/// ```
844#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
845pub struct EconomicsContextSeed {
846    /// The work the compiler was asked about (an issue id).
847    pub seed: String,
848    /// Distinct files the compiler judged relevant to the work: what it selected, and what
849    /// it left out only because the budget ran out. The denominator of context reduction.
850    pub candidates: u64,
851    /// Their bytes.
852    pub candidate_bytes: u64,
853    /// Their tokens, counted.
854    pub candidate_tokens: u64,
855    /// Distinct files the compiler reached at all, relevant or not.
856    pub considered: u64,
857    /// Their tokens, counted. Never a denominator: most of it the compiler judged irrelevant.
858    pub considered_tokens: u64,
859    /// Distinct files it selected.
860    pub selected: u64,
861    /// Their bytes.
862    pub selected_bytes: u64,
863    /// Their tokens, counted.
864    pub selected_tokens: u64,
865    /// The compiler's own cost estimate of what it selected (bytes over four).
866    pub estimated_selected_tokens: u64,
867    /// Counted tokens it left out, by the reason it gave.
868    pub excluded_tokens: BTreeMap<String, u64>,
869    /// Entries naming nothing on disk, which could not be counted.
870    pub unresolved: u64,
871    /// Whether the compiler reported its selection over budget.
872    pub over_budget: bool,
873}
874
875/// The identity of a tokenizer, recorded with every count it produced.
876///
877/// ```
878/// use majordomus_cli::economics::model::EconomicsTokenizer;
879/// let t: EconomicsTokenizer = serde_json::from_value(serde_json::json!({
880///     "encoding": "o200k_base", "implementation": "tiktoken-rs 0.12.0"
881/// }))
882/// .unwrap();
883/// let other = EconomicsTokenizer { implementation: "tiktoken-rs 0.13.0".into(), ..t.clone() };
884/// assert_ne!(t, other, "the same encoding from another implementation is another tokenizer");
885/// ```
886#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
887pub struct EconomicsTokenizer {
888    /// The encoding: `o200k_base`.
889    pub encoding: String,
890    /// The implementation and its pinned version.
891    pub implementation: String,
892}
893
894/// One recorded measurement of the context suite: every seed the compiler compiled, counted,
895/// and every seed it refused, named with its reason rather than dropped.
896///
897/// ```
898/// use majordomus_cli::economics::model::EconomicsContextRun;
899/// let run: EconomicsContextRun = serde_json::from_value(serde_json::json!({
900///     "schema": "economics-context-run/v1", "suite": "context", "suite_version": 1,
901///     "methodology": 1, "repository": { "commit": "8d37d011ef", "dirty": false },
902///     "majordomus_version": "0.7.0",
903///     "tokenizer": { "encoding": "o200k_base", "implementation": "tiktoken-rs 0.12.0" },
904///     "inputs_digest": "i0", "budget_tokens": 8000, "measured_at": "2026-09-24T10:00:00Z",
905///     "seeds": []
906/// }))
907/// .unwrap();
908/// assert_eq!(run.tokenizer.encoding, "o200k_base");
909/// assert!(run.seeds.is_empty());
910/// assert!(run.refused.is_empty(), "a record from before refusals were kept reads as none");
911/// let json = serde_json::to_value(&run).unwrap();
912/// assert_eq!(json["refused"], serde_json::json!([]), "a new record says none explicitly");
913/// ```
914#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
915pub struct EconomicsContextRun {
916    /// `economics-context-run/v1`.
917    pub schema: String,
918    /// The suite.
919    pub suite: String,
920    /// The suite's version.
921    pub suite_version: u32,
922    /// The methodology version.
923    pub methodology: u32,
924    /// The repository revision measured.
925    pub repository: EconomicsRevision,
926    /// The Majordomus version that compiled the contexts.
927    pub majordomus_version: String,
928    /// The tokenizer.
929    pub tokenizer: EconomicsTokenizer,
930    /// Digest of the suite's freshness inputs when measured.
931    pub inputs_digest: String,
932    /// The compiler's budget, in its own units.
933    pub budget_tokens: u64,
934    /// When measured, UTC.
935    pub measured_at: String,
936    /// One entry per seed, sorted by seed.
937    pub seeds: Vec<EconomicsContextSeed>,
938    /// The seeds the compiler refused, each with the reason it gave (`I0042: no such
939    /// issue`). They were not measured, so they are in no count above; they are kept here
940    /// so that a ratio over the seeds that compiled is never read as one over every seed.
941    /// A record written before this field existed reads as an empty list.
942    #[serde(default)]
943    pub refused: Vec<String>,
944}
945
946/// A context measurement orders by when it was taken, then by the commit it measured, never
947/// by its file name, so the last one is the latest. `measured_at` is a UTC RFC 3339 timestamp
948/// at whole seconds, whose digit runs have fixed widths, so the natural order reads it
949/// chronologically.
950impl crate::order::Ordered for EconomicsContextRun {
951    fn order_key(&self) -> crate::order::OrderKey<'_> {
952        crate::order::OrderKey::plain(&self.measured_at, &self.repository.commit)
953    }
954}
955
956// ---------------------------------------------------------------- derived answers
957
958/// How recorded evidence stands against the repository as it is now.
959///
960/// ```
961/// use majordomus_cli::economics::model::EconomicsFreshness;
962/// assert_eq!(serde_json::to_value(EconomicsFreshness::NoEvidence).unwrap(), "no_evidence");
963/// let f: EconomicsFreshness = serde_json::from_value("incompatible".into()).unwrap();
964/// assert_eq!(f, EconomicsFreshness::Incompatible);
965/// ```
966#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
967#[serde(rename_all = "snake_case")]
968pub enum EconomicsFreshness {
969    /// Recorded under this methodology, and nothing it depends on changed since.
970    Current,
971    /// Recorded under this methodology, but a mechanism it measured changed since.
972    Stale,
973    /// Recorded under another methodology version: not comparable at all.
974    Incompatible,
975    /// Nothing recorded.
976    NoEvidence,
977}
978
979/// Why a pair does or does not count.
980///
981/// ```
982/// use majordomus_cli::economics::model::EconomicsPairStatus;
983/// assert_eq!(serde_json::to_value(EconomicsPairStatus::ControlFailed).unwrap(), "control_failed");
984/// let s: EconomicsPairStatus = serde_json::from_value("usage_unavailable".into()).unwrap();
985/// assert_ne!(s, EconomicsPairStatus::Valid);
986/// ```
987#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
988#[serde(rename_all = "snake_case")]
989pub enum EconomicsPairStatus {
990    /// Both runs succeeded, are comparable and report usage.
991    Valid,
992    /// The control run failed a success gate.
993    ControlFailed,
994    /// The treatment run failed a success gate.
995    TreatmentFailed,
996    /// Both failed.
997    BothFailed,
998    /// A run reports no usage to compare.
999    UsageUnavailable,
1000    /// The runs differ in something the methodology requires them to share.
1001    Incomparable,
1002    /// One side was never recorded.
1003    Missing,
1004    /// A run is excluded under the outlier rule.
1005    Excluded,
1006}
1007
1008/// A run's totals, derived from its raw usage.
1009///
1010/// ```
1011/// use majordomus_cli::economics::model::EconomicsUsage;
1012/// let u = EconomicsUsage {
1013///     input_uncached: 10, cache_write: 90, cache_read: 900, output: 50,
1014///     total_input: 1000, total: 1050, requests: 3, ..EconomicsUsage::default()
1015/// };
1016/// assert_eq!(u.total_input, u.input_uncached + u.cache_write + u.cache_read);
1017/// let json = serde_json::to_value(&u).unwrap();
1018/// assert!(json.get("reasoning").is_none() && json.get("cost_microusd").is_none());
1019/// ```
1020#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1021pub struct EconomicsUsage {
1022    /// Input neither read from nor written to the cache.
1023    pub input_uncached: u64,
1024    /// Input written to the cache.
1025    pub cache_write: u64,
1026    /// Input read from the cache.
1027    pub cache_read: u64,
1028    /// Output, reasoning included.
1029    pub output: u64,
1030    /// Reasoning tokens, when every request reported them.
1031    #[serde(default, skip_serializing_if = "Option::is_none")]
1032    pub reasoning: Option<u64>,
1033    /// All input.
1034    pub total_input: u64,
1035    /// All input and output.
1036    pub total: u64,
1037    /// Provider requests.
1038    pub requests: u64,
1039    /// Tool calls.
1040    pub tool_calls: u64,
1041    /// The harness's cost projection, in millionths of a dollar, when every session had one.
1042    #[serde(default, skip_serializing_if = "Option::is_none")]
1043    pub cost_microusd: Option<u64>,
1044}
1045
1046/// A control run and its treatment run, and what follows from them.
1047///
1048/// ```
1049/// use majordomus_cli::economics::model::{EconomicsPair, EconomicsPairStatus};
1050/// let p: EconomicsPair = serde_json::from_value(serde_json::json!({
1051///     "suite": "pilot", "task": "vat-rounding", "category": "bugfix", "sessions": 1,
1052///     "repetition": 2, "control": "vat-rounding--baseline--r2", "status": "missing",
1053///     "reasons": ["no treatment run recorded"]
1054/// }))
1055/// .unwrap();
1056/// assert_eq!(p.status, EconomicsPairStatus::Missing);
1057/// assert!(p.treatment.is_none() && p.token_reduction.is_none(), "no side, no reduction");
1058/// ```
1059#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1060pub struct EconomicsPair {
1061    /// The suite.
1062    pub suite: String,
1063    /// The task.
1064    pub task: String,
1065    /// The task's category.
1066    pub category: String,
1067    /// Sessions the task spans.
1068    pub sessions: usize,
1069    /// The repetition.
1070    pub repetition: u32,
1071    /// The control run's id, when recorded.
1072    #[serde(default, skip_serializing_if = "Option::is_none")]
1073    pub control: Option<String>,
1074    /// The treatment run's id, when recorded.
1075    #[serde(default, skip_serializing_if = "Option::is_none")]
1076    pub treatment: Option<String>,
1077    /// Whether it counts.
1078    pub status: EconomicsPairStatus,
1079    /// Why not, when it does not.
1080    #[serde(default)]
1081    pub reasons: Vec<String>,
1082    /// The control's totals.
1083    #[serde(default, skip_serializing_if = "Option::is_none")]
1084    pub control_usage: Option<EconomicsUsage>,
1085    /// The treatment's totals.
1086    #[serde(default, skip_serializing_if = "Option::is_none")]
1087    pub treatment_usage: Option<EconomicsUsage>,
1088    /// `1 - treatment / control` on total tokens. Negative: the treatment cost more.
1089    /// Present on a valid pair, and on an excluded pair that would otherwise be valid, so
1090    /// that the outlier rule's effect can be shown both ways; only valid pairs enter a
1091    /// metric other than `effective_token_reduction.including_excluded`. The same holds for
1092    /// every reduction and overhead below.
1093    #[serde(default, skip_serializing_if = "Option::is_none")]
1094    pub token_reduction: Option<f64>,
1095    /// The same on the harness's cost projection.
1096    #[serde(default, skip_serializing_if = "Option::is_none")]
1097    pub cost_reduction: Option<f64>,
1098    /// The same on tool calls.
1099    #[serde(default, skip_serializing_if = "Option::is_none")]
1100    pub tool_call_reduction: Option<f64>,
1101    /// The same on the tokens of the last session only: what continuing cost.
1102    #[serde(default, skip_serializing_if = "Option::is_none")]
1103    pub continuation_reduction: Option<f64>,
1104    /// First-request input of the treatment minus that of the control: what the treatment
1105    /// adds before the model has done anything.
1106    #[serde(default, skip_serializing_if = "Option::is_none")]
1107    pub first_request_overhead: Option<i64>,
1108}
1109
1110/// The shape of a set of per-pair values.
1111///
1112/// ```
1113/// use majordomus_cli::economics::model::EconomicsDistribution;
1114/// let d: EconomicsDistribution = serde_json::from_value(serde_json::json!({
1115///     "n": 1, "mean": 0.4, "median": 0.4, "p25": 0.4, "p75": 0.4, "p95": 0.4,
1116///     "min": 0.4, "max": 0.4
1117/// }))
1118/// .unwrap();
1119/// assert!(d.sd.is_none(), "one value has no sample standard deviation");
1120/// assert!(d.min <= d.p25 && d.p25 <= d.median && d.median <= d.p75 && d.p75 <= d.max);
1121/// ```
1122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1123pub struct EconomicsDistribution {
1124    /// How many values.
1125    pub n: usize,
1126    /// Arithmetic mean.
1127    pub mean: f64,
1128    /// Median.
1129    pub median: f64,
1130    /// 25th percentile.
1131    pub p25: f64,
1132    /// 75th percentile.
1133    pub p75: f64,
1134    /// 95th percentile.
1135    pub p95: f64,
1136    /// Smallest.
1137    pub min: f64,
1138    /// Largest.
1139    pub max: f64,
1140    /// Sample standard deviation; absent for one value.
1141    #[serde(default, skip_serializing_if = "Option::is_none")]
1142    pub sd: Option<f64>,
1143}
1144
1145/// A bootstrap interval, with everything needed to reproduce it.
1146///
1147/// ```
1148/// use majordomus_cli::economics::model::EconomicsInterval;
1149/// let i: EconomicsInterval = serde_json::from_value(serde_json::json!({
1150///     "level_bp": 9500, "low": 0.21, "high": 0.38,
1151///     "method": "bootstrap percentile of the median", "resamples": 10000, "seed": 20260924
1152/// }))
1153/// .unwrap();
1154/// assert!(i.low <= i.high);
1155/// assert_eq!(serde_json::to_value(&i).unwrap()["seed"], 20260924, "the seed travels with it");
1156/// ```
1157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1158pub struct EconomicsInterval {
1159    /// Confidence in basis points.
1160    pub level_bp: u32,
1161    /// Lower bound.
1162    pub low: f64,
1163    /// Upper bound.
1164    pub high: f64,
1165    /// How it was computed: a cluster bootstrap over tasks for every metric over pairs or
1166    /// runs, whose repetitions of one task are not independent, and an independent
1167    /// bootstrap over seeds for the context suite.
1168    pub method: String,
1169    /// Resamples.
1170    pub resamples: u32,
1171    /// Generator seed.
1172    pub seed: u64,
1173}
1174
1175/// Where a metric stands against the publication rule: whether its value may back a claim,
1176/// is a result too thin to claim anything, or was never measured at all.
1177///
1178/// ```
1179/// use majordomus_cli::economics::model::EconomicsMetricStatus;
1180/// assert_eq!(serde_json::to_value(EconomicsMetricStatus::NotMeasured).unwrap(), "not_measured");
1181/// let s: EconomicsMetricStatus = serde_json::from_value("preliminary".into()).unwrap();
1182/// assert_ne!(s, EconomicsMetricStatus::Verified, "a preliminary result is not a claim");
1183/// ```
1184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1185#[serde(rename_all = "snake_case")]
1186pub enum EconomicsMetricStatus {
1187    /// Measured, and meets the publication rule. Only the primary metric over all the
1188    /// evidence can be: the rule is evaluated for it and for nothing else, so no secondary
1189    /// metric and no narrowed slice is ever verified.
1190    Verified,
1191    /// Measured, but no claim may rest on it: below the publication rule, narrowed by a
1192    /// query, or a metric the rule was never evaluated for. A result, not a claim.
1193    Preliminary,
1194    /// Measured deterministically; the publication rule for sampled data does not apply.
1195    Measured,
1196    /// Nothing recorded to compute it from.
1197    NotMeasured,
1198}
1199
1200/// One metric: its value, where it comes from, and how much it can bear.
1201///
1202/// ```
1203/// use majordomus_cli::economics::model::{EconomicsClass, EconomicsMetric, EconomicsMetricStatus};
1204/// let m: EconomicsMetric = serde_json::from_value(serde_json::json!({
1205///     "id": "effective_token_reduction", "title": "Effective token reduction",
1206///     "class": "derived", "inputs": "observed", "unit": "ratio",
1207///     "formula": "median of 1 - treatment / control", "status": "not_measured", "n": 0,
1208///     "suite": "pilot"
1209/// }))
1210/// .unwrap();
1211/// assert_eq!(m.inputs, Some(EconomicsClass::Observed));
1212/// assert!(m.value.is_none() && m.status == EconomicsMetricStatus::NotMeasured);
1213/// assert!(serde_json::to_value(&m).unwrap().get("value").is_none(), "no value, not a zero");
1214/// ```
1215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1216pub struct EconomicsMetric {
1217    /// The metric's id.
1218    pub id: String,
1219    /// A name.
1220    pub title: String,
1221    /// The measurement class.
1222    pub class: EconomicsClass,
1223    /// For a derived metric, the class of what it was derived from: a reduction derived from
1224    /// observed usage is not the same evidence as one derived from counted bytes.
1225    #[serde(default, skip_serializing_if = "Option::is_none")]
1226    pub inputs: Option<EconomicsClass>,
1227    /// `ratio`, `tokens`, `calls`.
1228    pub unit: String,
1229    /// The formula, in words.
1230    pub formula: String,
1231    /// What the metric is not, where it is easily mistaken for something else.
1232    #[serde(default, skip_serializing_if = "Option::is_none")]
1233    pub not: Option<String>,
1234    /// The headline value; absent when nothing was measured.
1235    #[serde(default, skip_serializing_if = "Option::is_none")]
1236    pub value: Option<f64>,
1237    /// Where it stands.
1238    pub status: EconomicsMetricStatus,
1239    /// How many values it rests on.
1240    pub n: usize,
1241    /// Their distribution.
1242    #[serde(default, skip_serializing_if = "Option::is_none")]
1243    pub distribution: Option<EconomicsDistribution>,
1244    /// The interval, when enough values exist.
1245    #[serde(default, skip_serializing_if = "Option::is_none")]
1246    pub interval: Option<EconomicsInterval>,
1247    /// The suite.
1248    pub suite: String,
1249    /// The runs, pairs or seeds it rests on.
1250    #[serde(default)]
1251    pub evidence: Vec<String>,
1252    /// What a reader must know before using it.
1253    #[serde(default)]
1254    pub warnings: Vec<String>,
1255}
1256
1257/// A metric's value within one segment of the pairs.
1258///
1259/// ```
1260/// use majordomus_cli::economics::model::EconomicsSegment;
1261/// let s: EconomicsSegment = serde_json::from_value(serde_json::json!({
1262///     "dimension": "category", "value": "multi-session", "n": 0
1263/// }))
1264/// .unwrap();
1265/// assert!(s.token_reduction.is_none(), "a segment with no valid pair has no distribution");
1266/// ```
1267#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1268pub struct EconomicsSegment {
1269    /// `category`, `sessions`, `complexity`.
1270    pub dimension: String,
1271    /// The segment.
1272    pub value: String,
1273    /// Valid pairs in it.
1274    pub n: usize,
1275    /// Distribution of per-pair token reduction.
1276    #[serde(default, skip_serializing_if = "Option::is_none")]
1277    pub token_reduction: Option<EconomicsDistribution>,
1278}
1279
1280/// Pair counts of a suite, so that nothing is dropped out of sight.
1281///
1282/// ```
1283/// use majordomus_cli::economics::model::EconomicsPairCounts;
1284/// let c = EconomicsPairCounts {
1285///     declared: 14, attempted: 12, valid: 9, control_failed: 1,
1286///     treatment_failed: 1, both_failed: 0, other: 1,
1287/// };
1288/// let accounted = c.valid + c.control_failed + c.treatment_failed + c.both_failed + c.other;
1289/// assert_eq!(accounted, c.attempted, "every attempted pair is in exactly one bucket");
1290/// assert_eq!(EconomicsPairCounts::default().declared, 0);
1291/// ```
1292#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1293pub struct EconomicsPairCounts {
1294    /// Pairs the suite declares.
1295    pub declared: usize,
1296    /// Pairs with at least one run recorded.
1297    pub attempted: usize,
1298    /// Pairs that count.
1299    pub valid: usize,
1300    /// Pairs whose control failed.
1301    pub control_failed: usize,
1302    /// Pairs whose treatment failed.
1303    pub treatment_failed: usize,
1304    /// Pairs where both failed.
1305    pub both_failed: usize,
1306    /// Pairs not comparable, missing a side, lacking usage, or excluded.
1307    pub other: usize,
1308}
1309
1310/// A suite and the state of its evidence.
1311///
1312/// ```
1313/// use majordomus_cli::economics::model::{
1314///     EconomicsFreshness, EconomicsPairCounts, EconomicsSuiteView,
1315/// };
1316/// let v = EconomicsSuiteView {
1317///     id: "pilot".into(), kind: "live".into(), version: 1, title: "Pilot".into(),
1318///     model: Some("claude-sonnet-5".into()), freshness: EconomicsFreshness::NoEvidence,
1319///     freshness_detail: Some("no run recorded".into()), runs: 0,
1320///     pairs: EconomicsPairCounts { declared: 14, ..EconomicsPairCounts::default() },
1321///     revisions: vec![], harnesses: vec![], models_reported: vec![],
1322/// };
1323/// let json = serde_json::to_value(&v).unwrap();
1324/// assert_eq!(json["freshness"], "no_evidence");
1325/// assert_eq!(json["pairs"]["declared"], 14, "declared pairs show even when none ran");
1326/// ```
1327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1328pub struct EconomicsSuiteView {
1329    /// The suite.
1330    pub id: String,
1331    /// `live` or `context`.
1332    pub kind: String,
1333    /// Its version.
1334    pub version: u32,
1335    /// A name.
1336    pub title: String,
1337    /// The model, for a live suite.
1338    #[serde(default, skip_serializing_if = "Option::is_none")]
1339    pub model: Option<String>,
1340    /// Where its evidence stands.
1341    pub freshness: EconomicsFreshness,
1342    /// Why, when not current.
1343    #[serde(default, skip_serializing_if = "Option::is_none")]
1344    pub freshness_detail: Option<String>,
1345    /// Runs recorded.
1346    pub runs: usize,
1347    /// Pair counts.
1348    pub pairs: EconomicsPairCounts,
1349    /// The revisions the evidence was recorded at.
1350    #[serde(default)]
1351    pub revisions: Vec<String>,
1352    /// The harness versions that ran it.
1353    #[serde(default)]
1354    pub harnesses: Vec<String>,
1355    /// The models the harness reported.
1356    #[serde(default)]
1357    pub models_reported: Vec<String>,
1358}
1359
1360/// The one sentence the evidence allows, and why it allows no stronger one.
1361///
1362/// ```
1363/// use majordomus_cli::economics::model::EconomicsVerdict;
1364/// let v: EconomicsVerdict = serde_json::from_value(serde_json::json!({
1365///     "publishable": false,
1366///     "statement": "No quantitative token claim: 9 valid pairs of the 30 the rule requires.",
1367///     "unmet": ["min_valid_pairs: 9 < 30"]
1368/// }))
1369/// .unwrap();
1370/// assert!(!v.publishable);
1371/// assert_eq!(v.unmet.len(), 1, "every threshold not met is named");
1372/// ```
1373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1374pub struct EconomicsVerdict {
1375    /// Whether a quantitative total-token claim meets the publication rule.
1376    pub publishable: bool,
1377    /// The strongest statement the evidence supports, generated, never typed.
1378    pub statement: String,
1379    /// Every publication threshold not met.
1380    #[serde(default)]
1381    pub unmet: Vec<String>,
1382}
1383
1384/// The context suite's latest measurement, in brief.
1385///
1386/// ```
1387/// use std::collections::BTreeMap;
1388/// use majordomus_cli::economics::model::{EconomicsContextView, EconomicsTokenizer};
1389/// let v = EconomicsContextView {
1390///     revision: "8d37d011ef".into(), measured_at: "2026-09-24T10:00:00Z".into(),
1391///     tokenizer: EconomicsTokenizer {
1392///         encoding: "o200k_base".into(), implementation: "tiktoken-rs 0.12.0".into(),
1393///     },
1394///     seeds: 2, candidate_tokens: 20_000, considered_tokens: 300_000, selected_tokens: 15_000,
1395///     estimated_selected_tokens: 16_000,
1396///     excluded_tokens: BTreeMap::from([("budget".into(), 5_000)]),
1397///     seeds_over_budget_counted: 0, budget_tokens: 8_000,
1398/// };
1399/// assert_eq!(v.selected_tokens + v.excluded_tokens["budget"], v.candidate_tokens);
1400/// assert_eq!(serde_json::to_value(&v).unwrap()["tokenizer"]["encoding"], "o200k_base");
1401/// ```
1402#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1403pub struct EconomicsContextView {
1404    /// The revision measured.
1405    pub revision: String,
1406    /// When.
1407    pub measured_at: String,
1408    /// The tokenizer.
1409    pub tokenizer: EconomicsTokenizer,
1410    /// Seeds measured.
1411    pub seeds: usize,
1412    /// Counted candidate tokens (relevant: selected or cut by the budget), all seeds.
1413    pub candidate_tokens: u64,
1414    /// Counted tokens of everything the compiler reached, all seeds.
1415    pub considered_tokens: u64,
1416    /// Counted selected tokens, all seeds.
1417    pub selected_tokens: u64,
1418    /// The compiler's own estimate of the selected tokens, all seeds.
1419    pub estimated_selected_tokens: u64,
1420    /// Counted tokens left out, by reason, all seeds.
1421    pub excluded_tokens: BTreeMap<String, u64>,
1422    /// Seeds whose counted selection exceeds the compiler's budget.
1423    pub seeds_over_budget_counted: usize,
1424    /// The compiler's budget.
1425    pub budget_tokens: u64,
1426}
1427
1428/// A recorded measurement over time: one entry per recorded context run.
1429///
1430/// ```
1431/// use majordomus_cli::economics::model::EconomicsHistoryPoint;
1432/// let p: EconomicsHistoryPoint = serde_json::from_value(serde_json::json!({
1433///     "suite": "context", "methodology": 1, "revision": "8d37d011ef",
1434///     "at": "2026-09-24T10:00:00Z", "metric": "context_reduction_ratio", "value": 0.62, "n": 40
1435/// }))
1436/// .unwrap();
1437/// assert_eq!((p.methodology, p.n), (1, 40));
1438/// assert_eq!(serde_json::to_value(&p).unwrap()["metric"], "context_reduction_ratio");
1439/// ```
1440#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1441pub struct EconomicsHistoryPoint {
1442    /// The suite.
1443    pub suite: String,
1444    /// The methodology version, so that incompatible versions are never drawn as one line.
1445    pub methodology: u32,
1446    /// The revision.
1447    pub revision: String,
1448    /// When.
1449    pub at: String,
1450    /// The metric.
1451    pub metric: String,
1452    /// Its value.
1453    pub value: f64,
1454    /// How many values it rests on.
1455    pub n: usize,
1456}
1457
1458/// A point of the history orders within its suite by when it was measured, then by the
1459/// revision it was measured at. `at` is a UTC RFC 3339 timestamp at whole seconds, whose
1460/// digit runs have fixed widths, so the natural order reads it chronologically.
1461impl crate::order::Ordered for EconomicsHistoryPoint {
1462    fn order_key(&self) -> crate::order::OrderKey<'_> {
1463        crate::order::OrderKey::grouped(&self.suite, &self.at, &self.revision)
1464    }
1465}
1466
1467/// The answer of `economics.summary`: every metric, where each comes from, and the one
1468/// statement the evidence allows.
1469///
1470/// ```
1471/// use majordomus_cli::economics::model::EconomicsSummary;
1472/// let s: EconomicsSummary = serde_json::from_value(serde_json::json!({
1473///     "present": false,
1474///     "verdict": { "publishable": false, "statement": "No methodology is declared." },
1475///     "metrics": [], "suites": [], "pairs": [], "segments": [], "history": [],
1476///     "variants": [], "hypotheses": []
1477/// }))
1478/// .unwrap();
1479/// assert!(s.methodology.is_none() && s.publication.is_none() && s.diagnostics.is_empty());
1480/// let json = serde_json::to_value(&s).unwrap();
1481/// assert!(json.get("methodology").is_none(), "an absent methodology is absent, not null");
1482/// ```
1483#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1484pub struct EconomicsSummary {
1485    /// Whether a methodology is declared here at all.
1486    pub present: bool,
1487    /// The methodology version.
1488    #[serde(default, skip_serializing_if = "Option::is_none")]
1489    pub methodology: Option<u32>,
1490    /// The question.
1491    #[serde(default, skip_serializing_if = "Option::is_none")]
1492    pub question: Option<String>,
1493    /// The primary metric's id.
1494    #[serde(default, skip_serializing_if = "Option::is_none")]
1495    pub primary_metric: Option<String>,
1496    /// What the total-token evidence allows to be said.
1497    pub verdict: EconomicsVerdict,
1498    /// Every metric.
1499    pub metrics: Vec<EconomicsMetric>,
1500    /// Every suite and its evidence.
1501    pub suites: Vec<EconomicsSuiteView>,
1502    /// Every pair, valid or not.
1503    pub pairs: Vec<EconomicsPair>,
1504    /// Token reduction by segment.
1505    pub segments: Vec<EconomicsSegment>,
1506    /// The context suite in brief.
1507    #[serde(default, skip_serializing_if = "Option::is_none")]
1508    pub context: Option<EconomicsContextView>,
1509    /// The metric over recorded measurements.
1510    pub history: Vec<EconomicsHistoryPoint>,
1511    /// The arms of the experiment.
1512    pub variants: Vec<EconomicsVariant>,
1513    /// The hypotheses, as hypotheses.
1514    pub hypotheses: Vec<EconomicsHypothesis>,
1515    /// The publication rule.
1516    #[serde(default, skip_serializing_if = "Option::is_none")]
1517    pub publication: Option<EconomicsPublicationPolicy>,
1518    /// Problems reading the declarations or the evidence.
1519    #[serde(default)]
1520    pub diagnostics: Vec<String>,
1521}
1522
1523// ---------------------------------------------------------------- queries
1524
1525/// The input of `economics.summary`: optional narrowing of the pairs and metrics. The
1526/// verdict is always about all the evidence, whatever is narrowed, and a narrowed metric
1527/// is never verified: the publication rule was evaluated over all of it, not over a slice.
1528///
1529/// ```
1530/// use majordomus_cli::economics::model::EconomicsQuery;
1531/// let all: EconomicsQuery = serde_json::from_value(serde_json::json!({})).unwrap();
1532/// assert_eq!(all, EconomicsQuery::default());
1533/// let q: EconomicsQuery =
1534///     serde_json::from_value(serde_json::json!({ "suite": "pilot", "category": "bugfix" }))
1535///         .unwrap();
1536/// assert_eq!(q.suite.as_deref(), Some("pilot"));
1537/// let typo = serde_json::json!({ "suites": "pilot" });
1538/// let refused = serde_json::from_value::<EconomicsQuery>(typo);
1539/// assert!(refused.is_err(), "a misspelt filter is refused");
1540/// ```
1541#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1542#[serde(deny_unknown_fields, default)]
1543pub struct EconomicsQuery {
1544    /// Only this suite.
1545    #[serde(skip_serializing_if = "Option::is_none")]
1546    pub suite: Option<String>,
1547    /// Only tasks of this category.
1548    #[serde(skip_serializing_if = "Option::is_none")]
1549    pub category: Option<String>,
1550    /// Only this task.
1551    #[serde(skip_serializing_if = "Option::is_none")]
1552    pub task: Option<String>,
1553    /// Only runs that asked for this model.
1554    #[serde(skip_serializing_if = "Option::is_none")]
1555    pub model: Option<String>,
1556}
1557
1558/// The input of `economics.explain`: the id of the one metric to explain. Nothing else is
1559/// accepted, so a misspelt field is refused rather than silently ignored.
1560///
1561/// ```
1562/// use majordomus_cli::economics::model::EconomicsExplainInput;
1563/// let i: EconomicsExplainInput =
1564///     serde_json::from_value(serde_json::json!({ "metric": "context_reduction_ratio" })).unwrap();
1565/// assert_eq!(i.metric, "context_reduction_ratio");
1566/// assert!(serde_json::from_value::<EconomicsExplainInput>(serde_json::json!({})).is_err());
1567/// let extra = serde_json::json!({ "metric": "context_reduction_ratio", "suite": "context" });
1568/// assert!(serde_json::from_value::<EconomicsExplainInput>(extra).is_err());
1569/// ```
1570#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1571#[serde(deny_unknown_fields)]
1572pub struct EconomicsExplainInput {
1573    /// The metric's id, as `economics.summary` lists it.
1574    pub metric: String,
1575}
1576
1577/// The answer of `economics.explain`: one metric, and everything it rests on.
1578///
1579/// A repository that declares no benchmark methodology measures nothing, so it has no
1580/// metric to explain: it is answered as `economics.summary` answers it — `present: false`,
1581/// the statement why, no metric and no number — rather than refused, because a repository
1582/// that does not benchmark itself is not a malformed request. A metric id that a declared
1583/// methodology does not have is still refused.
1584///
1585/// ```
1586/// use majordomus_cli::economics::model::{EconomicsClass, EconomicsExplanation};
1587/// let e: EconomicsExplanation = serde_json::from_value(serde_json::json!({
1588///     "present": true,
1589///     "metric": { "id": "context_reduction_ratio", "title": "Context reduction",
1590///         "class": "derived", "inputs": "counted", "unit": "ratio",
1591///         "formula": "median over seeds of 1 - selected_tokens / candidate_tokens",
1592///         "status": "not_measured", "n": 0, "suite": "context" },
1593///     "class_meaning": "Arithmetic over observed or counted values.", "methodology": 1,
1594///     "suites": [], "pairs": [], "excluded": [], "variants": [],
1595///     "reproduce": ["majordomus economics measure --suite context"]
1596/// }))
1597/// .unwrap();
1598/// assert_eq!(e.metric.unwrap().inputs, Some(EconomicsClass::Counted));
1599/// assert!(e.pairs.is_empty(), "a counted metric rests on seeds, not on pairs");
1600///
1601/// let none: EconomicsExplanation = serde_json::from_value(serde_json::json!({
1602///     "present": false, "statement": "No verified total-token-savings claim is available",
1603///     "class_meaning": "", "suites": [], "pairs": [], "excluded": [], "variants": [],
1604///     "reproduce": []
1605/// }))
1606/// .unwrap();
1607/// assert!(none.metric.is_none() && none.methodology.is_none(), "nothing declared, no number");
1608/// ```
1609#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1610pub struct EconomicsExplanation {
1611    /// Whether a methodology is declared here at all.
1612    pub present: bool,
1613    /// Why there is nothing to explain, when `present` is false.
1614    #[serde(default, skip_serializing_if = "Option::is_none")]
1615    pub statement: Option<String>,
1616    /// The metric; absent when no methodology is declared.
1617    #[serde(default, skip_serializing_if = "Option::is_none")]
1618    pub metric: Option<EconomicsMetric>,
1619    /// What its class means, in the methodology's words.
1620    pub class_meaning: String,
1621    /// The methodology version; absent when none is declared.
1622    #[serde(default, skip_serializing_if = "Option::is_none")]
1623    pub methodology: Option<u32>,
1624    /// The suites it draws on.
1625    pub suites: Vec<EconomicsSuiteView>,
1626    /// The pairs it rests on, valid or not, when it rests on pairs.
1627    pub pairs: Vec<EconomicsPair>,
1628    /// Runs excluded under the outlier rule.
1629    pub excluded: Vec<EconomicsExclusion>,
1630    /// The arms compared.
1631    pub variants: Vec<EconomicsVariant>,
1632    /// How to reproduce it.
1633    pub reproduce: Vec<String>,
1634}
1635
1636/// The input of `economics.runs`: optional filters on suite, task and variant, all of which
1637/// must match. An empty query lists every recorded run, and an unknown field is refused.
1638///
1639/// ```
1640/// use majordomus_cli::economics::model::EconomicsRunsQuery;
1641/// let wire = serde_json::json!({ "suite": "pilot", "variant": "baseline" });
1642/// let q: EconomicsRunsQuery = serde_json::from_value(wire.clone()).unwrap();
1643/// assert!(q.task.is_none(), "an absent filter matches every task");
1644/// assert_eq!(serde_json::to_value(&q).unwrap(), wire, "absent filters are not written back");
1645/// let by_model = serde_json::json!({ "model": "claude-sonnet-5" });
1646/// assert!(serde_json::from_value::<EconomicsRunsQuery>(by_model).is_err());
1647/// ```
1648#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1649#[serde(deny_unknown_fields, default)]
1650pub struct EconomicsRunsQuery {
1651    /// Only this suite.
1652    #[serde(skip_serializing_if = "Option::is_none")]
1653    pub suite: Option<String>,
1654    /// Only this task.
1655    #[serde(skip_serializing_if = "Option::is_none")]
1656    pub task: Option<String>,
1657    /// Only this variant.
1658    #[serde(skip_serializing_if = "Option::is_none")]
1659    pub variant: Option<String>,
1660}
1661
1662/// One run in brief, with its derived totals.
1663///
1664/// ```
1665/// use majordomus_cli::economics::model::EconomicsRunView;
1666/// let v: EconomicsRunView = serde_json::from_value(serde_json::json!({
1667///     "id": "vat-rounding--majordomus--r1", "suite": "pilot", "task": "vat-rounding",
1668///     "variant": "majordomus", "repetition": 1, "model": "claude-sonnet-5",
1669///     "revision": "8d37d011ef", "completed": false, "checks": [], "sessions": 1,
1670///     "path": ".ai/repo/benchmarks/economics/runs/pilot/vat-rounding--majordomus--r1.json"
1671/// }))
1672/// .unwrap();
1673/// assert!(v.usage.is_none(), "a run whose provider reported no usage has no totals");
1674/// assert!(v.path.ends_with(&format!("{}.json", v.id)));
1675/// ```
1676#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1677pub struct EconomicsRunView {
1678    /// The run's id.
1679    pub id: String,
1680    /// The suite.
1681    pub suite: String,
1682    /// The task.
1683    pub task: String,
1684    /// The variant.
1685    pub variant: String,
1686    /// The repetition.
1687    pub repetition: u32,
1688    /// The model requested.
1689    pub model: String,
1690    /// The revision.
1691    pub revision: String,
1692    /// Whether every success gate passed.
1693    pub completed: bool,
1694    /// Each gate's verdict.
1695    pub checks: Vec<EconomicsCheck>,
1696    /// Sessions.
1697    pub sessions: usize,
1698    /// Totals, when the provider reported usage.
1699    #[serde(default, skip_serializing_if = "Option::is_none")]
1700    pub usage: Option<EconomicsUsage>,
1701    /// Where the raw record is.
1702    pub path: String,
1703}
1704
1705/// A run in brief orders by its id, and by the path of its record when two share one.
1706impl crate::order::Ordered for EconomicsRunView {
1707    fn order_key(&self) -> crate::order::OrderKey<'_> {
1708        crate::order::OrderKey::plain(&self.id, &self.path)
1709    }
1710}
1711
1712/// The answer of `economics.runs`: the raw facts every metric is computed from.
1713///
1714/// ```
1715/// use majordomus_cli::economics::model::EconomicsRunList;
1716/// let l: EconomicsRunList =
1717///     serde_json::from_value(serde_json::json!({ "count": 0, "runs": [] })).unwrap();
1718/// assert_eq!(l.count, l.runs.len());
1719/// assert!(l.diagnostics.is_empty(), "no diagnostics listed means none");
1720/// ```
1721#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1722pub struct EconomicsRunList {
1723    /// How many.
1724    pub count: usize,
1725    /// The runs, sorted by id.
1726    pub runs: Vec<EconomicsRunView>,
1727    /// Records that could not be read.
1728    #[serde(default)]
1729    pub diagnostics: Vec<String>,
1730}