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 predicted_pass: None,
800 elastic: None,
801 };
802 trace.recompute_savings();
803 trace
804 }
805
806 fn make_escalated_trace(tenant: &str, haiku_cost: f64, sonnet_cost: f64) -> Trace {
808 let haiku = firstpass_core::Attempt {
809 rung: 0,
810 model: "haiku".to_owned(),
811 provider: "anthropic".to_owned(),
812 in_tokens: 10,
813 out_tokens: 5,
814 cost_usd: haiku_cost,
815 latency_ms: 10,
816 gates: vec![GateResult {
817 gate_id: "g".to_owned(),
818 verdict: Verdict::Fail,
819 score: Some(Score::clamped(0.3)),
820 cost_usd: 0.0,
821 ms: 5,
822 reason: None,
823 evidence_ref: None,
824 }],
825 verdict: Verdict::Fail,
826 };
827 let sonnet = firstpass_core::Attempt {
828 rung: 1,
829 model: "sonnet".to_owned(),
830 provider: "anthropic".to_owned(),
831 in_tokens: 10,
832 out_tokens: 5,
833 cost_usd: sonnet_cost,
834 latency_ms: 20,
835 gates: vec![GateResult {
836 gate_id: "g".to_owned(),
837 verdict: Verdict::Pass,
838 score: Some(Score::clamped(0.9)),
839 cost_usd: 0.0,
840 ms: 5,
841 reason: None,
842 evidence_ref: None,
843 }],
844 verdict: Verdict::Pass,
845 };
846 let total = haiku_cost + sonnet_cost;
847 let mut trace = Trace {
848 trace_id: uuid::Uuid::now_v7(),
849 prev_hash: GENESIS_HASH.to_owned(),
850 tenant_id: tenant.to_owned(),
851 session_id: "s2".to_owned(),
852 ts: jiff::Timestamp::now(),
853 mode: Mode::Enforce,
854 policy: PolicyRef {
855 id: "test@v0".to_owned(),
856 explore: false,
857 propensity: None,
858 mode_profile: None,
859 },
860 request: RequestInfo {
861 api: "anthropic.messages".to_owned(),
862 prompt_hash: "beef".to_owned(),
863 features: Features::new(TaskKind::Other),
864 },
865 attempts: vec![haiku, sonnet],
866 deferred: Vec::new(),
867 final_: FinalOutcome {
868 served_rung: Some(1),
869 served_from: ServedFrom::Attempt,
870 total_cost_usd: total,
871 gate_cost_usd: 0.0,
872 total_latency_ms: 30,
873 escalations: 1,
874 counterfactual_baseline_usd: total,
875 savings_usd: 0.0,
876 },
877 probe: None,
878 predicted_pass: None,
879 elastic: None,
880 };
881 trace.recompute_savings();
882 trace
883 }
884
885 fn deferred_pass(gate: &str) -> firstpass_core::DeferredVerdict {
886 firstpass_core::DeferredVerdict {
887 gate_id: gate.to_owned(),
888 verdict: Verdict::Pass,
889 score: None,
890 reported_at: jiff::Timestamp::now(),
891 reporter: "test".to_owned(),
892 }
893 }
894
895 fn deferred_fail(gate: &str) -> firstpass_core::DeferredVerdict {
896 firstpass_core::DeferredVerdict {
897 gate_id: gate.to_owned(),
898 verdict: Verdict::Fail,
899 score: None,
900 reported_at: jiff::Timestamp::now(),
901 reporter: "test".to_owned(),
902 }
903 }
904
905 fn tmp_db() -> std::path::PathBuf {
906 std::env::temp_dir().join(format!("fp-ope-{}.db", uuid::Uuid::now_v7()))
907 }
908
909 #[tokio::test]
912 async fn candidate_equals_logged_matches_exactly() {
913 let db = tmp_db();
914 let (tx, handle) = store::open(&db).unwrap();
915
916 let mut ids = Vec::new();
918 for _ in 0..10 {
919 let t = make_trace("tenant-a", 0, "haiku", 0.8, 0.001);
920 ids.push(t.trace_id.to_string());
921 tx.try_send(t).unwrap();
922 }
923 drop(tx);
924 handle.await.unwrap();
925
926 for id in &ids {
927 store::append_deferred(&db, id, &deferred_pass("out")).unwrap();
928 }
929
930 let policy = CandidatePolicy {
931 ladder: vec!["haiku".to_owned()],
932 serve_threshold: None,
933 };
934 let report = ope_from_store(&db, "tenant-a", &policy).unwrap();
935
936 assert_eq!(report.n_traces, 10);
937 assert_eq!(report.n_evaluable, 10);
938 assert!((report.coverage - 1.0).abs() < 1e-9);
939 assert!((report.est_cost_per_request - 0.001).abs() < 1e-9);
941 assert!((report.logged_cost_per_request - 0.001).abs() < 1e-9);
942 assert_eq!(report.n_correctness_known, 10);
944 assert!((report.est_served_failure.unwrap() - 0.0).abs() < 1e-9);
945 assert!((report.escalation_rate - 0.0).abs() < 1e-9);
947
948 let _ = std::fs::remove_file(&db);
949 }
950
951 #[tokio::test]
954 async fn cheaper_candidate_reduces_cost_and_escalation() {
955 let db = tmp_db();
956 let (tx, handle) = store::open(&db).unwrap();
957
958 for _ in 0..5 {
961 tx.try_send(make_escalated_trace("t", 0.001, 0.01)).unwrap();
962 }
963 drop(tx);
964 handle.await.unwrap();
965
966 let policy = CandidatePolicy {
970 ladder: vec!["haiku".to_owned()],
971 serve_threshold: None,
972 };
973 let report = ope_from_store(&db, "t", &policy).unwrap();
974
975 assert_eq!(report.n_evaluable, 5);
976 assert!((report.coverage - 1.0).abs() < 1e-9);
977 assert!(
979 (report.est_cost_per_request - 0.001).abs() < 1e-9,
980 "got {}",
981 report.est_cost_per_request
982 );
983 assert!((report.logged_cost_per_request - 0.011).abs() < 1e-9);
984 assert_eq!(report.n_correctness_known, 0);
986 assert!(report.est_served_failure.is_none());
987 assert!((report.escalation_rate - 1.0).abs() < 1e-9);
989
990 let _ = std::fs::remove_file(&db);
991 }
992
993 #[tokio::test]
996 async fn unlogged_model_makes_trace_unevaluable() {
997 let db = tmp_db();
998 let (tx, handle) = store::open(&db).unwrap();
999
1000 for _ in 0..3 {
1002 tx.try_send(make_trace("t", 0, "haiku", 0.8, 0.001))
1003 .unwrap();
1004 }
1005 for _ in 0..3 {
1006 tx.try_send(make_trace("t", 0, "sonnet", 0.8, 0.01))
1007 .unwrap();
1008 }
1009 drop(tx);
1010 handle.await.unwrap();
1011
1012 let policy = CandidatePolicy {
1015 ladder: vec!["newmodel".to_owned(), "haiku".to_owned()],
1016 serve_threshold: None,
1017 };
1018 let report = ope_from_store(&db, "t", &policy).unwrap();
1019
1020 assert_eq!(report.n_traces, 6);
1021 assert_eq!(report.n_evaluable, 0);
1022 assert!((report.coverage - 0.0).abs() < 1e-9);
1023 assert!(report.est_served_failure.is_none());
1024
1025 let _ = std::fs::remove_file(&db);
1026 }
1027
1028 #[tokio::test]
1031 async fn different_rung_served_correctness_unknown() {
1032 let db = tmp_db();
1033 let (tx, handle) = store::open(&db).unwrap();
1034
1035 let t = make_escalated_trace("t", 0.001, 0.01);
1037 let tid = t.trace_id.to_string();
1038 tx.try_send(t).unwrap();
1039 drop(tx);
1040 handle.await.unwrap();
1041
1042 store::append_deferred(&db, &tid, &deferred_pass("out")).unwrap();
1044
1045 let policy = CandidatePolicy {
1048 ladder: vec!["haiku".to_owned(), "sonnet".to_owned()],
1049 serve_threshold: Some(0.1), };
1051 let report = ope_from_store(&db, "t", &policy).unwrap();
1052
1053 assert_eq!(report.n_evaluable, 1);
1054 assert_eq!(report.n_correctness_known, 0, "different rung => UNKNOWN");
1055 assert!(report.est_served_failure.is_none());
1056 assert!((report.est_cost_per_request - 0.001).abs() < 1e-9);
1058 assert!((report.escalation_rate - 0.0).abs() < 1e-9);
1060
1061 let _ = std::fs::remove_file(&db);
1062 }
1063
1064 #[tokio::test]
1067 async fn bootstrap_ci_deterministic_and_sane() {
1068 let db = tmp_db();
1069 let (tx, handle) = store::open(&db).unwrap();
1070
1071 let mut ids = Vec::new();
1073 for i in 0..30u32 {
1074 let cost = if i % 2 == 0 { 0.001 } else { 0.002 };
1075 let t = make_trace("t", 0, "m", 0.9, cost);
1076 ids.push(t.trace_id.to_string());
1077 tx.try_send(t).unwrap();
1078 }
1079 drop(tx);
1080 handle.await.unwrap();
1081
1082 for (i, id) in ids.iter().enumerate() {
1084 let dv = if i < 15 {
1085 deferred_pass("o")
1086 } else {
1087 deferred_fail("o")
1088 };
1089 store::append_deferred(&db, id, &dv).unwrap();
1090 }
1091
1092 let policy = CandidatePolicy {
1093 ladder: vec!["m".to_owned()],
1094 serve_threshold: None,
1095 };
1096 let r1 = ope_from_store(&db, "t", &policy).unwrap();
1097 let r2 = ope_from_store(&db, "t", &policy).unwrap();
1098
1099 assert_eq!(r1.ci_cost, r2.ci_cost, "CI must be deterministic");
1101 assert_eq!(r1.ci_served_failure, r2.ci_served_failure);
1102
1103 let (lo, hi) = r1.ci_cost;
1105 assert!(
1106 lo <= r1.est_cost_per_request + 1e-9,
1107 "CI lo {lo} > est {}",
1108 r1.est_cost_per_request
1109 );
1110 assert!(
1111 hi >= r1.est_cost_per_request - 1e-9,
1112 "CI hi {hi} < est {}",
1113 r1.est_cost_per_request
1114 );
1115 assert!(lo <= hi, "CI must be ordered");
1116
1117 if let Some((flo, fhi)) = r1.ci_served_failure {
1118 let f = r1.est_served_failure.unwrap();
1119 assert!(flo <= f + 1e-9, "failure CI lo {flo} > est {f}");
1120 assert!(fhi >= f - 1e-9, "failure CI hi {fhi} < est {f}");
1121 assert!(flo <= fhi);
1122 }
1123
1124 let _ = std::fs::remove_file(&db);
1125 }
1126
1127 #[test]
1130 fn empty_store_returns_zero_trace_report() {
1131 let policy = CandidatePolicy {
1133 ladder: vec!["m".to_owned()],
1134 serve_threshold: None,
1135 };
1136 let db = std::path::Path::new("/nonexistent/fp-ope-empty.db");
1137 let report = ope_from_store(db, "t", &policy).unwrap();
1138 assert_eq!(report.n_traces, 0);
1139 assert_eq!(report.n_evaluable, 0);
1140 assert!((report.coverage - 1.0).abs() < 1e-9);
1141 assert!(report.est_served_failure.is_none());
1142 }
1143
1144 #[tokio::test]
1147 async fn same_rung_deferred_correctness_attributed() {
1148 let db = tmp_db();
1149 let (tx, handle) = store::open(&db).unwrap();
1150
1151 let t_pass = make_trace("t", 0, "m", 0.9, 0.001);
1152 let t_fail = make_trace("t", 0, "m", 0.9, 0.001);
1153 let (id_pass, id_fail) = (t_pass.trace_id.to_string(), t_fail.trace_id.to_string());
1154 tx.try_send(t_pass).unwrap();
1155 tx.try_send(t_fail).unwrap();
1156 drop(tx);
1157 handle.await.unwrap();
1158
1159 store::append_deferred(&db, &id_pass, &deferred_pass("out")).unwrap();
1160 store::append_deferred(&db, &id_fail, &deferred_fail("out")).unwrap();
1161
1162 let policy = CandidatePolicy {
1163 ladder: vec!["m".to_owned()],
1164 serve_threshold: None,
1165 };
1166 let r = ope_from_store(&db, "t", &policy).unwrap();
1167
1168 assert_eq!(r.n_evaluable, 2);
1169 assert_eq!(r.n_correctness_known, 2);
1170 assert!((r.est_served_failure.unwrap() - 0.5).abs() < 1e-9);
1172
1173 let _ = std::fs::remove_file(&db);
1174 }
1175
1176 #[tokio::test]
1179 async fn high_threshold_forces_escalation_and_higher_cost() {
1180 let db = tmp_db();
1181 let (tx, handle) = store::open(&db).unwrap();
1182
1183 let t = make_trace("t", 0, "haiku", 0.7, 0.001); tx.try_send(t).unwrap();
1188 drop(tx);
1189 handle.await.unwrap();
1190
1191 let policy = CandidatePolicy {
1192 ladder: vec!["haiku".to_owned()],
1193 serve_threshold: Some(0.8), };
1195 let r = ope_from_store(&db, "t", &policy).unwrap();
1196
1197 assert_eq!(r.n_evaluable, 1);
1200 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);
1204
1205 let _ = std::fs::remove_file(&db);
1206 }
1207
1208 #[test]
1211 fn from_toml_extracts_first_route_and_threshold() {
1212 let toml = r#"
1213[[route]]
1214match = {}
1215mode = "enforce"
1216ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
1217
1218[escalation]
1219serve_threshold = 0.75
1220"#;
1221 let p = CandidatePolicy::from_toml(toml).unwrap();
1222 assert_eq!(
1223 p.ladder,
1224 ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
1225 );
1226 assert!((p.serve_threshold.unwrap() - 0.75).abs() < 1e-9);
1227 }
1228
1229 #[test]
1230 fn from_toml_no_threshold_is_none() {
1231 let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"m\"]\n";
1232 let p = CandidatePolicy::from_toml(toml).unwrap();
1233 assert!(p.serve_threshold.is_none());
1234 }
1235
1236 fn make_propensity_trace(
1242 tenant: &str,
1243 rung: u32,
1244 cost_usd: f64,
1245 propensity: Option<f64>,
1246 ) -> Trace {
1247 let attempt = firstpass_core::Attempt {
1248 rung,
1249 model: "m".to_owned(),
1250 provider: "anthropic".to_owned(),
1251 in_tokens: 10,
1252 out_tokens: 5,
1253 cost_usd,
1254 latency_ms: 5,
1255 gates: vec![],
1256 verdict: Verdict::Pass,
1257 };
1258 let mut trace = Trace {
1259 trace_id: uuid::Uuid::now_v7(),
1260 prev_hash: GENESIS_HASH.to_owned(),
1261 tenant_id: tenant.to_owned(),
1262 session_id: "s".to_owned(),
1263 ts: jiff::Timestamp::now(),
1264 mode: Mode::Enforce,
1265 policy: PolicyRef {
1266 id: "bandit@v1+eps".to_owned(),
1267 explore: rung != 0,
1268 propensity,
1269 mode_profile: None,
1270 },
1271 request: RequestInfo {
1272 api: "anthropic.messages".to_owned(),
1273 prompt_hash: "ph".to_owned(),
1274 features: Features::new(TaskKind::Other),
1275 },
1276 attempts: vec![attempt],
1277 deferred: Vec::new(),
1278 final_: FinalOutcome {
1279 served_rung: Some(rung),
1280 served_from: ServedFrom::Attempt,
1281 total_cost_usd: cost_usd,
1282 gate_cost_usd: 0.0,
1283 total_latency_ms: 5,
1284 escalations: 0,
1285 counterfactual_baseline_usd: cost_usd,
1286 savings_usd: 0.0,
1287 },
1288 probe: None,
1289 predicted_pass: None,
1290 elastic: None,
1291 };
1292 trace.recompute_savings();
1293 trace
1294 }
1295
1296 #[tokio::test]
1316 async fn ips_correctness_from_known_logging_policy() {
1317 let db = tmp_db();
1318 let (tx, handle) = store::open(&db).unwrap();
1319
1320 for _ in 0..45 {
1321 tx.try_send(make_propensity_trace("t", 0, 0.001, Some(0.9)))
1322 .unwrap();
1323 }
1324 for _ in 0..5 {
1325 tx.try_send(make_propensity_trace("t", 1, 0.010, Some(0.1)))
1326 .unwrap();
1327 }
1328 drop(tx);
1329 handle.await.unwrap();
1330
1331 let report = ips_from_store(&db, "t", 1).unwrap();
1332
1333 assert_eq!(report.n_traces, 50);
1334 assert_eq!(report.n_with_propensity, 50);
1335 assert!(
1337 (report.ips_cost - 0.010).abs() < 1e-9,
1338 "IPS cost={} expected 0.010",
1339 report.ips_cost
1340 );
1341 assert!(
1342 (report.snips_cost - 0.010).abs() < 1e-9,
1343 "SNIPS cost={} expected 0.010",
1344 report.snips_cost
1345 );
1346 assert!(
1347 report.ess.is_finite() && report.ess > 0.0,
1348 "ESS must be positive"
1349 );
1350 assert!(
1351 (report.ess - 5.0).abs() < 1e-9,
1352 "ESS={} expected 5.0",
1353 report.ess
1354 );
1355 assert_eq!(report.n_correctness_known, 0);
1357 assert!(report.ips_served_failure.is_none());
1358
1359 let _ = std::fs::remove_file(&db);
1360 }
1361
1362 #[tokio::test]
1365 async fn ips_excludes_traces_without_propensity() {
1366 let db = tmp_db();
1367 let (tx, handle) = store::open(&db).unwrap();
1368
1369 for _ in 0..5 {
1371 tx.try_send(make_propensity_trace("t", 0, 0.001, Some(0.9)))
1372 .unwrap();
1373 }
1374 for _ in 0..5 {
1375 tx.try_send(make_propensity_trace("t", 0, 0.001, None))
1376 .unwrap();
1377 }
1378 drop(tx);
1379 handle.await.unwrap();
1380
1381 let report = ips_from_store(&db, "t", 0).unwrap();
1382
1383 assert_eq!(report.n_traces, 10);
1384 assert_eq!(
1385 report.n_with_propensity, 5,
1386 "only traces with propensity count"
1387 );
1388 let expected_ips = 0.001_f64 / 0.9;
1391 assert!(
1392 (report.ips_cost - expected_ips).abs() < 1e-9,
1393 "IPS={} expected {expected_ips}",
1394 report.ips_cost
1395 );
1396
1397 let _ = std::fs::remove_file(&db);
1398 }
1399
1400 #[test]
1403 fn ips_empty_store_returns_zero_report() {
1404 let db = std::path::Path::new("/nonexistent/fp-ips-empty.db");
1405 let report = ips_from_store(db, "t", 0).unwrap();
1406 assert_eq!(report.n_traces, 0);
1407 assert_eq!(report.n_with_propensity, 0);
1408 assert_eq!(report.ips_cost, 0.0);
1409 assert_eq!(report.snips_cost, 0.0);
1410 assert_eq!(report.ess, 0.0);
1411 assert!(report.ips_served_failure.is_none());
1412 assert_eq!(report.dr_cost, 0.0);
1414 assert_eq!(report.ci_dr_cost, (0.0, 0.0));
1415 }
1416
1417 fn make_propensity_trace_ce(rung: u32, cost: f64, p: Option<f64>) -> Trace {
1422 let mut t = make_propensity_trace("t", rung, cost, p);
1423 t.request.features.task_kind = TaskKind::CodeEdit;
1424 t
1425 }
1426
1427 #[tokio::test]
1434 async fn dr_degenerate_propensities_equals_dm_and_ips() {
1435 let db = tmp_db();
1436 let (tx, handle) = store::open(&db).unwrap();
1437
1438 for _ in 0..5 {
1439 tx.try_send(make_propensity_trace("t", 1, 0.010, Some(1.0)))
1440 .unwrap();
1441 }
1442 drop(tx);
1443 handle.await.unwrap();
1444
1445 let r = ips_from_store(&db, "t", 1).unwrap();
1446
1447 assert!(
1449 (r.dr_cost - 0.010).abs() < 1e-9,
1450 "DR={} expected 0.010",
1451 r.dr_cost
1452 );
1453 assert!(
1454 (r.dr_cost - r.ips_cost).abs() < 1e-9,
1455 "DR should equal IPS; DR={} IPS={}",
1456 r.dr_cost,
1457 r.ips_cost
1458 );
1459 assert!(
1460 (r.dr_cost - r.snips_cost).abs() < 1e-9,
1461 "DR should equal SNIPS; DR={} SNIPS={}",
1462 r.dr_cost,
1463 r.snips_cost
1464 );
1465
1466 let _ = std::fs::remove_file(&db);
1467 }
1468
1469 #[tokio::test]
1485 async fn dr_correction_recovers_true_cost_under_correct_propensities() {
1486 let db = tmp_db();
1487 let (tx, handle) = store::open(&db).unwrap();
1488
1489 for _ in 0..9 {
1490 tx.try_send(make_propensity_trace("t", 0, 0.001, Some(0.9)))
1491 .unwrap();
1492 }
1493 tx.try_send(make_propensity_trace("t", 1, 0.010, Some(0.1)))
1494 .unwrap();
1495 drop(tx);
1496 handle.await.unwrap();
1497
1498 let r = ips_from_store(&db, "t", 1).unwrap();
1499
1500 assert!(
1502 (r.dr_cost - 0.010).abs() < 1e-9,
1503 "DR={} expected 0.010 (not the naive logged mean 0.0019)",
1504 r.dr_cost
1505 );
1506 assert!(
1507 (r.dr_cost - r.ips_cost).abs() < 1e-9,
1508 "DR should equal IPS under correct propensities; DR={} IPS={}",
1509 r.dr_cost,
1510 r.ips_cost
1511 );
1512
1513 let _ = std::fs::remove_file(&db);
1514 }
1515
1516 #[tokio::test]
1529 async fn dr_sparse_bucket_fallback_no_nan() {
1530 let db = tmp_db();
1531 let (tx, handle) = store::open(&db).unwrap();
1532
1533 for _ in 0..5 {
1534 tx.try_send(make_propensity_trace_ce(0, 0.001, Some(0.5)))
1535 .unwrap();
1536 }
1537 for _ in 0..5 {
1538 tx.try_send(make_propensity_trace("t", 1, 0.020, Some(0.5)))
1539 .unwrap();
1540 }
1541 drop(tx);
1542 handle.await.unwrap();
1543
1544 let r = ips_from_store(&db, "t", 1).unwrap();
1545
1546 assert!(
1547 r.dr_cost.is_finite(),
1548 "DR must be finite even when a context bucket is empty (got NaN/inf)"
1549 );
1550 assert!(
1551 (r.dr_cost - 0.020).abs() < 1e-9,
1552 "DR={} expected 0.020",
1553 r.dr_cost
1554 );
1555
1556 let _ = std::fs::remove_file(&db);
1557 }
1558
1559 #[tokio::test]
1563 async fn dr_ci_present_and_sane() {
1564 let db = tmp_db();
1565 let (tx, handle) = store::open(&db).unwrap();
1566
1567 for i in 0..20u32 {
1569 let cost = if i % 2 == 0 { 0.001 } else { 0.002 };
1570 tx.try_send(make_propensity_trace("t", 1, cost, Some(0.5)))
1571 .unwrap();
1572 }
1573 drop(tx);
1574 handle.await.unwrap();
1575
1576 let r1 = ips_from_store(&db, "t", 1).unwrap();
1577 let r2 = ips_from_store(&db, "t", 1).unwrap();
1578
1579 assert_eq!(r1.ci_dr_cost, r2.ci_dr_cost, "DR CI must be deterministic");
1581
1582 let (lo, hi) = r1.ci_dr_cost;
1584 assert!(lo.is_finite() && hi.is_finite(), "DR CI must be finite");
1585 assert!(lo <= hi, "DR CI must be ordered: lo={lo} hi={hi}");
1586
1587 assert!(
1589 lo <= r1.dr_cost + 1e-9,
1590 "DR CI lo {lo} > dr_cost {}",
1591 r1.dr_cost
1592 );
1593 assert!(
1594 hi >= r1.dr_cost - 1e-9,
1595 "DR CI hi {hi} < dr_cost {}",
1596 r1.dr_cost
1597 );
1598
1599 let _ = std::fs::remove_file(&db);
1600 }
1601}