Skip to main content

lean_ctx/core/
pipeline.rs

1//! # Context Pipeline
2//!
3//! The pipeline defines the processing stages that content flows through
4//! between raw input and the compressed output delivered to the LLM.
5//!
6//! ## Pipeline Flow
7//!
8//! ```text
9//! Input → Intent → Relevance → Compression → Translation → Delivery
10//! ```
11//!
12//! - **Input**: Raw file content / shell output enters the pipeline
13//! - **Intent**: Task-conditioned filtering — what is relevant to the current goal?
14//! - **Relevance**: Graph/heatmap-based prioritization of content sections
15//! - **Compression**: AST signatures, entropy filtering, delta encoding
16//! - **Translation**: Token shorthand (TDD), symbol replacement
17//! - **Delivery**: LITM positioning, CRP formatting, final output assembly
18//!
19//! `PipelineStats` is the aggregate shape for per-layer metrics. No production
20//! path records layer metrics, so its readers (`ctx_metrics`, `ctx_proof`,
21//! `lean-ctx gain --pipeline`) report it only as "0 runs" or "not collected".
22
23use std::collections::HashMap;
24
25/// Identifies a stage in the compression pipeline.
26///
27/// Layers execute in the order defined by [`LayerKind::all`]:
28/// Input → Intent → Relevance → Compression → Translation → Delivery.
29#[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    /// Returns the canonical string label for this layer.
42    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    /// Returns all layer kinds in pipeline execution order.
55    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/// Performance metrics for a single layer execution: tokens in/out, timing, ratio.
94#[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/// Persistent aggregated statistics across all pipeline runs.
126#[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/// Cumulative token counts and timing for a single pipeline layer across all runs.
133#[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    /// Returns the average compression ratio (output/input) across all runs.
143    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    /// Returns the average duration per invocation in milliseconds.
151    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    /// Creates empty pipeline stats with zero runs.
161    pub fn new() -> Self {
162        Self {
163            runs: 0,
164            per_layer: HashMap::new(),
165        }
166    }
167
168    /// Records a batch of layer metrics from a single pipeline execution.
169    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    /// Records metrics for a single layer execution.
181    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    /// Returns the total tokens saved across all pipeline layers.
197    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    /// Persists pipeline stats to the state dir's `pipeline_stats.json`.
205    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    /// Loads pipeline stats from disk, returning defaults if absent.
215    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    /// Formats a human-readable summary of per-layer stats and total savings.
225    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    /// GH #408 (XDG-3): `pipeline_stats.json` is runtime STATE and must persist
248    /// to `state_dir()`, never the data dir. With distinct category overrides the
249    /// file MUST land under STATE and be absent from DATA — this is the only kind
250    /// of test that catches a write/read category mismatch, since the shared test
251    /// sandbox collapses all categories onto one dir.
252    #[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}