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 },
780 request: RequestInfo {
781 api: "anthropic.messages".to_owned(),
782 prompt_hash: "deadbeef".to_owned(),
783 features: Features::new(TaskKind::Other),
784 },
785 attempts: vec![attempt],
786 deferred: Vec::new(),
787 final_: FinalOutcome {
788 served_rung: Some(rung),
789 served_from: ServedFrom::Attempt,
790 total_cost_usd: cost_usd,
791 gate_cost_usd: 0.0,
792 total_latency_ms: 12,
793 escalations: 0,
794 counterfactual_baseline_usd: cost_usd,
795 savings_usd: 0.0,
796 },
797 };
798 trace.recompute_savings();
799 trace
800 }
801
802 fn make_escalated_trace(tenant: &str, haiku_cost: f64, sonnet_cost: f64) -> Trace {
804 let haiku = firstpass_core::Attempt {
805 rung: 0,
806 model: "haiku".to_owned(),
807 provider: "anthropic".to_owned(),
808 in_tokens: 10,
809 out_tokens: 5,
810 cost_usd: haiku_cost,
811 latency_ms: 10,
812 gates: vec![GateResult {
813 gate_id: "g".to_owned(),
814 verdict: Verdict::Fail,
815 score: Some(Score::clamped(0.3)),
816 cost_usd: 0.0,
817 ms: 5,
818 reason: None,
819 evidence_ref: None,
820 }],
821 verdict: Verdict::Fail,
822 };
823 let sonnet = firstpass_core::Attempt {
824 rung: 1,
825 model: "sonnet".to_owned(),
826 provider: "anthropic".to_owned(),
827 in_tokens: 10,
828 out_tokens: 5,
829 cost_usd: sonnet_cost,
830 latency_ms: 20,
831 gates: vec![GateResult {
832 gate_id: "g".to_owned(),
833 verdict: Verdict::Pass,
834 score: Some(Score::clamped(0.9)),
835 cost_usd: 0.0,
836 ms: 5,
837 reason: None,
838 evidence_ref: None,
839 }],
840 verdict: Verdict::Pass,
841 };
842 let total = haiku_cost + sonnet_cost;
843 let mut trace = Trace {
844 trace_id: uuid::Uuid::now_v7(),
845 prev_hash: GENESIS_HASH.to_owned(),
846 tenant_id: tenant.to_owned(),
847 session_id: "s2".to_owned(),
848 ts: jiff::Timestamp::now(),
849 mode: Mode::Enforce,
850 policy: PolicyRef {
851 id: "test@v0".to_owned(),
852 explore: false,
853 propensity: None,
854 },
855 request: RequestInfo {
856 api: "anthropic.messages".to_owned(),
857 prompt_hash: "beef".to_owned(),
858 features: Features::new(TaskKind::Other),
859 },
860 attempts: vec![haiku, sonnet],
861 deferred: Vec::new(),
862 final_: FinalOutcome {
863 served_rung: Some(1),
864 served_from: ServedFrom::Attempt,
865 total_cost_usd: total,
866 gate_cost_usd: 0.0,
867 total_latency_ms: 30,
868 escalations: 1,
869 counterfactual_baseline_usd: total,
870 savings_usd: 0.0,
871 },
872 };
873 trace.recompute_savings();
874 trace
875 }
876
877 fn deferred_pass(gate: &str) -> firstpass_core::DeferredVerdict {
878 firstpass_core::DeferredVerdict {
879 gate_id: gate.to_owned(),
880 verdict: Verdict::Pass,
881 score: None,
882 reported_at: jiff::Timestamp::now(),
883 reporter: "test".to_owned(),
884 }
885 }
886
887 fn deferred_fail(gate: &str) -> firstpass_core::DeferredVerdict {
888 firstpass_core::DeferredVerdict {
889 gate_id: gate.to_owned(),
890 verdict: Verdict::Fail,
891 score: None,
892 reported_at: jiff::Timestamp::now(),
893 reporter: "test".to_owned(),
894 }
895 }
896
897 fn tmp_db() -> std::path::PathBuf {
898 std::env::temp_dir().join(format!("fp-ope-{}.db", uuid::Uuid::now_v7()))
899 }
900
901 #[tokio::test]
904 async fn candidate_equals_logged_matches_exactly() {
905 let db = tmp_db();
906 let (tx, handle) = store::open(&db).unwrap();
907
908 let mut ids = Vec::new();
910 for _ in 0..10 {
911 let t = make_trace("tenant-a", 0, "haiku", 0.8, 0.001);
912 ids.push(t.trace_id.to_string());
913 tx.try_send(t).unwrap();
914 }
915 drop(tx);
916 handle.await.unwrap();
917
918 for id in &ids {
919 store::append_deferred(&db, id, &deferred_pass("out")).unwrap();
920 }
921
922 let policy = CandidatePolicy {
923 ladder: vec!["haiku".to_owned()],
924 serve_threshold: None,
925 };
926 let report = ope_from_store(&db, "tenant-a", &policy).unwrap();
927
928 assert_eq!(report.n_traces, 10);
929 assert_eq!(report.n_evaluable, 10);
930 assert!((report.coverage - 1.0).abs() < 1e-9);
931 assert!((report.est_cost_per_request - 0.001).abs() < 1e-9);
933 assert!((report.logged_cost_per_request - 0.001).abs() < 1e-9);
934 assert_eq!(report.n_correctness_known, 10);
936 assert!((report.est_served_failure.unwrap() - 0.0).abs() < 1e-9);
937 assert!((report.escalation_rate - 0.0).abs() < 1e-9);
939
940 let _ = std::fs::remove_file(&db);
941 }
942
943 #[tokio::test]
946 async fn cheaper_candidate_reduces_cost_and_escalation() {
947 let db = tmp_db();
948 let (tx, handle) = store::open(&db).unwrap();
949
950 for _ in 0..5 {
953 tx.try_send(make_escalated_trace("t", 0.001, 0.01)).unwrap();
954 }
955 drop(tx);
956 handle.await.unwrap();
957
958 let policy = CandidatePolicy {
962 ladder: vec!["haiku".to_owned()],
963 serve_threshold: None,
964 };
965 let report = ope_from_store(&db, "t", &policy).unwrap();
966
967 assert_eq!(report.n_evaluable, 5);
968 assert!((report.coverage - 1.0).abs() < 1e-9);
969 assert!(
971 (report.est_cost_per_request - 0.001).abs() < 1e-9,
972 "got {}",
973 report.est_cost_per_request
974 );
975 assert!((report.logged_cost_per_request - 0.011).abs() < 1e-9);
976 assert_eq!(report.n_correctness_known, 0);
978 assert!(report.est_served_failure.is_none());
979 assert!((report.escalation_rate - 1.0).abs() < 1e-9);
981
982 let _ = std::fs::remove_file(&db);
983 }
984
985 #[tokio::test]
988 async fn unlogged_model_makes_trace_unevaluable() {
989 let db = tmp_db();
990 let (tx, handle) = store::open(&db).unwrap();
991
992 for _ in 0..3 {
994 tx.try_send(make_trace("t", 0, "haiku", 0.8, 0.001))
995 .unwrap();
996 }
997 for _ in 0..3 {
998 tx.try_send(make_trace("t", 0, "sonnet", 0.8, 0.01))
999 .unwrap();
1000 }
1001 drop(tx);
1002 handle.await.unwrap();
1003
1004 let policy = CandidatePolicy {
1007 ladder: vec!["newmodel".to_owned(), "haiku".to_owned()],
1008 serve_threshold: None,
1009 };
1010 let report = ope_from_store(&db, "t", &policy).unwrap();
1011
1012 assert_eq!(report.n_traces, 6);
1013 assert_eq!(report.n_evaluable, 0);
1014 assert!((report.coverage - 0.0).abs() < 1e-9);
1015 assert!(report.est_served_failure.is_none());
1016
1017 let _ = std::fs::remove_file(&db);
1018 }
1019
1020 #[tokio::test]
1023 async fn different_rung_served_correctness_unknown() {
1024 let db = tmp_db();
1025 let (tx, handle) = store::open(&db).unwrap();
1026
1027 let t = make_escalated_trace("t", 0.001, 0.01);
1029 let tid = t.trace_id.to_string();
1030 tx.try_send(t).unwrap();
1031 drop(tx);
1032 handle.await.unwrap();
1033
1034 store::append_deferred(&db, &tid, &deferred_pass("out")).unwrap();
1036
1037 let policy = CandidatePolicy {
1040 ladder: vec!["haiku".to_owned(), "sonnet".to_owned()],
1041 serve_threshold: Some(0.1), };
1043 let report = ope_from_store(&db, "t", &policy).unwrap();
1044
1045 assert_eq!(report.n_evaluable, 1);
1046 assert_eq!(report.n_correctness_known, 0, "different rung => UNKNOWN");
1047 assert!(report.est_served_failure.is_none());
1048 assert!((report.est_cost_per_request - 0.001).abs() < 1e-9);
1050 assert!((report.escalation_rate - 0.0).abs() < 1e-9);
1052
1053 let _ = std::fs::remove_file(&db);
1054 }
1055
1056 #[tokio::test]
1059 async fn bootstrap_ci_deterministic_and_sane() {
1060 let db = tmp_db();
1061 let (tx, handle) = store::open(&db).unwrap();
1062
1063 let mut ids = Vec::new();
1065 for i in 0..30u32 {
1066 let cost = if i % 2 == 0 { 0.001 } else { 0.002 };
1067 let t = make_trace("t", 0, "m", 0.9, cost);
1068 ids.push(t.trace_id.to_string());
1069 tx.try_send(t).unwrap();
1070 }
1071 drop(tx);
1072 handle.await.unwrap();
1073
1074 for (i, id) in ids.iter().enumerate() {
1076 let dv = if i < 15 {
1077 deferred_pass("o")
1078 } else {
1079 deferred_fail("o")
1080 };
1081 store::append_deferred(&db, id, &dv).unwrap();
1082 }
1083
1084 let policy = CandidatePolicy {
1085 ladder: vec!["m".to_owned()],
1086 serve_threshold: None,
1087 };
1088 let r1 = ope_from_store(&db, "t", &policy).unwrap();
1089 let r2 = ope_from_store(&db, "t", &policy).unwrap();
1090
1091 assert_eq!(r1.ci_cost, r2.ci_cost, "CI must be deterministic");
1093 assert_eq!(r1.ci_served_failure, r2.ci_served_failure);
1094
1095 let (lo, hi) = r1.ci_cost;
1097 assert!(
1098 lo <= r1.est_cost_per_request + 1e-9,
1099 "CI lo {lo} > est {}",
1100 r1.est_cost_per_request
1101 );
1102 assert!(
1103 hi >= r1.est_cost_per_request - 1e-9,
1104 "CI hi {hi} < est {}",
1105 r1.est_cost_per_request
1106 );
1107 assert!(lo <= hi, "CI must be ordered");
1108
1109 if let Some((flo, fhi)) = r1.ci_served_failure {
1110 let f = r1.est_served_failure.unwrap();
1111 assert!(flo <= f + 1e-9, "failure CI lo {flo} > est {f}");
1112 assert!(fhi >= f - 1e-9, "failure CI hi {fhi} < est {f}");
1113 assert!(flo <= fhi);
1114 }
1115
1116 let _ = std::fs::remove_file(&db);
1117 }
1118
1119 #[test]
1122 fn empty_store_returns_zero_trace_report() {
1123 let policy = CandidatePolicy {
1125 ladder: vec!["m".to_owned()],
1126 serve_threshold: None,
1127 };
1128 let db = std::path::Path::new("/nonexistent/fp-ope-empty.db");
1129 let report = ope_from_store(db, "t", &policy).unwrap();
1130 assert_eq!(report.n_traces, 0);
1131 assert_eq!(report.n_evaluable, 0);
1132 assert!((report.coverage - 1.0).abs() < 1e-9);
1133 assert!(report.est_served_failure.is_none());
1134 }
1135
1136 #[tokio::test]
1139 async fn same_rung_deferred_correctness_attributed() {
1140 let db = tmp_db();
1141 let (tx, handle) = store::open(&db).unwrap();
1142
1143 let t_pass = make_trace("t", 0, "m", 0.9, 0.001);
1144 let t_fail = make_trace("t", 0, "m", 0.9, 0.001);
1145 let (id_pass, id_fail) = (t_pass.trace_id.to_string(), t_fail.trace_id.to_string());
1146 tx.try_send(t_pass).unwrap();
1147 tx.try_send(t_fail).unwrap();
1148 drop(tx);
1149 handle.await.unwrap();
1150
1151 store::append_deferred(&db, &id_pass, &deferred_pass("out")).unwrap();
1152 store::append_deferred(&db, &id_fail, &deferred_fail("out")).unwrap();
1153
1154 let policy = CandidatePolicy {
1155 ladder: vec!["m".to_owned()],
1156 serve_threshold: None,
1157 };
1158 let r = ope_from_store(&db, "t", &policy).unwrap();
1159
1160 assert_eq!(r.n_evaluable, 2);
1161 assert_eq!(r.n_correctness_known, 2);
1162 assert!((r.est_served_failure.unwrap() - 0.5).abs() < 1e-9);
1164
1165 let _ = std::fs::remove_file(&db);
1166 }
1167
1168 #[tokio::test]
1171 async fn high_threshold_forces_escalation_and_higher_cost() {
1172 let db = tmp_db();
1173 let (tx, handle) = store::open(&db).unwrap();
1174
1175 let t = make_trace("t", 0, "haiku", 0.7, 0.001); tx.try_send(t).unwrap();
1180 drop(tx);
1181 handle.await.unwrap();
1182
1183 let policy = CandidatePolicy {
1184 ladder: vec!["haiku".to_owned()],
1185 serve_threshold: Some(0.8), };
1187 let r = ope_from_store(&db, "t", &policy).unwrap();
1188
1189 assert_eq!(r.n_evaluable, 1);
1192 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);
1196
1197 let _ = std::fs::remove_file(&db);
1198 }
1199
1200 #[test]
1203 fn from_toml_extracts_first_route_and_threshold() {
1204 let toml = r#"
1205[[route]]
1206match = {}
1207mode = "enforce"
1208ladder = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
1209
1210[escalation]
1211serve_threshold = 0.75
1212"#;
1213 let p = CandidatePolicy::from_toml(toml).unwrap();
1214 assert_eq!(
1215 p.ladder,
1216 ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"]
1217 );
1218 assert!((p.serve_threshold.unwrap() - 0.75).abs() < 1e-9);
1219 }
1220
1221 #[test]
1222 fn from_toml_no_threshold_is_none() {
1223 let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"m\"]\n";
1224 let p = CandidatePolicy::from_toml(toml).unwrap();
1225 assert!(p.serve_threshold.is_none());
1226 }
1227
1228 fn make_propensity_trace(
1234 tenant: &str,
1235 rung: u32,
1236 cost_usd: f64,
1237 propensity: Option<f64>,
1238 ) -> Trace {
1239 let attempt = firstpass_core::Attempt {
1240 rung,
1241 model: "m".to_owned(),
1242 provider: "anthropic".to_owned(),
1243 in_tokens: 10,
1244 out_tokens: 5,
1245 cost_usd,
1246 latency_ms: 5,
1247 gates: vec![],
1248 verdict: Verdict::Pass,
1249 };
1250 let mut trace = Trace {
1251 trace_id: uuid::Uuid::now_v7(),
1252 prev_hash: GENESIS_HASH.to_owned(),
1253 tenant_id: tenant.to_owned(),
1254 session_id: "s".to_owned(),
1255 ts: jiff::Timestamp::now(),
1256 mode: Mode::Enforce,
1257 policy: PolicyRef {
1258 id: "bandit@v1+eps".to_owned(),
1259 explore: rung != 0,
1260 propensity,
1261 },
1262 request: RequestInfo {
1263 api: "anthropic.messages".to_owned(),
1264 prompt_hash: "ph".to_owned(),
1265 features: Features::new(TaskKind::Other),
1266 },
1267 attempts: vec![attempt],
1268 deferred: Vec::new(),
1269 final_: FinalOutcome {
1270 served_rung: Some(rung),
1271 served_from: ServedFrom::Attempt,
1272 total_cost_usd: cost_usd,
1273 gate_cost_usd: 0.0,
1274 total_latency_ms: 5,
1275 escalations: 0,
1276 counterfactual_baseline_usd: cost_usd,
1277 savings_usd: 0.0,
1278 },
1279 };
1280 trace.recompute_savings();
1281 trace
1282 }
1283
1284 #[tokio::test]
1304 async fn ips_correctness_from_known_logging_policy() {
1305 let db = tmp_db();
1306 let (tx, handle) = store::open(&db).unwrap();
1307
1308 for _ in 0..45 {
1309 tx.try_send(make_propensity_trace("t", 0, 0.001, Some(0.9)))
1310 .unwrap();
1311 }
1312 for _ in 0..5 {
1313 tx.try_send(make_propensity_trace("t", 1, 0.010, Some(0.1)))
1314 .unwrap();
1315 }
1316 drop(tx);
1317 handle.await.unwrap();
1318
1319 let report = ips_from_store(&db, "t", 1).unwrap();
1320
1321 assert_eq!(report.n_traces, 50);
1322 assert_eq!(report.n_with_propensity, 50);
1323 assert!(
1325 (report.ips_cost - 0.010).abs() < 1e-9,
1326 "IPS cost={} expected 0.010",
1327 report.ips_cost
1328 );
1329 assert!(
1330 (report.snips_cost - 0.010).abs() < 1e-9,
1331 "SNIPS cost={} expected 0.010",
1332 report.snips_cost
1333 );
1334 assert!(
1335 report.ess.is_finite() && report.ess > 0.0,
1336 "ESS must be positive"
1337 );
1338 assert!(
1339 (report.ess - 5.0).abs() < 1e-9,
1340 "ESS={} expected 5.0",
1341 report.ess
1342 );
1343 assert_eq!(report.n_correctness_known, 0);
1345 assert!(report.ips_served_failure.is_none());
1346
1347 let _ = std::fs::remove_file(&db);
1348 }
1349
1350 #[tokio::test]
1353 async fn ips_excludes_traces_without_propensity() {
1354 let db = tmp_db();
1355 let (tx, handle) = store::open(&db).unwrap();
1356
1357 for _ in 0..5 {
1359 tx.try_send(make_propensity_trace("t", 0, 0.001, Some(0.9)))
1360 .unwrap();
1361 }
1362 for _ in 0..5 {
1363 tx.try_send(make_propensity_trace("t", 0, 0.001, None))
1364 .unwrap();
1365 }
1366 drop(tx);
1367 handle.await.unwrap();
1368
1369 let report = ips_from_store(&db, "t", 0).unwrap();
1370
1371 assert_eq!(report.n_traces, 10);
1372 assert_eq!(
1373 report.n_with_propensity, 5,
1374 "only traces with propensity count"
1375 );
1376 let expected_ips = 0.001_f64 / 0.9;
1379 assert!(
1380 (report.ips_cost - expected_ips).abs() < 1e-9,
1381 "IPS={} expected {expected_ips}",
1382 report.ips_cost
1383 );
1384
1385 let _ = std::fs::remove_file(&db);
1386 }
1387
1388 #[test]
1391 fn ips_empty_store_returns_zero_report() {
1392 let db = std::path::Path::new("/nonexistent/fp-ips-empty.db");
1393 let report = ips_from_store(db, "t", 0).unwrap();
1394 assert_eq!(report.n_traces, 0);
1395 assert_eq!(report.n_with_propensity, 0);
1396 assert_eq!(report.ips_cost, 0.0);
1397 assert_eq!(report.snips_cost, 0.0);
1398 assert_eq!(report.ess, 0.0);
1399 assert!(report.ips_served_failure.is_none());
1400 assert_eq!(report.dr_cost, 0.0);
1402 assert_eq!(report.ci_dr_cost, (0.0, 0.0));
1403 }
1404
1405 fn make_propensity_trace_ce(rung: u32, cost: f64, p: Option<f64>) -> Trace {
1410 let mut t = make_propensity_trace("t", rung, cost, p);
1411 t.request.features.task_kind = TaskKind::CodeEdit;
1412 t
1413 }
1414
1415 #[tokio::test]
1422 async fn dr_degenerate_propensities_equals_dm_and_ips() {
1423 let db = tmp_db();
1424 let (tx, handle) = store::open(&db).unwrap();
1425
1426 for _ in 0..5 {
1427 tx.try_send(make_propensity_trace("t", 1, 0.010, Some(1.0)))
1428 .unwrap();
1429 }
1430 drop(tx);
1431 handle.await.unwrap();
1432
1433 let r = ips_from_store(&db, "t", 1).unwrap();
1434
1435 assert!(
1437 (r.dr_cost - 0.010).abs() < 1e-9,
1438 "DR={} expected 0.010",
1439 r.dr_cost
1440 );
1441 assert!(
1442 (r.dr_cost - r.ips_cost).abs() < 1e-9,
1443 "DR should equal IPS; DR={} IPS={}",
1444 r.dr_cost,
1445 r.ips_cost
1446 );
1447 assert!(
1448 (r.dr_cost - r.snips_cost).abs() < 1e-9,
1449 "DR should equal SNIPS; DR={} SNIPS={}",
1450 r.dr_cost,
1451 r.snips_cost
1452 );
1453
1454 let _ = std::fs::remove_file(&db);
1455 }
1456
1457 #[tokio::test]
1473 async fn dr_correction_recovers_true_cost_under_correct_propensities() {
1474 let db = tmp_db();
1475 let (tx, handle) = store::open(&db).unwrap();
1476
1477 for _ in 0..9 {
1478 tx.try_send(make_propensity_trace("t", 0, 0.001, Some(0.9)))
1479 .unwrap();
1480 }
1481 tx.try_send(make_propensity_trace("t", 1, 0.010, Some(0.1)))
1482 .unwrap();
1483 drop(tx);
1484 handle.await.unwrap();
1485
1486 let r = ips_from_store(&db, "t", 1).unwrap();
1487
1488 assert!(
1490 (r.dr_cost - 0.010).abs() < 1e-9,
1491 "DR={} expected 0.010 (not the naive logged mean 0.0019)",
1492 r.dr_cost
1493 );
1494 assert!(
1495 (r.dr_cost - r.ips_cost).abs() < 1e-9,
1496 "DR should equal IPS under correct propensities; DR={} IPS={}",
1497 r.dr_cost,
1498 r.ips_cost
1499 );
1500
1501 let _ = std::fs::remove_file(&db);
1502 }
1503
1504 #[tokio::test]
1517 async fn dr_sparse_bucket_fallback_no_nan() {
1518 let db = tmp_db();
1519 let (tx, handle) = store::open(&db).unwrap();
1520
1521 for _ in 0..5 {
1522 tx.try_send(make_propensity_trace_ce(0, 0.001, Some(0.5)))
1523 .unwrap();
1524 }
1525 for _ in 0..5 {
1526 tx.try_send(make_propensity_trace("t", 1, 0.020, Some(0.5)))
1527 .unwrap();
1528 }
1529 drop(tx);
1530 handle.await.unwrap();
1531
1532 let r = ips_from_store(&db, "t", 1).unwrap();
1533
1534 assert!(
1535 r.dr_cost.is_finite(),
1536 "DR must be finite even when a context bucket is empty (got NaN/inf)"
1537 );
1538 assert!(
1539 (r.dr_cost - 0.020).abs() < 1e-9,
1540 "DR={} expected 0.020",
1541 r.dr_cost
1542 );
1543
1544 let _ = std::fs::remove_file(&db);
1545 }
1546
1547 #[tokio::test]
1551 async fn dr_ci_present_and_sane() {
1552 let db = tmp_db();
1553 let (tx, handle) = store::open(&db).unwrap();
1554
1555 for i in 0..20u32 {
1557 let cost = if i % 2 == 0 { 0.001 } else { 0.002 };
1558 tx.try_send(make_propensity_trace("t", 1, cost, Some(0.5)))
1559 .unwrap();
1560 }
1561 drop(tx);
1562 handle.await.unwrap();
1563
1564 let r1 = ips_from_store(&db, "t", 1).unwrap();
1565 let r2 = ips_from_store(&db, "t", 1).unwrap();
1566
1567 assert_eq!(r1.ci_dr_cost, r2.ci_dr_cost, "DR CI must be deterministic");
1569
1570 let (lo, hi) = r1.ci_dr_cost;
1572 assert!(lo.is_finite() && hi.is_finite(), "DR CI must be finite");
1573 assert!(lo <= hi, "DR CI must be ordered: lo={lo} hi={hi}");
1574
1575 assert!(
1577 lo <= r1.dr_cost + 1e-9,
1578 "DR CI lo {lo} > dr_cost {}",
1579 r1.dr_cost
1580 );
1581 assert!(
1582 hi >= r1.dr_cost - 1e-9,
1583 "DR CI hi {hi} < dr_cost {}",
1584 r1.dr_cost
1585 );
1586
1587 let _ = std::fs::remove_file(&db);
1588 }
1589}