Skip to main content

lean_ctx/core/triage/
mod.rs

1pub mod accuracy_tracker;
2pub mod calibration;
3#[cfg(test)]
4mod calibration_tests;
5pub mod confidence;
6pub mod distillation;
7#[cfg(test)]
8mod distillation_tests;
9pub mod fusion;
10pub mod markdown;
11pub mod model_loader;
12pub mod profile;
13pub mod rules;
14pub mod semantic_analyzer;
15pub mod validation;
16#[cfg(test)]
17mod validation_tests;
18
19use profile::TaskProfileLocal;
20use std::thread;
21use std::{fmt, sync::Arc};
22
23/// Analyzes task inputs into ranked profile hypotheses.
24pub trait TaskAnalyzer: std::fmt::Debug + Send + Sync {
25    fn analyze(&self, input: &TaskAnalysisInput) -> Result<ProfileHypothesis, TriageError>;
26    fn name(&self) -> &'static str;
27
28    /// Marks an observation-only analyzer that may run beside rules in shadow mode.
29    fn shadow_enabled(&self) -> bool {
30        false
31    }
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35/// Captures the signals used to classify a task.
36pub struct TaskAnalysisInput {
37    pub query: String,
38    pub files_touched: Vec<String>,
39    pub active_diagnostics: usize,
40    pub session_context: Option<String>,
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44/// Describes one task-profile classification hypothesis.
45pub struct ProfileHypothesis {
46    pub profile: TaskProfileLocal,
47    pub confidence_milli: u16,
48    pub backend: TriageBackendLocal,
49}
50
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
52/// Identifies the backend that produced a triage hypothesis.
53pub enum TriageBackendLocal {
54    #[default]
55    Rules,
56    Semantic,
57    Hybrid,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61/// Represents a task-triage failure.
62pub enum TriageError {
63    NoSignal,
64    ModelUnavailable,
65    InternalError(String),
66}
67
68#[derive(Debug, Clone)]
69/// Orchestrates analyzers to select the strongest task profile.
70pub struct TriageEngine {
71    pub analyzers: Vec<Arc<dyn TaskAnalyzer>>,
72}
73
74impl fmt::Display for TriageError {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match self {
77            Self::NoSignal => write!(f, "no triage signal available"),
78            Self::ModelUnavailable => write!(f, "triage model unavailable"),
79            Self::InternalError(error) => write!(f, "triage internal error: {error}"),
80        }
81    }
82}
83
84impl std::error::Error for TriageError {}
85
86impl TriageEngine {
87    pub fn new(analyzers: Vec<Box<dyn TaskAnalyzer>>) -> Self {
88        Self {
89            analyzers: analyzers.into_iter().map(Arc::from).collect(),
90        }
91    }
92
93    pub fn with_rules() -> Self {
94        Self::new(vec![Box::new(rules::RuleTriageBackend)])
95    }
96
97    #[allow(clippy::match_same_arms)]
98    pub fn analyze(&self, input: &TaskAnalysisInput) -> Result<ProfileHypothesis, TriageError> {
99        if let Some((rules, semantic)) = self.shadow_pair() {
100            let (rules_result, semantic_result) = thread::scope(|scope| {
101                let rules_task = scope.spawn(|| rules.analyze(input));
102                let semantic_task = scope.spawn(|| semantic.analyze(input));
103                (rules_task.join(), semantic_task.join())
104            });
105            let rules_result = rules_result.map_err(|_| {
106                TriageError::InternalError("rules triage worker panicked".to_string())
107            })?;
108            let semantic_result = semantic_result.map_err(|_| {
109                TriageError::InternalError("semantic triage worker panicked".to_string())
110            })?;
111            if let (Ok(rules), Ok(semantic)) = (&rules_result, &semantic_result) {
112                if let Err(error) = accuracy_tracker::record_comparison(input, rules, semantic) {
113                    tracing::warn!(%error, "failed to persist semantic triage shadow comparison");
114                }
115            }
116            // Rules remain the sole execution authority while semantic is shadowed.
117            return rules_result;
118        }
119        let mut best: Option<ProfileHypothesis> = None;
120        let mut first_error = None;
121        for analyzer in &self.analyzers {
122            match analyzer.analyze(input) {
123                Ok(candidate)
124                    if best.as_ref().is_none_or(|current: &ProfileHypothesis| {
125                        candidate.confidence_milli > current.confidence_milli
126                    }) =>
127                {
128                    best = Some(candidate);
129                }
130                Ok(_) => {}
131                Err(error) if first_error.is_none() => {
132                    first_error = Some(error);
133                }
134                Err(_) => {}
135            }
136        }
137        best.ok_or_else(|| first_error.unwrap_or(TriageError::NoSignal))
138    }
139
140    fn shadow_pair(&self) -> Option<(&Arc<dyn TaskAnalyzer>, &Arc<dyn TaskAnalyzer>)> {
141        let rules = self
142            .analyzers
143            .iter()
144            .find(|analyzer| analyzer.name() == "rules")?;
145        let semantic = self
146            .analyzers
147            .iter()
148            .find(|analyzer| analyzer.name() == "semantic" && analyzer.shadow_enabled())?;
149        Some((rules, semantic))
150    }
151}
152
153impl Default for TriageEngine {
154    fn default() -> Self {
155        Self::with_rules()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[derive(Debug)]
164    struct FixedAnalyzer {
165        name: &'static str,
166        result: Result<ProfileHypothesis, TriageError>,
167        shadow: bool,
168    }
169
170    impl TaskAnalyzer for FixedAnalyzer {
171        fn analyze(&self, _: &TaskAnalysisInput) -> Result<ProfileHypothesis, TriageError> {
172            self.result.clone()
173        }
174
175        fn name(&self) -> &'static str {
176            self.name
177        }
178        fn shadow_enabled(&self) -> bool {
179            self.shadow
180        }
181    }
182
183    fn hypothesis(intent: &str, backend: TriageBackendLocal) -> ProfileHypothesis {
184        ProfileHypothesis {
185            profile: TaskProfileLocal {
186                intent: intent.into(),
187                ..Default::default()
188            },
189            confidence_milli: 500,
190            backend,
191        }
192    }
193
194    #[test]
195    fn shadow_parallel_logs_and_keeps_rules_result() {
196        let data = crate::core::data_dir::isolated_data_dir();
197        let engine = TriageEngine::new(vec![
198            Box::new(FixedAnalyzer {
199                name: "rules",
200                result: Ok(hypothesis("rules", TriageBackendLocal::Rules)),
201                shadow: false,
202            }),
203            Box::new(FixedAnalyzer {
204                name: "semantic",
205                result: Ok(hypothesis("semantic", TriageBackendLocal::Semantic)),
206                shadow: true,
207            }),
208        ]);
209        let result = engine
210            .analyze(&TaskAnalysisInput {
211                query: "task".into(),
212                ..Default::default()
213            })
214            .unwrap();
215        assert_eq!(result.backend, TriageBackendLocal::Rules);
216        assert!(data.path().join("triage_shadow.jsonl").is_file());
217    }
218
219    #[test]
220    fn shadow_parallel_skips_log_when_semantic_is_unavailable() {
221        let data = crate::core::data_dir::isolated_data_dir();
222        let engine = TriageEngine::new(vec![
223            Box::new(FixedAnalyzer {
224                name: "rules",
225                result: Ok(hypothesis("rules", TriageBackendLocal::Rules)),
226                shadow: false,
227            }),
228            Box::new(FixedAnalyzer {
229                name: "semantic",
230                result: Err(TriageError::ModelUnavailable),
231                shadow: true,
232            }),
233        ]);
234        engine.analyze(&TaskAnalysisInput::default()).unwrap();
235        assert!(!data.path().join("triage_shadow.jsonl").exists());
236    }
237}