1use 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
22pub(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
38pub(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
58pub const DEFAULT_CODEX_MODEL: &str = "gpt-5.6-sol";
60
61pub fn is_codex_model(model: &str) -> bool {
64 let m = model.to_ascii_lowercase();
65 m.contains("codex") || m.contains("gpt")
66}
67
68pub const DEFAULT_DROID_MODEL: &str = "accounts/fireworks/models/glm-5p2";
71
72pub fn is_droid_model(model: &str) -> bool {
75 let m = model.to_ascii_lowercase();
76 m.contains("glm") || m.contains("fireworks")
77}
78
79pub const DEFAULT_KIMI_MODEL: &str = "kimi-code/k3";
82
83pub const DEFAULT_CURSOR_MODEL: &str = "gpt-5";
88
89pub fn is_kimi_model(model: &str) -> bool {
92 let m = model.to_ascii_lowercase();
93 m.contains("k3") || m.contains("kimi")
94}
95
96#[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 pub fn cache_read_per_mtok(&self) -> f64 {
106 0.1 * self.input_per_mtok
107 }
108
109 pub fn cache_write_per_mtok(&self) -> f64 {
111 1.25 * self.input_per_mtok
112 }
113}
114
115pub 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 Pricing {
138 input_per_mtok: 0.55,
139 output_per_mtok: 2.19,
140 }
141 } else if m.contains("k3") || m.contains("kimi") {
142 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
175pub 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 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
200pub 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#[derive(Debug, Clone, Copy, PartialEq)]
216pub struct EstimateParams {
217 pub respawn_allowance: f64,
219 pub fix_cycles_per_milestone: f64,
221 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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
245pub struct CostEstimate {
246 pub worker_runs: f64,
248 pub validator_runs: f64,
250 pub low_usd: f64,
251 pub expected_usd: f64,
252 pub high_usd: f64,
253 pub shape: MissionShape,
256 pub confidence: Confidence,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263pub enum Confidence {
264 High,
267 Low,
270}
271
272pub 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 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
328pub const CACHE_MISS_MULT: f64 = 9.0;
336
337#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
342#[serde(rename_all = "camelCase")]
343pub struct TwoPathEstimate {
344 pub local_usd: f64,
346 pub escalated: CostEstimate,
348 pub cache_miss_usd: f64,
350}
351
352pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
392pub enum MissionShape {
393 CodeChange,
396 DocHeavy,
399 Unknown,
402}
403
404pub 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#[derive(Debug, Clone, Copy, PartialEq)]
463pub struct Calibration {
464 pub params: EstimateParams,
465 pub missions_used: usize,
466 pub doc_heavy_missions_used: usize,
470 pub expected_mult: f64,
476 pub low_mult: f64,
482 pub high_mult: f64,
483 pub excluded_non_frontier: usize,
489 pub gate_correlation: Option<f64>,
495}
496
497pub const MIN_CALIBRATION_MISSIONS: usize = 5;
503
504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510pub enum MissionCostClass {
511 Frontier,
513 Local,
517 Mixed,
520}
521
522pub 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#[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 pub fn score(&self) -> f64 {
555 (self.blocked + self.grant_requests + self.fix_features + self.resumes) as f64
556 }
557}
558
559pub 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
574fn 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
594pub 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 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; };
621 let Ok(state) = reducer::fold(&events) else {
622 continue; };
624 if state.mission.status != MissionStatus::Complete {
625 continue;
626 }
627 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(¶ms, &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
694fn 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
704fn 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 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
759fn 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
790fn 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
799fn 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
831pub 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 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
876const LOW_CONFIDENCE_HIGH_MULT: f64 = 15.0;
881
882fn 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 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 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 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 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 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 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 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 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, ¶ms).expected_usd;
1322 corpus.push((1, 2, cfg.clone(), pred, 1.0)); }
1324 let pred = estimate(&counts_plan(1, 2), &cfg, ¶ms).expected_usd;
1325 corpus.push((1, 2, cfg.clone(), pred * 4.0, 13.0)); let (center, _low, high, gate_correlation) = fit_estimate_to_corpus(¶ms, &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 assert!(estimate_two_path(base, &MissionConfig::default(), &p).is_none());
1352
1353 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 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 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 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 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;