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 Pipx,
187 Npm,
189 Docker,
191 Unknown,
193}
194
195impl Channel {
196 #[must_use]
198 pub const fn label(self) -> &'static str {
199 match self {
200 Self::Dist => "installer script (self-updating)",
201 Self::Homebrew => "Homebrew",
202 Self::Cargo => "cargo install",
203 Self::Python => "Python wheel (pip / uv / uvx)",
204 Self::Pipx => "pipx",
205 Self::Npm => "npm",
206 Self::Docker => "Docker image",
207 Self::Unknown => "unrecognised",
208 }
209 }
210
211 #[must_use]
213 pub const fn upgrade_command(self) -> Option<&'static str> {
214 match self {
215 Self::Dist => Some("firstpass-update"),
216 Self::Homebrew => Some("brew upgrade firstpass-proxy"),
217 Self::Cargo => Some(
218 "cargo install --git https://github.com/dshakes/firstpass firstpass-proxy --force",
219 ),
220 Self::Python => Some("uv tool upgrade firstpass # or: pip install -U firstpass"),
221 Self::Pipx => Some("pipx upgrade firstpass"),
222 Self::Npm => Some("npm install -g @dshakesnotbot/firstpass-proxy@latest"),
223 Self::Docker => Some("docker pull ghcr.io/dshakes/firstpass:latest"),
224 Self::Unknown => None,
225 }
226 }
227}
228
229#[must_use]
238pub fn detect_channel(exe: &std::path::Path, updater_present: bool, in_container: bool) -> Channel {
239 let p = exe.to_string_lossy().to_lowercase().replace('\\', "/");
240 let has = |needle: &str| p.contains(needle);
241 if in_container {
242 return Channel::Docker;
243 }
244 if has("/.cargo/bin/") || has("/.cargo/registry/") {
245 return Channel::Cargo;
246 }
247 if has("/cellar/") || has("/homebrew/") || has("/linuxbrew/") {
248 return Channel::Homebrew;
249 }
250 if has("/pipx/") {
251 return Channel::Pipx; }
253 if has("/site-packages/")
254 || has("/dist-packages/")
255 || has("/.venv/")
256 || has("/uv/")
257 || has("/uv-cache/")
258 {
259 return Channel::Python;
260 }
261 if has("/node_modules/") {
262 return Channel::Npm;
263 }
264 if updater_present {
265 return Channel::Dist;
266 }
267 Channel::Unknown
268}
269
270#[must_use]
272pub fn upgrade_report(channel: Channel, current_version: &str) -> String {
273 let mut out = format!(
274 "firstpass {current_version} · installed via {}\n",
275 channel.label()
276 );
277 match channel.upgrade_command() {
278 Some(cmd) if channel == Channel::Dist => {
279 out.push_str(&format!("\nupgrading in place — running `{cmd}`\n"));
280 }
281 Some(cmd) => {
282 out.push_str(&format!(
283 "\nthis channel is owned by its package manager, so upgrade with:\n {cmd}\n"
284 ));
285 }
286 None => {
287 out.push_str(
288 "\ncould not tell how this binary was installed. Pick the line that matches:\n\
289 \x20 firstpass-update # installer script\n\
290 \x20 brew upgrade firstpass-proxy # Homebrew\n\
291 \x20 uv tool upgrade firstpass # uv / pip: pip install -U firstpass\n\
292 \x20 pipx upgrade firstpass # pipx\n\
293 \x20 npm install -g @dshakesnotbot/firstpass-proxy@latest\n\
294 \x20 docker pull ghcr.io/dshakes/firstpass:latest\n\
295 \x20 cargo install --git https://github.com/dshakes/firstpass firstpass-proxy --force\n",
296 );
297 }
298 }
299 out
300}
301
302fn can_write_db(db_path: &str) -> bool {
304 let path = std::path::Path::new(db_path);
305 let dir = path
306 .parent()
307 .filter(|d| !d.as_os_str().is_empty())
308 .map_or_else(
309 || std::path::PathBuf::from("."),
310 std::path::Path::to_path_buf,
311 );
312 let probe = dir.join(format!(".firstpass-doctor-probe-{}", std::process::id()));
313 match std::fs::File::create(&probe) {
314 Ok(_) => {
315 let _ = std::fs::remove_file(&probe);
316 true
317 }
318 Err(_) => false,
319 }
320}
321
322#[derive(Debug, serde::Serialize)]
325pub struct EvalsSummary {
326 pub enforce_traces: usize,
328 pub escalations: u64,
330 pub gates: std::collections::BTreeMap<String, (u64, u64, u64)>,
332 pub served_by_rung: std::collections::BTreeMap<u32, u64>,
334}
335
336#[must_use]
338#[derive(Debug, serde::Serialize)]
344pub struct PredictorEval {
345 pub n: usize,
347 pub auc: Option<f64>,
350 pub brier: f64,
353}
354
355fn predictor_auc(pairs: &[(f64, bool)]) -> Option<f64> {
357 let pos: Vec<f64> = pairs.iter().filter(|(_, y)| *y).map(|(s, _)| *s).collect();
358 let neg: Vec<f64> = pairs.iter().filter(|(_, y)| !*y).map(|(s, _)| *s).collect();
359 if pos.is_empty() || neg.is_empty() {
360 return None;
361 }
362 let mut wins = 0.0_f64;
363 for &p in &pos {
364 for &n in &neg {
365 wins += if p > n {
366 1.0
367 } else if (p - n).abs() < f64::EPSILON {
368 0.5
369 } else {
370 0.0
371 };
372 }
373 }
374 Some(wins / (pos.len() as f64 * neg.len() as f64))
375}
376
377pub fn evaluate_predictor(traces: &[Trace], lr: f64, l2: f64) -> PredictorEval {
379 use firstpass_core::{PassPredictor, Verdict};
380 let mut model = PassPredictor::new(lr, l2);
381 let mut pairs: Vec<(f64, bool)> = Vec::new();
382 let mut se = 0.0_f64;
383 for t in traces {
384 for a in &t.attempts {
385 let y = match a.verdict {
386 Verdict::Pass => true,
387 Verdict::Fail => false,
388 Verdict::Abstain => continue,
389 };
390 let p = model.predict(&t.request.features, a.rung);
391 let target = f64::from(u8::from(y));
392 se += (p - target) * (p - target);
393 pairs.push((p, y));
394 model.update(&t.request.features, a.rung, y);
395 }
396 }
397 let n = pairs.len();
398 PredictorEval {
399 n,
400 auc: predictor_auc(&pairs),
401 brier: if n == 0 { 0.0 } else { se / n as f64 },
402 }
403}
404
405pub fn summarize_evals(traces: &[Trace]) -> EvalsSummary {
406 let mut s = EvalsSummary {
407 enforce_traces: 0,
408 escalations: 0,
409 gates: std::collections::BTreeMap::new(),
410 served_by_rung: std::collections::BTreeMap::new(),
411 };
412 for t in traces {
413 if t.mode != Mode::Enforce {
414 continue;
415 }
416 s.enforce_traces += 1;
417 s.escalations += u64::from(t.final_.escalations);
418 if let Some(rung) = t.final_.served_rung {
419 *s.served_by_rung.entry(rung).or_insert(0) += 1;
420 }
421 for attempt in &t.attempts {
422 for g in &attempt.gates {
423 let e = s.gates.entry(g.gate_id.clone()).or_insert((0, 0, 0));
424 match g.verdict {
425 firstpass_core::Verdict::Pass => e.0 += 1,
426 firstpass_core::Verdict::Fail => e.1 += 1,
427 firstpass_core::Verdict::Abstain => e.2 += 1,
428 }
429 }
430 }
431 }
432 s
433}
434
435#[must_use]
437pub fn format_evals(s: &EvalsSummary) -> String {
438 if s.enforce_traces == 0 {
439 return "no enforce traces yet — route some traffic in enforce mode first".to_owned();
440 }
441 let mut out = format!(
442 "enforce traces: {} · escalations: {}\n",
443 s.enforce_traces, s.escalations
444 );
445 out.push_str("gates (pass / fail / abstain):\n");
446 for (id, (p, f, a)) in &s.gates {
447 out.push_str(&format!(" {id:<24} {p:>6} / {f:>6} / {a:>6}\n"));
448 }
449 out.push_str("served by rung:\n");
450 for (rung, n) in &s.served_by_rung {
451 out.push_str(&format!(" rung {rung:<2} {n:>6}\n"));
452 }
453 out.trim_end().to_owned()
454}
455
456#[derive(Debug, serde::Serialize)]
459pub struct RouteExplanation {
460 pub trace_id: String,
461 pub mode: String,
462 pub policy: String,
463 pub served_model: Option<String>,
465 pub served_rung: Option<u32>,
466 pub escalations: u32,
467 pub total_cost_usd: f64,
468 pub baseline_usd: f64,
469 pub savings_usd: f64,
470 pub attempts: Vec<AttemptExplanation>,
472 pub summary: String,
474}
475
476#[derive(Debug, serde::Serialize)]
478pub struct AttemptExplanation {
479 pub rung: u32,
480 pub model: String,
481 pub verdict: String,
482 pub gates: Vec<(String, String)>,
484}
485
486fn verdict_str(v: firstpass_core::Verdict) -> &'static str {
487 match v {
488 firstpass_core::Verdict::Pass => "pass",
489 firstpass_core::Verdict::Fail => "fail",
490 firstpass_core::Verdict::Abstain => "abstain",
491 }
492}
493
494#[must_use]
497pub fn explain_trace(t: &Trace) -> RouteExplanation {
498 let attempts: Vec<AttemptExplanation> = t
499 .attempts
500 .iter()
501 .map(|a| AttemptExplanation {
502 rung: a.rung,
503 model: a.model.clone(),
504 verdict: verdict_str(a.verdict).to_owned(),
505 gates: a
506 .gates
507 .iter()
508 .map(|g| (g.gate_id.clone(), verdict_str(g.verdict).to_owned()))
509 .collect(),
510 })
511 .collect();
512 let served_model = t
513 .final_
514 .served_rung
515 .and_then(|r| t.attempts.iter().find(|a| a.rung == r))
516 .map(|a| a.model.clone());
517 let summary = match &served_model {
518 Some(m) => format!(
519 "served {m} at rung {} after {} escalation(s); spent ${:.4} vs ${:.4} always-top (saved ${:.4})",
520 t.final_.served_rung.unwrap_or(0),
521 t.final_.escalations,
522 t.final_.total_cost_usd,
523 t.final_.counterfactual_baseline_usd,
524 t.final_.savings_usd
525 ),
526 None => "no rung served (all attempts failed the gate or errored)".to_owned(),
527 };
528 RouteExplanation {
529 trace_id: t.trace_id.to_string(),
530 mode: match t.mode {
531 Mode::Enforce => "enforce",
532 Mode::Observe => "observe",
533 }
534 .to_owned(),
535 policy: t.policy.id.clone(),
536 served_model,
537 served_rung: t.final_.served_rung,
538 escalations: t.final_.escalations,
539 total_cost_usd: t.final_.total_cost_usd,
540 baseline_usd: t.final_.counterfactual_baseline_usd,
541 savings_usd: t.final_.savings_usd,
542 attempts,
543 summary,
544 }
545}
546
547#[derive(Debug, serde::Serialize)]
549pub struct VerifyReport {
550 pub receipts: usize,
552 pub valid: bool,
554 #[serde(skip_serializing_if = "Option::is_none")]
556 pub broken_at: Option<usize>,
557 #[serde(skip_serializing_if = "Option::is_none")]
558 pub detail: Option<String>,
559}
560
561#[must_use]
565pub fn verify_receipts(traces: &[Trace]) -> VerifyReport {
566 match firstpass_core::verify_chain(traces, firstpass_core::GENESIS_HASH) {
567 Ok(()) => VerifyReport {
568 receipts: traces.len(),
569 valid: true,
570 broken_at: None,
571 detail: None,
572 },
573 Err(firstpass_core::Error::ChainBroken { index, detail }) => VerifyReport {
574 receipts: traces.len(),
575 valid: false,
576 broken_at: Some(index),
577 detail: Some(detail),
578 },
579 Err(e) => VerifyReport {
580 receipts: traces.len(),
581 valid: false,
582 broken_at: None,
583 detail: Some(e.to_string()),
584 },
585 }
586}
587
588pub fn parse_receipt_jsonl(text: &str) -> Result<Vec<Trace>, String> {
594 let mut traces = Vec::new();
595 for (i, line) in text.lines().enumerate() {
596 if line.trim().is_empty() {
597 continue;
598 }
599 let t: Trace = serde_json::from_str(line).map_err(|e| format!("line {}: {e}", i + 1))?;
600 traces.push(t);
601 }
602 Ok(traces)
603}
604
605#[must_use]
609pub fn export_receipts_jsonl(traces: &[Trace]) -> String {
610 let mut out = String::new();
611 for t in traces {
612 if let Ok(line) = serde_json::to_string(t) {
613 out.push_str(&line);
614 out.push('\n');
615 }
616 }
617 out
618}
619
620#[derive(Debug, serde::Serialize)]
623pub struct SavingsSummary {
624 pub traces: usize,
626 pub enforce_traces: usize,
628 pub spent_usd: f64,
630 pub gate_usd: f64,
632 pub baseline_usd: f64,
634 pub savings_usd: f64,
636 pub savings_pct: f64,
638}
639
640#[must_use]
642pub fn summarize_savings(traces: &[Trace]) -> SavingsSummary {
643 let mut s = SavingsSummary {
644 traces: traces.len(),
645 enforce_traces: 0,
646 spent_usd: 0.0,
647 gate_usd: 0.0,
648 baseline_usd: 0.0,
649 savings_usd: 0.0,
650 savings_pct: 0.0,
651 };
652 for t in traces {
653 if t.mode == Mode::Enforce {
654 s.enforce_traces += 1;
655 }
656 s.spent_usd += t.final_.total_cost_usd;
657 s.gate_usd += t.final_.gate_cost_usd;
658 s.baseline_usd += t.final_.counterfactual_baseline_usd;
659 }
660 s.savings_usd = s.baseline_usd - s.spent_usd;
661 if s.baseline_usd > 0.0 {
662 s.savings_pct = s.savings_usd / s.baseline_usd;
663 }
664 s
665}
666
667#[must_use]
669pub fn format_savings(s: &SavingsSummary) -> String {
670 if s.traces == 0 {
671 return "no traces recorded yet — route some traffic through the proxy first".to_owned();
672 }
673 format!(
674 "traces: {} ({} enforce)\nspent: ${:.4} (gates ${:.4})\nbaseline: ${:.4} (always top rung)\nsavings: ${:.4} ({:.1}%)",
675 s.traces,
676 s.enforce_traces,
677 s.spent_usd,
678 s.gate_usd,
679 s.baseline_usd,
680 s.savings_usd,
681 s.savings_pct * 100.0
682 )
683}
684
685#[must_use]
688pub fn format_traces(traces: &[Trace], limit: usize) -> String {
689 let mut lines: Vec<String> = traces
690 .iter()
691 .rev()
692 .take(limit)
693 .filter_map(|t| serde_json::to_string(t).ok())
694 .collect();
695 lines.reverse();
696 if lines.is_empty() {
697 "no traces recorded yet".to_owned()
698 } else {
699 lines.join("\n")
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706
707 fn config_with(toml: Option<&str>, db_path: &str) -> ProxyConfig {
708 ProxyConfig::from_lookup(|k| match k {
709 "FIRSTPASS_MODE" if toml.is_some() => Some("enforce".to_owned()),
710 "FIRSTPASS_CONFIG_TOML" => toml.map(str::to_owned),
711 "FIRSTPASS_DB" => Some(db_path.to_owned()),
712 _ => None,
713 })
714 .unwrap()
715 }
716
717 #[test]
718 fn command_on_path_finds_real_and_rejects_fake() {
719 let path = std::env::var("PATH").ok();
720 assert!(command_on_path("sh", path.as_deref()), "sh is on PATH");
721 assert!(!command_on_path(
722 "firstpass-definitely-not-a-real-binary",
723 path.as_deref()
724 ));
725 assert!(!command_on_path("/nonexistent/abs/path", None));
726 }
727
728 #[test]
729 fn doctor_flags_a_missing_gate_binary() {
730 let toml = "[[route]]\nmatch = {}\nmode = \"enforce\"\nladder = [\"anthropic/claude-haiku-4-5\"]\ngates = [\"good\", \"bad\"]\n\
731 [[gate]]\nid = \"good\"\ncmd = [\"sh\"]\n\
732 [[gate]]\nid = \"bad\"\ncmd = [\"firstpass-nope-not-real\"]\n";
733 let db = std::env::temp_dir().join("firstpass-doctor-test.db");
734 let config = config_with(Some(toml), db.to_str().unwrap());
735
736 let report = doctor(&config, |k| match k {
738 "PATH" => std::env::var("PATH").ok(),
739 _ => None,
740 });
741
742 assert!(
743 !report.healthy(),
744 "a missing gate binary must fail the report"
745 );
746 let bad = report.checks.iter().find(|c| c.name == "gate:bad").unwrap();
747 assert_eq!(bad.status, CheckStatus::Fail);
748 let good = report
749 .checks
750 .iter()
751 .find(|c| c.name == "gate:good")
752 .unwrap();
753 assert_eq!(good.status, CheckStatus::Ok);
754 let key = report
755 .checks
756 .iter()
757 .find(|c| c.name == "anthropic-key")
758 .unwrap();
759 assert_eq!(key.status, CheckStatus::Warn);
760 }
761
762 #[test]
763 fn doctor_is_healthy_for_a_plain_observe_setup() {
764 let db = std::env::temp_dir().join("firstpass-doctor-ok.db");
765 let config = config_with(None, db.to_str().unwrap());
766 let report = doctor(&config, |k| {
767 (k == "ANTHROPIC_API_KEY").then(|| "sk-test".to_owned())
768 });
769 assert!(report.healthy(), "{}", report.render());
770 }
771
772 #[test]
773 fn format_traces_handles_empty() {
774 assert_eq!(format_traces(&[], 10), "no traces recorded yet");
775 }
776
777 #[test]
778 fn savings_summary_empty_and_nonempty() {
779 let empty = summarize_savings(&[]);
780 assert_eq!(empty.traces, 0);
781 assert!(format_savings(&empty).contains("no traces"));
782 }
783
784 #[test]
785 fn evals_summary_empty_zero_state() {
786 let s = summarize_evals(&[]);
787 assert_eq!(s.enforce_traces, 0);
788 assert!(format_evals(&s).contains("no enforce traces"));
789 }
790
791 #[test]
792 fn verify_and_export_round_trip_detects_tampering() {
793 use firstpass_core::{
794 Attempt, Features, FinalOutcome, GENESIS_HASH, PolicyRef, RequestInfo, ServedFrom,
795 TaskKind, Verdict,
796 };
797
798 let mk = |prev: &str, session: &str| Trace {
800 trace_id: uuid::Uuid::now_v7(),
801 prev_hash: prev.to_owned(),
802 tenant_id: "default".to_owned(),
803 session_id: session.to_owned(),
804 ts: jiff::Timestamp::UNIX_EPOCH,
805 mode: Mode::Observe,
806 policy: PolicyRef {
807 id: "observe-passthrough@v0".to_owned(),
808 explore: false,
809 propensity: None,
810 mode_profile: None,
811 },
812 request: RequestInfo {
813 api: "anthropic.messages".to_owned(),
814 prompt_hash: "deadbeef".to_owned(),
815 features: Features::new(TaskKind::Other),
816 },
817 attempts: vec![Attempt {
818 rung: 0,
819 model: "anthropic/claude-haiku-4-5".to_owned(),
820 provider: "anthropic".to_owned(),
821 in_tokens: 10,
822 out_tokens: 5,
823 cost_usd: 0.001,
824 latency_ms: 12,
825 gates: vec![],
826 verdict: Verdict::Pass,
827 }],
828 deferred: Vec::new(),
829 final_: FinalOutcome {
830 served_rung: Some(0),
831 served_from: ServedFrom::Attempt,
832 total_cost_usd: 0.001,
833 gate_cost_usd: 0.0,
834 total_latency_ms: 12,
835 escalations: 0,
836 counterfactual_baseline_usd: 0.001,
837 savings_usd: 0.0,
838 },
839 probe: None,
840 rollout: None,
841 shadow: None,
842 route_ix: None,
843 predicted_pass: None,
844 elastic: None,
845 };
846 let t0 = mk(GENESIS_HASH, "s0");
847 let t1 = mk(&t0.hash().unwrap(), "s1");
848 let t2 = mk(&t1.hash().unwrap(), "s2");
849 let chain = vec![t0, t1, t2];
850
851 let report = verify_receipts(&chain);
853 assert!(report.valid, "intact chain must verify: {report:?}");
854 assert_eq!(report.receipts, 3);
855
856 let jsonl = export_receipts_jsonl(&chain);
858 assert_eq!(jsonl.lines().count(), 3);
859 let reparsed = parse_receipt_jsonl(&jsonl).expect("export must re-parse");
860 assert!(
861 verify_receipts(&reparsed).valid,
862 "round-tripped chain must verify"
863 );
864
865 let mut tampered = reparsed;
867 tampered[1].final_.total_cost_usd = 999.0; let bad = verify_receipts(&tampered);
869 assert!(!bad.valid, "a mutated receipt must break the chain");
870 assert_eq!(
871 bad.broken_at,
872 Some(2),
873 "the break surfaces at the next link"
874 );
875
876 let mut reordered = parse_receipt_jsonl(&jsonl).unwrap();
878 reordered.swap(0, 1);
879 assert!(
880 !verify_receipts(&reordered).valid,
881 "reordering breaks the chain"
882 );
883 }
884
885 #[test]
886 fn parse_receipt_jsonl_reports_bad_line() {
887 let err = parse_receipt_jsonl("not json\n").unwrap_err();
888 assert!(err.starts_with("line 1:"), "must name the bad line: {err}");
889 assert!(
890 parse_receipt_jsonl("\n\n").unwrap().is_empty(),
891 "blank lines skipped"
892 );
893 }
894
895 #[test]
896 fn explain_trace_summarizes_served_and_escalation() {
897 use firstpass_core::{
898 Attempt, Features, FinalOutcome, GENESIS_HASH, GateResult, PolicyRef, RequestInfo,
899 ServedFrom, TaskKind, Verdict,
900 };
901 let t = Trace {
902 trace_id: uuid::Uuid::now_v7(),
903 prev_hash: GENESIS_HASH.to_owned(),
904 tenant_id: "default".to_owned(),
905 session_id: "s".to_owned(),
906 ts: jiff::Timestamp::UNIX_EPOCH,
907 mode: Mode::Enforce,
908 policy: PolicyRef {
909 id: "static-ladder@v0".to_owned(),
910 explore: false,
911 propensity: None,
912 mode_profile: None,
913 },
914 request: RequestInfo {
915 api: "anthropic.messages".to_owned(),
916 prompt_hash: "x".to_owned(),
917 features: Features::new(TaskKind::Other),
918 },
919 attempts: vec![
920 Attempt {
921 rung: 0,
922 model: "anthropic/claude-haiku-4-5".to_owned(),
923 provider: "anthropic".to_owned(),
924 in_tokens: 1,
925 out_tokens: 1,
926 cost_usd: 0.001,
927 latency_ms: 5,
928 gates: vec![GateResult::deterministic("non-empty", Verdict::Fail, 0)],
929 verdict: Verdict::Fail,
930 },
931 Attempt {
932 rung: 1,
933 model: "anthropic/claude-sonnet-5".to_owned(),
934 provider: "anthropic".to_owned(),
935 in_tokens: 1,
936 out_tokens: 1,
937 cost_usd: 0.01,
938 latency_ms: 9,
939 gates: vec![GateResult::deterministic("non-empty", Verdict::Pass, 0)],
940 verdict: Verdict::Pass,
941 },
942 ],
943 deferred: Vec::new(),
944 final_: FinalOutcome {
945 served_rung: Some(1),
946 served_from: ServedFrom::Attempt,
947 total_cost_usd: 0.011,
948 gate_cost_usd: 0.0,
949 total_latency_ms: 14,
950 escalations: 1,
951 counterfactual_baseline_usd: 0.02,
952 savings_usd: 0.009,
953 },
954 probe: None,
955 rollout: None,
956 shadow: None,
957 route_ix: None,
958 predicted_pass: None,
959 elastic: None,
960 };
961 let ex = explain_trace(&t);
962 assert_eq!(
963 ex.served_model.as_deref(),
964 Some("anthropic/claude-sonnet-5")
965 );
966 assert_eq!(ex.escalations, 1);
967 assert_eq!(ex.attempts.len(), 2);
968 assert_eq!(ex.attempts[0].verdict, "fail");
969 assert_eq!(
970 ex.attempts[1].gates[0],
971 ("non-empty".to_owned(), "pass".to_owned())
972 );
973 assert!(ex.summary.contains("served anthropic/claude-sonnet-5"));
974 }
975
976 #[test]
977 fn evaluate_predictor_empty_and_signal() {
978 let e = evaluate_predictor(&[], 0.05, 1e-4);
980 assert_eq!(e.n, 0);
981 assert!(e.auc.is_none());
982 }
983
984 #[test]
987 fn detect_channel_reads_real_install_paths() {
988 use std::path::Path;
989 let cases = [
990 ("/Users/u/.cargo/bin/firstpass", Channel::Cargo),
991 (
992 "/opt/homebrew/Cellar/firstpass-proxy/0.2.2/bin/firstpass",
993 Channel::Homebrew,
994 ),
995 (
996 "/home/linuxbrew/.linuxbrew/bin/firstpass",
997 Channel::Homebrew,
998 ),
999 (
1000 "/Users/u/.venv/lib/python3.12/site-packages/firstpass/firstpass",
1001 Channel::Python,
1002 ),
1003 (
1006 "/Users/u/.cache/uv/archive-v0/DQ4usH_XuqNyywgHKB8qB/bin/firstpass",
1007 Channel::Python,
1008 ),
1009 (
1010 "/Users/u/.local/share/uv/tools/firstpass/bin/firstpass",
1011 Channel::Python,
1012 ),
1013 (
1014 "/usr/local/lib/node_modules/@dshakesnotbot/firstpass-proxy/bin/firstpass",
1015 Channel::Npm,
1016 ),
1017 (r"C:\Users\u\.cargo\bin\firstpass.exe", Channel::Cargo),
1018 ];
1019 for (path, want) in cases {
1020 assert_eq!(
1021 detect_channel(Path::new(path), false, false),
1022 want,
1023 "misread {path}"
1024 );
1025 }
1026 assert_eq!(
1028 detect_channel(Path::new("/Users/u/.local/bin/firstpass"), true, false),
1029 Channel::Dist
1030 );
1031 assert_eq!(
1032 detect_channel(Path::new("/Users/u/.local/bin/firstpass"), false, false),
1033 Channel::Unknown
1034 );
1035 assert_eq!(
1037 detect_channel(Path::new("/usr/local/bin/firstpass"), true, true),
1038 Channel::Docker
1039 );
1040 }
1041
1042 #[test]
1045 fn package_manager_paths_win_over_the_self_updater() {
1046 use std::path::Path;
1047 for (path, want) in [
1048 (
1049 "/opt/homebrew/Cellar/firstpass-proxy/0.2.2/bin/firstpass",
1050 Channel::Homebrew,
1051 ),
1052 ("/Users/u/.venv/bin/firstpass", Channel::Python),
1053 ("/Users/u/.cargo/bin/firstpass", Channel::Cargo),
1054 ] {
1055 assert_eq!(detect_channel(Path::new(path), true, false), want, "{path}");
1056 }
1057 }
1058
1059 #[test]
1062 fn upgrade_report_runs_only_our_own_channel() {
1063 assert!(upgrade_report(Channel::Dist, "0.2.2").contains("upgrading in place"));
1064 for c in [
1065 Channel::Homebrew,
1066 Channel::Python,
1067 Channel::Npm,
1068 Channel::Docker,
1069 Channel::Cargo,
1070 ] {
1071 let r = upgrade_report(c, "0.2.2");
1072 assert!(
1073 !r.contains("upgrading in place"),
1074 "{c:?} must not self-update"
1075 );
1076 assert!(
1077 r.contains("owned by its package manager"),
1078 "{c:?} must explain why"
1079 );
1080 assert!(
1081 r.contains(c.upgrade_command().expect("has a command")),
1082 "{c:?} must print its exact command"
1083 );
1084 }
1085 let unknown = upgrade_report(Channel::Unknown, "0.2.2");
1086 assert!(unknown.contains("brew upgrade") && unknown.contains("docker pull"));
1087 assert!(
1088 unknown.contains("0.2.2"),
1089 "always states the running version"
1090 );
1091 }
1092
1093 #[test]
1097 fn pipx_install_is_recognised_from_its_real_path() {
1098 use std::path::Path;
1099 let real = "/Users/u/.local/pipx/venvs/firstpass/bin/firstpass";
1100 assert_eq!(detect_channel(Path::new(real), false, false), Channel::Pipx);
1101 assert_eq!(
1102 Channel::Pipx.upgrade_command(),
1103 Some("pipx upgrade firstpass")
1104 );
1105
1106 assert_eq!(
1109 detect_channel(Path::new("/Users/u/.local/bin/firstpass"), false, false),
1110 Channel::Unknown
1111 );
1112
1113 assert_eq!(
1115 detect_channel(
1116 Path::new(
1117 "/Users/u/.local/pipx/venvs/firstpass/lib/python3.14/site-packages/x/firstpass"
1118 ),
1119 false,
1120 false
1121 ),
1122 Channel::Pipx
1123 );
1124 }
1125}