1use std::collections::{HashMap, HashSet};
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use serde::{Deserialize, Serialize};
17
18#[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#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct InferenceOutcome {
44 pub trace_id: String,
46 pub model_id: String,
48 pub task: InferenceTask,
50 pub routing_reason: String,
52 pub latency_ms: u64,
54 pub input_tokens: usize,
57 pub output_tokens: usize,
59 #[serde(default)]
62 pub cache_read_input_tokens: usize,
63 #[serde(default)]
66 pub cache_creation_input_tokens: usize,
67 pub inferred_outcome: Option<InferredOutcome>,
69 pub code_outcome: Option<CodeOutcome>,
71 pub error: Option<String>,
73 pub timestamp: u64,
75 #[serde(skip)]
82 pub success_credited: bool,
83 #[serde(skip)]
89 pub completed_at: Option<u64>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(tag = "type", rename_all = "snake_case")]
95pub enum InferredOutcome {
96 Accepted { confidence: f64 },
98 AcceptedWithEdits { confidence: f64 },
100 Rejected { confidence: f64 },
102 Inconclusive,
104}
105
106impl InferredOutcome {
107 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#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(tag = "type", rename_all = "snake_case")]
130pub enum CodeOutcome {
131 Applied,
133 Modified,
135 Ignored,
137 SignatureChanged,
139 BodyModified,
141 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
162pub const QUALITY_EVIDENCE_VERSION: u32 = 1;
172
173fn 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
198const NEUTRAL_QUALITY: f64 = 0.5;
200
201fn neutral_quality() -> f64 {
202 NEUTRAL_QUALITY
203}
204
205#[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#[derive(Debug, Clone, Serialize, Deserialize)]
217#[serde(default)]
218pub struct TaskStats {
219 pub calls: u64,
220 pub successes: u64,
221 pub failures: u64,
222 pub avg_latency_ms: f64,
224 pub ema_quality: f64,
226 #[serde(default)]
231 pub prior_sample_size: usize,
232 #[serde(default)]
240 pub quality_observations: u64,
241 pub unseeded_ema_quality: f64,
248 pub unseeded_quality_observations: u64,
250 #[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 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 } self.successes as f64 / total as f64
289 }
290
291 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 pub fn external_evidence(&self) -> (f64, f64) {
309 self.external_evidence_at(now_unix())
310 }
311
312 pub fn external_evidence_at(&self, now: u64) -> (f64, f64) {
327 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
359pub const EXTERNAL_GRADE_EVIDENCE_CAP: u64 = 8;
363pub const EXTERNAL_GRADE_REPORTER_EVIDENCE_CAP: u64 = 8;
368pub const EXTERNAL_GRADE_TOTAL_EVIDENCE_CAP: u64 = 16;
373pub const MAX_EXTERNAL_GRADE_SOURCES: usize = 8;
377pub const EXTERNAL_GRADE_MAX_AGE_SECS: u64 = 90 * 24 * 60 * 60;
381pub const MAX_EXTERNAL_GRADE_REPORTER_LEN: usize = 160;
383pub const MAX_EXTERNAL_GRADE_CASES: u32 = 100;
385pub const MAX_EXTERNAL_GRADE_SOURCE_LEN: usize = 64;
387
388#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
390pub struct ExternalGradeStats {
391 pub quality: f64,
396 pub observations: u64,
398 pub updated_at: u64,
400}
401
402impl ExternalGradeStats {
403 pub fn evidence(&self) -> u64 {
407 self.observations.min(EXTERNAL_GRADE_EVIDENCE_CAP)
408 }
409
410 pub fn is_stale_at(&self, now: u64) -> bool {
413 now.saturating_sub(self.updated_at) > EXTERNAL_GRADE_MAX_AGE_SECS
414 }
415}
416
417pub 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
429pub fn external_grade_reporter(key: &str) -> Option<&str> {
431 key.split_once('/').map(|(reporter, _)| reporter)
432}
433
434#[derive(Debug, Clone, PartialEq)]
436pub enum ExternalGradeRefusal {
437 InvalidQuality,
439 InvalidCases,
441 InvalidSource,
444 InvalidModel,
446 InvalidReporter,
449 SourceLimitReached,
452}
453
454impl ExternalGradeRefusal {
455 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#[derive(Debug, Clone, Default, PartialEq)]
498pub struct ResolveReport {
499 pub resolved: usize,
501 pub dropped: Vec<String>,
504}
505
506#[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 #[serde(default)]
518 pub total_input_tokens: u64,
519 #[serde(default)]
521 pub total_output_tokens: u64,
522 #[serde(default)]
524 pub total_cache_read_input_tokens: u64,
525 #[serde(default)]
527 pub total_cache_creation_input_tokens: u64,
528 pub task_stats: HashMap<String, TaskStats>,
530 pub ema_quality: f64,
532 #[serde(default)]
535 pub prior_sample_size: usize,
536 #[serde(default)]
539 pub quality_observations: u64,
540 #[serde(default = "neutral_quality")]
542 pub unseeded_ema_quality: f64,
543 #[serde(default)]
545 pub unseeded_quality_observations: u64,
546 #[serde(default)]
551 pub quality_per_1k_tokens: f64,
552 #[serde(default)]
555 pub quality_evidence_version: u32,
556 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 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 pub fn should_degrade(&self, threshold: u64) -> bool {
610 self.fail_count > self.success_count + threshold
611 }
612
613 pub fn task_stats(&self, task: InferenceTask) -> Option<&TaskStats> {
615 self.task_stats.get(&task.to_string())
616 }
617
618 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq)]
686pub struct CacheRates {
687 pub read_mult: f64,
689 pub write_mult: f64,
694}
695
696impl CacheRates {
697 pub const ANTHROPIC: Self = Self {
699 read_mult: 0.1,
700 write_mult: 1.25,
701 };
702 pub const OPENAI: Self = Self {
704 read_mult: 0.5,
705 write_mult: 0.0,
706 };
707 pub const NONE: Self = Self {
711 read_mult: 0.0,
712 write_mult: 0.0,
713 };
714}
715
716impl Default for CacheRates {
717 fn default() -> Self {
720 Self::ANTHROPIC
721 }
722}
723
724pub 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
742const EMA_ALPHA: f64 = 0.2;
744
745#[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 #[serde(default, skip_serializing_if = "is_zero_usize")]
764 pub cache_read_input_tokens: usize,
765 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub project_id: Option<String>,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub intent: Option<String>,
783 #[serde(default, skip_serializing_if = "Option::is_none")]
787 pub attribution: Option<UnattributedReason>,
788 #[serde(default, skip_serializing_if = "Option::is_none")]
794 pub car_version: Option<String>,
795 pub timestamp: u64,
796}
797
798impl OutcomeLedgerEntry {
799 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
830#[serde(rename_all = "snake_case")]
831pub enum UnattributedReason {
832 Capability,
835 Account,
838 ResourceBlocked,
841 ContentRefused,
843 Cancelled,
846 Unregistered,
849}
850
851fn is_zero_usize(n: &usize) -> bool {
855 *n == 0
856}
857
858const MAX_LEDGER_BUFFER: usize = 5000;
861
862const MAX_LEDGER_ERROR_CHARS: usize = 256;
867
868fn 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
877fn is_no_answer_failure(error: &str) -> bool {
903 let e = error.to_ascii_lowercase();
904 const NEEDLES: &[&str] = &[
905 "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 "internal server error",
928 "service unavailable",
929 "temporarily unavailable",
930 "overloaded",
931 "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
951pub 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
965fn 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#[derive(Debug, Default, Clone, PartialEq, Eq)]
1005pub struct LedgerReconciliation {
1006 pub reclassified: usize,
1009 pub unregistered: usize,
1012 pub profiles_rebuilt: usize,
1014 pub profiles_removed: usize,
1016}
1017
1018fn legacy_unattributed(error: &str) -> Option<UnattributedReason> {
1029 use UnattributedReason as R;
1030 const PREFIXES: &[(&str, UnattributedReason)] = &[
1031 (
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 (
1054 "inference failed: no API keys available for endpoint ",
1055 R::Account,
1056 ),
1057 (
1059 "inference failed: Parslee org lookup failed: HTTP 401",
1060 R::Account,
1061 ),
1062 (
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 (error.starts_with("inference failed: mode ") && error.contains(" not implemented on backend "))
1078 .then_some(R::Capability)
1079}
1080
1081#[derive(Debug, Clone, Default)]
1086pub struct QualitySeed {
1087 pub overall: Option<f64>,
1088 pub tasks: HashMap<String, f64>,
1089}
1090
1091#[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
1101pub 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 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 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 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 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
1251pub fn write_reconciled_ledger(
1254 path: &std::path::Path,
1255 entries: &[OutcomeLedgerEntry],
1256) -> std::io::Result<()> {
1257 rewrite_ledger(path, entries)
1258}
1259
1260fn 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 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 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 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 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 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
1359pub 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 #[cfg(unix)]
1379 {
1380 use std::os::unix::fs::OpenOptionsExt;
1381 opts.mode(0o600);
1382 }
1383 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
1398pub 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 static LEARNING: bool;
1420 static DISPATCHED: std::sync::Arc<std::sync::atomic::AtomicBool>;
1423}
1424
1425pub 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
1436pub(crate) fn note_dispatch() {
1440 let _ = DISPATCHED.try_with(|probe| probe.store(true, std::sync::atomic::Ordering::SeqCst));
1441}
1442
1443pub 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
1456pub const PENDING_GRADE_TTL_SECS: u64 = 15 * 60;
1462
1463pub const PENDING_IN_FLIGHT_TTL_SECS: u64 = 2 * 60 * 60;
1466
1467pub struct OutcomeTracker {
1469 profiles: HashMap<String, ModelProfile>,
1471 pending: HashMap<String, InferenceOutcome>,
1474 trace_counter: u64,
1476 excluded: HashSet<String>,
1478 dirty: bool,
1484 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 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 pub fn drain_ledger(&mut self) -> Vec<OutcomeLedgerEntry> {
1511 self.ledger_buffer.drain(..).collect()
1512 }
1513
1514 pub fn is_excluded(&self, model_id: &str) -> bool {
1516 self.excluded.contains(model_id)
1517 }
1518
1519 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 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 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 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 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 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 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 profile.total_calls += 1;
1664 profile.fail_count += 1;
1665
1666 let is_rate_limited = error.contains("429") || error.contains("RESOURCE_EXHAUSTED");
1669 let is_no_answer = !is_rate_limited && is_no_answer_failure(error);
1677 if is_rate_limited {
1678 self.excluded.insert(outcome.model_id.clone());
1680 }
1681 let task_key = outcome.task.to_string();
1686 let ts = profile.task_stats.entry(task_key).or_default();
1687 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 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 self.pending.remove(trace_id);
1733 }
1734
1735 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 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 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 pub fn commit_reconciled(&mut self, profiles: HashMap<String, ModelProfile>) {
1774 self.profiles = profiles;
1775 self.dirty = true;
1776 }
1777
1778 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 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 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 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 pub fn resolve_pending_from_signals(&mut self, outcomes: Vec<(String, InferredOutcome)>) {
1842 self.resolve_pending_reporting(outcomes);
1843 }
1844
1845 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 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 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 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 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 #[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 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 *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 pub fn infer_outcomes_from_action_sequence(
2054 &self,
2055 action_results: &[(String, bool, f64, String)], ) -> 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; }
2063
2064 if !success {
2065 outcomes.push((
2066 trace_id.clone(),
2067 InferredOutcome::Rejected {
2068 confidence: *confidence,
2069 },
2070 ));
2071 continue;
2072 }
2073
2074 let next_succeeded = action_results
2076 .get(i + 1)
2077 .map(|(_, s, _, _)| *s)
2078 .unwrap_or(true); 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 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 pub fn profile(&self, model_id: &str) -> Option<&ModelProfile> {
2107 self.profiles.get(model_id)
2108 }
2109
2110 pub fn has_pending(&self, trace_id: &str) -> bool {
2115 self.pending.contains_key(trace_id)
2116 }
2117
2118 pub fn all_profiles(&self) -> &HashMap<String, ModelProfile> {
2120 &self.profiles
2121 }
2122
2123 pub fn pending_trace_ids(&self) -> Vec<String> {
2125 self.pending.keys().cloned().collect()
2126 }
2127
2128 pub fn get_pending(&self, trace_id: &str) -> Option<&InferenceOutcome> {
2130 self.pending.get(trace_id)
2131 }
2132
2133 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 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 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 pub fn is_dirty(&self) -> bool {
2180 self.dirty
2181 }
2182
2183 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 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 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 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 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 {
2254 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 pub fn sweep_pending(&mut self, ttl_secs: u64) -> usize {
2302 self.sweep_pending_at(ttl_secs, now_unix())
2303 }
2304
2305 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 if o.latency_ms > 0 {
2333 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 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; }
2420
2421 #[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 #[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 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 #[cfg(feature = "ast")]
2471 fn check_git_outcomes_ast(repo_dir: &std::path::Path) -> Option<CodeOutcome> {
2472 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 if car_ast::Language::from_filename(file).is_none() {
2495 continue;
2496 }
2497
2498 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 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, _ => {}
2542 }
2543 }
2544
2545 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 }
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 #[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 #[test]
2598 fn a_new_task_starts_at_neutral_quality() {
2599 assert_eq!(TaskStats::default().ema_quality, NEUTRAL_QUALITY);
2600 }
2601
2602 #[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 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 #[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 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 let trace = tracker.record_start(
2706 "qwen/qwen3-4b:q4_k_m",
2707 InferenceTask::Code,
2708 "Code task -> Qwen3-4B",
2709 );
2710
2711 tracker.record_complete(&trace, 1200, 100, 50);
2713
2714 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 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); }
2726
2727 #[test]
2728 fn failure_degrades() {
2729 let mut tracker = OutcomeTracker::new();
2734 for _ in 0..5 {
2735 let trace = tracker.record_start("bad-model", InferenceTask::Generate, "test");
2736 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)); 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 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 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); 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 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 assert_eq!(p.tokens_per_success(), None);
2820 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 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; p.total_cache_read_input_tokens = 1000; p.total_cache_creation_input_tokens = 200; p.total_output_tokens = 50; 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 assert_eq!(p.total_tokens(), 100 + 1000 + 200 + 50);
2849 let mut naive = ModelProfile::new("naive".into());
2852 naive.success_count = 1;
2853 naive.total_input_tokens = 1300; 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 let mut p = ModelProfile::new("gpt".into());
2868 p.success_count = 1;
2869 p.total_input_tokens = 100; p.total_cache_read_input_tokens = 1000; p.total_cache_creation_input_tokens = 0; 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 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 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 assert!(!tracker.is_dirty());
2908 assert!(!tracker.save_if_dirty(&path).unwrap());
2909 assert!(!path.exists());
2910
2911 let trace = tracker.record_start("m1", InferenceTask::Generate, "router");
2913 tracker.record_complete(&trace, 100, 10, 20);
2914 assert!(tracker.is_dirty());
2915
2916 assert!(tracker.save_if_dirty(&path).unwrap());
2918 assert!(path.exists());
2919 assert!(!tracker.is_dirty());
2920
2921 assert!(!tracker.save_if_dirty(&path).unwrap());
2923
2924 let mut fresh = OutcomeTracker::new();
2926 fresh.load_from_file(&path).unwrap();
2927 assert!(!fresh.is_dirty());
2928
2929 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 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 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 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 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 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"); 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 let t1 = tracker.record_start("m", InferenceTask::Generate, "r");
3034 tracker.record_complete(&t1, 500, 10, 20);
3035 let t2 = tracker.record_start("m", InferenceTask::Generate, "r");
3037 tracker.record_complete(&t2, 300, 5, 0);
3038 let _t3 = tracker.record_start("m", InferenceTask::Generate, "r");
3040
3041 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 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 let mut receipts = tracker.drain_ledger();
3054 receipts.sort_by_key(|r| r.latency_ms);
3055 assert_eq!(receipts.len(), 2);
3056 assert_eq!(receipts[0].latency_ms, 300);
3058 assert_eq!(receipts[0].success, None);
3059 assert_eq!(receipts[1].latency_ms, 500);
3061 assert_eq!(receipts[1].success, Some(true));
3062 assert_eq!(receipts[1].quality, None);
3063
3064 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 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 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 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 let mut tracker = OutcomeTracker::new();
3121
3122 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 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 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); 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 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 let mut tracker = OutcomeTracker::new();
3207 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 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 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 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 for s in [
3284 "model produced malformed output",
3285 "field 'user' not found in model response", "tokenizer: invalid token id 99999", "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 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"); let t = tracker.record_start("m", InferenceTask::Code, "r");
3310 tracker.record_failure(&t, "daemon read timeout on infer after 30s"); let t = tracker.record_start("m", InferenceTask::Code, "r");
3312 tracker.record_failure(&t, "429 RESOURCE_EXHAUSTED"); let t = tracker.record_start("m", InferenceTask::Code, "r");
3314 tracker.record_failure(&t, "upstream: 503 service unavailable"); let t = tracker.record_start("m", InferenceTask::Code, "r");
3316 tracker.record_failure(&t, "HTTP 500 Internal Server Error"); let t = tracker.record_start("m", InferenceTask::Code, "r");
3318 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 assert!(entries.iter().all(|e| e.success == Some(false)));
3328 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 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 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 assert_eq!(tracker.sweep_pending_at(ttl, now_unix()), 0);
3412 tracker.record_complete(&t, (ttl + 600) * 1000, 10, 20);
3413 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 (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 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 tracker
3555 .record_external_grade("m", task, 0.9, 1, "src-0")
3556 .unwrap();
3557 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 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 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 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 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 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 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}