Skip to main content

car_inference/
outcome.rs

1//! Outcome tracking — learn from inference results to improve routing.
2//!
3//! Two observation channels:
4//! 1. **Conversation signals** — implicit feedback from what happens after an inference
5//!    call (user moved on = accepted, user corrected = rejected, re-asked = rejected).
6//! 2. **Git-diff tracking** — for code generation, compare suggestions to actual commits
7//!    (ground truth, no classification model needed).
8//!
9//! Every inference call produces an `InferenceOutcome`. Outcomes accumulate into
10//! `ModelProfile`s with per-task statistics. The adaptive router uses profiles
11//! to make data-driven model selection.
12
13use std::collections::{HashMap, HashSet};
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use serde::{Deserialize, Serialize};
17
18/// Task type for outcome tracking. Maps to ModelCapability but at the call level.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum InferenceTask {
22    Generate,
23    Embed,
24    Classify,
25    Code,
26    Reasoning,
27}
28
29impl std::fmt::Display for InferenceTask {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            InferenceTask::Generate => write!(f, "generate"),
33            InferenceTask::Embed => write!(f, "embed"),
34            InferenceTask::Classify => write!(f, "classify"),
35            InferenceTask::Code => write!(f, "code"),
36            InferenceTask::Reasoning => write!(f, "reasoning"),
37        }
38    }
39}
40
41/// A single inference invocation record.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct InferenceOutcome {
44    /// Unique trace ID for this invocation.
45    pub trace_id: String,
46    /// Model that was used.
47    pub model_id: String,
48    /// Task type.
49    pub task: InferenceTask,
50    /// How the model was selected.
51    pub routing_reason: String,
52    /// Wall-clock latency in milliseconds.
53    pub latency_ms: u64,
54    /// Input tokens (estimated). For a prompt-cached provider this is the
55    /// *uncached* prefix only; the cached portion is split out below.
56    pub input_tokens: usize,
57    /// Output tokens (estimated).
58    pub output_tokens: usize,
59    /// Prompt-cache hit input tokens (Anthropic `cache_read_input_tokens`),
60    /// billed at ~0.1× base input. `0` for uncached / non-caching providers.
61    #[serde(default)]
62    pub cache_read_input_tokens: usize,
63    /// Prompt-cache write input tokens (Anthropic `cache_creation_input_tokens`),
64    /// billed at ~1.25×/2× base input. `0` for uncached / non-caching providers.
65    #[serde(default)]
66    pub cache_creation_input_tokens: usize,
67    /// Outcome from conversation signal inference.
68    pub inferred_outcome: Option<InferredOutcome>,
69    /// Outcome from git-diff tracking (code only).
70    pub code_outcome: Option<CodeOutcome>,
71    /// Error message if inference failed.
72    pub error: Option<String>,
73    /// Unix timestamp.
74    pub timestamp: u64,
75    /// Whether a *mechanical* success has already been credited to the
76    /// model profile for this call (booked at completion when it produced
77    /// output with no error — see [`OutcomeTracker::record_complete`]).
78    /// Guards against the later pending-sweep or a downstream quality
79    /// signal double-counting the same call. In-memory only (`pending` is
80    /// never persisted), so it's skipped from (de)serialization.
81    #[serde(skip)]
82    pub success_credited: bool,
83}
84
85/// Outcome inferred from conversation flow (implicit feedback).
86#[derive(Debug, Clone, Serialize, Deserialize)]
87#[serde(tag = "type", rename_all = "snake_case")]
88pub enum InferredOutcome {
89    /// User moved on, built on the response.
90    Accepted { confidence: f64 },
91    /// User used the result but modified it.
92    AcceptedWithEdits { confidence: f64 },
93    /// User corrected, re-asked, or explicitly rejected.
94    Rejected { confidence: f64 },
95    /// No follow-up signal (session ended, inconclusive).
96    Inconclusive,
97}
98
99impl InferredOutcome {
100    /// Convert to a quality score (0.0 = bad, 1.0 = good).
101    pub fn quality_score(&self) -> Option<f64> {
102        match self {
103            InferredOutcome::Accepted { confidence } => Some(*confidence),
104            InferredOutcome::AcceptedWithEdits { confidence } => Some(confidence * 0.7),
105            InferredOutcome::Rejected { confidence } => Some((1.0 - confidence) * 0.3),
106            InferredOutcome::Inconclusive => None,
107        }
108    }
109
110    pub fn is_success(&self) -> Option<bool> {
111        match self {
112            InferredOutcome::Accepted { .. } => Some(true),
113            InferredOutcome::AcceptedWithEdits { .. } => Some(true),
114            InferredOutcome::Rejected { .. } => Some(false),
115            InferredOutcome::Inconclusive => None,
116        }
117    }
118}
119
120/// Outcome from git-diff comparison (code generation ground truth).
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(tag = "type", rename_all = "snake_case")]
123pub enum CodeOutcome {
124    /// Suggestion was applied as-is (exact or near-exact match in diff).
125    Applied,
126    /// User changed the same file but differently (partial adoption).
127    Modified,
128    /// File unchanged despite suggestion (rejected / not used).
129    Ignored,
130    /// AST structural diff: signature was changed (breaking change).
131    SignatureChanged,
132    /// AST structural diff: body was modified but signature preserved (non-breaking).
133    BodyModified,
134    /// AST structural diff: new symbol was added.
135    SymbolAdded,
136}
137
138impl CodeOutcome {
139    pub fn quality_score(&self) -> f64 {
140        match self {
141            CodeOutcome::Applied => 1.0,
142            CodeOutcome::SignatureChanged => 0.8,
143            CodeOutcome::BodyModified => 0.7,
144            CodeOutcome::SymbolAdded => 0.7,
145            CodeOutcome::Modified => 0.6,
146            CodeOutcome::Ignored => 0.1,
147        }
148    }
149
150    pub fn is_success(&self) -> bool {
151        !matches!(self, CodeOutcome::Ignored)
152    }
153}
154
155/// Backfill `quality_observations` for a profile hydrated from a pre-upgrade
156/// snapshot (where the field deserializes to 0). Failures are always graded,
157/// so `fail_count` is a safe lower bound on how many observations moved the
158/// EMA — using it preserves learned distrust across the upgrade. Only fills
159/// when the count is still 0, so profiles written by this version are untouched.
160fn backfill_quality_observations(p: &mut ModelProfile) {
161    if p.quality_observations == 0 && p.fail_count > 0 {
162        p.quality_observations = p.fail_count;
163    }
164    for ts in p.task_stats.values_mut() {
165        if ts.quality_observations == 0 && ts.failures > 0 {
166            ts.quality_observations = ts.failures;
167        }
168    }
169}
170
171/// Per-task statistics within a model profile.
172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
173pub struct TaskStats {
174    pub calls: u64,
175    pub successes: u64,
176    pub failures: u64,
177    /// Running average latency in ms.
178    pub avg_latency_ms: f64,
179    /// Exponential moving average of quality score.
180    pub ema_quality: f64,
181    /// Number of benchmark cases behind `ema_quality` when it is a *seeded
182    /// cold-start prior* (i.e. `calls == 0`). Lets the router shrink the
183    /// seeded score toward its schema estimate by sample size. `0` means no
184    /// benchmark evidence — the router then sits at the schema estimate.
185    #[serde(default)]
186    pub prior_sample_size: usize,
187    /// Number of *graded* quality observations that have moved `ema_quality`
188    /// for this task — accept/edit/reject signals and failures, NOT mechanical
189    /// successes (which leave answer quality unknown). The router trusts
190    /// `ema_quality` in proportion to this count, so a task with many ungraded
191    /// successes stays anchored to its schema estimate instead of a hollow
192    /// neutral EMA.
193    #[serde(default)]
194    pub quality_observations: u64,
195}
196
197impl TaskStats {
198    pub fn success_rate(&self) -> f64 {
199        let total = self.successes + self.failures;
200        if total == 0 {
201            return 0.5;
202        } // prior: assume neutral
203        self.successes as f64 / total as f64
204    }
205}
206
207/// Per-model performance profile, built from observed outcomes.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct ModelProfile {
210    pub model_id: String,
211    pub total_calls: u64,
212    pub success_count: u64,
213    pub fail_count: u64,
214    pub total_latency_ms: u64,
215    /// Total estimated input tokens across all calls. For prompt-cached
216    /// providers this is the *uncached* prefix only; cached tokens are in
217    /// the two fields below so each bucket can be priced at its own rate.
218    #[serde(default)]
219    pub total_input_tokens: u64,
220    /// Total estimated output tokens across all calls.
221    #[serde(default)]
222    pub total_output_tokens: u64,
223    /// Total prompt-cache *read* (hit) input tokens — billed at ~0.1× input.
224    #[serde(default)]
225    pub total_cache_read_input_tokens: u64,
226    /// Total prompt-cache *write* input tokens — billed at ~1.25×/2× input.
227    #[serde(default)]
228    pub total_cache_creation_input_tokens: u64,
229    /// Per-task statistics.
230    pub task_stats: HashMap<String, TaskStats>,
231    /// Overall EMA quality score (0.0 - 1.0).
232    pub ema_quality: f64,
233    /// Number of benchmark cases behind `ema_quality` when it is a *seeded
234    /// cold-start prior* (`total_calls == 0`). See `TaskStats::prior_sample_size`.
235    #[serde(default)]
236    pub prior_sample_size: usize,
237    /// Graded quality observations that have moved `ema_quality` (overall).
238    /// See `TaskStats::quality_observations`.
239    #[serde(default)]
240    pub quality_observations: u64,
241    /// Derived metric: quality per 1K total tokens. Populated on export
242    /// (not on every update) so it always reflects the latest snapshot.
243    /// Inspired by Meta-Harness: context-token efficiency is a first-class
244    /// optimization target, so it needs to be visible in model_stats.
245    #[serde(default)]
246    pub quality_per_1k_tokens: f64,
247    /// Last updated (unix timestamp).
248    pub updated_at: u64,
249}
250
251impl ModelProfile {
252    pub fn new(model_id: String) -> Self {
253        Self {
254            model_id,
255            total_calls: 0,
256            success_count: 0,
257            fail_count: 0,
258            total_latency_ms: 0,
259            total_input_tokens: 0,
260            total_output_tokens: 0,
261            total_cache_read_input_tokens: 0,
262            total_cache_creation_input_tokens: 0,
263            task_stats: HashMap::new(),
264            ema_quality: 0.5, // neutral prior
265            prior_sample_size: 0,
266            quality_observations: 0,
267            quality_per_1k_tokens: 0.0,
268            updated_at: now_unix(),
269        }
270    }
271
272    /// Success rate over *resolved* outcomes, or `None` when nothing has
273    /// resolved yet. Returns `None` rather than a fabricated 0.5 so callers
274    /// (all of which are display/reporting surfaces) can render "no resolved
275    /// signal" instead of a misleading confident "50%" for a never-measured
276    /// model. The router does NOT use this — its Thompson sampler reads the
277    /// raw `success_count`/`fail_count` and blends its own schema-derived
278    /// Beta prior (`adaptive_router`), so no 0.5 floor belongs here. Mirrors
279    /// `LaneUsage::success_rate`'s Option semantics.
280    pub fn success_rate_resolved(&self) -> Option<f64> {
281        let resolved = self.success_count + self.fail_count;
282        if resolved == 0 {
283            None
284        } else {
285            Some(self.success_count as f64 / resolved as f64)
286        }
287    }
288
289    pub fn avg_latency_ms(&self) -> f64 {
290        if self.total_calls == 0 {
291            return 0.0;
292        }
293        self.total_latency_ms as f64 / self.total_calls as f64
294    }
295
296    /// Same degradation pattern as SkillStats: fail_count > success_count + threshold.
297    pub fn should_degrade(&self, threshold: u64) -> bool {
298        self.fail_count > self.success_count + threshold
299    }
300
301    /// Get stats for a specific task type.
302    pub fn task_stats(&self, task: InferenceTask) -> Option<&TaskStats> {
303        self.task_stats.get(&task.to_string())
304    }
305
306    /// Total tokens observed across all calls (all input buckets + output).
307    /// Includes prompt-cache read and write tokens — they are real tokens the
308    /// provider processed, so price-free token efficiency must count them.
309    pub fn total_tokens(&self) -> u64 {
310        self.total_input_tokens
311            + self.total_cache_read_input_tokens
312            + self.total_cache_creation_input_tokens
313            + self.total_output_tokens
314    }
315
316    /// Quality per 1000 tokens: `ema_quality * 1000 / total_tokens`.
317    /// Returns 0.0 before any tokens have been observed.
318    pub fn compute_quality_per_1k_tokens(&self) -> f64 {
319        let total = self.total_tokens();
320        if total == 0 {
321            return 0.0;
322        }
323        self.ema_quality * 1000.0 / total as f64
324    }
325
326    /// Tokens spent per *successful* outcome: total tokens ÷ successes. `None`
327    /// before any success has resolved. This is PRICE-FREE token efficiency —
328    /// NOT dollar cost-per-outcome: a $0.10/Mtok and a $10/Mtok model with equal
329    /// token counts score identically here. For the dollar metric the
330    /// outcome-scoreboard vision wants, use [`Self::usd_per_success`] with the
331    /// model's catalog prices. Pairs with [`Self::success_rate_resolved`].
332    pub fn tokens_per_success(&self) -> Option<f64> {
333        if self.success_count == 0 {
334            None
335        } else {
336            Some(self.total_tokens() as f64 / self.success_count as f64)
337        }
338    }
339
340    /// Dollar cost per *successful* outcome — the honest "cost-per-outcome":
341    /// input+output token spend priced at the model's catalog rates, divided by
342    /// successes. `None` before any success. Prices are USD per 1M tokens
343    /// (from the catalog/registry `cost`), supplied by the caller so the tracker
344    /// stays catalog-free. This is the metric the scoreboard should lead with —
345    /// "cry once" is only legible in dollars-per-outcome, not tokens.
346    pub fn usd_per_success(
347        &self,
348        input_per_mtok: f64,
349        output_per_mtok: f64,
350        cache: CacheRates,
351    ) -> Option<f64> {
352        if self.success_count == 0 {
353            return None;
354        }
355        let usd = priced_input_usd(
356            self.total_input_tokens,
357            self.total_cache_read_input_tokens,
358            self.total_cache_creation_input_tokens,
359            input_per_mtok,
360            cache,
361        ) + self.total_output_tokens as f64 * output_per_mtok / 1_000_000.0;
362        Some(usd / self.success_count as f64)
363    }
364}
365
366/// Per-provider prompt-cache economics, expressed relative to the base input
367/// rate. The providers differ structurally: Anthropic uses explicit
368/// breakpoints with a deep read discount and a write premium; OpenAI caches
369/// automatically with a shallower read discount and no write charge. The
370/// durable ledger is catalog-free, so the caller derives these from the model
371/// schema's protocol (see [`crate::schema::ApiProtocol::cache_rates`]) and
372/// passes them in alongside the per-token prices.
373#[derive(Debug, Clone, Copy, PartialEq)]
374pub struct CacheRates {
375    /// Cached-read (hit) price ÷ base input. Anthropic ~0.1×, OpenAI ~0.5×.
376    pub read_mult: f64,
377    /// Cache-write price ÷ base input. Anthropic ~1.25× (5-minute TTL; 1-hour
378    /// is 2× but the ledger doesn't record which TTL, so writes bill at 1.25×
379    /// — exact for the default path, a conservative under-estimate for 1-hour).
380    /// OpenAI has no write charge (`0`) and reports no write tokens.
381    pub write_mult: f64,
382}
383
384impl CacheRates {
385    /// Anthropic / Bedrock-Claude: deep read discount + write premium.
386    pub const ANTHROPIC: Self = Self {
387        read_mult: 0.1,
388        write_mult: 1.25,
389    };
390    /// OpenAI (incl. Azure OpenAI): automatic caching, ~0.5× read, no write.
391    pub const OPENAI: Self = Self {
392        read_mult: 0.5,
393        write_mult: 0.0,
394    };
395    /// Providers without parsed prompt caching (Google/Vertex, local). Their
396    /// cache token counts are always zero, so the multipliers are never
397    /// exercised — this is just an honest, inert default.
398    pub const NONE: Self = Self {
399        read_mult: 0.0,
400        write_mult: 0.0,
401    };
402}
403
404impl Default for CacheRates {
405    /// Anthropic was the first (and write-bucket-bearing) provider; defaulting
406    /// here keeps a missing-rates path conservative rather than free.
407    fn default() -> Self {
408        Self::ANTHROPIC
409    }
410}
411
412/// USD cost of the three input buckets (uncached prefix, cache read, cache
413/// write), each priced off the same base `input_per_mtok` rate with the
414/// provider's [`CacheRates`] applied. Shared by [`ModelProfile::usd_per_success`]
415/// and the durable [`crate::scoreboard::Scoreboard`] so both price caching the
416/// same way.
417pub fn priced_input_usd(
418    uncached_input_tokens: u64,
419    cache_read_input_tokens: u64,
420    cache_creation_input_tokens: u64,
421    input_per_mtok: f64,
422    cache: CacheRates,
423) -> f64 {
424    (uncached_input_tokens as f64 * input_per_mtok
425        + cache_read_input_tokens as f64 * input_per_mtok * cache.read_mult
426        + cache_creation_input_tokens as f64 * input_per_mtok * cache.write_mult)
427        / 1_000_000.0
428}
429
430/// EMA smoothing factor. Higher = more weight on recent observations.
431const EMA_ALPHA: f64 = 0.2;
432
433/// One resolved inference outcome — the durable, attributable "receipt"
434/// the concierge quotes ("routed to X at T because Y; latency 1.2s;
435/// outcome success q=0.9"). Append-only; one JSON line per resolution.
436/// Deliberately flat (success/quality/error pulled out of the richer
437/// `InferenceOutcome`) so it's stable to read back and cheap to reason
438/// over. Carries no prompt/output text — only routing-adjacent metadata.
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct OutcomeLedgerEntry {
441    pub trace_id: String,
442    pub model_id: String,
443    pub task: InferenceTask,
444    pub routing_reason: String,
445    pub latency_ms: u64,
446    pub input_tokens: usize,
447    pub output_tokens: usize,
448    /// Prompt-cache read (hit) input tokens, billed at ~0.1× input. Defaults
449    /// to 0 so receipts written before caching telemetry existed read back
450    /// (and price) as fully-uncached.
451    #[serde(default, skip_serializing_if = "is_zero_usize")]
452    pub cache_read_input_tokens: usize,
453    /// Prompt-cache write input tokens, billed at ~1.25×/2× input.
454    #[serde(default, skip_serializing_if = "is_zero_usize")]
455    pub cache_creation_input_tokens: usize,
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub success: Option<bool>,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub quality: Option<f64>,
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub error: Option<String>,
462    /// Project/workspace the call belonged to — the key B1 groups paired
463    /// comparisons on. `None` until the capture path threads it (B1); kept
464    /// in the schema now so today's receipts are forward-compatible.
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub project_id: Option<String>,
467    /// Intent/use-case lane (finer than `task`), if the router computed
468    /// one. `None` until threaded (B1).
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub intent: Option<String>,
471    pub timestamp: u64,
472}
473
474/// Serde skip helper: keep the cache-token fields out of receipts that never
475/// touched the cache, so the durable ledger stays byte-identical to its
476/// pre-caching shape for the common (uncached) case.
477fn is_zero_usize(n: &usize) -> bool {
478    *n == 0
479}
480
481/// Cap on the in-memory ledger buffer between flushes — backstop against
482/// unbounded growth if the flush timer never runs. Oldest entries drop.
483const MAX_LEDGER_BUFFER: usize = 5000;
484
485/// Max characters of an error string kept in a ledger receipt. Errors are
486/// useful signal ("429", "context overflow") but can echo provider text
487/// or paths — cap the length so the privacy-bounded ledger never grows a
488/// large/sensitive blob. The classification lives in the prefix.
489const MAX_LEDGER_ERROR_CHARS: usize = 256;
490
491/// Truncate an error to [`MAX_LEDGER_ERROR_CHARS`] on a char boundary.
492fn redact_error(error: &str) -> String {
493    if error.chars().count() <= MAX_LEDGER_ERROR_CHARS {
494        return error.to_string();
495    }
496    let truncated: String = error.chars().take(MAX_LEDGER_ERROR_CHARS).collect();
497    format!("{truncated}…")
498}
499
500/// Classify an inference error as a **no-answer failure** — the call never
501/// produced an answer to grade — rather than a *generation* failure (the model
502/// returned content that was judged bad). Two families qualify:
503///   - transport/infrastructure: read timeout, connection reset, 5xx, EOF,
504///     overload.
505///   - auth/client rejection: 401/403/400/404, bad API key, `invalid_client`.
506///     The request was refused before any tokens were generated.
507///
508/// Such a call carries NO information about the model's *answer* quality — like a
509/// *mechanical* success, it leaves quality unknown — so it must not move the
510/// quality EMA. Counting these toward quality silently buries a good-but-slow,
511/// transiently-unreachable, or merely *mis-authenticated* model: observed live
512/// as `parslee/*` collapsing to `ema_quality ≈ 0.003` (from read-timeouts AND
513/// ~47 `HTTP 401 Unauthorized` from an undeployed gateway), which then
514/// deprioritized it under every workload. (Rate-limit / 429 is handled
515/// separately as a harsher *availability* penalty, not a quality one.)
516///
517/// This is the principled cut: a failure is answer-quality evidence ONLY when
518/// the model actually produced an answer. Auth/HTTP/transport rejections didn't,
519/// so the default for them is quality-neutral. We match on unambiguous textual
520/// forms, NOT bare status numbers (a bare "400"/"500" substring matches
521/// "1500ms", "tokens: 400", etc.) — a status-only error with no recognizable
522/// phrase falls through to the generation-failure path, which is the safe
523/// direction (it can only over-penalize a model that *did* answer, never
524/// silently bury one that didn't).
525fn is_no_answer_failure(error: &str) -> bool {
526    let e = error.to_ascii_lowercase();
527    const NEEDLES: &[&str] = &[
528        // --- transport / infrastructure (no answer produced) ---
529        "timeout",
530        "timed out",
531        "deadline",
532        "connection",
533        "connect error",
534        "reset by peer",
535        "broken pipe",
536        "network",
537        "unreachable",
538        "eof",
539        "stream closed",
540        "socket",
541        "503",
542        "502",
543        "504",
544        // A 500 is a server-side fault that almost always produced no answer,
545        // but bare "500" is too broad a substring (it matches "1500ms",
546        // "tokens: 500", a 500-dim error, …), so we match only its unambiguous
547        // textual form. A status-only "HTTP 500" with no body stays classified
548        // as a generation failure — accept that edge rather than risk
549        // false-positive hits on coincidental "500" substrings.
550        "internal server error",
551        "service unavailable",
552        "temporarily unavailable",
553        "overloaded",
554        // --- auth / client rejection (request refused, no answer produced) ---
555        // Same bare-number caution as 5xx: match textual forms, not "401"/"400".
556        // Kept HIGH-PRECISION: only phrases that unambiguously mean "the request
557        // was refused before generation." Deliberately NOT "not found" (matches a
558        // generation/parse error like "field not found") or "invalid token"
559        // (collides with tokenizer errors) — a rare 404 mis-scored as a bad
560        // answer is the safe direction; silently burying an auth-blocked model is
561        // not.
562        "unauthorized",
563        "unauthenticated",
564        "forbidden",
565        "permission denied",
566        "authentication",
567        "invalid_client",
568        "invalid api key",
569        "bad request",
570    ];
571    NEEDLES.iter().any(|n| e.contains(n))
572}
573
574/// Rewrite the ledger keeping only the most recent `max_entries` receipts
575/// (retention bound). No-op when under the cap or the file is absent.
576/// Atomic (temp + rename) so a crash mid-prune can't corrupt the ledger.
577pub fn prune_ledger(path: &std::path::Path, max_entries: usize) -> std::io::Result<()> {
578    if max_entries == 0 || !path.exists() {
579        return Ok(());
580    }
581    let entries = read_ledger(path, 0);
582    if entries.len() <= max_entries {
583        return Ok(());
584    }
585    let keep = &entries[entries.len() - max_entries..];
586    let mut body = String::new();
587    for e in keep {
588        let line = serde_json::to_string(e).map_err(std::io::Error::other)?;
589        body.push_str(&line);
590        body.push('\n');
591    }
592    let tmp = path.with_extension("jsonl.tmp");
593    std::fs::write(&tmp, body)?;
594    std::fs::rename(&tmp, path)
595}
596
597/// Append resolved outcome receipts to the JSONL ledger (one line each).
598/// Append-only: no full-file rewrite, so this never triggers the
599/// write-storm the profiles file had. Creates the file/parent on first
600/// write.
601pub fn append_ledger_entries(
602    path: &std::path::Path,
603    entries: &[OutcomeLedgerEntry],
604) -> std::io::Result<()> {
605    if entries.is_empty() {
606        return Ok(());
607    }
608    if let Some(parent) = path.parent() {
609        std::fs::create_dir_all(parent)?;
610    }
611    use std::io::Write;
612    let mut opts = std::fs::OpenOptions::new();
613    opts.create(true).append(true);
614    // Local, privacy-bounded file (error text, soon project paths): owner
615    // read/write only.
616    #[cfg(unix)]
617    {
618        use std::os::unix::fs::OpenOptionsExt;
619        opts.mode(0o600);
620    }
621    // On Windows `mode(0o600)` is ignored; lock the file owner-only via ACL on
622    // a fresh create only (idempotent). No-op off Windows.
623    let existed = path.exists();
624    let mut f = opts.open(path)?;
625    if !existed {
626        car_secrets::harden_owner_only(path);
627    }
628    for e in entries {
629        let line = serde_json::to_string(e).map_err(std::io::Error::other)?;
630        f.write_all(line.as_bytes())?;
631        f.write_all(b"\n")?;
632    }
633    Ok(())
634}
635
636/// Read the most recent `limit` ledger entries (0 = all). Tolerant of
637/// partial/garbage lines (skips them) so a torn append never poisons a
638/// read — the consumer (UsageProfile) gets whatever is well-formed.
639pub fn read_ledger(path: &std::path::Path, limit: usize) -> Vec<OutcomeLedgerEntry> {
640    let Ok(content) = std::fs::read_to_string(path) else {
641        return Vec::new();
642    };
643    let mut out: Vec<OutcomeLedgerEntry> = content
644        .lines()
645        .filter(|l| !l.trim().is_empty())
646        .filter_map(|l| serde_json::from_str(l).ok())
647        .collect();
648    if limit > 0 && out.len() > limit {
649        out = out.split_off(out.len() - limit);
650    }
651    out
652}
653
654/// Tracks inference outcomes and builds performance profiles.
655pub struct OutcomeTracker {
656    /// In-memory profiles, keyed by model_id.
657    profiles: HashMap<String, ModelProfile>,
658    /// Pending outcomes: completed inference calls awaiting outcome signal.
659    /// Keyed by trace_id.
660    pending: HashMap<String, InferenceOutcome>,
661    /// Counter for generating trace IDs.
662    trace_counter: u64,
663    /// Models excluded for this session (429/rate-limited). Hard exclusion.
664    excluded: HashSet<String>,
665    /// Set whenever a persisted field (a `ModelProfile`) changes; cleared
666    /// on save. Lets the engine debounce disk writes — persist only when
667    /// there's something new, instead of serializing the whole file after
668    /// every inference call. `excluded`/`pending` are session-only and do
669    /// not flip this.
670    dirty: bool,
671    /// Resolved outcome receipts awaiting append to the JSONL ledger.
672    /// Drained by the engine on flush. Bounded by [`MAX_LEDGER_BUFFER`].
673    ledger_buffer: std::collections::VecDeque<OutcomeLedgerEntry>,
674}
675
676impl OutcomeTracker {
677    pub fn new() -> Self {
678        Self {
679            profiles: HashMap::new(),
680            pending: HashMap::new(),
681            trace_counter: 0,
682            excluded: HashSet::new(),
683            dirty: false,
684            ledger_buffer: std::collections::VecDeque::new(),
685        }
686    }
687
688    /// Push a resolved receipt to the ledger buffer (capped; oldest drops).
689    fn push_ledger(&mut self, entry: OutcomeLedgerEntry) {
690        if self.ledger_buffer.len() >= MAX_LEDGER_BUFFER {
691            self.ledger_buffer.pop_front();
692        }
693        self.ledger_buffer.push_back(entry);
694    }
695
696    /// Drain buffered receipts for the engine to append to the ledger file.
697    pub fn drain_ledger(&mut self) -> Vec<OutcomeLedgerEntry> {
698        self.ledger_buffer.drain(..).collect()
699    }
700
701    /// Check if a model is excluded (rate-limited) for this session.
702    pub fn is_excluded(&self, model_id: &str) -> bool {
703        self.excluded.contains(model_id)
704    }
705
706    /// Record that an inference call started. Returns a trace_id.
707    pub fn record_start(
708        &mut self,
709        model_id: &str,
710        task: InferenceTask,
711        routing_reason: &str,
712    ) -> String {
713        self.trace_counter += 1;
714        let trace_id = format!("t-{}-{}", now_unix(), self.trace_counter);
715
716        let outcome = InferenceOutcome {
717            trace_id: trace_id.clone(),
718            model_id: model_id.to_string(),
719            task,
720            // Length-capped: `routing_reason` is the only free-text field
721            // that reaches the persisted ledger. INVARIANT: it must NEVER
722            // embed prompt or output text — only a routing rationale
723            // ("Code task -> Qwen3-4B"). The cap bounds accidental growth.
724            routing_reason: redact_error(routing_reason),
725            latency_ms: 0,
726            input_tokens: 0,
727            output_tokens: 0,
728            cache_read_input_tokens: 0,
729            cache_creation_input_tokens: 0,
730            inferred_outcome: None,
731            code_outcome: None,
732            error: None,
733            timestamp: now_unix(),
734            success_credited: false,
735        };
736
737        self.pending.insert(trace_id.clone(), outcome);
738        trace_id
739    }
740
741    /// Record completion of an inference call (timing + token counts).
742    pub fn record_complete(
743        &mut self,
744        trace_id: &str,
745        latency_ms: u64,
746        input_tokens: usize,
747        output_tokens: usize,
748    ) {
749        self.record_complete_cached(trace_id, latency_ms, input_tokens, output_tokens, 0, 0);
750    }
751
752    /// Like [`Self::record_complete`] but also records the prompt-cache token
753    /// split (`cache_read` = hit, `cache_creation` = write). `input_tokens`
754    /// here is the *uncached* prefix only — for Anthropic that's
755    /// `usage.input_tokens`, with the cached portion passed separately so cost
756    /// accounting prices each bucket at its own rate. The plain 4-arg
757    /// [`Self::record_complete`] forwards here with both cache buckets zeroed,
758    /// which is correct for non-caching providers and local inference.
759    pub fn record_complete_cached(
760        &mut self,
761        trace_id: &str,
762        latency_ms: u64,
763        input_tokens: usize,
764        output_tokens: usize,
765        cache_read_input_tokens: usize,
766        cache_creation_input_tokens: usize,
767    ) {
768        if let Some(outcome) = self.pending.get_mut(trace_id) {
769            outcome.latency_ms = latency_ms;
770            outcome.input_tokens = input_tokens;
771            outcome.output_tokens = output_tokens;
772            outcome.cache_read_input_tokens = cache_read_input_tokens;
773            outcome.cache_creation_input_tokens = cache_creation_input_tokens;
774
775            // Credit a *mechanical* success immediately when the call ran
776            // and produced output with no error. This is symmetric with
777            // `record_failure`, which books a failure the moment it happens
778            // — previously a success was credited only by the 300s pending
779            // sweep, so short-lived processes (the `car infer` CLI, eval
780            // harnesses) exited before the sweep and NEVER recorded a
781            // success, pinning working models at the 0.5 EMA prior / "0
782            // successes" in the health UI (#312, only partially fixed by
783            // the deferred sweep). `ema_quality` is untouched — only a real
784            // downstream accept/edit signal moves quality. `success_credited`
785            // stops the later sweep and any quality-signal resolution from
786            // double-counting this same call.
787            let mechanical_success = output_tokens > 0 && outcome.error.is_none();
788            if mechanical_success {
789                outcome.success_credited = true;
790            }
791            let model_id = outcome.model_id.clone();
792            let task_key = outcome.task.to_string();
793
794            // Update profile with timing data (the `outcome` borrow of
795            // `self.pending` ends here; `self.profiles` is a disjoint field).
796            let profile = self
797                .profiles
798                .entry(model_id.clone())
799                .or_insert_with(|| ModelProfile::new(model_id));
800
801            profile.total_calls += 1;
802            profile.total_latency_ms += latency_ms;
803            profile.total_input_tokens += input_tokens as u64;
804            profile.total_output_tokens += output_tokens as u64;
805            profile.total_cache_read_input_tokens += cache_read_input_tokens as u64;
806            profile.total_cache_creation_input_tokens += cache_creation_input_tokens as u64;
807            if mechanical_success {
808                profile.success_count += 1;
809            }
810
811            let ts = profile.task_stats.entry(task_key).or_default();
812            ts.calls += 1;
813            if mechanical_success {
814                ts.successes += 1;
815            }
816            ts.avg_latency_ms =
817                ts.avg_latency_ms + (latency_ms as f64 - ts.avg_latency_ms) / ts.calls as f64;
818
819            profile.updated_at = now_unix();
820            self.dirty = true;
821        }
822    }
823
824    /// Record a failure.
825    pub fn record_failure(&mut self, trace_id: &str, error: &str) {
826        let mut ledger_entry = None;
827        if let Some(outcome) = self.pending.get_mut(trace_id) {
828            outcome.error = Some(error.to_string());
829
830            let profile = self
831                .profiles
832                .entry(outcome.model_id.clone())
833                .or_insert_with(|| ModelProfile::new(outcome.model_id.clone()));
834
835            // A failed call is still a call: count it in `total_calls` so the
836            // denominator includes failures (record_complete counts successes
837            // the same way). Without this, `total_calls` tallied only
838            // completions and could read *smaller* than `fail_count` — the
839            // live symptom on parslee/* models (6 calls, 14 fails).
840            profile.total_calls += 1;
841            profile.fail_count += 1;
842
843            // Rate-limit errors (429) get a harsher penalty — the model is
844            // guaranteed to fail again, so drop quality aggressively.
845            let is_rate_limited = error.contains("429") || error.contains("RESOURCE_EXHAUSTED");
846            // No-answer failures (read timeout, connection reset, 5xx, EOF, AND
847            // auth/client rejections like 401/403/400) never produced an answer,
848            // so answer quality is UNKNOWN — they must stay quality-neutral. The
849            // call + failure are already booked above (`total_calls`/`fail_count`)
850            // so availability and the circuit breaker still react; we just leave
851            // `ema_quality` / `quality_observations` untouched. See
852            // `is_no_answer_failure`.
853            let is_no_answer = !is_rate_limited && is_no_answer_failure(error);
854            if is_rate_limited {
855                // Hard-exclude for the rest of this session (#13)
856                self.excluded.insert(outcome.model_id.clone());
857                profile.ema_quality *= 0.1;
858                // A 429 is a real (availability) quality signal — it counts.
859                profile.quality_observations += 1;
860            } else if !is_no_answer {
861                profile.ema_quality = profile.ema_quality * (1.0 - EMA_ALPHA) + 0.0 * EMA_ALPHA;
862                // A generation failure is a real graded quality signal (it moved
863                // ema_quality toward 0), so it counts toward quality-evidence.
864                profile.quality_observations += 1;
865            }
866
867            let task_key = outcome.task.to_string();
868            let ts = profile.task_stats.entry(task_key).or_default();
869            // `failures` is an availability counter — every failure counts,
870            // no-answer failures included (it feeds degradation / circuit-breaking).
871            ts.failures += 1;
872            if is_rate_limited {
873                ts.ema_quality *= 0.1;
874                ts.quality_observations += 1;
875            } else if !is_no_answer {
876                ts.ema_quality *= 1.0 - EMA_ALPHA;
877                ts.quality_observations += 1;
878            }
879
880            profile.updated_at = now_unix();
881            self.dirty = true;
882
883            ledger_entry = Some(OutcomeLedgerEntry {
884                trace_id: outcome.trace_id.clone(),
885                model_id: outcome.model_id.clone(),
886                task: outcome.task,
887                routing_reason: outcome.routing_reason.clone(),
888                latency_ms: outcome.latency_ms,
889                input_tokens: outcome.input_tokens,
890                output_tokens: outcome.output_tokens,
891                cache_read_input_tokens: outcome.cache_read_input_tokens,
892                cache_creation_input_tokens: outcome.cache_creation_input_tokens,
893                success: Some(false),
894                // #374 follow-up: the ledger's `quality` is ANSWER quality,
895                // recorded only when an answer was actually produced. A
896                // *generation* failure produced a bad answer, so it's a real
897                // graded `0.0` — mirroring the in-memory EMA's move toward 0 — so
898                // the scoreboard's `avg_quality` and the shadow-calibration band
899                // (#369) that fold this ledger see the same answer-quality signal
900                // the live EMA does. A *no-answer* failure (transport/timeout/5xx
901                // OR an auth/client rejection like 401/403/400) or a *429* produced
902                // NO answer, so answer quality is unknown -> `None`, keeping
903                // availability noise out of the answer-quality fold — the same
904                // separation `record_failure` applies to the in-memory EMA above.
905                //
906                // NOTE: a 429 maps to `None` here INTENTIONALLY, even though the
907                // in-memory path above counts it as a quality observation
908                // (`ema_quality *= 0.1`). The in-memory EMA is a *routing-
909                // availability* knob (a 429 means "keep steering away this
910                // session"); the ledger field is *answer* quality, and a 429
911                // produced no answer to grade. Don't "align" this to `Some(0.0)`
912                // — that would inject availability noise into avg_quality / the
913                // shadow band (the very thing #374 set out to remove).
914                quality: if is_rate_limited || is_no_answer {
915                    None
916                } else {
917                    Some(0.0)
918                },
919                error: Some(redact_error(error)),
920                project_id: None,
921                intent: None,
922                timestamp: now_unix(),
923            });
924        }
925
926        if let Some(entry) = ledger_entry {
927            self.push_ledger(entry);
928        }
929
930        // Failed outcomes don't need further tracking
931        self.pending.remove(trace_id);
932    }
933
934    /// Resolve a request/provider capability mismatch without treating it as a
935    /// model-health or answer-quality failure.
936    ///
937    /// The attempt is still emitted to the receipt ledger for operator
938    /// visibility, but it must not change the profile that adaptive routing
939    /// uses for unrelated requests to the same model.
940    pub fn record_capability_rejection(&mut self, trace_id: &str, error: &str) {
941        self.record_unattributed(trace_id, error);
942    }
943
944    /// Resolve a provider **account** rejection — bad/absent key, or out of
945    /// credits — without treating it as a model failure.
946    ///
947    /// Same unattributed receipt as [`record_capability_rejection`](crate::outcome::OutcomeTracker::record_capability_rejection), different
948    /// reason: the condition is account-wide, so it is not evidence about the
949    /// model that happened to be selected. Booking it as a failure degrades the
950    /// 30-day health EMA of every model the fallback chain touches, and that
951    /// penalty outlives the top-up that fixes the account (Parslee-ai/car#650).
952    pub fn record_account_rejection(&mut self, trace_id: &str, error: &str) {
953        self.record_unattributed(trace_id, error);
954    }
955
956    /// Emit a receipt that records what happened without attributing it to the
957    /// model: `success: None`, `quality: None`, so the ledger keeps the
958    /// evidence while the routing profile stays untouched.
959    fn record_unattributed(&mut self, trace_id: &str, error: &str) {
960        if let Some(outcome) = self.pending.remove(trace_id) {
961            self.push_ledger(OutcomeLedgerEntry {
962                trace_id: outcome.trace_id,
963                model_id: outcome.model_id,
964                task: outcome.task,
965                routing_reason: outcome.routing_reason,
966                latency_ms: outcome.latency_ms,
967                input_tokens: outcome.input_tokens,
968                output_tokens: outcome.output_tokens,
969                cache_read_input_tokens: outcome.cache_read_input_tokens,
970                cache_creation_input_tokens: outcome.cache_creation_input_tokens,
971                success: None,
972                quality: None,
973                error: Some(redact_error(error)),
974                project_id: None,
975                intent: None,
976                timestamp: now_unix(),
977            });
978        }
979    }
980
981    /// Record an inferred outcome from conversation signals.
982    pub fn record_inferred_outcome(&mut self, trace_id: &str, outcome: InferredOutcome) {
983        if let Some(pending) = self.pending.remove(trace_id) {
984            self.apply_outcome(&pending, outcome.quality_score(), outcome.is_success());
985        }
986    }
987
988    /// Record an outcome from git-diff comparison (code generation).
989    pub fn record_code_outcome(&mut self, trace_id: &str, outcome: CodeOutcome) {
990        if let Some(pending) = self.pending.remove(trace_id) {
991            self.apply_outcome(
992                &pending,
993                Some(outcome.quality_score()),
994                Some(outcome.is_success()),
995            );
996        }
997    }
998
999    /// Resolve all pending outcomes for a completed conversation turn.
1000    /// Called with the inferred outcomes from conversation signal analysis.
1001    pub fn resolve_pending_from_signals(&mut self, outcomes: Vec<(String, InferredOutcome)>) {
1002        for (trace_id, inferred) in outcomes {
1003            self.record_inferred_outcome(&trace_id, inferred);
1004        }
1005    }
1006
1007    /// Infer outcomes from a sequence of action results.
1008    ///
1009    /// In a reasoning session, each action's output feeds the next. If action N
1010    /// produced output and action N+1 succeeded using it, N was implicitly accepted.
1011    /// If N produced empty output or N+1 failed, N was implicitly rejected.
1012    ///
1013    /// Returns (trace_id, inferred_outcome) pairs ready for `resolve_pending_from_signals`.
1014    pub fn infer_outcomes_from_action_sequence(
1015        &self,
1016        action_results: &[(String, bool, f64, String)], // (trace_id, success, confidence, output)
1017    ) -> Vec<(String, InferredOutcome)> {
1018        let mut outcomes = Vec::new();
1019
1020        for (i, (trace_id, success, confidence, output)) in action_results.iter().enumerate() {
1021            if trace_id.is_empty() {
1022                continue; // No trace (e.g., memgine-only action)
1023            }
1024
1025            if !success {
1026                outcomes.push((
1027                    trace_id.clone(),
1028                    InferredOutcome::Rejected {
1029                        confidence: *confidence,
1030                    },
1031                ));
1032                continue;
1033            }
1034
1035            // Check if the next action used this one's output (implicit acceptance)
1036            let next_succeeded = action_results
1037                .get(i + 1)
1038                .map(|(_, s, _, _)| *s)
1039                .unwrap_or(true); // Last action: assume accepted if successful
1040
1041            let has_output = !output.trim().is_empty();
1042
1043            if has_output && next_succeeded {
1044                outcomes.push((
1045                    trace_id.clone(),
1046                    InferredOutcome::Accepted {
1047                        confidence: *confidence,
1048                    },
1049                ));
1050            } else if has_output && !next_succeeded {
1051                // Output existed but downstream failed — may not be this action's fault
1052                outcomes.push((
1053                    trace_id.clone(),
1054                    InferredOutcome::AcceptedWithEdits {
1055                        confidence: confidence * 0.7,
1056                    },
1057                ));
1058            } else {
1059                outcomes.push((trace_id.clone(), InferredOutcome::Inconclusive));
1060            }
1061        }
1062
1063        outcomes
1064    }
1065
1066    /// Get the profile for a model.
1067    pub fn profile(&self, model_id: &str) -> Option<&ModelProfile> {
1068        self.profiles.get(model_id)
1069    }
1070
1071    /// Whether a trace is still awaiting an outcome (in `pending`). Lets a
1072    /// caller observe, before calling `record_inferred_outcome`, whether the
1073    /// resolution will actually land or silently no-op (trace already resolved
1074    /// or swept) — so a lost outcome signal is countable, not invisible.
1075    pub fn has_pending(&self, trace_id: &str) -> bool {
1076        self.pending.contains_key(trace_id)
1077    }
1078
1079    /// Get all profiles.
1080    pub fn all_profiles(&self) -> &HashMap<String, ModelProfile> {
1081        &self.profiles
1082    }
1083
1084    /// Get pending trace IDs (for conversation signal analysis).
1085    pub fn pending_trace_ids(&self) -> Vec<String> {
1086        self.pending.keys().cloned().collect()
1087    }
1088
1089    /// Get a pending outcome by trace_id.
1090    pub fn get_pending(&self, trace_id: &str) -> Option<&InferenceOutcome> {
1091        self.pending.get(trace_id)
1092    }
1093
1094    /// Export profiles for serialization / persistence. Derived metrics
1095    /// (quality_per_1k_tokens) are recomputed on the way out so callers
1096    /// always see a consistent snapshot.
1097    pub fn export_profiles(&self) -> Vec<ModelProfile> {
1098        self.profiles
1099            .values()
1100            .cloned()
1101            .map(|mut p| {
1102                p.quality_per_1k_tokens = p.compute_quality_per_1k_tokens();
1103                p
1104            })
1105            .collect()
1106    }
1107
1108    /// Import profiles as a genuine mutation (benchmark priors, CLI import
1109    /// from the memgine fact graph, router merges). Marks the tracker
1110    /// dirty so the new profiles actually reach disk on the next flush.
1111    /// NOTE: hydration from disk does NOT go through here — see
1112    /// [`load_from_file`](crate::outcome::OutcomeTracker::load_from_file), which inserts directly and stays clean (loading
1113    /// is not a change).
1114    pub fn import_profiles(&mut self, profiles: Vec<ModelProfile>) {
1115        for p in profiles {
1116            self.profiles.insert(p.model_id.clone(), p);
1117        }
1118        self.dirty = true;
1119    }
1120
1121    /// Save profiles to a JSON file for cross-session persistence (#13).
1122    ///
1123    /// Atomic: serialize to a sibling temp file, then rename over the
1124    /// target. This is the durable receipt store — a torn `write()`
1125    /// (crash mid-write) would corrupt it, and `load_from_file` treats a
1126    /// parse failure as a hard error, which on next boot loses *all*
1127    /// history. The temp+rename makes a partial write impossible.
1128    pub fn save_to_file(&self, path: &std::path::Path) -> Result<(), std::io::Error> {
1129        let profiles = self.export_profiles();
1130        let json = serde_json::to_string_pretty(&profiles).map_err(std::io::Error::other)?;
1131        if let Some(parent) = path.parent() {
1132            std::fs::create_dir_all(parent)?;
1133        }
1134        let tmp = path.with_extension("json.tmp");
1135        std::fs::write(&tmp, json)?;
1136        std::fs::rename(&tmp, path)
1137    }
1138
1139    /// True if a persisted profile has changed since the last save.
1140    pub fn is_dirty(&self) -> bool {
1141        self.dirty
1142    }
1143
1144    /// Save only if dirty, clearing the flag on success. Returns whether
1145    /// a write happened. Lets callers persist cheaply on a timer without
1146    /// rewriting the whole file when nothing changed.
1147    pub fn save_if_dirty(&mut self, path: &std::path::Path) -> Result<bool, std::io::Error> {
1148        if !self.dirty {
1149            return Ok(false);
1150        }
1151        self.save_to_file(path)?;
1152        self.dirty = false;
1153        Ok(true)
1154    }
1155
1156    /// Load profiles from a JSON file for cross-session persistence (#13).
1157    pub fn load_from_file(&mut self, path: &std::path::Path) -> Result<usize, std::io::Error> {
1158        if !path.exists() {
1159            return Ok(0);
1160        }
1161        let json = std::fs::read_to_string(path)?;
1162        let profiles: Vec<ModelProfile> = serde_json::from_str(&json)
1163            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1164        let count = profiles.len();
1165        // Insert directly (NOT via import_profiles): hydration from disk is
1166        // not a change, so it must not flip the dirty flag — otherwise the
1167        // first idle flush would needlessly rewrite the file we just read.
1168        for mut p in profiles {
1169            // Migration: profiles persisted before `quality_observations`
1170            // existed deserialize it as 0, which would make the router forget
1171            // learned distrust (a model the EMA dropped on past failures would
1172            // revert to its schema estimate). Failures are always graded, so
1173            // `fail_count` is a safe lower bound — backfill from it so learned
1174            // distrust survives the upgrade. No-op for profiles written by this
1175            // version (a failure always bumps `quality_observations`, so the
1176            // count is already ≥ `fail_count`).
1177            backfill_quality_observations(&mut p);
1178            self.profiles.insert(p.model_id.clone(), p);
1179        }
1180        Ok(count)
1181    }
1182
1183    /// Apply a quality signal to the model's profile.
1184    fn apply_outcome(
1185        &mut self,
1186        pending: &InferenceOutcome,
1187        quality: Option<f64>,
1188        success: Option<bool>,
1189    ) {
1190        let profile = self
1191            .profiles
1192            .entry(pending.model_id.clone())
1193            .or_insert_with(|| ModelProfile::new(pending.model_id.clone()));
1194
1195        if let Some(q) = quality {
1196            profile.ema_quality = profile.ema_quality * (1.0 - EMA_ALPHA) + q * EMA_ALPHA;
1197            profile.quality_observations += 1;
1198
1199            let task_key = pending.task.to_string();
1200            let ts = profile.task_stats.entry(task_key).or_default();
1201            ts.ema_quality = ts.ema_quality * (1.0 - EMA_ALPHA) + q * EMA_ALPHA;
1202            ts.quality_observations += 1;
1203        }
1204
1205        if let Some(ok) = success {
1206            // The call may have already been credited a mechanical success at
1207            // completion (record_complete). Don't double-count it here.
1208            let already_credited = pending.success_credited;
1209            let task_key = pending.task.to_string();
1210            if ok {
1211                if !already_credited {
1212                    profile.success_count += 1;
1213                    let ts = profile.task_stats.entry(task_key).or_default();
1214                    ts.successes += 1;
1215                }
1216                // else: already counted; the quality EMA above is the new info.
1217            } else {
1218                // A real downstream signal says this call was bad. If we
1219                // mechanically credited it as a success at completion,
1220                // reclassify: undo the success and book a failure instead.
1221                if already_credited {
1222                    profile.success_count = profile.success_count.saturating_sub(1);
1223                    let ts = profile.task_stats.entry(task_key.clone()).or_default();
1224                    ts.successes = ts.successes.saturating_sub(1);
1225                }
1226                profile.fail_count += 1;
1227                let ts = profile.task_stats.entry(task_key).or_default();
1228                ts.failures += 1;
1229            }
1230        }
1231
1232        profile.updated_at = now_unix();
1233        self.dirty = true;
1234
1235        self.push_ledger(OutcomeLedgerEntry {
1236            trace_id: pending.trace_id.clone(),
1237            model_id: pending.model_id.clone(),
1238            task: pending.task,
1239            routing_reason: pending.routing_reason.clone(),
1240            latency_ms: pending.latency_ms,
1241            input_tokens: pending.input_tokens,
1242            output_tokens: pending.output_tokens,
1243            cache_read_input_tokens: pending.cache_read_input_tokens,
1244            cache_creation_input_tokens: pending.cache_creation_input_tokens,
1245            success,
1246            quality,
1247            error: None,
1248            project_id: None,
1249            intent: None,
1250            timestamp: now_unix(),
1251        });
1252    }
1253
1254    /// Evict `pending` entries older than `ttl_secs` so the map can't grow
1255    /// without bound in a long-running daemon (only a few resolution paths
1256    /// ever drain it). For each evicted entry that actually *completed*
1257    /// (latency recorded), emit a terminal `Inconclusive` receipt — this
1258    /// also de-biases the ledger: without it, the ledger would only ever
1259    /// contain calls that happened to get a follow-up signal (a
1260    /// systematic skew toward reasoning-session traffic), and downstream
1261    /// stats would be computed over a non-representative sample. Returns
1262    /// the number of entries swept.
1263    pub fn sweep_pending(&mut self, ttl_secs: u64) -> usize {
1264        self.sweep_pending_at(ttl_secs, now_unix())
1265    }
1266
1267    /// Clock-injectable core of [`sweep_pending`] for deterministic tests.
1268    fn sweep_pending_at(&mut self, ttl_secs: u64, now: u64) -> usize {
1269        let cutoff = now.saturating_sub(ttl_secs);
1270        let expired: Vec<String> = self
1271            .pending
1272            .iter()
1273            .filter(|(_, o)| o.timestamp < cutoff)
1274            .map(|(id, _)| id.clone())
1275            .collect();
1276        for id in &expired {
1277            if let Some(o) = self.pending.remove(id) {
1278                // Only completed-but-unresolved calls become receipts; a
1279                // never-completed trace (latency 0) is in-flight/crashed —
1280                // a zero-everything receipt would be noise.
1281                if o.latency_ms > 0 {
1282                    // A swept call completed but never received a downstream
1283                    // quality signal. Recording *every* such call Inconclusive
1284                    // (`success: None`) left signal-less models — notably ALL
1285                    // local inference, which has no accept/edit feedback loop —
1286                    // pinned at the 0.5 EMA prior with zero successes, so the
1287                    // health UI rendered working models as "50%"/"0%" (#312).
1288                    // Instead, credit a *mechanical* success when the call
1289                    // actually returned output with no error: it ran and
1290                    // produced tokens; we simply never learned whether the
1291                    // answer was *good*. `quality` stays None so `ema_quality`
1292                    // is only ever moved by a real quality signal. A completion
1293                    // that produced no output stays Inconclusive (ambiguous).
1294                    // Credit a mechanical success here ONLY if it wasn't
1295                    // already booked at completion (record_complete now does
1296                    // this immediately for the common case). A call credited
1297                    // at completion still gets a `Some(true)` receipt below,
1298                    // but its count is not incremented twice.
1299                    let credit_now =
1300                        o.output_tokens > 0 && o.error.is_none() && !o.success_credited;
1301                    let was_success = o.success_credited || credit_now;
1302                    if credit_now {
1303                        let profile = self
1304                            .profiles
1305                            .entry(o.model_id.clone())
1306                            .or_insert_with(|| ModelProfile::new(o.model_id.clone()));
1307                        profile.success_count += 1;
1308                        let ts = profile.task_stats.entry(o.task.to_string()).or_default();
1309                        ts.successes += 1;
1310                        profile.updated_at = now_unix();
1311                        self.dirty = true;
1312                    }
1313                    self.push_ledger(OutcomeLedgerEntry {
1314                        trace_id: o.trace_id,
1315                        model_id: o.model_id,
1316                        task: o.task,
1317                        routing_reason: o.routing_reason,
1318                        latency_ms: o.latency_ms,
1319                        input_tokens: o.input_tokens,
1320                        output_tokens: o.output_tokens,
1321                        cache_read_input_tokens: o.cache_read_input_tokens,
1322                        cache_creation_input_tokens: o.cache_creation_input_tokens,
1323                        success: if was_success { Some(true) } else { None },
1324                        quality: None,
1325                        error: None,
1326                        project_id: None,
1327                        intent: None,
1328                        timestamp: now_unix(),
1329                    });
1330                }
1331            }
1332        }
1333        expired.len()
1334    }
1335
1336    /// Check git diff for pending code suggestions and resolve outcomes.
1337    ///
1338    /// Two strategies:
1339    /// 1. **AST structural diff** (when `ast` feature is enabled): parse the old
1340    ///    and new versions of changed files and compare at the symbol level.
1341    ///    This gives precise outcomes: SignatureChanged, BodyModified, SymbolAdded.
1342    /// 2. **Text diff fallback**: token matching against the combined git diff.
1343    pub fn check_git_outcomes(&mut self, repo_dir: &std::path::Path) {
1344        let diff = match std::process::Command::new("git")
1345            .args(["diff", "--no-color"])
1346            .current_dir(repo_dir)
1347            .output()
1348        {
1349            Ok(output) => String::from_utf8_lossy(&output.stdout).to_string(),
1350            Err(_) => return,
1351        };
1352
1353        let staged_diff = match std::process::Command::new("git")
1354            .args(["diff", "--cached", "--no-color"])
1355            .current_dir(repo_dir)
1356            .output()
1357        {
1358            Ok(output) => String::from_utf8_lossy(&output.stdout).to_string(),
1359            Err(_) => String::new(),
1360        };
1361
1362        let combined_diff = format!("{}\n{}", diff, staged_diff);
1363
1364        if combined_diff.trim().is_empty() {
1365            return; // No changes at all
1366        }
1367
1368        // Try AST structural diff on changed files
1369        #[cfg(feature = "ast")]
1370        let ast_outcome = Self::check_git_outcomes_ast(repo_dir);
1371
1372        let code_traces: Vec<(String, String)> = self
1373            .pending
1374            .iter()
1375            .filter(|(_, o)| matches!(o.task, InferenceTask::Code))
1376            .map(|(id, o)| (id.clone(), o.model_id.clone()))
1377            .collect();
1378
1379        for (trace_id, _model_id) in code_traces {
1380            if let Some(pending) = self.pending.get(&trace_id) {
1381                // Try AST-based outcome first
1382                #[cfg(feature = "ast")]
1383                if let Some(ref ast_out) = ast_outcome {
1384                    let pending_clone = pending.clone();
1385                    self.apply_outcome(
1386                        &pending_clone,
1387                        Some(ast_out.quality_score()),
1388                        Some(ast_out.is_success()),
1389                    );
1390                    continue;
1391                }
1392
1393                // Fallback: text token matching
1394                let output_tokens: Vec<&str> = pending
1395                    .routing_reason
1396                    .split_whitespace()
1397                    .filter(|t| t.len() > 5)
1398                    .collect();
1399
1400                let outcome = if output_tokens.iter().any(|t| combined_diff.contains(t)) {
1401                    CodeOutcome::Applied
1402                } else {
1403                    CodeOutcome::Modified
1404                };
1405
1406                let pending_clone = pending.clone();
1407                self.apply_outcome(
1408                    &pending_clone,
1409                    Some(outcome.quality_score()),
1410                    Some(outcome.is_success()),
1411                );
1412            }
1413        }
1414    }
1415
1416    /// AST-based git outcome: parse changed files before and after, diff symbols.
1417    #[cfg(feature = "ast")]
1418    fn check_git_outcomes_ast(repo_dir: &std::path::Path) -> Option<CodeOutcome> {
1419        // Get list of changed files
1420        let name_only = std::process::Command::new("git")
1421            .args(["diff", "--name-only"])
1422            .current_dir(repo_dir)
1423            .output()
1424            .ok()?;
1425        let changed_files: Vec<&str> = std::str::from_utf8(&name_only.stdout)
1426            .ok()?
1427            .lines()
1428            .filter(|f| !f.is_empty())
1429            .collect();
1430
1431        if changed_files.is_empty() {
1432            return None;
1433        }
1434
1435        let mut has_sig_change = false;
1436        let mut has_body_change = false;
1437        let mut has_addition = false;
1438
1439        for file in &changed_files {
1440            // Only parse files tree-sitter supports
1441            if car_ast::Language::from_filename(file).is_none() {
1442                continue;
1443            }
1444
1445            // Get the HEAD version
1446            let old_content = std::process::Command::new("git")
1447                .args(["show", &format!("HEAD:{}", file)])
1448                .current_dir(repo_dir)
1449                .output()
1450                .ok()
1451                .and_then(|o| {
1452                    if o.status.success() {
1453                        String::from_utf8(o.stdout).ok()
1454                    } else {
1455                        None
1456                    }
1457                });
1458
1459            // Get the working tree version
1460            let new_path = repo_dir.join(file);
1461            let new_content = std::fs::read_to_string(&new_path).ok();
1462
1463            match (old_content, new_content) {
1464                (Some(old), Some(new)) => {
1465                    let old_parsed = car_ast::parse_file(&old, file);
1466                    let new_parsed = car_ast::parse_file(&new, file);
1467
1468                    if let (Some(old_p), Some(new_p)) = (old_parsed, new_parsed) {
1469                        let changes = car_ast::diff_symbols(&old_p, &new_p);
1470                        for change in &changes {
1471                            match change {
1472                                car_ast::SymbolChange::Added(_) => has_addition = true,
1473                                car_ast::SymbolChange::Modified {
1474                                    signature_changed, ..
1475                                } => {
1476                                    if *signature_changed {
1477                                        has_sig_change = true;
1478                                    } else {
1479                                        has_body_change = true;
1480                                    }
1481                                }
1482                                car_ast::SymbolChange::Removed(_) => has_sig_change = true,
1483                            }
1484                        }
1485                    }
1486                }
1487                (None, Some(_)) => has_addition = true, // New file
1488                _ => {}
1489            }
1490        }
1491
1492        // Return the most significant outcome
1493        if has_sig_change {
1494            Some(CodeOutcome::SignatureChanged)
1495        } else if has_body_change {
1496            Some(CodeOutcome::BodyModified)
1497        } else if has_addition {
1498            Some(CodeOutcome::SymbolAdded)
1499        } else {
1500            None // No structural changes detected (maybe non-code files changed)
1501        }
1502    }
1503}
1504
1505impl Default for OutcomeTracker {
1506    fn default() -> Self {
1507        Self::new()
1508    }
1509}
1510
1511fn now_unix() -> u64 {
1512    SystemTime::now()
1513        .duration_since(UNIX_EPOCH)
1514        .unwrap_or_default()
1515        .as_secs()
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520    use super::*;
1521
1522    #[test]
1523    fn lifecycle() {
1524        let mut tracker = OutcomeTracker::new();
1525
1526        // Start an inference call
1527        let trace = tracker.record_start(
1528            "qwen/qwen3-4b:q4_k_m",
1529            InferenceTask::Code,
1530            "Code task -> Qwen3-4B",
1531        );
1532
1533        // Complete it
1534        tracker.record_complete(&trace, 1200, 100, 50);
1535
1536        // Profile should have 1 call
1537        let profile = tracker.profile("qwen/qwen3-4b:q4_k_m").unwrap();
1538        assert_eq!(profile.total_calls, 1);
1539        assert_eq!(profile.avg_latency_ms(), 1200.0);
1540
1541        // Record positive outcome
1542        tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 0.9 });
1543
1544        let profile = tracker.profile("qwen/qwen3-4b:q4_k_m").unwrap();
1545        assert_eq!(profile.success_count, 1);
1546        assert!(profile.ema_quality > 0.5); // should have gone up from 0.5
1547    }
1548
1549    #[test]
1550    fn failure_degrades() {
1551        // A failed call goes through record_failure ONLY — it never also
1552        // hits record_complete (a call either completes or errors, not both).
1553        // record_complete now credits a mechanical success, so mixing the two
1554        // on one trace would model a flow that does not occur.
1555        let mut tracker = OutcomeTracker::new();
1556        for _ in 0..5 {
1557            let trace = tracker.record_start("bad-model", InferenceTask::Generate, "test");
1558            // A *generation* failure (not a transport/timeout error, which is
1559            // quality-neutral — see `transport_failures_are_quality_neutral`).
1560            tracker.record_failure(&trace, "model produced malformed output");
1561        }
1562
1563        let profile = tracker.profile("bad-model").unwrap();
1564        assert_eq!(profile.fail_count, 5);
1565        assert_eq!(profile.success_count, 0);
1566        assert!(profile.should_degrade(2)); // 5 > 0 + 2
1567        assert!(profile.ema_quality < 0.3); // decayed toward 0
1568    }
1569
1570    #[test]
1571    fn code_outcome_ground_truth() {
1572        let mut tracker = OutcomeTracker::new();
1573
1574        let trace = tracker.record_start("qwen/qwen3-4b:q4_k_m", InferenceTask::Code, "code");
1575        tracker.record_complete(&trace, 500, 200, 100);
1576        tracker.record_code_outcome(&trace, CodeOutcome::Applied);
1577
1578        let profile = tracker.profile("qwen/qwen3-4b:q4_k_m").unwrap();
1579        assert_eq!(profile.success_count, 1);
1580        // EMA should reflect Applied quality (1.0): 0.5 * 0.8 + 1.0 * 0.2 = 0.6
1581        assert!((profile.ema_quality - 0.6).abs() < 0.01);
1582    }
1583
1584    #[test]
1585    fn per_task_stats() {
1586        let mut tracker = OutcomeTracker::new();
1587
1588        // Two code calls, one generate call
1589        for _ in 0..2 {
1590            let trace = tracker.record_start("m1", InferenceTask::Code, "code");
1591            tracker.record_complete(&trace, 1000, 100, 50);
1592            tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 0.8 });
1593        }
1594        let trace = tracker.record_start("m1", InferenceTask::Generate, "gen");
1595        tracker.record_complete(&trace, 500, 50, 25);
1596        tracker.record_inferred_outcome(&trace, InferredOutcome::Rejected { confidence: 0.9 });
1597
1598        let profile = tracker.profile("m1").unwrap();
1599        assert_eq!(profile.total_calls, 3);
1600
1601        let code_stats = profile.task_stats(InferenceTask::Code).unwrap();
1602        assert_eq!(code_stats.calls, 2);
1603        assert_eq!(code_stats.successes, 2);
1604
1605        let gen_stats = profile.task_stats(InferenceTask::Generate).unwrap();
1606        assert_eq!(gen_stats.calls, 1);
1607        assert_eq!(gen_stats.failures, 1);
1608    }
1609
1610    #[test]
1611    fn export_populates_quality_per_1k_tokens() {
1612        let mut tracker = OutcomeTracker::new();
1613        let trace = tracker.record_start("m1", InferenceTask::Generate, "test");
1614        tracker.record_complete(&trace, 100, 800, 200); // 1000 tokens total
1615        tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 1.0 });
1616
1617        let exported = tracker.export_profiles();
1618        assert_eq!(exported.len(), 1);
1619        let p = &exported[0];
1620        // ema_quality after one Accepted{1.0}: 0.5 * 0.8 + 1.0 * 0.2 = 0.6
1621        // quality_per_1k = 0.6 * 1000 / 1000 = 0.6
1622        assert!(
1623            (p.quality_per_1k_tokens - 0.6).abs() < 1e-6,
1624            "got {}",
1625            p.quality_per_1k_tokens
1626        );
1627    }
1628
1629    #[test]
1630    fn quality_per_1k_tokens_zero_without_tokens() {
1631        let profile = ModelProfile::new("x".into());
1632        assert_eq!(profile.compute_quality_per_1k_tokens(), 0.0);
1633    }
1634
1635    #[test]
1636    fn tokens_per_success_is_outcome_denominated() {
1637        let mut p = ModelProfile::new("x".into());
1638        // No success yet → None (not a fabricated number).
1639        assert_eq!(p.tokens_per_success(), None);
1640        // 900 total tokens over 3 successful outcomes → 300 tokens/success.
1641        p.total_input_tokens = 600;
1642        p.total_output_tokens = 300;
1643        p.success_count = 3;
1644        assert_eq!(p.tokens_per_success(), Some(300.0));
1645        // Dollar cost-per-success: (600*$1 + 300*$2)/1M / 3 successes.
1646        let usd = p.usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC).unwrap();
1647        assert!((usd - ((600.0 * 1.0 + 300.0 * 2.0) / 1_000_000.0 / 3.0)).abs() < 1e-12);
1648        assert_eq!(
1649            ModelProfile::new("y".into()).usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC),
1650            None
1651        );
1652    }
1653
1654    #[test]
1655    fn usd_per_success_prices_cache_buckets_separately() {
1656        let mut p = ModelProfile::new("cached".into());
1657        p.success_count = 1;
1658        p.total_input_tokens = 100; // uncached prefix @ 1.0×
1659        p.total_cache_read_input_tokens = 1000; // hit @ 0.1×
1660        p.total_cache_creation_input_tokens = 200; // write @ 1.25×
1661        p.total_output_tokens = 50; // output @ 2.0×
1662                                    // Each input bucket priced at its own multiplier off the $1/Mtok base.
1663        let expected_input = (100.0 * 1.0 + 1000.0 * 1.0 * 0.1 + 200.0 * 1.0 * 1.25) / 1_000_000.0;
1664        let expected = expected_input + 50.0 * 2.0 / 1_000_000.0;
1665        let usd = p.usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC).unwrap();
1666        assert!((usd - expected).abs() < 1e-12, "got {usd}, want {expected}");
1667        // Token efficiency counts all real tokens, cache included.
1668        assert_eq!(p.total_tokens(), 100 + 1000 + 200 + 50);
1669        // A cache hit must be ~10× cheaper than counting it as full input,
1670        // which is the whole point of fixing the accounting.
1671        let mut naive = ModelProfile::new("naive".into());
1672        naive.success_count = 1;
1673        naive.total_input_tokens = 1300; // same tokens, all priced at 1.0×
1674        naive.total_output_tokens = 50;
1675        assert!(
1676            naive
1677                .usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC)
1678                .unwrap()
1679                > usd
1680        );
1681    }
1682
1683    #[test]
1684    fn openai_cache_rates_price_reads_at_half_no_write_premium() {
1685        // OpenAI economics: cached reads at 0.5×, no write bucket. Same token
1686        // counts as Anthropic must cost MORE under OpenAI (0.5× vs 0.1× reads).
1687        let mut p = ModelProfile::new("gpt".into());
1688        p.success_count = 1;
1689        p.total_input_tokens = 100; // uncached @ 1.0×
1690        p.total_cache_read_input_tokens = 1000; // OpenAI hit @ 0.5×
1691        p.total_cache_creation_input_tokens = 0; // OpenAI has no write bucket
1692        let usd = p.usd_per_success(1.0, 2.0, CacheRates::OPENAI).unwrap();
1693        let expected = (100.0 * 1.0 + 1000.0 * 1.0 * 0.5) / 1_000_000.0;
1694        assert!((usd - expected).abs() < 1e-12, "got {usd}, want {expected}");
1695        // Same buckets under Anthropic rates (0.1× read) are cheaper.
1696        let anthropic = p.usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC).unwrap();
1697        assert!(
1698            usd > anthropic,
1699            "OpenAI 0.5× read must exceed Anthropic 0.1×"
1700        );
1701        // And both beat pricing the cache hit as full uncached input (1.0×).
1702        let mut naive = ModelProfile::new("naive".into());
1703        naive.success_count = 1;
1704        naive.total_input_tokens = 1100;
1705        assert!(naive.usd_per_success(1.0, 2.0, CacheRates::OPENAI).unwrap() > usd);
1706    }
1707
1708    #[test]
1709    fn record_complete_cached_accumulates_cache_totals() {
1710        let mut tracker = OutcomeTracker::new();
1711        let trace = tracker.record_start("m", InferenceTask::Generate, "gen");
1712        tracker.record_complete_cached(&trace, 100, 40, 20, 800, 120);
1713        let p = tracker.profile("m").unwrap();
1714        assert_eq!(p.total_input_tokens, 40, "uncached prefix only");
1715        assert_eq!(p.total_cache_read_input_tokens, 800);
1716        assert_eq!(p.total_cache_creation_input_tokens, 120);
1717    }
1718
1719    #[test]
1720    fn dirty_flag_and_save_if_dirty() {
1721        let dir = std::env::temp_dir().join("car-outcome-dirty-test");
1722        let _ = std::fs::remove_dir_all(&dir);
1723        let path = dir.join("outcome_profiles.json");
1724
1725        let mut tracker = OutcomeTracker::new();
1726        // Fresh tracker is clean → save_if_dirty is a no-op.
1727        assert!(!tracker.is_dirty());
1728        assert!(!tracker.save_if_dirty(&path).unwrap());
1729        assert!(!path.exists());
1730
1731        // A recorded outcome dirties it.
1732        let trace = tracker.record_start("m1", InferenceTask::Generate, "router");
1733        tracker.record_complete(&trace, 100, 10, 20);
1734        assert!(tracker.is_dirty());
1735
1736        // save_if_dirty writes once and clears the flag.
1737        assert!(tracker.save_if_dirty(&path).unwrap());
1738        assert!(path.exists());
1739        assert!(!tracker.is_dirty());
1740
1741        // Second call with no new changes does not rewrite.
1742        assert!(!tracker.save_if_dirty(&path).unwrap());
1743
1744        // Loading profiles must NOT mark the tracker dirty.
1745        let mut fresh = OutcomeTracker::new();
1746        fresh.load_from_file(&path).unwrap();
1747        assert!(!fresh.is_dirty());
1748
1749        // But a genuine import (benchmark priors / CLI) MUST dirty, so the
1750        // imported profiles actually persist on the next flush.
1751        fresh.import_profiles(vec![ModelProfile::new("seeded".into())]);
1752        assert!(fresh.is_dirty());
1753
1754        let _ = std::fs::remove_dir_all(&dir);
1755    }
1756
1757    #[test]
1758    fn ledger_captures_resolved_outcomes() {
1759        let dir = std::env::temp_dir().join("car-outcome-ledger-test");
1760        let _ = std::fs::remove_dir_all(&dir);
1761        let path = dir.join("outcome_ledger.jsonl");
1762
1763        let mut tracker = OutcomeTracker::new();
1764
1765        // A success with a quality signal → one resolved receipt.
1766        let t1 = tracker.record_start("good-model", InferenceTask::Generate, "router:test");
1767        tracker.record_complete(&t1, 1200, 50, 100);
1768        tracker.record_inferred_outcome(&t1, InferredOutcome::Accepted { confidence: 0.9 });
1769
1770        // A failure → one resolved receipt with the error.
1771        let t2 = tracker.record_start("bad-model", InferenceTask::Code, "router:test");
1772        tracker.record_failure(&t2, "boom: 500");
1773
1774        let drained = tracker.drain_ledger();
1775        assert_eq!(drained.len(), 2);
1776        assert!(tracker.drain_ledger().is_empty(), "drain clears the buffer");
1777
1778        append_ledger_entries(&path, &drained).unwrap();
1779        let read = read_ledger(&path, 0);
1780        assert_eq!(read.len(), 2);
1781
1782        let good = read.iter().find(|e| e.model_id == "good-model").unwrap();
1783        assert_eq!(good.success, Some(true));
1784        assert!(good.quality.is_some());
1785        assert_eq!(good.routing_reason, "router:test");
1786        assert_eq!(good.latency_ms, 1200);
1787
1788        let bad = read.iter().find(|e| e.model_id == "bad-model").unwrap();
1789        assert_eq!(bad.success, Some(false));
1790        assert_eq!(bad.error.as_deref(), Some("boom: 500"));
1791
1792        // Read back limited to most recent 1.
1793        assert_eq!(read_ledger(&path, 1).len(), 1);
1794
1795        let _ = std::fs::remove_dir_all(&dir);
1796    }
1797
1798    #[test]
1799    fn ledger_redacts_long_errors_and_prunes() {
1800        let dir = std::env::temp_dir().join("car-outcome-privacy-test");
1801        let _ = std::fs::remove_dir_all(&dir);
1802        let path = dir.join("outcome_ledger.jsonl");
1803
1804        // Long error is truncated on capture.
1805        let mut tracker = OutcomeTracker::new();
1806        let t = tracker.record_start("m", InferenceTask::Generate, "r");
1807        let huge = "x".repeat(5000);
1808        tracker.record_failure(&t, &huge);
1809        let drained = tracker.drain_ledger();
1810        let err = drained[0].error.as_ref().unwrap();
1811        assert!(
1812            err.chars().count() <= MAX_LEDGER_ERROR_CHARS + 1,
1813            "error truncated"
1814        );
1815
1816        // Pruning keeps only the most recent N.
1817        let entries: Vec<OutcomeLedgerEntry> = (0..10)
1818            .map(|i| OutcomeLedgerEntry {
1819                trace_id: format!("t{i}"),
1820                model_id: "m".into(),
1821                task: InferenceTask::Generate,
1822                routing_reason: "r".into(),
1823                latency_ms: 1,
1824                input_tokens: 1,
1825                output_tokens: 1,
1826                cache_read_input_tokens: 0,
1827                cache_creation_input_tokens: 0,
1828                success: Some(true),
1829                quality: Some(1.0),
1830                error: None,
1831                project_id: None,
1832                intent: None,
1833                timestamp: i,
1834            })
1835            .collect();
1836        append_ledger_entries(&path, &entries).unwrap();
1837        prune_ledger(&path, 3).unwrap();
1838        let kept = read_ledger(&path, 0);
1839        assert_eq!(kept.len(), 3);
1840        assert_eq!(kept[0].trace_id, "t7"); // most recent 3: t7,t8,t9
1841        assert_eq!(kept[2].trace_id, "t9");
1842
1843        let _ = std::fs::remove_dir_all(&dir);
1844    }
1845
1846    #[test]
1847    fn sweep_pending_credits_mechanical_success_or_inconclusive() {
1848        let mut tracker = OutcomeTracker::new();
1849
1850        // Completed with output, no signal -> mechanical success (#312).
1851        let t1 = tracker.record_start("m", InferenceTask::Generate, "r");
1852        tracker.record_complete(&t1, 500, 10, 20);
1853        // Completed but produced NO output -> stays Inconclusive.
1854        let t2 = tracker.record_start("m", InferenceTask::Generate, "r");
1855        tracker.record_complete(&t2, 300, 5, 0);
1856        // A never-completed call (in-flight / crashed) -> no receipt.
1857        let _t3 = tracker.record_start("m", InferenceTask::Generate, "r");
1858
1859        // Evaluate the sweep against a clock 10s in the future so the
1860        // same-second pending entries are unambiguously past the cutoff.
1861        let swept = tracker.sweep_pending_at(0, now_unix() + 10);
1862        assert_eq!(swept, 3, "all pending entries evicted");
1863
1864        // Only the two completed calls become receipts.
1865        let mut receipts = tracker.drain_ledger();
1866        receipts.sort_by_key(|r| r.latency_ms);
1867        assert_eq!(receipts.len(), 2);
1868        // 300ms, no output -> Inconclusive
1869        assert_eq!(receipts[0].latency_ms, 300);
1870        assert_eq!(receipts[0].success, None);
1871        // 500ms, produced output -> mechanical success, quality untouched
1872        assert_eq!(receipts[1].latency_ms, 500);
1873        assert_eq!(receipts[1].success, Some(true));
1874        assert_eq!(receipts[1].quality, None);
1875
1876        // success_count is credited; ema_quality stays the neutral prior
1877        // (no real quality signal ever arrived).
1878        let p = tracker.profile("m").expect("profile exists");
1879        assert_eq!(p.success_count, 1);
1880        assert_eq!(p.ema_quality, 0.5);
1881    }
1882
1883    #[test]
1884    fn record_complete_credits_success_immediately() {
1885        // The headline fix: a call that completes with output is credited a
1886        // success at completion, not deferred to the 300s sweep — so a
1887        // short-lived process (CLI) that exits before any sweep still records
1888        // the success. Previously success_count stayed 0 and health read 0.5.
1889        let mut tracker = OutcomeTracker::new();
1890        let t = tracker.record_start("m", InferenceTask::Generate, "r");
1891        tracker.record_complete(&t, 500, 12, 20);
1892
1893        let p = tracker.profile("m").expect("profile exists");
1894        assert_eq!(p.success_count, 1, "success credited at completion");
1895        assert_eq!(p.total_calls, 1);
1896        assert_eq!(p.total_input_tokens, 12, "input tokens recorded, not 0");
1897        assert_eq!(p.fail_count, 0);
1898
1899        // A later sweep of the same (still-pending) call must NOT double-count.
1900        tracker.sweep_pending_at(0, now_unix() + 10);
1901        let p = tracker.profile("m").unwrap();
1902        assert_eq!(p.success_count, 1, "sweep does not re-credit");
1903    }
1904
1905    #[test]
1906    fn success_rate_resolved_distinguishes_no_signal_from_measured() {
1907        // Display surfaces must not show a never-measured model as a confident
1908        // "50%": `success_rate_resolved()` returns None until something
1909        // resolves, then the real ratio. (The router never derives a rate
1910        // here — it reads raw success/fail counts directly.)
1911        let mut p = ModelProfile::new("m".to_string());
1912        assert_eq!(
1913            p.success_rate_resolved(),
1914            None,
1915            "no resolved signal, not a fabricated 0.5"
1916        );
1917
1918        p.success_count = 3;
1919        p.fail_count = 1;
1920        assert_eq!(
1921            p.success_rate_resolved(),
1922            Some(0.75),
1923            "real rate once resolved"
1924        );
1925    }
1926
1927    #[test]
1928    fn quality_observations_count_only_graded_signals() {
1929        // #3: a mechanical success must NOT count as a graded quality
1930        // observation (it leaves answer quality unknown), but an accept/reject
1931        // signal and a failure must. The router keys reliability on this count.
1932        let mut tracker = OutcomeTracker::new();
1933
1934        // Mechanical success — credited, but quality stays unknown.
1935        let t = tracker.record_start("m", InferenceTask::Generate, "r");
1936        tracker.record_complete(&t, 500, 12, 20);
1937        let p = tracker.profile("m").unwrap();
1938        assert_eq!(p.success_count, 1);
1939        assert_eq!(
1940            p.quality_observations, 0,
1941            "mechanical success is not a graded quality observation"
1942        );
1943        assert!(
1944            (p.ema_quality - 0.5).abs() < 1e-9,
1945            "EMA untouched by mechanical success"
1946        );
1947
1948        // A real accept signal — graded.
1949        tracker.record_inferred_outcome(&t, InferredOutcome::Accepted { confidence: 0.9 });
1950        let p = tracker.profile("m").unwrap();
1951        assert_eq!(p.quality_observations, 1, "graded accept signal counts");
1952        assert!(p.ema_quality > 0.5, "graded accept moved the EMA up");
1953        assert_eq!(
1954            p.task_stats(InferenceTask::Generate)
1955                .unwrap()
1956                .quality_observations,
1957            1,
1958            "per-task graded count tracked too"
1959        );
1960
1961        // A failure — also graded (moves EMA toward 0).
1962        let t2 = tracker.record_start("m", InferenceTask::Generate, "r");
1963        tracker.record_failure(&t2, "boom");
1964        let p = tracker.profile("m").unwrap();
1965        assert_eq!(p.quality_observations, 2, "failure is a graded observation");
1966    }
1967
1968    #[test]
1969    fn record_complete_no_output_is_not_a_success() {
1970        let mut tracker = OutcomeTracker::new();
1971        let t = tracker.record_start("m", InferenceTask::Generate, "r");
1972        tracker.record_complete(&t, 300, 5, 0); // no output
1973        let p = tracker.profile("m").unwrap();
1974        assert_eq!(p.success_count, 0, "no output -> no mechanical success");
1975        assert_eq!(p.total_calls, 1);
1976    }
1977
1978    #[test]
1979    fn record_failure_counts_total_calls() {
1980        // A failed call is still a call. total_calls must include it so it can
1981        // never read smaller than fail_count (the parslee/* 6-calls/14-fails
1982        // symptom).
1983        let mut tracker = OutcomeTracker::new();
1984        for _ in 0..3 {
1985            let t = tracker.record_start("m", InferenceTask::Generate, "r");
1986            tracker.record_failure(&t, "boom 500");
1987        }
1988        let p = tracker.profile("m").unwrap();
1989        assert_eq!(p.fail_count, 3);
1990        assert_eq!(p.total_calls, 3, "failures counted in total_calls");
1991        assert!(p.fail_count <= p.total_calls);
1992    }
1993
1994    #[test]
1995    fn capability_rejection_does_not_change_model_profile() {
1996        let mut tracker = OutcomeTracker::new();
1997        let trace = tracker.record_start("m", InferenceTask::Generate, "r");
1998
1999        tracker.record_capability_rejection(&trace, "unsupported mode: json schema");
2000
2001        assert!(tracker.profile("m").is_none());
2002        assert!(!tracker.has_pending(&trace));
2003        assert_eq!(tracker.ledger_buffer.len(), 1);
2004    }
2005
2006    #[test]
2007    fn transport_failures_are_quality_neutral() {
2008        // A read-timeout / connection error means the model never produced an
2009        // answer — its answer quality is UNKNOWN, so transport failures must not
2010        // move the quality EMA or count as graded quality evidence. (Observed
2011        // live: parslee/* collapsed to ema_quality ≈ 0.003 after ~23 daemon
2012        // read-timeouts, which buried it under every workload.) They DO still
2013        // count for availability (total_calls / fail_count / circuit breaker).
2014        let mut tracker = OutcomeTracker::new();
2015        // One genuine generation failure establishes the baseline EMA + a
2016        // graded quality observation.
2017        let t = tracker.record_start("m", InferenceTask::Code, "r");
2018        tracker.record_failure(&t, "model produced malformed output");
2019        let baseline_ema = tracker.profile("m").unwrap().ema_quality;
2020        let baseline_qobs = tracker.profile("m").unwrap().quality_observations;
2021
2022        // Hammer with transport timeouts AND auth/client rejections — neither
2023        // produced an answer, so quality must not budge. The 401 case is the
2024        // live parslee/* symptom: ~47 "HTTP 401 Unauthorized" from an undeployed
2025        // gateway must not bury the model on *answer* quality.
2026        for _ in 0..5 {
2027            let t = tracker.record_start("m", InferenceTask::Code, "r");
2028            tracker.record_failure(&t, "daemon read timeout on infer after 30s");
2029        }
2030        for _ in 0..5 {
2031            let t = tracker.record_start("m", InferenceTask::Code, "r");
2032            tracker.record_failure(
2033                &t,
2034                "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: Authentication",
2035            );
2036        }
2037
2038        let p = tracker.profile("m").unwrap();
2039        assert_eq!(
2040            p.fail_count, 11,
2041            "no-answer failures still counted for availability"
2042        );
2043        assert_eq!(p.total_calls, 11, "every call counted (failures included)");
2044        assert!(
2045            (p.ema_quality - baseline_ema).abs() < 1e-9,
2046            "timeouts AND auth rejections must not move the quality EMA ({baseline_ema} -> {})",
2047            p.ema_quality
2048        );
2049        assert_eq!(
2050            p.quality_observations, baseline_qobs,
2051            "no-answer failures (transport + auth) are not graded quality evidence"
2052        );
2053        // Contrast: a real generation failure still penalizes quality.
2054        let t = tracker.record_start("m", InferenceTask::Code, "r");
2055        tracker.record_failure(&t, "model produced malformed output");
2056        assert!(
2057            tracker.profile("m").unwrap().ema_quality < baseline_ema,
2058            "a non-transport failure must still move the quality EMA down"
2059        );
2060    }
2061
2062    #[test]
2063    fn is_no_answer_failure_precision_boundary() {
2064        // The classifier gates whether a failure counts as answer-quality, so
2065        // pin both sides of the precision boundary it promises. Matches are on
2066        // the FAILURE error string only (never model output).
2067        // No-answer: transport/infra + auth/client rejection → quality-neutral.
2068        for s in [
2069            "daemon read timeout on infer after 30s",
2070            "connection reset by peer",
2071            "upstream returned 503 service unavailable",
2072            "HTTP 500 Internal Server Error",
2073            "stream closed unexpectedly (eof)",
2074            "HTTP 401 Unauthorized",
2075            "unauthenticated request",
2076            "HTTP 403 Forbidden",
2077            "permission denied by gateway",
2078            "Parslee org lookup failed: Authentication required",
2079            "oauth: invalid_client",
2080            "invalid api key",
2081            "HTTP 400 Bad Request: max context length exceeded",
2082        ] {
2083            assert!(
2084                is_no_answer_failure(s),
2085                "expected no-answer (quality-neutral): {s:?}"
2086            );
2087        }
2088        // Generation failures: the model produced output that was bad/unparseable.
2089        // These MUST stay generation failures (graded), NOT be swallowed as
2090        // no-answer — including the deliberately-excluded ambiguous phrases.
2091        for s in [
2092            "model produced malformed output",
2093            "field 'user' not found in model response", // not "not found" → no-answer
2094            "tokenizer: invalid token id 99999",        // not "invalid token" → no-answer
2095            "response failed JSON schema validation",
2096            "tool call arguments did not validate",
2097        ] {
2098            assert!(
2099                !is_no_answer_failure(s),
2100                "expected generation failure (graded): {s:?}"
2101            );
2102        }
2103    }
2104
2105    #[test]
2106    fn ledger_quality_distinguishes_generation_from_transport_failure() {
2107        // The durable ledger's `quality` is ANSWER quality, mirroring the
2108        // in-memory EMA (#374 follow-up). A generation failure produced a bad
2109        // answer → graded `Some(0.0)`; a transport failure or a 429 produced no
2110        // answer → `None`. This keeps the scoreboard's avg_quality and the
2111        // shadow-calibration band (#369) — both of which fold this ledger — free
2112        // of availability noise while still seeing the genuine bad-answer signal.
2113        let mut tracker = OutcomeTracker::new();
2114
2115        let t = tracker.record_start("m", InferenceTask::Code, "r");
2116        tracker.record_failure(&t, "model produced malformed output"); // generation
2117        let t = tracker.record_start("m", InferenceTask::Code, "r");
2118        tracker.record_failure(&t, "daemon read timeout on infer after 30s"); // transport
2119        let t = tracker.record_start("m", InferenceTask::Code, "r");
2120        tracker.record_failure(&t, "429 RESOURCE_EXHAUSTED"); // rate-limit
2121        let t = tracker.record_start("m", InferenceTask::Code, "r");
2122        tracker.record_failure(&t, "upstream: 503 service unavailable"); // 5xx infra
2123        let t = tracker.record_start("m", InferenceTask::Code, "r");
2124        tracker.record_failure(&t, "HTTP 500 Internal Server Error"); // server fault
2125        let t = tracker.record_start("m", InferenceTask::Code, "r");
2126        // The live parslee/* failure: auth rejection, no answer produced.
2127        tracker.record_failure(
2128            &t,
2129            "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: Authentication",
2130        );
2131
2132        let entries = tracker.drain_ledger();
2133        assert_eq!(entries.len(), 6);
2134        // All are failures for availability accounting.
2135        assert!(entries.iter().all(|e| e.success == Some(false)));
2136        // Only the generation failure carries a graded answer-quality of 0.0.
2137        assert_eq!(
2138            entries[0].quality,
2139            Some(0.0),
2140            "generation failure -> graded 0.0 answer quality"
2141        );
2142        assert_eq!(
2143            entries[1].quality, None,
2144            "transport failure produced no answer -> quality unknown (None)"
2145        );
2146        assert_eq!(
2147            entries[2].quality, None,
2148            "429 produced no answer -> quality unknown (None), an availability event"
2149        );
2150        assert_eq!(
2151            entries[3].quality, None,
2152            "5xx infra failure produced no answer -> None"
2153        );
2154        assert_eq!(
2155            entries[4].quality, None,
2156            "an 'internal server error' (500) is a server fault, not a bad answer -> None"
2157        );
2158        assert_eq!(
2159            entries[5].quality, None,
2160            "a 401 auth rejection produced no answer -> None (not a bad-answer 0.0)"
2161        );
2162    }
2163
2164    #[test]
2165    fn real_failure_signal_reclassifies_mechanical_success() {
2166        // Completed with output -> mechanical success. A later Rejected signal
2167        // overrides it: undo the success, book a failure. No phantom success.
2168        let mut tracker = OutcomeTracker::new();
2169        let t = tracker.record_start("m", InferenceTask::Generate, "r");
2170        tracker.record_complete(&t, 100, 8, 15);
2171        assert_eq!(tracker.profile("m").unwrap().success_count, 1);
2172
2173        tracker.record_inferred_outcome(&t, InferredOutcome::Rejected { confidence: 0.9 });
2174        let p = tracker.profile("m").unwrap();
2175        assert_eq!(p.success_count, 0, "mechanical success undone");
2176        assert_eq!(p.fail_count, 1, "failure booked");
2177    }
2178
2179    #[test]
2180    fn real_success_signal_does_not_double_count() {
2181        let mut tracker = OutcomeTracker::new();
2182        let t = tracker.record_start("m", InferenceTask::Generate, "r");
2183        tracker.record_complete(&t, 100, 8, 15);
2184        tracker.record_inferred_outcome(&t, InferredOutcome::Accepted { confidence: 0.9 });
2185        let p = tracker.profile("m").unwrap();
2186        assert_eq!(
2187            p.success_count, 1,
2188            "Accepted on an already-credited call is not +2"
2189        );
2190    }
2191
2192    #[test]
2193    fn export_import() {
2194        let mut tracker = OutcomeTracker::new();
2195        let trace = tracker.record_start("m1", InferenceTask::Generate, "test");
2196        tracker.record_complete(&trace, 100, 10, 5);
2197        tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 0.9 });
2198
2199        let exported = tracker.export_profiles();
2200        assert_eq!(exported.len(), 1);
2201
2202        let mut tracker2 = OutcomeTracker::new();
2203        tracker2.import_profiles(exported);
2204        assert!(tracker2.profile("m1").is_some());
2205    }
2206}