Skip to main content

lean_ctx/core/triage/
accuracy_tracker.rs

1//! Append-only semantic-shadow comparison and accuracy reporting.
2
3use 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/// Persists semantic-shadow observations and derives their accuracy report.
38#[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}