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 for ts in p.task_stats.values_mut() {
187 ts.ema_quality = NEUTRAL_QUALITY;
188 ts.quality_observations = 0;
189 ts.prior_sample_size = 0;
190 }
191 p.quality_evidence_version = QUALITY_EVIDENCE_VERSION;
192}
193
194const NEUTRAL_QUALITY: f64 = 0.5;
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199#[serde(default)]
200pub struct TaskStats {
201 pub calls: u64,
202 pub successes: u64,
203 pub failures: u64,
204 pub avg_latency_ms: f64,
206 pub ema_quality: f64,
208 #[serde(default)]
213 pub prior_sample_size: usize,
214 #[serde(default)]
222 pub quality_observations: u64,
223 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
232 pub external_grades: std::collections::BTreeMap<String, ExternalGradeStats>,
233}
234
235impl Default for TaskStats {
236 fn default() -> Self {
240 Self {
241 calls: 0,
242 successes: 0,
243 failures: 0,
244 avg_latency_ms: 0.0,
245 ema_quality: NEUTRAL_QUALITY,
246 prior_sample_size: 0,
247 quality_observations: 0,
248 external_grades: Default::default(),
249 }
250 }
251}
252
253impl TaskStats {
254 pub fn success_rate(&self) -> f64 {
255 let total = self.successes + self.failures;
256 if total == 0 {
257 return 0.5;
258 } self.successes as f64 / total as f64
260 }
261
262 pub fn has_served_evidence(&self) -> bool {
269 self.calls > 0
270 || self.successes > 0
271 || self.failures > 0
272 || self.prior_sample_size > 0
273 || self.quality_observations > 0
274 || self.external_grades.is_empty()
275 }
276
277 pub fn external_evidence(&self) -> (f64, f64) {
280 self.external_evidence_at(now_unix())
281 }
282
283 pub fn external_evidence_at(&self, now: u64) -> (f64, f64) {
298 let mut by_reporter: std::collections::BTreeMap<Option<&str>, (f64, f64)> =
300 std::collections::BTreeMap::new();
301 for (key, grade) in &self.external_grades {
302 if grade.is_stale_at(now) {
303 continue;
304 }
305 let e = grade.evidence() as f64;
306 let entry = by_reporter
307 .entry(external_grade_reporter(key))
308 .or_insert((0.0, 0.0));
309 entry.0 += e;
310 entry.1 += e * grade.quality;
311 }
312 let mut evidence = 0.0;
313 let mut weighted = 0.0;
314 for (e, w) in by_reporter.into_values() {
315 if e <= 0.0 {
316 continue;
317 }
318 let counted = e.min(EXTERNAL_GRADE_REPORTER_EVIDENCE_CAP as f64);
319 evidence += counted;
320 weighted += counted * (w / e);
321 }
322 if evidence <= 0.0 {
323 return (0.0, 0.0);
324 }
325 let mean = weighted / evidence;
326 (evidence.min(EXTERNAL_GRADE_TOTAL_EVIDENCE_CAP as f64), mean)
327 }
328}
329
330pub const EXTERNAL_GRADE_EVIDENCE_CAP: u64 = 8;
334pub const EXTERNAL_GRADE_REPORTER_EVIDENCE_CAP: u64 = 8;
339pub const EXTERNAL_GRADE_TOTAL_EVIDENCE_CAP: u64 = 16;
344pub const MAX_EXTERNAL_GRADE_SOURCES: usize = 8;
348pub const EXTERNAL_GRADE_MAX_AGE_SECS: u64 = 90 * 24 * 60 * 60;
352pub const MAX_EXTERNAL_GRADE_REPORTER_LEN: usize = 160;
354pub const MAX_EXTERNAL_GRADE_CASES: u32 = 100;
356pub const MAX_EXTERNAL_GRADE_SOURCE_LEN: usize = 64;
358
359#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
361pub struct ExternalGradeStats {
362 pub quality: f64,
367 pub observations: u64,
369 pub updated_at: u64,
371}
372
373impl ExternalGradeStats {
374 pub fn evidence(&self) -> u64 {
378 self.observations.min(EXTERNAL_GRADE_EVIDENCE_CAP)
379 }
380
381 pub fn is_stale_at(&self, now: u64) -> bool {
384 now.saturating_sub(self.updated_at) > EXTERNAL_GRADE_MAX_AGE_SECS
385 }
386}
387
388pub fn external_grade_key(reporter: Option<&str>, source: &str) -> String {
394 match reporter {
395 Some(reporter) => format!("{reporter}/{source}"),
396 None => source.to_string(),
397 }
398}
399
400pub fn external_grade_reporter(key: &str) -> Option<&str> {
402 key.split_once('/').map(|(reporter, _)| reporter)
403}
404
405#[derive(Debug, Clone, PartialEq)]
407pub enum ExternalGradeRefusal {
408 InvalidQuality,
410 InvalidCases,
412 InvalidSource,
415 InvalidModel,
417 InvalidReporter,
420 SourceLimitReached,
423}
424
425impl ExternalGradeRefusal {
426 pub fn reason(&self) -> &'static str {
428 match self {
429 Self::InvalidQuality => "invalid_quality",
430 Self::InvalidCases => "invalid_n",
431 Self::InvalidSource => "invalid_source",
432 Self::InvalidModel => "invalid_model",
433 Self::InvalidReporter => "invalid_reporter",
434 Self::SourceLimitReached => "source_limit_reached",
435 }
436 }
437}
438
439impl std::fmt::Display for ExternalGradeRefusal {
440 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441 match self {
442 Self::InvalidQuality => write!(f, "quality must be a number in [0, 1]"),
443 Self::InvalidCases => {
444 write!(f, "n must be between 1 and {MAX_EXTERNAL_GRADE_CASES}")
445 }
446 Self::InvalidSource => write!(
447 f,
448 "source must be 1-{MAX_EXTERNAL_GRADE_SOURCE_LEN} characters of [A-Za-z0-9._:-]"
449 ),
450 Self::InvalidModel => write!(f, "model_id must be 1-256 bytes"),
451 Self::InvalidReporter => write!(
452 f,
453 "reporter must be 1-{MAX_EXTERNAL_GRADE_REPORTER_LEN} printable ASCII characters \
454 without `/`"
455 ),
456 Self::SourceLimitReached => write!(
457 f,
458 "this caller already keeps {MAX_EXTERNAL_GRADE_SOURCES} current external grade \
459 sources on this model's task; report under one of them, or wait for one to go \
460 stale after {} days",
461 EXTERNAL_GRADE_MAX_AGE_SECS / 86_400
462 ),
463 }
464 }
465}
466
467#[derive(Debug, Clone, Default, PartialEq)]
469pub struct ResolveReport {
470 pub resolved: usize,
472 pub dropped: Vec<String>,
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct ModelProfile {
480 pub model_id: String,
481 pub total_calls: u64,
482 pub success_count: u64,
483 pub fail_count: u64,
484 pub total_latency_ms: u64,
485 #[serde(default)]
489 pub total_input_tokens: u64,
490 #[serde(default)]
492 pub total_output_tokens: u64,
493 #[serde(default)]
495 pub total_cache_read_input_tokens: u64,
496 #[serde(default)]
498 pub total_cache_creation_input_tokens: u64,
499 pub task_stats: HashMap<String, TaskStats>,
501 pub ema_quality: f64,
503 #[serde(default)]
506 pub prior_sample_size: usize,
507 #[serde(default)]
510 pub quality_observations: u64,
511 #[serde(default)]
516 pub quality_per_1k_tokens: f64,
517 #[serde(default)]
520 pub quality_evidence_version: u32,
521 pub updated_at: u64,
523}
524
525impl ModelProfile {
526 pub fn new(model_id: String) -> Self {
527 Self {
528 model_id,
529 total_calls: 0,
530 success_count: 0,
531 fail_count: 0,
532 total_latency_ms: 0,
533 total_input_tokens: 0,
534 total_output_tokens: 0,
535 total_cache_read_input_tokens: 0,
536 total_cache_creation_input_tokens: 0,
537 task_stats: HashMap::new(),
538 ema_quality: NEUTRAL_QUALITY,
539 prior_sample_size: 0,
540 quality_observations: 0,
541 quality_per_1k_tokens: 0.0,
542 quality_evidence_version: QUALITY_EVIDENCE_VERSION,
543 updated_at: now_unix(),
544 }
545 }
546
547 pub fn success_rate_resolved(&self) -> Option<f64> {
556 let resolved = self.success_count + self.fail_count;
557 if resolved == 0 {
558 None
559 } else {
560 Some(self.success_count as f64 / resolved as f64)
561 }
562 }
563
564 pub fn avg_latency_ms(&self) -> f64 {
565 if self.total_calls == 0 {
566 return 0.0;
567 }
568 self.total_latency_ms as f64 / self.total_calls as f64
569 }
570
571 pub fn should_degrade(&self, threshold: u64) -> bool {
573 self.fail_count > self.success_count + threshold
574 }
575
576 pub fn task_stats(&self, task: InferenceTask) -> Option<&TaskStats> {
578 self.task_stats.get(&task.to_string())
579 }
580
581 pub fn total_tokens(&self) -> u64 {
585 self.total_input_tokens
586 + self.total_cache_read_input_tokens
587 + self.total_cache_creation_input_tokens
588 + self.total_output_tokens
589 }
590
591 pub fn compute_quality_per_1k_tokens(&self) -> f64 {
594 let total = self.total_tokens();
595 if total == 0 {
596 return 0.0;
597 }
598 self.ema_quality * 1000.0 / total as f64
599 }
600
601 pub fn tokens_per_success(&self) -> Option<f64> {
608 if self.success_count == 0 {
609 None
610 } else {
611 Some(self.total_tokens() as f64 / self.success_count as f64)
612 }
613 }
614
615 pub fn usd_per_success(
622 &self,
623 input_per_mtok: f64,
624 output_per_mtok: f64,
625 cache: CacheRates,
626 ) -> Option<f64> {
627 if self.success_count == 0 {
628 return None;
629 }
630 let usd = priced_input_usd(
631 self.total_input_tokens,
632 self.total_cache_read_input_tokens,
633 self.total_cache_creation_input_tokens,
634 input_per_mtok,
635 cache,
636 ) + self.total_output_tokens as f64 * output_per_mtok / 1_000_000.0;
637 Some(usd / self.success_count as f64)
638 }
639}
640
641#[derive(Debug, Clone, Copy, PartialEq)]
649pub struct CacheRates {
650 pub read_mult: f64,
652 pub write_mult: f64,
657}
658
659impl CacheRates {
660 pub const ANTHROPIC: Self = Self {
662 read_mult: 0.1,
663 write_mult: 1.25,
664 };
665 pub const OPENAI: Self = Self {
667 read_mult: 0.5,
668 write_mult: 0.0,
669 };
670 pub const NONE: Self = Self {
674 read_mult: 0.0,
675 write_mult: 0.0,
676 };
677}
678
679impl Default for CacheRates {
680 fn default() -> Self {
683 Self::ANTHROPIC
684 }
685}
686
687pub fn priced_input_usd(
693 uncached_input_tokens: u64,
694 cache_read_input_tokens: u64,
695 cache_creation_input_tokens: u64,
696 input_per_mtok: f64,
697 cache: CacheRates,
698) -> f64 {
699 (uncached_input_tokens as f64 * input_per_mtok
700 + cache_read_input_tokens as f64 * input_per_mtok * cache.read_mult
701 + cache_creation_input_tokens as f64 * input_per_mtok * cache.write_mult)
702 / 1_000_000.0
703}
704
705const EMA_ALPHA: f64 = 0.2;
707
708#[derive(Debug, Clone, Serialize, Deserialize)]
715pub struct OutcomeLedgerEntry {
716 pub trace_id: String,
717 pub model_id: String,
718 pub task: InferenceTask,
719 pub routing_reason: String,
720 pub latency_ms: u64,
721 pub input_tokens: usize,
722 pub output_tokens: usize,
723 #[serde(default, skip_serializing_if = "is_zero_usize")]
727 pub cache_read_input_tokens: usize,
728 #[serde(default, skip_serializing_if = "is_zero_usize")]
730 pub cache_creation_input_tokens: usize,
731 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub success: Option<bool>,
733 #[serde(default, skip_serializing_if = "Option::is_none")]
734 pub quality: Option<f64>,
735 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub error: Option<String>,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
741 pub project_id: Option<String>,
742 #[serde(default, skip_serializing_if = "Option::is_none")]
745 pub intent: Option<String>,
746 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub attribution: Option<UnattributedReason>,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub car_version: Option<String>,
758 pub timestamp: u64,
759}
760
761impl OutcomeLedgerEntry {
762 pub fn counts_against_model(&self) -> bool {
782 self.success == Some(false)
783 && self.attribution.is_none()
784 && self.car_version.as_deref() == Some(env!("CARGO_PKG_VERSION"))
785 }
786}
787
788#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
793#[serde(rename_all = "snake_case")]
794pub enum UnattributedReason {
795 Capability,
798 Account,
801 ResourceBlocked,
804 ContentRefused,
806 Cancelled,
809 Unregistered,
812}
813
814fn is_zero_usize(n: &usize) -> bool {
818 *n == 0
819}
820
821const MAX_LEDGER_BUFFER: usize = 5000;
824
825const MAX_LEDGER_ERROR_CHARS: usize = 256;
830
831fn redact_error(error: &str) -> String {
833 if error.chars().count() <= MAX_LEDGER_ERROR_CHARS {
834 return error.to_string();
835 }
836 let truncated: String = error.chars().take(MAX_LEDGER_ERROR_CHARS).collect();
837 format!("{truncated}…")
838}
839
840fn is_no_answer_failure(error: &str) -> bool {
866 let e = error.to_ascii_lowercase();
867 const NEEDLES: &[&str] = &[
868 "timeout",
870 "timed out",
871 "deadline",
872 "connection",
873 "connect error",
874 "reset by peer",
875 "broken pipe",
876 "network",
877 "unreachable",
878 "eof",
879 "stream closed",
880 "socket",
881 "503",
882 "502",
883 "504",
884 "internal server error",
891 "service unavailable",
892 "temporarily unavailable",
893 "overloaded",
894 "unauthorized",
903 "unauthenticated",
904 "forbidden",
905 "permission denied",
906 "authentication",
907 "invalid_client",
908 "invalid api key",
909 "bad request",
910 ];
911 NEEDLES.iter().any(|n| e.contains(n))
912}
913
914pub fn prune_ledger(path: &std::path::Path, max_entries: usize) -> std::io::Result<()> {
918 if max_entries == 0 || !path.exists() {
919 return Ok(());
920 }
921 let entries = read_ledger(path, 0);
922 if entries.len() <= max_entries {
923 return Ok(());
924 }
925 rewrite_ledger(path, &entries[entries.len() - max_entries..])
926}
927
928fn rewrite_ledger(path: &std::path::Path, entries: &[OutcomeLedgerEntry]) -> std::io::Result<()> {
935 use std::io::Write;
936 let dir = path
937 .parent()
938 .filter(|d| !d.as_os_str().is_empty())
939 .unwrap_or_else(|| std::path::Path::new("."));
940 let mut tmp = tempfile::Builder::new()
941 .prefix(".outcome_ledger.")
942 .suffix(".tmp")
943 .tempfile_in(dir)?;
944 #[cfg(unix)]
945 {
946 use std::os::unix::fs::PermissionsExt;
947 tmp.as_file()
948 .set_permissions(std::fs::Permissions::from_mode(0o600))?;
949 }
950 for e in entries {
951 let line = serde_json::to_string(e).map_err(std::io::Error::other)?;
952 tmp.write_all(line.as_bytes())?;
953 tmp.write_all(b"\n")?;
954 }
955 tmp.as_file().sync_all()?;
956 car_secrets::harden_owner_only(tmp.path());
957 tmp.persist(path).map_err(|e| e.error)?;
958 #[cfg(unix)]
959 if let Ok(d) = std::fs::File::open(dir) {
960 let _ = d.sync_all();
961 }
962 Ok(())
963}
964
965#[derive(Debug, Default, Clone, PartialEq, Eq)]
968pub struct LedgerReconciliation {
969 pub reclassified: usize,
972 pub unregistered: usize,
975 pub profiles_rebuilt: usize,
977 pub profiles_removed: usize,
979}
980
981fn legacy_unattributed(error: &str) -> Option<UnattributedReason> {
992 use UnattributedReason as R;
993 const PREFIXES: &[(&str, UnattributedReason)] = &[
994 (
997 "Not enough memory is free to start this model",
998 R::ResourceBlocked,
999 ),
1000 ("This model needs about ", R::ResourceBlocked),
1001 (
1002 "Local model loading is disabled by the 0 GB allocation",
1003 R::ResourceBlocked,
1004 ),
1005 ("CAR could not measure live memory", R::ResourceBlocked),
1006 (
1007 "This local model is being removed or maintained",
1008 R::ResourceBlocked,
1009 ),
1010 (
1011 "CAR is still confirming that the previous local model process exited",
1012 R::ResourceBlocked,
1013 ),
1014 (
1017 "inference failed: no API keys available for endpoint ",
1018 R::Account,
1019 ),
1020 (
1022 "inference failed: Parslee org lookup failed: HTTP 401",
1023 R::Account,
1024 ),
1025 (
1028 "inference failed: FoundationModels declined the request (",
1029 R::ContentRefused,
1030 ),
1031 ];
1032 if let Some((_, reason)) = PREFIXES
1033 .iter()
1034 .find(|(prefix, _)| error.starts_with(prefix))
1035 {
1036 return Some(*reason);
1037 }
1038 (error.starts_with("inference failed: mode ") && error.contains(" not implemented on backend "))
1041 .then_some(R::Capability)
1042}
1043
1044#[derive(Debug, Clone, Default)]
1049pub struct QualitySeed {
1050 pub overall: Option<f64>,
1051 pub tasks: HashMap<String, f64>,
1052}
1053
1054#[derive(Debug, Default)]
1058pub struct ReconcilePlan {
1059 pub report: LedgerReconciliation,
1060 pub ledger: Option<Vec<OutcomeLedgerEntry>>,
1061 pub profiles: Option<HashMap<String, ModelProfile>>,
1062}
1063
1064pub fn plan_reconcile(
1105 path: &std::path::Path,
1106 tracker: &OutcomeTracker,
1107 is_registered: &dyn Fn(&str) -> bool,
1108 seeds: &HashMap<String, QualitySeed>,
1109) -> ReconcilePlan {
1110 let mut plan = ReconcilePlan::default();
1111 let mut profiles = tracker.profiles.clone();
1114 let mut profiles_changed = false;
1115 if !path.exists() {
1116 return plan;
1117 }
1118 let mut entries = read_ledger(path, 0);
1119
1120 let ran: std::collections::HashSet<&str> = entries
1121 .iter()
1122 .filter(|e| e.success == Some(true))
1123 .map(|e| e.model_id.as_str())
1124 .collect();
1125 let never_ran: std::collections::HashSet<String> = entries
1126 .iter()
1127 .map(|e| e.model_id.as_str())
1128 .filter(|id| {
1129 !ran.contains(id)
1130 && !is_registered(id)
1131 && profiles.get(*id).is_none_or(|p| p.success_count == 0)
1132 })
1133 .map(str::to_string)
1134 .collect();
1135
1136 let mut touched = std::collections::BTreeSet::new();
1137 let mut unbooked: HashMap<String, HashMap<String, u64>> = HashMap::new();
1140 for entry in &mut entries {
1141 if entry.attribution.is_some() {
1142 continue;
1143 }
1144 let legacy = entry.error.as_deref().and_then(legacy_unattributed);
1145 let reason = if never_ran.contains(&entry.model_id) && entry.success == Some(false) {
1146 UnattributedReason::Unregistered
1147 } else if let Some(reason) = legacy.filter(|_| entry.success != Some(true)) {
1148 reason
1152 } else {
1153 continue;
1154 };
1155 match reason {
1156 UnattributedReason::Unregistered => plan.report.unregistered += 1,
1157 _ if entry.success == Some(false) => plan.report.reclassified += 1,
1158 _ => {}
1159 }
1160 if entry.success == Some(false) {
1161 *unbooked
1162 .entry(entry.model_id.clone())
1163 .or_default()
1164 .entry(entry.task.to_string())
1165 .or_default() += 1;
1166 }
1167 entry.success = None;
1168 entry.quality = None;
1169 entry.attribution = Some(reason);
1170 touched.insert(entry.model_id.clone());
1171 }
1172 let ledger_changed = !touched.is_empty();
1173
1174 for profile in profiles.values_mut() {
1176 let floor = profile.success_count + profile.fail_count;
1177 if profile.total_calls < floor {
1178 profile.total_calls = floor;
1179 plan.report.profiles_rebuilt += 1;
1180 profiles_changed = true;
1181 }
1182 }
1183
1184 for model_id in &touched {
1185 if never_ran.contains(model_id) {
1186 if profiles.remove(model_id).is_some() {
1187 plan.report.profiles_removed += 1;
1188 profiles_changed = true;
1189 }
1190 continue;
1191 }
1192 let Some(profile) = profiles.get_mut(model_id) else {
1193 continue;
1194 };
1195 let rows: Vec<&OutcomeLedgerEntry> =
1196 entries.iter().filter(|e| &e.model_id == model_id).collect();
1197 let seed = seeds.get(model_id);
1198 let unbook = unbooked.get(model_id);
1199 if correct_profile(profile, &rows, unbook, seed) {
1200 plan.report.profiles_rebuilt += 1;
1201 profiles_changed = true;
1202 }
1203 }
1204
1205 if ledger_changed {
1206 plan.ledger = Some(entries);
1207 }
1208 if profiles_changed {
1209 plan.profiles = Some(profiles);
1210 }
1211 plan
1212}
1213
1214pub fn write_reconciled_ledger(
1217 path: &std::path::Path,
1218 entries: &[OutcomeLedgerEntry],
1219) -> std::io::Result<()> {
1220 rewrite_ledger(path, entries)
1221}
1222
1223fn correct_profile(
1227 profile: &mut ModelProfile,
1228 rows: &[&OutcomeLedgerEntry],
1229 unbook: Option<&HashMap<String, u64>>,
1230 seed: Option<&QualitySeed>,
1231) -> bool {
1232 let before = serde_json::to_value(&*profile).ok();
1233
1234 for (task, n) in unbook.into_iter().flatten() {
1236 profile.total_calls = profile.total_calls.saturating_sub(*n);
1237 profile.fail_count = profile.fail_count.saturating_sub(*n);
1238 if let Some(ts) = profile.task_stats.get_mut(task) {
1239 ts.failures = ts.failures.saturating_sub(*n);
1240 }
1241 }
1242
1243 let attributed: Vec<&OutcomeLedgerEntry> = rows
1245 .iter()
1246 .copied()
1247 .filter(|e| e.attribution.is_none())
1248 .collect();
1249 let in_task =
1250 |e: &&OutcomeLedgerEntry, task: Option<&str>| task.is_none_or(|t| e.task.to_string() == t);
1251
1252 let graded: Vec<(&OutcomeLedgerEntry, f64)> = attributed
1256 .iter()
1257 .copied()
1258 .filter(|e| e.success != Some(false))
1259 .filter_map(|e| e.quality.map(|q| (e, q)))
1260 .collect();
1261 let replay = |start: f64, task: Option<&str>| {
1262 let mut ema = start;
1263 let mut n = 0u64;
1264 for (_, q) in graded.iter().filter(|(e, _)| in_task(e, task)) {
1265 ema = ema * (1.0 - EMA_ALPHA) + q * EMA_ALPHA;
1266 n += 1;
1267 }
1268 (ema, n)
1269 };
1270 let profile_start = match (profile.prior_sample_size, seed.and_then(|s| s.overall)) {
1273 (0, _) => Some(NEUTRAL_QUALITY),
1274 (_, Some(seeded)) => Some(seeded.clamp(0.0, 1.0)),
1275 (_, None) => None,
1276 };
1277 if let Some(start) = profile_start {
1278 let (ema, n) = replay(start, None);
1279 if profile.quality_observations > n {
1280 profile.ema_quality = ema;
1281 profile.quality_observations = n;
1282 }
1283 }
1284 for (task, ts) in profile.task_stats.iter_mut() {
1285 let seeded = seed.and_then(|s| s.tasks.get(task)).copied();
1286 let start = match (ts.prior_sample_size, seeded) {
1287 (0, _) => Some(TaskStats::default().ema_quality),
1288 (_, Some(seeded)) => Some(seeded.clamp(0.0, 1.0)),
1289 (_, None) => None,
1290 };
1291 let Some(start) = start else { continue };
1292 let (ema, n) = replay(start, Some(task));
1293 if ts.quality_observations > n {
1294 ts.ema_quality = if n == 0 && ts.prior_sample_size == 0 {
1298 NEUTRAL_QUALITY
1299 } else {
1300 ema
1301 };
1302 ts.quality_observations = n;
1303 }
1304 }
1305 let changed = serde_json::to_value(&*profile).ok() != before;
1306 if changed {
1307 profile.updated_at = now_unix();
1308 }
1309 changed
1310}
1311
1312pub fn append_ledger_entries(
1317 path: &std::path::Path,
1318 entries: &[OutcomeLedgerEntry],
1319) -> std::io::Result<()> {
1320 if entries.is_empty() {
1321 return Ok(());
1322 }
1323 if let Some(parent) = path.parent() {
1324 std::fs::create_dir_all(parent)?;
1325 }
1326 use std::io::Write;
1327 let mut opts = std::fs::OpenOptions::new();
1328 opts.create(true).append(true);
1329 #[cfg(unix)]
1332 {
1333 use std::os::unix::fs::OpenOptionsExt;
1334 opts.mode(0o600);
1335 }
1336 let existed = path.exists();
1339 let mut f = opts.open(path)?;
1340 if !existed {
1341 car_secrets::harden_owner_only(path);
1342 }
1343 for e in entries {
1344 let line = serde_json::to_string(e).map_err(std::io::Error::other)?;
1345 f.write_all(line.as_bytes())?;
1346 f.write_all(b"\n")?;
1347 }
1348 Ok(())
1349}
1350
1351pub fn read_ledger(path: &std::path::Path, limit: usize) -> Vec<OutcomeLedgerEntry> {
1355 let Ok(content) = std::fs::read_to_string(path) else {
1356 return Vec::new();
1357 };
1358 let mut out: Vec<OutcomeLedgerEntry> = content
1359 .lines()
1360 .filter(|l| !l.trim().is_empty())
1361 .filter_map(|l| serde_json::from_str(l).ok())
1362 .collect();
1363 if limit > 0 && out.len() > limit {
1364 out = out.split_off(out.len() - limit);
1365 }
1366 out
1367}
1368
1369tokio::task_local! {
1370 static LEARNING: bool;
1373 static DISPATCHED: std::sync::Arc<std::sync::atomic::AtomicBool>;
1376}
1377
1378pub async fn scope_dispatch_probe<F: std::future::Future>(
1383 probe: std::sync::Arc<std::sync::atomic::AtomicBool>,
1384 future: F,
1385) -> F::Output {
1386 DISPATCHED.scope(probe, future).await
1387}
1388
1389pub(crate) fn note_dispatch() {
1393 let _ = DISPATCHED.try_with(|probe| probe.store(true, std::sync::atomic::Ordering::SeqCst));
1394}
1395
1396pub async fn scope_learning<F: std::future::Future>(learn: bool, future: F) -> F::Output {
1402 LEARNING.scope(learn, future).await
1403}
1404
1405pub(crate) fn learning_suppressed() -> bool {
1406 LEARNING.try_with(|learn| !*learn).unwrap_or(false)
1407}
1408
1409pub const PENDING_GRADE_TTL_SECS: u64 = 15 * 60;
1415
1416pub const PENDING_IN_FLIGHT_TTL_SECS: u64 = 2 * 60 * 60;
1419
1420pub struct OutcomeTracker {
1422 profiles: HashMap<String, ModelProfile>,
1424 pending: HashMap<String, InferenceOutcome>,
1427 trace_counter: u64,
1429 excluded: HashSet<String>,
1431 dirty: bool,
1437 ledger_buffer: std::collections::VecDeque<OutcomeLedgerEntry>,
1440}
1441
1442impl OutcomeTracker {
1443 pub fn new() -> Self {
1444 Self {
1445 profiles: HashMap::new(),
1446 pending: HashMap::new(),
1447 trace_counter: 0,
1448 excluded: HashSet::new(),
1449 dirty: false,
1450 ledger_buffer: std::collections::VecDeque::new(),
1451 }
1452 }
1453
1454 fn push_ledger(&mut self, entry: OutcomeLedgerEntry) {
1456 if self.ledger_buffer.len() >= MAX_LEDGER_BUFFER {
1457 self.ledger_buffer.pop_front();
1458 }
1459 self.ledger_buffer.push_back(entry);
1460 }
1461
1462 pub fn drain_ledger(&mut self) -> Vec<OutcomeLedgerEntry> {
1464 self.ledger_buffer.drain(..).collect()
1465 }
1466
1467 pub fn is_excluded(&self, model_id: &str) -> bool {
1469 self.excluded.contains(model_id)
1470 }
1471
1472 pub fn record_start(
1478 &mut self,
1479 model_id: &str,
1480 task: InferenceTask,
1481 routing_reason: &str,
1482 ) -> String {
1483 self.trace_counter += 1;
1484 let trace_id = format!("t-{}-{}", now_unix(), self.trace_counter);
1485 note_dispatch();
1486 if learning_suppressed() {
1487 return trace_id;
1488 }
1489
1490 let outcome = InferenceOutcome {
1491 trace_id: trace_id.clone(),
1492 model_id: model_id.to_string(),
1493 task,
1494 routing_reason: redact_error(routing_reason),
1499 latency_ms: 0,
1500 input_tokens: 0,
1501 output_tokens: 0,
1502 cache_read_input_tokens: 0,
1503 cache_creation_input_tokens: 0,
1504 inferred_outcome: None,
1505 code_outcome: None,
1506 error: None,
1507 timestamp: now_unix(),
1508 success_credited: false,
1509 completed_at: None,
1510 };
1511
1512 self.pending.insert(trace_id.clone(), outcome);
1513 trace_id
1514 }
1515
1516 pub fn record_complete(
1518 &mut self,
1519 trace_id: &str,
1520 latency_ms: u64,
1521 input_tokens: usize,
1522 output_tokens: usize,
1523 ) {
1524 self.record_complete_cached(trace_id, latency_ms, input_tokens, output_tokens, 0, 0);
1525 }
1526
1527 pub fn record_complete_cached(
1535 &mut self,
1536 trace_id: &str,
1537 latency_ms: u64,
1538 input_tokens: usize,
1539 output_tokens: usize,
1540 cache_read_input_tokens: usize,
1541 cache_creation_input_tokens: usize,
1542 ) {
1543 if let Some(outcome) = self.pending.get_mut(trace_id) {
1544 outcome.completed_at = Some(now_unix());
1545 outcome.latency_ms = latency_ms;
1546 outcome.input_tokens = input_tokens;
1547 outcome.output_tokens = output_tokens;
1548 outcome.cache_read_input_tokens = cache_read_input_tokens;
1549 outcome.cache_creation_input_tokens = cache_creation_input_tokens;
1550
1551 let mechanical_success = output_tokens > 0 && outcome.error.is_none();
1564 if mechanical_success {
1565 outcome.success_credited = true;
1566 }
1567 let model_id = outcome.model_id.clone();
1568 let task_key = outcome.task.to_string();
1569
1570 let profile = self
1573 .profiles
1574 .entry(model_id.clone())
1575 .or_insert_with(|| ModelProfile::new(model_id));
1576
1577 profile.total_calls += 1;
1578 profile.total_latency_ms += latency_ms;
1579 profile.total_input_tokens += input_tokens as u64;
1580 profile.total_output_tokens += output_tokens as u64;
1581 profile.total_cache_read_input_tokens += cache_read_input_tokens as u64;
1582 profile.total_cache_creation_input_tokens += cache_creation_input_tokens as u64;
1583 if mechanical_success {
1584 profile.success_count += 1;
1585 }
1586
1587 let ts = profile.task_stats.entry(task_key).or_default();
1588 ts.calls += 1;
1589 if mechanical_success {
1590 ts.successes += 1;
1591 }
1592 ts.avg_latency_ms =
1593 ts.avg_latency_ms + (latency_ms as f64 - ts.avg_latency_ms) / ts.calls as f64;
1594
1595 profile.updated_at = now_unix();
1596 self.dirty = true;
1597 }
1598 }
1599
1600 pub fn record_failure(&mut self, trace_id: &str, error: &str) {
1602 let mut ledger_entry = None;
1603 if let Some(outcome) = self.pending.get_mut(trace_id) {
1604 outcome.error = Some(error.to_string());
1605
1606 let profile = self
1607 .profiles
1608 .entry(outcome.model_id.clone())
1609 .or_insert_with(|| ModelProfile::new(outcome.model_id.clone()));
1610
1611 profile.total_calls += 1;
1617 profile.fail_count += 1;
1618
1619 let is_rate_limited = error.contains("429") || error.contains("RESOURCE_EXHAUSTED");
1622 let is_no_answer = !is_rate_limited && is_no_answer_failure(error);
1630 if is_rate_limited {
1631 self.excluded.insert(outcome.model_id.clone());
1633 }
1634 let task_key = outcome.task.to_string();
1639 let ts = profile.task_stats.entry(task_key).or_default();
1640 ts.failures += 1;
1643
1644 profile.updated_at = now_unix();
1645 self.dirty = true;
1646
1647 ledger_entry = Some(OutcomeLedgerEntry {
1648 trace_id: outcome.trace_id.clone(),
1649 model_id: outcome.model_id.clone(),
1650 task: outcome.task,
1651 routing_reason: outcome.routing_reason.clone(),
1652 latency_ms: outcome.latency_ms,
1653 input_tokens: outcome.input_tokens,
1654 output_tokens: outcome.output_tokens,
1655 cache_read_input_tokens: outcome.cache_read_input_tokens,
1656 cache_creation_input_tokens: outcome.cache_creation_input_tokens,
1657 success: Some(false),
1658 quality: if is_rate_limited || is_no_answer {
1667 None
1668 } else {
1669 Some(0.0)
1670 },
1671 error: Some(redact_error(error)),
1672 project_id: None,
1673 intent: None,
1674 attribution: None,
1675 car_version: Some(env!("CARGO_PKG_VERSION").to_string()),
1676 timestamp: now_unix(),
1677 });
1678 }
1679
1680 if let Some(entry) = ledger_entry {
1681 self.push_ledger(entry);
1682 }
1683
1684 self.pending.remove(trace_id);
1686 }
1687
1688 pub fn record_capability_rejection(&mut self, trace_id: &str, error: &str) {
1695 self.record_not_model_fault(trace_id, error, UnattributedReason::Capability);
1696 }
1697
1698 pub fn record_account_rejection(&mut self, trace_id: &str, error: &str) {
1707 self.record_not_model_fault(trace_id, error, UnattributedReason::Account);
1708 }
1709
1710 pub fn record_resource_rejection(&mut self, trace_id: &str, error: &str) {
1719 self.record_not_model_fault(trace_id, error, UnattributedReason::ResourceBlocked);
1720 }
1721
1722 pub fn commit_reconciled(&mut self, profiles: HashMap<String, ModelProfile>) {
1727 self.profiles = profiles;
1728 self.dirty = true;
1729 }
1730
1731 pub fn note_elapsed(&mut self, trace_id: &str, latency_ms: u64) {
1736 if let Some(outcome) = self.pending.get_mut(trace_id) {
1737 outcome.latency_ms = latency_ms;
1738 outcome.completed_at.get_or_insert_with(now_unix);
1739 }
1740 }
1741
1742 pub fn record_not_model_fault(
1746 &mut self,
1747 trace_id: &str,
1748 error: &str,
1749 reason: UnattributedReason,
1750 ) {
1751 if let Some(outcome) = self.pending.remove(trace_id) {
1752 self.push_ledger(OutcomeLedgerEntry {
1753 trace_id: outcome.trace_id,
1754 model_id: outcome.model_id,
1755 task: outcome.task,
1756 routing_reason: outcome.routing_reason,
1757 latency_ms: outcome.latency_ms,
1758 input_tokens: outcome.input_tokens,
1759 output_tokens: outcome.output_tokens,
1760 cache_read_input_tokens: outcome.cache_read_input_tokens,
1761 cache_creation_input_tokens: outcome.cache_creation_input_tokens,
1762 success: None,
1763 quality: None,
1764 error: Some(redact_error(error)),
1765 project_id: None,
1766 intent: None,
1767 attribution: Some(reason),
1768 car_version: Some(env!("CARGO_PKG_VERSION").to_string()),
1769 timestamp: now_unix(),
1770 });
1771 }
1772 }
1773
1774 pub fn record_inferred_outcome(&mut self, trace_id: &str, outcome: InferredOutcome) {
1776 if let Some(pending) = self.pending.remove(trace_id) {
1777 self.apply_outcome(&pending, outcome.quality_score(), outcome.is_success());
1778 }
1779 }
1780
1781 pub fn record_code_outcome(&mut self, trace_id: &str, outcome: CodeOutcome) {
1783 if let Some(pending) = self.pending.remove(trace_id) {
1784 self.apply_outcome(
1785 &pending,
1786 Some(outcome.quality_score()),
1787 Some(outcome.is_success()),
1788 );
1789 }
1790 }
1791
1792 pub fn resolve_pending_from_signals(&mut self, outcomes: Vec<(String, InferredOutcome)>) {
1795 self.resolve_pending_reporting(outcomes);
1796 }
1797
1798 pub fn resolve_pending_reporting(
1802 &mut self,
1803 outcomes: Vec<(String, InferredOutcome)>,
1804 ) -> ResolveReport {
1805 let mut report = ResolveReport::default();
1806 for (trace_id, inferred) in outcomes {
1807 if self.has_pending(&trace_id) {
1808 self.record_inferred_outcome(&trace_id, inferred);
1809 report.resolved += 1;
1810 } else {
1811 report.dropped.push(trace_id);
1812 }
1813 }
1814 report
1815 }
1816
1817 pub fn record_external_grade(
1819 &mut self,
1820 model_id: &str,
1821 task: InferenceTask,
1822 quality: f64,
1823 n: u32,
1824 source: &str,
1825 ) -> Result<ExternalGradeStats, ExternalGradeRefusal> {
1826 self.record_external_grade_as(None, model_id, task, quality, n, source)
1827 }
1828
1829 pub fn record_external_grade_as(
1845 &mut self,
1846 reporter: Option<&str>,
1847 model_id: &str,
1848 task: InferenceTask,
1849 quality: f64,
1850 n: u32,
1851 source: &str,
1852 ) -> Result<ExternalGradeStats, ExternalGradeRefusal> {
1853 self.record_external_grade_at(reporter, model_id, task, quality, n, source, now_unix())
1854 }
1855
1856 #[allow(clippy::too_many_arguments)]
1858 fn record_external_grade_at(
1859 &mut self,
1860 reporter: Option<&str>,
1861 model_id: &str,
1862 task: InferenceTask,
1863 quality: f64,
1864 n: u32,
1865 source: &str,
1866 now: u64,
1867 ) -> Result<ExternalGradeStats, ExternalGradeRefusal> {
1868 if model_id.trim().is_empty() || model_id.len() > 256 {
1869 return Err(ExternalGradeRefusal::InvalidModel);
1870 }
1871 if !quality.is_finite() || !(0.0..=1.0).contains(&quality) {
1872 return Err(ExternalGradeRefusal::InvalidQuality);
1873 }
1874 if n == 0 || n > MAX_EXTERNAL_GRADE_CASES {
1875 return Err(ExternalGradeRefusal::InvalidCases);
1876 }
1877 if source.is_empty()
1878 || source.len() > MAX_EXTERNAL_GRADE_SOURCE_LEN
1879 || !source
1880 .bytes()
1881 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'))
1882 {
1883 return Err(ExternalGradeRefusal::InvalidSource);
1884 }
1885 if let Some(reporter) = reporter {
1886 if reporter.is_empty()
1887 || reporter.len() > MAX_EXTERNAL_GRADE_REPORTER_LEN
1888 || !reporter.bytes().all(|b| b.is_ascii_graphic() && b != b'/')
1889 {
1890 return Err(ExternalGradeRefusal::InvalidReporter);
1891 }
1892 }
1893 let key = external_grade_key(reporter, source);
1894
1895 let mut evict = None;
1898 if let Some(sources) = self
1899 .profiles
1900 .get(model_id)
1901 .and_then(|p| p.task_stats.get(&task.to_string()))
1902 .map(|ts| &ts.external_grades)
1903 {
1904 if !sources.contains_key(&key) {
1905 let own: Vec<(&String, &ExternalGradeStats)> = sources
1906 .iter()
1907 .filter(|(k, _)| external_grade_reporter(k) == reporter)
1908 .collect();
1909 if own.len() >= MAX_EXTERNAL_GRADE_SOURCES {
1910 match own.into_iter().min_by_key(|(_, g)| g.updated_at) {
1911 Some((stalest, g)) if g.is_stale_at(now) => {
1912 evict = Some(stalest.clone());
1913 }
1914 _ => return Err(ExternalGradeRefusal::SourceLimitReached),
1915 }
1916 }
1917 }
1918 }
1919
1920 let profile = self
1921 .profiles
1922 .entry(model_id.to_string())
1923 .or_insert_with(|| ModelProfile::new(model_id.to_string()));
1924 let ts = profile.task_stats.entry(task.to_string()).or_default();
1925 if let Some(stalest) = evict {
1926 ts.external_grades.remove(&stalest);
1927 }
1928 let fresh = ExternalGradeStats {
1929 quality,
1930 observations: 0,
1931 updated_at: now,
1932 };
1933 let grade = ts.external_grades.entry(key).or_insert(fresh.clone());
1934 if grade.is_stale_at(now) {
1935 *grade = fresh;
1937 }
1938 let n = u64::from(n);
1939 let observations = grade.observations.saturating_add(n);
1940 let window = observations.min(EXTERNAL_GRADE_EVIDENCE_CAP);
1941 let weight = n.min(window) as f64 / window as f64;
1942 grade.quality = (grade.quality * (1.0 - weight) + quality * weight).clamp(0.0, 1.0);
1943 grade.observations = observations;
1944 grade.updated_at = now;
1945 let stats = grade.clone();
1946 profile.updated_at = now;
1947 self.dirty = true;
1948 Ok(stats)
1949 }
1950
1951 pub fn infer_outcomes_from_action_sequence(
1959 &self,
1960 action_results: &[(String, bool, f64, String)], ) -> Vec<(String, InferredOutcome)> {
1962 let mut outcomes = Vec::new();
1963
1964 for (i, (trace_id, success, confidence, output)) in action_results.iter().enumerate() {
1965 if trace_id.is_empty() {
1966 continue; }
1968
1969 if !success {
1970 outcomes.push((
1971 trace_id.clone(),
1972 InferredOutcome::Rejected {
1973 confidence: *confidence,
1974 },
1975 ));
1976 continue;
1977 }
1978
1979 let next_succeeded = action_results
1981 .get(i + 1)
1982 .map(|(_, s, _, _)| *s)
1983 .unwrap_or(true); let has_output = !output.trim().is_empty();
1986
1987 if has_output && next_succeeded {
1988 outcomes.push((
1989 trace_id.clone(),
1990 InferredOutcome::Accepted {
1991 confidence: *confidence,
1992 },
1993 ));
1994 } else if has_output && !next_succeeded {
1995 outcomes.push((
1997 trace_id.clone(),
1998 InferredOutcome::AcceptedWithEdits {
1999 confidence: confidence * 0.7,
2000 },
2001 ));
2002 } else {
2003 outcomes.push((trace_id.clone(), InferredOutcome::Inconclusive));
2004 }
2005 }
2006
2007 outcomes
2008 }
2009
2010 pub fn profile(&self, model_id: &str) -> Option<&ModelProfile> {
2012 self.profiles.get(model_id)
2013 }
2014
2015 pub fn has_pending(&self, trace_id: &str) -> bool {
2020 self.pending.contains_key(trace_id)
2021 }
2022
2023 pub fn all_profiles(&self) -> &HashMap<String, ModelProfile> {
2025 &self.profiles
2026 }
2027
2028 pub fn pending_trace_ids(&self) -> Vec<String> {
2030 self.pending.keys().cloned().collect()
2031 }
2032
2033 pub fn get_pending(&self, trace_id: &str) -> Option<&InferenceOutcome> {
2035 self.pending.get(trace_id)
2036 }
2037
2038 pub fn export_profiles(&self) -> Vec<ModelProfile> {
2042 self.profiles
2043 .values()
2044 .cloned()
2045 .map(|mut p| {
2046 p.quality_per_1k_tokens = p.compute_quality_per_1k_tokens();
2047 p
2048 })
2049 .collect()
2050 }
2051
2052 pub fn import_profiles(&mut self, profiles: Vec<ModelProfile>) {
2059 for p in profiles {
2060 self.profiles.insert(p.model_id.clone(), p);
2061 }
2062 self.dirty = true;
2063 }
2064
2065 pub fn save_to_file(&self, path: &std::path::Path) -> Result<(), std::io::Error> {
2073 let profiles = self.export_profiles();
2074 let json = serde_json::to_string_pretty(&profiles).map_err(std::io::Error::other)?;
2075 if let Some(parent) = path.parent() {
2076 std::fs::create_dir_all(parent)?;
2077 }
2078 let tmp = path.with_extension("json.tmp");
2079 std::fs::write(&tmp, json)?;
2080 std::fs::rename(&tmp, path)
2081 }
2082
2083 pub fn is_dirty(&self) -> bool {
2085 self.dirty
2086 }
2087
2088 pub fn save_if_dirty(&mut self, path: &std::path::Path) -> Result<bool, std::io::Error> {
2092 if !self.dirty {
2093 return Ok(false);
2094 }
2095 self.save_to_file(path)?;
2096 self.dirty = false;
2097 Ok(true)
2098 }
2099
2100 pub fn load_from_file(&mut self, path: &std::path::Path) -> Result<usize, std::io::Error> {
2102 if !path.exists() {
2103 return Ok(0);
2104 }
2105 let json = std::fs::read_to_string(path)?;
2106 let profiles: Vec<ModelProfile> = serde_json::from_str(&json)
2107 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
2108 let count = profiles.len();
2109 for mut p in profiles {
2113 migrate_quality_evidence(&mut p);
2114 self.profiles.insert(p.model_id.clone(), p);
2115 }
2116 Ok(count)
2117 }
2118
2119 fn apply_outcome(
2121 &mut self,
2122 pending: &InferenceOutcome,
2123 quality: Option<f64>,
2124 success: Option<bool>,
2125 ) {
2126 let profile = self
2127 .profiles
2128 .entry(pending.model_id.clone())
2129 .or_insert_with(|| ModelProfile::new(pending.model_id.clone()));
2130
2131 if let Some(q) = quality {
2132 profile.ema_quality = profile.ema_quality * (1.0 - EMA_ALPHA) + q * EMA_ALPHA;
2133 profile.quality_observations += 1;
2134
2135 let task_key = pending.task.to_string();
2136 let ts = profile.task_stats.entry(task_key).or_default();
2137 ts.ema_quality = ts.ema_quality * (1.0 - EMA_ALPHA) + q * EMA_ALPHA;
2138 ts.quality_observations += 1;
2139 }
2140
2141 if let Some(ok) = success {
2142 let already_credited = pending.success_credited;
2145 let task_key = pending.task.to_string();
2146 if ok {
2147 if !already_credited {
2148 profile.success_count += 1;
2149 let ts = profile.task_stats.entry(task_key).or_default();
2150 ts.successes += 1;
2151 }
2152 } else {
2154 if already_credited {
2158 profile.success_count = profile.success_count.saturating_sub(1);
2159 let ts = profile.task_stats.entry(task_key.clone()).or_default();
2160 ts.successes = ts.successes.saturating_sub(1);
2161 }
2162 profile.fail_count += 1;
2163 let ts = profile.task_stats.entry(task_key).or_default();
2164 ts.failures += 1;
2165 }
2166 }
2167
2168 profile.updated_at = now_unix();
2169 self.dirty = true;
2170
2171 self.push_ledger(OutcomeLedgerEntry {
2172 trace_id: pending.trace_id.clone(),
2173 model_id: pending.model_id.clone(),
2174 task: pending.task,
2175 routing_reason: pending.routing_reason.clone(),
2176 latency_ms: pending.latency_ms,
2177 input_tokens: pending.input_tokens,
2178 output_tokens: pending.output_tokens,
2179 cache_read_input_tokens: pending.cache_read_input_tokens,
2180 cache_creation_input_tokens: pending.cache_creation_input_tokens,
2181 success,
2182 quality,
2183 error: None,
2184 project_id: None,
2185 intent: None,
2186 attribution: None,
2187 car_version: Some(env!("CARGO_PKG_VERSION").to_string()),
2188 timestamp: now_unix(),
2189 });
2190 }
2191
2192 pub fn sweep_pending(&mut self, ttl_secs: u64) -> usize {
2202 self.sweep_pending_at(ttl_secs, now_unix())
2203 }
2204
2205 fn sweep_pending_at(&mut self, ttl_secs: u64, now: u64) -> usize {
2216 let ended_cutoff = now.saturating_sub(ttl_secs);
2217 let in_flight_cutoff = now.saturating_sub(ttl_secs.max(PENDING_IN_FLIGHT_TTL_SECS));
2218 let expired: Vec<String> = self
2219 .pending
2220 .iter()
2221 .filter(|(_, o)| match o.completed_at {
2222 Some(ended) => ended < ended_cutoff,
2223 None => o.timestamp < in_flight_cutoff,
2224 })
2225 .map(|(id, _)| id.clone())
2226 .collect();
2227 for id in &expired {
2228 if let Some(o) = self.pending.remove(id) {
2229 if o.latency_ms > 0 {
2233 let credit_now =
2251 o.output_tokens > 0 && o.error.is_none() && !o.success_credited;
2252 let was_success = o.success_credited || credit_now;
2253 if credit_now {
2254 let profile = self
2255 .profiles
2256 .entry(o.model_id.clone())
2257 .or_insert_with(|| ModelProfile::new(o.model_id.clone()));
2258 profile.success_count += 1;
2259 let ts = profile.task_stats.entry(o.task.to_string()).or_default();
2260 ts.successes += 1;
2261 profile.updated_at = now_unix();
2262 self.dirty = true;
2263 }
2264 self.push_ledger(OutcomeLedgerEntry {
2265 trace_id: o.trace_id,
2266 model_id: o.model_id,
2267 task: o.task,
2268 routing_reason: o.routing_reason,
2269 latency_ms: o.latency_ms,
2270 input_tokens: o.input_tokens,
2271 output_tokens: o.output_tokens,
2272 cache_read_input_tokens: o.cache_read_input_tokens,
2273 cache_creation_input_tokens: o.cache_creation_input_tokens,
2274 success: if was_success { Some(true) } else { None },
2275 quality: None,
2276 error: None,
2277 project_id: None,
2278 intent: None,
2279 attribution: None,
2280 car_version: Some(env!("CARGO_PKG_VERSION").to_string()),
2281 timestamp: now_unix(),
2282 });
2283 }
2284 }
2285 }
2286 expired.len()
2287 }
2288
2289 pub fn check_git_outcomes(&mut self, repo_dir: &std::path::Path) {
2297 let diff = match std::process::Command::new("git")
2298 .args(["diff", "--no-color"])
2299 .current_dir(repo_dir)
2300 .output()
2301 {
2302 Ok(output) => String::from_utf8_lossy(&output.stdout).to_string(),
2303 Err(_) => return,
2304 };
2305
2306 let staged_diff = match std::process::Command::new("git")
2307 .args(["diff", "--cached", "--no-color"])
2308 .current_dir(repo_dir)
2309 .output()
2310 {
2311 Ok(output) => String::from_utf8_lossy(&output.stdout).to_string(),
2312 Err(_) => String::new(),
2313 };
2314
2315 let combined_diff = format!("{}\n{}", diff, staged_diff);
2316
2317 if combined_diff.trim().is_empty() {
2318 return; }
2320
2321 #[cfg(feature = "ast")]
2323 let ast_outcome = Self::check_git_outcomes_ast(repo_dir);
2324
2325 let code_traces: Vec<(String, String)> = self
2326 .pending
2327 .iter()
2328 .filter(|(_, o)| matches!(o.task, InferenceTask::Code))
2329 .map(|(id, o)| (id.clone(), o.model_id.clone()))
2330 .collect();
2331
2332 for (trace_id, _model_id) in code_traces {
2333 if let Some(pending) = self.pending.get(&trace_id) {
2334 #[cfg(feature = "ast")]
2336 if let Some(ref ast_out) = ast_outcome {
2337 let pending_clone = pending.clone();
2338 self.apply_outcome(
2339 &pending_clone,
2340 Some(ast_out.quality_score()),
2341 Some(ast_out.is_success()),
2342 );
2343 continue;
2344 }
2345
2346 let output_tokens: Vec<&str> = pending
2348 .routing_reason
2349 .split_whitespace()
2350 .filter(|t| t.len() > 5)
2351 .collect();
2352
2353 let outcome = if output_tokens.iter().any(|t| combined_diff.contains(t)) {
2354 CodeOutcome::Applied
2355 } else {
2356 CodeOutcome::Modified
2357 };
2358
2359 let pending_clone = pending.clone();
2360 self.apply_outcome(
2361 &pending_clone,
2362 Some(outcome.quality_score()),
2363 Some(outcome.is_success()),
2364 );
2365 }
2366 }
2367 }
2368
2369 #[cfg(feature = "ast")]
2371 fn check_git_outcomes_ast(repo_dir: &std::path::Path) -> Option<CodeOutcome> {
2372 let name_only = std::process::Command::new("git")
2374 .args(["diff", "--name-only"])
2375 .current_dir(repo_dir)
2376 .output()
2377 .ok()?;
2378 let changed_files: Vec<&str> = std::str::from_utf8(&name_only.stdout)
2379 .ok()?
2380 .lines()
2381 .filter(|f| !f.is_empty())
2382 .collect();
2383
2384 if changed_files.is_empty() {
2385 return None;
2386 }
2387
2388 let mut has_sig_change = false;
2389 let mut has_body_change = false;
2390 let mut has_addition = false;
2391
2392 for file in &changed_files {
2393 if car_ast::Language::from_filename(file).is_none() {
2395 continue;
2396 }
2397
2398 let old_content = std::process::Command::new("git")
2400 .args(["show", &format!("HEAD:{}", file)])
2401 .current_dir(repo_dir)
2402 .output()
2403 .ok()
2404 .and_then(|o| {
2405 if o.status.success() {
2406 String::from_utf8(o.stdout).ok()
2407 } else {
2408 None
2409 }
2410 });
2411
2412 let new_path = repo_dir.join(file);
2414 let new_content = std::fs::read_to_string(&new_path).ok();
2415
2416 match (old_content, new_content) {
2417 (Some(old), Some(new)) => {
2418 let old_parsed = car_ast::parse_file(&old, file);
2419 let new_parsed = car_ast::parse_file(&new, file);
2420
2421 if let (Some(old_p), Some(new_p)) = (old_parsed, new_parsed) {
2422 let changes = car_ast::diff_symbols(&old_p, &new_p);
2423 for change in &changes {
2424 match change {
2425 car_ast::SymbolChange::Added(_) => has_addition = true,
2426 car_ast::SymbolChange::Modified {
2427 signature_changed, ..
2428 } => {
2429 if *signature_changed {
2430 has_sig_change = true;
2431 } else {
2432 has_body_change = true;
2433 }
2434 }
2435 car_ast::SymbolChange::Removed(_) => has_sig_change = true,
2436 }
2437 }
2438 }
2439 }
2440 (None, Some(_)) => has_addition = true, _ => {}
2442 }
2443 }
2444
2445 if has_sig_change {
2447 Some(CodeOutcome::SignatureChanged)
2448 } else if has_body_change {
2449 Some(CodeOutcome::BodyModified)
2450 } else if has_addition {
2451 Some(CodeOutcome::SymbolAdded)
2452 } else {
2453 None }
2455 }
2456}
2457
2458impl Default for OutcomeTracker {
2459 fn default() -> Self {
2460 Self::new()
2461 }
2462}
2463
2464pub(crate) fn now_unix() -> u64 {
2465 SystemTime::now()
2466 .duration_since(UNIX_EPOCH)
2467 .unwrap_or_default()
2468 .as_secs()
2469}
2470
2471#[cfg(test)]
2472mod quality_evidence_tests {
2473 use super::*;
2474
2475 #[test]
2478 fn a_failure_counts_against_availability_not_quality() {
2479 let mut tracker = OutcomeTracker::new();
2480 let trace = tracker.record_start("m", InferenceTask::Code, "write a parser");
2481 tracker.record_failure(&trace, "model returned malformed tool call JSON");
2482 let p = tracker.profile("m").unwrap();
2483 assert_eq!(p.fail_count, 1);
2484 assert_eq!(
2485 (p.ema_quality, p.quality_observations),
2486 (NEUTRAL_QUALITY, 0)
2487 );
2488 let ts = p.task_stats(InferenceTask::Code).unwrap();
2489 assert_eq!(ts.failures, 1);
2490 assert_eq!(
2491 (ts.ema_quality, ts.quality_observations),
2492 (NEUTRAL_QUALITY, 0)
2493 );
2494 }
2495
2496 #[test]
2498 fn a_new_task_starts_at_neutral_quality() {
2499 assert_eq!(TaskStats::default().ema_quality, NEUTRAL_QUALITY);
2500 }
2501
2502 #[test]
2506 fn a_version_zero_profile_loads_without_its_failure_derived_quality() {
2507 let mut tracker = OutcomeTracker::new();
2508 for _ in 0..5 {
2509 let trace = tracker.record_start("m", InferenceTask::Code, "x");
2510 tracker.record_failure(&trace, "generation failed");
2511 }
2512 let mut saved = serde_json::to_value(tracker.profile("m").unwrap()).unwrap();
2513 saved["ema_quality"] = serde_json::json!(0.12);
2515 saved["quality_observations"] = serde_json::json!(5);
2516 saved["task_stats"]["code"]["ema_quality"] = serde_json::json!(0.07);
2517 saved["task_stats"]["code"]["quality_observations"] = serde_json::json!(5);
2518 saved
2519 .as_object_mut()
2520 .unwrap()
2521 .remove("quality_evidence_version");
2522
2523 let dir = tempfile::tempdir().unwrap();
2524 let path = dir.path().join("outcome_profiles.json");
2525 std::fs::write(&path, serde_json::to_string(&vec![saved]).unwrap()).unwrap();
2526 let mut loaded = OutcomeTracker::new();
2527 loaded.load_from_file(&path).unwrap();
2528
2529 let p = loaded.profile("m").unwrap();
2530 assert_eq!(p.fail_count, 5, "availability survives");
2531 assert_eq!(
2532 (p.ema_quality, p.quality_observations),
2533 (NEUTRAL_QUALITY, 0)
2534 );
2535 let ts = p.task_stats(InferenceTask::Code).unwrap();
2536 assert_eq!(ts.failures, 5);
2537 assert_eq!(
2538 (ts.ema_quality, ts.quality_observations),
2539 (NEUTRAL_QUALITY, 0)
2540 );
2541 assert_eq!(p.quality_evidence_version, QUALITY_EVIDENCE_VERSION);
2542 }
2543}
2544
2545#[cfg(test)]
2546mod tests {
2547 #[tokio::test]
2551 async fn an_unlearned_call_changes_no_profile() {
2552 let mut tracker = OutcomeTracker::new();
2553 super::scope_learning(false, async {
2554 let trace = tracker.record_start("m", InferenceTask::Generate, "pinned");
2555 tracker.record_complete(&trace, 10, 5, 5);
2556 let failed = tracker.record_start("m", InferenceTask::Generate, "pinned");
2557 tracker.record_failure(&failed, "boom");
2558 })
2559 .await;
2560 assert!(tracker.profile("m").is_none());
2561 let trace = tracker.record_start("m", InferenceTask::Generate, "pinned");
2562 tracker.record_complete(&trace, 10, 5, 5);
2563 assert_eq!(
2564 tracker.profile("m").map(|profile| profile.total_calls),
2565 Some(1)
2566 );
2567 }
2568
2569 use super::*;
2570
2571 #[test]
2572 fn only_a_failure_this_car_recorded_counts_against_a_model() {
2573 let entry = |success, version: Option<&str>| OutcomeLedgerEntry {
2574 trace_id: "t".into(),
2575 model_id: "m".into(),
2576 task: InferenceTask::Generate,
2577 routing_reason: String::new(),
2578 latency_ms: 0,
2579 input_tokens: 0,
2580 output_tokens: 0,
2581 cache_read_input_tokens: 0,
2582 cache_creation_input_tokens: 0,
2583 success: Some(success),
2584 quality: None,
2585 error: Some("config.json has no vocab_size".into()),
2586 project_id: None,
2587 intent: None,
2588 attribution: None,
2589 car_version: version.map(str::to_string),
2590 timestamp: 1,
2591 };
2592 let here = env!("CARGO_PKG_VERSION");
2593 assert!(entry(false, Some(here)).counts_against_model());
2594 assert!(!entry(false, Some("0.0.1")).counts_against_model());
2596 assert!(!entry(false, None).counts_against_model());
2597 assert!(!entry(true, Some(here)).counts_against_model());
2598 }
2599
2600 #[test]
2601 fn lifecycle() {
2602 let mut tracker = OutcomeTracker::new();
2603
2604 let trace = tracker.record_start(
2606 "qwen/qwen3-4b:q4_k_m",
2607 InferenceTask::Code,
2608 "Code task -> Qwen3-4B",
2609 );
2610
2611 tracker.record_complete(&trace, 1200, 100, 50);
2613
2614 let profile = tracker.profile("qwen/qwen3-4b:q4_k_m").unwrap();
2616 assert_eq!(profile.total_calls, 1);
2617 assert_eq!(profile.avg_latency_ms(), 1200.0);
2618
2619 tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 0.9 });
2621
2622 let profile = tracker.profile("qwen/qwen3-4b:q4_k_m").unwrap();
2623 assert_eq!(profile.success_count, 1);
2624 assert!(profile.ema_quality > 0.5); }
2626
2627 #[test]
2628 fn failure_degrades() {
2629 let mut tracker = OutcomeTracker::new();
2634 for _ in 0..5 {
2635 let trace = tracker.record_start("bad-model", InferenceTask::Generate, "test");
2636 tracker.record_failure(&trace, "model produced malformed output");
2639 }
2640
2641 let profile = tracker.profile("bad-model").unwrap();
2642 assert_eq!(profile.fail_count, 5);
2643 assert_eq!(profile.success_count, 0);
2644 assert!(profile.should_degrade(2)); assert_eq!(profile.ema_quality, NEUTRAL_QUALITY);
2648 }
2649
2650 #[test]
2651 fn code_outcome_ground_truth() {
2652 let mut tracker = OutcomeTracker::new();
2653
2654 let trace = tracker.record_start("qwen/qwen3-4b:q4_k_m", InferenceTask::Code, "code");
2655 tracker.record_complete(&trace, 500, 200, 100);
2656 tracker.record_code_outcome(&trace, CodeOutcome::Applied);
2657
2658 let profile = tracker.profile("qwen/qwen3-4b:q4_k_m").unwrap();
2659 assert_eq!(profile.success_count, 1);
2660 assert!((profile.ema_quality - 0.6).abs() < 0.01);
2662 }
2663
2664 #[test]
2665 fn per_task_stats() {
2666 let mut tracker = OutcomeTracker::new();
2667
2668 for _ in 0..2 {
2670 let trace = tracker.record_start("m1", InferenceTask::Code, "code");
2671 tracker.record_complete(&trace, 1000, 100, 50);
2672 tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 0.8 });
2673 }
2674 let trace = tracker.record_start("m1", InferenceTask::Generate, "gen");
2675 tracker.record_complete(&trace, 500, 50, 25);
2676 tracker.record_inferred_outcome(&trace, InferredOutcome::Rejected { confidence: 0.9 });
2677
2678 let profile = tracker.profile("m1").unwrap();
2679 assert_eq!(profile.total_calls, 3);
2680
2681 let code_stats = profile.task_stats(InferenceTask::Code).unwrap();
2682 assert_eq!(code_stats.calls, 2);
2683 assert_eq!(code_stats.successes, 2);
2684
2685 let gen_stats = profile.task_stats(InferenceTask::Generate).unwrap();
2686 assert_eq!(gen_stats.calls, 1);
2687 assert_eq!(gen_stats.failures, 1);
2688 }
2689
2690 #[test]
2691 fn export_populates_quality_per_1k_tokens() {
2692 let mut tracker = OutcomeTracker::new();
2693 let trace = tracker.record_start("m1", InferenceTask::Generate, "test");
2694 tracker.record_complete(&trace, 100, 800, 200); tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 1.0 });
2696
2697 let exported = tracker.export_profiles();
2698 assert_eq!(exported.len(), 1);
2699 let p = &exported[0];
2700 assert!(
2703 (p.quality_per_1k_tokens - 0.6).abs() < 1e-6,
2704 "got {}",
2705 p.quality_per_1k_tokens
2706 );
2707 }
2708
2709 #[test]
2710 fn quality_per_1k_tokens_zero_without_tokens() {
2711 let profile = ModelProfile::new("x".into());
2712 assert_eq!(profile.compute_quality_per_1k_tokens(), 0.0);
2713 }
2714
2715 #[test]
2716 fn tokens_per_success_is_outcome_denominated() {
2717 let mut p = ModelProfile::new("x".into());
2718 assert_eq!(p.tokens_per_success(), None);
2720 p.total_input_tokens = 600;
2722 p.total_output_tokens = 300;
2723 p.success_count = 3;
2724 assert_eq!(p.tokens_per_success(), Some(300.0));
2725 let usd = p.usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC).unwrap();
2727 assert!((usd - ((600.0 * 1.0 + 300.0 * 2.0) / 1_000_000.0 / 3.0)).abs() < 1e-12);
2728 assert_eq!(
2729 ModelProfile::new("y".into()).usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC),
2730 None
2731 );
2732 }
2733
2734 #[test]
2735 fn usd_per_success_prices_cache_buckets_separately() {
2736 let mut p = ModelProfile::new("cached".into());
2737 p.success_count = 1;
2738 p.total_input_tokens = 100; 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;
2744 let expected = expected_input + 50.0 * 2.0 / 1_000_000.0;
2745 let usd = p.usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC).unwrap();
2746 assert!((usd - expected).abs() < 1e-12, "got {usd}, want {expected}");
2747 assert_eq!(p.total_tokens(), 100 + 1000 + 200 + 50);
2749 let mut naive = ModelProfile::new("naive".into());
2752 naive.success_count = 1;
2753 naive.total_input_tokens = 1300; naive.total_output_tokens = 50;
2755 assert!(
2756 naive
2757 .usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC)
2758 .unwrap()
2759 > usd
2760 );
2761 }
2762
2763 #[test]
2764 fn openai_cache_rates_price_reads_at_half_no_write_premium() {
2765 let mut p = ModelProfile::new("gpt".into());
2768 p.success_count = 1;
2769 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();
2773 let expected = (100.0 * 1.0 + 1000.0 * 1.0 * 0.5) / 1_000_000.0;
2774 assert!((usd - expected).abs() < 1e-12, "got {usd}, want {expected}");
2775 let anthropic = p.usd_per_success(1.0, 2.0, CacheRates::ANTHROPIC).unwrap();
2777 assert!(
2778 usd > anthropic,
2779 "OpenAI 0.5× read must exceed Anthropic 0.1×"
2780 );
2781 let mut naive = ModelProfile::new("naive".into());
2783 naive.success_count = 1;
2784 naive.total_input_tokens = 1100;
2785 assert!(naive.usd_per_success(1.0, 2.0, CacheRates::OPENAI).unwrap() > usd);
2786 }
2787
2788 #[test]
2789 fn record_complete_cached_accumulates_cache_totals() {
2790 let mut tracker = OutcomeTracker::new();
2791 let trace = tracker.record_start("m", InferenceTask::Generate, "gen");
2792 tracker.record_complete_cached(&trace, 100, 40, 20, 800, 120);
2793 let p = tracker.profile("m").unwrap();
2794 assert_eq!(p.total_input_tokens, 40, "uncached prefix only");
2795 assert_eq!(p.total_cache_read_input_tokens, 800);
2796 assert_eq!(p.total_cache_creation_input_tokens, 120);
2797 }
2798
2799 #[test]
2800 fn dirty_flag_and_save_if_dirty() {
2801 let dir = std::env::temp_dir().join("car-outcome-dirty-test");
2802 let _ = std::fs::remove_dir_all(&dir);
2803 let path = dir.join("outcome_profiles.json");
2804
2805 let mut tracker = OutcomeTracker::new();
2806 assert!(!tracker.is_dirty());
2808 assert!(!tracker.save_if_dirty(&path).unwrap());
2809 assert!(!path.exists());
2810
2811 let trace = tracker.record_start("m1", InferenceTask::Generate, "router");
2813 tracker.record_complete(&trace, 100, 10, 20);
2814 assert!(tracker.is_dirty());
2815
2816 assert!(tracker.save_if_dirty(&path).unwrap());
2818 assert!(path.exists());
2819 assert!(!tracker.is_dirty());
2820
2821 assert!(!tracker.save_if_dirty(&path).unwrap());
2823
2824 let mut fresh = OutcomeTracker::new();
2826 fresh.load_from_file(&path).unwrap();
2827 assert!(!fresh.is_dirty());
2828
2829 fresh.import_profiles(vec![ModelProfile::new("seeded".into())]);
2832 assert!(fresh.is_dirty());
2833
2834 let _ = std::fs::remove_dir_all(&dir);
2835 }
2836
2837 #[test]
2838 fn ledger_captures_resolved_outcomes() {
2839 let dir = std::env::temp_dir().join("car-outcome-ledger-test");
2840 let _ = std::fs::remove_dir_all(&dir);
2841 let path = dir.join("outcome_ledger.jsonl");
2842
2843 let mut tracker = OutcomeTracker::new();
2844
2845 let t1 = tracker.record_start("good-model", InferenceTask::Generate, "router:test");
2847 tracker.record_complete(&t1, 1200, 50, 100);
2848 tracker.record_inferred_outcome(&t1, InferredOutcome::Accepted { confidence: 0.9 });
2849
2850 let t2 = tracker.record_start("bad-model", InferenceTask::Code, "router:test");
2852 tracker.record_failure(&t2, "boom: 500");
2853
2854 let drained = tracker.drain_ledger();
2855 assert_eq!(drained.len(), 2);
2856 assert!(tracker.drain_ledger().is_empty(), "drain clears the buffer");
2857
2858 append_ledger_entries(&path, &drained).unwrap();
2859 let read = read_ledger(&path, 0);
2860 assert_eq!(read.len(), 2);
2861
2862 let good = read.iter().find(|e| e.model_id == "good-model").unwrap();
2863 assert_eq!(good.success, Some(true));
2864 assert!(good.quality.is_some());
2865 assert_eq!(good.routing_reason, "router:test");
2866 assert_eq!(good.latency_ms, 1200);
2867
2868 let bad = read.iter().find(|e| e.model_id == "bad-model").unwrap();
2869 assert_eq!(bad.success, Some(false));
2870 assert_eq!(bad.error.as_deref(), Some("boom: 500"));
2871
2872 assert_eq!(read_ledger(&path, 1).len(), 1);
2874
2875 let _ = std::fs::remove_dir_all(&dir);
2876 }
2877
2878 #[test]
2879 fn ledger_redacts_long_errors_and_prunes() {
2880 let dir = std::env::temp_dir().join("car-outcome-privacy-test");
2881 let _ = std::fs::remove_dir_all(&dir);
2882 let path = dir.join("outcome_ledger.jsonl");
2883
2884 let mut tracker = OutcomeTracker::new();
2886 let t = tracker.record_start("m", InferenceTask::Generate, "r");
2887 let huge = "x".repeat(5000);
2888 tracker.record_failure(&t, &huge);
2889 let drained = tracker.drain_ledger();
2890 let err = drained[0].error.as_ref().unwrap();
2891 assert!(
2892 err.chars().count() <= MAX_LEDGER_ERROR_CHARS + 1,
2893 "error truncated"
2894 );
2895
2896 let entries: Vec<OutcomeLedgerEntry> = (0..10)
2898 .map(|i| OutcomeLedgerEntry {
2899 trace_id: format!("t{i}"),
2900 model_id: "m".into(),
2901 task: InferenceTask::Generate,
2902 routing_reason: "r".into(),
2903 latency_ms: 1,
2904 input_tokens: 1,
2905 output_tokens: 1,
2906 cache_read_input_tokens: 0,
2907 cache_creation_input_tokens: 0,
2908 success: Some(true),
2909 quality: Some(1.0),
2910 error: None,
2911 project_id: None,
2912 intent: None,
2913 attribution: None,
2914 car_version: None,
2915 timestamp: i,
2916 })
2917 .collect();
2918 append_ledger_entries(&path, &entries).unwrap();
2919 prune_ledger(&path, 3).unwrap();
2920 let kept = read_ledger(&path, 0);
2921 assert_eq!(kept.len(), 3);
2922 assert_eq!(kept[0].trace_id, "t7"); assert_eq!(kept[2].trace_id, "t9");
2924
2925 let _ = std::fs::remove_dir_all(&dir);
2926 }
2927
2928 #[test]
2929 fn sweep_pending_credits_mechanical_success_or_inconclusive() {
2930 let mut tracker = OutcomeTracker::new();
2931
2932 let t1 = tracker.record_start("m", InferenceTask::Generate, "r");
2934 tracker.record_complete(&t1, 500, 10, 20);
2935 let t2 = tracker.record_start("m", InferenceTask::Generate, "r");
2937 tracker.record_complete(&t2, 300, 5, 0);
2938 let _t3 = tracker.record_start("m", InferenceTask::Generate, "r");
2940
2941 let swept = tracker.sweep_pending_at(0, now_unix() + 10);
2944 assert_eq!(
2945 swept, 2,
2946 "both ended calls evicted; the in-flight one waits"
2947 );
2948 let swept = tracker.sweep_pending_at(0, now_unix() + PENDING_IN_FLIGHT_TTL_SECS + 10);
2950 assert_eq!(swept, 1, "a call that never ended is evicted eventually");
2951
2952 let mut receipts = tracker.drain_ledger();
2954 receipts.sort_by_key(|r| r.latency_ms);
2955 assert_eq!(receipts.len(), 2);
2956 assert_eq!(receipts[0].latency_ms, 300);
2958 assert_eq!(receipts[0].success, None);
2959 assert_eq!(receipts[1].latency_ms, 500);
2961 assert_eq!(receipts[1].success, Some(true));
2962 assert_eq!(receipts[1].quality, None);
2963
2964 let p = tracker.profile("m").expect("profile exists");
2967 assert_eq!(p.success_count, 1);
2968 assert_eq!(p.ema_quality, 0.5);
2969 }
2970
2971 #[test]
2972 fn record_complete_credits_success_immediately() {
2973 let mut tracker = OutcomeTracker::new();
2978 let t = tracker.record_start("m", InferenceTask::Generate, "r");
2979 tracker.record_complete(&t, 500, 12, 20);
2980
2981 let p = tracker.profile("m").expect("profile exists");
2982 assert_eq!(p.success_count, 1, "success credited at completion");
2983 assert_eq!(p.total_calls, 1);
2984 assert_eq!(p.total_input_tokens, 12, "input tokens recorded, not 0");
2985 assert_eq!(p.fail_count, 0);
2986
2987 tracker.sweep_pending_at(0, now_unix() + 10);
2989 let p = tracker.profile("m").unwrap();
2990 assert_eq!(p.success_count, 1, "sweep does not re-credit");
2991 }
2992
2993 #[test]
2994 fn success_rate_resolved_distinguishes_no_signal_from_measured() {
2995 let mut p = ModelProfile::new("m".to_string());
3000 assert_eq!(
3001 p.success_rate_resolved(),
3002 None,
3003 "no resolved signal, not a fabricated 0.5"
3004 );
3005
3006 p.success_count = 3;
3007 p.fail_count = 1;
3008 assert_eq!(
3009 p.success_rate_resolved(),
3010 Some(0.75),
3011 "real rate once resolved"
3012 );
3013 }
3014
3015 #[test]
3016 fn quality_observations_count_only_graded_signals() {
3017 let mut tracker = OutcomeTracker::new();
3021
3022 let t = tracker.record_start("m", InferenceTask::Generate, "r");
3024 tracker.record_complete(&t, 500, 12, 20);
3025 let p = tracker.profile("m").unwrap();
3026 assert_eq!(p.success_count, 1);
3027 assert_eq!(
3028 p.quality_observations, 0,
3029 "mechanical success is not a graded quality observation"
3030 );
3031 assert!(
3032 (p.ema_quality - 0.5).abs() < 1e-9,
3033 "EMA untouched by mechanical success"
3034 );
3035
3036 tracker.record_inferred_outcome(&t, InferredOutcome::Accepted { confidence: 0.9 });
3038 let p = tracker.profile("m").unwrap();
3039 assert_eq!(p.quality_observations, 1, "graded accept signal counts");
3040 assert!(p.ema_quality > 0.5, "graded accept moved the EMA up");
3041 assert_eq!(
3042 p.task_stats(InferenceTask::Generate)
3043 .unwrap()
3044 .quality_observations,
3045 1,
3046 "per-task graded count tracked too"
3047 );
3048
3049 let t2 = tracker.record_start("m", InferenceTask::Generate, "r");
3052 tracker.record_failure(&t2, "boom");
3053 let p = tracker.profile("m").unwrap();
3054 assert_eq!(
3055 p.quality_observations, 1,
3056 "a failure is not a graded observation"
3057 );
3058 }
3059
3060 #[test]
3061 fn record_complete_no_output_is_not_a_success() {
3062 let mut tracker = OutcomeTracker::new();
3063 let t = tracker.record_start("m", InferenceTask::Generate, "r");
3064 tracker.record_complete(&t, 300, 5, 0); let p = tracker.profile("m").unwrap();
3066 assert_eq!(p.success_count, 0, "no output -> no mechanical success");
3067 assert_eq!(p.total_calls, 1);
3068 }
3069
3070 #[test]
3071 fn record_failure_counts_total_calls() {
3072 let mut tracker = OutcomeTracker::new();
3076 for _ in 0..3 {
3077 let t = tracker.record_start("m", InferenceTask::Generate, "r");
3078 tracker.record_failure(&t, "boom 500");
3079 }
3080 let p = tracker.profile("m").unwrap();
3081 assert_eq!(p.fail_count, 3);
3082 assert_eq!(p.total_calls, 3, "failures counted in total_calls");
3083 assert!(p.fail_count <= p.total_calls);
3084 }
3085
3086 #[test]
3087 fn capability_rejection_does_not_change_model_profile() {
3088 let mut tracker = OutcomeTracker::new();
3089 let trace = tracker.record_start("m", InferenceTask::Generate, "r");
3090
3091 tracker.record_capability_rejection(&trace, "unsupported mode: json schema");
3092
3093 assert!(tracker.profile("m").is_none());
3094 assert!(!tracker.has_pending(&trace));
3095 assert_eq!(tracker.ledger_buffer.len(), 1);
3096 }
3097
3098 #[test]
3099 fn transport_failures_are_quality_neutral() {
3100 let mut tracker = OutcomeTracker::new();
3107 let t = tracker.record_start("m", InferenceTask::Code, "r");
3110 tracker.record_failure(&t, "model produced malformed output");
3111 let baseline_ema = tracker.profile("m").unwrap().ema_quality;
3112 let baseline_qobs = tracker.profile("m").unwrap().quality_observations;
3113
3114 for _ in 0..5 {
3119 let t = tracker.record_start("m", InferenceTask::Code, "r");
3120 tracker.record_failure(&t, "daemon read timeout on infer after 30s");
3121 }
3122 for _ in 0..5 {
3123 let t = tracker.record_start("m", InferenceTask::Code, "r");
3124 tracker.record_failure(
3125 &t,
3126 "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: Authentication",
3127 );
3128 }
3129
3130 let p = tracker.profile("m").unwrap();
3131 assert_eq!(
3132 p.fail_count, 11,
3133 "no-answer failures still counted for availability"
3134 );
3135 assert_eq!(p.total_calls, 11, "every call counted (failures included)");
3136 assert!(
3137 (p.ema_quality - baseline_ema).abs() < 1e-9,
3138 "timeouts AND auth rejections must not move the quality EMA ({baseline_ema} -> {})",
3139 p.ema_quality
3140 );
3141 assert_eq!(
3142 p.quality_observations, baseline_qobs,
3143 "no-answer failures (transport + auth) are not graded quality evidence"
3144 );
3145 let t = tracker.record_start("m", InferenceTask::Code, "r");
3148 tracker.record_failure(&t, "model produced malformed output");
3149 let p = tracker.profile("m").unwrap();
3150 assert_eq!(p.fail_count, 12);
3151 assert_eq!(p.ema_quality, baseline_ema);
3152 }
3153
3154 #[test]
3155 fn is_no_answer_failure_precision_boundary() {
3156 for s in [
3161 "daemon read timeout on infer after 30s",
3162 "connection reset by peer",
3163 "upstream returned 503 service unavailable",
3164 "HTTP 500 Internal Server Error",
3165 "stream closed unexpectedly (eof)",
3166 "HTTP 401 Unauthorized",
3167 "unauthenticated request",
3168 "HTTP 403 Forbidden",
3169 "permission denied by gateway",
3170 "Parslee org lookup failed: Authentication required",
3171 "oauth: invalid_client",
3172 "invalid api key",
3173 "HTTP 400 Bad Request: max context length exceeded",
3174 ] {
3175 assert!(
3176 is_no_answer_failure(s),
3177 "expected no-answer (quality-neutral): {s:?}"
3178 );
3179 }
3180 for s in [
3184 "model produced malformed output",
3185 "field 'user' not found in model response", "tokenizer: invalid token id 99999", "response failed JSON schema validation",
3188 "tool call arguments did not validate",
3189 ] {
3190 assert!(
3191 !is_no_answer_failure(s),
3192 "expected generation failure (graded): {s:?}"
3193 );
3194 }
3195 }
3196
3197 #[test]
3198 fn ledger_quality_distinguishes_generation_from_transport_failure() {
3199 let mut tracker = OutcomeTracker::new();
3206
3207 let t = tracker.record_start("m", InferenceTask::Code, "r");
3208 tracker.record_failure(&t, "model produced malformed output"); let t = tracker.record_start("m", InferenceTask::Code, "r");
3210 tracker.record_failure(&t, "daemon read timeout on infer after 30s"); let t = tracker.record_start("m", InferenceTask::Code, "r");
3212 tracker.record_failure(&t, "429 RESOURCE_EXHAUSTED"); let t = tracker.record_start("m", InferenceTask::Code, "r");
3214 tracker.record_failure(&t, "upstream: 503 service unavailable"); let t = tracker.record_start("m", InferenceTask::Code, "r");
3216 tracker.record_failure(&t, "HTTP 500 Internal Server Error"); let t = tracker.record_start("m", InferenceTask::Code, "r");
3218 tracker.record_failure(
3220 &t,
3221 "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: Authentication",
3222 );
3223
3224 let entries = tracker.drain_ledger();
3225 assert_eq!(entries.len(), 6);
3226 assert!(entries.iter().all(|e| e.success == Some(false)));
3228 assert_eq!(
3230 entries[0].quality,
3231 Some(0.0),
3232 "generation failure -> graded 0.0 answer quality"
3233 );
3234 assert_eq!(
3235 entries[1].quality, None,
3236 "transport failure produced no answer -> quality unknown (None)"
3237 );
3238 assert_eq!(
3239 entries[2].quality, None,
3240 "429 produced no answer -> quality unknown (None), an availability event"
3241 );
3242 assert_eq!(
3243 entries[3].quality, None,
3244 "5xx infra failure produced no answer -> None"
3245 );
3246 assert_eq!(
3247 entries[4].quality, None,
3248 "an 'internal server error' (500) is a server fault, not a bad answer -> None"
3249 );
3250 assert_eq!(
3251 entries[5].quality, None,
3252 "a 401 auth rejection produced no answer -> None (not a bad-answer 0.0)"
3253 );
3254 }
3255
3256 #[test]
3257 fn real_failure_signal_reclassifies_mechanical_success() {
3258 let mut tracker = OutcomeTracker::new();
3261 let t = tracker.record_start("m", InferenceTask::Generate, "r");
3262 tracker.record_complete(&t, 100, 8, 15);
3263 assert_eq!(tracker.profile("m").unwrap().success_count, 1);
3264
3265 tracker.record_inferred_outcome(&t, InferredOutcome::Rejected { confidence: 0.9 });
3266 let p = tracker.profile("m").unwrap();
3267 assert_eq!(p.success_count, 0, "mechanical success undone");
3268 assert_eq!(p.fail_count, 1, "failure booked");
3269 }
3270
3271 #[test]
3272 fn real_success_signal_does_not_double_count() {
3273 let mut tracker = OutcomeTracker::new();
3274 let t = tracker.record_start("m", InferenceTask::Generate, "r");
3275 tracker.record_complete(&t, 100, 8, 15);
3276 tracker.record_inferred_outcome(&t, InferredOutcome::Accepted { confidence: 0.9 });
3277 let p = tracker.profile("m").unwrap();
3278 assert_eq!(
3279 p.success_count, 1,
3280 "Accepted on an already-credited call is not +2"
3281 );
3282 }
3283
3284 #[test]
3285 fn export_import() {
3286 let mut tracker = OutcomeTracker::new();
3287 let trace = tracker.record_start("m1", InferenceTask::Generate, "test");
3288 tracker.record_complete(&trace, 100, 10, 5);
3289 tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 0.9 });
3290
3291 let exported = tracker.export_profiles();
3292 assert_eq!(exported.len(), 1);
3293
3294 let mut tracker2 = OutcomeTracker::new();
3295 tracker2.import_profiles(exported);
3296 assert!(tracker2.profile("m1").is_some());
3297 }
3298
3299 #[test]
3300 fn a_call_longer_than_the_ttl_can_still_be_graded_after_it_ends() {
3301 let ttl = 900;
3305 let mut tracker = OutcomeTracker::new();
3306 let t = tracker.record_start("m", InferenceTask::Generate, "r");
3307 let started = now_unix().saturating_sub(ttl + 600);
3308 tracker.pending.get_mut(&t).unwrap().timestamp = started;
3309
3310 assert_eq!(tracker.sweep_pending_at(ttl, now_unix()), 0);
3312 tracker.record_complete(&t, (ttl + 600) * 1000, 10, 20);
3313 assert_eq!(tracker.sweep_pending_at(ttl, now_unix() + ttl / 2), 0);
3315
3316 let report = tracker
3317 .resolve_pending_reporting(vec![(t, InferredOutcome::Accepted { confidence: 0.9 })]);
3318 assert_eq!(report.resolved, 1);
3319 assert!(report.dropped.is_empty());
3320 let p = tracker.profile("m").unwrap();
3321 assert_eq!(p.quality_observations, 1, "the grade moved the EMA");
3322 assert_eq!(p.total_calls, 1, "the completion was booked");
3323 }
3324
3325 #[test]
3326 fn an_ended_call_is_swept_ttl_after_it_ends() {
3327 let mut tracker = OutcomeTracker::new();
3328 let t = tracker.record_start("m", InferenceTask::Generate, "r");
3329 tracker.record_complete(&t, 100, 1, 1);
3330 assert_eq!(tracker.sweep_pending_at(900, now_unix() + 899), 0);
3331 assert_eq!(tracker.sweep_pending_at(900, now_unix() + 902), 1);
3332 assert!(!tracker.has_pending(&t));
3333 }
3334
3335 #[test]
3336 fn resolve_reports_signals_that_matched_no_pending_trace() {
3337 let mut tracker = OutcomeTracker::new();
3338 let live = tracker.record_start("m", InferenceTask::Generate, "r");
3339 tracker.record_complete(&live, 100, 1, 1);
3340 let report = tracker.resolve_pending_reporting(vec![
3341 (live.clone(), InferredOutcome::Accepted { confidence: 0.8 }),
3342 (live.clone(), InferredOutcome::Accepted { confidence: 0.8 }),
3344 (
3345 "t-0-swept".into(),
3346 InferredOutcome::Rejected { confidence: 0.9 },
3347 ),
3348 ]);
3349 assert_eq!(report.resolved, 1);
3350 assert_eq!(report.dropped, vec![live, "t-0-swept".to_string()]);
3351 assert_eq!(tracker.profile("m").unwrap().quality_observations, 1);
3352 }
3353
3354 #[test]
3355 fn external_grade_is_kept_per_source_and_leaves_served_counters_alone() {
3356 let mut tracker = OutcomeTracker::new();
3357 let stats = tracker
3358 .record_external_grade("gpt-x", InferenceTask::Code, 0.25, 6, "neo")
3359 .unwrap();
3360 assert_eq!(stats.quality, 0.25);
3361 assert_eq!(stats.observations, 6);
3362 assert_eq!(stats.evidence(), 6);
3363
3364 let p = tracker.profile("gpt-x").unwrap();
3365 assert_eq!(p.total_calls, 0);
3366 assert_eq!(p.success_count + p.fail_count, 0);
3367 assert_eq!(p.quality_observations, 0, "served evidence untouched");
3368 assert_eq!(p.ema_quality, NEUTRAL_QUALITY, "served EMA untouched");
3369 let ts = p.task_stats(InferenceTask::Code).unwrap();
3370 assert_eq!(ts.quality_observations, 0);
3371 assert!(!ts.has_served_evidence());
3372 assert_eq!(ts.external_evidence(), (6.0, 0.25));
3373 assert!(tracker.is_dirty(), "an external grade must reach disk");
3374 assert!(tracker.drain_ledger().is_empty(), "no served receipt");
3375 }
3376
3377 #[test]
3378 fn one_source_cannot_add_weight_past_its_cap() {
3379 let mut tracker = OutcomeTracker::new();
3380 let big = tracker
3381 .record_external_grade("m", InferenceTask::Generate, 0.0, 100, "bench")
3382 .unwrap();
3383 assert_eq!(big.observations, 100);
3384 assert_eq!(big.evidence(), EXTERNAL_GRADE_EVIDENCE_CAP);
3385 for _ in 0..20 {
3388 tracker
3389 .record_external_grade("m", InferenceTask::Generate, 1.0, 1, "bench")
3390 .unwrap();
3391 }
3392 let ts = tracker
3393 .profile("m")
3394 .unwrap()
3395 .task_stats(InferenceTask::Generate)
3396 .unwrap();
3397 let (evidence, mean) = ts.external_evidence();
3398 assert_eq!(evidence, EXTERNAL_GRADE_EVIDENCE_CAP as f64);
3399 assert!(mean > 0.9, "newer reports keep moving quality: {mean}");
3400 assert_eq!(ts.external_grades["bench"].observations, 120);
3401 }
3402
3403 #[test]
3404 fn external_grade_validation_and_source_limit() {
3405 let mut tracker = OutcomeTracker::new();
3406 let task = InferenceTask::Generate;
3407 let refused = |r: Result<ExternalGradeStats, ExternalGradeRefusal>| r.unwrap_err();
3408 assert_eq!(
3409 refused(tracker.record_external_grade("m", task, 1.5, 1, "s")),
3410 ExternalGradeRefusal::InvalidQuality
3411 );
3412 assert_eq!(
3413 refused(tracker.record_external_grade("m", task, f64::NAN, 1, "s")),
3414 ExternalGradeRefusal::InvalidQuality
3415 );
3416 assert_eq!(
3417 refused(tracker.record_external_grade("m", task, 0.5, 0, "s")),
3418 ExternalGradeRefusal::InvalidCases
3419 );
3420 assert_eq!(
3421 refused(tracker.record_external_grade(
3422 "m",
3423 task,
3424 0.5,
3425 MAX_EXTERNAL_GRADE_CASES + 1,
3426 "s"
3427 )),
3428 ExternalGradeRefusal::InvalidCases
3429 );
3430 assert_eq!(
3431 refused(tracker.record_external_grade("m", task, 0.5, 1, "has space")),
3432 ExternalGradeRefusal::InvalidSource
3433 );
3434 assert_eq!(
3435 refused(tracker.record_external_grade("m", task, 0.5, 1, &"s".repeat(65))),
3436 ExternalGradeRefusal::InvalidSource
3437 );
3438 assert_eq!(
3439 refused(tracker.record_external_grade(" ", task, 0.5, 1, "s")),
3440 ExternalGradeRefusal::InvalidModel
3441 );
3442 assert!(tracker.profile("m").is_none(), "a refusal records nothing");
3443
3444 for i in 0..MAX_EXTERNAL_GRADE_SOURCES {
3445 tracker
3446 .record_external_grade("m", task, 0.5, 1, &format!("src-{i}"))
3447 .unwrap();
3448 }
3449 assert_eq!(
3450 refused(tracker.record_external_grade("m", task, 0.5, 1, "one-more")),
3451 ExternalGradeRefusal::SourceLimitReached
3452 );
3453 tracker
3455 .record_external_grade("m", task, 0.9, 1, "src-0")
3456 .unwrap();
3457 tracker
3459 .record_external_grade("m", InferenceTask::Code, 0.5, 1, "one-more")
3460 .unwrap();
3461 }
3462
3463 #[test]
3464 fn one_reporter_cannot_add_weight_by_rotating_source_names() {
3465 let mut tracker = OutcomeTracker::new();
3466 let task = InferenceTask::Code;
3467 for i in 0..MAX_EXTERNAL_GRADE_SOURCES {
3468 tracker
3469 .record_external_grade("m", task, 0.0, 8, &format!("alias-{i}"))
3470 .unwrap();
3471 }
3472 let ts = tracker.profile("m").unwrap().task_stats(task).unwrap();
3473 assert_eq!(
3474 ts.external_evidence(),
3475 (EXTERNAL_GRADE_REPORTER_EVIDENCE_CAP as f64, 0.0),
3476 "eight names from one reporter count as one reporter's cap"
3477 );
3478 for i in 0..2 {
3480 tracker
3481 .record_external_grade_as(Some("agent:b"), "m", task, 1.0, 8, &format!("s{i}"))
3482 .unwrap();
3483 }
3484 let ts = tracker.profile("m").unwrap().task_stats(task).unwrap();
3485 assert_eq!(ts.external_evidence(), (16.0, 0.5));
3486 tracker
3488 .record_external_grade_as(Some("agent:c"), "m", task, 1.0, 8, "s")
3489 .unwrap();
3490 let ts = tracker.profile("m").unwrap().task_stats(task).unwrap();
3491 let (evidence, mean) = ts.external_evidence();
3492 assert_eq!(evidence, EXTERNAL_GRADE_TOTAL_EVIDENCE_CAP as f64);
3493 assert!((mean - 2.0 / 3.0).abs() < 1e-12, "{mean}");
3494 }
3495
3496 #[test]
3497 fn a_reporter_cannot_write_another_reporters_source() {
3498 let mut tracker = OutcomeTracker::new();
3499 let task = InferenceTask::Code;
3500 tracker
3501 .record_external_grade("m", task, 0.9, 8, "neo")
3502 .unwrap();
3503 tracker
3504 .record_external_grade_as(Some("agent:mallory"), "m", task, 0.0, 100, "neo")
3505 .unwrap();
3506 let ts = tracker.profile("m").unwrap().task_stats(task).unwrap();
3507 assert_eq!(
3508 ts.external_grades["neo"].quality, 0.9,
3509 "operator's untouched"
3510 );
3511 assert_eq!(ts.external_grades["agent:mallory/neo"].quality, 0.0);
3512 assert_eq!(
3513 tracker
3514 .record_external_grade_as(Some("agent:a/b"), "m", task, 0.5, 1, "x")
3515 .unwrap_err(),
3516 ExternalGradeRefusal::InvalidReporter,
3517 "a reporter cannot forge a key under another prefix"
3518 );
3519 for i in 0..MAX_EXTERNAL_GRADE_SOURCES {
3522 let _ = tracker.record_external_grade_as(
3523 Some("agent:mallory"),
3524 "m",
3525 task,
3526 0.0,
3527 1,
3528 &format!("junk-{i}"),
3529 );
3530 }
3531 tracker
3532 .record_external_grade("m", task, 0.5, 1, "nightly")
3533 .unwrap();
3534 tracker
3535 .record_external_grade_as(Some("agent:other"), "m", task, 0.5, 1, "nightly")
3536 .unwrap();
3537 }
3538
3539 #[test]
3540 fn a_stale_source_stops_counting_and_gives_up_its_slot() {
3541 let mut tracker = OutcomeTracker::new();
3542 let task = InferenceTask::Code;
3543 let then = 1_000_000_000;
3544 let later = then + EXTERNAL_GRADE_MAX_AGE_SECS + 1;
3545 for i in 0..MAX_EXTERNAL_GRADE_SOURCES {
3546 tracker
3547 .record_external_grade_at(None, "m", task, 0.0, 8, &format!("old-{i}"), then)
3548 .unwrap();
3549 }
3550 let ts = tracker.profile("m").unwrap().task_stats(task).unwrap();
3551 assert_eq!(ts.external_evidence_at(then).0, 8.0);
3552 assert_eq!(
3553 ts.external_evidence_at(later),
3554 (0.0, 0.0),
3555 "a stale grade no longer steers routing"
3556 );
3557 assert_eq!(
3560 tracker
3561 .record_external_grade_at(None, "m", task, 1.0, 1, "new", then + 1)
3562 .unwrap_err(),
3563 ExternalGradeRefusal::SourceLimitReached
3564 );
3565 tracker
3566 .record_external_grade_at(None, "m", task, 1.0, 1, "new", later)
3567 .unwrap();
3568 let ts = tracker.profile("m").unwrap().task_stats(task).unwrap();
3569 assert_eq!(ts.external_grades.len(), MAX_EXTERNAL_GRADE_SOURCES);
3570 assert!(ts.external_grades.contains_key("new"));
3571 assert_eq!(ts.external_evidence_at(later), (1.0, 1.0));
3572 let restarted = tracker
3574 .record_external_grade_at(None, "m", task, 1.0, 1, "old-7", later)
3575 .unwrap();
3576 assert_eq!((restarted.quality, restarted.observations), (1.0, 1));
3577 }
3578
3579 #[test]
3580 fn external_grades_survive_a_save_and_load() {
3581 let dir = tempfile::tempdir().unwrap();
3582 let path = dir.path().join("profiles.json");
3583 let mut tracker = OutcomeTracker::new();
3584 tracker
3585 .record_external_grade("m", InferenceTask::Code, 0.75, 3, "neo")
3586 .unwrap();
3587 tracker.save_to_file(&path).unwrap();
3588
3589 let mut loaded = OutcomeTracker::new();
3590 loaded.load_from_file(&path).unwrap();
3591 let ts = loaded
3592 .profile("m")
3593 .unwrap()
3594 .task_stats(InferenceTask::Code)
3595 .unwrap();
3596 assert_eq!(ts.external_grades["neo"].quality, 0.75);
3597 assert_eq!(ts.external_grades["neo"].observations, 3);
3598
3599 let old: TaskStats = serde_json::from_str(
3601 r#"{"calls":1,"successes":1,"failures":0,"avg_latency_ms":1.0,"ema_quality":0.5}"#,
3602 )
3603 .unwrap();
3604 assert!(old.external_grades.is_empty());
3605 assert!(!serde_json::to_string(&old)
3606 .unwrap()
3607 .contains("external_grades"));
3608 }
3609}