1use std::collections::HashMap;
48use std::path::Path;
49
50use firstpass_core::{Attempt, Config as RoutingConfig, DeferredVerdict, TaskKind, Trace, Verdict};
51
52use crate::calibrate::gate_score;
53use crate::store::{self, StoreError};
54
55#[derive(Debug)]
62pub struct CandidatePolicy {
63 pub ladder: Vec<String>,
65 pub serve_threshold: Option<f64>,
67}
68
69impl CandidatePolicy {
70 pub fn from_toml(toml: &str) -> Result<Self, String> {
78 let config = RoutingConfig::parse(toml).map_err(|e| e.to_string())?;
79 let ladder = config
80 .routes
81 .into_iter()
82 .next()
83 .map(|r| r.ladder)
84 .unwrap_or_default();
85 Ok(Self {
86 ladder,
87 serve_threshold: config.escalation.serve_threshold,
88 })
89 }
90}
91
92fn would_serve(attempt: &Attempt, policy: &CandidatePolicy) -> bool {
96 match policy.serve_threshold {
97 Some(t) => gate_score(&attempt.gates, attempt.verdict) >= t,
98 None => attempt.verdict == Verdict::Pass,
99 }
100}
101
102fn attempt_cost(a: &Attempt) -> f64 {
104 a.cost_usd + a.gates.iter().map(|g| g.cost_usd).sum::<f64>()
105}
106
107enum ReplayResult {
108 Unevaluable,
110 Evaluated {
111 cost: f64,
113 served_model: Option<String>,
115 logged_served_model: Option<String>,
117 },
118}
119
120fn replay_trace(trace: &Trace, policy: &CandidatePolicy) -> ReplayResult {
121 let logged_served_model = trace.final_.served_rung.and_then(|rung| {
123 trace
124 .attempts
125 .iter()
126 .find(|a| a.rung == rung)
127 .map(|a| a.model.clone())
128 });
129
130 let mut total_cost = 0.0f64;
131 let mut served_model: Option<String> = None;
132
133 for model in &policy.ladder {
134 let Some(attempt) = trace.attempts.iter().find(|a| &a.model == model) else {
135 return ReplayResult::Unevaluable;
136 };
137 total_cost += attempt_cost(attempt);
138 if would_serve(attempt, policy) {
139 served_model = Some(model.clone());
140 break;
141 }
142 }
143
144 ReplayResult::Evaluated {
145 cost: total_cost,
146 served_model,
147 logged_served_model,
148 }
149}
150
151fn splitmix64(state: &mut u64) -> u64 {
156 *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
157 let mut z = *state;
158 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
159 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
160 z ^ (z >> 31)
161}
162
163#[inline]
164fn rand_usize(rng: &mut u64, n: usize) -> usize {
165 (splitmix64(rng) % n as u64) as usize
166}
167
168fn bootstrap_mean_ci(values: &[f64], n_resamples: usize, seed: u64) -> (f64, f64) {
170 if values.is_empty() {
171 return (0.0, 0.0);
172 }
173 let n = values.len();
174 let mut rng = seed;
175 let mut means: Vec<f64> = (0..n_resamples)
176 .map(|_| {
177 let s: f64 = (0..n).map(|_| values[rand_usize(&mut rng, n)]).sum();
178 s / n as f64
179 })
180 .collect();
181 means.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
182 let lo_idx = (n_resamples as f64 * 0.025) as usize;
183 let hi_idx = ((n_resamples as f64 * 0.975) as usize).min(n_resamples - 1);
184 (means[lo_idx], means[hi_idx])
185}
186
187fn bootstrap_failure_ci(correct: &[bool], n_resamples: usize, seed: u64) -> (f64, f64) {
189 if correct.is_empty() {
190 return (0.0, 0.0);
191 }
192 let n = correct.len();
193 let mut rng = seed;
194 let mut rates: Vec<f64> = (0..n_resamples)
195 .map(|_| {
196 let fails = (0..n).filter(|_| !correct[rand_usize(&mut rng, n)]).count();
197 fails as f64 / n as f64
198 })
199 .collect();
200 rates.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
201 let lo_idx = (n_resamples as f64 * 0.025) as usize;
202 let hi_idx = ((n_resamples as f64 * 0.975) as usize).min(n_resamples - 1);
203 (rates[lo_idx], rates[hi_idx])
204}
205
206#[derive(Debug, Clone)]
210pub struct OpeReport {
211 pub n_traces: usize,
213 pub n_evaluable: usize,
215 pub coverage: f64,
217 pub est_cost_per_request: f64,
219 pub logged_cost_per_request: f64,
221 pub est_served_failure: Option<f64>,
224 pub n_correctness_known: usize,
226 pub escalation_rate: f64,
228 pub ci_cost: (f64, f64),
230 pub ci_served_failure: Option<(f64, f64)>,
232}
233
234impl OpeReport {
235 #[must_use]
238 pub fn render(&self) -> String {
239 let mut out = format!(
240 "traces: {n_traces} evaluable: {n_evaluable} coverage: {cov:.3}\n\
241 n_correctness_known: {n_known}\n\
242 est cost/request: ${est:.6} (logged: ${logged:.6})\n\
243 cost CI [2.5%, 97.5%]: [${clo:.6}, ${chi:.6}]\n\
244 escalation rate: {esc:.4}\n",
245 n_traces = self.n_traces,
246 n_evaluable = self.n_evaluable,
247 cov = self.coverage,
248 n_known = self.n_correctness_known,
249 est = self.est_cost_per_request,
250 logged = self.logged_cost_per_request,
251 clo = self.ci_cost.0,
252 chi = self.ci_cost.1,
253 esc = self.escalation_rate,
254 );
255 match self.est_served_failure {
256 Some(f) => {
257 let (lo, hi) = self.ci_served_failure.unwrap_or((f, f));
258 out.push_str(&format!(
259 "est served-failure: {f:.4} CI [{lo:.4}, {hi:.4}]\n"
260 ));
261 }
262 None => {
263 out.push_str(
264 "est served-failure: n/a (no deferred feedback on same-rung evaluable traces)\n",
265 );
266 }
267 }
268 out.push_str(
269 "\nreplay of logged outcomes (direct method); \
270 rungs never logged are not guessed β see coverage.\n",
271 );
272 out
273 }
274}
275
276struct EvalPoint {
280 candidate_cost: f64,
281 logged_cost: f64,
282 correctness: Option<bool>,
284 escalated: bool,
285}
286
287fn build_report(n_traces: usize, points: Vec<EvalPoint>) -> OpeReport {
288 let n_evaluable = points.len();
289 let coverage = if n_traces == 0 {
291 1.0
292 } else {
293 n_evaluable as f64 / n_traces as f64
294 };
295
296 if points.is_empty() {
297 return OpeReport {
298 n_traces,
299 n_evaluable: 0,
300 coverage,
301 est_cost_per_request: 0.0,
302 logged_cost_per_request: 0.0,
303 est_served_failure: None,
304 n_correctness_known: 0,
305 escalation_rate: 0.0,
306 ci_cost: (0.0, 0.0),
307 ci_served_failure: None,
308 };
309 }
310
311 let est_cost = mean(&points, |p| p.candidate_cost);
312 let logged_cost = mean(&points, |p| p.logged_cost);
313 let escalation_rate = points.iter().filter(|p| p.escalated).count() as f64 / n_evaluable as f64;
314
315 let known: Vec<bool> = points.iter().filter_map(|p| p.correctness).collect();
316 let n_correctness_known = known.len();
317 let est_served_failure = if known.is_empty() {
318 None
319 } else {
320 Some(known.iter().filter(|&&c| !c).count() as f64 / known.len() as f64)
321 };
322
323 let costs: Vec<f64> = points.iter().map(|p| p.candidate_cost).collect();
324 let ci_cost = bootstrap_mean_ci(&costs, 1000, 42);
326 let ci_served_failure = if known.is_empty() {
327 None
328 } else {
329 Some(bootstrap_failure_ci(&known, 1000, 43))
330 };
331
332 OpeReport {
333 n_traces,
334 n_evaluable,
335 coverage,
336 est_cost_per_request: est_cost,
337 logged_cost_per_request: logged_cost,
338 est_served_failure,
339 n_correctness_known,
340 escalation_rate,
341 ci_cost,
342 ci_served_failure,
343 }
344}
345
346fn mean(points: &[EvalPoint], f: impl Fn(&EvalPoint) -> f64) -> f64 {
347 if points.is_empty() {
348 return 0.0;
349 }
350 points.iter().map(f).sum::<f64>() / points.len() as f64
351}
352
353pub fn ope_from_store(
362 db_path: impl AsRef<Path>,
363 tenant: &str,
364 policy: &CandidatePolicy,
365) -> Result<OpeReport, StoreError> {
366 let traces = store::load_tenant_traces(&db_path, tenant).unwrap_or_default();
367 let n_traces = traces.len();
368 let mut points: Vec<EvalPoint> = Vec::with_capacity(n_traces);
369
370 for trace in &traces {
371 let deferred = store::load_deferred(&db_path, &trace.trace_id.to_string())?;
372 let replay = replay_trace(trace, policy);
373 let ReplayResult::Evaluated {
374 cost,
375 served_model,
376 logged_served_model,
377 } = replay
378 else {
379 continue; };
381
382 let correctness = match (&served_model, &logged_served_model) {
386 (Some(cm), Some(lm)) if cm == lm => deferred
387 .last()
388 .map(|dv: &DeferredVerdict| dv.verdict == Verdict::Pass),
389 _ => None,
390 };
391
392 let first_model = policy.ladder.first();
393 let escalated = match (&served_model, first_model) {
394 (Some(m), Some(first)) => m != first,
395 (None, Some(_)) => true, _ => false,
397 };
398
399 points.push(EvalPoint {
400 candidate_cost: cost,
401 logged_cost: trace.final_.total_cost_usd,
402 correctness,
403 escalated,
404 });
405 }
406
407 Ok(build_report(n_traces, points))
408}
409
410#[derive(Debug, Clone)]
421pub struct IpsReport {
422 pub n_traces: usize,
424 pub n_with_propensity: usize,
426 pub candidate_start_rung: u32,
428 pub ips_cost: f64,
430 pub snips_cost: f64,
432 pub ess: f64,
434 pub ci_ips_cost: (f64, f64),
436 pub ips_served_failure: Option<f64>,
438 pub snips_served_failure: Option<f64>,
440 pub n_correctness_known: usize,
442 pub ci_ips_served_failure: Option<(f64, f64)>,
444 pub dr_cost: f64,
449 pub ci_dr_cost: (f64, f64),
451}
452
453impl IpsReport {
454 #[must_use]
456 pub fn render(&self) -> String {
457 let mut out = format!(
458 "traces: {n} n_with_propensity: {nwp} candidate_start_rung: {sr}\n\
459 IPS cost/request: ${ips:.6}\n\
460 SNIPS cost/request: ${snips:.6}\n\
461 IPS cost CI [2.5%, 97.5%]: [${clo:.6}, ${chi:.6}]\n\
462 effective sample size (ESS): {ess:.1}\n\
463 n_correctness_known: {nck}\n",
464 n = self.n_traces,
465 nwp = self.n_with_propensity,
466 sr = self.candidate_start_rung,
467 ips = self.ips_cost,
468 snips = self.snips_cost,
469 clo = self.ci_ips_cost.0,
470 chi = self.ci_ips_cost.1,
471 ess = self.ess,
472 nck = self.n_correctness_known,
473 );
474 out.push_str(&format!(
475 "DR cost/request: ${dr:.6}\n\
476 DR cost CI [2.5%, 97.5%]: [${drlo:.6}, ${drhi:.6}]\n",
477 dr = self.dr_cost,
478 drlo = self.ci_dr_cost.0,
479 drhi = self.ci_dr_cost.1,
480 ));
481 match self.ips_served_failure {
482 Some(f) => {
483 let sf_snips = self.snips_served_failure.unwrap_or(f);
484 let (lo, hi) = self.ci_ips_served_failure.unwrap_or((f, f));
485 out.push_str(&format!(
486 "IPS served-failure: {f:.4} SNIPS: {sf_snips:.4} CI [{lo:.4}, {hi:.4}]\n"
487 ));
488 }
489 None => {
490 out.push_str(
491 "IPS served-failure: n/a (no deferred feedback on matched-start traces)\n",
492 );
493 }
494 }
495 out.push_str(
496 "\nIPS/SNIPS/DR valid only for candidates over the same logged ladder with \
497 propensity-logged traffic. Traces without propensity excluded. \
498 DR reward model: per-(task-kind, rung) empirical mean β coarse; see ponytail comment. \
499 Direct-method replay remains for ladder/threshold changes.\n",
500 );
501 out
502 }
503}
504
505struct DmModel {
519 bucket: HashMap<(TaskKind, u32), f64>,
521 rung_mean: HashMap<u32, f64>,
523 global_mean: f64,
525}
526
527impl DmModel {
528 fn build(traces: &[Trace]) -> Self {
529 let mut bucket_acc: HashMap<(TaskKind, u32), (f64, u32)> = HashMap::new();
530 let mut rung_acc: HashMap<u32, (f64, u32)> = HashMap::new();
531 let mut global_sum = 0.0f64;
532 let mut global_n = 0u32;
533
534 for trace in traces {
535 if trace.policy.propensity.is_none() {
536 continue; }
538 let Some(first) = trace.attempts.first() else {
539 continue;
540 };
541 let rung = first.rung;
542 let cost = trace.final_.total_cost_usd;
543 let ctx = trace.request.features.task_kind;
544
545 let be = bucket_acc.entry((ctx, rung)).or_default();
546 be.0 += cost;
547 be.1 += 1;
548
549 let re = rung_acc.entry(rung).or_default();
550 re.0 += cost;
551 re.1 += 1;
552
553 global_sum += cost;
554 global_n += 1;
555 }
556
557 let bucket = bucket_acc
558 .into_iter()
559 .map(|(k, (s, n))| (k, s / n as f64))
560 .collect();
561 let rung_mean = rung_acc
562 .into_iter()
563 .map(|(k, (s, n))| (k, s / n as f64))
564 .collect();
565 let global_mean = if global_n > 0 {
566 global_sum / global_n as f64
567 } else {
568 0.0
569 };
570
571 Self {
572 bucket,
573 rung_mean,
574 global_mean,
575 }
576 }
577
578 fn predict(&self, task_kind: TaskKind, rung: u32) -> f64 {
580 self.bucket
581 .get(&(task_kind, rung))
582 .copied()
583 .or_else(|| self.rung_mean.get(&rung).copied())
584 .unwrap_or(self.global_mean)
585 }
586}
587
588pub fn ips_from_store(
597 db_path: impl AsRef<Path>,
598 tenant: &str,
599 candidate_start_rung: u32,
600) -> Result<IpsReport, StoreError> {
601 let traces = store::load_tenant_traces(&db_path, tenant).unwrap_or_default();
602 let n_traces = traces.len();
603
604 struct IpsPoint {
605 weight: f64,
606 cost: f64,
607 correctness: Option<bool>,
609 task_kind: TaskKind,
611 logged_rung: u32,
613 }
614
615 let mut points: Vec<IpsPoint> = Vec::with_capacity(n_traces);
616 let mut n_with_propensity = 0usize;
617
618 for trace in &traces {
619 let Some(p) = trace.policy.propensity else {
620 continue; };
622 if p <= 0.0 {
623 continue; }
625 n_with_propensity += 1;
626
627 let logged_start = trace.attempts.first().map(|a| a.rung);
628 let indicator = f64::from(logged_start == Some(candidate_start_rung));
629 let w = indicator / p;
630
631 let correctness = if w > 0.0 {
634 let deferred = store::load_deferred(&db_path, &trace.trace_id.to_string())?;
635 deferred
636 .last()
637 .map(|dv: &DeferredVerdict| dv.verdict == Verdict::Pass)
638 } else {
639 None
640 };
641
642 points.push(IpsPoint {
643 weight: w,
644 cost: trace.final_.total_cost_usd,
645 correctness,
646 task_kind: trace.request.features.task_kind,
647 logged_rung: logged_start.unwrap_or(0),
648 });
649 }
650
651 let n = n_with_propensity as f64;
652 let sum_w: f64 = points.iter().map(|p| p.weight).sum();
653 let sum_w2: f64 = points.iter().map(|p| p.weight * p.weight).sum();
654 let sum_wc: f64 = points.iter().map(|p| p.weight * p.cost).sum();
655
656 let ips_cost = if n > 0.0 { sum_wc / n } else { 0.0 };
657 let snips_cost = if sum_w > 0.0 { sum_wc / sum_w } else { 0.0 };
658 let ess = if sum_w2 > 0.0 {
659 sum_w * sum_w / sum_w2
660 } else {
661 0.0
662 };
663
664 let wc_values: Vec<f64> = points.iter().map(|p| p.weight * p.cost).collect();
666 let ci_ips_cost = bootstrap_mean_ci(&wc_values, 1000, 42);
667
668 let known: Vec<(f64, bool)> = points
670 .iter()
671 .filter_map(|p| p.correctness.map(|c| (p.weight, c)))
672 .collect();
673 let n_correctness_known = known.len();
674
675 let (ips_served_failure, snips_served_failure, ci_ips_served_failure) = if known.is_empty() {
676 (None, None, None)
677 } else {
678 let n_known = known.len() as f64;
679 let sum_wf: f64 = known.iter().filter(|(_, c)| !c).map(|(w, _)| w).sum();
680 let sum_wk: f64 = known.iter().map(|(w, _)| w).sum();
681 let ips_f = sum_wf / n_known;
682 let snips_f = if sum_wk > 0.0 { sum_wf / sum_wk } else { 0.0 };
683 let wf_vals: Vec<f64> = known
684 .iter()
685 .map(|(w, c)| if !c { *w } else { 0.0 })
686 .collect();
687 let ci = bootstrap_mean_ci(&wf_vals, 1000, 43);
688 (Some(ips_f), Some(snips_f), Some(ci))
689 };
690
691 let dm = DmModel::build(&traces);
696 let dr_vals: Vec<f64> = points
697 .iter()
698 .map(|pt| {
699 let dm_cand = dm.predict(pt.task_kind, candidate_start_rung);
700 let dm_logged = dm.predict(pt.task_kind, pt.logged_rung);
701 dm_cand + pt.weight * (pt.cost - dm_logged)
702 })
703 .collect();
704 let dr_cost = if dr_vals.is_empty() {
705 0.0
706 } else {
707 dr_vals.iter().sum::<f64>() / dr_vals.len() as f64
708 };
709 let ci_dr_cost = bootstrap_mean_ci(&dr_vals, 1000, 44);
711
712 Ok(IpsReport {
713 n_traces,
714 n_with_propensity,
715 candidate_start_rung,
716 ips_cost,
717 snips_cost,
718 ess,
719 ci_ips_cost,
720 ips_served_failure,
721 snips_served_failure,
722 n_correctness_known,
723 ci_ips_served_failure,
724 dr_cost,
725 ci_dr_cost,
726 })
727}
728
729#[cfg(test)]
732mod tests {
733 use firstpass_core::{
734 Features, FinalOutcome, GENESIS_HASH, GateResult, Mode, PolicyRef, RequestInfo, Score,
735 ServedFrom, TaskKind, Verdict,
736 };
737
738 use super::*;
739 use crate::store;
740
741 fn make_trace(tenant: &str, rung: u32, model: &str, pass_score: f64, cost_usd: f64) -> Trace {
744 let verdict = if pass_score >= 0.5 {
745 Verdict::Pass
746 } else {
747 Verdict::Fail
748 };
749 let attempt = firstpass_core::Attempt {
750 rung,
751 model: model.to_owned(),
752 provider: "anthropic".to_owned(),
753 in_tokens: 10,
754 out_tokens: 5,
755 cost_usd,
756 latency_ms: 12,
757 gates: vec![GateResult {
758 gate_id: "gate@v1".to_owned(),
759 verdict,
760 score: Some(Score::clamped(pass_score)),
761 cost_usd: 0.0,
762 ms: 10,
763 reason: None,
764 evidence_ref: None,
765 }],
766 verdict,
767 };
768 let mut trace = Trace {
769 trace_id: uuid::Uuid::now_v7(),
770 prev_hash: GENESIS_HASH.to_owned(),
771 tenant_id: tenant.to_owned(),
772 session_id: "s1".to_owned(),
773 ts: jiff::Timestamp::now(),
774 mode: Mode::Enforce,
775 policy: PolicyRef {
776 id: "test@v0".to_owned(),
777 explore: false,
778 propensity: None,
779 mode_profile: None,
780 },
781 request: RequestInfo {
782 api: "anthropic.messages".to_owned(),
783 prompt_hash: "deadbeef".to_owned(),
784 features: Features::new(TaskKind::Other),
785 },
786 attempts: vec![attempt],
787 deferred: Vec::new(),
788 final_: FinalOutcome {
789 served_rung: Some(rung),
790 served_from: ServedFrom::Attempt,
791 total_cost_usd: cost_usd,
792 gate_cost_usd: 0.0,
793 total_latency_ms: 12,
794 escalations: 0,
795 counterfactual_baseline_usd: cost_usd,
796 savings_usd: 0.0,
797 },
798 probe: None,
799 rollout: None,
800 shadow: None,
801 route_ix: None,
802 predicted_pass: None,
803 elastic: None,
804 };
805 trace.recompute_savings();
806 trace
807 }
808
809 fn make_escalated_trace(tenant: &str, haiku_cost: f64, sonnet_cost: f64) -> Trace {
811 let haiku = firstpass_core::Attempt {
812 rung: 0,
813 model: "haiku".to_owned(),
814 provider: "anthropic".to_owned(),
815 in_tokens: 10,
816 out_tokens: 5,
817 cost_usd: haiku_cost,
818 latency_ms: 10,
819 gates: vec![GateResult {
820 gate_id: "g".to_owned(),
821 verdict: Verdict::Fail,
822 score: Some(Score::clamped(0.3)),
823 cost_usd: 0.0,
824 ms: 5,
825 reason: None,
826 evidence_ref: None,
827 }],
828 verdict: Verdict::Fail,
829 };
830 let sonnet = firstpass_core::Attempt {
831 rung: 1,
832 model: "sonnet".to_owned(),
833 provider: "anthropic".to_owned(),
834 in_tokens: 10,
835 out_tokens: 5,
836 cost_usd: sonnet_cost,
837 latency_ms: 20,
838 gates: vec![GateResult {
839 gate_id: "g".to_owned(),
840 verdict: Verdict::Pass,
841 score: Some(Score::clamped(0.9)),
842 cost_usd: 0.0,
843 ms: 5,
844 reason: None,
845 evidence_ref: None,
846 }],
847 verdict: Verdict::Pass,
848 };
849 let total = haiku_cost + sonnet_cost;
850 let mut trace = Trace {
851 trace_id: uuid::Uuid::now_v7(),
852 prev_hash: GENESIS_HASH.to_owned(),
853 tenant_id: tenant.to_owned(),
854 session_id: "s2".to_owned(),
855 ts: jiff::Timestamp::now(),
856 mode: Mode::Enforce,
857 policy: PolicyRef {
858 id: "test@v0".to_owned(),
859 explore: false,
860 propensity: None,
861 mode_profile: None,
862 },
863 request: RequestInfo {
864 api: "anthropic.messages".to_owned(),
865 prompt_hash: "beef".to_owned(),
866 features: Features::new(TaskKind::Other),
867 },
868 attempts: vec![haiku, sonnet],
869 deferred: Vec::new(),
870 final_: FinalOutcome {
871 served_rung: Some(1),
872 served_from: ServedFrom::Attempt,
873 total_cost_usd: total,
874 gate_cost_usd: 0.0,
875 total_latency_ms: 30,
876 escalations: 1,
877 counterfactual_baseline_usd: total,
878 savings_usd: 0.0,
879 },
880 probe: None,
881 rollout: None,
882 shadow: None,
883 route_ix: None,
884 predicted_pass: None,
885 elastic: None,
886 };
887 trace.recompute_savings();
888 trace
889 }
890
891 fn deferred_pass(gate: &str) -> firstpass_core::DeferredVerdict {
892 firstpass_core::DeferredVerdict {
893 gate_id: gate.to_owned(),
894 verdict: Verdict::Pass,
895 score: None,
896 reported_at: jiff::Timestamp::now(),
897 reporter: "test".to_owned(),
898 }
899 }
900
901 fn deferred_fail(gate: &str) -> firstpass_core::DeferredVerdict {
902 firstpass_core::DeferredVerdict {
903 gate_id: gate.to_owned(),
904 verdict: Verdict::Fail,
905 score: None,
906 reported_at: jiff::Timestamp::now(),
907 reporter: "test".to_owned(),
908 }
909 }
910
911 fn tmp_db() -> std::path::PathBuf {
912 std::env::temp_dir().join(format!("fp-ope-{}.db", uuid::Uuid::now_v7()))
913 }
914
915 #[tokio::test]
918 async fn candidate_equals_logged_matches_exactly() {
919 let db = tmp_db();
920 let (tx, handle) = store::open(&db).unwrap();
921
922 let mut ids = Vec::new();
924 for _ in 0..10 {
925 let t = make_trace("tenant-a", 0, "haiku", 0.8, 0.001);
926 ids.push(t.trace_id.to_string());
927 tx.try_send(t).unwrap();
928 }
929 drop(tx);
930 handle.await.unwrap();
931
932 for id in &ids {
933 store::append_deferred(&db, id, &deferred_pass("out")).unwrap();
934 }
935
936 let policy = CandidatePolicy {
937 ladder: vec!["haiku".to_owned()],
938 serve_threshold: None,
939 };
940 let report = ope_from_store(&db, "tenant-a", &policy).unwrap();
941
942 assert_eq!(report.n_traces, 10);
943 assert_eq!(report.n_evaluable, 10);
944 assert!((report.coverage - 1.0).abs() < 1e-9);
945 assert!((report.est_cost_per_request - 0.001).abs() < 1e-9);
947 assert!((report.logged_cost_per_request - 0.001).abs() < 1e-9);
948 assert_eq!(report.n_correctness_known, 10);
950 assert!((report.est_served_failure.unwrap() - 0.0).abs() < 1e-9);
951 assert!((report.escalation_rate - 0.0).abs() < 1e-9);
953
954 let _ = std::fs::remove_file(&db);
955 }
956
957 #[tokio::test]
960 async fn cheaper_candidate_reduces_cost_and_escalation() {
961 let db = tmp_db();
962 let (tx, handle) = store::open(&db).unwrap();
963
964 for _ in 0..5 {
967 tx.try_send(make_escalated_trace("t", 0.001, 0.01)).unwrap();
968 }
969 drop(tx);
970 handle.await.unwrap();
971
972 let policy = CandidatePolicy {
976 ladder: vec!["haiku".to_owned()],
977 serve_threshold: None,
978 };
979 let report = ope_from_store(&db, "t", &policy).unwrap();
980
981 assert_eq!(report.n_evaluable, 5);
982 assert!((report.coverage - 1.0).abs() < 1e-9);
983 assert!(
985 (report.est_cost_per_request - 0.001).abs() < 1e-9,
986 "got {}",
987 report.est_cost_per_request
988 );
989 assert!((report.logged_cost_per_request - 0.011).abs() < 1e-9);
990 assert_eq!(report.n_correctness_known, 0);
992 assert!(report.est_served_failure.is_none());
993 assert!((report.escalation_rate - 1.0).abs() < 1e-9);
995
996 let _ = std::fs::remove_file(&db);
997 }
998
999 #[tokio::test]
1002 async fn unlogged_model_makes_trace_unevaluable() {
1003 let db = tmp_db();
1004 let (tx, handle) = store::open(&db).unwrap();
1005
1006 for _ in 0..3 {
1008 tx.try_send(make_trace("t", 0, "haiku", 0.8, 0.001))
1009 .unwrap();
1010 }
1011 for _ in 0..3 {
1012 tx.try_send(make_trace("t", 0, "sonnet", 0.8, 0.01))
1013 .unwrap();
1014 }
1015 drop(tx);
1016 handle.await.unwrap();
1017
1018 let policy = CandidatePolicy {
1021 ladder: vec!["newmodel".to_owned(), "haiku".to_owned()],
1022 serve_threshold: None,
1023 };
1024 let report = ope_from_store(&db, "t", &policy).unwrap();
1025
1026 assert_eq!(report.n_traces, 6);
1027 assert_eq!(report.n_evaluable, 0);
1028 assert!((report.coverage - 0.0).abs() < 1e-9);
1029 assert!(report.est_served_failure.is_none());
1030
1031 let _ = std::fs::remove_file(&db);
1032 }
1033
1034 #[tokio::test]
1037 async fn different_rung_served_correctness_unknown() {
1038 let db = tmp_db();
1039 let (tx, handle) = store::open(&db).unwrap();
1040
1041 let t = make_escalated_trace("t", 0.001, 0.01);
1043 let tid = t.trace_id.to_string();
1044 tx.try_send(t).unwrap();
1045 drop(tx);
1046 handle.await.unwrap();
1047
1048 store::append_deferred(&db, &tid, &deferred_pass("out")).unwrap();
1050
1051 let policy = CandidatePolicy {
1054 ladder: vec!["haiku".to_owned(), "sonnet".to_owned()],
1055 serve_threshold: Some(0.1), };
1057 let report = ope_from_store(&db, "t", &policy).unwrap();
1058
1059 assert_eq!(report.n_evaluable, 1);
1060 assert_eq!(report.n_correctness_known, 0, "different rung => UNKNOWN");
1061 assert!(report.est_served_failure.is_none());
1062 assert!((report.est_cost_per_request - 0.001).abs() < 1e-9);
1064 assert!((report.escalation_rate - 0.0).abs() < 1e-9);
1066
1067 let _ = std::fs::remove_file(&db);
1068 }
1069
1070 #[tokio::test]
1073 async fn bootstrap_ci_deterministic_and_sane() {
1074 let db = tmp_db();
1075 let (tx, handle) = store::open(&db).unwrap();
1076
1077 let mut ids = Vec::new();
1079 for i in 0..30u32 {
1080 let cost = if i % 2 == 0 { 0.001 } else { 0.002 };
1081 let t = make_trace("t", 0, "m", 0.9, cost);
1082 ids.push(t.trace_id.to_string());
1083 tx.try_send(t).unwrap();
1084 }
1085 drop(tx);
1086 handle.await.unwrap();
1087
1088 for (i, id) in ids.iter().enumerate() {
1090 let dv = if i < 15 {
1091 deferred_pass("o")
1092 } else {
1093 deferred_fail("o")
1094 };
1095 store::append_deferred(&db, id, &dv).unwrap();
1096 }
1097
1098 let policy = CandidatePolicy {
1099 ladder: vec!["m".to_owned()],
1100 serve_threshold: None,
1101 };
1102 let r1 = ope_from_store(&db, "t", &policy).unwrap();
1103 let r2 = ope_from_store(&db, "t", &policy).unwrap();
1104
1105 assert_eq!(r1.ci_cost, r2.ci_cost, "CI must be deterministic");
1107 assert_eq!(r1.ci_served_failure, r2.ci_served_failure);
1108
1109 let (lo, hi) = r1.ci_cost;
1111 assert!(
1112 lo <= r1.est_cost_per_request + 1e-9,
1113 "CI lo {lo} > est {}",
1114 r1.est_cost_per_request
1115 );
1116 assert!(
1117 hi >= r1.est_cost_per_request - 1e-9,
1118 "CI hi {hi} < est {}",
1119 r1.est_cost_per_request
1120 );
1121 assert!(lo <= hi, "CI must be ordered");
1122
1123 if let Some((flo, fhi)) = r1.ci_served_failure {
1124 let f = r1.est_served_failure.unwrap();
1125 assert!(flo <= f + 1e-9, "failure CI lo {flo} > est {f}");
1126 assert!(fhi >= f - 1e-9, "failure CI hi {fhi} < est {f}");
1127 assert!(flo <= fhi);
1128 }
1129
1130 let _ = std::fs::remove_file(&db);
1131 }
1132
1133 #[test]
1136 fn empty_store_returns_zero_trace_report() {
1137 let policy = CandidatePolicy {
1139 ladder: vec!["m".to_owned()],
1140 serve_threshold: None,
1141 };
1142 let db = std::path::Path::new("/nonexistent/fp-ope-empty.db");
1143 let report = ope_from_store(db, "t", &policy).unwrap();
1144 assert_eq!(report.n_traces, 0);
1145 assert_eq!(report.n_evaluable, 0);
1146 assert!((report.coverage - 1.0).abs() < 1e-9);
1147 assert!(report.est_served_failure.is_none());
1148 }
1149
1150 #[tokio::test]
1153 async fn same_rung_deferred_correctness_attributed() {
1154 let db = tmp_db();
1155 let (tx, handle) = store::open(&db).unwrap();
1156
1157 let t_pass = make_trace("t", 0, "m", 0.9, 0.001);
1158 let t_fail = make_trace("t", 0, "m", 0.9, 0.001);
1159 let (id_pass, id_fail) = (t_pass.trace_id.to_string(), t_fail.trace_id.to_string());
1160 tx.try_send(t_pass).unwrap();
1161 tx.try_send(t_fail).unwrap();
1162 drop(tx);
1163 handle.await.unwrap();
1164
1165 store::append_deferred(&db, &id_pass, &deferred_pass("out")).unwrap();
1166 store::append_deferred(&db, &id_fail, &deferred_fail("out")).unwrap();
1167
1168 let policy = CandidatePolicy {
1169 ladder: vec!["m".to_owned()],
1170 serve_threshold: None,
1171 };
1172 let r = ope_from_store(&db, "t", &policy).unwrap();
1173
1174 assert_eq!(r.n_evaluable, 2);
1175 assert_eq!(r.n_correctness_known, 2);
1176 assert!((r.est_served_failure.unwrap() - 0.5).abs() < 1e-9);
1178
1179 let _ = std::fs::remove_file(&db);
1180 }
1181
1182 #[tokio::test]
1185 async fn high_threshold_forces_escalation_and_higher_cost() {
1186 let db = tmp_db();
1187 let (tx, handle) = store::open(&db).unwrap();
1188
1189 let t = make_trace("t", 0, "haiku", 0.7, 0.001); tx.try_send(t).unwrap();
1194 drop(tx);
1195 handle.await.unwrap();
1196
1197 let policy = CandidatePolicy {
1198 ladder: vec!["haiku".to_owned()],
1199 serve_threshold: Some(0.8), };
1201 let r = ope_from_store(&db, "t", &policy).unwrap();
1202
1203 assert_eq!(r.n_evaluable, 1);
1206 assert!((r.est_cost_per_request - 0.001).abs() < 1e-9); assert_eq!(r.n_correctness_known, 0); assert!((r.escalation_rate - 1.0).abs() < 1e-9);
1210
1211 let _ = std::fs::remove_file(&db);
1212 }
1213
1214 #[test]
1217 fn from_toml_extracts_first_route_and_threshold() {
1218 let toml = r#"
1219[[route]]
1220match = {}
1221mode = "enforce"
1222ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
1223
1224[escalation]
1225serve_threshold = 0.75
1226"#;
1227 let p = CandidatePolicy::from_toml(toml).unwrap();
1228 assert_eq!(
1229 p.ladder,
1230 ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
1231 );
1232 assert!((p.serve_threshold.unwrap() - 0.75).abs() < 1e-9);
1233 }
1234
1235 #[test]
1236 fn from_toml_no_threshold_is_none() {
1237 let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"m\"]\n";
1238 let p = CandidatePolicy::from_toml(toml).unwrap();
1239 assert!(p.serve_threshold.is_none());
1240 }
1241
1242 fn make_propensity_trace(
1248 tenant: &str,
1249 rung: u32,
1250 cost_usd: f64,
1251 propensity: Option<f64>,
1252 ) -> Trace {
1253 let attempt = firstpass_core::Attempt {
1254 rung,
1255 model: "m".to_owned(),
1256 provider: "anthropic".to_owned(),
1257 in_tokens: 10,
1258 out_tokens: 5,
1259 cost_usd,
1260 latency_ms: 5,
1261 gates: vec![],
1262 verdict: Verdict::Pass,
1263 };
1264 let mut trace = Trace {
1265 trace_id: uuid::Uuid::now_v7(),
1266 prev_hash: GENESIS_HASH.to_owned(),
1267 tenant_id: tenant.to_owned(),
1268 session_id: "s".to_owned(),
1269 ts: jiff::Timestamp::now(),
1270 mode: Mode::Enforce,
1271 policy: PolicyRef {
1272 id: "bandit@v1+eps".to_owned(),
1273 explore: rung != 0,
1274 propensity,
1275 mode_profile: None,
1276 },
1277 request: RequestInfo {
1278 api: "anthropic.messages".to_owned(),
1279 prompt_hash: "ph".to_owned(),
1280 features: Features::new(TaskKind::Other),
1281 },
1282 attempts: vec![attempt],
1283 deferred: Vec::new(),
1284 final_: FinalOutcome {
1285 served_rung: Some(rung),
1286 served_from: ServedFrom::Attempt,
1287 total_cost_usd: cost_usd,
1288 gate_cost_usd: 0.0,
1289 total_latency_ms: 5,
1290 escalations: 0,
1291 counterfactual_baseline_usd: cost_usd,
1292 savings_usd: 0.0,
1293 },
1294 probe: None,
1295 rollout: None,
1296 shadow: None,
1297 route_ix: None,
1298 predicted_pass: None,
1299 elastic: None,
1300 };
1301 trace.recompute_savings();
1302 trace
1303 }
1304
1305 #[tokio::test]
1325 async fn ips_correctness_from_known_logging_policy() {
1326 let db = tmp_db();
1327 let (tx, handle) = store::open(&db).unwrap();
1328
1329 for _ in 0..45 {
1330 tx.try_send(make_propensity_trace("t", 0, 0.001, Some(0.9)))
1331 .unwrap();
1332 }
1333 for _ in 0..5 {
1334 tx.try_send(make_propensity_trace("t", 1, 0.010, Some(0.1)))
1335 .unwrap();
1336 }
1337 drop(tx);
1338 handle.await.unwrap();
1339
1340 let report = ips_from_store(&db, "t", 1).unwrap();
1341
1342 assert_eq!(report.n_traces, 50);
1343 assert_eq!(report.n_with_propensity, 50);
1344 assert!(
1346 (report.ips_cost - 0.010).abs() < 1e-9,
1347 "IPS cost={} expected 0.010",
1348 report.ips_cost
1349 );
1350 assert!(
1351 (report.snips_cost - 0.010).abs() < 1e-9,
1352 "SNIPS cost={} expected 0.010",
1353 report.snips_cost
1354 );
1355 assert!(
1356 report.ess.is_finite() && report.ess > 0.0,
1357 "ESS must be positive"
1358 );
1359 assert!(
1360 (report.ess - 5.0).abs() < 1e-9,
1361 "ESS={} expected 5.0",
1362 report.ess
1363 );
1364 assert_eq!(report.n_correctness_known, 0);
1366 assert!(report.ips_served_failure.is_none());
1367
1368 let _ = std::fs::remove_file(&db);
1369 }
1370
1371 #[tokio::test]
1374 async fn ips_excludes_traces_without_propensity() {
1375 let db = tmp_db();
1376 let (tx, handle) = store::open(&db).unwrap();
1377
1378 for _ in 0..5 {
1380 tx.try_send(make_propensity_trace("t", 0, 0.001, Some(0.9)))
1381 .unwrap();
1382 }
1383 for _ in 0..5 {
1384 tx.try_send(make_propensity_trace("t", 0, 0.001, None))
1385 .unwrap();
1386 }
1387 drop(tx);
1388 handle.await.unwrap();
1389
1390 let report = ips_from_store(&db, "t", 0).unwrap();
1391
1392 assert_eq!(report.n_traces, 10);
1393 assert_eq!(
1394 report.n_with_propensity, 5,
1395 "only traces with propensity count"
1396 );
1397 let expected_ips = 0.001_f64 / 0.9;
1400 assert!(
1401 (report.ips_cost - expected_ips).abs() < 1e-9,
1402 "IPS={} expected {expected_ips}",
1403 report.ips_cost
1404 );
1405
1406 let _ = std::fs::remove_file(&db);
1407 }
1408
1409 #[test]
1412 fn ips_empty_store_returns_zero_report() {
1413 let db = std::path::Path::new("/nonexistent/fp-ips-empty.db");
1414 let report = ips_from_store(db, "t", 0).unwrap();
1415 assert_eq!(report.n_traces, 0);
1416 assert_eq!(report.n_with_propensity, 0);
1417 assert_eq!(report.ips_cost, 0.0);
1418 assert_eq!(report.snips_cost, 0.0);
1419 assert_eq!(report.ess, 0.0);
1420 assert!(report.ips_served_failure.is_none());
1421 assert_eq!(report.dr_cost, 0.0);
1423 assert_eq!(report.ci_dr_cost, (0.0, 0.0));
1424 }
1425
1426 fn make_propensity_trace_ce(rung: u32, cost: f64, p: Option<f64>) -> Trace {
1431 let mut t = make_propensity_trace("t", rung, cost, p);
1432 t.request.features.task_kind = TaskKind::CodeEdit;
1433 t
1434 }
1435
1436 #[tokio::test]
1443 async fn dr_degenerate_propensities_equals_dm_and_ips() {
1444 let db = tmp_db();
1445 let (tx, handle) = store::open(&db).unwrap();
1446
1447 for _ in 0..5 {
1448 tx.try_send(make_propensity_trace("t", 1, 0.010, Some(1.0)))
1449 .unwrap();
1450 }
1451 drop(tx);
1452 handle.await.unwrap();
1453
1454 let r = ips_from_store(&db, "t", 1).unwrap();
1455
1456 assert!(
1458 (r.dr_cost - 0.010).abs() < 1e-9,
1459 "DR={} expected 0.010",
1460 r.dr_cost
1461 );
1462 assert!(
1463 (r.dr_cost - r.ips_cost).abs() < 1e-9,
1464 "DR should equal IPS; DR={} IPS={}",
1465 r.dr_cost,
1466 r.ips_cost
1467 );
1468 assert!(
1469 (r.dr_cost - r.snips_cost).abs() < 1e-9,
1470 "DR should equal SNIPS; DR={} SNIPS={}",
1471 r.dr_cost,
1472 r.snips_cost
1473 );
1474
1475 let _ = std::fs::remove_file(&db);
1476 }
1477
1478 #[tokio::test]
1494 async fn dr_correction_recovers_true_cost_under_correct_propensities() {
1495 let db = tmp_db();
1496 let (tx, handle) = store::open(&db).unwrap();
1497
1498 for _ in 0..9 {
1499 tx.try_send(make_propensity_trace("t", 0, 0.001, Some(0.9)))
1500 .unwrap();
1501 }
1502 tx.try_send(make_propensity_trace("t", 1, 0.010, Some(0.1)))
1503 .unwrap();
1504 drop(tx);
1505 handle.await.unwrap();
1506
1507 let r = ips_from_store(&db, "t", 1).unwrap();
1508
1509 assert!(
1511 (r.dr_cost - 0.010).abs() < 1e-9,
1512 "DR={} expected 0.010 (not the naive logged mean 0.0019)",
1513 r.dr_cost
1514 );
1515 assert!(
1516 (r.dr_cost - r.ips_cost).abs() < 1e-9,
1517 "DR should equal IPS under correct propensities; DR={} IPS={}",
1518 r.dr_cost,
1519 r.ips_cost
1520 );
1521
1522 let _ = std::fs::remove_file(&db);
1523 }
1524
1525 #[tokio::test]
1538 async fn dr_sparse_bucket_fallback_no_nan() {
1539 let db = tmp_db();
1540 let (tx, handle) = store::open(&db).unwrap();
1541
1542 for _ in 0..5 {
1543 tx.try_send(make_propensity_trace_ce(0, 0.001, Some(0.5)))
1544 .unwrap();
1545 }
1546 for _ in 0..5 {
1547 tx.try_send(make_propensity_trace("t", 1, 0.020, Some(0.5)))
1548 .unwrap();
1549 }
1550 drop(tx);
1551 handle.await.unwrap();
1552
1553 let r = ips_from_store(&db, "t", 1).unwrap();
1554
1555 assert!(
1556 r.dr_cost.is_finite(),
1557 "DR must be finite even when a context bucket is empty (got NaN/inf)"
1558 );
1559 assert!(
1560 (r.dr_cost - 0.020).abs() < 1e-9,
1561 "DR={} expected 0.020",
1562 r.dr_cost
1563 );
1564
1565 let _ = std::fs::remove_file(&db);
1566 }
1567
1568 #[tokio::test]
1572 async fn dr_ci_present_and_sane() {
1573 let db = tmp_db();
1574 let (tx, handle) = store::open(&db).unwrap();
1575
1576 for i in 0..20u32 {
1578 let cost = if i % 2 == 0 { 0.001 } else { 0.002 };
1579 tx.try_send(make_propensity_trace("t", 1, cost, Some(0.5)))
1580 .unwrap();
1581 }
1582 drop(tx);
1583 handle.await.unwrap();
1584
1585 let r1 = ips_from_store(&db, "t", 1).unwrap();
1586 let r2 = ips_from_store(&db, "t", 1).unwrap();
1587
1588 assert_eq!(r1.ci_dr_cost, r2.ci_dr_cost, "DR CI must be deterministic");
1590
1591 let (lo, hi) = r1.ci_dr_cost;
1593 assert!(lo.is_finite() && hi.is_finite(), "DR CI must be finite");
1594 assert!(lo <= hi, "DR CI must be ordered: lo={lo} hi={hi}");
1595
1596 assert!(
1598 lo <= r1.dr_cost + 1e-9,
1599 "DR CI lo {lo} > dr_cost {}",
1600 r1.dr_cost
1601 );
1602 assert!(
1603 hi >= r1.dr_cost - 1e-9,
1604 "DR CI hi {hi} < dr_cost {}",
1605 r1.dr_cost
1606 );
1607
1608 let _ = std::fs::remove_file(&db);
1609 }
1610}