1use std::io::Write;
4
5use chrono::Utc;
6use serde::{Deserialize, Serialize};
7
8use super::{ProfileHypothesis, TaskAnalysisInput};
9
10const SHADOW_FILE: &str = "triage_shadow.jsonl";
11const ACCURACY_FILE: &str = "triage_accuracy.jsonl";
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14struct ComparisonRecord {
15 task_query: String,
16 rules_result: ProfileHypothesis,
17 semantic_result: ProfileHypothesis,
18 agreed: bool,
19 timestamp: String,
20}
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
23pub struct PerFieldF1 {
24 pub intent: f64,
25 pub complexity: f64,
26 pub scope: f64,
27}
28
29#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
30pub struct AccuracyReport {
31 pub agreement_rate: f64,
32 pub per_field_f1: PerFieldF1,
33 pub total_tasks_compared: usize,
34 pub ready_for_promotion: bool,
35}
36
37#[derive(Debug, Default)]
39pub struct AccuracyTracker;
40
41impl AccuracyTracker {
42 pub fn record(
43 input: &TaskAnalysisInput,
44 rules_result: &ProfileHypothesis,
45 semantic_result: &ProfileHypothesis,
46 ) -> Result<(), String> {
47 record_comparison(input, rules_result, semantic_result)
48 }
49
50 pub fn report() -> AccuracyReport {
51 report()
52 }
53}
54
55pub fn record_comparison(
56 input: &TaskAnalysisInput,
57 rules_result: &ProfileHypothesis,
58 semantic_result: &ProfileHypothesis,
59) -> Result<(), String> {
60 let record = ComparisonRecord {
61 task_query: input.query.clone(),
62 rules_result: rules_result.clone(),
63 semantic_result: semantic_result.clone(),
64 agreed: rules_result.profile.intent == semantic_result.profile.intent
65 && rules_result.profile.complexity == semantic_result.profile.complexity
66 && rules_result.profile.scope == semantic_result.profile.scope,
67 timestamp: Utc::now().to_rfc3339(),
68 };
69 append(SHADOW_FILE, &record)?;
70 append(ACCURACY_FILE, &record)
71}
72
73pub fn report() -> AccuracyReport {
74 let records = read_records();
75 let total = records.len();
76 if total == 0 {
77 return AccuracyReport::default();
78 }
79 let agreement_rate =
80 records.iter().filter(|record| record.agreed).count() as f64 / total as f64;
81 let report = AccuracyReport {
82 agreement_rate,
83 per_field_f1: PerFieldF1 {
84 intent: macro_f1(&records, |record| {
85 (
86 record.rules_result.profile.intent.clone(),
87 record.semantic_result.profile.intent.clone(),
88 )
89 }),
90 complexity: macro_f1(&records, |record| {
91 (
92 record.rules_result.profile.complexity.clone(),
93 record.semantic_result.profile.complexity.clone(),
94 )
95 }),
96 scope: macro_f1(&records, |record| {
97 (
98 format!("{:?}", record.rules_result.profile.scope),
99 format!("{:?}", record.semantic_result.profile.scope),
100 )
101 }),
102 },
103 total_tasks_compared: total,
104 ready_for_promotion: agreement_rate == 1.0,
105 };
106 if report.ready_for_promotion {
107 tracing::info!(
108 total,
109 "semantic triage is ready for promotion: 100% agreement"
110 );
111 }
112 report
113}
114
115fn append<T: Serialize>(filename: &str, record: &T) -> Result<(), String> {
116 let dir = crate::core::data_dir::lean_ctx_data_dir()?;
117 std::fs::create_dir_all(&dir)
118 .map_err(|error| format!("create triage data directory: {error}"))?;
119 let mut line = serde_json::to_string(record)
120 .map_err(|error| format!("serialize triage record: {error}"))?;
121 line.push('\n');
122 let mut file = std::fs::OpenOptions::new()
123 .create(true)
124 .append(true)
125 .open(dir.join(filename))
126 .map_err(|error| format!("open triage record: {error}"))?;
127 file.write_all(line.as_bytes())
128 .map_err(|error| format!("write triage record: {error}"))
129}
130
131fn read_records() -> Vec<ComparisonRecord> {
132 let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() else {
133 return Vec::new();
134 };
135 let Ok(contents) = std::fs::read_to_string(dir.join(ACCURACY_FILE)) else {
136 return Vec::new();
137 };
138 contents
139 .lines()
140 .filter_map(|line| serde_json::from_str(line).ok())
141 .collect()
142}
143
144fn macro_f1(
145 records: &[ComparisonRecord],
146 labels: impl Fn(&ComparisonRecord) -> (String, String),
147) -> f64 {
148 let mut values = std::collections::BTreeSet::new();
149 for record in records {
150 let (expected, actual) = labels(record);
151 values.insert(expected);
152 values.insert(actual);
153 }
154 values
155 .iter()
156 .map(|label| {
157 let (mut true_positive, mut false_positive, mut false_negative) = (0_u64, 0_u64, 0_u64);
158 for record in records {
159 let (expected, actual) = labels(record);
160 match (expected == *label, actual == *label) {
161 (true, true) => true_positive += 1,
162 (false, true) => false_positive += 1,
163 (true, false) => false_negative += 1,
164 (false, false) => {}
165 }
166 }
167 let denominator = 2 * true_positive + false_positive + false_negative;
168 if denominator == 0 {
169 0.0
170 } else {
171 2.0 * true_positive as f64 / denominator as f64
172 }
173 })
174 .sum::<f64>()
175 / values.len() as f64
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::core::triage::{
182 TriageBackendLocal,
183 profile::{TaskProfileLocal, TaskScopeLocal},
184 };
185
186 fn hypothesis(intent: &str, complexity: &str, scope: TaskScopeLocal) -> ProfileHypothesis {
187 ProfileHypothesis {
188 profile: TaskProfileLocal {
189 intent: intent.into(),
190 complexity: complexity.into(),
191 scope,
192 ..Default::default()
193 },
194 confidence_milli: 500,
195 backend: TriageBackendLocal::Rules,
196 }
197 }
198
199 #[test]
200 fn accuracy_tracker_computes_correct_agreement_rate() {
201 let _data = crate::core::data_dir::isolated_data_dir();
202 let input = TaskAnalysisInput {
203 query: "task".into(),
204 ..Default::default()
205 };
206 record_comparison(
207 &input,
208 &hypothesis("fix", "low", TaskScopeLocal::SingleFile),
209 &hypothesis("fix", "low", TaskScopeLocal::SingleFile),
210 )
211 .unwrap();
212 record_comparison(
213 &input,
214 &hypothesis("fix", "low", TaskScopeLocal::SingleFile),
215 &hypothesis("review", "high", TaskScopeLocal::MultiFile),
216 )
217 .unwrap();
218 let report = report();
219 assert_eq!(report.total_tasks_compared, 2);
220 assert_eq!(report.agreement_rate, 0.5);
221 }
222
223 #[test]
224 fn full_agreement_is_ready_for_promotion() {
225 let _data = crate::core::data_dir::isolated_data_dir();
226 let input = TaskAnalysisInput {
227 query: "task".into(),
228 ..Default::default()
229 };
230 let result = hypothesis("fix", "low", TaskScopeLocal::SingleFile);
231 record_comparison(&input, &result, &result).unwrap();
232 assert!(report().ready_for_promotion);
233 }
234}