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