Skip to main content

ipfrs_semantic/
query_pipeline.rs

1//! Semantic Query Pipeline — composable multi-stage query processing.
2//!
3//! Chains pre-processing, expansion, retrieval simulation, ranking, and
4//! post-filtering into a single, configurable pipeline that records
5//! per-stage metrics for every run.
6
7// ---------------------------------------------------------------------------
8// PipelineStageKind
9// ---------------------------------------------------------------------------
10
11/// Identifies the kind of processing performed by a pipeline stage.
12#[derive(Clone, Debug, PartialEq)]
13pub enum PipelineStageKind {
14    /// Normalise / clean the query embedding (L2-normalisation).
15    Preprocess,
16    /// Generate `max_variants` synthetic query variants from the embedding.
17    Expand { max_variants: usize },
18    /// Simulate retrieval and return `top_k` scored results.
19    Retrieve { top_k: usize },
20    /// Re-rank all results by score descending.
21    Rank,
22    /// Discard results whose score falls below `min_score`.
23    Filter { min_score: f32 },
24}
25
26// ---------------------------------------------------------------------------
27// QueryResult
28// ---------------------------------------------------------------------------
29
30/// A single result emitted or transformed by the pipeline.
31#[derive(Clone, Debug)]
32pub struct QueryResult {
33    /// Opaque numeric identifier for this result.
34    pub result_id: u64,
35    /// Relevance score assigned by the producing stage.
36    pub score: f32,
37    /// Content Identifier string for the matching document / chunk.
38    pub cid: String,
39    /// Name of the stage that introduced this result into the pipeline.
40    pub stage_added: String,
41}
42
43// ---------------------------------------------------------------------------
44// StageMetrics
45// ---------------------------------------------------------------------------
46
47/// Execution metrics collected for a single pipeline stage during one run.
48#[derive(Clone, Debug)]
49pub struct StageMetrics {
50    /// Human-readable name of the stage (matches its variant name).
51    pub stage_name: String,
52    /// Number of results entering this stage.
53    pub input_count: usize,
54    /// Number of results leaving this stage.
55    pub output_count: usize,
56    /// Simulated duration expressed as the zero-based stage index.
57    pub duration_ticks: u64,
58}
59
60// ---------------------------------------------------------------------------
61// PipelineRun
62// ---------------------------------------------------------------------------
63
64/// The complete output of a single pipeline execution.
65pub struct PipelineRun {
66    /// The (possibly normalised) query embedding used for this run.
67    pub query_embedding: Vec<f32>,
68    /// Results that survived all stages.
69    pub results: Vec<QueryResult>,
70    /// Per-stage metrics recorded during the run.
71    pub stage_metrics: Vec<StageMetrics>,
72    /// Total number of stages executed.
73    pub total_stages: usize,
74}
75
76impl PipelineRun {
77    /// Returns the number of results in this run.
78    pub fn result_count(&self) -> usize {
79        self.results.len()
80    }
81
82    /// Returns the result with the highest score, or `None` if results is empty.
83    pub fn top_result(&self) -> Option<&QueryResult> {
84        self.results.iter().max_by(|a, b| {
85            a.score
86                .partial_cmp(&b.score)
87                .unwrap_or(std::cmp::Ordering::Equal)
88        })
89    }
90}
91
92// ---------------------------------------------------------------------------
93// PipelineConfig
94// ---------------------------------------------------------------------------
95
96/// Describes the ordered sequence of stages that form the pipeline.
97#[derive(Clone, Debug)]
98pub struct PipelineConfig {
99    /// Ordered list of stages to execute on each run.
100    pub stages: Vec<PipelineStageKind>,
101}
102
103impl PipelineConfig {
104    /// Returns the number of stages configured.
105    pub fn stage_count(&self) -> usize {
106        self.stages.len()
107    }
108}
109
110// ---------------------------------------------------------------------------
111// PipelineStats
112// ---------------------------------------------------------------------------
113
114/// Cumulative statistics aggregated across all runs of a pipeline instance.
115#[derive(Clone, Debug, Default)]
116pub struct PipelineStats {
117    /// Total number of times `run()` has been called.
118    pub total_runs: u64,
119    /// Total number of results returned across all runs.
120    pub total_results_returned: u64,
121    /// Average number of results returned per run.
122    pub avg_results_per_run: f64,
123}
124
125// ---------------------------------------------------------------------------
126// SemanticQueryPipeline
127// ---------------------------------------------------------------------------
128
129/// A composable pipeline that chains multiple semantic query processing stages.
130///
131/// Stages are executed in the order defined by [`PipelineConfig`]. Metrics are
132/// recorded for every stage on every run, and cumulative statistics are kept
133/// across all invocations of [`run`](Self::run).
134pub struct SemanticQueryPipeline {
135    /// Configuration describing which stages to run and their parameters.
136    pub config: PipelineConfig,
137    /// Running statistics updated after each call to [`run`](Self::run).
138    pub stats: PipelineStats,
139}
140
141impl SemanticQueryPipeline {
142    /// Create a new pipeline with the given configuration and zeroed statistics.
143    pub fn new(config: PipelineConfig) -> Self {
144        Self {
145            config,
146            stats: PipelineStats::default(),
147        }
148    }
149
150    /// Execute the pipeline for one query embedding, collecting per-stage metrics.
151    ///
152    /// The `query_embedding` is consumed and may be modified in-place by the
153    /// `Preprocess` stage before being stored in the returned [`PipelineRun`].
154    pub fn run(&mut self, query_embedding: Vec<f32>) -> PipelineRun {
155        let mut embedding = query_embedding;
156        let mut results: Vec<QueryResult> = Vec::new();
157        let mut stage_metrics: Vec<StageMetrics> = Vec::new();
158        let total_stages = self.config.stages.len();
159
160        for (stage_index, stage) in self.config.stages.clone().iter().enumerate() {
161            let input_count = results.len();
162            let tick = stage_index as u64;
163
164            match stage {
165                PipelineStageKind::Preprocess => {
166                    // L2-normalise the query embedding in-place.
167                    let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
168                    if norm > 1e-9 {
169                        for v in embedding.iter_mut() {
170                            *v /= norm;
171                        }
172                    }
173                    let output_count = results.len();
174                    stage_metrics.push(StageMetrics {
175                        stage_name: "Preprocess".to_string(),
176                        input_count,
177                        output_count,
178                        duration_ticks: tick,
179                    });
180                }
181
182                PipelineStageKind::Expand { max_variants } => {
183                    for i in 0..*max_variants {
184                        results.push(QueryResult {
185                            result_id: i as u64,
186                            score: 0.8 - (i as f32 * 0.05),
187                            cid: format!("expand_{i}"),
188                            stage_added: "Expand".to_string(),
189                        });
190                    }
191                    let output_count = results.len();
192                    stage_metrics.push(StageMetrics {
193                        stage_name: "Expand".to_string(),
194                        input_count,
195                        output_count,
196                        duration_ticks: tick,
197                    });
198                }
199
200                PipelineStageKind::Retrieve { top_k } => {
201                    for i in 0..*top_k {
202                        results.push(QueryResult {
203                            result_id: i as u64,
204                            score: 1.0 - (i as f32 * 0.1),
205                            cid: format!("retrieve_{i}"),
206                            stage_added: "Retrieve".to_string(),
207                        });
208                    }
209                    let output_count = results.len();
210                    stage_metrics.push(StageMetrics {
211                        stage_name: "Retrieve".to_string(),
212                        input_count,
213                        output_count,
214                        duration_ticks: tick,
215                    });
216                }
217
218                PipelineStageKind::Rank => {
219                    results.sort_by(|a, b| {
220                        b.score
221                            .partial_cmp(&a.score)
222                            .unwrap_or(std::cmp::Ordering::Equal)
223                    });
224                    let output_count = results.len();
225                    stage_metrics.push(StageMetrics {
226                        stage_name: "Rank".to_string(),
227                        input_count,
228                        output_count,
229                        duration_ticks: tick,
230                    });
231                }
232
233                PipelineStageKind::Filter { min_score } => {
234                    let threshold = *min_score;
235                    results.retain(|r| r.score >= threshold);
236                    let output_count = results.len();
237                    stage_metrics.push(StageMetrics {
238                        stage_name: "Filter".to_string(),
239                        input_count,
240                        output_count,
241                        duration_ticks: tick,
242                    });
243                }
244            }
245        }
246
247        // Update cumulative statistics.
248        self.stats.total_runs += 1;
249        self.stats.total_results_returned += results.len() as u64;
250        self.stats.avg_results_per_run =
251            self.stats.total_results_returned as f64 / self.stats.total_runs as f64;
252
253        PipelineRun {
254            query_embedding: embedding,
255            results,
256            stage_metrics,
257            total_stages,
258        }
259    }
260
261    /// Return a reference to the cumulative pipeline statistics.
262    pub fn stats(&self) -> &PipelineStats {
263        &self.stats
264    }
265}
266
267// ---------------------------------------------------------------------------
268// Tests
269// ---------------------------------------------------------------------------
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    fn make_pipeline(stages: Vec<PipelineStageKind>) -> SemanticQueryPipeline {
276        SemanticQueryPipeline::new(PipelineConfig { stages })
277    }
278
279    fn unit_embedding(dim: usize) -> Vec<f32> {
280        vec![1.0_f32 / (dim as f32).sqrt(); dim]
281    }
282
283    // 1. new() starts with zero stats
284    #[test]
285    fn test_new_stats_zeroed() {
286        let pipeline = make_pipeline(vec![]);
287        let s = pipeline.stats();
288        assert_eq!(s.total_runs, 0);
289        assert_eq!(s.total_results_returned, 0);
290        assert_eq!(s.avg_results_per_run, 0.0);
291    }
292
293    // 2. run with empty stages returns empty results
294    #[test]
295    fn test_empty_pipeline_empty_results() {
296        let mut pipeline = make_pipeline(vec![]);
297        let run = pipeline.run(vec![0.5, 0.5]);
298        assert_eq!(run.result_count(), 0);
299        assert!(run.results.is_empty());
300        assert!(run.stage_metrics.is_empty());
301    }
302
303    // 3. Preprocess normalizes embedding (no change to result count)
304    #[test]
305    fn test_preprocess_normalizes() {
306        let mut pipeline = make_pipeline(vec![PipelineStageKind::Preprocess]);
307        let run = pipeline.run(vec![3.0, 4.0]);
308        // 3-4-5 triangle: norm = 5 → [0.6, 0.8]
309        let emb = &run.query_embedding;
310        assert!((emb[0] - 0.6).abs() < 1e-5, "expected 0.6 got {}", emb[0]);
311        assert!((emb[1] - 0.8).abs() < 1e-5, "expected 0.8 got {}", emb[1]);
312    }
313
314    // 4. Preprocess does not change result count
315    #[test]
316    fn test_preprocess_no_result_change() {
317        let mut pipeline = make_pipeline(vec![PipelineStageKind::Preprocess]);
318        let run = pipeline.run(vec![1.0, 0.0]);
319        assert_eq!(run.result_count(), 0);
320    }
321
322    // 5. Expand generates correct number of results
323    #[test]
324    fn test_expand_result_count() {
325        let mut pipeline = make_pipeline(vec![PipelineStageKind::Expand { max_variants: 5 }]);
326        let run = pipeline.run(unit_embedding(4));
327        assert_eq!(run.result_count(), 5);
328    }
329
330    // 6. Expand result scores correctly generated (0.8, 0.75, 0.70, ...)
331    #[test]
332    fn test_expand_scores() {
333        let mut pipeline = make_pipeline(vec![PipelineStageKind::Expand { max_variants: 3 }]);
334        let run = pipeline.run(unit_embedding(4));
335        let scores: Vec<f32> = run.results.iter().map(|r| r.score).collect();
336        assert!((scores[0] - 0.8).abs() < 1e-5, "score[0]={}", scores[0]);
337        assert!((scores[1] - 0.75).abs() < 1e-5, "score[1]={}", scores[1]);
338        assert!((scores[2] - 0.70).abs() < 1e-5, "score[2]={}", scores[2]);
339    }
340
341    // 7. Expand result cids use "expand_{i}" format
342    #[test]
343    fn test_expand_cids() {
344        let mut pipeline = make_pipeline(vec![PipelineStageKind::Expand { max_variants: 2 }]);
345        let run = pipeline.run(unit_embedding(4));
346        assert_eq!(run.results[0].cid, "expand_0");
347        assert_eq!(run.results[1].cid, "expand_1");
348    }
349
350    // 8. Retrieve generates top_k results
351    #[test]
352    fn test_retrieve_result_count() {
353        let mut pipeline = make_pipeline(vec![PipelineStageKind::Retrieve { top_k: 7 }]);
354        let run = pipeline.run(unit_embedding(4));
355        assert_eq!(run.result_count(), 7);
356    }
357
358    // 9. Retrieve result scores 1.0, 0.9, 0.8 etc.
359    #[test]
360    fn test_retrieve_scores() {
361        let mut pipeline = make_pipeline(vec![PipelineStageKind::Retrieve { top_k: 3 }]);
362        let run = pipeline.run(unit_embedding(4));
363        assert!((run.results[0].score - 1.0).abs() < 1e-5);
364        assert!((run.results[1].score - 0.9).abs() < 1e-5);
365        assert!((run.results[2].score - 0.8).abs() < 1e-5);
366    }
367
368    // 10. Retrieve cids use "retrieve_{i}" format
369    #[test]
370    fn test_retrieve_cids() {
371        let mut pipeline = make_pipeline(vec![PipelineStageKind::Retrieve { top_k: 2 }]);
372        let run = pipeline.run(unit_embedding(4));
373        assert_eq!(run.results[0].cid, "retrieve_0");
374        assert_eq!(run.results[1].cid, "retrieve_1");
375    }
376
377    // 11. Rank sorts results by score descending
378    #[test]
379    fn test_rank_sorts_descending() {
380        // Expand adds scores 0.8, 0.75; Retrieve adds 1.0, 0.9; Rank sorts all.
381        let mut pipeline = make_pipeline(vec![
382            PipelineStageKind::Expand { max_variants: 2 },
383            PipelineStageKind::Retrieve { top_k: 2 },
384            PipelineStageKind::Rank,
385        ]);
386        let run = pipeline.run(unit_embedding(4));
387        let scores: Vec<f32> = run.results.iter().map(|r| r.score).collect();
388        for w in scores.windows(2) {
389            assert!(
390                w[0] >= w[1],
391                "Scores not sorted descending: {} < {}",
392                w[0],
393                w[1]
394            );
395        }
396    }
397
398    // 12. Filter removes results below threshold
399    #[test]
400    fn test_filter_removes_below_threshold() {
401        let mut pipeline = make_pipeline(vec![
402            PipelineStageKind::Retrieve { top_k: 5 },
403            PipelineStageKind::Filter { min_score: 0.85 },
404        ]);
405        let run = pipeline.run(unit_embedding(4));
406        // scores: 1.0, 0.9, 0.8, 0.7, 0.6 → only 1.0 and 0.9 pass
407        assert_eq!(run.result_count(), 2);
408        for r in &run.results {
409            assert!(r.score >= 0.85, "score {} below threshold", r.score);
410        }
411    }
412
413    // 13. Filter keeps results above threshold
414    #[test]
415    fn test_filter_keeps_above_threshold() {
416        let mut pipeline = make_pipeline(vec![
417            PipelineStageKind::Retrieve { top_k: 3 },
418            PipelineStageKind::Filter { min_score: 0.5 },
419        ]);
420        let run = pipeline.run(unit_embedding(4));
421        // scores 1.0, 0.9, 0.8 all pass 0.5
422        assert_eq!(run.result_count(), 3);
423    }
424
425    // 14. Multi-stage pipeline: Retrieve → Rank → Filter
426    #[test]
427    fn test_multi_stage_pipeline() {
428        let mut pipeline = make_pipeline(vec![
429            PipelineStageKind::Retrieve { top_k: 10 },
430            PipelineStageKind::Rank,
431            PipelineStageKind::Filter { min_score: 0.75 },
432        ]);
433        let run = pipeline.run(unit_embedding(4));
434        // Retrieve: 1.0, 0.9, 0.8, 0.7, … (10 items)
435        // Filter ≥ 0.75: 1.0, 0.9, 0.8 → 3 items
436        assert_eq!(run.result_count(), 3);
437        // Should still be sorted after Rank
438        let scores: Vec<f32> = run.results.iter().map(|r| r.score).collect();
439        for w in scores.windows(2) {
440            assert!(w[0] >= w[1]);
441        }
442    }
443
444    // 15. stage_metrics recorded for each stage
445    #[test]
446    fn test_stage_metrics_count() {
447        let mut pipeline = make_pipeline(vec![
448            PipelineStageKind::Preprocess,
449            PipelineStageKind::Expand { max_variants: 3 },
450            PipelineStageKind::Rank,
451        ]);
452        let run = pipeline.run(unit_embedding(4));
453        assert_eq!(run.stage_metrics.len(), 3);
454    }
455
456    // 16. StageMetrics input/output counts correct for Expand
457    #[test]
458    fn test_stage_metrics_expand_counts() {
459        let mut pipeline = make_pipeline(vec![PipelineStageKind::Expand { max_variants: 4 }]);
460        let run = pipeline.run(unit_embedding(4));
461        let m = &run.stage_metrics[0];
462        assert_eq!(m.input_count, 0, "Expand should see 0 inputs");
463        assert_eq!(m.output_count, 4, "Expand should produce 4 outputs");
464    }
465
466    // 17. StageMetrics input/output counts correct for Filter
467    #[test]
468    fn test_stage_metrics_filter_counts() {
469        let mut pipeline = make_pipeline(vec![
470            PipelineStageKind::Retrieve { top_k: 5 },
471            PipelineStageKind::Filter { min_score: 0.85 },
472        ]);
473        let run = pipeline.run(unit_embedding(4));
474        let filter_m = &run.stage_metrics[1];
475        assert_eq!(filter_m.input_count, 5);
476        assert_eq!(filter_m.output_count, 2); // 1.0 and 0.9 pass
477    }
478
479    // 18. StageMetrics duration_ticks equals stage index
480    #[test]
481    fn test_stage_metrics_duration_ticks() {
482        let mut pipeline = make_pipeline(vec![
483            PipelineStageKind::Preprocess,
484            PipelineStageKind::Retrieve { top_k: 2 },
485            PipelineStageKind::Rank,
486            PipelineStageKind::Filter { min_score: 0.5 },
487        ]);
488        let run = pipeline.run(unit_embedding(4));
489        for (idx, m) in run.stage_metrics.iter().enumerate() {
490            assert_eq!(
491                m.duration_ticks, idx as u64,
492                "stage {} ticks should be {}",
493                idx, idx
494            );
495        }
496    }
497
498    // 19. PipelineRun total_stages correct
499    #[test]
500    fn test_pipeline_run_total_stages() {
501        let mut pipeline = make_pipeline(vec![
502            PipelineStageKind::Expand { max_variants: 2 },
503            PipelineStageKind::Rank,
504        ]);
505        let run = pipeline.run(unit_embedding(4));
506        assert_eq!(run.total_stages, 2);
507    }
508
509    // 20. PipelineRun top_result() returns highest score
510    #[test]
511    fn test_top_result_highest_score() {
512        let mut pipeline = make_pipeline(vec![
513            PipelineStageKind::Expand { max_variants: 3 },
514            PipelineStageKind::Retrieve { top_k: 3 },
515        ]);
516        let run = pipeline.run(unit_embedding(4));
517        let top = run.top_result().expect("should have a top result");
518        let max_score = run
519            .results
520            .iter()
521            .map(|r| r.score)
522            .fold(f32::NEG_INFINITY, f32::max);
523        assert!(
524            (top.score - max_score).abs() < 1e-6,
525            "top_result score {} != max {}",
526            top.score,
527            max_score
528        );
529    }
530
531    // 21. PipelineRun result_count() correct
532    #[test]
533    fn test_result_count_method() {
534        let mut pipeline = make_pipeline(vec![PipelineStageKind::Retrieve { top_k: 6 }]);
535        let run = pipeline.run(unit_embedding(4));
536        assert_eq!(run.result_count(), 6);
537        assert_eq!(run.result_count(), run.results.len());
538    }
539
540    // 22. stats total_runs increments
541    #[test]
542    fn test_stats_total_runs_increments() {
543        let mut pipeline = make_pipeline(vec![]);
544        pipeline.run(vec![1.0]);
545        pipeline.run(vec![1.0]);
546        pipeline.run(vec![1.0]);
547        assert_eq!(pipeline.stats().total_runs, 3);
548    }
549
550    // 23. stats total_results_returned accumulates
551    #[test]
552    fn test_stats_total_results_accumulates() {
553        let mut pipeline = make_pipeline(vec![PipelineStageKind::Retrieve { top_k: 3 }]);
554        pipeline.run(unit_embedding(4)); // +3
555        pipeline.run(unit_embedding(4)); // +3
556        assert_eq!(pipeline.stats().total_results_returned, 6);
557    }
558
559    // 24. stats avg_results_per_run correct
560    #[test]
561    fn test_stats_avg_results_per_run() {
562        let mut pipeline = make_pipeline(vec![PipelineStageKind::Retrieve { top_k: 4 }]);
563        pipeline.run(unit_embedding(4)); // 4 results
564        pipeline.run(unit_embedding(4)); // 4 results
565        let avg = pipeline.stats().avg_results_per_run;
566        assert!((avg - 4.0).abs() < 1e-9, "expected avg 4.0, got {}", avg);
567    }
568
569    // 25. top_result returns None for empty results
570    #[test]
571    fn test_top_result_none_when_empty() {
572        let mut pipeline = make_pipeline(vec![]);
573        let run = pipeline.run(vec![]);
574        assert!(run.top_result().is_none());
575    }
576
577    // 26. Expand stage_added field is "Expand"
578    #[test]
579    fn test_expand_stage_added_field() {
580        let mut pipeline = make_pipeline(vec![PipelineStageKind::Expand { max_variants: 2 }]);
581        let run = pipeline.run(unit_embedding(4));
582        for r in &run.results {
583            assert_eq!(r.stage_added, "Expand");
584        }
585    }
586
587    // 27. Retrieve stage_added field is "Retrieve"
588    #[test]
589    fn test_retrieve_stage_added_field() {
590        let mut pipeline = make_pipeline(vec![PipelineStageKind::Retrieve { top_k: 2 }]);
591        let run = pipeline.run(unit_embedding(4));
592        for r in &run.results {
593            assert_eq!(r.stage_added, "Retrieve");
594        }
595    }
596
597    // 28. PipelineConfig::stage_count matches stages length
598    #[test]
599    fn test_pipeline_config_stage_count() {
600        let config = PipelineConfig {
601            stages: vec![
602                PipelineStageKind::Preprocess,
603                PipelineStageKind::Expand { max_variants: 1 },
604                PipelineStageKind::Rank,
605            ],
606        };
607        assert_eq!(config.stage_count(), 3);
608    }
609
610    // 29. Preprocess with zero vector stays zero (no panic)
611    #[test]
612    fn test_preprocess_zero_vector_no_panic() {
613        let mut pipeline = make_pipeline(vec![PipelineStageKind::Preprocess]);
614        let run = pipeline.run(vec![0.0, 0.0, 0.0]);
615        // Should remain zeroed and not panic.
616        assert!(run.query_embedding.iter().all(|&v| v == 0.0));
617    }
618
619    // 30. Rank with single result leaves it unchanged
620    #[test]
621    fn test_rank_single_result() {
622        let mut pipeline = make_pipeline(vec![
623            PipelineStageKind::Retrieve { top_k: 1 },
624            PipelineStageKind::Rank,
625        ]);
626        let run = pipeline.run(unit_embedding(4));
627        assert_eq!(run.result_count(), 1);
628        assert!((run.results[0].score - 1.0).abs() < 1e-5);
629    }
630}