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}