Skip to main content

firstpass_proxy/
cli.rs

1//! CLI surfaces for the `firstpass` binary (SPEC §7.3/§7.4): `doctor` validates a setup before
2//! you route real traffic through it, and `trace` reads recent audit records from the store. Both
3//! are kept here (not in the binary) so the judgment is unit-tested.
4
5use crate::config::ProxyConfig;
6use firstpass_core::{Mode, Trace};
7
8/// A single doctor check outcome.
9#[derive(Debug, PartialEq, Eq)]
10pub enum CheckStatus {
11    /// Healthy.
12    Ok,
13    /// Works, but worth knowing (e.g. no key in env — observe still runs).
14    Warn,
15    /// Broken; `firstpass doctor` exits non-zero.
16    Fail,
17}
18
19/// One line of the doctor report.
20#[derive(Debug)]
21pub struct Check {
22    /// Short check name.
23    pub name: String,
24    /// Outcome.
25    pub status: CheckStatus,
26    /// Human-readable detail.
27    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/// The result of `firstpass doctor`.
41#[derive(Debug)]
42pub struct DoctorReport {
43    /// One entry per check, in report order.
44    pub checks: Vec<Check>,
45}
46
47impl DoctorReport {
48    /// Healthy iff no check failed (warnings are fine).
49    #[must_use]
50    pub fn healthy(&self) -> bool {
51        self.checks.iter().all(|c| c.status != CheckStatus::Fail)
52    }
53
54    /// Render the report as human-readable lines (a rendering of the structured checks).
55    #[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/// Validate a loaded config against the environment: routing sanity, provider key presence, and
76/// that every configured gate's command actually exists. `env` looks up environment variables
77/// (injected so this is testable).
78#[must_use]
79pub fn doctor(config: &ProxyConfig, env: impl Fn(&str) -> Option<String>) -> DoctorReport {
80    let mut checks = Vec::new();
81
82    // Config parsed (we hold a ProxyConfig), report the shape.
83    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    // Enforce is only meaningful with an enforce route to activate the engine.
91    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    // A provider key in the environment. Observe uses the caller's key, so absence is a warning.
109    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    // Every configured gate command must resolve, or that gate silently abstains at runtime.
120    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    // The trace store must be writable, or we'd trade the audit trail (or availability) for it.
143    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/// Whether `program` is runnable: an explicit path (contains a separator) that exists, or a bare
161/// name found in one of `PATH`'s directories.
162#[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/// How this binary got onto the machine. Upgrading is channel-specific — running the wrong
172/// updater either does nothing or fights the package manager that owns the file — so
173/// `firstpass upgrade` detects the channel instead of guessing one.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum Channel {
176    /// The shell/powershell installer, which ships its own `firstpass-update` (dist-workspace.toml
177    /// sets `install-updater = true`). This is the only channel we can upgrade in place.
178    Dist,
179    /// `brew install dshakes/tap/firstpass-proxy`.
180    Homebrew,
181    /// `cargo install --git`.
182    Cargo,
183    /// pip / uv / uvx wheel.
184    Python,
185    /// pipx-managed install (its own venv, symlinked onto PATH).
186    Pipx,
187    /// The npm installer package.
188    Npm,
189    /// Running inside the container image.
190    Docker,
191    /// Built from source, vendored, or something we do not recognise.
192    Unknown,
193}
194
195impl Channel {
196    /// Human label used in the report.
197    #[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    /// The command that upgrades this channel. `None` when we cannot tell.
212    #[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/// Work out how this binary was installed from the path it runs as. Pure: the executable path,
230/// the updater probe and the container check are all injected, so every branch is unit-tested
231/// without installing anything.
232///
233/// Package-manager path markers are checked BEFORE the updater probe: a Homebrew or wheel install
234/// can still carry the dist updater alongside it, and in that case the package manager owns the
235/// file — running the self-updater would leave the manager's metadata pointing at a binary it no
236/// longer controls.
237#[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; // before the generic markers: a pipx venv also has site-packages
252    }
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/// The text `firstpass upgrade` prints. Separated from the doing so the wording is testable.
271#[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
302/// Probe whether the trace DB's directory is writable, without creating the DB itself.
303fn 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/// Per-gate and per-rung evaluation summary computed from receipts — the operator's live
323/// eval suite: how each gate is verdict-ing, how often routing escalates, where serves land.
324#[derive(Debug, serde::Serialize)]
325pub struct EvalsSummary {
326    /// Traces aggregated (enforce mode only — observe records no gate decisions on-path).
327    pub enforce_traces: usize,
328    /// Total escalations across those traces.
329    pub escalations: u64,
330    /// gate_id → (pass, fail, abstain) counts across every attempt.
331    pub gates: std::collections::BTreeMap<String, (u64, u64, u64)>,
332    /// rung index → times an attempt at that rung was the served one.
333    pub served_by_rung: std::collections::BTreeMap<u32, u64>,
334}
335
336/// Aggregate gate verdicts + routing behavior over `traces` (enforce only).
337#[must_use]
338/// Prequential evaluation of the per-query gate-pass predictor (ADR 0008 Phase 2) — the honest
339/// "is it any good yet" check before the predictor is ever allowed to *act*. Replays the receipts
340/// in order through a fresh predictor: for each attempt with a clear Pass/Fail verdict, **predict
341/// before update** (so every prediction is on data the model has not trained on), then update.
342/// Reports AUC (predicted -> passed) and Brier score over those pairs.
343#[derive(Debug, serde::Serialize)]
344pub struct PredictorEval {
345    /// Labeled attempts evaluated.
346    pub n: usize,
347    /// AUC of predicted P(pass) ranking actual passes above fails. 0.5 = no signal; 1.0 = perfect.
348    /// `None` when one class is absent (all pass or all fail — AUC undefined).
349    pub auc: Option<f64>,
350    /// Mean squared error of the probabilistic prediction, in `[0, 1]`. Lower is better; 0.25 is
351    /// the always-0.5 baseline.
352    pub brier: f64,
353}
354
355/// AUC via the Mann-Whitney rank-sum identity (ties = 0.5). `None` if either class is empty.
356fn 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
377/// Run the prequential predictor evaluation over `traces` with the given `lr`/`l2`.
378pub 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/// Render an [`EvalsSummary`] for humans (`--json` callers serialize the struct instead).
436#[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/// A structured, agent-readable explanation of a single routing decision — why the ladder
457/// went where it did, built purely from a sealed [`Trace`].
458#[derive(Debug, serde::Serialize)]
459pub struct RouteExplanation {
460    pub trace_id: String,
461    pub mode: String,
462    pub policy: String,
463    /// The model actually served, if any.
464    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    /// Per attempt, in ladder order: what was tried and how it was judged.
471    pub attempts: Vec<AttemptExplanation>,
472    /// One-line human summary.
473    pub summary: String,
474}
475
476/// Per-attempt slice of a [`RouteExplanation`].
477#[derive(Debug, serde::Serialize)]
478pub struct AttemptExplanation {
479    pub rung: u32,
480    pub model: String,
481    pub verdict: String,
482    /// gate_id → verdict, for every gate that ran on this attempt.
483    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/// Explain one routing decision from its sealed receipt — the "why this model" answer, as
495/// structured data an agent can act on (and the backing for the MCP `explain_route` tool).
496#[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/// Outcome of an independent receipt-chain verification — the compliance artifact.
548#[derive(Debug, serde::Serialize)]
549pub struct VerifyReport {
550    /// Receipts checked.
551    pub receipts: usize,
552    /// `true` iff the hash chain re-derives cleanly from genesis (tamper-evident proof).
553    pub valid: bool,
554    /// On failure, the 0-based index of the first broken link and why.
555    #[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/// Independently re-derive the hash chain over `traces` from genesis — the same computation an
562/// external auditor runs, with no trust in the proxy or the database. A single altered or
563/// reordered receipt breaks the chain at its index.
564#[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
588/// Parse a JSONL receipt export (one [`Trace`] per line) back into traces, for offline
589/// verification. Blank lines are skipped; a malformed line fails loudly with its line number.
590///
591/// # Errors
592/// Returns the 1-based line number and parse error of the first unparseable line.
593pub 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/// Render `traces` as a JSONL receipt export — one sealed receipt per line, in chain order.
606/// This is the artifact an operator hands an auditor; the deferred-verdict side table is never
607/// included (it is not part of the hashed body).
608#[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/// Aggregated spend/savings over a set of traces — the number the operator screenshots.
621/// Pure so it's unit-testable; `firstpass savings` feeds it the trace store.
622#[derive(Debug, serde::Serialize)]
623pub struct SavingsSummary {
624    /// Traces aggregated.
625    pub traces: usize,
626    /// Enforce-mode traces (the ones where routing actually decided).
627    pub enforce_traces: usize,
628    /// USD actually spent (model + gate calls), summed over all traces.
629    pub spent_usd: f64,
630    /// USD spent on gates alone (the price of proof).
631    pub gate_usd: f64,
632    /// What always calling the top rung would have cost (§9.1 counterfactual), summed.
633    pub baseline_usd: f64,
634    /// `baseline - spent` — the savings the receipts prove.
635    pub savings_usd: f64,
636    /// Savings as a fraction of baseline, `0.0` when there is no baseline.
637    pub savings_pct: f64,
638}
639
640/// Aggregate spend vs the always-top counterfactual over `traces`.
641#[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/// Render a [`SavingsSummary`] for humans (`--json` callers serialize the struct instead).
668#[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/// Render the most recent `limit` traces as JSON lines — machine-first (SPEC §0.2): each line is a
686/// full [`Trace`], newest last.
687#[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        // Real PATH so `sh` resolves; no ANTHROPIC_API_KEY -> a warning, not a failure.
737        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        // Build a valid 3-link chain: each prev_hash = the previous record's hash.
799        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        // Clean chain verifies.
852        let report = verify_receipts(&chain);
853        assert!(report.valid, "intact chain must verify: {report:?}");
854        assert_eq!(report.receipts, 3);
855
856        // Export → parse round-trips and still verifies (the auditor's offline path).
857        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        // Tamper with a middle receipt's body → verification catches it at that index.
866        let mut tampered = reparsed;
867        tampered[1].final_.total_cost_usd = 999.0; // alter a sealed field
868        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        // Reordering is also caught.
877        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        // empty store => n=0, no crash
979        let e = evaluate_predictor(&[], 0.05, 1e-4);
980        assert_eq!(e.n, 0);
981        assert!(e.auc.is_none());
982    }
983
984    /// Each channel must be recognised from a realistic install path. Getting this wrong is not
985    /// cosmetic: it would print an upgrade command that silently does nothing.
986    #[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            // Both observed on a real machine rather than invented: uvx runs from an ephemeral
1004            // env under archive-v0, and `uv tool install` lands under the uv tools dir.
1005            (
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        // The installer channel is identified by its updater, not by its path.
1027        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        // A container wins outright — nothing on the host owns that file.
1036        assert_eq!(
1037            detect_channel(Path::new("/usr/local/bin/firstpass"), true, true),
1038            Channel::Docker
1039        );
1040    }
1041
1042    /// A package manager owning the file must beat the updater probe: a brew or wheel install can
1043    /// carry the updater too, and self-updating underneath the manager desynchronises its metadata.
1044    #[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    /// Only the channel we own may be upgraded in place; the rest must be advice, and the
1060    /// unknown case must still leave the user with something runnable.
1061    #[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    /// Regression: reported from a real machine. pipx installs into its own venv and puts a
1094    /// symlink on PATH, so the launch path is `~/.local/bin/firstpass` and carries no marker at
1095    /// all. Detection runs on the CANONICAL path; these are the two forms of that install.
1096    #[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        // The launch path alone is genuinely unidentifiable — which is why the caller must
1107        // canonicalize rather than this function pretending it can tell.
1108        assert_eq!(
1109            detect_channel(Path::new("/Users/u/.local/bin/firstpass"), false, false),
1110            Channel::Unknown
1111        );
1112
1113        // A pipx venv also contains site-packages; pipx must win so the command is right.
1114        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}