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
23pub trait TaskAnalyzer: std::fmt::Debug + Send + Sync {
25 fn analyze(&self, input: &TaskAnalysisInput) -> Result<ProfileHypothesis, TriageError>;
26 fn name(&self) -> &'static str;
27
28 fn shadow_enabled(&self) -> bool {
30 false
31 }
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub 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)]
44pub 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)]
52pub enum TriageBackendLocal {
54 #[default]
55 Rules,
56 Semantic,
57 Hybrid,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum TriageError {
63 NoSignal,
64 ModelUnavailable,
65 InternalError(String),
66}
67
68#[derive(Debug, Clone)]
69pub 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 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}