1use std::collections::HashMap;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
30pub enum LayerKind {
31 Input,
32 Autonomy,
33 Intent,
34 Relevance,
35 Compression,
36 Translation,
37 Delivery,
38}
39
40impl LayerKind {
41 pub fn as_str(&self) -> &'static str {
43 match self {
44 Self::Input => "input",
45 Self::Autonomy => "autonomy",
46 Self::Intent => "intent",
47 Self::Relevance => "relevance",
48 Self::Compression => "compression",
49 Self::Translation => "translation",
50 Self::Delivery => "delivery",
51 }
52 }
53
54 pub fn all() -> &'static [LayerKind] {
56 &[
57 Self::Input,
58 Self::Autonomy,
59 Self::Intent,
60 Self::Relevance,
61 Self::Compression,
62 Self::Translation,
63 Self::Delivery,
64 ]
65 }
66}
67
68impl std::fmt::Display for LayerKind {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 write!(f, "{}", self.as_str())
71 }
72}
73
74impl std::str::FromStr for LayerKind {
75 type Err = String;
76
77 fn from_str(s: &str) -> Result<Self, Self::Err> {
78 match s.to_ascii_lowercase().as_str() {
79 "input" => Ok(Self::Input),
80 "autonomy" => Ok(Self::Autonomy),
81 "intent" => Ok(Self::Intent),
82 "relevance" => Ok(Self::Relevance),
83 "compression" => Ok(Self::Compression),
84 "translation" => Ok(Self::Translation),
85 "delivery" => Ok(Self::Delivery),
86 _ => Err(format!(
87 "unknown pipeline layer '{s}'; expected one of: input, autonomy, intent, relevance, compression, translation, delivery"
88 )),
89 }
90 }
91}
92
93#[derive(Debug, Clone)]
95pub struct LayerMetrics {
96 pub layer: LayerKind,
97 pub input_tokens: usize,
98 pub output_tokens: usize,
99 pub duration_us: u64,
100 pub compression_ratio: f64,
101}
102
103impl LayerMetrics {
104 pub fn new(
105 layer: LayerKind,
106 input_tokens: usize,
107 output_tokens: usize,
108 duration_us: u64,
109 ) -> Self {
110 let ratio = if input_tokens == 0 {
111 1.0
112 } else {
113 output_tokens as f64 / input_tokens as f64
114 };
115 Self {
116 layer,
117 input_tokens,
118 output_tokens,
119 duration_us,
120 compression_ratio: ratio,
121 }
122 }
123}
124
125#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
127pub struct PipelineStats {
128 pub runs: usize,
129 pub per_layer: HashMap<LayerKind, AggregatedMetrics>,
130}
131
132#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
134pub struct AggregatedMetrics {
135 pub total_input_tokens: usize,
136 pub total_output_tokens: usize,
137 pub total_duration_us: u64,
138 pub count: usize,
139}
140
141impl AggregatedMetrics {
142 pub fn avg_ratio(&self) -> f64 {
144 if self.total_input_tokens == 0 {
145 return 1.0;
146 }
147 self.total_output_tokens as f64 / self.total_input_tokens as f64
148 }
149
150 pub fn avg_duration_ms(&self) -> f64 {
152 if self.count == 0 {
153 return 0.0;
154 }
155 self.total_duration_us as f64 / self.count as f64 / 1000.0
156 }
157}
158
159impl PipelineStats {
160 pub fn new() -> Self {
162 Self {
163 runs: 0,
164 per_layer: HashMap::new(),
165 }
166 }
167
168 pub fn record(&mut self, metrics: &[LayerMetrics]) {
170 self.runs += 1;
171 for m in metrics {
172 let agg = self.per_layer.entry(m.layer).or_default();
173 agg.total_input_tokens += m.input_tokens;
174 agg.total_output_tokens += m.output_tokens;
175 agg.total_duration_us += m.duration_us;
176 agg.count += 1;
177 }
178 }
179
180 pub fn record_single(
182 &mut self,
183 layer: LayerKind,
184 input_tokens: usize,
185 output_tokens: usize,
186 duration: std::time::Duration,
187 ) {
188 self.runs += 1;
189 let agg = self.per_layer.entry(layer).or_default();
190 agg.total_input_tokens += input_tokens;
191 agg.total_output_tokens += output_tokens;
192 agg.total_duration_us += duration.as_micros() as u64;
193 agg.count += 1;
194 }
195
196 pub fn total_tokens_saved(&self) -> usize {
198 self.per_layer
199 .values()
200 .map(|a| a.total_input_tokens.saturating_sub(a.total_output_tokens))
201 .sum()
202 }
203
204 pub fn save(&self) {
206 if let Ok(dir) = crate::core::paths::state_dir() {
207 let path = dir.join("pipeline_stats.json");
208 if let Ok(json) = serde_json::to_string(self) {
209 let _ = std::fs::write(path, json);
210 }
211 }
212 }
213
214 pub fn load() -> Self {
216 crate::core::paths::state_dir()
217 .ok()
218 .map(|d| d.join("pipeline_stats.json"))
219 .and_then(|p| std::fs::read_to_string(p).ok())
220 .and_then(|s| serde_json::from_str(&s).ok())
221 .unwrap_or_default()
222 }
223
224 pub fn format_summary(&self) -> String {
226 let mut out = format!("Pipeline Stats ({} runs):\n", self.runs);
227 for kind in LayerKind::all() {
228 if let Some(agg) = self.per_layer.get(kind) {
229 out.push_str(&format!(
230 " {}: avg {:.0}% ratio, {:.1}ms, {} invocations\n",
231 kind,
232 agg.avg_ratio() * 100.0,
233 agg.avg_duration_ms(),
234 agg.count,
235 ));
236 }
237 }
238 out.push_str(&format!(" SAVED: {} tokens\n", self.total_tokens_saved()));
239 out
240 }
241}
242
243#[cfg(test)]
244pub mod tests {
245 use super::*;
246
247 #[test]
253 fn pipeline_stats_persist_to_state_dir_not_data_dir() {
254 let _lock = crate::core::data_dir::test_env_lock();
255 let state = tempfile::tempdir().unwrap();
256 let data = tempfile::tempdir().unwrap();
257 crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
258 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
259
260 PipelineStats::default().save();
261
262 let in_state = state.path().join("pipeline_stats.json").exists();
263 let in_data = data.path().join("pipeline_stats.json").exists();
264
265 crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
266 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
267
268 assert!(in_state, "pipeline_stats.json must be written to state_dir");
269 assert!(
270 !in_data,
271 "pipeline_stats.json must NOT land in the data dir"
272 );
273 }
274
275 #[test]
276 fn layer_kind_all_ordered() {
277 let all = LayerKind::all();
278 assert_eq!(all.len(), 7);
279 assert_eq!(all[0], LayerKind::Input);
280 assert_eq!(all[1], LayerKind::Autonomy);
281 assert_eq!(all[6], LayerKind::Delivery);
282 }
283
284 #[test]
285 fn metrics_new_calculates_ratio() {
286 let m = LayerMetrics::new(LayerKind::Compression, 100, 50, 1000);
287 assert!((m.compression_ratio - 0.5).abs() < f64::EPSILON);
288 }
289
290 #[test]
291 fn pipeline_stats_record_and_summarize() {
292 let mut stats = PipelineStats::default();
293 let metrics = vec![
294 LayerMetrics::new(LayerKind::Input, 1000, 1000, 100),
295 LayerMetrics::new(LayerKind::Compression, 1000, 300, 5000),
296 LayerMetrics::new(LayerKind::Delivery, 300, 300, 50),
297 ];
298 stats.record(&metrics);
299 stats.record(&metrics);
300
301 assert_eq!(stats.runs, 2);
302 assert_eq!(stats.total_tokens_saved(), 1400);
303
304 let agg = stats.per_layer.get(&LayerKind::Compression).unwrap();
305 assert_eq!(agg.count, 2);
306 assert_eq!(agg.total_input_tokens, 2000);
307 assert_eq!(agg.total_output_tokens, 600);
308
309 let summary = stats.format_summary();
310 assert!(summary.contains("2 runs"));
311 assert!(summary.contains("SAVED: 1400"));
312 }
313
314 #[test]
315 fn aggregated_metrics_avg() {
316 let agg = AggregatedMetrics {
317 total_input_tokens: 1000,
318 total_output_tokens: 500,
319 total_duration_us: 10000,
320 count: 2,
321 };
322 assert!((agg.avg_ratio() - 0.5).abs() < f64::EPSILON);
323 assert!((agg.avg_duration_ms() - 5.0).abs() < f64::EPSILON);
324 }
325
326 #[test]
327 fn layer_kind_from_str_valid() {
328 assert_eq!("input".parse::<LayerKind>().unwrap(), LayerKind::Input);
329 assert_eq!("Intent".parse::<LayerKind>().unwrap(), LayerKind::Intent);
330 assert_eq!(
331 "COMPRESSION".parse::<LayerKind>().unwrap(),
332 LayerKind::Compression
333 );
334 assert_eq!(
335 "delivery".parse::<LayerKind>().unwrap(),
336 LayerKind::Delivery
337 );
338 }
339
340 #[test]
341 fn layer_kind_from_str_invalid() {
342 let err = "unknown".parse::<LayerKind>().unwrap_err();
343 assert!(err.contains("unknown pipeline layer"));
344 assert!(err.contains("input, autonomy, intent"));
345 }
346
347 #[test]
348 fn layer_kind_roundtrip_str() {
349 for kind in LayerKind::all() {
350 let s = kind.as_str();
351 let parsed: LayerKind = s.parse().unwrap();
352 assert_eq!(*kind, parsed);
353 }
354 }
355
356 #[test]
357 fn pipeline_stats_record_single() {
358 let mut stats = PipelineStats::new();
359 stats.record_single(
360 LayerKind::Compression,
361 1000,
362 300,
363 std::time::Duration::from_millis(5),
364 );
365 assert_eq!(stats.runs, 1);
366 let agg = stats.per_layer.get(&LayerKind::Compression).unwrap();
367 assert_eq!(agg.total_input_tokens, 1000);
368 assert_eq!(agg.total_output_tokens, 300);
369 assert_eq!(agg.count, 1);
370 }
371}