1use crate::config::ProxyConfig;
6use firstpass_core::{Mode, Trace};
7
8#[derive(Debug, PartialEq, Eq)]
10pub enum CheckStatus {
11 Ok,
13 Warn,
15 Fail,
17}
18
19#[derive(Debug)]
21pub struct Check {
22 pub name: String,
24 pub status: CheckStatus,
26 pub detail: String,
28}
29
30impl Check {
31 fn new(name: impl Into<String>, status: CheckStatus, detail: impl Into<String>) -> Self {
32 Self {
33 name: name.into(),
34 status,
35 detail: detail.into(),
36 }
37 }
38}
39
40#[derive(Debug)]
42pub struct DoctorReport {
43 pub checks: Vec<Check>,
45}
46
47impl DoctorReport {
48 #[must_use]
50 pub fn healthy(&self) -> bool {
51 self.checks.iter().all(|c| c.status != CheckStatus::Fail)
52 }
53
54 #[must_use]
56 pub fn render(&self) -> String {
57 let mut out = String::new();
58 for c in &self.checks {
59 let mark = match c.status {
60 CheckStatus::Ok => "✓",
61 CheckStatus::Warn => "!",
62 CheckStatus::Fail => "✗",
63 };
64 out.push_str(&format!("{mark} {}: {}\n", c.name, c.detail));
65 }
66 out.push_str(if self.healthy() {
67 "\nhealthy — ready to route.\n"
68 } else {
69 "\nnot healthy — fix the ✗ items above.\n"
70 });
71 out
72 }
73}
74
75#[must_use]
79pub fn doctor(config: &ProxyConfig, env: impl Fn(&str) -> Option<String>) -> DoctorReport {
80 let mut checks = Vec::new();
81
82 let route_count = config.routing.as_ref().map_or(0, |c| c.routes.len());
84 checks.push(Check::new(
85 "config",
86 CheckStatus::Ok,
87 format!("default mode {:?}, {route_count} route(s)", config.mode),
88 ));
89
90 let enforce_routes = config.routing.as_ref().map_or(0, |c| {
92 c.routes.iter().filter(|r| r.mode == Mode::Enforce).count()
93 });
94 if config.mode == Mode::Enforce && enforce_routes == 0 {
95 checks.push(Check::new(
96 "routing",
97 CheckStatus::Warn,
98 "mode is enforce but no enforce route is defined — all traffic will observe",
99 ));
100 } else {
101 checks.push(Check::new(
102 "routing",
103 CheckStatus::Ok,
104 format!("{enforce_routes} enforce route(s)"),
105 ));
106 }
107
108 if env("ANTHROPIC_API_KEY").is_some_and(|k| !k.is_empty()) {
110 checks.push(Check::new("anthropic-key", CheckStatus::Ok, "present"));
111 } else {
112 checks.push(Check::new(
113 "anthropic-key",
114 CheckStatus::Warn,
115 "ANTHROPIC_API_KEY not set — observe uses the caller's key; enforce needs one reachable",
116 ));
117 }
118
119 let path = env("PATH");
121 let gate_defs = config.routing.as_ref().map_or(&[][..], |c| &c.gate_defs);
122 for def in gate_defs {
123 match def.cmd.first() {
124 Some(program) if command_on_path(program, path.as_deref()) => checks.push(Check::new(
125 format!("gate:{}", def.id),
126 CheckStatus::Ok,
127 format!("`{program}` found"),
128 )),
129 Some(program) => checks.push(Check::new(
130 format!("gate:{}", def.id),
131 CheckStatus::Fail,
132 format!("`{program}` not found on PATH"),
133 )),
134 None => checks.push(Check::new(
135 format!("gate:{}", def.id),
136 CheckStatus::Fail,
137 "empty command",
138 )),
139 }
140 }
141
142 if can_write_db(&config.db_path) {
144 checks.push(Check::new(
145 "trace-store",
146 CheckStatus::Ok,
147 format!("{} is writable", config.db_path),
148 ));
149 } else {
150 checks.push(Check::new(
151 "trace-store",
152 CheckStatus::Fail,
153 format!("cannot write near {}", config.db_path),
154 ));
155 }
156
157 DoctorReport { checks }
158}
159
160#[must_use]
163pub fn command_on_path(program: &str, path_var: Option<&str>) -> bool {
164 if program.contains('/') || program.contains('\\') {
165 return std::path::Path::new(program).is_file();
166 }
167 let Some(path) = path_var else { return false };
168 std::env::split_paths(path).any(|dir| dir.join(program).is_file())
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum Channel {
176 Dist,
179 Homebrew,
181 Cargo,
183 Python,
185 Npm,
187 Docker,
189 Unknown,
191}
192
193impl Channel {
194 #[must_use]
196 pub const fn label(self) -> &'static str {
197 match self {
198 Self::Dist => "installer script (self-updating)",
199 Self::Homebrew => "Homebrew",
200 Self::Cargo => "cargo install",
201 Self::Python => "Python wheel (pip / uv / uvx)",
202 Self::Npm => "npm",
203 Self::Docker => "Docker image",
204 Self::Unknown => "unrecognised",
205 }
206 }
207
208 #[must_use]
210 pub const fn upgrade_command(self) -> Option<&'static str> {
211 match self {
212 Self::Dist => Some("firstpass-update"),
213 Self::Homebrew => Some("brew upgrade firstpass-proxy"),
214 Self::Cargo => Some(
215 "cargo install --git https://github.com/dshakes/firstpass firstpass-proxy --force",
216 ),
217 Self::Python => Some("uv tool upgrade firstpass # or: pip install -U firstpass"),
218 Self::Npm => Some("npm install -g @dshakesnotbot/firstpass-proxy@latest"),
219 Self::Docker => Some("docker pull ghcr.io/dshakes/firstpass:latest"),
220 Self::Unknown => None,
221 }
222 }
223}
224
225#[must_use]
234pub fn detect_channel(exe: &std::path::Path, updater_present: bool, in_container: bool) -> Channel {
235 let p = exe.to_string_lossy().to_lowercase().replace('\\', "/");
236 let has = |needle: &str| p.contains(needle);
237 if in_container {
238 return Channel::Docker;
239 }
240 if has("/.cargo/bin/") || has("/.cargo/registry/") {
241 return Channel::Cargo;
242 }
243 if has("/cellar/") || has("/homebrew/") || has("/linuxbrew/") {
244 return Channel::Homebrew;
245 }
246 if has("/site-packages/")
247 || has("/dist-packages/")
248 || has("/.venv/")
249 || has("/pipx/")
250 || has("/uv/")
251 || has("/uv-cache/")
252 {
253 return Channel::Python;
254 }
255 if has("/node_modules/") {
256 return Channel::Npm;
257 }
258 if updater_present {
259 return Channel::Dist;
260 }
261 Channel::Unknown
262}
263
264#[must_use]
266pub fn upgrade_report(channel: Channel, current_version: &str) -> String {
267 let mut out = format!(
268 "firstpass {current_version} · installed via {}\n",
269 channel.label()
270 );
271 match channel.upgrade_command() {
272 Some(cmd) if channel == Channel::Dist => {
273 out.push_str(&format!("\nupgrading in place — running `{cmd}`\n"));
274 }
275 Some(cmd) => {
276 out.push_str(&format!(
277 "\nthis channel is owned by its package manager, so upgrade with:\n {cmd}\n"
278 ));
279 }
280 None => {
281 out.push_str(
282 "\ncould not tell how this binary was installed. Pick the line that matches:\n\
283 \x20 firstpass-update # installer script\n\
284 \x20 brew upgrade firstpass-proxy # Homebrew\n\
285 \x20 uv tool upgrade firstpass # uv / pip: pip install -U firstpass\n\
286 \x20 npm install -g @dshakesnotbot/firstpass-proxy@latest\n\
287 \x20 docker pull ghcr.io/dshakes/firstpass:latest\n\
288 \x20 cargo install --git https://github.com/dshakes/firstpass firstpass-proxy --force\n",
289 );
290 }
291 }
292 out
293}
294
295fn can_write_db(db_path: &str) -> bool {
297 let path = std::path::Path::new(db_path);
298 let dir = path
299 .parent()
300 .filter(|d| !d.as_os_str().is_empty())
301 .map_or_else(
302 || std::path::PathBuf::from("."),
303 std::path::Path::to_path_buf,
304 );
305 let probe = dir.join(format!(".firstpass-doctor-probe-{}", std::process::id()));
306 match std::fs::File::create(&probe) {
307 Ok(_) => {
308 let _ = std::fs::remove_file(&probe);
309 true
310 }
311 Err(_) => false,
312 }
313}
314
315#[derive(Debug, serde::Serialize)]
318pub struct EvalsSummary {
319 pub enforce_traces: usize,
321 pub escalations: u64,
323 pub gates: std::collections::BTreeMap<String, (u64, u64, u64)>,
325 pub served_by_rung: std::collections::BTreeMap<u32, u64>,
327}
328
329#[must_use]
331#[derive(Debug, serde::Serialize)]
337pub struct PredictorEval {
338 pub n: usize,
340 pub auc: Option<f64>,
343 pub brier: f64,
346}
347
348fn predictor_auc(pairs: &[(f64, bool)]) -> Option<f64> {
350 let pos: Vec<f64> = pairs.iter().filter(|(_, y)| *y).map(|(s, _)| *s).collect();
351 let neg: Vec<f64> = pairs.iter().filter(|(_, y)| !*y).map(|(s, _)| *s).collect();
352 if pos.is_empty() || neg.is_empty() {
353 return None;
354 }
355 let mut wins = 0.0_f64;
356 for &p in &pos {
357 for &n in &neg {
358 wins += if p > n {
359 1.0
360 } else if (p - n).abs() < f64::EPSILON {
361 0.5
362 } else {
363 0.0
364 };
365 }
366 }
367 Some(wins / (pos.len() as f64 * neg.len() as f64))
368}
369
370pub fn evaluate_predictor(traces: &[Trace], lr: f64, l2: f64) -> PredictorEval {
372 use firstpass_core::{PassPredictor, Verdict};
373 let mut model = PassPredictor::new(lr, l2);
374 let mut pairs: Vec<(f64, bool)> = Vec::new();
375 let mut se = 0.0_f64;
376 for t in traces {
377 for a in &t.attempts {
378 let y = match a.verdict {
379 Verdict::Pass => true,
380 Verdict::Fail => false,
381 Verdict::Abstain => continue,
382 };
383 let p = model.predict(&t.request.features, a.rung);
384 let target = f64::from(u8::from(y));
385 se += (p - target) * (p - target);
386 pairs.push((p, y));
387 model.update(&t.request.features, a.rung, y);
388 }
389 }
390 let n = pairs.len();
391 PredictorEval {
392 n,
393 auc: predictor_auc(&pairs),
394 brier: if n == 0 { 0.0 } else { se / n as f64 },
395 }
396}
397
398pub fn summarize_evals(traces: &[Trace]) -> EvalsSummary {
399 let mut s = EvalsSummary {
400 enforce_traces: 0,
401 escalations: 0,
402 gates: std::collections::BTreeMap::new(),
403 served_by_rung: std::collections::BTreeMap::new(),
404 };
405 for t in traces {
406 if t.mode != Mode::Enforce {
407 continue;
408 }
409 s.enforce_traces += 1;
410 s.escalations += u64::from(t.final_.escalations);
411 if let Some(rung) = t.final_.served_rung {
412 *s.served_by_rung.entry(rung).or_insert(0) += 1;
413 }
414 for attempt in &t.attempts {
415 for g in &attempt.gates {
416 let e = s.gates.entry(g.gate_id.clone()).or_insert((0, 0, 0));
417 match g.verdict {
418 firstpass_core::Verdict::Pass => e.0 += 1,
419 firstpass_core::Verdict::Fail => e.1 += 1,
420 firstpass_core::Verdict::Abstain => e.2 += 1,
421 }
422 }
423 }
424 }
425 s
426}
427
428#[must_use]
430pub fn format_evals(s: &EvalsSummary) -> String {
431 if s.enforce_traces == 0 {
432 return "no enforce traces yet — route some traffic in enforce mode first".to_owned();
433 }
434 let mut out = format!(
435 "enforce traces: {} · escalations: {}\n",
436 s.enforce_traces, s.escalations
437 );
438 out.push_str("gates (pass / fail / abstain):\n");
439 for (id, (p, f, a)) in &s.gates {
440 out.push_str(&format!(" {id:<24} {p:>6} / {f:>6} / {a:>6}\n"));
441 }
442 out.push_str("served by rung:\n");
443 for (rung, n) in &s.served_by_rung {
444 out.push_str(&format!(" rung {rung:<2} {n:>6}\n"));
445 }
446 out.trim_end().to_owned()
447}
448
449#[derive(Debug, serde::Serialize)]
452pub struct RouteExplanation {
453 pub trace_id: String,
454 pub mode: String,
455 pub policy: String,
456 pub served_model: Option<String>,
458 pub served_rung: Option<u32>,
459 pub escalations: u32,
460 pub total_cost_usd: f64,
461 pub baseline_usd: f64,
462 pub savings_usd: f64,
463 pub attempts: Vec<AttemptExplanation>,
465 pub summary: String,
467}
468
469#[derive(Debug, serde::Serialize)]
471pub struct AttemptExplanation {
472 pub rung: u32,
473 pub model: String,
474 pub verdict: String,
475 pub gates: Vec<(String, String)>,
477}
478
479fn verdict_str(v: firstpass_core::Verdict) -> &'static str {
480 match v {
481 firstpass_core::Verdict::Pass => "pass",
482 firstpass_core::Verdict::Fail => "fail",
483 firstpass_core::Verdict::Abstain => "abstain",
484 }
485}
486
487#[must_use]
490pub fn explain_trace(t: &Trace) -> RouteExplanation {
491 let attempts: Vec<AttemptExplanation> = t
492 .attempts
493 .iter()
494 .map(|a| AttemptExplanation {
495 rung: a.rung,
496 model: a.model.clone(),
497 verdict: verdict_str(a.verdict).to_owned(),
498 gates: a
499 .gates
500 .iter()
501 .map(|g| (g.gate_id.clone(), verdict_str(g.verdict).to_owned()))
502 .collect(),
503 })
504 .collect();
505 let served_model = t
506 .final_
507 .served_rung
508 .and_then(|r| t.attempts.iter().find(|a| a.rung == r))
509 .map(|a| a.model.clone());
510 let summary = match &served_model {
511 Some(m) => format!(
512 "served {m} at rung {} after {} escalation(s); spent ${:.4} vs ${:.4} always-top (saved ${:.4})",
513 t.final_.served_rung.unwrap_or(0),
514 t.final_.escalations,
515 t.final_.total_cost_usd,
516 t.final_.counterfactual_baseline_usd,
517 t.final_.savings_usd
518 ),
519 None => "no rung served (all attempts failed the gate or errored)".to_owned(),
520 };
521 RouteExplanation {
522 trace_id: t.trace_id.to_string(),
523 mode: match t.mode {
524 Mode::Enforce => "enforce",
525 Mode::Observe => "observe",
526 }
527 .to_owned(),
528 policy: t.policy.id.clone(),
529 served_model,
530 served_rung: t.final_.served_rung,
531 escalations: t.final_.escalations,
532 total_cost_usd: t.final_.total_cost_usd,
533 baseline_usd: t.final_.counterfactual_baseline_usd,
534 savings_usd: t.final_.savings_usd,
535 attempts,
536 summary,
537 }
538}
539
540#[derive(Debug, serde::Serialize)]
542pub struct VerifyReport {
543 pub receipts: usize,
545 pub valid: bool,
547 #[serde(skip_serializing_if = "Option::is_none")]
549 pub broken_at: Option<usize>,
550 #[serde(skip_serializing_if = "Option::is_none")]
551 pub detail: Option<String>,
552}
553
554#[must_use]
558pub fn verify_receipts(traces: &[Trace]) -> VerifyReport {
559 match firstpass_core::verify_chain(traces, firstpass_core::GENESIS_HASH) {
560 Ok(()) => VerifyReport {
561 receipts: traces.len(),
562 valid: true,
563 broken_at: None,
564 detail: None,
565 },
566 Err(firstpass_core::Error::ChainBroken { index, detail }) => VerifyReport {
567 receipts: traces.len(),
568 valid: false,
569 broken_at: Some(index),
570 detail: Some(detail),
571 },
572 Err(e) => VerifyReport {
573 receipts: traces.len(),
574 valid: false,
575 broken_at: None,
576 detail: Some(e.to_string()),
577 },
578 }
579}
580
581pub fn parse_receipt_jsonl(text: &str) -> Result<Vec<Trace>, String> {
587 let mut traces = Vec::new();
588 for (i, line) in text.lines().enumerate() {
589 if line.trim().is_empty() {
590 continue;
591 }
592 let t: Trace = serde_json::from_str(line).map_err(|e| format!("line {}: {e}", i + 1))?;
593 traces.push(t);
594 }
595 Ok(traces)
596}
597
598#[must_use]
602pub fn export_receipts_jsonl(traces: &[Trace]) -> String {
603 let mut out = String::new();
604 for t in traces {
605 if let Ok(line) = serde_json::to_string(t) {
606 out.push_str(&line);
607 out.push('\n');
608 }
609 }
610 out
611}
612
613#[derive(Debug, serde::Serialize)]
616pub struct SavingsSummary {
617 pub traces: usize,
619 pub enforce_traces: usize,
621 pub spent_usd: f64,
623 pub gate_usd: f64,
625 pub baseline_usd: f64,
627 pub savings_usd: f64,
629 pub savings_pct: f64,
631}
632
633#[must_use]
635pub fn summarize_savings(traces: &[Trace]) -> SavingsSummary {
636 let mut s = SavingsSummary {
637 traces: traces.len(),
638 enforce_traces: 0,
639 spent_usd: 0.0,
640 gate_usd: 0.0,
641 baseline_usd: 0.0,
642 savings_usd: 0.0,
643 savings_pct: 0.0,
644 };
645 for t in traces {
646 if t.mode == Mode::Enforce {
647 s.enforce_traces += 1;
648 }
649 s.spent_usd += t.final_.total_cost_usd;
650 s.gate_usd += t.final_.gate_cost_usd;
651 s.baseline_usd += t.final_.counterfactual_baseline_usd;
652 }
653 s.savings_usd = s.baseline_usd - s.spent_usd;
654 if s.baseline_usd > 0.0 {
655 s.savings_pct = s.savings_usd / s.baseline_usd;
656 }
657 s
658}
659
660#[must_use]
662pub fn format_savings(s: &SavingsSummary) -> String {
663 if s.traces == 0 {
664 return "no traces recorded yet — route some traffic through the proxy first".to_owned();
665 }
666 format!(
667 "traces: {} ({} enforce)\nspent: ${:.4} (gates ${:.4})\nbaseline: ${:.4} (always top rung)\nsavings: ${:.4} ({:.1}%)",
668 s.traces,
669 s.enforce_traces,
670 s.spent_usd,
671 s.gate_usd,
672 s.baseline_usd,
673 s.savings_usd,
674 s.savings_pct * 100.0
675 )
676}
677
678#[must_use]
681pub fn format_traces(traces: &[Trace], limit: usize) -> String {
682 let mut lines: Vec<String> = traces
683 .iter()
684 .rev()
685 .take(limit)
686 .filter_map(|t| serde_json::to_string(t).ok())
687 .collect();
688 lines.reverse();
689 if lines.is_empty() {
690 "no traces recorded yet".to_owned()
691 } else {
692 lines.join("\n")
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 fn config_with(toml: Option<&str>, db_path: &str) -> ProxyConfig {
701 ProxyConfig::from_lookup(|k| match k {
702 "FIRSTPASS_MODE" if toml.is_some() => Some("enforce".to_owned()),
703 "FIRSTPASS_CONFIG_TOML" => toml.map(str::to_owned),
704 "FIRSTPASS_DB" => Some(db_path.to_owned()),
705 _ => None,
706 })
707 .unwrap()
708 }
709
710 #[test]
711 fn command_on_path_finds_real_and_rejects_fake() {
712 let path = std::env::var("PATH").ok();
713 assert!(command_on_path("sh", path.as_deref()), "sh is on PATH");
714 assert!(!command_on_path(
715 "firstpass-definitely-not-a-real-binary",
716 path.as_deref()
717 ));
718 assert!(!command_on_path("/nonexistent/abs/path", None));
719 }
720
721 #[test]
722 fn doctor_flags_a_missing_gate_binary() {
723 let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\ngates = [\"good\", \"bad\"]\n\
724 [[gate]]\nid = \"good\"\ncmd = [\"sh\"]\n\
725 [[gate]]\nid = \"bad\"\ncmd = [\"firstpass-nope-not-real\"]\n";
726 let db = std::env::temp_dir().join("firstpass-doctor-test.db");
727 let config = config_with(Some(toml), db.to_str().unwrap());
728
729 let report = doctor(&config, |k| match k {
731 "PATH" => std::env::var("PATH").ok(),
732 _ => None,
733 });
734
735 assert!(
736 !report.healthy(),
737 "a missing gate binary must fail the report"
738 );
739 let bad = report.checks.iter().find(|c| c.name == "gate:bad").unwrap();
740 assert_eq!(bad.status, CheckStatus::Fail);
741 let good = report
742 .checks
743 .iter()
744 .find(|c| c.name == "gate:good")
745 .unwrap();
746 assert_eq!(good.status, CheckStatus::Ok);
747 let key = report
748 .checks
749 .iter()
750 .find(|c| c.name == "anthropic-key")
751 .unwrap();
752 assert_eq!(key.status, CheckStatus::Warn);
753 }
754
755 #[test]
756 fn doctor_is_healthy_for_a_plain_observe_setup() {
757 let db = std::env::temp_dir().join("firstpass-doctor-ok.db");
758 let config = config_with(None, db.to_str().unwrap());
759 let report = doctor(&config, |k| {
760 (k == "ANTHROPIC_API_KEY").then(|| "sk-test".to_owned())
761 });
762 assert!(report.healthy(), "{}", report.render());
763 }
764
765 #[test]
766 fn format_traces_handles_empty() {
767 assert_eq!(format_traces(&[], 10), "no traces recorded yet");
768 }
769
770 #[test]
771 fn savings_summary_empty_and_nonempty() {
772 let empty = summarize_savings(&[]);
773 assert_eq!(empty.traces, 0);
774 assert!(format_savings(&empty).contains("no traces"));
775 }
776
777 #[test]
778 fn evals_summary_empty_zero_state() {
779 let s = summarize_evals(&[]);
780 assert_eq!(s.enforce_traces, 0);
781 assert!(format_evals(&s).contains("no enforce traces"));
782 }
783
784 #[test]
785 fn verify_and_export_round_trip_detects_tampering() {
786 use firstpass_core::{
787 Attempt, Features, FinalOutcome, GENESIS_HASH, PolicyRef, RequestInfo, ServedFrom,
788 TaskKind, Verdict,
789 };
790
791 let mk = |prev: &str, session: &str| Trace {
793 trace_id: uuid::Uuid::now_v7(),
794 prev_hash: prev.to_owned(),
795 tenant_id: "default".to_owned(),
796 session_id: session.to_owned(),
797 ts: jiff::Timestamp::UNIX_EPOCH,
798 mode: Mode::Observe,
799 policy: PolicyRef {
800 id: "observe-passthrough@v0".to_owned(),
801 explore: false,
802 propensity: None,
803 mode_profile: None,
804 },
805 request: RequestInfo {
806 api: "anthropic.messages".to_owned(),
807 prompt_hash: "deadbeef".to_owned(),
808 features: Features::new(TaskKind::Other),
809 },
810 attempts: vec![Attempt {
811 rung: 0,
812 model: "anthropic/claude-haiku-4-5".to_owned(),
813 provider: "anthropic".to_owned(),
814 in_tokens: 10,
815 out_tokens: 5,
816 cost_usd: 0.001,
817 latency_ms: 12,
818 gates: vec![],
819 verdict: Verdict::Pass,
820 }],
821 deferred: Vec::new(),
822 final_: FinalOutcome {
823 served_rung: Some(0),
824 served_from: ServedFrom::Attempt,
825 total_cost_usd: 0.001,
826 gate_cost_usd: 0.0,
827 total_latency_ms: 12,
828 escalations: 0,
829 counterfactual_baseline_usd: 0.001,
830 savings_usd: 0.0,
831 },
832 probe: None,
833 predicted_pass: None,
834 elastic: None,
835 };
836 let t0 = mk(GENESIS_HASH, "s0");
837 let t1 = mk(&t0.hash().unwrap(), "s1");
838 let t2 = mk(&t1.hash().unwrap(), "s2");
839 let chain = vec![t0, t1, t2];
840
841 let report = verify_receipts(&chain);
843 assert!(report.valid, "intact chain must verify: {report:?}");
844 assert_eq!(report.receipts, 3);
845
846 let jsonl = export_receipts_jsonl(&chain);
848 assert_eq!(jsonl.lines().count(), 3);
849 let reparsed = parse_receipt_jsonl(&jsonl).expect("export must re-parse");
850 assert!(
851 verify_receipts(&reparsed).valid,
852 "round-tripped chain must verify"
853 );
854
855 let mut tampered = reparsed;
857 tampered[1].final_.total_cost_usd = 999.0; let bad = verify_receipts(&tampered);
859 assert!(!bad.valid, "a mutated receipt must break the chain");
860 assert_eq!(
861 bad.broken_at,
862 Some(2),
863 "the break surfaces at the next link"
864 );
865
866 let mut reordered = parse_receipt_jsonl(&jsonl).unwrap();
868 reordered.swap(0, 1);
869 assert!(
870 !verify_receipts(&reordered).valid,
871 "reordering breaks the chain"
872 );
873 }
874
875 #[test]
876 fn parse_receipt_jsonl_reports_bad_line() {
877 let err = parse_receipt_jsonl("not json\n").unwrap_err();
878 assert!(err.starts_with("line 1:"), "must name the bad line: {err}");
879 assert!(
880 parse_receipt_jsonl("\n\n").unwrap().is_empty(),
881 "blank lines skipped"
882 );
883 }
884
885 #[test]
886 fn explain_trace_summarizes_served_and_escalation() {
887 use firstpass_core::{
888 Attempt, Features, FinalOutcome, GENESIS_HASH, GateResult, PolicyRef, RequestInfo,
889 ServedFrom, TaskKind, Verdict,
890 };
891 let t = Trace {
892 trace_id: uuid::Uuid::now_v7(),
893 prev_hash: GENESIS_HASH.to_owned(),
894 tenant_id: "default".to_owned(),
895 session_id: "s".to_owned(),
896 ts: jiff::Timestamp::UNIX_EPOCH,
897 mode: Mode::Enforce,
898 policy: PolicyRef {
899 id: "static-ladder@v0".to_owned(),
900 explore: false,
901 propensity: None,
902 mode_profile: None,
903 },
904 request: RequestInfo {
905 api: "anthropic.messages".to_owned(),
906 prompt_hash: "x".to_owned(),
907 features: Features::new(TaskKind::Other),
908 },
909 attempts: vec![
910 Attempt {
911 rung: 0,
912 model: "anthropic/claude-haiku-4-5".to_owned(),
913 provider: "anthropic".to_owned(),
914 in_tokens: 1,
915 out_tokens: 1,
916 cost_usd: 0.001,
917 latency_ms: 5,
918 gates: vec![GateResult::deterministic("non-empty", Verdict::Fail, 0)],
919 verdict: Verdict::Fail,
920 },
921 Attempt {
922 rung: 1,
923 model: "anthropic/claude-sonnet-5".to_owned(),
924 provider: "anthropic".to_owned(),
925 in_tokens: 1,
926 out_tokens: 1,
927 cost_usd: 0.01,
928 latency_ms: 9,
929 gates: vec![GateResult::deterministic("non-empty", Verdict::Pass, 0)],
930 verdict: Verdict::Pass,
931 },
932 ],
933 deferred: Vec::new(),
934 final_: FinalOutcome {
935 served_rung: Some(1),
936 served_from: ServedFrom::Attempt,
937 total_cost_usd: 0.011,
938 gate_cost_usd: 0.0,
939 total_latency_ms: 14,
940 escalations: 1,
941 counterfactual_baseline_usd: 0.02,
942 savings_usd: 0.009,
943 },
944 probe: None,
945 predicted_pass: None,
946 elastic: None,
947 };
948 let ex = explain_trace(&t);
949 assert_eq!(
950 ex.served_model.as_deref(),
951 Some("anthropic/claude-sonnet-5")
952 );
953 assert_eq!(ex.escalations, 1);
954 assert_eq!(ex.attempts.len(), 2);
955 assert_eq!(ex.attempts[0].verdict, "fail");
956 assert_eq!(
957 ex.attempts[1].gates[0],
958 ("non-empty".to_owned(), "pass".to_owned())
959 );
960 assert!(ex.summary.contains("served anthropic/claude-sonnet-5"));
961 }
962
963 #[test]
964 fn evaluate_predictor_empty_and_signal() {
965 let e = evaluate_predictor(&[], 0.05, 1e-4);
967 assert_eq!(e.n, 0);
968 assert!(e.auc.is_none());
969 }
970
971 #[test]
974 fn detect_channel_reads_real_install_paths() {
975 use std::path::Path;
976 let cases = [
977 ("/Users/u/.cargo/bin/firstpass", Channel::Cargo),
978 (
979 "/opt/homebrew/Cellar/firstpass-proxy/0.2.2/bin/firstpass",
980 Channel::Homebrew,
981 ),
982 (
983 "/home/linuxbrew/.linuxbrew/bin/firstpass",
984 Channel::Homebrew,
985 ),
986 (
987 "/Users/u/.venv/lib/python3.12/site-packages/firstpass/firstpass",
988 Channel::Python,
989 ),
990 (
993 "/Users/u/.cache/uv/archive-v0/DQ4usH_XuqNyywgHKB8qB/bin/firstpass",
994 Channel::Python,
995 ),
996 (
997 "/Users/u/.local/share/uv/tools/firstpass/bin/firstpass",
998 Channel::Python,
999 ),
1000 (
1001 "/usr/local/lib/node_modules/@dshakesnotbot/firstpass-proxy/bin/firstpass",
1002 Channel::Npm,
1003 ),
1004 (r"C:\Users\u\.cargo\bin\firstpass.exe", Channel::Cargo),
1005 ];
1006 for (path, want) in cases {
1007 assert_eq!(
1008 detect_channel(Path::new(path), false, false),
1009 want,
1010 "misread {path}"
1011 );
1012 }
1013 assert_eq!(
1015 detect_channel(Path::new("/Users/u/.local/bin/firstpass"), true, false),
1016 Channel::Dist
1017 );
1018 assert_eq!(
1019 detect_channel(Path::new("/Users/u/.local/bin/firstpass"), false, false),
1020 Channel::Unknown
1021 );
1022 assert_eq!(
1024 detect_channel(Path::new("/usr/local/bin/firstpass"), true, true),
1025 Channel::Docker
1026 );
1027 }
1028
1029 #[test]
1032 fn package_manager_paths_win_over_the_self_updater() {
1033 use std::path::Path;
1034 for (path, want) in [
1035 (
1036 "/opt/homebrew/Cellar/firstpass-proxy/0.2.2/bin/firstpass",
1037 Channel::Homebrew,
1038 ),
1039 ("/Users/u/.venv/bin/firstpass", Channel::Python),
1040 ("/Users/u/.cargo/bin/firstpass", Channel::Cargo),
1041 ] {
1042 assert_eq!(detect_channel(Path::new(path), true, false), want, "{path}");
1043 }
1044 }
1045
1046 #[test]
1049 fn upgrade_report_runs_only_our_own_channel() {
1050 assert!(upgrade_report(Channel::Dist, "0.2.2").contains("upgrading in place"));
1051 for c in [
1052 Channel::Homebrew,
1053 Channel::Python,
1054 Channel::Npm,
1055 Channel::Docker,
1056 Channel::Cargo,
1057 ] {
1058 let r = upgrade_report(c, "0.2.2");
1059 assert!(
1060 !r.contains("upgrading in place"),
1061 "{c:?} must not self-update"
1062 );
1063 assert!(
1064 r.contains("owned by its package manager"),
1065 "{c:?} must explain why"
1066 );
1067 assert!(
1068 r.contains(c.upgrade_command().expect("has a command")),
1069 "{c:?} must print its exact command"
1070 );
1071 }
1072 let unknown = upgrade_report(Channel::Unknown, "0.2.2");
1073 assert!(unknown.contains("brew upgrade") && unknown.contains("docker pull"));
1074 assert!(
1075 unknown.contains("0.2.2"),
1076 "always states the running version"
1077 );
1078 }
1079}