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    /// Unix time the call ended (completed, or ran out without a completion —
84    /// see [`OutcomeTracker::note_elapsed`]). `None` while it is in flight.
85    /// The pending sweep counts its grace period from here, not from
86    /// `timestamp` (the start), so a call that itself runs longer than the
87    /// sweep TTL can still be graded after it finishes. In-memory only.
88    #[serde(skip)]
89    pub completed_at: Option<u64>,
90}
91
92/// Outcome inferred from conversation flow (implicit feedback).
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(tag = "type", rename_all = "snake_case")]
95pub enum InferredOutcome {
96    /// User moved on, built on the response.
97    Accepted { confidence: f64 },
98    /// User used the result but modified it.
99    AcceptedWithEdits { confidence: f64 },
100    /// User corrected, re-asked, or explicitly rejected.
101    Rejected { confidence: f64 },
102    /// No follow-up signal (session ended, inconclusive).
103    Inconclusive,
104}
105
106impl InferredOutcome {
107    /// Convert to a quality score (0.0 = bad, 1.0 = good).
108    pub fn quality_score(&self) -> Option<f64> {
109        match self {
110            InferredOutcome::Accepted { confidence } => Some(*confidence),
111            InferredOutcome::AcceptedWithEdits { confidence } => Some(confidence * 0.7),
112            InferredOutcome::Rejected { confidence } => Some((1.0 - confidence) * 0.3),
113            InferredOutcome::Inconclusive => None,
114        }
115    }
116
117    pub fn is_success(&self) -> Option<bool> {
118        match self {
119            InferredOutcome::Accepted { .. } => Some(true),
120            InferredOutcome::AcceptedWithEdits { .. } => Some(true),
121            InferredOutcome::Rejected { .. } => Some(false),
122            InferredOutcome::Inconclusive => None,
123        }
124    }
125}
126
127/// Outcome from git-diff comparison (code generation ground truth).
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(tag = "type", rename_all = "snake_case")]
130pub enum CodeOutcome {
131    /// Suggestion was applied as-is (exact or near-exact match in diff).
132    Applied,
133    /// User changed the same file but differently (partial adoption).
134    Modified,
135    /// File unchanged despite suggestion (rejected / not used).
136    Ignored,
137    /// AST structural diff: signature was changed (breaking change).
138    SignatureChanged,
139    /// AST structural diff: body was modified but signature preserved (non-breaking).
140    BodyModified,
141    /// AST structural diff: new symbol was added.
142    SymbolAdded,
143}
144
145impl CodeOutcome {
146    pub fn quality_score(&self) -> f64 {
147        match self {
148            CodeOutcome::Applied => 1.0,
149            CodeOutcome::SignatureChanged => 0.8,
150            CodeOutcome::BodyModified => 0.7,
151            CodeOutcome::SymbolAdded => 0.7,
152            CodeOutcome::Modified => 0.6,
153            CodeOutcome::Ignored => 0.1,
154        }
155    }
156
157    pub fn is_success(&self) -> bool {
158        !matches!(self, CodeOutcome::Ignored)
159    }
160}
161
162/// What `ema_quality` and `quality_observations` mean in a saved profile.
163///
164/// - 0: a generation failure was graded 0.0 and counted as quality evidence,
165///   and a success was never graded, so the evidence could only ever say
166///   "worse". One stray failure took a third of a model's catalog quality
167///   away for good (`reliability`'s `e/(e+K)` with W=2, K=4).
168/// - 1: only real grades (accept/edit/reject, external grades) move quality.
169///   A failure is availability evidence: it feeds `fail_count`, the circuit
170///   breaker and the router's completion rate, not answer quality.
171pub const QUALITY_EVIDENCE_VERSION: u32 = 1;
172
173/// Bring a profile saved under an older [`QUALITY_EVIDENCE_VERSION`] to the
174/// current one. Version 0's quality evidence cannot be separated into real
175/// grades and failures, and failures dominate it, so it is dropped: quality
176/// returns to neutral with no evidence and the router sits at the catalog
177/// estimate until real grades arrive. Availability counters and external
178/// grades are kept.
179fn migrate_quality_evidence(p: &mut ModelProfile) {
180    if p.quality_evidence_version >= QUALITY_EVIDENCE_VERSION {
181        return;
182    }
183    p.ema_quality = NEUTRAL_QUALITY;
184    p.quality_observations = 0;
185    p.prior_sample_size = 0;
186    for ts in p.task_stats.values_mut() {
187        ts.ema_quality = NEUTRAL_QUALITY;
188        ts.quality_observations = 0;
189        ts.prior_sample_size = 0;
190    }
191    p.quality_evidence_version = QUALITY_EVIDENCE_VERSION;
192}
193
194/// The neutral quality prior a fresh profile starts at.
195const NEUTRAL_QUALITY: f64 = 0.5;
196
197/// Per-task statistics within a model profile.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[serde(default)]
200pub struct TaskStats {
201    pub calls: u64,
202    pub successes: u64,
203    pub failures: u64,
204    /// Running average latency in ms.
205    pub avg_latency_ms: f64,
206    /// Exponential moving average of quality score.
207    pub ema_quality: f64,
208    /// Number of benchmark cases behind `ema_quality` when it is a *seeded
209    /// cold-start prior* (i.e. `calls == 0`). Lets the router shrink the
210    /// seeded score toward its schema estimate by sample size. `0` means no
211    /// benchmark evidence — the router then sits at the schema estimate.
212    #[serde(default)]
213    pub prior_sample_size: usize,
214    /// Number of *graded* quality observations that have moved `ema_quality`
215    /// for this task — accept/edit/reject signals, NOT mechanical successes or
216    /// failures (which say whether a call ran, not how good its answer was;
217    /// see [`QUALITY_EVIDENCE_VERSION`]). The router trusts
218    /// `ema_quality` in proportion to this count, so a task with many ungraded
219    /// successes stays anchored to its schema estimate instead of a hollow
220    /// neutral EMA.
221    #[serde(default)]
222    pub quality_observations: u64,
223    /// Graded results reported from outside this daemon
224    /// (`outcomes.record_external_grade`), one entry per source key (see
225    /// [`external_grade_key`]). Kept apart from `ema_quality` on purpose:
226    /// these grades are not tied to a call this daemon served, so they never
227    /// touch the served counters, the ledger replay in [`plan_reconcile`], or
228    /// the bootstrap floor. The router pools them into `reliability` through
229    /// [`TaskStats::external_evidence`], whose caps bound what one source, one
230    /// reporter, and all of them together can weigh.
231    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
232    pub external_grades: std::collections::BTreeMap<String, ExternalGradeStats>,
233}
234
235impl Default for TaskStats {
236    /// A task's first grade moves quality from neutral, as the overall
237    /// profile's does. Starting at 0.0 made a perfect first grade read as a
238    /// poor one.
239    fn default() -> Self {
240        Self {
241            calls: 0,
242            successes: 0,
243            failures: 0,
244            avg_latency_ms: 0.0,
245            ema_quality: NEUTRAL_QUALITY,
246            prior_sample_size: 0,
247            quality_observations: 0,
248            external_grades: Default::default(),
249        }
250    }
251}
252
253impl TaskStats {
254    pub fn success_rate(&self) -> f64 {
255        let total = self.successes + self.failures;
256        if total == 0 {
257            return 0.5;
258        } // prior: assume neutral
259        self.successes as f64 / total as f64
260    }
261
262    /// Whether this task has any served or seeded evidence of its own, as
263    /// opposed to holding only external grades. An entry that external grades
264    /// created must not shadow the overall profile's served evidence in the
265    /// router, and its `ema_quality` (the `Default` 0.0) is not a measurement.
266    /// An entry with no external grades always counts as served, so the
267    /// router's choice between task and overall stats is unchanged for it.
268    pub fn has_served_evidence(&self) -> bool {
269        self.calls > 0
270            || self.successes > 0
271            || self.failures > 0
272            || self.prior_sample_size > 0
273            || self.quality_observations > 0
274            || self.external_grades.is_empty()
275    }
276
277    /// The pooled external evidence for this task now: `(evidence, mean)`.
278    /// See [`Self::external_evidence_at`].
279    pub fn external_evidence(&self) -> (f64, f64) {
280        self.external_evidence_at(now_unix())
281    }
282
283    /// The pooled external evidence for this task at `now`: `(evidence,
284    /// mean)`, `(0.0, 0.0)` when nothing counts. Three caps apply in turn:
285    ///
286    /// 1. each source counts its observations up to
287    ///    [`EXTERNAL_GRADE_EVIDENCE_CAP`];
288    /// 2. all of one reporter's sources together count at most
289    ///    [`EXTERNAL_GRADE_REPORTER_EVIDENCE_CAP`], so a reporter cannot add
290    ///    weight by rotating source names;
291    /// 3. every reporter together counts at most
292    ///    [`EXTERNAL_GRADE_TOTAL_EVIDENCE_CAP`].
293    ///
294    /// A cap scales a group's weight down without changing its mean. A source
295    /// not updated for [`EXTERNAL_GRADE_MAX_AGE_SECS`] counts for nothing, so
296    /// a stale grade stops steering routing on its own.
297    pub fn external_evidence_at(&self, now: u64) -> (f64, f64) {
298        // reporter -> (evidence, evidence-weighted quality sum)
299        let mut by_reporter: std::collections::BTreeMap<Option<&str>, (f64, f64)> =
300            std::collections::BTreeMap::new();
301        for (key, grade) in &self.external_grades {
302            if grade.is_stale_at(now) {
303                continue;
304            }
305            let e = grade.evidence() as f64;
306            let entry = by_reporter
307                .entry(external_grade_reporter(key))
308                .or_insert((0.0, 0.0));
309            entry.0 += e;
310            entry.1 += e * grade.quality;
311        }
312        let mut evidence = 0.0;
313        let mut weighted = 0.0;
314        for (e, w) in by_reporter.into_values() {
315            if e <= 0.0 {
316                continue;
317            }
318            let counted = e.min(EXTERNAL_GRADE_REPORTER_EVIDENCE_CAP as f64);
319            evidence += counted;
320            weighted += counted * (w / e);
321        }
322        if evidence <= 0.0 {
323            return (0.0, 0.0);
324        }
325        let mean = weighted / evidence;
326        (evidence.min(EXTERNAL_GRADE_TOTAL_EVIDENCE_CAP as f64), mean)
327    }
328}
329
330/// Most evidence one external source contributes to a model's task, in
331/// benchmark-case units. Reports beyond the cap keep updating that source's
332/// quality but add no further weight.
333pub const EXTERNAL_GRADE_EVIDENCE_CAP: u64 = 8;
334/// Most evidence all of one reporter's sources together contribute to a
335/// model's task. With the router's prior pseudocount of 4, one reporter moves
336/// `reliability` at most two thirds of the way from the catalog prior to the
337/// quality it reports, however many source names it uses.
338pub const EXTERNAL_GRADE_REPORTER_EVIDENCE_CAP: u64 = 8;
339/// Most evidence every external grade together contributes to a model's task:
340/// with the prior pseudocount of 4, at most four fifths of the way from the
341/// catalog prior to the pooled external quality. Served, graded calls are not
342/// capped, so a model CAR serves is steered by its own record over time.
343pub const EXTERNAL_GRADE_TOTAL_EVIDENCE_CAP: u64 = 16;
344/// Most distinct sources one reporter may keep on one model's task. A source
345/// that has gone stale (see [`EXTERNAL_GRADE_MAX_AGE_SECS`]) is evicted to
346/// make room instead of refusing the new one.
347pub const MAX_EXTERNAL_GRADE_SOURCES: usize = 8;
348/// A source not updated for this long (90 days) no longer counts toward
349/// routing, and its slot can be taken by a new source. Reporting under it
350/// again starts it afresh rather than averaging with the stale quality.
351pub const EXTERNAL_GRADE_MAX_AGE_SECS: u64 = 90 * 24 * 60 * 60;
352/// Longest accepted reporter identity, in bytes.
353pub const MAX_EXTERNAL_GRADE_REPORTER_LEN: usize = 160;
354/// Largest `n` (graded cases) one report may carry.
355pub const MAX_EXTERNAL_GRADE_CASES: u32 = 100;
356/// Longest accepted source name, in bytes.
357pub const MAX_EXTERNAL_GRADE_SOURCE_LEN: usize = 64;
358
359/// One external source's graded evidence for a model's task.
360#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
361pub struct ExternalGradeStats {
362    /// The source's current quality for this model and task, in `[0, 1]`: a
363    /// running mean over its reports whose window is capped at
364    /// [`EXTERNAL_GRADE_EVIDENCE_CAP`] cases, so a newer verdict keeps moving
365    /// it after the cap is reached.
366    pub quality: f64,
367    /// Graded cases this source has reported, uncapped.
368    pub observations: u64,
369    /// Unix time of the source's latest report.
370    pub updated_at: u64,
371}
372
373impl ExternalGradeStats {
374    /// The evidence this source counts for on its own: its observations,
375    /// capped. The reporter and total caps in
376    /// [`TaskStats::external_evidence_at`] may count it for less.
377    pub fn evidence(&self) -> u64 {
378        self.observations.min(EXTERNAL_GRADE_EVIDENCE_CAP)
379    }
380
381    /// Whether this source was last updated more than
382    /// [`EXTERNAL_GRADE_MAX_AGE_SECS`] before `now`.
383    pub fn is_stale_at(&self, now: u64) -> bool {
384        now.saturating_sub(self.updated_at) > EXTERNAL_GRADE_MAX_AGE_SECS
385    }
386}
387
388/// The key an external grade is stored under in
389/// [`TaskStats::external_grades`]: the source name for a report from an
390/// operator (`reporter` is `None`), `<reporter>/<source>` otherwise. A source
391/// name cannot contain `/`, so one reporter can neither write under another's
392/// key nor under an operator's.
393pub fn external_grade_key(reporter: Option<&str>, source: &str) -> String {
394    match reporter {
395        Some(reporter) => format!("{reporter}/{source}"),
396        None => source.to_string(),
397    }
398}
399
400/// The reporter part of an external grade key: `None` for an operator's.
401pub fn external_grade_reporter(key: &str) -> Option<&str> {
402    key.split_once('/').map(|(reporter, _)| reporter)
403}
404
405/// Why an external grade was not recorded.
406#[derive(Debug, Clone, PartialEq)]
407pub enum ExternalGradeRefusal {
408    /// `quality` was not a finite number in `[0, 1]`.
409    InvalidQuality,
410    /// `n` was zero or above [`MAX_EXTERNAL_GRADE_CASES`].
411    InvalidCases,
412    /// The source name was empty, too long, or used characters outside
413    /// `[A-Za-z0-9._:-]`.
414    InvalidSource,
415    /// The model id was empty or longer than 256 bytes.
416    InvalidModel,
417    /// The reporter identity was empty, too long, or contained `/` or a
418    /// character outside printable ASCII. Set by the daemon, never the caller.
419    InvalidReporter,
420    /// The reporter already keeps [`MAX_EXTERNAL_GRADE_SOURCES`] other current
421    /// sources on this task.
422    SourceLimitReached,
423}
424
425impl ExternalGradeRefusal {
426    /// Stable machine-readable reason, carried on the wire as `data.reason`.
427    pub fn reason(&self) -> &'static str {
428        match self {
429            Self::InvalidQuality => "invalid_quality",
430            Self::InvalidCases => "invalid_n",
431            Self::InvalidSource => "invalid_source",
432            Self::InvalidModel => "invalid_model",
433            Self::InvalidReporter => "invalid_reporter",
434            Self::SourceLimitReached => "source_limit_reached",
435        }
436    }
437}
438
439impl std::fmt::Display for ExternalGradeRefusal {
440    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441        match self {
442            Self::InvalidQuality => write!(f, "quality must be a number in [0, 1]"),
443            Self::InvalidCases => {
444                write!(f, "n must be between 1 and {MAX_EXTERNAL_GRADE_CASES}")
445            }
446            Self::InvalidSource => write!(
447                f,
448                "source must be 1-{MAX_EXTERNAL_GRADE_SOURCE_LEN} characters of [A-Za-z0-9._:-]"
449            ),
450            Self::InvalidModel => write!(f, "model_id must be 1-256 bytes"),
451            Self::InvalidReporter => write!(
452                f,
453                "reporter must be 1-{MAX_EXTERNAL_GRADE_REPORTER_LEN} printable ASCII characters \
454                 without `/`"
455            ),
456            Self::SourceLimitReached => write!(
457                f,
458                "this caller already keeps {MAX_EXTERNAL_GRADE_SOURCES} current external grade \
459                 sources on this model's task; report under one of them, or wait for one to go \
460                 stale after {} days",
461                EXTERNAL_GRADE_MAX_AGE_SECS / 86_400
462            ),
463        }
464    }
465}
466
467/// What resolving a batch of outcome signals did.
468#[derive(Debug, Clone, Default, PartialEq)]
469pub struct ResolveReport {
470    /// Signals that matched a pending trace and were recorded.
471    pub resolved: usize,
472    /// Trace ids that matched no pending trace — already resolved, never
473    /// started by this tracker, or swept — so the signal was not recorded.
474    pub dropped: Vec<String>,
475}
476
477/// Per-model performance profile, built from observed outcomes.
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct ModelProfile {
480    pub model_id: String,
481    pub total_calls: u64,
482    pub success_count: u64,
483    pub fail_count: u64,
484    pub total_latency_ms: u64,
485    /// Total estimated input tokens across all calls. For prompt-cached
486    /// providers this is the *uncached* prefix only; cached tokens are in
487    /// the two fields below so each bucket can be priced at its own rate.
488    #[serde(default)]
489    pub total_input_tokens: u64,
490    /// Total estimated output tokens across all calls.
491    #[serde(default)]
492    pub total_output_tokens: u64,
493    /// Total prompt-cache *read* (hit) input tokens — billed at ~0.1× input.
494    #[serde(default)]
495    pub total_cache_read_input_tokens: u64,
496    /// Total prompt-cache *write* input tokens — billed at ~1.25×/2× input.
497    #[serde(default)]
498    pub total_cache_creation_input_tokens: u64,
499    /// Per-task statistics.
500    pub task_stats: HashMap<String, TaskStats>,
501    /// Overall EMA quality score (0.0 - 1.0).
502    pub ema_quality: f64,
503    /// Number of benchmark cases behind `ema_quality` when it is a *seeded
504    /// cold-start prior* (`total_calls == 0`). See `TaskStats::prior_sample_size`.
505    #[serde(default)]
506    pub prior_sample_size: usize,
507    /// Graded quality observations that have moved `ema_quality` (overall).
508    /// See `TaskStats::quality_observations`.
509    #[serde(default)]
510    pub quality_observations: u64,
511    /// Derived metric: quality per 1K total tokens. Populated on export
512    /// (not on every update) so it always reflects the latest snapshot.
513    /// Inspired by Meta-Harness: context-token efficiency is a first-class
514    /// optimization target, so it needs to be visible in model_stats.
515    #[serde(default)]
516    pub quality_per_1k_tokens: f64,
517    /// The [`QUALITY_EVIDENCE_VERSION`] this profile's quality was recorded
518    /// under. Absent (0) on profiles saved before the version existed.
519    #[serde(default)]
520    pub quality_evidence_version: u32,
521    /// Last updated (unix timestamp).
522    pub updated_at: u64,
523}
524
525impl ModelProfile {
526    pub fn new(model_id: String) -> Self {
527        Self {
528            model_id,
529            total_calls: 0,
530            success_count: 0,
531            fail_count: 0,
532            total_latency_ms: 0,
533            total_input_tokens: 0,
534            total_output_tokens: 0,
535            total_cache_read_input_tokens: 0,
536            total_cache_creation_input_tokens: 0,
537            task_stats: HashMap::new(),
538            ema_quality: NEUTRAL_QUALITY,
539            prior_sample_size: 0,
540            quality_observations: 0,
541            quality_per_1k_tokens: 0.0,
542            quality_evidence_version: QUALITY_EVIDENCE_VERSION,
543            updated_at: now_unix(),
544        }
545    }
546
547    /// Success rate over *resolved* outcomes, or `None` when nothing has
548    /// resolved yet. Returns `None` rather than a fabricated 0.5 so callers
549    /// (all of which are display/reporting surfaces) can render "no resolved
550    /// signal" instead of a misleading confident "50%" for a never-measured
551    /// model. The router does NOT use this — its reliability estimate reads
552    /// the raw `success_count`/`fail_count` with its own add-one prior
553    /// (`adaptive_router`), so no 0.5 floor belongs here. Mirrors
554    /// `LaneUsage::success_rate`'s Option semantics.
555    pub fn success_rate_resolved(&self) -> Option<f64> {
556        let resolved = self.success_count + self.fail_count;
557        if resolved == 0 {
558            None
559        } else {
560            Some(self.success_count as f64 / resolved as f64)
561        }
562    }
563
564    pub fn avg_latency_ms(&self) -> f64 {
565        if self.total_calls == 0 {
566            return 0.0;
567        }
568        self.total_latency_ms as f64 / self.total_calls as f64
569    }
570
571    /// Same degradation pattern as SkillStats: fail_count > success_count + threshold.
572    pub fn should_degrade(&self, threshold: u64) -> bool {
573        self.fail_count > self.success_count + threshold
574    }
575
576    /// Get stats for a specific task type.
577    pub fn task_stats(&self, task: InferenceTask) -> Option<&TaskStats> {
578        self.task_stats.get(&task.to_string())
579    }
580
581    /// Total tokens observed across all calls (all input buckets + output).
582    /// Includes prompt-cache read and write tokens — they are real tokens the
583    /// provider processed, so price-free token efficiency must count them.
584    pub fn total_tokens(&self) -> u64 {
585        self.total_input_tokens
586            + self.total_cache_read_input_tokens
587            + self.total_cache_creation_input_tokens
588            + self.total_output_tokens
589    }
590
591    /// Quality per 1000 tokens: `ema_quality * 1000 / total_tokens`.
592    /// Returns 0.0 before any tokens have been observed.
593    pub fn compute_quality_per_1k_tokens(&self) -> f64 {
594        let total = self.total_tokens();
595        if total == 0 {
596            return 0.0;
597        }
598        self.ema_quality * 1000.0 / total as f64
599    }
600
601    /// Tokens spent per *successful* outcome: total tokens ÷ successes. `None`
602    /// before any success has resolved. This is PRICE-FREE token efficiency —
603    /// NOT dollar cost-per-outcome: a $0.10/Mtok and a $10/Mtok model with equal
604    /// token counts score identically here. For the dollar metric the
605    /// outcome-scoreboard vision wants, use [`Self::usd_per_success`] with the
606    /// model's catalog prices. Pairs with [`Self::success_rate_resolved`].
607    pub fn tokens_per_success(&self) -> Option<f64> {
608        if self.success_count == 0 {
609            None
610        } else {
611            Some(self.total_tokens() as f64 / self.success_count as f64)
612        }
613    }
614
615    /// Dollar cost per *successful* outcome — the honest "cost-per-outcome":
616    /// input+output token spend priced at the model's catalog rates, divided by
617    /// successes. `None` before any success. Prices are USD per 1M tokens
618    /// (from the catalog/registry `cost`), supplied by the caller so the tracker
619    /// stays catalog-free. This is the metric the scoreboard should lead with —
620    /// "cry once" is only legible in dollars-per-outcome, not tokens.
621    pub fn usd_per_success(
622        &self,
623        input_per_mtok: f64,
624        output_per_mtok: f64,
625        cache: CacheRates,
626    ) -> Option<f64> {
627        if self.success_count == 0 {
628            return None;
629        }
630        let usd = priced_input_usd(
631            self.total_input_tokens,
632            self.total_cache_read_input_tokens,
633            self.total_cache_creation_input_tokens,
634            input_per_mtok,
635            cache,
636        ) + self.total_output_tokens as f64 * output_per_mtok / 1_000_000.0;
637        Some(usd / self.success_count as f64)
638    }
639}
640
641/// Per-provider prompt-cache economics, expressed relative to the base input
642/// rate. The providers differ structurally: Anthropic uses explicit
643/// breakpoints with a deep read discount and a write premium; OpenAI caches
644/// automatically with a shallower read discount and no write charge. The
645/// durable ledger is catalog-free, so the caller derives these from the model
646/// schema's protocol (see [`crate::schema::ApiProtocol::cache_rates`]) and
647/// passes them in alongside the per-token prices.
648#[derive(Debug, Clone, Copy, PartialEq)]
649pub struct CacheRates {
650    /// Cached-read (hit) price ÷ base input. Anthropic ~0.1×, OpenAI ~0.5×.
651    pub read_mult: f64,
652    /// Cache-write price ÷ base input. Anthropic ~1.25× (5-minute TTL; 1-hour
653    /// is 2× but the ledger doesn't record which TTL, so writes bill at 1.25×
654    /// — exact for the default path, a conservative under-estimate for 1-hour).
655    /// OpenAI has no write charge (`0`) and reports no write tokens.
656    pub write_mult: f64,
657}
658
659impl CacheRates {
660    /// Anthropic / Bedrock-Claude: deep read discount + write premium.
661    pub const ANTHROPIC: Self = Self {
662        read_mult: 0.1,
663        write_mult: 1.25,
664    };
665    /// OpenAI (incl. Azure OpenAI): automatic caching, ~0.5× read, no write.
666    pub const OPENAI: Self = Self {
667        read_mult: 0.5,
668        write_mult: 0.0,
669    };
670    /// Providers without parsed prompt caching (Google/Vertex, local). Their
671    /// cache token counts are always zero, so the multipliers are never
672    /// exercised — this is just an honest, inert default.
673    pub const NONE: Self = Self {
674        read_mult: 0.0,
675        write_mult: 0.0,
676    };
677}
678
679impl Default for CacheRates {
680    /// Anthropic was the first (and write-bucket-bearing) provider; defaulting
681    /// here keeps a missing-rates path conservative rather than free.
682    fn default() -> Self {
683        Self::ANTHROPIC
684    }
685}
686
687/// USD cost of the three input buckets (uncached prefix, cache read, cache
688/// write), each priced off the same base `input_per_mtok` rate with the
689/// provider's [`CacheRates`] applied. Shared by [`ModelProfile::usd_per_success`]
690/// and the durable [`crate::scoreboard::Scoreboard`] so both price caching the
691/// same way.
692pub fn priced_input_usd(
693    uncached_input_tokens: u64,
694    cache_read_input_tokens: u64,
695    cache_creation_input_tokens: u64,
696    input_per_mtok: f64,
697    cache: CacheRates,
698) -> f64 {
699    (uncached_input_tokens as f64 * input_per_mtok
700        + cache_read_input_tokens as f64 * input_per_mtok * cache.read_mult
701        + cache_creation_input_tokens as f64 * input_per_mtok * cache.write_mult)
702        / 1_000_000.0
703}
704
705/// EMA smoothing factor. Higher = more weight on recent observations.
706const EMA_ALPHA: f64 = 0.2;
707
708/// One resolved inference outcome — the durable, attributable "receipt"
709/// the concierge quotes ("routed to X at T because Y; latency 1.2s;
710/// outcome success q=0.9"). Append-only; one JSON line per resolution.
711/// Deliberately flat (success/quality/error pulled out of the richer
712/// `InferenceOutcome`) so it's stable to read back and cheap to reason
713/// over. Carries no prompt/output text — only routing-adjacent metadata.
714#[derive(Debug, Clone, Serialize, Deserialize)]
715pub struct OutcomeLedgerEntry {
716    pub trace_id: String,
717    pub model_id: String,
718    pub task: InferenceTask,
719    pub routing_reason: String,
720    pub latency_ms: u64,
721    pub input_tokens: usize,
722    pub output_tokens: usize,
723    /// Prompt-cache read (hit) input tokens, billed at ~0.1× input. Defaults
724    /// to 0 so receipts written before caching telemetry existed read back
725    /// (and price) as fully-uncached.
726    #[serde(default, skip_serializing_if = "is_zero_usize")]
727    pub cache_read_input_tokens: usize,
728    /// Prompt-cache write input tokens, billed at ~1.25×/2× input.
729    #[serde(default, skip_serializing_if = "is_zero_usize")]
730    pub cache_creation_input_tokens: usize,
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub success: Option<bool>,
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    pub quality: Option<f64>,
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub error: Option<String>,
737    /// Project/workspace the call belonged to — the key B1 groups paired
738    /// comparisons on. `None` until the capture path threads it (B1); kept
739    /// in the schema now so today's receipts are forward-compatible.
740    #[serde(default, skip_serializing_if = "Option::is_none")]
741    pub project_id: Option<String>,
742    /// Intent/use-case lane (finer than `task`), if the router computed
743    /// one. `None` until threaded (B1).
744    #[serde(default, skip_serializing_if = "Option::is_none")]
745    pub intent: Option<String>,
746    /// Why an unattributed receipt (`success: None` with an `error`) was not
747    /// the model's fault. `None` on attributed receipts and on unattributed
748    /// ones written before reasons were recorded.
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    pub attribution: Option<UnattributedReason>,
751    /// The CAR version that recorded this receipt. A model's failures under an
752    /// older CAR are not evidence against it: a release can fix the loader
753    /// (gemma-4's nested `vocab_size` failed every load until it did), and
754    /// counting those failures would retire weights for CAR's own bug. `None`
755    /// on receipts written before versions were recorded.
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    pub car_version: Option<String>,
758    pub timestamp: u64,
759}
760
761impl OutcomeLedgerEntry {
762    /// Whether this receipt is evidence the model cannot run: a failure the
763    /// model was blamed for, recorded by this CAR. A failure an older build
764    /// recorded may have been that build's bug (gemma-4's nested `vocab_size`
765    /// failed every load until a release read it), so it never condemns
766    /// weights this build might run.
767    ///
768    /// The rule scopes the portfolio's `cannot_load`, the one verdict that
769    /// deletes. Routing health keeps older builds' failures:
770    /// - the circuit breaker is per session, in memory, so nothing an older
771    ///   build recorded holds it open after a restart;
772    /// - the lifetime `fail_count` sets the router's completion rate,
773    ///   `(completed + 1) / (resolved + 1)`, which scales the model's
774    ///   reliability whichever build recorded the failures; successes earn it
775    ///   back.
776    ///
777    /// Subtracting old-build failures at reconciliation would need a once-only
778    /// stamp on every failure receipt at every release, and would hand a
779    /// genuinely broken model a clean slate each upgrade — which is why
780    /// [`plan_reconcile`] keeps the counters the profile's.
781    pub fn counts_against_model(&self) -> bool {
782        self.success == Some(false)
783            && self.attribution.is_none()
784            && self.car_version.as_deref() == Some(env!("CARGO_PKG_VERSION"))
785    }
786}
787
788/// Why a failed attempt was kept out of the model's health. Recorded on the
789/// receipt so a reader — the concierge quoting "refused for memory 20 times",
790/// a lane deciding what counts as friction — branches on a type instead of
791/// re-reading error text.
792#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
793#[serde(rename_all = "snake_case")]
794pub enum UnattributedReason {
795    /// The request asked for something this model cannot do (a mode, a
796    /// response format, a catalog precondition).
797    Capability,
798    /// The account behind the model: a missing key, an expired sign-in, no
799    /// credits, no workspace, a gateway with no upstream.
800    Account,
801    /// This machine could not hold the model while keeping CAR's memory
802    /// policy and emergency reserve. The model never ran.
803    ResourceBlocked,
804    /// A filter in front of the model ruled on the request's content.
805    ContentRefused,
806    /// The caller stopped it: a dropped stream, a spend limit, a controlled
807    /// termination.
808    Cancelled,
809    /// A model id the registry does not know and that never ran — a test
810    /// pinning a made-up id against the live daemon.
811    Unregistered,
812}
813
814/// Serde skip helper: keep the cache-token fields out of receipts that never
815/// touched the cache, so the durable ledger stays byte-identical to its
816/// pre-caching shape for the common (uncached) case.
817fn is_zero_usize(n: &usize) -> bool {
818    *n == 0
819}
820
821/// Cap on the in-memory ledger buffer between flushes — backstop against
822/// unbounded growth if the flush timer never runs. Oldest entries drop.
823const MAX_LEDGER_BUFFER: usize = 5000;
824
825/// Max characters of an error string kept in a ledger receipt. Errors are
826/// useful signal ("429", "context overflow") but can echo provider text
827/// or paths — cap the length so the privacy-bounded ledger never grows a
828/// large/sensitive blob. The classification lives in the prefix.
829const MAX_LEDGER_ERROR_CHARS: usize = 256;
830
831/// Truncate an error to [`MAX_LEDGER_ERROR_CHARS`] on a char boundary.
832fn redact_error(error: &str) -> String {
833    if error.chars().count() <= MAX_LEDGER_ERROR_CHARS {
834        return error.to_string();
835    }
836    let truncated: String = error.chars().take(MAX_LEDGER_ERROR_CHARS).collect();
837    format!("{truncated}…")
838}
839
840/// Classify an inference error as a **no-answer failure** — the call never
841/// produced an answer to grade — rather than a *generation* failure (the model
842/// returned content that was judged bad). Two families qualify:
843///   - transport/infrastructure: read timeout, connection reset, 5xx, EOF,
844///     overload.
845///   - auth/client rejection: 401/403/400/404, bad API key, `invalid_client`.
846///     The request was refused before any tokens were generated.
847///
848/// Such a call carries NO information about the model's *answer* quality — like a
849/// *mechanical* success, it leaves quality unknown — so it must not move the
850/// quality EMA. Counting these toward quality silently buries a good-but-slow,
851/// transiently-unreachable, or merely *mis-authenticated* model: observed live
852/// as `parslee/*` collapsing to `ema_quality ≈ 0.003` (from read-timeouts AND
853/// ~47 `HTTP 401 Unauthorized` from an undeployed gateway), which then
854/// deprioritized it under every workload. (Rate-limit / 429 is handled
855/// separately as a harsher *availability* penalty, not a quality one.)
856///
857/// This is the principled cut: a failure is answer-quality evidence ONLY when
858/// the model actually produced an answer. Auth/HTTP/transport rejections didn't,
859/// so the default for them is quality-neutral. We match on unambiguous textual
860/// forms, NOT bare status numbers (a bare "400"/"500" substring matches
861/// "1500ms", "tokens: 400", etc.) — a status-only error with no recognizable
862/// phrase falls through to the generation-failure path, which is the safe
863/// direction (it can only over-penalize a model that *did* answer, never
864/// silently bury one that didn't).
865fn is_no_answer_failure(error: &str) -> bool {
866    let e = error.to_ascii_lowercase();
867    const NEEDLES: &[&str] = &[
868        // --- transport / infrastructure (no answer produced) ---
869        "timeout",
870        "timed out",
871        "deadline",
872        "connection",
873        "connect error",
874        "reset by peer",
875        "broken pipe",
876        "network",
877        "unreachable",
878        "eof",
879        "stream closed",
880        "socket",
881        "503",
882        "502",
883        "504",
884        // A 500 is a server-side fault that almost always produced no answer,
885        // but bare "500" is too broad a substring (it matches "1500ms",
886        // "tokens: 500", a 500-dim error, …), so we match only its unambiguous
887        // textual form. A status-only "HTTP 500" with no body stays classified
888        // as a generation failure — accept that edge rather than risk
889        // false-positive hits on coincidental "500" substrings.
890        "internal server error",
891        "service unavailable",
892        "temporarily unavailable",
893        "overloaded",
894        // --- auth / client rejection (request refused, no answer produced) ---
895        // Same bare-number caution as 5xx: match textual forms, not "401"/"400".
896        // Kept HIGH-PRECISION: only phrases that unambiguously mean "the request
897        // was refused before generation." Deliberately NOT "not found" (matches a
898        // generation/parse error like "field not found") or "invalid token"
899        // (collides with tokenizer errors) — a rare 404 mis-scored as a bad
900        // answer is the safe direction; silently burying an auth-blocked model is
901        // not.
902        "unauthorized",
903        "unauthenticated",
904        "forbidden",
905        "permission denied",
906        "authentication",
907        "invalid_client",
908        "invalid api key",
909        "bad request",
910    ];
911    NEEDLES.iter().any(|n| e.contains(n))
912}
913
914/// Rewrite the ledger keeping only the most recent `max_entries` receipts
915/// (retention bound). No-op when under the cap or the file is absent.
916/// Atomic (temp + rename) so a crash mid-prune can't corrupt the ledger.
917pub fn prune_ledger(path: &std::path::Path, max_entries: usize) -> std::io::Result<()> {
918    if max_entries == 0 || !path.exists() {
919        return Ok(());
920    }
921    let entries = read_ledger(path, 0);
922    if entries.len() <= max_entries {
923        return Ok(());
924    }
925    rewrite_ledger(path, &entries[entries.len() - max_entries..])
926}
927
928/// Replace the ledger with `entries`: written to a uniquely named temp file
929/// in the same directory, made owner-only, synced, and renamed over the
930/// ledger, then the directory is synced. A shared temp name let a prune and a
931/// reconcile truncate each other's half-written file, and `fs::write` left the
932/// rewritten ledger world-readable — undoing the 0600 an append sets on a file
933/// holding error text.
934fn rewrite_ledger(path: &std::path::Path, entries: &[OutcomeLedgerEntry]) -> std::io::Result<()> {
935    use std::io::Write;
936    let dir = path
937        .parent()
938        .filter(|d| !d.as_os_str().is_empty())
939        .unwrap_or_else(|| std::path::Path::new("."));
940    let mut tmp = tempfile::Builder::new()
941        .prefix(".outcome_ledger.")
942        .suffix(".tmp")
943        .tempfile_in(dir)?;
944    #[cfg(unix)]
945    {
946        use std::os::unix::fs::PermissionsExt;
947        tmp.as_file()
948            .set_permissions(std::fs::Permissions::from_mode(0o600))?;
949    }
950    for e in entries {
951        let line = serde_json::to_string(e).map_err(std::io::Error::other)?;
952        tmp.write_all(line.as_bytes())?;
953        tmp.write_all(b"\n")?;
954    }
955    tmp.as_file().sync_all()?;
956    car_secrets::harden_owner_only(tmp.path());
957    tmp.persist(path).map_err(|e| e.error)?;
958    #[cfg(unix)]
959    if let Ok(d) = std::fs::File::open(dir) {
960        let _ = d.sync_all();
961    }
962    Ok(())
963}
964
965/// What [`plan_reconcile`] changed. All zero when the ledger and profiles
966/// were already consistent.
967#[derive(Debug, Default, Clone, PartialEq, Eq)]
968pub struct LedgerReconciliation {
969    /// Failure receipts an older build wrote for errors current code books as
970    /// unattributed, now marked with the reason it would record.
971    pub reclassified: usize,
972    /// Failure receipts for model ids the registry does not know and that
973    /// never ran, now marked [`UnattributedReason::Unregistered`].
974    pub unregistered: usize,
975    /// Profiles whose counters and quality evidence were rebuilt.
976    pub profiles_rebuilt: usize,
977    /// Profiles of never-run unregistered ids, removed.
978    pub profiles_removed: usize,
979}
980
981/// The reason current code would record for a legacy receipt's error, when it
982/// would not attribute that error to the model at all.
983///
984/// Receipts written before those errors were typed carry only their rendered
985/// text, so this is the one place that classifies by text — on history that
986/// has no type to read. Live booking routes on the [`crate::InferenceError`]
987/// variant and records an [`UnattributedReason`]; a receipt that carries one is
988/// never re-read here. The prefixes are the renderings of the producers named
989/// beside them; the ones whose producer no longer exists are kept as they
990/// were written.
991fn legacy_unattributed(error: &str) -> Option<UnattributedReason> {
992    use UnattributedReason as R;
993    const PREFIXES: &[(&str, UnattributedReason)] = &[
994        // `From<LocalAdmissionError> for InferenceError`: each verdict's
995        // recovery text, which is the `LocalResourceBlocked` Display.
996        (
997            "Not enough memory is free to start this model",
998            R::ResourceBlocked,
999        ),
1000        ("This model needs about ", R::ResourceBlocked),
1001        (
1002            "Local model loading is disabled by the 0 GB allocation",
1003            R::ResourceBlocked,
1004        ),
1005        ("CAR could not measure live memory", R::ResourceBlocked),
1006        (
1007            "This local model is being removed or maintained",
1008            R::ResourceBlocked,
1009        ),
1010        (
1011            "CAR is still confirming that the previous local model process exited",
1012            R::ResourceBlocked,
1013        ),
1014        // `InferenceError::ProviderKeyMissing` (typed 2026-09-15); its Display
1015        // kept the `InferenceFailed` rendering.
1016        (
1017            "inference failed: no API keys available for endpoint ",
1018            R::Account,
1019        ),
1020        // The Parslee org lookup's 401, before it was `CredentialUnavailable`.
1021        (
1022            "inference failed: Parslee org lookup failed: HTTP 401",
1023            R::Account,
1024        ),
1025        // A FoundationModels guardrail or refusal, before it was typed
1026        // `ContentRefused`.
1027        (
1028            "inference failed: FoundationModels declined the request (",
1029            R::ContentRefused,
1030        ),
1031    ];
1032    if let Some((_, reason)) = PREFIXES
1033        .iter()
1034        .find(|(prefix, _)| error.starts_with(prefix))
1035    {
1036        return Some(*reason);
1037    }
1038    // `UnsupportedMode` rendered through `InferenceFailed`: anchored on the
1039    // variant's own Display, not the bare word "mode".
1040    (error.starts_with("inference failed: mode ") && error.contains(" not implemented on backend "))
1041        .then_some(R::Capability)
1042}
1043
1044/// The quality a profile and its tasks started from before any outcome moved
1045/// them: the benchmark prior when one seeded the profile, otherwise neutral.
1046/// Read from today's priors files, which may have changed since the profile
1047/// was seeded — the best available stand-in, not a record.
1048#[derive(Debug, Clone, Default)]
1049pub struct QualitySeed {
1050    pub overall: Option<f64>,
1051    pub tasks: HashMap<String, f64>,
1052}
1053
1054/// A planned reconciliation. Nothing is applied yet: `ledger`, when present,
1055/// is what the ledger file must become, and `profiles` the corrected profile
1056/// set to commit once it has.
1057#[derive(Debug, Default)]
1058pub struct ReconcilePlan {
1059    pub report: LedgerReconciliation,
1060    pub ledger: Option<Vec<OutcomeLedgerEntry>>,
1061    pub profiles: Option<HashMap<String, ModelProfile>>,
1062}
1063
1064/// Make the ledger and the persisted profiles agree about which failures were
1065/// the model's.
1066///
1067/// Older builds booked failures a model was not responsible for — admission
1068/// refusals of this machine's memory, missing keys, expired sign-ins, content
1069/// rulings, ids that were never registered. This marks those receipts with the
1070/// [`UnattributedReason`] current code would record and corrects each affected
1071/// profile:
1072///
1073/// - **Counters stay the profile's.** The ledger is not a complete record of
1074///   successes — `record_complete` credits a mechanical success to the profile
1075///   and the receipt waits for a sweep that a short-lived process exits before
1076///   — while every failure is written at once. Rebuilding counts from the
1077///   ledger would pair a full failure history with a partial success history
1078///   and bury exactly the local models this repair is for. So the failures
1079///   stamped **in this run** are subtracted from `fail_count`, `total_calls`
1080///   and the task's `failures`, the exact inverse of what `record_failure`
1081///   added, and nothing else is touched. A profile breaking
1082///   `total_calls >= success_count + fail_count` is clamped, not rebuilt.
1083/// - **Quality evidence is replayed from the ledger.** Graded receipts are
1084///   written when the grade arrives, so for these the ledger is complete:
1085///   when a profile carries more `quality_observations` than its graded
1086///   receipts account for — the misattributed case — the count and the EMA are
1087///   replayed from those receipts in order, starting from the benchmark seed
1088///   where one seeded the profile (`seeds`). The replay is a function of the
1089///   ledger alone, so repeating it is harmless.
1090///
1091/// Subtraction must happen once per receipt, so it rides on the stamp. The
1092/// plan touches nothing: the caller writes the stamped ledger **first**, and
1093/// only once that succeeds commits `profiles` into the tracker and saves them.
1094/// A failed write leaves the tracker untouched; a crash after the write keeps
1095/// those failures counted (the conservative direction). Neither can subtract
1096/// twice; rows an earlier version already
1097/// rewrote to `success: None` subtract nothing, because that version already
1098/// took them out of the counters. A reconciled state root has nothing left to
1099/// stamp, so steady-state starts change nothing.
1100///
1101/// An id is unregistered only if the registry does not know it, no receipt
1102/// shows it succeeding, and no profile credits it a success — an id that ran
1103/// keeps its history whole. Nothing is deleted from the ledger.
1104pub fn plan_reconcile(
1105    path: &std::path::Path,
1106    tracker: &OutcomeTracker,
1107    is_registered: &dyn Fn(&str) -> bool,
1108    seeds: &HashMap<String, QualitySeed>,
1109) -> ReconcilePlan {
1110    let mut plan = ReconcilePlan::default();
1111    // Corrections are made on a copy and committed by the caller only once
1112    // the stamped ledger is on disk — see [`OutcomeTracker::commit_reconciled`].
1113    let mut profiles = tracker.profiles.clone();
1114    let mut profiles_changed = false;
1115    if !path.exists() {
1116        return plan;
1117    }
1118    let mut entries = read_ledger(path, 0);
1119
1120    let ran: std::collections::HashSet<&str> = entries
1121        .iter()
1122        .filter(|e| e.success == Some(true))
1123        .map(|e| e.model_id.as_str())
1124        .collect();
1125    let never_ran: std::collections::HashSet<String> = entries
1126        .iter()
1127        .map(|e| e.model_id.as_str())
1128        .filter(|id| {
1129            !ran.contains(id)
1130                && !is_registered(id)
1131                && profiles.get(*id).is_none_or(|p| p.success_count == 0)
1132        })
1133        .map(str::to_string)
1134        .collect();
1135
1136    let mut touched = std::collections::BTreeSet::new();
1137    // Failures stamped in this run, per model and task: what to take back out
1138    // of the profile counters.
1139    let mut unbooked: HashMap<String, HashMap<String, u64>> = HashMap::new();
1140    for entry in &mut entries {
1141        if entry.attribution.is_some() {
1142            continue;
1143        }
1144        let legacy = entry.error.as_deref().and_then(legacy_unattributed);
1145        let reason = if never_ran.contains(&entry.model_id) && entry.success == Some(false) {
1146            UnattributedReason::Unregistered
1147        } else if let Some(reason) = legacy.filter(|_| entry.success != Some(true)) {
1148            // `success: None` here is either a row the first version of this
1149            // pass already reclassified or an unattributed row written before
1150            // reasons existed; stamping it changes nothing it is counted as.
1151            reason
1152        } else {
1153            continue;
1154        };
1155        match reason {
1156            UnattributedReason::Unregistered => plan.report.unregistered += 1,
1157            _ if entry.success == Some(false) => plan.report.reclassified += 1,
1158            _ => {}
1159        }
1160        if entry.success == Some(false) {
1161            *unbooked
1162                .entry(entry.model_id.clone())
1163                .or_default()
1164                .entry(entry.task.to_string())
1165                .or_default() += 1;
1166        }
1167        entry.success = None;
1168        entry.quality = None;
1169        entry.attribution = Some(reason);
1170        touched.insert(entry.model_id.clone());
1171    }
1172    let ledger_changed = !touched.is_empty();
1173
1174    // Clamp profiles whose counters contradict themselves.
1175    for profile in profiles.values_mut() {
1176        let floor = profile.success_count + profile.fail_count;
1177        if profile.total_calls < floor {
1178            profile.total_calls = floor;
1179            plan.report.profiles_rebuilt += 1;
1180            profiles_changed = true;
1181        }
1182    }
1183
1184    for model_id in &touched {
1185        if never_ran.contains(model_id) {
1186            if profiles.remove(model_id).is_some() {
1187                plan.report.profiles_removed += 1;
1188                profiles_changed = true;
1189            }
1190            continue;
1191        }
1192        let Some(profile) = profiles.get_mut(model_id) else {
1193            continue;
1194        };
1195        let rows: Vec<&OutcomeLedgerEntry> =
1196            entries.iter().filter(|e| &e.model_id == model_id).collect();
1197        let seed = seeds.get(model_id);
1198        let unbook = unbooked.get(model_id);
1199        if correct_profile(profile, &rows, unbook, seed) {
1200            plan.report.profiles_rebuilt += 1;
1201            profiles_changed = true;
1202        }
1203    }
1204
1205    if ledger_changed {
1206        plan.ledger = Some(entries);
1207    }
1208    if profiles_changed {
1209        plan.profiles = Some(profiles);
1210    }
1211    plan
1212}
1213
1214/// Write the ledger a [`ReconcilePlan`] produced. Done before the corrected
1215/// profiles are committed, so a failed write leaves them as they were.
1216pub fn write_reconciled_ledger(
1217    path: &std::path::Path,
1218    entries: &[OutcomeLedgerEntry],
1219) -> std::io::Result<()> {
1220    rewrite_ledger(path, entries)
1221}
1222
1223/// Correct one profile: take the failures stamped in this run back out of its
1224/// counters (`unbook`, per task), then replay its quality evidence from the
1225/// graded receipts. Returns whether anything changed.
1226fn correct_profile(
1227    profile: &mut ModelProfile,
1228    rows: &[&OutcomeLedgerEntry],
1229    unbook: Option<&HashMap<String, u64>>,
1230    seed: Option<&QualitySeed>,
1231) -> bool {
1232    let before = serde_json::to_value(&*profile).ok();
1233
1234    // Exactly what `record_failure` added: a call, a failure, a task failure.
1235    for (task, n) in unbook.into_iter().flatten() {
1236        profile.total_calls = profile.total_calls.saturating_sub(*n);
1237        profile.fail_count = profile.fail_count.saturating_sub(*n);
1238        if let Some(ts) = profile.task_stats.get_mut(task) {
1239            ts.failures = ts.failures.saturating_sub(*n);
1240        }
1241    }
1242
1243    // Attributed receipts only: an unattributed one never touched a profile.
1244    let attributed: Vec<&OutcomeLedgerEntry> = rows
1245        .iter()
1246        .copied()
1247        .filter(|e| e.attribution.is_none())
1248        .collect();
1249    let in_task =
1250        |e: &&OutcomeLedgerEntry, task: Option<&str>| task.is_none_or(|t| e.task.to_string() == t);
1251
1252    // Quality evidence: the graded receipts, in order, from the seed.
1253    // A failure receipt's `0.0` is answer quality for the scoreboard, not a
1254    // routing grade (QUALITY_EVIDENCE_VERSION).
1255    let graded: Vec<(&OutcomeLedgerEntry, f64)> = attributed
1256        .iter()
1257        .copied()
1258        .filter(|e| e.success != Some(false))
1259        .filter_map(|e| e.quality.map(|q| (e, q)))
1260        .collect();
1261    let replay = |start: f64, task: Option<&str>| {
1262        let mut ema = start;
1263        let mut n = 0u64;
1264        for (_, q) in graded.iter().filter(|(e, _)| in_task(e, task)) {
1265            ema = ema * (1.0 - EMA_ALPHA) + q * EMA_ALPHA;
1266            n += 1;
1267        }
1268        (ema, n)
1269    };
1270    // A seeded profile replays from its seed; one whose seed is gone (the
1271    // priors file was removed) cannot be replayed honestly and is left.
1272    let profile_start = match (profile.prior_sample_size, seed.and_then(|s| s.overall)) {
1273        (0, _) => Some(NEUTRAL_QUALITY),
1274        (_, Some(seeded)) => Some(seeded.clamp(0.0, 1.0)),
1275        (_, None) => None,
1276    };
1277    if let Some(start) = profile_start {
1278        let (ema, n) = replay(start, None);
1279        if profile.quality_observations > n {
1280            profile.ema_quality = ema;
1281            profile.quality_observations = n;
1282        }
1283    }
1284    for (task, ts) in profile.task_stats.iter_mut() {
1285        let seeded = seed.and_then(|s| s.tasks.get(task)).copied();
1286        let start = match (ts.prior_sample_size, seeded) {
1287            (0, _) => Some(TaskStats::default().ema_quality),
1288            (_, Some(seeded)) => Some(seeded.clamp(0.0, 1.0)),
1289            (_, None) => None,
1290        };
1291        let Some(start) = start else { continue };
1292        let (ema, n) = replay(start, Some(task));
1293        if ts.quality_observations > n {
1294            // With no graded evidence left an unseeded task returns to
1295            // neutral, which also keeps `backfill_quality_observations` from
1296            // refilling its weight from `failures` on the next load.
1297            ts.ema_quality = if n == 0 && ts.prior_sample_size == 0 {
1298                NEUTRAL_QUALITY
1299            } else {
1300                ema
1301            };
1302            ts.quality_observations = n;
1303        }
1304    }
1305    let changed = serde_json::to_value(&*profile).ok() != before;
1306    if changed {
1307        profile.updated_at = now_unix();
1308    }
1309    changed
1310}
1311
1312/// Append resolved outcome receipts to the JSONL ledger (one line each).
1313/// Append-only: no full-file rewrite, so this never triggers the
1314/// write-storm the profiles file had. Creates the file/parent on first
1315/// write.
1316pub fn append_ledger_entries(
1317    path: &std::path::Path,
1318    entries: &[OutcomeLedgerEntry],
1319) -> std::io::Result<()> {
1320    if entries.is_empty() {
1321        return Ok(());
1322    }
1323    if let Some(parent) = path.parent() {
1324        std::fs::create_dir_all(parent)?;
1325    }
1326    use std::io::Write;
1327    let mut opts = std::fs::OpenOptions::new();
1328    opts.create(true).append(true);
1329    // Local, privacy-bounded file (error text, soon project paths): owner
1330    // read/write only.
1331    #[cfg(unix)]
1332    {
1333        use std::os::unix::fs::OpenOptionsExt;
1334        opts.mode(0o600);
1335    }
1336    // On Windows `mode(0o600)` is ignored; lock the file owner-only via ACL on
1337    // a fresh create only (idempotent). No-op off Windows.
1338    let existed = path.exists();
1339    let mut f = opts.open(path)?;
1340    if !existed {
1341        car_secrets::harden_owner_only(path);
1342    }
1343    for e in entries {
1344        let line = serde_json::to_string(e).map_err(std::io::Error::other)?;
1345        f.write_all(line.as_bytes())?;
1346        f.write_all(b"\n")?;
1347    }
1348    Ok(())
1349}
1350
1351/// Read the most recent `limit` ledger entries (0 = all). Tolerant of
1352/// partial/garbage lines (skips them) so a torn append never poisons a
1353/// read — the consumer (UsageProfile) gets whatever is well-formed.
1354pub fn read_ledger(path: &std::path::Path, limit: usize) -> Vec<OutcomeLedgerEntry> {
1355    let Ok(content) = std::fs::read_to_string(path) else {
1356        return Vec::new();
1357    };
1358    let mut out: Vec<OutcomeLedgerEntry> = content
1359        .lines()
1360        .filter(|l| !l.trim().is_empty())
1361        .filter_map(|l| serde_json::from_str(l).ok())
1362        .collect();
1363    if limit > 0 && out.len() > limit {
1364        out = out.split_off(out.len() - limit);
1365    }
1366    out
1367}
1368
1369tokio::task_local! {
1370    /// Whether calls made inside the scope may teach the router (see
1371    /// [`scope_learning`]).
1372    static LEARNING: bool;
1373    /// Set once a call in the scope is dispatched to a model (see
1374    /// [`scope_dispatch_probe`]).
1375    static DISPATCHED: std::sync::Arc<std::sync::atomic::AtomicBool>;
1376}
1377
1378/// Run `future`, setting `probe` once one of its calls is dispatched to a
1379/// model: when the engine starts an attempt on a chosen model, past routing
1380/// and every refusal raised before dispatch. A caller that meters a call uses
1381/// it to count only calls that reached a model.
1382pub async fn scope_dispatch_probe<F: std::future::Future>(
1383    probe: std::sync::Arc<std::sync::atomic::AtomicBool>,
1384    future: F,
1385) -> F::Output {
1386    DISPATCHED.scope(probe, future).await
1387}
1388
1389/// Mark the call in this scope as dispatched to a model (see
1390/// [`scope_dispatch_probe`]): an attempt was recorded on a chosen model, or a
1391/// streamed request's bytes were handed to the HTTP client.
1392pub(crate) fn note_dispatch() {
1393    let _ = DISPATCHED.try_with(|probe| probe.store(true, std::sync::atomic::Ordering::SeqCst));
1394}
1395
1396/// Run `future` with the outcome tracker learning from its calls or not. A
1397/// worker's calls run with `learn = false`, so the adaptive router's scores
1398/// and outcome history neither steer nor learn from them
1399/// (docs/proposals/orchestrator-workers.md, I5). Outside any scope every call
1400/// is learned from, as before.
1401pub async fn scope_learning<F: std::future::Future>(learn: bool, future: F) -> F::Output {
1402    LEARNING.scope(learn, future).await
1403}
1404
1405pub(crate) fn learning_suppressed() -> bool {
1406    LEARNING.try_with(|learn| !*learn).unwrap_or(false)
1407}
1408
1409/// How long a call that ended stays pending — gradeable through
1410/// `outcomes.resolve_pending` — before the daemon's sweep records it
1411/// ungraded: fifteen minutes from the call's END. Long enough for a judge
1412/// that is itself a multi-minute generation; mechanical success is already
1413/// credited at completion, so the wait costs only memory and receipt latency.
1414pub const PENDING_GRADE_TTL_SECS: u64 = 15 * 60;
1415
1416/// How long a call still in flight stays pending before the sweep treats it
1417/// as crashed: two hours from its start. See [`OutcomeTracker::sweep_pending`].
1418pub const PENDING_IN_FLIGHT_TTL_SECS: u64 = 2 * 60 * 60;
1419
1420/// Tracks inference outcomes and builds performance profiles.
1421pub struct OutcomeTracker {
1422    /// In-memory profiles, keyed by model_id.
1423    profiles: HashMap<String, ModelProfile>,
1424    /// Pending outcomes: completed inference calls awaiting outcome signal.
1425    /// Keyed by trace_id.
1426    pending: HashMap<String, InferenceOutcome>,
1427    /// Counter for generating trace IDs.
1428    trace_counter: u64,
1429    /// Models excluded for this session (429/rate-limited). Hard exclusion.
1430    excluded: HashSet<String>,
1431    /// Set whenever a persisted field (a `ModelProfile`) changes; cleared
1432    /// on save. Lets the engine debounce disk writes — persist only when
1433    /// there's something new, instead of serializing the whole file after
1434    /// every inference call. `excluded`/`pending` are session-only and do
1435    /// not flip this.
1436    dirty: bool,
1437    /// Resolved outcome receipts awaiting append to the JSONL ledger.
1438    /// Drained by the engine on flush. Bounded by [`MAX_LEDGER_BUFFER`].
1439    ledger_buffer: std::collections::VecDeque<OutcomeLedgerEntry>,
1440}
1441
1442impl OutcomeTracker {
1443    pub fn new() -> Self {
1444        Self {
1445            profiles: HashMap::new(),
1446            pending: HashMap::new(),
1447            trace_counter: 0,
1448            excluded: HashSet::new(),
1449            dirty: false,
1450            ledger_buffer: std::collections::VecDeque::new(),
1451        }
1452    }
1453
1454    /// Push a resolved receipt to the ledger buffer (capped; oldest drops).
1455    fn push_ledger(&mut self, entry: OutcomeLedgerEntry) {
1456        if self.ledger_buffer.len() >= MAX_LEDGER_BUFFER {
1457            self.ledger_buffer.pop_front();
1458        }
1459        self.ledger_buffer.push_back(entry);
1460    }
1461
1462    /// Drain buffered receipts for the engine to append to the ledger file.
1463    pub fn drain_ledger(&mut self) -> Vec<OutcomeLedgerEntry> {
1464        self.ledger_buffer.drain(..).collect()
1465    }
1466
1467    /// Check if a model is excluded (rate-limited) for this session.
1468    pub fn is_excluded(&self, model_id: &str) -> bool {
1469        self.excluded.contains(model_id)
1470    }
1471
1472    /// Record that an inference call started. Returns a trace_id.
1473    ///
1474    /// Inside [`scope_learning`]`(false, ..)` the call is not recorded at all:
1475    /// the trace id is returned but nothing is pending under it, so its
1476    /// completion, failure and any later quality signal change no profile.
1477    pub fn record_start(
1478        &mut self,
1479        model_id: &str,
1480        task: InferenceTask,
1481        routing_reason: &str,
1482    ) -> String {
1483        self.trace_counter += 1;
1484        let trace_id = format!("t-{}-{}", now_unix(), self.trace_counter);
1485        note_dispatch();
1486        if learning_suppressed() {
1487            return trace_id;
1488        }
1489
1490        let outcome = InferenceOutcome {
1491            trace_id: trace_id.clone(),
1492            model_id: model_id.to_string(),
1493            task,
1494            // Length-capped: `routing_reason` is the only free-text field
1495            // that reaches the persisted ledger. INVARIANT: it must NEVER
1496            // embed prompt or output text — only a routing rationale
1497            // ("Code task -> Qwen3-4B"). The cap bounds accidental growth.
1498            routing_reason: redact_error(routing_reason),
1499            latency_ms: 0,
1500            input_tokens: 0,
1501            output_tokens: 0,
1502            cache_read_input_tokens: 0,
1503            cache_creation_input_tokens: 0,
1504            inferred_outcome: None,
1505            code_outcome: None,
1506            error: None,
1507            timestamp: now_unix(),
1508            success_credited: false,
1509            completed_at: None,
1510        };
1511
1512        self.pending.insert(trace_id.clone(), outcome);
1513        trace_id
1514    }
1515
1516    /// Record completion of an inference call (timing + token counts).
1517    pub fn record_complete(
1518        &mut self,
1519        trace_id: &str,
1520        latency_ms: u64,
1521        input_tokens: usize,
1522        output_tokens: usize,
1523    ) {
1524        self.record_complete_cached(trace_id, latency_ms, input_tokens, output_tokens, 0, 0);
1525    }
1526
1527    /// Like [`Self::record_complete`] but also records the prompt-cache token
1528    /// split (`cache_read` = hit, `cache_creation` = write). `input_tokens`
1529    /// here is the *uncached* prefix only — for Anthropic that's
1530    /// `usage.input_tokens`, with the cached portion passed separately so cost
1531    /// accounting prices each bucket at its own rate. The plain 4-arg
1532    /// [`Self::record_complete`] forwards here with both cache buckets zeroed,
1533    /// which is correct for non-caching providers and local inference.
1534    pub fn record_complete_cached(
1535        &mut self,
1536        trace_id: &str,
1537        latency_ms: u64,
1538        input_tokens: usize,
1539        output_tokens: usize,
1540        cache_read_input_tokens: usize,
1541        cache_creation_input_tokens: usize,
1542    ) {
1543        if let Some(outcome) = self.pending.get_mut(trace_id) {
1544            outcome.completed_at = Some(now_unix());
1545            outcome.latency_ms = latency_ms;
1546            outcome.input_tokens = input_tokens;
1547            outcome.output_tokens = output_tokens;
1548            outcome.cache_read_input_tokens = cache_read_input_tokens;
1549            outcome.cache_creation_input_tokens = cache_creation_input_tokens;
1550
1551            // Credit a *mechanical* success immediately when the call ran
1552            // and produced output with no error. This is symmetric with
1553            // `record_failure`, which books a failure the moment it happens
1554            // — previously a success was credited only by the 300s pending
1555            // sweep, so short-lived processes (the `car infer` CLI, eval
1556            // harnesses) exited before the sweep and NEVER recorded a
1557            // success, pinning working models at the 0.5 EMA prior / "0
1558            // successes" in the health UI (#312, only partially fixed by
1559            // the deferred sweep). `ema_quality` is untouched — only a real
1560            // downstream accept/edit signal moves quality. `success_credited`
1561            // stops the later sweep and any quality-signal resolution from
1562            // double-counting this same call.
1563            let mechanical_success = output_tokens > 0 && outcome.error.is_none();
1564            if mechanical_success {
1565                outcome.success_credited = true;
1566            }
1567            let model_id = outcome.model_id.clone();
1568            let task_key = outcome.task.to_string();
1569
1570            // Update profile with timing data (the `outcome` borrow of
1571            // `self.pending` ends here; `self.profiles` is a disjoint field).
1572            let profile = self
1573                .profiles
1574                .entry(model_id.clone())
1575                .or_insert_with(|| ModelProfile::new(model_id));
1576
1577            profile.total_calls += 1;
1578            profile.total_latency_ms += latency_ms;
1579            profile.total_input_tokens += input_tokens as u64;
1580            profile.total_output_tokens += output_tokens as u64;
1581            profile.total_cache_read_input_tokens += cache_read_input_tokens as u64;
1582            profile.total_cache_creation_input_tokens += cache_creation_input_tokens as u64;
1583            if mechanical_success {
1584                profile.success_count += 1;
1585            }
1586
1587            let ts = profile.task_stats.entry(task_key).or_default();
1588            ts.calls += 1;
1589            if mechanical_success {
1590                ts.successes += 1;
1591            }
1592            ts.avg_latency_ms =
1593                ts.avg_latency_ms + (latency_ms as f64 - ts.avg_latency_ms) / ts.calls as f64;
1594
1595            profile.updated_at = now_unix();
1596            self.dirty = true;
1597        }
1598    }
1599
1600    /// Record a failure.
1601    pub fn record_failure(&mut self, trace_id: &str, error: &str) {
1602        let mut ledger_entry = None;
1603        if let Some(outcome) = self.pending.get_mut(trace_id) {
1604            outcome.error = Some(error.to_string());
1605
1606            let profile = self
1607                .profiles
1608                .entry(outcome.model_id.clone())
1609                .or_insert_with(|| ModelProfile::new(outcome.model_id.clone()));
1610
1611            // A failed call is still a call: count it in `total_calls` so the
1612            // denominator includes failures (record_complete counts successes
1613            // the same way). Without this, `total_calls` tallied only
1614            // completions and could read *smaller* than `fail_count` — the
1615            // live symptom on parslee/* models (6 calls, 14 fails).
1616            profile.total_calls += 1;
1617            profile.fail_count += 1;
1618
1619            // Rate-limit errors (429) get a harsher penalty — the model is
1620            // guaranteed to fail again, so drop quality aggressively.
1621            let is_rate_limited = error.contains("429") || error.contains("RESOURCE_EXHAUSTED");
1622            // No-answer failures (read timeout, connection reset, 5xx, EOF, AND
1623            // auth/client rejections like 401/403/400) never produced an answer,
1624            // so answer quality is UNKNOWN — they must stay quality-neutral. The
1625            // call + failure are already booked above (`total_calls`/`fail_count`)
1626            // so availability and the circuit breaker still react; we just leave
1627            // `ema_quality` / `quality_observations` untouched. See
1628            // `is_no_answer_failure`.
1629            let is_no_answer = !is_rate_limited && is_no_answer_failure(error);
1630            if is_rate_limited {
1631                // Hard-exclude for the rest of this session (#13)
1632                self.excluded.insert(outcome.model_id.clone());
1633            }
1634            // A failure is availability evidence (the counters above, the
1635            // circuit breaker, the sampler's failure count), never a grade of
1636            // answer quality: see QUALITY_EVIDENCE_VERSION.
1637
1638            let task_key = outcome.task.to_string();
1639            let ts = profile.task_stats.entry(task_key).or_default();
1640            // `failures` is an availability counter — every failure counts,
1641            // no-answer failures included (it feeds degradation / circuit-breaking).
1642            ts.failures += 1;
1643
1644            profile.updated_at = now_unix();
1645            self.dirty = true;
1646
1647            ledger_entry = Some(OutcomeLedgerEntry {
1648                trace_id: outcome.trace_id.clone(),
1649                model_id: outcome.model_id.clone(),
1650                task: outcome.task,
1651                routing_reason: outcome.routing_reason.clone(),
1652                latency_ms: outcome.latency_ms,
1653                input_tokens: outcome.input_tokens,
1654                output_tokens: outcome.output_tokens,
1655                cache_read_input_tokens: outcome.cache_read_input_tokens,
1656                cache_creation_input_tokens: outcome.cache_creation_input_tokens,
1657                success: Some(false),
1658                // #374 follow-up: the ledger's `quality` is ANSWER quality for the
1659                // scoreboard's `avg_quality` and the shadow-calibration band
1660                // (#369). A *generation* failure is recorded there as `0.0`. A
1661                // *no-answer* failure (transport/timeout/5xx, 401/403/400) or a
1662                // *429* produced no answer, so it is `None`. Routing quality does
1663                // not read failure receipts at all (QUALITY_EVIDENCE_VERSION): the
1664                // reconcile replay skips `success: Some(false)` rows, as the live
1665                // tracker no longer grades failures.
1666                quality: if is_rate_limited || is_no_answer {
1667                    None
1668                } else {
1669                    Some(0.0)
1670                },
1671                error: Some(redact_error(error)),
1672                project_id: None,
1673                intent: None,
1674                attribution: None,
1675                car_version: Some(env!("CARGO_PKG_VERSION").to_string()),
1676                timestamp: now_unix(),
1677            });
1678        }
1679
1680        if let Some(entry) = ledger_entry {
1681            self.push_ledger(entry);
1682        }
1683
1684        // Failed outcomes don't need further tracking
1685        self.pending.remove(trace_id);
1686    }
1687
1688    /// Resolve a request/provider capability mismatch without treating it as a
1689    /// model-health or answer-quality failure.
1690    ///
1691    /// The attempt is still emitted to the receipt ledger for operator
1692    /// visibility, but it must not change the profile that adaptive routing
1693    /// uses for unrelated requests to the same model.
1694    pub fn record_capability_rejection(&mut self, trace_id: &str, error: &str) {
1695        self.record_not_model_fault(trace_id, error, UnattributedReason::Capability);
1696    }
1697
1698    /// Resolve a provider **account** rejection — bad/absent key, or out of
1699    /// credits — without treating it as a model failure.
1700    ///
1701    /// Same unattributed receipt as [`record_capability_rejection`](crate::outcome::OutcomeTracker::record_capability_rejection), different
1702    /// reason: the condition is account-wide, so it is not evidence about the
1703    /// model that happened to be selected. Booking it as a failure degrades the
1704    /// 30-day health EMA of every model the fallback chain touches, and that
1705    /// penalty outlives the top-up that fixes the account (Parslee-ai/car#650).
1706    pub fn record_account_rejection(&mut self, trace_id: &str, error: &str) {
1707        self.record_not_model_fault(trace_id, error, UnattributedReason::Account);
1708    }
1709
1710    /// Resolve a local admission refusal — the machine could not hold the
1711    /// model while keeping CAR's memory policy and emergency reserve — without
1712    /// treating it as a model failure.
1713    ///
1714    /// The model never ran. Booking the refusal as a failure measured the
1715    /// machine's free memory at that moment and charged it to the model: a
1716    /// 27B checkpoint refused five times on a busy afternoon read as 58%
1717    /// healthy for good, which is the number a concierge would retire it on.
1718    pub fn record_resource_rejection(&mut self, trace_id: &str, error: &str) {
1719        self.record_not_model_fault(trace_id, error, UnattributedReason::ResourceBlocked);
1720    }
1721
1722    /// Replace the profiles with a [`ReconcilePlan`]'s corrected set. Called
1723    /// only after the plan's ledger is on disk — committing first would let the
1724    /// periodic save persist a correction whose stamps never landed, and the
1725    /// next start would make it again.
1726    pub fn commit_reconciled(&mut self, profiles: HashMap<String, ModelProfile>) {
1727        self.profiles = profiles;
1728        self.dirty = true;
1729    }
1730
1731    /// Note how long an attempt ran before it ended without a completion — a
1732    /// dropped stream, for one — so its unattributed receipt keeps the time
1733    /// instead of reading as instantaneous. A stall the caller gave up on
1734    /// stays visible in the latency even though it is not booked as a failure.
1735    pub fn note_elapsed(&mut self, trace_id: &str, latency_ms: u64) {
1736        if let Some(outcome) = self.pending.get_mut(trace_id) {
1737            outcome.latency_ms = latency_ms;
1738            outcome.completed_at.get_or_insert_with(now_unix);
1739        }
1740    }
1741
1742    /// Emit a receipt that records what happened without attributing it to the
1743    /// model: `success: None`, `quality: None`, so the ledger keeps the
1744    /// evidence while the routing profile stays untouched.
1745    pub fn record_not_model_fault(
1746        &mut self,
1747        trace_id: &str,
1748        error: &str,
1749        reason: UnattributedReason,
1750    ) {
1751        if let Some(outcome) = self.pending.remove(trace_id) {
1752            self.push_ledger(OutcomeLedgerEntry {
1753                trace_id: outcome.trace_id,
1754                model_id: outcome.model_id,
1755                task: outcome.task,
1756                routing_reason: outcome.routing_reason,
1757                latency_ms: outcome.latency_ms,
1758                input_tokens: outcome.input_tokens,
1759                output_tokens: outcome.output_tokens,
1760                cache_read_input_tokens: outcome.cache_read_input_tokens,
1761                cache_creation_input_tokens: outcome.cache_creation_input_tokens,
1762                success: None,
1763                quality: None,
1764                error: Some(redact_error(error)),
1765                project_id: None,
1766                intent: None,
1767                attribution: Some(reason),
1768                car_version: Some(env!("CARGO_PKG_VERSION").to_string()),
1769                timestamp: now_unix(),
1770            });
1771        }
1772    }
1773
1774    /// Record an inferred outcome from conversation signals.
1775    pub fn record_inferred_outcome(&mut self, trace_id: &str, outcome: InferredOutcome) {
1776        if let Some(pending) = self.pending.remove(trace_id) {
1777            self.apply_outcome(&pending, outcome.quality_score(), outcome.is_success());
1778        }
1779    }
1780
1781    /// Record an outcome from git-diff comparison (code generation).
1782    pub fn record_code_outcome(&mut self, trace_id: &str, outcome: CodeOutcome) {
1783        if let Some(pending) = self.pending.remove(trace_id) {
1784            self.apply_outcome(
1785                &pending,
1786                Some(outcome.quality_score()),
1787                Some(outcome.is_success()),
1788            );
1789        }
1790    }
1791
1792    /// Resolve all pending outcomes for a completed conversation turn.
1793    /// Called with the inferred outcomes from conversation signal analysis.
1794    pub fn resolve_pending_from_signals(&mut self, outcomes: Vec<(String, InferredOutcome)>) {
1795        self.resolve_pending_reporting(outcomes);
1796    }
1797
1798    /// [`Self::resolve_pending_from_signals`], reporting which signals landed
1799    /// and which matched no pending trace. A dropped signal changes nothing,
1800    /// and the report is the only place that is visible.
1801    pub fn resolve_pending_reporting(
1802        &mut self,
1803        outcomes: Vec<(String, InferredOutcome)>,
1804    ) -> ResolveReport {
1805        let mut report = ResolveReport::default();
1806        for (trace_id, inferred) in outcomes {
1807            if self.has_pending(&trace_id) {
1808                self.record_inferred_outcome(&trace_id, inferred);
1809                report.resolved += 1;
1810            } else {
1811                report.dropped.push(trace_id);
1812            }
1813        }
1814        report
1815    }
1816
1817    /// [`Self::record_external_grade_as`] for an operator (no reporter).
1818    pub fn record_external_grade(
1819        &mut self,
1820        model_id: &str,
1821        task: InferenceTask,
1822        quality: f64,
1823        n: u32,
1824        source: &str,
1825    ) -> Result<ExternalGradeStats, ExternalGradeRefusal> {
1826        self.record_external_grade_as(None, model_id, task, quality, n, source)
1827    }
1828
1829    /// Record a graded result for `model_id` on `task` that is not tied to a
1830    /// call this tracker served — a head-to-head or eval run outside CAR.
1831    ///
1832    /// `quality` is the mean grade over `n` cases. `reporter` is who is
1833    /// reporting, as the daemon knows it (`None` for an operator); the grade
1834    /// is stored under [`external_grade_key`]`(reporter, source)` on the
1835    /// task's [`TaskStats::external_grades`], so a reporter can only update
1836    /// its own sources. The source's quality is a running mean whose window
1837    /// is capped at [`EXTERNAL_GRADE_EVIDENCE_CAP`] cases; how much routing
1838    /// weight it carries is decided when pooling, see
1839    /// [`TaskStats::external_evidence_at`]. A reporter's new source past
1840    /// [`MAX_EXTERNAL_GRADE_SOURCES`] evicts that reporter's stalest source if
1841    /// it has gone stale, and is refused otherwise. Served counters, the
1842    /// served quality EMA and the ledger are untouched. Returns the source's
1843    /// state after the report.
1844    pub fn record_external_grade_as(
1845        &mut self,
1846        reporter: Option<&str>,
1847        model_id: &str,
1848        task: InferenceTask,
1849        quality: f64,
1850        n: u32,
1851        source: &str,
1852    ) -> Result<ExternalGradeStats, ExternalGradeRefusal> {
1853        self.record_external_grade_at(reporter, model_id, task, quality, n, source, now_unix())
1854    }
1855
1856    /// Clock-injectable core of [`Self::record_external_grade_as`].
1857    #[allow(clippy::too_many_arguments)]
1858    fn record_external_grade_at(
1859        &mut self,
1860        reporter: Option<&str>,
1861        model_id: &str,
1862        task: InferenceTask,
1863        quality: f64,
1864        n: u32,
1865        source: &str,
1866        now: u64,
1867    ) -> Result<ExternalGradeStats, ExternalGradeRefusal> {
1868        if model_id.trim().is_empty() || model_id.len() > 256 {
1869            return Err(ExternalGradeRefusal::InvalidModel);
1870        }
1871        if !quality.is_finite() || !(0.0..=1.0).contains(&quality) {
1872            return Err(ExternalGradeRefusal::InvalidQuality);
1873        }
1874        if n == 0 || n > MAX_EXTERNAL_GRADE_CASES {
1875            return Err(ExternalGradeRefusal::InvalidCases);
1876        }
1877        if source.is_empty()
1878            || source.len() > MAX_EXTERNAL_GRADE_SOURCE_LEN
1879            || !source
1880                .bytes()
1881                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'))
1882        {
1883            return Err(ExternalGradeRefusal::InvalidSource);
1884        }
1885        if let Some(reporter) = reporter {
1886            if reporter.is_empty()
1887                || reporter.len() > MAX_EXTERNAL_GRADE_REPORTER_LEN
1888                || !reporter.bytes().all(|b| b.is_ascii_graphic() && b != b'/')
1889            {
1890                return Err(ExternalGradeRefusal::InvalidReporter);
1891            }
1892        }
1893        let key = external_grade_key(reporter, source);
1894
1895        // The reporter's own sources other than this one: at the limit, make
1896        // room by evicting the stalest if it has gone stale, else refuse.
1897        let mut evict = None;
1898        if let Some(sources) = self
1899            .profiles
1900            .get(model_id)
1901            .and_then(|p| p.task_stats.get(&task.to_string()))
1902            .map(|ts| &ts.external_grades)
1903        {
1904            if !sources.contains_key(&key) {
1905                let own: Vec<(&String, &ExternalGradeStats)> = sources
1906                    .iter()
1907                    .filter(|(k, _)| external_grade_reporter(k) == reporter)
1908                    .collect();
1909                if own.len() >= MAX_EXTERNAL_GRADE_SOURCES {
1910                    match own.into_iter().min_by_key(|(_, g)| g.updated_at) {
1911                        Some((stalest, g)) if g.is_stale_at(now) => {
1912                            evict = Some(stalest.clone());
1913                        }
1914                        _ => return Err(ExternalGradeRefusal::SourceLimitReached),
1915                    }
1916                }
1917            }
1918        }
1919
1920        let profile = self
1921            .profiles
1922            .entry(model_id.to_string())
1923            .or_insert_with(|| ModelProfile::new(model_id.to_string()));
1924        let ts = profile.task_stats.entry(task.to_string()).or_default();
1925        if let Some(stalest) = evict {
1926            ts.external_grades.remove(&stalest);
1927        }
1928        let fresh = ExternalGradeStats {
1929            quality,
1930            observations: 0,
1931            updated_at: now,
1932        };
1933        let grade = ts.external_grades.entry(key).or_insert(fresh.clone());
1934        if grade.is_stale_at(now) {
1935            // A stale source starts over: its old verdict no longer counts.
1936            *grade = fresh;
1937        }
1938        let n = u64::from(n);
1939        let observations = grade.observations.saturating_add(n);
1940        let window = observations.min(EXTERNAL_GRADE_EVIDENCE_CAP);
1941        let weight = n.min(window) as f64 / window as f64;
1942        grade.quality = (grade.quality * (1.0 - weight) + quality * weight).clamp(0.0, 1.0);
1943        grade.observations = observations;
1944        grade.updated_at = now;
1945        let stats = grade.clone();
1946        profile.updated_at = now;
1947        self.dirty = true;
1948        Ok(stats)
1949    }
1950
1951    /// Infer outcomes from a sequence of action results.
1952    ///
1953    /// In a reasoning session, each action's output feeds the next. If action N
1954    /// produced output and action N+1 succeeded using it, N was implicitly accepted.
1955    /// If N produced empty output or N+1 failed, N was implicitly rejected.
1956    ///
1957    /// Returns (trace_id, inferred_outcome) pairs ready for `resolve_pending_from_signals`.
1958    pub fn infer_outcomes_from_action_sequence(
1959        &self,
1960        action_results: &[(String, bool, f64, String)], // (trace_id, success, confidence, output)
1961    ) -> Vec<(String, InferredOutcome)> {
1962        let mut outcomes = Vec::new();
1963
1964        for (i, (trace_id, success, confidence, output)) in action_results.iter().enumerate() {
1965            if trace_id.is_empty() {
1966                continue; // No trace (e.g., memgine-only action)
1967            }
1968
1969            if !success {
1970                outcomes.push((
1971                    trace_id.clone(),
1972                    InferredOutcome::Rejected {
1973                        confidence: *confidence,
1974                    },
1975                ));
1976                continue;
1977            }
1978
1979            // Check if the next action used this one's output (implicit acceptance)
1980            let next_succeeded = action_results
1981                .get(i + 1)
1982                .map(|(_, s, _, _)| *s)
1983                .unwrap_or(true); // Last action: assume accepted if successful
1984
1985            let has_output = !output.trim().is_empty();
1986
1987            if has_output && next_succeeded {
1988                outcomes.push((
1989                    trace_id.clone(),
1990                    InferredOutcome::Accepted {
1991                        confidence: *confidence,
1992                    },
1993                ));
1994            } else if has_output && !next_succeeded {
1995                // Output existed but downstream failed — may not be this action's fault
1996                outcomes.push((
1997                    trace_id.clone(),
1998                    InferredOutcome::AcceptedWithEdits {
1999                        confidence: confidence * 0.7,
2000                    },
2001                ));
2002            } else {
2003                outcomes.push((trace_id.clone(), InferredOutcome::Inconclusive));
2004            }
2005        }
2006
2007        outcomes
2008    }
2009
2010    /// Get the profile for a model.
2011    pub fn profile(&self, model_id: &str) -> Option<&ModelProfile> {
2012        self.profiles.get(model_id)
2013    }
2014
2015    /// Whether a trace is still awaiting an outcome (in `pending`). Lets a
2016    /// caller observe, before calling `record_inferred_outcome`, whether the
2017    /// resolution will actually land or silently no-op (trace already resolved
2018    /// or swept) — so a lost outcome signal is countable, not invisible.
2019    pub fn has_pending(&self, trace_id: &str) -> bool {
2020        self.pending.contains_key(trace_id)
2021    }
2022
2023    /// Get all profiles.
2024    pub fn all_profiles(&self) -> &HashMap<String, ModelProfile> {
2025        &self.profiles
2026    }
2027
2028    /// Get pending trace IDs (for conversation signal analysis).
2029    pub fn pending_trace_ids(&self) -> Vec<String> {
2030        self.pending.keys().cloned().collect()
2031    }
2032
2033    /// Get a pending outcome by trace_id.
2034    pub fn get_pending(&self, trace_id: &str) -> Option<&InferenceOutcome> {
2035        self.pending.get(trace_id)
2036    }
2037
2038    /// Export profiles for serialization / persistence. Derived metrics
2039    /// (quality_per_1k_tokens) are recomputed on the way out so callers
2040    /// always see a consistent snapshot.
2041    pub fn export_profiles(&self) -> Vec<ModelProfile> {
2042        self.profiles
2043            .values()
2044            .cloned()
2045            .map(|mut p| {
2046                p.quality_per_1k_tokens = p.compute_quality_per_1k_tokens();
2047                p
2048            })
2049            .collect()
2050    }
2051
2052    /// Import profiles as a genuine mutation (benchmark priors, CLI import
2053    /// from the memgine fact graph, router merges). Marks the tracker
2054    /// dirty so the new profiles actually reach disk on the next flush.
2055    /// NOTE: hydration from disk does NOT go through here — see
2056    /// [`load_from_file`](crate::outcome::OutcomeTracker::load_from_file), which inserts directly and stays clean (loading
2057    /// is not a change).
2058    pub fn import_profiles(&mut self, profiles: Vec<ModelProfile>) {
2059        for p in profiles {
2060            self.profiles.insert(p.model_id.clone(), p);
2061        }
2062        self.dirty = true;
2063    }
2064
2065    /// Save profiles to a JSON file for cross-session persistence (#13).
2066    ///
2067    /// Atomic: serialize to a sibling temp file, then rename over the
2068    /// target. This is the durable receipt store — a torn `write()`
2069    /// (crash mid-write) would corrupt it, and `load_from_file` treats a
2070    /// parse failure as a hard error, which on next boot loses *all*
2071    /// history. The temp+rename makes a partial write impossible.
2072    pub fn save_to_file(&self, path: &std::path::Path) -> Result<(), std::io::Error> {
2073        let profiles = self.export_profiles();
2074        let json = serde_json::to_string_pretty(&profiles).map_err(std::io::Error::other)?;
2075        if let Some(parent) = path.parent() {
2076            std::fs::create_dir_all(parent)?;
2077        }
2078        let tmp = path.with_extension("json.tmp");
2079        std::fs::write(&tmp, json)?;
2080        std::fs::rename(&tmp, path)
2081    }
2082
2083    /// True if a persisted profile has changed since the last save.
2084    pub fn is_dirty(&self) -> bool {
2085        self.dirty
2086    }
2087
2088    /// Save only if dirty, clearing the flag on success. Returns whether
2089    /// a write happened. Lets callers persist cheaply on a timer without
2090    /// rewriting the whole file when nothing changed.
2091    pub fn save_if_dirty(&mut self, path: &std::path::Path) -> Result<bool, std::io::Error> {
2092        if !self.dirty {
2093            return Ok(false);
2094        }
2095        self.save_to_file(path)?;
2096        self.dirty = false;
2097        Ok(true)
2098    }
2099
2100    /// Load profiles from a JSON file for cross-session persistence (#13).
2101    pub fn load_from_file(&mut self, path: &std::path::Path) -> Result<usize, std::io::Error> {
2102        if !path.exists() {
2103            return Ok(0);
2104        }
2105        let json = std::fs::read_to_string(path)?;
2106        let profiles: Vec<ModelProfile> = serde_json::from_str(&json)
2107            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
2108        let count = profiles.len();
2109        // Insert directly (NOT via import_profiles): hydration from disk is
2110        // not a change, so it must not flip the dirty flag — otherwise the
2111        // first idle flush would needlessly rewrite the file we just read.
2112        for mut p in profiles {
2113            migrate_quality_evidence(&mut p);
2114            self.profiles.insert(p.model_id.clone(), p);
2115        }
2116        Ok(count)
2117    }
2118
2119    /// Apply a quality signal to the model's profile.
2120    fn apply_outcome(
2121        &mut self,
2122        pending: &InferenceOutcome,
2123        quality: Option<f64>,
2124        success: Option<bool>,
2125    ) {
2126        let profile = self
2127            .profiles
2128            .entry(pending.model_id.clone())
2129            .or_insert_with(|| ModelProfile::new(pending.model_id.clone()));
2130
2131        if let Some(q) = quality {
2132            profile.ema_quality = profile.ema_quality * (1.0 - EMA_ALPHA) + q * EMA_ALPHA;
2133            profile.quality_observations += 1;
2134
2135            let task_key = pending.task.to_string();
2136            let ts = profile.task_stats.entry(task_key).or_default();
2137            ts.ema_quality = ts.ema_quality * (1.0 - EMA_ALPHA) + q * EMA_ALPHA;
2138            ts.quality_observations += 1;
2139        }
2140
2141        if let Some(ok) = success {
2142            // The call may have already been credited a mechanical success at
2143            // completion (record_complete). Don't double-count it here.
2144            let already_credited = pending.success_credited;
2145            let task_key = pending.task.to_string();
2146            if ok {
2147                if !already_credited {
2148                    profile.success_count += 1;
2149                    let ts = profile.task_stats.entry(task_key).or_default();
2150                    ts.successes += 1;
2151                }
2152                // else: already counted; the quality EMA above is the new info.
2153            } else {
2154                // A real downstream signal says this call was bad. If we
2155                // mechanically credited it as a success at completion,
2156                // reclassify: undo the success and book a failure instead.
2157                if already_credited {
2158                    profile.success_count = profile.success_count.saturating_sub(1);
2159                    let ts = profile.task_stats.entry(task_key.clone()).or_default();
2160                    ts.successes = ts.successes.saturating_sub(1);
2161                }
2162                profile.fail_count += 1;
2163                let ts = profile.task_stats.entry(task_key).or_default();
2164                ts.failures += 1;
2165            }
2166        }
2167
2168        profile.updated_at = now_unix();
2169        self.dirty = true;
2170
2171        self.push_ledger(OutcomeLedgerEntry {
2172            trace_id: pending.trace_id.clone(),
2173            model_id: pending.model_id.clone(),
2174            task: pending.task,
2175            routing_reason: pending.routing_reason.clone(),
2176            latency_ms: pending.latency_ms,
2177            input_tokens: pending.input_tokens,
2178            output_tokens: pending.output_tokens,
2179            cache_read_input_tokens: pending.cache_read_input_tokens,
2180            cache_creation_input_tokens: pending.cache_creation_input_tokens,
2181            success,
2182            quality,
2183            error: None,
2184            project_id: None,
2185            intent: None,
2186            attribution: None,
2187            car_version: Some(env!("CARGO_PKG_VERSION").to_string()),
2188            timestamp: now_unix(),
2189        });
2190    }
2191
2192    /// Evict `pending` entries older than `ttl_secs` so the map can't grow
2193    /// without bound in a long-running daemon (only a few resolution paths
2194    /// ever drain it). For each evicted entry that actually *completed*
2195    /// (latency recorded), emit a terminal `Inconclusive` receipt — this
2196    /// also de-biases the ledger: without it, the ledger would only ever
2197    /// contain calls that happened to get a follow-up signal (a
2198    /// systematic skew toward reasoning-session traffic), and downstream
2199    /// stats would be computed over a non-representative sample. Returns
2200    /// the number of entries swept.
2201    pub fn sweep_pending(&mut self, ttl_secs: u64) -> usize {
2202        self.sweep_pending_at(ttl_secs, now_unix())
2203    }
2204
2205    /// Clock-injectable core of [`sweep_pending`] for deterministic tests.
2206    ///
2207    /// A call that ended is swept `ttl_secs` after it **ended**, so the grace
2208    /// period a grader has is the same however long the call ran; before
2209    /// car-releases#110 it counted from the start, and a call longer than the
2210    /// TTL could never be graded. A call still in flight is swept only after
2211    /// [`PENDING_IN_FLIGHT_TTL_SECS`] from its start (or `ttl_secs`, if
2212    /// longer): the bound that keeps a crashed call from living in `pending`
2213    /// forever, set well past any real generation so a long call is not
2214    /// evicted before its completion can land.
2215    fn sweep_pending_at(&mut self, ttl_secs: u64, now: u64) -> usize {
2216        let ended_cutoff = now.saturating_sub(ttl_secs);
2217        let in_flight_cutoff = now.saturating_sub(ttl_secs.max(PENDING_IN_FLIGHT_TTL_SECS));
2218        let expired: Vec<String> = self
2219            .pending
2220            .iter()
2221            .filter(|(_, o)| match o.completed_at {
2222                Some(ended) => ended < ended_cutoff,
2223                None => o.timestamp < in_flight_cutoff,
2224            })
2225            .map(|(id, _)| id.clone())
2226            .collect();
2227        for id in &expired {
2228            if let Some(o) = self.pending.remove(id) {
2229                // Only completed-but-unresolved calls become receipts; a
2230                // never-completed trace (latency 0) is in-flight/crashed —
2231                // a zero-everything receipt would be noise.
2232                if o.latency_ms > 0 {
2233                    // A swept call completed but never received a downstream
2234                    // quality signal. Recording *every* such call Inconclusive
2235                    // (`success: None`) left signal-less models — notably ALL
2236                    // local inference, which has no accept/edit feedback loop —
2237                    // pinned at the 0.5 EMA prior with zero successes, so the
2238                    // health UI rendered working models as "50%"/"0%" (#312).
2239                    // Instead, credit a *mechanical* success when the call
2240                    // actually returned output with no error: it ran and
2241                    // produced tokens; we simply never learned whether the
2242                    // answer was *good*. `quality` stays None so `ema_quality`
2243                    // is only ever moved by a real quality signal. A completion
2244                    // that produced no output stays Inconclusive (ambiguous).
2245                    // Credit a mechanical success here ONLY if it wasn't
2246                    // already booked at completion (record_complete now does
2247                    // this immediately for the common case). A call credited
2248                    // at completion still gets a `Some(true)` receipt below,
2249                    // but its count is not incremented twice.
2250                    let credit_now =
2251                        o.output_tokens > 0 && o.error.is_none() && !o.success_credited;
2252                    let was_success = o.success_credited || credit_now;
2253                    if credit_now {
2254                        let profile = self
2255                            .profiles
2256                            .entry(o.model_id.clone())
2257                            .or_insert_with(|| ModelProfile::new(o.model_id.clone()));
2258                        profile.success_count += 1;
2259                        let ts = profile.task_stats.entry(o.task.to_string()).or_default();
2260                        ts.successes += 1;
2261                        profile.updated_at = now_unix();
2262                        self.dirty = true;
2263                    }
2264                    self.push_ledger(OutcomeLedgerEntry {
2265                        trace_id: o.trace_id,
2266                        model_id: o.model_id,
2267                        task: o.task,
2268                        routing_reason: o.routing_reason,
2269                        latency_ms: o.latency_ms,
2270                        input_tokens: o.input_tokens,
2271                        output_tokens: o.output_tokens,
2272                        cache_read_input_tokens: o.cache_read_input_tokens,
2273                        cache_creation_input_tokens: o.cache_creation_input_tokens,
2274                        success: if was_success { Some(true) } else { None },
2275                        quality: None,
2276                        error: None,
2277                        project_id: None,
2278                        intent: None,
2279                        attribution: None,
2280                        car_version: Some(env!("CARGO_PKG_VERSION").to_string()),
2281                        timestamp: now_unix(),
2282                    });
2283                }
2284            }
2285        }
2286        expired.len()
2287    }
2288
2289    /// Check git diff for pending code suggestions and resolve outcomes.
2290    ///
2291    /// Two strategies:
2292    /// 1. **AST structural diff** (when `ast` feature is enabled): parse the old
2293    ///    and new versions of changed files and compare at the symbol level.
2294    ///    This gives precise outcomes: SignatureChanged, BodyModified, SymbolAdded.
2295    /// 2. **Text diff fallback**: token matching against the combined git diff.
2296    pub fn check_git_outcomes(&mut self, repo_dir: &std::path::Path) {
2297        let diff = match std::process::Command::new("git")
2298            .args(["diff", "--no-color"])
2299            .current_dir(repo_dir)
2300            .output()
2301        {
2302            Ok(output) => String::from_utf8_lossy(&output.stdout).to_string(),
2303            Err(_) => return,
2304        };
2305
2306        let staged_diff = match std::process::Command::new("git")
2307            .args(["diff", "--cached", "--no-color"])
2308            .current_dir(repo_dir)
2309            .output()
2310        {
2311            Ok(output) => String::from_utf8_lossy(&output.stdout).to_string(),
2312            Err(_) => String::new(),
2313        };
2314
2315        let combined_diff = format!("{}\n{}", diff, staged_diff);
2316
2317        if combined_diff.trim().is_empty() {
2318            return; // No changes at all
2319        }
2320
2321        // Try AST structural diff on changed files
2322        #[cfg(feature = "ast")]
2323        let ast_outcome = Self::check_git_outcomes_ast(repo_dir);
2324
2325        let code_traces: Vec<(String, String)> = self
2326            .pending
2327            .iter()
2328            .filter(|(_, o)| matches!(o.task, InferenceTask::Code))
2329            .map(|(id, o)| (id.clone(), o.model_id.clone()))
2330            .collect();
2331
2332        for (trace_id, _model_id) in code_traces {
2333            if let Some(pending) = self.pending.get(&trace_id) {
2334                // Try AST-based outcome first
2335                #[cfg(feature = "ast")]
2336                if let Some(ref ast_out) = ast_outcome {
2337                    let pending_clone = pending.clone();
2338                    self.apply_outcome(
2339                        &pending_clone,
2340                        Some(ast_out.quality_score()),
2341                        Some(ast_out.is_success()),
2342                    );
2343                    continue;
2344                }
2345
2346                // Fallback: text token matching
2347                let output_tokens: Vec<&str> = pending
2348                    .routing_reason
2349                    .split_whitespace()
2350                    .filter(|t| t.len() > 5)
2351                    .collect();
2352
2353                let outcome = if output_tokens.iter().any(|t| combined_diff.contains(t)) {
2354                    CodeOutcome::Applied
2355                } else {
2356                    CodeOutcome::Modified
2357                };
2358
2359                let pending_clone = pending.clone();
2360                self.apply_outcome(
2361                    &pending_clone,
2362                    Some(outcome.quality_score()),
2363                    Some(outcome.is_success()),
2364                );
2365            }
2366        }
2367    }
2368
2369    /// AST-based git outcome: parse changed files before and after, diff symbols.
2370    #[cfg(feature = "ast")]
2371    fn check_git_outcomes_ast(repo_dir: &std::path::Path) -> Option<CodeOutcome> {
2372        // Get list of changed files
2373        let name_only = std::process::Command::new("git")
2374            .args(["diff", "--name-only"])
2375            .current_dir(repo_dir)
2376            .output()
2377            .ok()?;
2378        let changed_files: Vec<&str> = std::str::from_utf8(&name_only.stdout)
2379            .ok()?
2380            .lines()
2381            .filter(|f| !f.is_empty())
2382            .collect();
2383
2384        if changed_files.is_empty() {
2385            return None;
2386        }
2387
2388        let mut has_sig_change = false;
2389        let mut has_body_change = false;
2390        let mut has_addition = false;
2391
2392        for file in &changed_files {
2393            // Only parse files tree-sitter supports
2394            if car_ast::Language::from_filename(file).is_none() {
2395                continue;
2396            }
2397
2398            // Get the HEAD version
2399            let old_content = std::process::Command::new("git")
2400                .args(["show", &format!("HEAD:{}", file)])
2401                .current_dir(repo_dir)
2402                .output()
2403                .ok()
2404                .and_then(|o| {
2405                    if o.status.success() {
2406                        String::from_utf8(o.stdout).ok()
2407                    } else {
2408                        None
2409                    }
2410                });
2411
2412            // Get the working tree version
2413            let new_path = repo_dir.join(file);
2414            let new_content = std::fs::read_to_string(&new_path).ok();
2415
2416            match (old_content, new_content) {
2417                (Some(old), Some(new)) => {
2418                    let old_parsed = car_ast::parse_file(&old, file);
2419                    let new_parsed = car_ast::parse_file(&new, file);
2420
2421                    if let (Some(old_p), Some(new_p)) = (old_parsed, new_parsed) {
2422                        let changes = car_ast::diff_symbols(&old_p, &new_p);
2423                        for change in &changes {
2424                            match change {
2425                                car_ast::SymbolChange::Added(_) => has_addition = true,
2426                                car_ast::SymbolChange::Modified {
2427                                    signature_changed, ..
2428                                } => {
2429                                    if *signature_changed {
2430                                        has_sig_change = true;
2431                                    } else {
2432                                        has_body_change = true;
2433                                    }
2434                                }
2435                                car_ast::SymbolChange::Removed(_) => has_sig_change = true,
2436                            }
2437                        }
2438                    }
2439                }
2440                (None, Some(_)) => has_addition = true, // New file
2441                _ => {}
2442            }
2443        }
2444
2445        // Return the most significant outcome
2446        if has_sig_change {
2447            Some(CodeOutcome::SignatureChanged)
2448        } else if has_body_change {
2449            Some(CodeOutcome::BodyModified)
2450        } else if has_addition {
2451            Some(CodeOutcome::SymbolAdded)
2452        } else {
2453            None // No structural changes detected (maybe non-code files changed)
2454        }
2455    }
2456}
2457
2458impl Default for OutcomeTracker {
2459    fn default() -> Self {
2460        Self::new()
2461    }
2462}
2463
2464pub(crate) fn now_unix() -> u64 {
2465    SystemTime::now()
2466        .duration_since(UNIX_EPOCH)
2467        .unwrap_or_default()
2468        .as_secs()
2469}
2470
2471#[cfg(test)]
2472mod quality_evidence_tests {
2473    use super::*;
2474
2475    /// A generation failure is availability evidence only: it is counted, and
2476    /// answer quality stays where it was.
2477    #[test]
2478    fn a_failure_counts_against_availability_not_quality() {
2479        let mut tracker = OutcomeTracker::new();
2480        let trace = tracker.record_start("m", InferenceTask::Code, "write a parser");
2481        tracker.record_failure(&trace, "model returned malformed tool call JSON");
2482        let p = tracker.profile("m").unwrap();
2483        assert_eq!(p.fail_count, 1);
2484        assert_eq!(
2485            (p.ema_quality, p.quality_observations),
2486            (NEUTRAL_QUALITY, 0)
2487        );
2488        let ts = p.task_stats(InferenceTask::Code).unwrap();
2489        assert_eq!(ts.failures, 1);
2490        assert_eq!(
2491            (ts.ema_quality, ts.quality_observations),
2492            (NEUTRAL_QUALITY, 0)
2493        );
2494    }
2495
2496    /// A task's first grade moves quality from neutral, as the profile's does.
2497    #[test]
2498    fn a_new_task_starts_at_neutral_quality() {
2499        assert_eq!(TaskStats::default().ema_quality, NEUTRAL_QUALITY);
2500    }
2501
2502    /// A profile as today's code writes it (no `quality_evidence_version`)
2503    /// after a run of graded failures: its quality evidence is dropped on load,
2504    /// its availability counters are kept.
2505    #[test]
2506    fn a_version_zero_profile_loads_without_its_failure_derived_quality() {
2507        let mut tracker = OutcomeTracker::new();
2508        for _ in 0..5 {
2509            let trace = tracker.record_start("m", InferenceTask::Code, "x");
2510            tracker.record_failure(&trace, "generation failed");
2511        }
2512        let mut saved = serde_json::to_value(tracker.profile("m").unwrap()).unwrap();
2513        // What a version-0 writer stored after those five failures.
2514        saved["ema_quality"] = serde_json::json!(0.12);
2515        saved["quality_observations"] = serde_json::json!(5);
2516        saved["task_stats"]["code"]["ema_quality"] = serde_json::json!(0.07);
2517        saved["task_stats"]["code"]["quality_observations"] = serde_json::json!(5);
2518        saved
2519            .as_object_mut()
2520            .unwrap()
2521            .remove("quality_evidence_version");
2522
2523        let dir = tempfile::tempdir().unwrap();
2524        let path = dir.path().join("outcome_profiles.json");
2525        std::fs::write(&path, serde_json::to_string(&vec![saved]).unwrap()).unwrap();
2526        let mut loaded = OutcomeTracker::new();
2527        loaded.load_from_file(&path).unwrap();
2528
2529        let p = loaded.profile("m").unwrap();
2530        assert_eq!(p.fail_count, 5, "availability survives");
2531        assert_eq!(
2532            (p.ema_quality, p.quality_observations),
2533            (NEUTRAL_QUALITY, 0)
2534        );
2535        let ts = p.task_stats(InferenceTask::Code).unwrap();
2536        assert_eq!(ts.failures, 5);
2537        assert_eq!(
2538            (ts.ema_quality, ts.quality_observations),
2539            (NEUTRAL_QUALITY, 0)
2540        );
2541        assert_eq!(p.quality_evidence_version, QUALITY_EVIDENCE_VERSION);
2542    }
2543}
2544
2545#[cfg(test)]
2546mod tests {
2547    /// A worker's call teaches the router nothing: inside
2548    /// `scope_learning(false, ..)` its start, completion and failure change no
2549    /// model profile. The same calls outside the scope do.
2550    #[tokio::test]
2551    async fn an_unlearned_call_changes_no_profile() {
2552        let mut tracker = OutcomeTracker::new();
2553        super::scope_learning(false, async {
2554            let trace = tracker.record_start("m", InferenceTask::Generate, "pinned");
2555            tracker.record_complete(&trace, 10, 5, 5);
2556            let failed = tracker.record_start("m", InferenceTask::Generate, "pinned");
2557            tracker.record_failure(&failed, "boom");
2558        })
2559        .await;
2560        assert!(tracker.profile("m").is_none());
2561        let trace = tracker.record_start("m", InferenceTask::Generate, "pinned");
2562        tracker.record_complete(&trace, 10, 5, 5);
2563        assert_eq!(
2564            tracker.profile("m").map(|profile| profile.total_calls),
2565            Some(1)
2566        );
2567    }
2568
2569    use super::*;
2570
2571    #[test]
2572    fn only_a_failure_this_car_recorded_counts_against_a_model() {
2573        let entry = |success, version: Option<&str>| OutcomeLedgerEntry {
2574            trace_id: "t".into(),
2575            model_id: "m".into(),
2576            task: InferenceTask::Generate,
2577            routing_reason: String::new(),
2578            latency_ms: 0,
2579            input_tokens: 0,
2580            output_tokens: 0,
2581            cache_read_input_tokens: 0,
2582            cache_creation_input_tokens: 0,
2583            success: Some(success),
2584            quality: None,
2585            error: Some("config.json has no vocab_size".into()),
2586            project_id: None,
2587            intent: None,
2588            attribution: None,
2589            car_version: version.map(str::to_string),
2590            timestamp: 1,
2591        };
2592        let here = env!("CARGO_PKG_VERSION");
2593        assert!(entry(false, Some(here)).counts_against_model());
2594        // An older build's failure may be that build's bug.
2595        assert!(!entry(false, Some("0.0.1")).counts_against_model());
2596        assert!(!entry(false, None).counts_against_model());
2597        assert!(!entry(true, Some(here)).counts_against_model());
2598    }
2599
2600    #[test]
2601    fn lifecycle() {
2602        let mut tracker = OutcomeTracker::new();
2603
2604        // Start an inference call
2605        let trace = tracker.record_start(
2606            "qwen/qwen3-4b:q4_k_m",
2607            InferenceTask::Code,
2608            "Code task -> Qwen3-4B",
2609        );
2610
2611        // Complete it
2612        tracker.record_complete(&trace, 1200, 100, 50);
2613
2614        // Profile should have 1 call
2615        let profile = tracker.profile("qwen/qwen3-4b:q4_k_m").unwrap();
2616        assert_eq!(profile.total_calls, 1);
2617        assert_eq!(profile.avg_latency_ms(), 1200.0);
2618
2619        // Record positive outcome
2620        tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 0.9 });
2621
2622        let profile = tracker.profile("qwen/qwen3-4b:q4_k_m").unwrap();
2623        assert_eq!(profile.success_count, 1);
2624        assert!(profile.ema_quality > 0.5); // should have gone up from 0.5
2625    }
2626
2627    #[test]
2628    fn failure_degrades() {
2629        // A failed call goes through record_failure ONLY — it never also
2630        // hits record_complete (a call either completes or errors, not both).
2631        // record_complete now credits a mechanical success, so mixing the two
2632        // on one trace would model a flow that does not occur.
2633        let mut tracker = OutcomeTracker::new();
2634        for _ in 0..5 {
2635            let trace = tracker.record_start("bad-model", InferenceTask::Generate, "test");
2636            // A *generation* failure (not a transport/timeout error, which is
2637            // quality-neutral — see `transport_failures_are_quality_neutral`).
2638            tracker.record_failure(&trace, "model produced malformed output");
2639        }
2640
2641        let profile = tracker.profile("bad-model").unwrap();
2642        assert_eq!(profile.fail_count, 5);
2643        assert_eq!(profile.success_count, 0);
2644        assert!(profile.should_degrade(2)); // 5 > 0 + 2
2645                                            // Availability degrades; answer quality is not graded by a failure
2646                                            // (QUALITY_EVIDENCE_VERSION).
2647        assert_eq!(profile.ema_quality, NEUTRAL_QUALITY);
2648    }
2649
2650    #[test]
2651    fn code_outcome_ground_truth() {
2652        let mut tracker = OutcomeTracker::new();
2653
2654        let trace = tracker.record_start("qwen/qwen3-4b:q4_k_m", InferenceTask::Code, "code");
2655        tracker.record_complete(&trace, 500, 200, 100);
2656        tracker.record_code_outcome(&trace, CodeOutcome::Applied);
2657
2658        let profile = tracker.profile("qwen/qwen3-4b:q4_k_m").unwrap();
2659        assert_eq!(profile.success_count, 1);
2660        // EMA should reflect Applied quality (1.0): 0.5 * 0.8 + 1.0 * 0.2 = 0.6
2661        assert!((profile.ema_quality - 0.6).abs() < 0.01);
2662    }
2663
2664    #[test]
2665    fn per_task_stats() {
2666        let mut tracker = OutcomeTracker::new();
2667
2668        // Two code calls, one generate call
2669        for _ in 0..2 {
2670            let trace = tracker.record_start("m1", InferenceTask::Code, "code");
2671            tracker.record_complete(&trace, 1000, 100, 50);
2672            tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 0.8 });
2673        }
2674        let trace = tracker.record_start("m1", InferenceTask::Generate, "gen");
2675        tracker.record_complete(&trace, 500, 50, 25);
2676        tracker.record_inferred_outcome(&trace, InferredOutcome::Rejected { confidence: 0.9 });
2677
2678        let profile = tracker.profile("m1").unwrap();
2679        assert_eq!(profile.total_calls, 3);
2680
2681        let code_stats = profile.task_stats(InferenceTask::Code).unwrap();
2682        assert_eq!(code_stats.calls, 2);
2683        assert_eq!(code_stats.successes, 2);
2684
2685        let gen_stats = profile.task_stats(InferenceTask::Generate).unwrap();
2686        assert_eq!(gen_stats.calls, 1);
2687        assert_eq!(gen_stats.failures, 1);
2688    }
2689
2690    #[test]
2691    fn export_populates_quality_per_1k_tokens() {
2692        let mut tracker = OutcomeTracker::new();
2693        let trace = tracker.record_start("m1", InferenceTask::Generate, "test");
2694        tracker.record_complete(&trace, 100, 800, 200); // 1000 tokens total
2695        tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 1.0 });
2696
2697        let exported = tracker.export_profiles();
2698        assert_eq!(exported.len(), 1);
2699        let p = &exported[0];
2700        // ema_quality after one Accepted{1.0}: 0.5 * 0.8 + 1.0 * 0.2 = 0.6
2701        // quality_per_1k = 0.6 * 1000 / 1000 = 0.6
2702        assert!(
2703            (p.quality_per_1k_tokens - 0.6).abs() < 1e-6,
2704            "got {}",
2705            p.quality_per_1k_tokens
2706        );
2707    }
2708
2709    #[test]
2710    fn quality_per_1k_tokens_zero_without_tokens() {
2711        let profile = ModelProfile::new("x".into());
2712        assert_eq!(profile.compute_quality_per_1k_tokens(), 0.0);
2713    }
2714
2715    #[test]
2716    fn tokens_per_success_is_outcome_denominated() {
2717        let mut p = ModelProfile::new("x".into());
2718        // No success yet → None (not a fabricated number).
2719        assert_eq!(p.tokens_per_success(), None);
2720        // 900 total tokens over 3 successful outcomes → 300 tokens/success.
2721        p.total_input_tokens = 600;
2722        p.total_output_tokens = 300;
2723        p.success_count = 3;
2724        assert_eq!(p.tokens_per_success(), Some(300.0));
2725        // Dollar cost-per-success: (600*$1 + 300*$2)/1M / 3 successes.
2726        let usd = p.usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC).unwrap();
2727        assert!((usd - ((600.0 * 1.0 + 300.0 * 2.0) / 1_000_000.0 / 3.0)).abs() < 1e-12);
2728        assert_eq!(
2729            ModelProfile::new("y".into()).usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC),
2730            None
2731        );
2732    }
2733
2734    #[test]
2735    fn usd_per_success_prices_cache_buckets_separately() {
2736        let mut p = ModelProfile::new("cached".into());
2737        p.success_count = 1;
2738        p.total_input_tokens = 100; // uncached prefix @ 1.0×
2739        p.total_cache_read_input_tokens = 1000; // hit @ 0.1×
2740        p.total_cache_creation_input_tokens = 200; // write @ 1.25×
2741        p.total_output_tokens = 50; // output @ 2.0×
2742                                    // Each input bucket priced at its own multiplier off the $1/Mtok base.
2743        let expected_input = (100.0 * 1.0 + 1000.0 * 1.0 * 0.1 + 200.0 * 1.0 * 1.25) / 1_000_000.0;
2744        let expected = expected_input + 50.0 * 2.0 / 1_000_000.0;
2745        let usd = p.usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC).unwrap();
2746        assert!((usd - expected).abs() < 1e-12, "got {usd}, want {expected}");
2747        // Token efficiency counts all real tokens, cache included.
2748        assert_eq!(p.total_tokens(), 100 + 1000 + 200 + 50);
2749        // A cache hit must be ~10× cheaper than counting it as full input,
2750        // which is the whole point of fixing the accounting.
2751        let mut naive = ModelProfile::new("naive".into());
2752        naive.success_count = 1;
2753        naive.total_input_tokens = 1300; // same tokens, all priced at 1.0×
2754        naive.total_output_tokens = 50;
2755        assert!(
2756            naive
2757                .usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC)
2758                .unwrap()
2759                > usd
2760        );
2761    }
2762
2763    #[test]
2764    fn openai_cache_rates_price_reads_at_half_no_write_premium() {
2765        // OpenAI economics: cached reads at 0.5×, no write bucket. Same token
2766        // counts as Anthropic must cost MORE under OpenAI (0.5× vs 0.1× reads).
2767        let mut p = ModelProfile::new("gpt".into());
2768        p.success_count = 1;
2769        p.total_input_tokens = 100; // uncached @ 1.0×
2770        p.total_cache_read_input_tokens = 1000; // OpenAI hit @ 0.5×
2771        p.total_cache_creation_input_tokens = 0; // OpenAI has no write bucket
2772        let usd = p.usd_per_success(1.0, 2.0, CacheRates::OPENAI).unwrap();
2773        let expected = (100.0 * 1.0 + 1000.0 * 1.0 * 0.5) / 1_000_000.0;
2774        assert!((usd - expected).abs() < 1e-12, "got {usd}, want {expected}");
2775        // Same buckets under Anthropic rates (0.1× read) are cheaper.
2776        let anthropic = p.usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC).unwrap();
2777        assert!(
2778            usd > anthropic,
2779            "OpenAI 0.5× read must exceed Anthropic 0.1×"
2780        );
2781        // And both beat pricing the cache hit as full uncached input (1.0×).
2782        let mut naive = ModelProfile::new("naive".into());
2783        naive.success_count = 1;
2784        naive.total_input_tokens = 1100;
2785        assert!(naive.usd_per_success(1.0, 2.0, CacheRates::OPENAI).unwrap() > usd);
2786    }
2787
2788    #[test]
2789    fn record_complete_cached_accumulates_cache_totals() {
2790        let mut tracker = OutcomeTracker::new();
2791        let trace = tracker.record_start("m", InferenceTask::Generate, "gen");
2792        tracker.record_complete_cached(&trace, 100, 40, 20, 800, 120);
2793        let p = tracker.profile("m").unwrap();
2794        assert_eq!(p.total_input_tokens, 40, "uncached prefix only");
2795        assert_eq!(p.total_cache_read_input_tokens, 800);
2796        assert_eq!(p.total_cache_creation_input_tokens, 120);
2797    }
2798
2799    #[test]
2800    fn dirty_flag_and_save_if_dirty() {
2801        let dir = std::env::temp_dir().join("car-outcome-dirty-test");
2802        let _ = std::fs::remove_dir_all(&dir);
2803        let path = dir.join("outcome_profiles.json");
2804
2805        let mut tracker = OutcomeTracker::new();
2806        // Fresh tracker is clean → save_if_dirty is a no-op.
2807        assert!(!tracker.is_dirty());
2808        assert!(!tracker.save_if_dirty(&path).unwrap());
2809        assert!(!path.exists());
2810
2811        // A recorded outcome dirties it.
2812        let trace = tracker.record_start("m1", InferenceTask::Generate, "router");
2813        tracker.record_complete(&trace, 100, 10, 20);
2814        assert!(tracker.is_dirty());
2815
2816        // save_if_dirty writes once and clears the flag.
2817        assert!(tracker.save_if_dirty(&path).unwrap());
2818        assert!(path.exists());
2819        assert!(!tracker.is_dirty());
2820
2821        // Second call with no new changes does not rewrite.
2822        assert!(!tracker.save_if_dirty(&path).unwrap());
2823
2824        // Loading profiles must NOT mark the tracker dirty.
2825        let mut fresh = OutcomeTracker::new();
2826        fresh.load_from_file(&path).unwrap();
2827        assert!(!fresh.is_dirty());
2828
2829        // But a genuine import (benchmark priors / CLI) MUST dirty, so the
2830        // imported profiles actually persist on the next flush.
2831        fresh.import_profiles(vec![ModelProfile::new("seeded".into())]);
2832        assert!(fresh.is_dirty());
2833
2834        let _ = std::fs::remove_dir_all(&dir);
2835    }
2836
2837    #[test]
2838    fn ledger_captures_resolved_outcomes() {
2839        let dir = std::env::temp_dir().join("car-outcome-ledger-test");
2840        let _ = std::fs::remove_dir_all(&dir);
2841        let path = dir.join("outcome_ledger.jsonl");
2842
2843        let mut tracker = OutcomeTracker::new();
2844
2845        // A success with a quality signal → one resolved receipt.
2846        let t1 = tracker.record_start("good-model", InferenceTask::Generate, "router:test");
2847        tracker.record_complete(&t1, 1200, 50, 100);
2848        tracker.record_inferred_outcome(&t1, InferredOutcome::Accepted { confidence: 0.9 });
2849
2850        // A failure → one resolved receipt with the error.
2851        let t2 = tracker.record_start("bad-model", InferenceTask::Code, "router:test");
2852        tracker.record_failure(&t2, "boom: 500");
2853
2854        let drained = tracker.drain_ledger();
2855        assert_eq!(drained.len(), 2);
2856        assert!(tracker.drain_ledger().is_empty(), "drain clears the buffer");
2857
2858        append_ledger_entries(&path, &drained).unwrap();
2859        let read = read_ledger(&path, 0);
2860        assert_eq!(read.len(), 2);
2861
2862        let good = read.iter().find(|e| e.model_id == "good-model").unwrap();
2863        assert_eq!(good.success, Some(true));
2864        assert!(good.quality.is_some());
2865        assert_eq!(good.routing_reason, "router:test");
2866        assert_eq!(good.latency_ms, 1200);
2867
2868        let bad = read.iter().find(|e| e.model_id == "bad-model").unwrap();
2869        assert_eq!(bad.success, Some(false));
2870        assert_eq!(bad.error.as_deref(), Some("boom: 500"));
2871
2872        // Read back limited to most recent 1.
2873        assert_eq!(read_ledger(&path, 1).len(), 1);
2874
2875        let _ = std::fs::remove_dir_all(&dir);
2876    }
2877
2878    #[test]
2879    fn ledger_redacts_long_errors_and_prunes() {
2880        let dir = std::env::temp_dir().join("car-outcome-privacy-test");
2881        let _ = std::fs::remove_dir_all(&dir);
2882        let path = dir.join("outcome_ledger.jsonl");
2883
2884        // Long error is truncated on capture.
2885        let mut tracker = OutcomeTracker::new();
2886        let t = tracker.record_start("m", InferenceTask::Generate, "r");
2887        let huge = "x".repeat(5000);
2888        tracker.record_failure(&t, &huge);
2889        let drained = tracker.drain_ledger();
2890        let err = drained[0].error.as_ref().unwrap();
2891        assert!(
2892            err.chars().count() <= MAX_LEDGER_ERROR_CHARS + 1,
2893            "error truncated"
2894        );
2895
2896        // Pruning keeps only the most recent N.
2897        let entries: Vec<OutcomeLedgerEntry> = (0..10)
2898            .map(|i| OutcomeLedgerEntry {
2899                trace_id: format!("t{i}"),
2900                model_id: "m".into(),
2901                task: InferenceTask::Generate,
2902                routing_reason: "r".into(),
2903                latency_ms: 1,
2904                input_tokens: 1,
2905                output_tokens: 1,
2906                cache_read_input_tokens: 0,
2907                cache_creation_input_tokens: 0,
2908                success: Some(true),
2909                quality: Some(1.0),
2910                error: None,
2911                project_id: None,
2912                intent: None,
2913                attribution: None,
2914                car_version: None,
2915                timestamp: i,
2916            })
2917            .collect();
2918        append_ledger_entries(&path, &entries).unwrap();
2919        prune_ledger(&path, 3).unwrap();
2920        let kept = read_ledger(&path, 0);
2921        assert_eq!(kept.len(), 3);
2922        assert_eq!(kept[0].trace_id, "t7"); // most recent 3: t7,t8,t9
2923        assert_eq!(kept[2].trace_id, "t9");
2924
2925        let _ = std::fs::remove_dir_all(&dir);
2926    }
2927
2928    #[test]
2929    fn sweep_pending_credits_mechanical_success_or_inconclusive() {
2930        let mut tracker = OutcomeTracker::new();
2931
2932        // Completed with output, no signal -> mechanical success (#312).
2933        let t1 = tracker.record_start("m", InferenceTask::Generate, "r");
2934        tracker.record_complete(&t1, 500, 10, 20);
2935        // Completed but produced NO output -> stays Inconclusive.
2936        let t2 = tracker.record_start("m", InferenceTask::Generate, "r");
2937        tracker.record_complete(&t2, 300, 5, 0);
2938        // A never-completed call (in-flight / crashed) -> no receipt.
2939        let _t3 = tracker.record_start("m", InferenceTask::Generate, "r");
2940
2941        // Evaluate the sweep against a clock 10s in the future so the
2942        // same-second pending entries are unambiguously past the cutoff.
2943        let swept = tracker.sweep_pending_at(0, now_unix() + 10);
2944        assert_eq!(
2945            swept, 2,
2946            "both ended calls evicted; the in-flight one waits"
2947        );
2948        // The in-flight call is evicted only past its own, longer bound.
2949        let swept = tracker.sweep_pending_at(0, now_unix() + PENDING_IN_FLIGHT_TTL_SECS + 10);
2950        assert_eq!(swept, 1, "a call that never ended is evicted eventually");
2951
2952        // Only the two completed calls become receipts.
2953        let mut receipts = tracker.drain_ledger();
2954        receipts.sort_by_key(|r| r.latency_ms);
2955        assert_eq!(receipts.len(), 2);
2956        // 300ms, no output -> Inconclusive
2957        assert_eq!(receipts[0].latency_ms, 300);
2958        assert_eq!(receipts[0].success, None);
2959        // 500ms, produced output -> mechanical success, quality untouched
2960        assert_eq!(receipts[1].latency_ms, 500);
2961        assert_eq!(receipts[1].success, Some(true));
2962        assert_eq!(receipts[1].quality, None);
2963
2964        // success_count is credited; ema_quality stays the neutral prior
2965        // (no real quality signal ever arrived).
2966        let p = tracker.profile("m").expect("profile exists");
2967        assert_eq!(p.success_count, 1);
2968        assert_eq!(p.ema_quality, 0.5);
2969    }
2970
2971    #[test]
2972    fn record_complete_credits_success_immediately() {
2973        // The headline fix: a call that completes with output is credited a
2974        // success at completion, not deferred to the 300s sweep — so a
2975        // short-lived process (CLI) that exits before any sweep still records
2976        // the success. Previously success_count stayed 0 and health read 0.5.
2977        let mut tracker = OutcomeTracker::new();
2978        let t = tracker.record_start("m", InferenceTask::Generate, "r");
2979        tracker.record_complete(&t, 500, 12, 20);
2980
2981        let p = tracker.profile("m").expect("profile exists");
2982        assert_eq!(p.success_count, 1, "success credited at completion");
2983        assert_eq!(p.total_calls, 1);
2984        assert_eq!(p.total_input_tokens, 12, "input tokens recorded, not 0");
2985        assert_eq!(p.fail_count, 0);
2986
2987        // A later sweep of the same (still-pending) call must NOT double-count.
2988        tracker.sweep_pending_at(0, now_unix() + 10);
2989        let p = tracker.profile("m").unwrap();
2990        assert_eq!(p.success_count, 1, "sweep does not re-credit");
2991    }
2992
2993    #[test]
2994    fn success_rate_resolved_distinguishes_no_signal_from_measured() {
2995        // Display surfaces must not show a never-measured model as a confident
2996        // "50%": `success_rate_resolved()` returns None until something
2997        // resolves, then the real ratio. (The router never derives a rate
2998        // here — it reads raw success/fail counts directly.)
2999        let mut p = ModelProfile::new("m".to_string());
3000        assert_eq!(
3001            p.success_rate_resolved(),
3002            None,
3003            "no resolved signal, not a fabricated 0.5"
3004        );
3005
3006        p.success_count = 3;
3007        p.fail_count = 1;
3008        assert_eq!(
3009            p.success_rate_resolved(),
3010            Some(0.75),
3011            "real rate once resolved"
3012        );
3013    }
3014
3015    #[test]
3016    fn quality_observations_count_only_graded_signals() {
3017        // #3: a mechanical success must NOT count as a graded quality
3018        // observation (it leaves answer quality unknown), but an accept/reject
3019        // signal and a failure must. The router keys reliability on this count.
3020        let mut tracker = OutcomeTracker::new();
3021
3022        // Mechanical success — credited, but quality stays unknown.
3023        let t = tracker.record_start("m", InferenceTask::Generate, "r");
3024        tracker.record_complete(&t, 500, 12, 20);
3025        let p = tracker.profile("m").unwrap();
3026        assert_eq!(p.success_count, 1);
3027        assert_eq!(
3028            p.quality_observations, 0,
3029            "mechanical success is not a graded quality observation"
3030        );
3031        assert!(
3032            (p.ema_quality - 0.5).abs() < 1e-9,
3033            "EMA untouched by mechanical success"
3034        );
3035
3036        // A real accept signal — graded.
3037        tracker.record_inferred_outcome(&t, InferredOutcome::Accepted { confidence: 0.9 });
3038        let p = tracker.profile("m").unwrap();
3039        assert_eq!(p.quality_observations, 1, "graded accept signal counts");
3040        assert!(p.ema_quality > 0.5, "graded accept moved the EMA up");
3041        assert_eq!(
3042            p.task_stats(InferenceTask::Generate)
3043                .unwrap()
3044                .quality_observations,
3045            1,
3046            "per-task graded count tracked too"
3047        );
3048
3049        // A failure is availability evidence, not a grade
3050        // (QUALITY_EVIDENCE_VERSION).
3051        let t2 = tracker.record_start("m", InferenceTask::Generate, "r");
3052        tracker.record_failure(&t2, "boom");
3053        let p = tracker.profile("m").unwrap();
3054        assert_eq!(
3055            p.quality_observations, 1,
3056            "a failure is not a graded observation"
3057        );
3058    }
3059
3060    #[test]
3061    fn record_complete_no_output_is_not_a_success() {
3062        let mut tracker = OutcomeTracker::new();
3063        let t = tracker.record_start("m", InferenceTask::Generate, "r");
3064        tracker.record_complete(&t, 300, 5, 0); // no output
3065        let p = tracker.profile("m").unwrap();
3066        assert_eq!(p.success_count, 0, "no output -> no mechanical success");
3067        assert_eq!(p.total_calls, 1);
3068    }
3069
3070    #[test]
3071    fn record_failure_counts_total_calls() {
3072        // A failed call is still a call. total_calls must include it so it can
3073        // never read smaller than fail_count (the parslee/* 6-calls/14-fails
3074        // symptom).
3075        let mut tracker = OutcomeTracker::new();
3076        for _ in 0..3 {
3077            let t = tracker.record_start("m", InferenceTask::Generate, "r");
3078            tracker.record_failure(&t, "boom 500");
3079        }
3080        let p = tracker.profile("m").unwrap();
3081        assert_eq!(p.fail_count, 3);
3082        assert_eq!(p.total_calls, 3, "failures counted in total_calls");
3083        assert!(p.fail_count <= p.total_calls);
3084    }
3085
3086    #[test]
3087    fn capability_rejection_does_not_change_model_profile() {
3088        let mut tracker = OutcomeTracker::new();
3089        let trace = tracker.record_start("m", InferenceTask::Generate, "r");
3090
3091        tracker.record_capability_rejection(&trace, "unsupported mode: json schema");
3092
3093        assert!(tracker.profile("m").is_none());
3094        assert!(!tracker.has_pending(&trace));
3095        assert_eq!(tracker.ledger_buffer.len(), 1);
3096    }
3097
3098    #[test]
3099    fn transport_failures_are_quality_neutral() {
3100        // A read-timeout / connection error means the model never produced an
3101        // answer — its answer quality is UNKNOWN, so transport failures must not
3102        // move the quality EMA or count as graded quality evidence. (Observed
3103        // live: parslee/* collapsed to ema_quality ≈ 0.003 after ~23 daemon
3104        // read-timeouts, which buried it under every workload.) They DO still
3105        // count for availability (total_calls / fail_count / circuit breaker).
3106        let mut tracker = OutcomeTracker::new();
3107        // One genuine generation failure establishes the baseline EMA + a
3108        // graded quality observation.
3109        let t = tracker.record_start("m", InferenceTask::Code, "r");
3110        tracker.record_failure(&t, "model produced malformed output");
3111        let baseline_ema = tracker.profile("m").unwrap().ema_quality;
3112        let baseline_qobs = tracker.profile("m").unwrap().quality_observations;
3113
3114        // Hammer with transport timeouts AND auth/client rejections — neither
3115        // produced an answer, so quality must not budge. The 401 case is the
3116        // live parslee/* symptom: ~47 "HTTP 401 Unauthorized" from an undeployed
3117        // gateway must not bury the model on *answer* quality.
3118        for _ in 0..5 {
3119            let t = tracker.record_start("m", InferenceTask::Code, "r");
3120            tracker.record_failure(&t, "daemon read timeout on infer after 30s");
3121        }
3122        for _ in 0..5 {
3123            let t = tracker.record_start("m", InferenceTask::Code, "r");
3124            tracker.record_failure(
3125                &t,
3126                "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: Authentication",
3127            );
3128        }
3129
3130        let p = tracker.profile("m").unwrap();
3131        assert_eq!(
3132            p.fail_count, 11,
3133            "no-answer failures still counted for availability"
3134        );
3135        assert_eq!(p.total_calls, 11, "every call counted (failures included)");
3136        assert!(
3137            (p.ema_quality - baseline_ema).abs() < 1e-9,
3138            "timeouts AND auth rejections must not move the quality EMA ({baseline_ema} -> {})",
3139            p.ema_quality
3140        );
3141        assert_eq!(
3142            p.quality_observations, baseline_qobs,
3143            "no-answer failures (transport + auth) are not graded quality evidence"
3144        );
3145        // A generation failure is availability evidence too: it is counted
3146        // and leaves quality alone (QUALITY_EVIDENCE_VERSION).
3147        let t = tracker.record_start("m", InferenceTask::Code, "r");
3148        tracker.record_failure(&t, "model produced malformed output");
3149        let p = tracker.profile("m").unwrap();
3150        assert_eq!(p.fail_count, 12);
3151        assert_eq!(p.ema_quality, baseline_ema);
3152    }
3153
3154    #[test]
3155    fn is_no_answer_failure_precision_boundary() {
3156        // The classifier gates whether a failure counts as answer-quality, so
3157        // pin both sides of the precision boundary it promises. Matches are on
3158        // the FAILURE error string only (never model output).
3159        // No-answer: transport/infra + auth/client rejection → quality-neutral.
3160        for s in [
3161            "daemon read timeout on infer after 30s",
3162            "connection reset by peer",
3163            "upstream returned 503 service unavailable",
3164            "HTTP 500 Internal Server Error",
3165            "stream closed unexpectedly (eof)",
3166            "HTTP 401 Unauthorized",
3167            "unauthenticated request",
3168            "HTTP 403 Forbidden",
3169            "permission denied by gateway",
3170            "Parslee org lookup failed: Authentication required",
3171            "oauth: invalid_client",
3172            "invalid api key",
3173            "HTTP 400 Bad Request: max context length exceeded",
3174        ] {
3175            assert!(
3176                is_no_answer_failure(s),
3177                "expected no-answer (quality-neutral): {s:?}"
3178            );
3179        }
3180        // Generation failures: the model produced output that was bad/unparseable.
3181        // These MUST stay generation failures (graded), NOT be swallowed as
3182        // no-answer — including the deliberately-excluded ambiguous phrases.
3183        for s in [
3184            "model produced malformed output",
3185            "field 'user' not found in model response", // not "not found" → no-answer
3186            "tokenizer: invalid token id 99999",        // not "invalid token" → no-answer
3187            "response failed JSON schema validation",
3188            "tool call arguments did not validate",
3189        ] {
3190            assert!(
3191                !is_no_answer_failure(s),
3192                "expected generation failure (graded): {s:?}"
3193            );
3194        }
3195    }
3196
3197    #[test]
3198    fn ledger_quality_distinguishes_generation_from_transport_failure() {
3199        // The durable ledger's `quality` is ANSWER quality, mirroring the
3200        // in-memory EMA (#374 follow-up). A generation failure produced a bad
3201        // answer → graded `Some(0.0)`; a transport failure or a 429 produced no
3202        // answer → `None`. This keeps the scoreboard's avg_quality and the
3203        // shadow-calibration band (#369) — both of which fold this ledger — free
3204        // of availability noise while still seeing the genuine bad-answer signal.
3205        let mut tracker = OutcomeTracker::new();
3206
3207        let t = tracker.record_start("m", InferenceTask::Code, "r");
3208        tracker.record_failure(&t, "model produced malformed output"); // generation
3209        let t = tracker.record_start("m", InferenceTask::Code, "r");
3210        tracker.record_failure(&t, "daemon read timeout on infer after 30s"); // transport
3211        let t = tracker.record_start("m", InferenceTask::Code, "r");
3212        tracker.record_failure(&t, "429 RESOURCE_EXHAUSTED"); // rate-limit
3213        let t = tracker.record_start("m", InferenceTask::Code, "r");
3214        tracker.record_failure(&t, "upstream: 503 service unavailable"); // 5xx infra
3215        let t = tracker.record_start("m", InferenceTask::Code, "r");
3216        tracker.record_failure(&t, "HTTP 500 Internal Server Error"); // server fault
3217        let t = tracker.record_start("m", InferenceTask::Code, "r");
3218        // The live parslee/* failure: auth rejection, no answer produced.
3219        tracker.record_failure(
3220            &t,
3221            "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: Authentication",
3222        );
3223
3224        let entries = tracker.drain_ledger();
3225        assert_eq!(entries.len(), 6);
3226        // All are failures for availability accounting.
3227        assert!(entries.iter().all(|e| e.success == Some(false)));
3228        // Only the generation failure carries a graded answer-quality of 0.0.
3229        assert_eq!(
3230            entries[0].quality,
3231            Some(0.0),
3232            "generation failure -> graded 0.0 answer quality"
3233        );
3234        assert_eq!(
3235            entries[1].quality, None,
3236            "transport failure produced no answer -> quality unknown (None)"
3237        );
3238        assert_eq!(
3239            entries[2].quality, None,
3240            "429 produced no answer -> quality unknown (None), an availability event"
3241        );
3242        assert_eq!(
3243            entries[3].quality, None,
3244            "5xx infra failure produced no answer -> None"
3245        );
3246        assert_eq!(
3247            entries[4].quality, None,
3248            "an 'internal server error' (500) is a server fault, not a bad answer -> None"
3249        );
3250        assert_eq!(
3251            entries[5].quality, None,
3252            "a 401 auth rejection produced no answer -> None (not a bad-answer 0.0)"
3253        );
3254    }
3255
3256    #[test]
3257    fn real_failure_signal_reclassifies_mechanical_success() {
3258        // Completed with output -> mechanical success. A later Rejected signal
3259        // overrides it: undo the success, book a failure. No phantom success.
3260        let mut tracker = OutcomeTracker::new();
3261        let t = tracker.record_start("m", InferenceTask::Generate, "r");
3262        tracker.record_complete(&t, 100, 8, 15);
3263        assert_eq!(tracker.profile("m").unwrap().success_count, 1);
3264
3265        tracker.record_inferred_outcome(&t, InferredOutcome::Rejected { confidence: 0.9 });
3266        let p = tracker.profile("m").unwrap();
3267        assert_eq!(p.success_count, 0, "mechanical success undone");
3268        assert_eq!(p.fail_count, 1, "failure booked");
3269    }
3270
3271    #[test]
3272    fn real_success_signal_does_not_double_count() {
3273        let mut tracker = OutcomeTracker::new();
3274        let t = tracker.record_start("m", InferenceTask::Generate, "r");
3275        tracker.record_complete(&t, 100, 8, 15);
3276        tracker.record_inferred_outcome(&t, InferredOutcome::Accepted { confidence: 0.9 });
3277        let p = tracker.profile("m").unwrap();
3278        assert_eq!(
3279            p.success_count, 1,
3280            "Accepted on an already-credited call is not +2"
3281        );
3282    }
3283
3284    #[test]
3285    fn export_import() {
3286        let mut tracker = OutcomeTracker::new();
3287        let trace = tracker.record_start("m1", InferenceTask::Generate, "test");
3288        tracker.record_complete(&trace, 100, 10, 5);
3289        tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 0.9 });
3290
3291        let exported = tracker.export_profiles();
3292        assert_eq!(exported.len(), 1);
3293
3294        let mut tracker2 = OutcomeTracker::new();
3295        tracker2.import_profiles(exported);
3296        assert!(tracker2.profile("m1").is_some());
3297    }
3298
3299    #[test]
3300    fn a_call_longer_than_the_ttl_can_still_be_graded_after_it_ends() {
3301        // car-releases#110: a 372 s generation with a 300 s TTL counted from
3302        // the START was swept while in flight, so neither its completion nor
3303        // its grade ever landed.
3304        let ttl = 900;
3305        let mut tracker = OutcomeTracker::new();
3306        let t = tracker.record_start("m", InferenceTask::Generate, "r");
3307        let started = now_unix().saturating_sub(ttl + 600);
3308        tracker.pending.get_mut(&t).unwrap().timestamp = started;
3309
3310        // Still running past the TTL: not evicted.
3311        assert_eq!(tracker.sweep_pending_at(ttl, now_unix()), 0);
3312        tracker.record_complete(&t, (ttl + 600) * 1000, 10, 20);
3313        // Ended just now: the grace period starts here.
3314        assert_eq!(tracker.sweep_pending_at(ttl, now_unix() + ttl / 2), 0);
3315
3316        let report = tracker
3317            .resolve_pending_reporting(vec![(t, InferredOutcome::Accepted { confidence: 0.9 })]);
3318        assert_eq!(report.resolved, 1);
3319        assert!(report.dropped.is_empty());
3320        let p = tracker.profile("m").unwrap();
3321        assert_eq!(p.quality_observations, 1, "the grade moved the EMA");
3322        assert_eq!(p.total_calls, 1, "the completion was booked");
3323    }
3324
3325    #[test]
3326    fn an_ended_call_is_swept_ttl_after_it_ends() {
3327        let mut tracker = OutcomeTracker::new();
3328        let t = tracker.record_start("m", InferenceTask::Generate, "r");
3329        tracker.record_complete(&t, 100, 1, 1);
3330        assert_eq!(tracker.sweep_pending_at(900, now_unix() + 899), 0);
3331        assert_eq!(tracker.sweep_pending_at(900, now_unix() + 902), 1);
3332        assert!(!tracker.has_pending(&t));
3333    }
3334
3335    #[test]
3336    fn resolve_reports_signals_that_matched_no_pending_trace() {
3337        let mut tracker = OutcomeTracker::new();
3338        let live = tracker.record_start("m", InferenceTask::Generate, "r");
3339        tracker.record_complete(&live, 100, 1, 1);
3340        let report = tracker.resolve_pending_reporting(vec![
3341            (live.clone(), InferredOutcome::Accepted { confidence: 0.8 }),
3342            // The same trace twice: the second signal has nothing to resolve.
3343            (live.clone(), InferredOutcome::Accepted { confidence: 0.8 }),
3344            (
3345                "t-0-swept".into(),
3346                InferredOutcome::Rejected { confidence: 0.9 },
3347            ),
3348        ]);
3349        assert_eq!(report.resolved, 1);
3350        assert_eq!(report.dropped, vec![live, "t-0-swept".to_string()]);
3351        assert_eq!(tracker.profile("m").unwrap().quality_observations, 1);
3352    }
3353
3354    #[test]
3355    fn external_grade_is_kept_per_source_and_leaves_served_counters_alone() {
3356        let mut tracker = OutcomeTracker::new();
3357        let stats = tracker
3358            .record_external_grade("gpt-x", InferenceTask::Code, 0.25, 6, "neo")
3359            .unwrap();
3360        assert_eq!(stats.quality, 0.25);
3361        assert_eq!(stats.observations, 6);
3362        assert_eq!(stats.evidence(), 6);
3363
3364        let p = tracker.profile("gpt-x").unwrap();
3365        assert_eq!(p.total_calls, 0);
3366        assert_eq!(p.success_count + p.fail_count, 0);
3367        assert_eq!(p.quality_observations, 0, "served evidence untouched");
3368        assert_eq!(p.ema_quality, NEUTRAL_QUALITY, "served EMA untouched");
3369        let ts = p.task_stats(InferenceTask::Code).unwrap();
3370        assert_eq!(ts.quality_observations, 0);
3371        assert!(!ts.has_served_evidence());
3372        assert_eq!(ts.external_evidence(), (6.0, 0.25));
3373        assert!(tracker.is_dirty(), "an external grade must reach disk");
3374        assert!(tracker.drain_ledger().is_empty(), "no served receipt");
3375    }
3376
3377    #[test]
3378    fn one_source_cannot_add_weight_past_its_cap() {
3379        let mut tracker = OutcomeTracker::new();
3380        let big = tracker
3381            .record_external_grade("m", InferenceTask::Generate, 0.0, 100, "bench")
3382            .unwrap();
3383        assert_eq!(big.observations, 100);
3384        assert_eq!(big.evidence(), EXTERNAL_GRADE_EVIDENCE_CAP);
3385        // Many small reports from the same source add no further weight, but
3386        // the newest verdict still moves the source's quality.
3387        for _ in 0..20 {
3388            tracker
3389                .record_external_grade("m", InferenceTask::Generate, 1.0, 1, "bench")
3390                .unwrap();
3391        }
3392        let ts = tracker
3393            .profile("m")
3394            .unwrap()
3395            .task_stats(InferenceTask::Generate)
3396            .unwrap();
3397        let (evidence, mean) = ts.external_evidence();
3398        assert_eq!(evidence, EXTERNAL_GRADE_EVIDENCE_CAP as f64);
3399        assert!(mean > 0.9, "newer reports keep moving quality: {mean}");
3400        assert_eq!(ts.external_grades["bench"].observations, 120);
3401    }
3402
3403    #[test]
3404    fn external_grade_validation_and_source_limit() {
3405        let mut tracker = OutcomeTracker::new();
3406        let task = InferenceTask::Generate;
3407        let refused = |r: Result<ExternalGradeStats, ExternalGradeRefusal>| r.unwrap_err();
3408        assert_eq!(
3409            refused(tracker.record_external_grade("m", task, 1.5, 1, "s")),
3410            ExternalGradeRefusal::InvalidQuality
3411        );
3412        assert_eq!(
3413            refused(tracker.record_external_grade("m", task, f64::NAN, 1, "s")),
3414            ExternalGradeRefusal::InvalidQuality
3415        );
3416        assert_eq!(
3417            refused(tracker.record_external_grade("m", task, 0.5, 0, "s")),
3418            ExternalGradeRefusal::InvalidCases
3419        );
3420        assert_eq!(
3421            refused(tracker.record_external_grade(
3422                "m",
3423                task,
3424                0.5,
3425                MAX_EXTERNAL_GRADE_CASES + 1,
3426                "s"
3427            )),
3428            ExternalGradeRefusal::InvalidCases
3429        );
3430        assert_eq!(
3431            refused(tracker.record_external_grade("m", task, 0.5, 1, "has space")),
3432            ExternalGradeRefusal::InvalidSource
3433        );
3434        assert_eq!(
3435            refused(tracker.record_external_grade("m", task, 0.5, 1, &"s".repeat(65))),
3436            ExternalGradeRefusal::InvalidSource
3437        );
3438        assert_eq!(
3439            refused(tracker.record_external_grade(" ", task, 0.5, 1, "s")),
3440            ExternalGradeRefusal::InvalidModel
3441        );
3442        assert!(tracker.profile("m").is_none(), "a refusal records nothing");
3443
3444        for i in 0..MAX_EXTERNAL_GRADE_SOURCES {
3445            tracker
3446                .record_external_grade("m", task, 0.5, 1, &format!("src-{i}"))
3447                .unwrap();
3448        }
3449        assert_eq!(
3450            refused(tracker.record_external_grade("m", task, 0.5, 1, "one-more")),
3451            ExternalGradeRefusal::SourceLimitReached
3452        );
3453        // A source already counted keeps reporting.
3454        tracker
3455            .record_external_grade("m", task, 0.9, 1, "src-0")
3456            .unwrap();
3457        // The limit is per task.
3458        tracker
3459            .record_external_grade("m", InferenceTask::Code, 0.5, 1, "one-more")
3460            .unwrap();
3461    }
3462
3463    #[test]
3464    fn one_reporter_cannot_add_weight_by_rotating_source_names() {
3465        let mut tracker = OutcomeTracker::new();
3466        let task = InferenceTask::Code;
3467        for i in 0..MAX_EXTERNAL_GRADE_SOURCES {
3468            tracker
3469                .record_external_grade("m", task, 0.0, 8, &format!("alias-{i}"))
3470                .unwrap();
3471        }
3472        let ts = tracker.profile("m").unwrap().task_stats(task).unwrap();
3473        assert_eq!(
3474            ts.external_evidence(),
3475            (EXTERNAL_GRADE_REPORTER_EVIDENCE_CAP as f64, 0.0),
3476            "eight names from one reporter count as one reporter's cap"
3477        );
3478        // A second reporter is its own budget, and the pool is its mean.
3479        for i in 0..2 {
3480            tracker
3481                .record_external_grade_as(Some("agent:b"), "m", task, 1.0, 8, &format!("s{i}"))
3482                .unwrap();
3483        }
3484        let ts = tracker.profile("m").unwrap().task_stats(task).unwrap();
3485        assert_eq!(ts.external_evidence(), (16.0, 0.5));
3486        // A third reporter adds its mean but no weight past the total cap.
3487        tracker
3488            .record_external_grade_as(Some("agent:c"), "m", task, 1.0, 8, "s")
3489            .unwrap();
3490        let ts = tracker.profile("m").unwrap().task_stats(task).unwrap();
3491        let (evidence, mean) = ts.external_evidence();
3492        assert_eq!(evidence, EXTERNAL_GRADE_TOTAL_EVIDENCE_CAP as f64);
3493        assert!((mean - 2.0 / 3.0).abs() < 1e-12, "{mean}");
3494    }
3495
3496    #[test]
3497    fn a_reporter_cannot_write_another_reporters_source() {
3498        let mut tracker = OutcomeTracker::new();
3499        let task = InferenceTask::Code;
3500        tracker
3501            .record_external_grade("m", task, 0.9, 8, "neo")
3502            .unwrap();
3503        tracker
3504            .record_external_grade_as(Some("agent:mallory"), "m", task, 0.0, 100, "neo")
3505            .unwrap();
3506        let ts = tracker.profile("m").unwrap().task_stats(task).unwrap();
3507        assert_eq!(
3508            ts.external_grades["neo"].quality, 0.9,
3509            "operator's untouched"
3510        );
3511        assert_eq!(ts.external_grades["agent:mallory/neo"].quality, 0.0);
3512        assert_eq!(
3513            tracker
3514                .record_external_grade_as(Some("agent:a/b"), "m", task, 0.5, 1, "x")
3515                .unwrap_err(),
3516            ExternalGradeRefusal::InvalidReporter,
3517            "a reporter cannot forge a key under another prefix"
3518        );
3519        // The source limit is per reporter: an agent at its limit does not
3520        // lock the operator, or another agent, out of the task.
3521        for i in 0..MAX_EXTERNAL_GRADE_SOURCES {
3522            let _ = tracker.record_external_grade_as(
3523                Some("agent:mallory"),
3524                "m",
3525                task,
3526                0.0,
3527                1,
3528                &format!("junk-{i}"),
3529            );
3530        }
3531        tracker
3532            .record_external_grade("m", task, 0.5, 1, "nightly")
3533            .unwrap();
3534        tracker
3535            .record_external_grade_as(Some("agent:other"), "m", task, 0.5, 1, "nightly")
3536            .unwrap();
3537    }
3538
3539    #[test]
3540    fn a_stale_source_stops_counting_and_gives_up_its_slot() {
3541        let mut tracker = OutcomeTracker::new();
3542        let task = InferenceTask::Code;
3543        let then = 1_000_000_000;
3544        let later = then + EXTERNAL_GRADE_MAX_AGE_SECS + 1;
3545        for i in 0..MAX_EXTERNAL_GRADE_SOURCES {
3546            tracker
3547                .record_external_grade_at(None, "m", task, 0.0, 8, &format!("old-{i}"), then)
3548                .unwrap();
3549        }
3550        let ts = tracker.profile("m").unwrap().task_stats(task).unwrap();
3551        assert_eq!(ts.external_evidence_at(then).0, 8.0);
3552        assert_eq!(
3553            ts.external_evidence_at(later),
3554            (0.0, 0.0),
3555            "a stale grade no longer steers routing"
3556        );
3557        // While fresh, the limit refuses a new source; once stale, the
3558        // stalest gives up its slot.
3559        assert_eq!(
3560            tracker
3561                .record_external_grade_at(None, "m", task, 1.0, 1, "new", then + 1)
3562                .unwrap_err(),
3563            ExternalGradeRefusal::SourceLimitReached
3564        );
3565        tracker
3566            .record_external_grade_at(None, "m", task, 1.0, 1, "new", later)
3567            .unwrap();
3568        let ts = tracker.profile("m").unwrap().task_stats(task).unwrap();
3569        assert_eq!(ts.external_grades.len(), MAX_EXTERNAL_GRADE_SOURCES);
3570        assert!(ts.external_grades.contains_key("new"));
3571        assert_eq!(ts.external_evidence_at(later), (1.0, 1.0));
3572        // Reporting again under a stale source starts it afresh.
3573        let restarted = tracker
3574            .record_external_grade_at(None, "m", task, 1.0, 1, "old-7", later)
3575            .unwrap();
3576        assert_eq!((restarted.quality, restarted.observations), (1.0, 1));
3577    }
3578
3579    #[test]
3580    fn external_grades_survive_a_save_and_load() {
3581        let dir = tempfile::tempdir().unwrap();
3582        let path = dir.path().join("profiles.json");
3583        let mut tracker = OutcomeTracker::new();
3584        tracker
3585            .record_external_grade("m", InferenceTask::Code, 0.75, 3, "neo")
3586            .unwrap();
3587        tracker.save_to_file(&path).unwrap();
3588
3589        let mut loaded = OutcomeTracker::new();
3590        loaded.load_from_file(&path).unwrap();
3591        let ts = loaded
3592            .profile("m")
3593            .unwrap()
3594            .task_stats(InferenceTask::Code)
3595            .unwrap();
3596        assert_eq!(ts.external_grades["neo"].quality, 0.75);
3597        assert_eq!(ts.external_grades["neo"].observations, 3);
3598
3599        // A profile written before external grades existed still loads.
3600        let old: TaskStats = serde_json::from_str(
3601            r#"{"calls":1,"successes":1,"failures":0,"avg_latency_ms":1.0,"ema_quality":0.5}"#,
3602        )
3603        .unwrap();
3604        assert!(old.external_grades.is_empty());
3605        assert!(!serde_json::to_string(&old)
3606            .unwrap()
3607            .contains("external_grades"));
3608    }
3609}