1use std::collections::HashMap;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
29pub enum LayerKind {
30 Input,
31 Autonomy,
32 Intent,
33 Relevance,
34 Compression,
35 Translation,
36 Delivery,
37}
38
39impl LayerKind {
40 pub fn as_str(&self) -> &'static str {
42 match self {
43 Self::Input => "input",
44 Self::Autonomy => "autonomy",
45 Self::Intent => "intent",
46 Self::Relevance => "relevance",
47 Self::Compression => "compression",
48 Self::Translation => "translation",
49 Self::Delivery => "delivery",
50 }
51 }
52
53 pub fn all() -> &'static [LayerKind] {
55 &[
56 Self::Input,
57 Self::Autonomy,
58 Self::Intent,
59 Self::Relevance,
60 Self::Compression,
61 Self::Translation,
62 Self::Delivery,
63 ]
64 }
65}
66
67impl std::fmt::Display for LayerKind {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 write!(f, "{}", self.as_str())
70 }
71}
72
73impl std::str::FromStr for LayerKind {
74 type Err = String;
75
76 fn from_str(s: &str) -> Result<Self, Self::Err> {
77 match s.to_ascii_lowercase().as_str() {
78 "input" => Ok(Self::Input),
79 "autonomy" => Ok(Self::Autonomy),
80 "intent" => Ok(Self::Intent),
81 "relevance" => Ok(Self::Relevance),
82 "compression" => Ok(Self::Compression),
83 "translation" => Ok(Self::Translation),
84 "delivery" => Ok(Self::Delivery),
85 _ => Err(format!(
86 "unknown pipeline layer '{s}'; expected one of: input, autonomy, intent, relevance, compression, translation, delivery"
87 )),
88 }
89 }
90}
91
92#[derive(Debug, Clone)]
94pub struct LayerInput {
95 pub content: String,
96 pub tokens: usize,
97 pub metadata: HashMap<String, String>,
98}
99
100#[derive(Debug, Clone)]
102pub struct LayerOutput {
103 pub content: String,
104 pub tokens: usize,
105 pub metadata: HashMap<String, String>,
106}
107
108#[derive(Debug, Clone)]
110pub struct LayerMetrics {
111 pub layer: LayerKind,
112 pub input_tokens: usize,
113 pub output_tokens: usize,
114 pub duration_us: u64,
115 pub compression_ratio: f64,
116}
117
118impl LayerMetrics {
119 pub fn new(
120 layer: LayerKind,
121 input_tokens: usize,
122 output_tokens: usize,
123 duration_us: u64,
124 ) -> Self {
125 let ratio = if input_tokens == 0 {
126 1.0
127 } else {
128 output_tokens as f64 / input_tokens as f64
129 };
130 Self {
131 layer,
132 input_tokens,
133 output_tokens,
134 duration_us,
135 compression_ratio: ratio,
136 }
137 }
138}
139
140pub trait Layer {
142 fn kind(&self) -> LayerKind;
143 fn process(&self, input: LayerInput) -> LayerOutput;
144}
145
146pub fn is_layer_enabled(kind: LayerKind, cfg: &crate::core::profiles::PipelineConfig) -> bool {
148 match kind {
149 LayerKind::Input | LayerKind::Autonomy | LayerKind::Delivery => true,
150 LayerKind::Intent => cfg.intent_effective(),
151 LayerKind::Relevance => cfg.relevance_effective(),
152 LayerKind::Compression => cfg.compression_effective(),
153 LayerKind::Translation => cfg.translation_effective(),
154 }
155}
156
157pub struct Pipeline {
159 layers: Vec<Box<dyn Layer>>,
160}
161
162impl Pipeline {
163 pub fn new() -> Self {
165 Self { layers: Vec::new() }
166 }
167
168 pub fn add_layer(mut self, layer: Box<dyn Layer>) -> Self {
170 self.layers.push(layer);
171 self
172 }
173
174 pub fn add_layer_if_enabled(
176 self,
177 layer: Box<dyn Layer>,
178 cfg: &crate::core::profiles::PipelineConfig,
179 ) -> Self {
180 if is_layer_enabled(layer.kind(), cfg) {
181 self.add_layer(layer)
182 } else {
183 self
184 }
185 }
186
187 pub fn execute(&self, input: LayerInput) -> (LayerOutput, Vec<LayerMetrics>) {
189 let mut current = input;
190 let mut metrics = Vec::new();
191
192 for layer in &self.layers {
193 let start = std::time::Instant::now();
194 let input_tokens = current.tokens;
195 let output = layer.process(current);
196 let duration = start.elapsed().as_micros() as u64;
197
198 metrics.push(LayerMetrics::new(
199 layer.kind(),
200 input_tokens,
201 output.tokens,
202 duration,
203 ));
204
205 current = LayerInput {
206 content: output.content,
207 tokens: output.tokens,
208 metadata: output.metadata,
209 };
210 }
211
212 let final_output = LayerOutput {
213 content: current.content,
214 tokens: current.tokens,
215 metadata: current.metadata,
216 };
217
218 (final_output, metrics)
219 }
220
221 pub fn format_metrics(metrics: &[LayerMetrics]) -> String {
223 let mut out = String::from("Pipeline Metrics:\n");
224 let mut total_saved = 0usize;
225 for m in metrics {
226 let saved = m.input_tokens.saturating_sub(m.output_tokens);
227 total_saved += saved;
228 out.push_str(&format!(
229 " {} : {} -> {} tok ({:.0}%, {:.1}ms)\n",
230 m.layer,
231 m.input_tokens,
232 m.output_tokens,
233 m.compression_ratio * 100.0,
234 m.duration_us as f64 / 1000.0,
235 ));
236 }
237 if let (Some(first), Some(last)) = (metrics.first(), metrics.last()) {
238 let total_ratio = if first.input_tokens == 0 {
239 1.0
240 } else {
241 last.output_tokens as f64 / first.input_tokens as f64
242 };
243 out.push_str(&format!(
244 " TOTAL: {} -> {} tok ({:.0}%, saved {})\n",
245 first.input_tokens,
246 last.output_tokens,
247 total_ratio * 100.0,
248 total_saved,
249 ));
250 }
251 out
252 }
253}
254
255#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
257pub struct PipelineStats {
258 pub runs: usize,
259 pub per_layer: HashMap<LayerKind, AggregatedMetrics>,
260}
261
262#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
264pub struct AggregatedMetrics {
265 pub total_input_tokens: usize,
266 pub total_output_tokens: usize,
267 pub total_duration_us: u64,
268 pub count: usize,
269}
270
271impl AggregatedMetrics {
272 pub fn avg_ratio(&self) -> f64 {
274 if self.total_input_tokens == 0 {
275 return 1.0;
276 }
277 self.total_output_tokens as f64 / self.total_input_tokens as f64
278 }
279
280 pub fn avg_duration_ms(&self) -> f64 {
282 if self.count == 0 {
283 return 0.0;
284 }
285 self.total_duration_us as f64 / self.count as f64 / 1000.0
286 }
287}
288
289impl PipelineStats {
290 pub fn new() -> Self {
292 Self {
293 runs: 0,
294 per_layer: HashMap::new(),
295 }
296 }
297
298 pub fn record(&mut self, metrics: &[LayerMetrics]) {
300 self.runs += 1;
301 for m in metrics {
302 let agg = self.per_layer.entry(m.layer).or_default();
303 agg.total_input_tokens += m.input_tokens;
304 agg.total_output_tokens += m.output_tokens;
305 agg.total_duration_us += m.duration_us;
306 agg.count += 1;
307 }
308 }
309
310 pub fn record_single(
312 &mut self,
313 layer: LayerKind,
314 input_tokens: usize,
315 output_tokens: usize,
316 duration: std::time::Duration,
317 ) {
318 self.runs += 1;
319 let agg = self.per_layer.entry(layer).or_default();
320 agg.total_input_tokens += input_tokens;
321 agg.total_output_tokens += output_tokens;
322 agg.total_duration_us += duration.as_micros() as u64;
323 agg.count += 1;
324 }
325
326 pub fn total_tokens_saved(&self) -> usize {
328 self.per_layer
329 .values()
330 .map(|a| a.total_input_tokens.saturating_sub(a.total_output_tokens))
331 .sum()
332 }
333
334 pub fn save(&self) {
336 if let Ok(dir) = crate::core::paths::state_dir() {
337 let path = dir.join("pipeline_stats.json");
338 if let Ok(json) = serde_json::to_string(self) {
339 let _ = std::fs::write(path, json);
340 }
341 }
342 }
343
344 pub fn load() -> Self {
346 crate::core::paths::state_dir()
347 .ok()
348 .map(|d| d.join("pipeline_stats.json"))
349 .and_then(|p| std::fs::read_to_string(p).ok())
350 .and_then(|s| serde_json::from_str(&s).ok())
351 .unwrap_or_default()
352 }
353
354 pub fn format_summary(&self) -> String {
356 let mut out = format!("Pipeline Stats ({} runs):\n", self.runs);
357 for kind in LayerKind::all() {
358 if let Some(agg) = self.per_layer.get(kind) {
359 out.push_str(&format!(
360 " {}: avg {:.0}% ratio, {:.1}ms, {} invocations\n",
361 kind,
362 agg.avg_ratio() * 100.0,
363 agg.avg_duration_ms(),
364 agg.count,
365 ));
366 }
367 }
368 out.push_str(&format!(" SAVED: {} tokens\n", self.total_tokens_saved()));
369 out
370 }
371}
372
373impl Default for Pipeline {
374 fn default() -> Self {
375 Self::new()
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
389 fn pipeline_stats_persist_to_state_dir_not_data_dir() {
390 let _lock = crate::core::data_dir::test_env_lock();
391 let state = tempfile::tempdir().unwrap();
392 let data = tempfile::tempdir().unwrap();
393 std::env::set_var("LEAN_CTX_STATE_DIR", state.path());
394 std::env::set_var("LEAN_CTX_DATA_DIR", data.path());
395
396 PipelineStats::default().save();
397
398 let in_state = state.path().join("pipeline_stats.json").exists();
399 let in_data = data.path().join("pipeline_stats.json").exists();
400
401 std::env::remove_var("LEAN_CTX_STATE_DIR");
402 std::env::remove_var("LEAN_CTX_DATA_DIR");
403
404 assert!(in_state, "pipeline_stats.json must be written to state_dir");
405 assert!(
406 !in_data,
407 "pipeline_stats.json must NOT land in the data dir"
408 );
409 }
410
411 struct PassthroughLayer {
412 kind: LayerKind,
413 }
414
415 impl Layer for PassthroughLayer {
416 fn kind(&self) -> LayerKind {
417 self.kind
418 }
419
420 fn process(&self, input: LayerInput) -> LayerOutput {
421 LayerOutput {
422 content: input.content,
423 tokens: input.tokens,
424 metadata: input.metadata,
425 }
426 }
427 }
428
429 struct CompressionLayer {
430 ratio: f64,
431 }
432
433 impl Layer for CompressionLayer {
434 fn kind(&self) -> LayerKind {
435 LayerKind::Compression
436 }
437
438 fn process(&self, input: LayerInput) -> LayerOutput {
439 let new_tokens = (input.tokens as f64 * self.ratio) as usize;
440 let truncated = if input.content.len() > new_tokens * 4 {
441 input.content[..new_tokens * 4].to_string()
442 } else {
443 input.content
444 };
445 LayerOutput {
446 content: truncated,
447 tokens: new_tokens,
448 metadata: input.metadata,
449 }
450 }
451 }
452
453 #[test]
454 fn layer_kind_all_ordered() {
455 let all = LayerKind::all();
456 assert_eq!(all.len(), 7);
457 assert_eq!(all[0], LayerKind::Input);
458 assert_eq!(all[1], LayerKind::Autonomy);
459 assert_eq!(all[6], LayerKind::Delivery);
460 }
461
462 #[test]
463 fn passthrough_preserves_content() {
464 let layer = PassthroughLayer {
465 kind: LayerKind::Input,
466 };
467 let input = LayerInput {
468 content: "hello world".to_string(),
469 tokens: 2,
470 metadata: HashMap::new(),
471 };
472 let output = layer.process(input);
473 assert_eq!(output.content, "hello world");
474 assert_eq!(output.tokens, 2);
475 }
476
477 #[test]
478 fn compression_layer_reduces() {
479 let layer = CompressionLayer { ratio: 0.5 };
480 let input = LayerInput {
481 content: "a ".repeat(100),
482 tokens: 100,
483 metadata: HashMap::new(),
484 };
485 let output = layer.process(input);
486 assert_eq!(output.tokens, 50);
487 }
488
489 #[test]
490 fn pipeline_chains_layers() {
491 let pipeline = Pipeline::new()
492 .add_layer(Box::new(PassthroughLayer {
493 kind: LayerKind::Input,
494 }))
495 .add_layer(Box::new(CompressionLayer { ratio: 0.5 }))
496 .add_layer(Box::new(PassthroughLayer {
497 kind: LayerKind::Delivery,
498 }));
499
500 let input = LayerInput {
501 content: "a ".repeat(100),
502 tokens: 100,
503 metadata: HashMap::new(),
504 };
505 let (output, metrics) = pipeline.execute(input);
506 assert_eq!(output.tokens, 50);
507 assert_eq!(metrics.len(), 3);
508 assert_eq!(metrics[0].layer, LayerKind::Input);
509 assert_eq!(metrics[1].layer, LayerKind::Compression);
510 assert_eq!(metrics[2].layer, LayerKind::Delivery);
511 }
512
513 #[test]
514 fn metrics_new_calculates_ratio() {
515 let m = LayerMetrics::new(LayerKind::Compression, 100, 50, 1000);
516 assert!((m.compression_ratio - 0.5).abs() < f64::EPSILON);
517 }
518
519 #[test]
520 fn metrics_format_readable() {
521 let metrics = vec![
522 LayerMetrics::new(LayerKind::Input, 1000, 1000, 100),
523 LayerMetrics::new(LayerKind::Compression, 1000, 300, 5000),
524 LayerMetrics::new(LayerKind::Delivery, 300, 300, 50),
525 ];
526 let formatted = Pipeline::format_metrics(&metrics);
527 assert!(formatted.contains("input"));
528 assert!(formatted.contains("compression"));
529 assert!(formatted.contains("delivery"));
530 assert!(formatted.contains("TOTAL"));
531 }
532
533 #[test]
534 fn empty_pipeline_passes_through() {
535 let pipeline = Pipeline::new();
536 let input = LayerInput {
537 content: "test".to_string(),
538 tokens: 1,
539 metadata: HashMap::new(),
540 };
541 let (output, metrics) = pipeline.execute(input);
542 assert_eq!(output.content, "test");
543 assert!(metrics.is_empty());
544 }
545
546 #[test]
547 fn pipeline_stats_record_and_summarize() {
548 let mut stats = PipelineStats::default();
549 let metrics = vec![
550 LayerMetrics::new(LayerKind::Input, 1000, 1000, 100),
551 LayerMetrics::new(LayerKind::Compression, 1000, 300, 5000),
552 LayerMetrics::new(LayerKind::Delivery, 300, 300, 50),
553 ];
554 stats.record(&metrics);
555 stats.record(&metrics);
556
557 assert_eq!(stats.runs, 2);
558 assert_eq!(stats.total_tokens_saved(), 1400);
559
560 let agg = stats.per_layer.get(&LayerKind::Compression).unwrap();
561 assert_eq!(agg.count, 2);
562 assert_eq!(agg.total_input_tokens, 2000);
563 assert_eq!(agg.total_output_tokens, 600);
564
565 let summary = stats.format_summary();
566 assert!(summary.contains("2 runs"));
567 assert!(summary.contains("SAVED: 1400"));
568 }
569
570 #[test]
571 fn aggregated_metrics_avg() {
572 let agg = AggregatedMetrics {
573 total_input_tokens: 1000,
574 total_output_tokens: 500,
575 total_duration_us: 10000,
576 count: 2,
577 };
578 assert!((agg.avg_ratio() - 0.5).abs() < f64::EPSILON);
579 assert!((agg.avg_duration_ms() - 5.0).abs() < f64::EPSILON);
580 }
581
582 #[test]
583 fn layer_kind_from_str_valid() {
584 assert_eq!("input".parse::<LayerKind>().unwrap(), LayerKind::Input);
585 assert_eq!("Intent".parse::<LayerKind>().unwrap(), LayerKind::Intent);
586 assert_eq!(
587 "COMPRESSION".parse::<LayerKind>().unwrap(),
588 LayerKind::Compression
589 );
590 assert_eq!(
591 "delivery".parse::<LayerKind>().unwrap(),
592 LayerKind::Delivery
593 );
594 }
595
596 #[test]
597 fn layer_kind_from_str_invalid() {
598 let err = "unknown".parse::<LayerKind>().unwrap_err();
599 assert!(err.contains("unknown pipeline layer"));
600 assert!(err.contains("input, autonomy, intent"));
601 }
602
603 #[test]
604 fn layer_kind_roundtrip_str() {
605 for kind in LayerKind::all() {
606 let s = kind.as_str();
607 let parsed: LayerKind = s.parse().unwrap();
608 assert_eq!(*kind, parsed);
609 }
610 }
611
612 #[test]
613 fn pipeline_stats_record_single() {
614 let mut stats = PipelineStats::new();
615 stats.record_single(
616 LayerKind::Compression,
617 1000,
618 300,
619 std::time::Duration::from_millis(5),
620 );
621 assert_eq!(stats.runs, 1);
622 let agg = stats.per_layer.get(&LayerKind::Compression).unwrap();
623 assert_eq!(agg.total_input_tokens, 1000);
624 assert_eq!(agg.total_output_tokens, 300);
625 assert_eq!(agg.count, 1);
626 }
627
628 #[test]
629 fn pipeline_full_flow_integration() {
630 let pipeline = Pipeline::new()
631 .add_layer(Box::new(PassthroughLayer {
632 kind: LayerKind::Input,
633 }))
634 .add_layer(Box::new(PassthroughLayer {
635 kind: LayerKind::Autonomy,
636 }))
637 .add_layer(Box::new(PassthroughLayer {
638 kind: LayerKind::Intent,
639 }))
640 .add_layer(Box::new(PassthroughLayer {
641 kind: LayerKind::Relevance,
642 }))
643 .add_layer(Box::new(CompressionLayer { ratio: 0.3 }))
644 .add_layer(Box::new(PassthroughLayer {
645 kind: LayerKind::Translation,
646 }))
647 .add_layer(Box::new(PassthroughLayer {
648 kind: LayerKind::Delivery,
649 }));
650
651 let input = LayerInput {
652 content: "x ".repeat(500),
653 tokens: 500,
654 metadata: HashMap::new(),
655 };
656 let (output, metrics) = pipeline.execute(input);
657
658 assert_eq!(metrics.len(), 7, "all layers should produce metrics");
659 assert_eq!(output.tokens, 150, "compression at 0.3 ratio");
660
661 for (i, kind) in LayerKind::all().iter().enumerate() {
662 assert_eq!(metrics[i].layer, *kind, "layer order must match");
663 }
664
665 let mut stats = PipelineStats::new();
666 stats.record(&metrics);
667 assert_eq!(stats.runs, 1);
668 assert_eq!(stats.total_tokens_saved(), 350);
669
670 let formatted = Pipeline::format_metrics(&metrics);
671 assert!(formatted.contains("TOTAL"));
672 assert!(formatted.contains("500"));
673 }
674
675 #[test]
676 fn is_layer_enabled_respects_config() {
677 let cfg = crate::core::profiles::PipelineConfig {
678 intent: Some(false),
679 relevance: Some(false),
680 compression: Some(true),
681 translation: Some(true),
682 };
683
684 assert!(is_layer_enabled(LayerKind::Input, &cfg));
685 assert!(!is_layer_enabled(LayerKind::Intent, &cfg));
686 assert!(!is_layer_enabled(LayerKind::Relevance, &cfg));
687 assert!(is_layer_enabled(LayerKind::Compression, &cfg));
688 assert!(is_layer_enabled(LayerKind::Translation, &cfg));
689 assert!(is_layer_enabled(LayerKind::Delivery, &cfg));
690 }
691
692 #[test]
693 fn add_layer_if_enabled_skips_disabled() {
694 let cfg = crate::core::profiles::PipelineConfig {
695 intent: Some(false),
696 relevance: Some(true),
697 compression: Some(true),
698 translation: Some(true),
699 };
700
701 let pipeline = Pipeline::new()
702 .add_layer_if_enabled(
703 Box::new(PassthroughLayer {
704 kind: LayerKind::Input,
705 }),
706 &cfg,
707 )
708 .add_layer_if_enabled(
709 Box::new(PassthroughLayer {
710 kind: LayerKind::Intent,
711 }),
712 &cfg,
713 )
714 .add_layer_if_enabled(Box::new(CompressionLayer { ratio: 0.5 }), &cfg)
715 .add_layer_if_enabled(
716 Box::new(PassthroughLayer {
717 kind: LayerKind::Delivery,
718 }),
719 &cfg,
720 );
721
722 let input = LayerInput {
723 content: "x ".repeat(100),
724 tokens: 100,
725 metadata: HashMap::new(),
726 };
727 let (output, metrics) = pipeline.execute(input);
728
729 assert_eq!(
730 metrics.len(),
731 3,
732 "Intent layer should be skipped, leaving Input + Compression + Delivery"
733 );
734 assert_eq!(metrics[0].layer, LayerKind::Input);
735 assert_eq!(metrics[1].layer, LayerKind::Compression);
736 assert_eq!(metrics[2].layer, LayerKind::Delivery);
737 assert_eq!(output.tokens, 50);
738 }
739}