Skip to main content

kranz_engine/
cost.rs

1//! Token pricing and pre-mission cost estimation (plan §5 Phase 1).
2//!
3//! The estimate produced here is exactly that — an ESTIMATE, presented as a
4//! wide range before the mission starts. Live token usage reported by the
5//! CLI (and folded into `MissionState.total_cost_usd`) is always
6//! authoritative; nothing in this module gates or bills anything.
7
8use crate::event_log::EventLog;
9use crate::events::{Event, EventKind};
10use crate::paths::MissionPaths;
11use crate::reducer;
12use crate::types::{
13    AssertionCheck, BackendKind, FeatureOrigin, MissionConfig, MissionState, MissionStatus, Plan,
14    PlanFeature, PlanMilestone, Role, TokenUsage, WorkerRun,
15};
16use serde::{Deserialize, Serialize};
17use std::path::Path;
18
19const TOKENS_PER_MTOK: f64 = 1_000_000.0;
20const GPT_5_6_LONG_CONTEXT_THRESHOLD: u64 = 272_000;
21
22/// Resolve immutable dispatch evidence before consulting legacy configuration.
23/// Old logs have no per-run backend: callers retain their historical config
24/// source (creation config for outcomes, folded config for calibration).
25/// Without either source, the original Claude default is preserved.
26pub(crate) fn resolved_run_backend(
27    recorded: Option<BackendKind>,
28    role: Role,
29    legacy_config: Option<&MissionConfig>,
30) -> BackendKind {
31    recorded.unwrap_or_else(|| {
32        legacy_config
33            .map(|cfg| cfg.backend_kind(role))
34            .unwrap_or(BackendKind::Claude)
35    })
36}
37
38/// CLI-reported cost, including an explicit zero, takes precedence over an
39/// estimate. Missing cost uses the actual resolved backend when available.
40pub(crate) fn resolved_run_cost(
41    recorded: Option<f64>,
42    usage: &TokenUsage,
43    model: &str,
44    backend: BackendKind,
45) -> f64 {
46    recorded.unwrap_or_else(|| usage_cost_usd_for_backend(usage, model, backend))
47}
48
49fn worker_run_cost(run: &WorkerRun, config: &MissionConfig) -> f64 {
50    resolved_run_cost(
51        run.cost_usd,
52        &run.tokens,
53        &run.model,
54        resolved_run_backend(run.backend, run.role, Some(config)),
55    )
56}
57
58/// Default model id for the Codex backend, importable engine-wide.
59pub const DEFAULT_CODEX_MODEL: &str = "gpt-5.6-sol";
60
61/// Whether `model` names a codex-family model (same substring match
62/// [`pricing_for_model`] uses to select codex pricing).
63pub fn is_codex_model(model: &str) -> bool {
64    let m = model.to_ascii_lowercase();
65    m.contains("codex") || m.contains("gpt")
66}
67
68/// Default model id for the Droid backend (Fireworks-hosted GLM 5.2),
69/// importable engine-wide.
70pub const DEFAULT_DROID_MODEL: &str = "accounts/fireworks/models/glm-5p2";
71
72/// Whether `model` names a droid-family (Fireworks GLM) model (same
73/// substring match [`pricing_for_model`] uses to select droid pricing).
74pub fn is_droid_model(model: &str) -> bool {
75    let m = model.to_ascii_lowercase();
76    m.contains("glm") || m.contains("fireworks")
77}
78
79/// Default model id for the Kimi backend (Kimi Code CLI's flagship alias),
80/// importable engine-wide.
81pub const DEFAULT_KIMI_MODEL: &str = "kimi-code/k3";
82
83/// Default model id for the Cursor backend, importable engine-wide: `gpt-5`,
84/// the `--help` example id and the probe's default (`agent --list-models`
85/// catalogs are account-specific, so the default stays the documented
86/// example rather than a captured catalog entry).
87pub const DEFAULT_CURSOR_MODEL: &str = "gpt-5";
88
89/// Whether `model` names a kimi-family model (same substring match
90/// [`pricing_for_model`] uses to select kimi pricing).
91pub fn is_kimi_model(model: &str) -> bool {
92    let m = model.to_ascii_lowercase();
93    m.contains("k3") || m.contains("kimi")
94}
95
96/// Per-model token pricing in USD per million tokens.
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct Pricing {
99    pub input_per_mtok: f64,
100    pub output_per_mtok: f64,
101}
102
103impl Pricing {
104    /// Cache reads are billed at 10% of the input rate.
105    pub fn cache_read_per_mtok(&self) -> f64 {
106        0.1 * self.input_per_mtok
107    }
108
109    /// Cache writes are billed at 125% of the input rate.
110    pub fn cache_write_per_mtok(&self) -> f64 {
111        1.25 * self.input_per_mtok
112    }
113}
114
115/// Pricing for a model alias or full id, by case-insensitive substring
116/// match on the family name. Unknown models fall back to opus-tier pricing
117/// (deliberately conservative for estimates).
118pub fn pricing_for_model(model: &str) -> Pricing {
119    let m = model.to_ascii_lowercase();
120    if m.contains("fable") {
121        Pricing {
122            input_per_mtok: 10.0,
123            output_per_mtok: 50.0,
124        }
125    } else if m == "gpt-5.6" || m.contains("gpt-5.6-sol") {
126        Pricing {
127            input_per_mtok: 4.0,
128            output_per_mtok: 20.0,
129        }
130    } else if m.contains("codex") || m.contains("gpt") {
131        Pricing {
132            input_per_mtok: 1.25,
133            output_per_mtok: 10.0,
134        }
135    } else if m.contains("glm") || m.contains("fireworks") {
136        // TODO(pricing): confirm Fireworks GLM 5.2 $/Mtok before ship
137        Pricing {
138            input_per_mtok: 0.55,
139            output_per_mtok: 2.19,
140        }
141    } else if m.contains("k3") || m.contains("kimi") {
142        // TODO(pricing): confirm kimi $/Mtok before ship
143        Pricing {
144            input_per_mtok: 0.60,
145            output_per_mtok: 2.50,
146        }
147    } else if m.contains("opus") {
148        Pricing {
149            input_per_mtok: 5.0,
150            output_per_mtok: 25.0,
151        }
152    } else if m.contains("sonnet") {
153        Pricing {
154            input_per_mtok: 3.0,
155            output_per_mtok: 15.0,
156        }
157    } else if m.contains("haiku") {
158        Pricing {
159            input_per_mtok: 1.0,
160            output_per_mtok: 5.0,
161        }
162    } else {
163        Pricing {
164            input_per_mtok: 5.0,
165            output_per_mtok: 25.0,
166        }
167    }
168}
169
170fn is_gpt_5_6_sol(model: &str) -> bool {
171    let m = model.to_ascii_lowercase();
172    m == "gpt-5.6" || m.contains("gpt-5.6-sol")
173}
174
175/// Dollar cost of a run's token usage under the model's pricing. Used as a
176/// fallback when the CLI result message does not report `cost_usd`.
177pub fn usage_cost_usd(usage: &TokenUsage, model: &str) -> f64 {
178    let p = pricing_for_model(model);
179    let total_input = usage
180        .input
181        .saturating_add(usage.cache_read)
182        .saturating_add(usage.cache_write);
183    // Official GPT-5.6 Sol pricing applies the long-context multiplier to the
184    // full request once prompt input exceeds 272K tokens. Codex emits one
185    // terminal usage record per single-shot request, so this fallback has the
186    // request boundary needed to apply it exactly.
187    let (input_multiplier, output_multiplier) =
188        if is_gpt_5_6_sol(model) && total_input > GPT_5_6_LONG_CONTEXT_THRESHOLD {
189            (2.0, 1.5)
190        } else {
191            (1.0, 1.0)
192        };
193    ((usage.input as f64 / TOKENS_PER_MTOK) * p.input_per_mtok
194        + (usage.cache_read as f64 / TOKENS_PER_MTOK) * p.cache_read_per_mtok()
195        + (usage.cache_write as f64 / TOKENS_PER_MTOK) * p.cache_write_per_mtok())
196        * input_multiplier
197        + (usage.output as f64 / TOKENS_PER_MTOK) * p.output_per_mtok * output_multiplier
198}
199
200/// [`usage_cost_usd`] fallback that is aware of the local backend: local
201/// model ids are free-form (scoping-doc addendum §5) and cannot be priced by
202/// [`pricing_for_model`]'s family match, so a local run always falls back to
203/// $0 marginal cost rather than the conservative opus-tier default. Every
204/// other backend prices exactly as [`usage_cost_usd`] does.
205pub fn usage_cost_usd_for_backend(usage: &TokenUsage, model: &str, backend: BackendKind) -> f64 {
206    if backend == BackendKind::Local {
207        0.0
208    } else {
209        usage_cost_usd(usage, model)
210    }
211}
212
213/// Tunable assumptions behind [`estimate`]. The defaults encode the plan's
214/// calibration; callers may override any of them.
215#[derive(Debug, Clone, Copy, PartialEq)]
216pub struct EstimateParams {
217    /// Fraction of runs expected to need a respawn (multiplies run counts).
218    pub respawn_allowance: f64,
219    /// Expected validation rounds that produce findings, per milestone.
220    pub fix_cycles_per_milestone: f64,
221    /// Expected fix-features created per fix cycle.
222    pub fix_features_per_cycle: f64,
223    pub avg_worker_run_usd: f64,
224    pub avg_validator_run_usd: f64,
225    pub orchestrator_overhead_usd_per_feature: f64,
226}
227
228impl Default for EstimateParams {
229    fn default() -> Self {
230        EstimateParams {
231            respawn_allowance: 0.2,
232            fix_cycles_per_milestone: 0.5,
233            fix_features_per_cycle: 2.0,
234            avg_worker_run_usd: 1.50,
235            avg_validator_run_usd: 0.75,
236            orchestrator_overhead_usd_per_feature: 0.25,
237        }
238    }
239}
240
241/// A pre-mission cost estimate range. `expected_usd` is the central guess;
242/// `low_usd`/`high_usd` bound it at 0.5x and 2.5x — real missions vary that
243/// much. Live usage remains authoritative.
244#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
245pub struct CostEstimate {
246    /// Expected worker session count (fractional — it is a rate, not a plan).
247    pub worker_runs: f64,
248    /// Expected validator session count.
249    pub validator_runs: f64,
250    pub low_usd: f64,
251    pub expected_usd: f64,
252    pub high_usd: f64,
253    /// Plan-observable shape. [`estimate`] itself is shape-neutral and always
254    /// reports `Unknown` here — only [`apply_shape`] classifies it.
255    pub shape: MissionShape,
256    /// Whether the calibration corpus covers this shape. [`estimate`] always
257    /// reports `High` — only [`apply_shape`] can lower it.
258    pub confidence: Confidence,
259}
260
261/// How much the calibration corpus backs a [`CostEstimate`]'s range.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263pub enum Confidence {
264    /// The calibration corpus has (or is assumed to have) comparable
265    /// missions.
266    High,
267    /// The plan's shape has zero comparable missions in the calibration
268    /// corpus — the range has been widened accordingly.
269    Low,
270}
271
272/// Estimate mission cost from the approved plan (plan §5 Phase 1 formula):
273///
274/// ```text
275/// worker_runs    = features·(1+r) + milestones·x·f·(1+r)
276/// validator_runs = 2·milestones·(1+x)          (2 = scrutiny+functional pair)
277/// expected       = worker_runs·avg_worker + validator_runs·avg_validator
278///                + features·orchestrator_overhead
279/// low = 0.5·expected, high = 2.5·expected
280/// ```
281///
282/// where `r` is the respawn allowance, `x` fix cycles per milestone, and `f`
283/// fix features per cycle. `skip_scrutiny` / `skip_functional` each remove
284/// half of the validator pairs.
285///
286/// Dispatch-pool multiplier (KRZ-303, the positioning ADR's 2026-07-31
287/// boundary gloss — "the cost multiplier is explicit in the consent
288/// surface"): a configured `workerCandidates` pool dispatches EVERY worker
289/// unit to all N candidates, so the worker-run count multiplies by N and the
290/// estimate the operator approves prices the SUM of all streams. Only the
291/// worker term multiplies — validators and orchestrator overhead are not
292/// fanned out.
293pub fn estimate(plan: &Plan, cfg: &MissionConfig, p: &EstimateParams) -> CostEstimate {
294    let milestones = plan.milestones.len() as f64;
295    let features = plan
296        .milestones
297        .iter()
298        .map(|m| m.features.len())
299        .sum::<usize>() as f64;
300
301    let r = p.respawn_allowance;
302    let x = p.fix_cycles_per_milestone;
303    let f = p.fix_features_per_cycle;
304
305    // Empty pool → multiplier 1 → byte-identical to the pre-pool formula.
306    let pool_n = cfg.worker_candidates.len().max(1) as f64;
307    let worker_runs = (features * (1.0 + r) + milestones * x * f * (1.0 + r)) * pool_n;
308
309    let validators_per_milestone =
310        2.0 - (cfg.skip_scrutiny as u8 as f64) - (cfg.skip_functional as u8 as f64);
311    let validator_runs = validators_per_milestone * milestones * (1.0 + x);
312
313    let expected_usd = worker_runs * p.avg_worker_run_usd
314        + validator_runs * p.avg_validator_run_usd
315        + features * p.orchestrator_overhead_usd_per_feature;
316
317    CostEstimate {
318        worker_runs,
319        validator_runs,
320        low_usd: 0.5 * expected_usd,
321        expected_usd,
322        high_usd: 2.5 * expected_usd,
323        shape: MissionShape::Unknown,
324        confidence: Confidence::High,
325    }
326}
327
328/// Multiplier on one worker-run cost for the cache-miss a tier switch pays:
329/// the first post-escalation frontier turn re-reads the whole conversation
330/// prefix UNCACHED, and cached-prefix tokens run ~10x cheaper (Cursor's
331/// Router post, cursor.com/blog/router). Priced ONCE per escalation — kranz
332/// escalates only at feature/milestone edges, where its fresh-context-per-
333/// feature design means there is no warm cache left to lose. Kranz-native
334/// number, ours to tune; not Factory's and not Cursor's gospel.
335pub const CACHE_MISS_MULT: f64 = 9.0;
336
337/// The two-path estimate for a plan routed to the local tier: what it costs
338/// if it completes locally (≈$0 marginal) vs if it escalates to frontier
339/// (frontier estimate + one cache-miss). Reported as a pair — a single
340/// number would be wrong in both directions.
341#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
342#[serde(rename_all = "camelCase")]
343pub struct TwoPathEstimate {
344    /// The completes-locally path: $0 marginal (fixed hardware, not per-token).
345    pub local_usd: f64,
346    /// The escalates-to-frontier path (frontier estimate + one cache-miss).
347    pub escalated: CostEstimate,
348    /// The priced cache-miss, once per escalation (see [`CACHE_MISS_MULT`]).
349    pub cache_miss_usd: f64,
350}
351
352/// Two-path estimate from the (shape-adjusted) frontier estimate when `cfg`
353/// routes the executor to the local tier; None for frontier-routed plans
354/// (their single frontier estimate is honest). Always None with a configured
355/// dispatch pool: pool candidates are never local-backed (validation rejects
356/// `local` entries), so a stray local `worker.backend` next to a pool must
357/// not paint a $0-marginal path over N paid streams.
358pub fn estimate_two_path(
359    frontier: CostEstimate,
360    cfg: &MissionConfig,
361    p: &EstimateParams,
362) -> Option<TwoPathEstimate> {
363    if !cfg.worker_candidates.is_empty() {
364        return None;
365    }
366    if cfg.worker.backend.as_deref() != Some("local") {
367        return None;
368    }
369    // The miss is paid once per escalation, never per turn: the first
370    // post-escalation turn re-reads the full prefix uncached; later turns
371    // rebuild a warm cache at the new tier.
372    let cache_miss_usd = p.avg_worker_run_usd * CACHE_MISS_MULT;
373    let mut escalated = frontier;
374    escalated.expected_usd += cache_miss_usd;
375    escalated.low_usd += cache_miss_usd;
376    escalated.high_usd += cache_miss_usd;
377    Some(TwoPathEstimate {
378        local_usd: 0.0,
379        escalated,
380        cache_miss_usd,
381    })
382}
383
384// ---------------------------------------------------------------------------
385// Mission shape classification (observable at plan time)
386// ---------------------------------------------------------------------------
387
388/// The plan-observable shape of a mission, used to flag validation contracts
389/// [`estimate`]'s calibration corpus doesn't cover (later features will widen
390/// ranges / lower confidence for these; this type is inert on its own).
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
392pub enum MissionShape {
393    /// Contract gates on a build/test/lint command — the calibration corpus's
394    /// typical shape.
395    CodeChange,
396    /// Contract leans on agent judgement with no build/test gate — expensive
397    /// to validate and not represented in the calibration corpus.
398    DocHeavy,
399    /// Neither signal present (empty or grep-only contract): treated as
400    /// neutral, never widened.
401    Unknown,
402}
403
404/// Classify a plan's shape from its validation contract alone (observable
405/// before any run happens).
406///
407/// `AgentJudgement` assertions are re-evaluated by validators and the
408/// orchestrator against the *entire, growing* mission diff on every
409/// validation pass — m-d341a7 had 2 judgement assertions over a 1074-line
410/// doc and that alone drove 21.5M cache-read tokens across 17 judgement
411/// turns. Missions that instead gate on a build/test/lint command bound that
412/// cost (the command runs once, deterministically, regardless of diff size),
413/// so any `Command` assertion invoking cargo/npm/pytest/go test/make wins
414/// over an `AgentJudgement` signal — the code-change shape doesn't scale
415/// with diff size the way a judgement-only contract does.
416pub fn classify_shape(plan: &Plan) -> MissionShape {
417    const BUILD_TEST_TOKENS: &[&str] = &[
418        "cargo test",
419        "cargo build",
420        "cargo check",
421        "cargo clippy",
422        "npm test",
423        "npm run",
424        "pytest",
425        "go test",
426        "make ",
427    ];
428
429    let judgements = plan
430        .validation_contract
431        .iter()
432        .filter(|a| a.check == AssertionCheck::AgentJudgement)
433        .count();
434
435    let build_test_cmd = plan.validation_contract.iter().any(|a| {
436        a.check == AssertionCheck::Command
437            && a.command
438                .as_deref()
439                .map(|c| {
440                    let lower = c.to_ascii_lowercase();
441                    BUILD_TEST_TOKENS.iter().any(|tok| lower.contains(tok))
442                })
443                .unwrap_or(false)
444    });
445
446    if build_test_cmd {
447        MissionShape::CodeChange
448    } else if judgements >= 1 {
449        MissionShape::DocHeavy
450    } else {
451        MissionShape::Unknown
452    }
453}
454
455// ---------------------------------------------------------------------------
456// Calibration from recorded actuals (roadmap M1)
457// ---------------------------------------------------------------------------
458
459/// [`EstimateParams`] derived from the repo's completed missions, plus how
460/// many missions informed them. `missions_used == 0` means the params are the
461/// built-in defaults (nothing to calibrate against yet).
462#[derive(Debug, Clone, Copy, PartialEq)]
463pub struct Calibration {
464    pub params: EstimateParams,
465    pub missions_used: usize,
466    /// How many of the completed missions folded into `params` classified as
467    /// [`MissionShape::DocHeavy`] (from their recorded plan). Zero means the
468    /// doc-heavy shape is uncovered by this corpus — see [`apply_shape`].
469    pub doc_heavy_missions_used: usize,
470    /// Multiplier on the raw [`estimate`] `expected_usd` that recenters it onto
471    /// the corpus (aggregate actual ÷ predicted). The calibrated per-run
472    /// `params` already carry most of the correction, so this removes only the
473    /// residual aggregate bias left by the count formula (≈1.08 on this repo's
474    /// corpus). `1.0` below [`MIN_CALIBRATION_MISSIONS`].
475    pub expected_mult: f64,
476    /// Multipliers on the raw `expected_usd` for the low/high bounds: the
477    /// empirical p10 / p90 of actual ÷ predicted, but clamped so the band only
478    /// ever WIDENS the built-in `0.5` / `2.5` guess (in-sample quantiles from a
479    /// small corpus understate a fresh mission's uncertainty). `0.5` / `2.5`
480    /// below [`MIN_CALIBRATION_MISSIONS`].
481    pub low_mult: f64,
482    pub high_mult: f64,
483    /// Completed missions EXCLUDED from the corpus for not being frontier
484    /// spend ([`MissionCostClass::Local`] or [`MissionCostClass::Mixed`]) —
485    /// their $0-marginal or blended actuals would distort the frontier
486    /// calibration. Visibility for the corpus-size readouts (ready.rs shows
487    /// `missions_used`).
488    pub excluded_non_frontier: usize,
489    /// Pearson r between per-mission gate activity and the actual÷predicted
490    /// ratio (gate activity = blocked + grant requests + fix features +
491    /// resumes). This is the calibrate-block-resume-cycles measurement;
492    /// `None` below [`MIN_CALIBRATION_MISSIONS`] or when either series is
493    /// constant.
494    pub gate_correlation: Option<f64>,
495}
496
497/// Minimum completed missions before the corpus fit ([`expected_mult`] etc.)
498/// engages. Below this a repo keeps the built-in 1.0 / 0.5 / 2.5 band so cold
499/// and small repos behave exactly as before the M1 calibration refit.
500///
501/// [`expected_mult`]: Calibration::expected_mult
502pub const MIN_CALIBRATION_MISSIONS: usize = 5;
503
504/// Whether a completed mission's spend is frontier-priced, local-marginal, or
505/// a blend — the calibration corpus is frontier-only, because a $0-marginal
506/// local mission next to $30–160 frontier missions would drag the corpus mean
507/// toward zero and pollute every future estimate (the
508/// local-inference-cost-accounting ticket).
509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510pub enum MissionCostClass {
511    /// Every run priced per-token at a frontier backend.
512    Frontier,
513    /// Routed to the local tier for the entire mission (`worker.backend ==
514    /// "local"`, never escalated): ~$0 marginal (fixed hardware +
515    /// electricity, not per-token).
516    Local,
517    /// Started local and escalated to frontier mid-mission: actuals blend
518    /// both tiers and are representative of neither corpus.
519    Mixed,
520}
521
522/// Classify a folded mission. After a `tier.escalated` fold the reducer
523/// resets `config.worker.backend` to None, so `still_local` reads true only
524/// for never-escalated local missions; the (still_local, escalated) cell is
525/// unreachable today and classified Mixed defensively. A configured dispatch
526/// pool forces Frontier: pool candidates are never local-backed, so every
527/// pool stream is per-token frontier spend.
528pub fn mission_cost_class(state: &MissionState) -> MissionCostClass {
529    let still_local = state.config.worker.backend.as_deref() == Some("local")
530        && state.config.worker_candidates.is_empty();
531    let escalated = state.escalated_milestones > 0;
532    match (still_local, escalated) {
533        (true, false) => MissionCostClass::Local,
534        (false, false) => MissionCostClass::Frontier,
535        _ => MissionCostClass::Mixed,
536    }
537}
538
539/// Gate-activity features of one mission's event log (the
540/// calibrate-block-resume-cycles ticket): the plumbing that blows estimates
541/// — checkpoint refusals, grant parks, validation fix cycles, resumes.
542/// Counted from events, never inferred from gaps.
543#[derive(Debug, Clone, Copy, Default, PartialEq)]
544pub struct GateActivity {
545    pub blocked: u32,
546    pub grant_requests: u32,
547    pub fix_features: u32,
548    pub resumes: u32,
549}
550
551impl GateActivity {
552    /// One scalar for correlation work. Fix features dominate because each
553    /// one is a full worker+validation cycle the count model under-prices.
554    pub fn score(&self) -> f64 {
555        (self.blocked + self.grant_requests + self.fix_features + self.resumes) as f64
556    }
557}
558
559/// Count one mission's gate activity from its event slice.
560pub fn gate_activity(events: &[Event]) -> GateActivity {
561    let mut activity = GateActivity::default();
562    for event in events {
563        match &event.kind {
564            EventKind::MilestoneBlocked { .. } => activity.blocked += 1,
565            EventKind::GrantRequested { .. } => activity.grant_requests += 1,
566            EventKind::FixFeatureCreated { .. } => activity.fix_features += 1,
567            EventKind::MissionResumed {} => activity.resumes += 1,
568            _ => {}
569        }
570    }
571    activity
572}
573
574/// Pearson r between two equal-length series; None when either is constant
575/// (correlation is undefined, not zero — small corpora must not read as
576/// "no relationship").
577fn pearson(xs: &[f64], ys: &[f64]) -> Option<f64> {
578    if xs.len() != ys.len() || xs.len() < 2 {
579        return None;
580    }
581    let n = xs.len() as f64;
582    let mean = |v: &[f64]| v.iter().sum::<f64>() / n;
583    let (mx, my) = (mean(xs), mean(ys));
584    let mut cov = 0.0;
585    let (mut vx, mut vy) = (0.0, 0.0);
586    for i in 0..xs.len() {
587        cov += (xs[i] - mx) * (ys[i] - my);
588        vx += (xs[i] - mx) * (xs[i] - mx);
589        vy += (ys[i] - my) * (ys[i] - my);
590    }
591    (vx > 0.0 && vy > 0.0).then(|| cov / vx.sqrt() / vy.sqrt())
592}
593
594/// Derive [`EstimateParams`] from the actuals recorded in this repo's
595/// COMPLETED missions (live estimates ran ~10x above actuals on the built-in
596/// defaults — real per-run costs are the fix).
597///
598/// Every mission under `.kranz/missions` is folded from its event log;
599/// unreadable or corrupt logs are skipped, as is any mission whose final
600/// status is not `Complete` (an in-flight or failed mission's actuals are not
601/// representative). Each surviving mission yields one set of per-mission
602/// actuals (see [`mission_actuals`]); the calibration is their simple mean.
603///
604/// With zero usable missions the built-in [`EstimateParams::default`] is
605/// returned with `missions_used == 0`. Derived costs are floored at $0.01 and
606/// rates at 0.0 so one weird mission (e.g. all-zero reported costs) cannot
607/// zero out future estimates.
608pub fn calibrate(repo_root: &Path) -> Calibration {
609    let mut per_mission: Vec<EstimateParams> = Vec::new();
610    let mut doc_heavy_missions_used = 0usize;
611    let mut excluded_non_frontier = 0usize;
612    // (milestones, planned features, config, actual total cost, gate-activity
613    // score) per completed mission — the inputs needed to re-predict each
614    // mission and measure the estimate's bias (see `fit_estimate_to_corpus`).
615    let mut corpus: Vec<(usize, usize, MissionConfig, f64, f64)> = Vec::new();
616    for mission_id in MissionPaths::list_missions(repo_root) {
617        let paths = MissionPaths::new(repo_root, &mission_id);
618        let Ok(events) = EventLog::read_events(&paths.events_file()) else {
619            continue; // missing or unreadable log: not calibration data
620        };
621        let Ok(state) = reducer::fold(&events) else {
622            continue; // corrupt / empty log: skip, never fail the estimate
623        };
624        if state.mission.status != MissionStatus::Complete {
625            continue;
626        }
627        // Frontier-only corpus (local-inference-cost-accounting): a
628        // $0-marginal local run next to $30–160 frontier missions would drag
629        // the corpus mean toward zero; a mixed (escalated) mission is
630        // representative of neither tier.
631        if mission_cost_class(&state) != MissionCostClass::Frontier {
632            excluded_non_frontier += 1;
633            continue;
634        }
635        if classify_shape(&mission_plan(&state)) == MissionShape::DocHeavy {
636            doc_heavy_missions_used += 1;
637        }
638        per_mission.push(mission_actuals(&state));
639        let milestones = state.mission.milestones.len();
640        let planned_features = state
641            .mission
642            .milestones
643            .iter()
644            .flat_map(|m| m.features.iter())
645            .filter(|f| f.origin == FeatureOrigin::Plan)
646            .count();
647        corpus.push((
648            milestones,
649            planned_features,
650            state.config.clone(),
651            mission_total_cost(&state),
652            gate_activity(&events).score(),
653        ));
654    }
655
656    if per_mission.is_empty() {
657        return Calibration {
658            params: EstimateParams::default(),
659            missions_used: 0,
660            doc_heavy_missions_used: 0,
661            expected_mult: 1.0,
662            low_mult: 0.5,
663            high_mult: 2.5,
664            excluded_non_frontier,
665            gate_correlation: None,
666        };
667    }
668
669    let n = per_mission.len() as f64;
670    let mean = |get: fn(&EstimateParams) -> f64| per_mission.iter().map(get).sum::<f64>() / n;
671    let params = EstimateParams {
672        respawn_allowance: mean(|p| p.respawn_allowance).max(0.0),
673        fix_cycles_per_milestone: mean(|p| p.fix_cycles_per_milestone).max(0.0),
674        fix_features_per_cycle: mean(|p| p.fix_features_per_cycle).max(0.0),
675        avg_worker_run_usd: mean(|p| p.avg_worker_run_usd).max(0.01),
676        avg_validator_run_usd: mean(|p| p.avg_validator_run_usd).max(0.01),
677        orchestrator_overhead_usd_per_feature: mean(|p| p.orchestrator_overhead_usd_per_feature)
678            .max(0.01),
679    };
680    let (expected_mult, low_mult, high_mult, gate_correlation) =
681        fit_estimate_to_corpus(&params, &corpus);
682    Calibration {
683        params,
684        missions_used: per_mission.len(),
685        doc_heavy_missions_used,
686        expected_mult,
687        low_mult,
688        high_mult,
689        excluded_non_frontier,
690        gate_correlation,
691    }
692}
693
694/// Total actual cost of a completed mission: the CLI-reported `cost_usd` per
695/// run, falling back to [`usage_cost_usd`], summed over every recorded run.
696fn mission_total_cost(state: &MissionState) -> f64 {
697    state
698        .runs
699        .values()
700        .map(|run| worker_run_cost(run, &state.config))
701        .sum()
702}
703
704/// Fit the count-based [`estimate`] to the corpus of completed missions
705/// (roadmap M1). Returns `(expected_mult, low_mult, high_mult)`, each a
706/// multiplier on the raw `estimate().expected_usd`:
707///
708/// - `expected_mult` = aggregate `Σ actual ÷ Σ predicted` — recenters the
709///   estimate onto reality, correcting the count formula's structural
710///   under-prediction (cost is driven by turns and diff size, not feature
711///   count);
712/// - `low_mult` / `high_mult` = the p10 / p90 of per-mission `actual ÷
713///   predicted` — the range that actually brackets past outcomes.
714///
715/// Below [`MIN_CALIBRATION_MISSIONS`] usable points the corpus can't be fit, so
716/// the built-in `1.0 / 0.5 / 2.5` is returned (an exact no-op in
717/// [`apply_shape`]). Values are clamped so one pathological mission can't blow
718/// the estimate up or collapse it.
719fn fit_estimate_to_corpus(
720    params: &EstimateParams,
721    corpus: &[(usize, usize, MissionConfig, f64, f64)],
722) -> (f64, f64, f64, Option<f64>) {
723    const DEFAULT: (f64, f64, f64, Option<f64>) = (1.0, 0.5, 2.5, None);
724    if corpus.len() < MIN_CALIBRATION_MISSIONS {
725        return DEFAULT;
726    }
727    let mut sum_pred = 0.0;
728    let mut sum_actual = 0.0;
729    let mut ratios: Vec<f64> = Vec::new();
730    let mut gate_scores: Vec<f64> = Vec::new();
731    for (milestones, features, cfg, actual, gate_score) in corpus {
732        let pred = estimate(&counts_plan(*milestones, *features), cfg, params).expected_usd;
733        if pred > 0.0 && *actual > 0.0 {
734            sum_pred += pred;
735            sum_actual += *actual;
736            ratios.push(*actual / pred);
737            gate_scores.push(*gate_score);
738        }
739    }
740    if ratios.len() < MIN_CALIBRATION_MISSIONS || sum_pred <= 0.0 {
741        return DEFAULT;
742    }
743    let gate_correlation = pearson(&gate_scores, &ratios);
744    ratios.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
745    let center = (sum_actual / sum_pred).clamp(0.1, 20.0);
746    // The band only ever WIDENS the built-in 0.5×/2.5× guess, never narrows it:
747    // these are in-sample quantiles from a small corpus, and they understate a
748    // fresh mission's true uncertainty — a repo whose past missions happen to
749    // cluster tightly must not yield an overconfident range. It also stays
750    // ordered around the recentered middle.
751    let low = percentile(&ratios, 0.10).min(0.5).min(center).max(0.02);
752    let high = percentile(&ratios, 0.90)
753        .max(2.5)
754        .max(center)
755        .min(center.max(1.0) * 8.0);
756    (center, low, high, gate_correlation)
757}
758
759/// A synthetic [`Plan`] carrying only the counts [`estimate`] reads (milestone
760/// count and total feature count); every other field is inert. Used to
761/// re-predict a completed mission from its recorded shape.
762fn counts_plan(milestones: usize, features: usize) -> Plan {
763    let milestones = milestones.max(1);
764    let mut ms = Vec::with_capacity(milestones);
765    for i in 0..milestones {
766        let n = if i == 0 { features } else { 0 };
767        ms.push(PlanMilestone {
768            title: String::new(),
769            features: (0..n)
770                .map(|_| PlanFeature {
771                    title: String::new(),
772                    spec: String::new(),
773                    validation_criteria: Vec::new(),
774                })
775                .collect(),
776        });
777    }
778    Plan {
779        goal: String::new(),
780        validation_contract: Vec::new(),
781        milestones: ms,
782        considered_alternatives: None,
783        command_grants: Vec::new(),
784        touch_set: Vec::new(),
785        standards_manifest: None,
786        reviewer_independence: None,
787    }
788}
789
790/// Nearest-rank percentile of an already-sorted slice (`q` in `[0,1]`).
791fn percentile(sorted: &[f64], q: f64) -> f64 {
792    if sorted.is_empty() {
793        return 0.0;
794    }
795    let idx = (q * (sorted.len() as f64 - 1.0)).round() as usize;
796    sorted[idx.min(sorted.len() - 1)]
797}
798
799/// Reconstruct the [`Plan`] a completed mission's state was approved from —
800/// only the fields [`classify_shape`] reads (validation contract) matter, so
801/// the milestone/feature reconstruction just needs consistent counts.
802fn mission_plan(state: &MissionState) -> Plan {
803    Plan {
804        goal: state.mission.goal.clone(),
805        considered_alternatives: None,
806        command_grants: state.mission.command_grants.clone(),
807        touch_set: state.mission.touch_set.clone(),
808        standards_manifest: state.mission.standards_manifest.clone().map(Box::new),
809        reviewer_independence: state.mission.reviewer_independence,
810        validation_contract: state.mission.validation_contract.clone(),
811        milestones: state
812            .mission
813            .milestones
814            .iter()
815            .map(|m| PlanMilestone {
816                title: m.title.clone(),
817                features: m
818                    .features
819                    .iter()
820                    .map(|f| PlanFeature {
821                        title: f.title.clone(),
822                        spec: f.spec.clone(),
823                        validation_criteria: f.validation_criteria.clone(),
824                    })
825                    .collect(),
826            })
827            .collect(),
828    }
829}
830
831/// Widen and confidence-label a base [`estimate`] for shapes the calibration
832/// corpus doesn't cover.
833///
834/// [`estimate`] itself stays shape-neutral (existing callers/tests see
835/// identical numbers). This is the post-processing step: classify the plan,
836/// and only if it is [`MissionShape::DocHeavy`] AND the corpus has zero
837/// doc-heavy missions (`cal.doc_heavy_missions_used == 0`) do we act — the
838/// corpus's pooled per-run costs are tuned for code-change missions, so a
839/// judgement-heavy plan's `expected_usd` is a reasonable central guess (same
840/// per-run rates) but its upper bound is not: a comparable mission
841/// (m-d341a7) ran to $163.64 against an $18.35 base estimate, ~9x over.
842/// `expected_usd` and `low_usd` are left alone (no evidence they're
843/// mis-centered); `high_usd` is widened to `expected_usd *
844/// LOW_CONFIDENCE_HIGH_MULT` so the range brackets that kind of overrun with
845/// margin, and `confidence` drops to `Low` so callers can flag it.
846///
847/// Every other case (`CodeChange`, `Unknown`, or a `DocHeavy` shape the
848/// corpus now has examples of) is a strict no-op beyond stamping `shape` and
849/// `confidence: High`.
850pub fn apply_shape(base: CostEstimate, plan: &Plan, cal: &Calibration) -> CostEstimate {
851    let shape = classify_shape(plan);
852    let mut est = base;
853    est.shape = shape;
854
855    // Recenter and empirically range the estimate onto the calibration corpus
856    // (roadmap M1). `raw` is the count formula's central guess; the corpus fit
857    // corrects its systematic under-prediction and replaces the fixed 0.5×/2.5×
858    // band with the observed p10/p90. Below MIN_CALIBRATION_MISSIONS the mults
859    // are 1.0 / 0.5 / 2.5, so this block is an exact no-op.
860    let raw = est.expected_usd;
861    est.expected_usd = raw * cal.expected_mult;
862    est.low_usd = raw * cal.low_mult;
863    est.high_usd = raw * cal.high_mult;
864
865    if shape == MissionShape::DocHeavy && cal.doc_heavy_missions_used == 0 {
866        est.confidence = Confidence::Low;
867        est.high_usd = est
868            .high_usd
869            .max(est.expected_usd * LOW_CONFIDENCE_HIGH_MULT);
870    } else {
871        est.confidence = Confidence::High;
872    }
873    est
874}
875
876/// Multiplier applied to `expected_usd` to get `high_usd` when a plan's shape
877/// is uncovered by the calibration corpus (see [`apply_shape`]). Chosen so
878/// the widened high comfortably exceeds m-d341a7's recorded actual
879/// ($163.64) from its own $18.35 base estimate: 15.0 * 18.35 = $275.25.
880const LOW_CONFIDENCE_HIGH_MULT: f64 = 15.0;
881
882/// One completed mission's actuals, expressed in [`EstimateParams`] terms so
883/// [`calibrate`] can average them directly:
884///
885/// - avg worker / validator run cost: mean over runs of that role of the
886///   CLI-reported `cost_usd`, falling back to [`usage_cost_usd`];
887/// - respawn allowance: total respawns / planned (origin `Plan`) features;
888/// - fix cycles per milestone: total fix cycles / milestones;
889/// - fix features per cycle: origin-`Fix` features / max(total fix cycles, 1);
890/// - orchestrator overhead per feature: total orchestrator run cost / total
891///   features.
892fn mission_actuals(state: &MissionState) -> EstimateParams {
893    let run_cost = |run: &WorkerRun| worker_run_cost(run, &state.config);
894    let mean_run_cost = |roles: &[Role]| -> f64 {
895        let costs: Vec<f64> = state
896            .runs
897            .values()
898            .filter(|r| roles.contains(&r.role))
899            .map(run_cost)
900            .collect();
901        if costs.is_empty() {
902            0.0
903        } else {
904            costs.iter().sum::<f64>() / costs.len() as f64
905        }
906    };
907
908    let features = || {
909        state
910            .mission
911            .milestones
912            .iter()
913            .flat_map(|m| m.features.iter())
914    };
915    let total_features = features().count() as f64;
916    let planned_features = features()
917        .filter(|f| f.origin == FeatureOrigin::Plan)
918        .count() as f64;
919    let fix_features = features()
920        .filter(|f| f.origin == FeatureOrigin::Fix)
921        .count() as f64;
922    let total_respawns = features().map(|f| f.respawns as f64).sum::<f64>();
923    let milestones = state.mission.milestones.len() as f64;
924    let total_fix_cycles = state
925        .mission
926        .milestones
927        .iter()
928        .map(|m| m.fix_cycles as f64)
929        .sum::<f64>();
930
931    let orchestrator_total = state
932        .runs
933        .values()
934        .filter(|r| r.role == Role::Orchestrator)
935        .map(run_cost)
936        .sum::<f64>();
937
938    let safe_div = |num: f64, den: f64| if den > 0.0 { num / den } else { 0.0 };
939
940    EstimateParams {
941        respawn_allowance: safe_div(total_respawns, planned_features),
942        fix_cycles_per_milestone: safe_div(total_fix_cycles, milestones),
943        fix_features_per_cycle: fix_features / total_fix_cycles.max(1.0),
944        avg_worker_run_usd: mean_run_cost(&[Role::Worker]),
945        avg_validator_run_usd: mean_run_cost(&[Role::ValidatorScrutiny, Role::ValidatorFunctional]),
946        orchestrator_overhead_usd_per_feature: safe_div(orchestrator_total, total_features),
947    }
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953
954    #[test]
955    fn codex_pricing_applied() {
956        let codex = pricing_for_model(DEFAULT_CODEX_MODEL);
957        assert_eq!(codex.input_per_mtok, 4.0);
958        assert_eq!(codex.output_per_mtok, 20.0);
959
960        let opus = pricing_for_model("opus");
961        assert_ne!(codex, opus);
962
963        let usage = TokenUsage {
964            input: 2_000_000,
965            output: 1_000_000,
966            cache_read: 500_000,
967            cache_write: 200_000,
968        };
969        // This fixture carries 2.7M total input tokens, so the Sol default's
970        // long-context rates apply to every input lane and to output.
971        let expected =
972            (2.0 * 4.0 + 0.5 * (0.1 * 4.0) + 0.2 * (1.25 * 4.0)) * 2.0 + 1.0 * 20.0 * 1.5;
973        let got = usage_cost_usd(&usage, DEFAULT_CODEX_MODEL);
974        assert!(
975            (got - expected).abs() < 1e-9,
976            "got {got}, expected {expected}"
977        );
978    }
979
980    #[test]
981    fn gpt_5_6_alias_uses_sol_pricing() {
982        assert_eq!(
983            pricing_for_model("gpt-5.6"),
984            pricing_for_model("gpt-5.6-sol")
985        );
986    }
987
988    #[test]
989    fn gpt_5_6_sol_long_context_multiplier_starts_above_272k() {
990        let at_threshold = TokenUsage {
991            input: 200_000,
992            cache_read: 72_000,
993            cache_write: 0,
994            output: 10_000,
995        };
996        let base = 0.2 * 4.0 + 0.072 * 0.4 + 0.01 * 20.0;
997        assert!((usage_cost_usd(&at_threshold, "gpt-5.6-sol") - base).abs() < 1e-9);
998
999        let above_threshold = TokenUsage {
1000            input: 200_001,
1001            ..at_threshold
1002        };
1003        let long = (0.200001 * 4.0 + 0.072 * 0.4) * 2.0 + 0.01 * 20.0 * 1.5;
1004        assert!((usage_cost_usd(&above_threshold, "gpt-5.6-sol") - long).abs() < 1e-9);
1005        assert!((usage_cost_usd(&above_threshold, "gpt-5.6") - long).abs() < 1e-9);
1006    }
1007
1008    #[test]
1009    fn unknown_model_falls_back_to_opus_tier() {
1010        let unknown = pricing_for_model("some-unknown-model-xyz");
1011        let opus = pricing_for_model("opus");
1012        assert_eq!(unknown, opus);
1013    }
1014
1015    #[test]
1016    fn droid_pricing_applied() {
1017        let glm = pricing_for_model(DEFAULT_DROID_MODEL);
1018        assert_eq!(glm.input_per_mtok, 0.55);
1019        assert_eq!(glm.output_per_mtok, 2.19);
1020
1021        let opus = pricing_for_model("opus");
1022        let codex = pricing_for_model(DEFAULT_CODEX_MODEL);
1023        assert_ne!(glm, opus);
1024        assert_ne!(glm, codex);
1025
1026        let usage = TokenUsage {
1027            input: 2_000_000,
1028            output: 1_000_000,
1029            cache_read: 500_000,
1030            cache_write: 200_000,
1031        };
1032        let expected = 2.0 * 0.55 + 1.0 * 2.19 + 0.5 * (0.1 * 0.55) + 0.2 * (1.25 * 0.55);
1033        let got = usage_cost_usd(&usage, DEFAULT_DROID_MODEL);
1034        assert!(
1035            (got - expected).abs() < 1e-9,
1036            "got {got}, expected {expected}"
1037        );
1038
1039        let fable = pricing_for_model("claude-fable-5");
1040        assert_eq!(fable.input_per_mtok, 10.0);
1041        assert_eq!(fable.output_per_mtok, 50.0);
1042    }
1043
1044    #[test]
1045    fn kimi_pricing_applied() {
1046        let kimi = pricing_for_model(DEFAULT_KIMI_MODEL);
1047        assert_eq!(kimi.input_per_mtok, 0.60);
1048        assert_eq!(kimi.output_per_mtok, 2.50);
1049
1050        let opus = pricing_for_model("opus");
1051        let codex = pricing_for_model(DEFAULT_CODEX_MODEL);
1052        let droid = pricing_for_model(DEFAULT_DROID_MODEL);
1053        assert_ne!(kimi, opus);
1054        assert_ne!(kimi, codex);
1055        assert_ne!(kimi, droid);
1056
1057        assert!(is_kimi_model("kimi-code/k3"));
1058        assert!(is_kimi_model("K3"));
1059        assert!(!is_kimi_model("opus"));
1060
1061        let usage = TokenUsage {
1062            input: 2_000_000,
1063            output: 1_000_000,
1064            cache_read: 500_000,
1065            cache_write: 200_000,
1066        };
1067        let expected = 2.0 * 0.60 + 1.0 * 2.50 + 0.5 * (0.1 * 0.60) + 0.2 * (1.25 * 0.60);
1068        let got = usage_cost_usd(&usage, DEFAULT_KIMI_MODEL);
1069        assert!(
1070            (got - expected).abs() < 1e-9,
1071            "got {got}, expected {expected}"
1072        );
1073    }
1074
1075    #[test]
1076    fn local_usage_cost_is_always_zero() {
1077        // Local model ids are free-form (e.g. "my-local-model") and never
1078        // match a pricing family, so the local-aware fallback must return $0
1079        // regardless of how much usage was reported.
1080        let usage = TokenUsage {
1081            input: 2_000_000,
1082            output: 1_000_000,
1083            cache_read: 500_000,
1084            cache_write: 200_000,
1085        };
1086        assert_eq!(
1087            usage_cost_usd_for_backend(&usage, "my-local-model", BackendKind::Local),
1088            0.0
1089        );
1090        assert_eq!(
1091            usage_cost_usd_for_backend(&usage, "anything-at-all", BackendKind::Local),
1092            0.0
1093        );
1094    }
1095
1096    #[test]
1097    fn local_usage_cost_does_not_change_other_backend_pricing() {
1098        let usage = TokenUsage {
1099            input: 2_000_000,
1100            output: 1_000_000,
1101            cache_read: 500_000,
1102            cache_write: 200_000,
1103        };
1104        for (backend, model) in [
1105            (BackendKind::Claude, "sonnet"),
1106            (BackendKind::Codex, DEFAULT_CODEX_MODEL),
1107            (BackendKind::Droid, DEFAULT_DROID_MODEL),
1108            (BackendKind::Kimi, DEFAULT_KIMI_MODEL),
1109        ] {
1110            assert_eq!(
1111                usage_cost_usd_for_backend(&usage, model, backend),
1112                usage_cost_usd(&usage, model),
1113                "backend {backend:?} pricing should be unchanged"
1114            );
1115        }
1116    }
1117
1118    // -----------------------------------------------------------------------
1119    // Local-inference cost accounting: classification + calibration exclusion
1120    // -----------------------------------------------------------------------
1121
1122    use crate::event_log::{EventLog, LockForce};
1123    use crate::events::{Event, EventKind};
1124    use crate::paths::MissionPaths;
1125    use std::time::Duration;
1126
1127    fn seed_mission(repo_root: &Path, id: &str, kinds: Vec<EventKind>) {
1128        let paths = MissionPaths::new(repo_root, id);
1129        let mut log = EventLog::acquire(&paths, id, Duration::ZERO, LockForce::No).unwrap();
1130        for kind in kinds {
1131            log.append(kind).unwrap();
1132        }
1133    }
1134
1135    fn created_with(config: MissionConfig) -> EventKind {
1136        EventKind::MissionCreated {
1137            goal: "g".into(),
1138            base_branch: "main".into(),
1139            mission_branch: "kranz/mission-x".into(),
1140            config,
1141        }
1142    }
1143
1144    fn local_config() -> MissionConfig {
1145        let mut cfg = MissionConfig::default();
1146        cfg.worker.backend = Some("local".to_string());
1147        cfg
1148    }
1149
1150    fn approved_and_completed() -> Vec<EventKind> {
1151        vec![
1152            EventKind::PlanApproved {
1153                plan: crate::types::Plan {
1154                    goal: "g".into(),
1155                    validation_contract: vec![],
1156                    // One milestone so tier.escalated has a real ms-1 to
1157                    // reference (the reducer rejects unknown milestones).
1158                    milestones: vec![crate::types::PlanMilestone {
1159                        title: "milestone one".into(),
1160                        features: vec![crate::types::PlanFeature {
1161                            title: "alpha".into(),
1162                            spec: "build alpha".into(),
1163                            validation_criteria: vec![],
1164                        }],
1165                    }],
1166                    considered_alternatives: None,
1167                    command_grants: vec![],
1168                    touch_set: vec![],
1169                    standards_manifest: None,
1170                    reviewer_independence: None,
1171                },
1172                base_sha: None,
1173            },
1174            EventKind::MissionCompleted {},
1175        ]
1176    }
1177
1178    #[test]
1179    fn mission_cost_class_maps_local_mixed_and_frontier() {
1180        let cases = [
1181            (MissionConfig::default(), false, MissionCostClass::Frontier),
1182            (local_config(), false, MissionCostClass::Local),
1183            (local_config(), true, MissionCostClass::Mixed),
1184        ];
1185        for (config, escalate, expected) in cases {
1186            let mut kinds = vec![created_with(config)];
1187            kinds.extend(approved_and_completed());
1188            if escalate {
1189                kinds.insert(
1190                    kinds.len() - 1,
1191                    EventKind::TierEscalated {
1192                        milestone_id: "ms-1".into(),
1193                        from: crate::types::ExecutorTier::Local,
1194                        to: crate::types::ExecutorTier::Frontier,
1195                        reason: "two failed local validations".into(),
1196                    },
1197                );
1198            }
1199            let events: Vec<Event> = kinds
1200                .into_iter()
1201                .enumerate()
1202                .map(|(i, kind)| Event {
1203                    seq: (i + 1) as u64,
1204                    ts: chrono::Utc::now(),
1205                    mission_id: "m-1".into(),
1206                    kind,
1207                })
1208                .collect();
1209            let state = crate::reducer::fold(&events).unwrap();
1210            assert_eq!(mission_cost_class(&state), expected, "escalate={escalate}");
1211        }
1212    }
1213
1214    #[test]
1215    fn calibrate_excludes_local_and_mixed_from_the_frontier_corpus() {
1216        let tmp = tempfile::tempdir().unwrap();
1217        let root = tmp.path();
1218
1219        // One frontier mission + one local + one escalated, all Complete.
1220        let mut frontier = vec![created_with(MissionConfig::default())];
1221        frontier.extend(approved_and_completed());
1222        seed_mission(root, "m-frontier", frontier);
1223
1224        let mut local = vec![created_with(local_config())];
1225        local.extend(approved_and_completed());
1226        seed_mission(root, "m-local", local);
1227
1228        let mut mixed = vec![created_with(local_config())];
1229        mixed.extend(approved_and_completed());
1230        // Escalate before completion: started local, ended frontier.
1231        mixed.insert(
1232            mixed.len() - 1,
1233            EventKind::TierEscalated {
1234                milestone_id: "ms-1".into(),
1235                from: crate::types::ExecutorTier::Local,
1236                to: crate::types::ExecutorTier::Frontier,
1237                reason: "two failed local validations".into(),
1238            },
1239        );
1240        seed_mission(root, "m-mixed", mixed);
1241
1242        let calibration = calibrate(root);
1243        // The pin from the ticket: a local (or mixed) run does NOT enter the
1244        // frontier calibration set.
1245        assert_eq!(calibration.missions_used, 1, "frontier missions only");
1246        assert_eq!(calibration.excluded_non_frontier, 2);
1247    }
1248
1249    #[test]
1250    fn gate_activity_counts_blocked_grants_fixes_and_resumes() {
1251        let events = vec![
1252            Event {
1253                seq: 1,
1254                ts: chrono::Utc::now(),
1255                mission_id: "m-1".into(),
1256                kind: EventKind::MilestoneBlocked {
1257                    block_context: None,
1258                    milestone_id: "ms-1".into(),
1259                    reason: "r".into(),
1260                },
1261            },
1262            Event {
1263                seq: 2,
1264                ts: chrono::Utc::now(),
1265                mission_id: "m-1".into(),
1266                kind: EventKind::GrantRequested {
1267                    milestone_id: "ms-1".into(),
1268                    kind: crate::types::GrantKind::Command,
1269                    command: "cargo test".into(),
1270                },
1271            },
1272            Event {
1273                seq: 3,
1274                ts: chrono::Utc::now(),
1275                mission_id: "m-1".into(),
1276                kind: EventKind::FixFeatureCreated {
1277                    milestone_id: "ms-1".into(),
1278                    feature: crate::types::Feature {
1279                        id: "ms-1-fix-1-1".into(),
1280                        title: "fix".into(),
1281                        spec: "s".into(),
1282                        validation_criteria: vec![],
1283                        origin: crate::types::FeatureOrigin::Fix,
1284                        status: crate::types::FeatureStatus::Pending,
1285                        worker_runs: vec![],
1286                        commits: vec![],
1287                        respawns: 0,
1288                    },
1289                },
1290            },
1291            Event {
1292                seq: 4,
1293                ts: chrono::Utc::now(),
1294                mission_id: "m-1".into(),
1295                kind: EventKind::MissionResumed {},
1296            },
1297            Event {
1298                seq: 5,
1299                ts: chrono::Utc::now(),
1300                mission_id: "m-1".into(),
1301                kind: EventKind::MissionCompleted {},
1302            },
1303        ];
1304        let activity = gate_activity(&events);
1305        assert_eq!(activity.blocked, 1);
1306        assert_eq!(activity.grant_requests, 1);
1307        assert_eq!(activity.fix_features, 1);
1308        assert_eq!(activity.resumes, 1);
1309        assert_eq!(activity.score(), 4.0);
1310    }
1311
1312    #[test]
1313    fn fit_widens_to_cover_a_gate_heavy_outlier_and_reports_correlation() {
1314        // Five missions: four on-estimate, one gate-heavy outlier at 4x.
1315        // The p90 band must cover the outlier without the center chasing it,
1316        // and the gate-activity series must correlate with the overrun.
1317        let params = EstimateParams::default();
1318        let cfg = MissionConfig::default();
1319        let mut corpus: Vec<(usize, usize, MissionConfig, f64, f64)> = Vec::new();
1320        for _ in 0..4 {
1321            let pred = estimate(&counts_plan(1, 2), &cfg, &params).expected_usd;
1322            corpus.push((1, 2, cfg.clone(), pred, 1.0)); // ratio 1.0, quiet
1323        }
1324        let pred = estimate(&counts_plan(1, 2), &cfg, &params).expected_usd;
1325        corpus.push((1, 2, cfg.clone(), pred * 4.0, 13.0)); // the m-b66d34 shape
1326
1327        let (center, _low, high, gate_correlation) = fit_estimate_to_corpus(&params, &corpus);
1328        assert!(high >= 4.0, "p90 must cover the 4x outlier: high={high}");
1329        assert!(
1330            center < 2.0,
1331            "the recentered middle must not chase the outlier: {center}"
1332        );
1333        let r = gate_correlation.expect("correlation defined with variance");
1334        assert!(r > 0.9, "gate activity tracks the overrun: r={r}");
1335    }
1336
1337    #[test]
1338    fn two_path_prices_the_miss_once_and_only_for_local_routes() {
1339        let p = EstimateParams::default();
1340        let base = CostEstimate {
1341            worker_runs: 4.0,
1342            validator_runs: 2.0,
1343            low_usd: 5.0,
1344            expected_usd: 10.0,
1345            high_usd: 25.0,
1346            shape: MissionShape::Unknown,
1347            confidence: Confidence::High,
1348        };
1349
1350        // Frontier routes keep the single honest estimate.
1351        assert!(estimate_two_path(base, &MissionConfig::default(), &p).is_none());
1352
1353        // Local routes get both paths, with the cache-miss priced ONCE —
1354        // never multiplied by runs or turns.
1355        let local_cfg = local_config();
1356        let two = estimate_two_path(base, &local_cfg, &p).unwrap();
1357        let miss = p.avg_worker_run_usd * CACHE_MISS_MULT;
1358        assert_eq!(two.local_usd, 0.0, "completes-locally is $0 marginal");
1359        assert_eq!(two.cache_miss_usd, miss);
1360        assert_eq!(
1361            two.escalated.expected_usd,
1362            10.0 + miss,
1363            "the miss is priced once per escalation, never per turn"
1364        );
1365        assert_eq!(two.escalated.low_usd, 5.0 + miss);
1366        assert_eq!(two.escalated.high_usd, 25.0 + miss);
1367    }
1368
1369    // -----------------------------------------------------------------------
1370    // Heterogeneous dispatch pool (KRZ-303): the consent multiplier
1371    // -----------------------------------------------------------------------
1372
1373    fn dispatch_pool_config() -> MissionConfig {
1374        MissionConfig {
1375            worker_candidates: vec![
1376                crate::types::CandidateSpec {
1377                    backend: "claude".into(),
1378                    model: "sonnet".into(),
1379                },
1380                crate::types::CandidateSpec {
1381                    backend: "codex".into(),
1382                    model: DEFAULT_CODEX_MODEL.into(),
1383                },
1384            ],
1385            ..MissionConfig::default()
1386        }
1387    }
1388
1389    #[test]
1390    fn dispatch_pool_estimate_multiplies_worker_runs_only() {
1391        // The consent multiplier: N=2 candidates double every worker session
1392        // count; validators and orchestrator overhead are not fanned out.
1393        let plan = counts_plan(1, 2);
1394        let p = EstimateParams::default();
1395        let single = estimate(&plan, &MissionConfig::default(), &p);
1396        let pooled = estimate(&plan, &dispatch_pool_config(), &p);
1397
1398        assert_eq!(pooled.worker_runs, single.worker_runs * 2.0);
1399        assert_eq!(pooled.validator_runs, single.validator_runs);
1400        let expected = single.worker_runs * 2.0 * p.avg_worker_run_usd
1401            + single.validator_runs * p.avg_validator_run_usd
1402            + 2.0 * p.orchestrator_overhead_usd_per_feature;
1403        assert!(
1404            (pooled.expected_usd - expected).abs() < 1e-9,
1405            "pooled {} vs hand-computed {expected}",
1406            pooled.expected_usd
1407        );
1408        assert!(pooled.expected_usd > single.expected_usd);
1409        assert!(pooled.high_usd > single.high_usd);
1410        // The multiplier is exactly N (not more): the estimate must not
1411        // double-count the fan-out.
1412        let delta = pooled.expected_usd - single.expected_usd;
1413        assert!(
1414            (delta - single.worker_runs * p.avg_worker_run_usd).abs() < 1e-9,
1415            "delta {delta} should be exactly one more worker-share ({})",
1416            single.worker_runs * p.avg_worker_run_usd
1417        );
1418    }
1419
1420    #[test]
1421    fn dispatch_pool_two_path_suppressed_and_cost_class_frontier() {
1422        // A stray local worker.backend next to a pool must not paint a
1423        // $0-marginal path over N paid streams, nor classify the mission's
1424        // actuals as local-tier for calibration.
1425        let p = EstimateParams::default();
1426        let mut cfg = dispatch_pool_config();
1427        cfg.worker.backend = Some("local".to_string());
1428        let base = estimate(&counts_plan(1, 1), &cfg, &p);
1429        assert!(estimate_two_path(base, &cfg, &p).is_none());
1430
1431        let mut kinds = vec![created_with(cfg)];
1432        kinds.extend(approved_and_completed());
1433        let events: Vec<Event> = kinds
1434            .into_iter()
1435            .enumerate()
1436            .map(|(i, kind)| Event {
1437                seq: (i + 1) as u64,
1438                ts: chrono::Utc::now(),
1439                mission_id: "m-1".into(),
1440                kind,
1441            })
1442            .collect();
1443        let state = crate::reducer::fold(&events).unwrap();
1444        assert_eq!(mission_cost_class(&state), MissionCostClass::Frontier);
1445        assert_eq!(state.executor_tier(), crate::types::ExecutorTier::Frontier);
1446    }
1447}
1448
1449#[cfg(test)]
1450#[path = "resolved_accounting_tests.rs"]
1451mod resolved_accounting_tests;