1#[derive(Clone, Debug, PartialEq)]
13pub enum PipelineStageKind {
14 Preprocess,
16 Expand { max_variants: usize },
18 Retrieve { top_k: usize },
20 Rank,
22 Filter { min_score: f32 },
24}
25
26#[derive(Clone, Debug)]
32pub struct QueryResult {
33 pub result_id: u64,
35 pub score: f32,
37 pub cid: String,
39 pub stage_added: String,
41}
42
43#[derive(Clone, Debug)]
49pub struct StageMetrics {
50 pub stage_name: String,
52 pub input_count: usize,
54 pub output_count: usize,
56 pub duration_ticks: u64,
58}
59
60pub struct PipelineRun {
66 pub query_embedding: Vec<f32>,
68 pub results: Vec<QueryResult>,
70 pub stage_metrics: Vec<StageMetrics>,
72 pub total_stages: usize,
74}
75
76impl PipelineRun {
77 pub fn result_count(&self) -> usize {
79 self.results.len()
80 }
81
82 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#[derive(Clone, Debug)]
98pub struct PipelineConfig {
99 pub stages: Vec<PipelineStageKind>,
101}
102
103impl PipelineConfig {
104 pub fn stage_count(&self) -> usize {
106 self.stages.len()
107 }
108}
109
110#[derive(Clone, Debug, Default)]
116pub struct PipelineStats {
117 pub total_runs: u64,
119 pub total_results_returned: u64,
121 pub avg_results_per_run: f64,
123}
124
125pub struct SemanticQueryPipeline {
135 pub config: PipelineConfig,
137 pub stats: PipelineStats,
139}
140
141impl SemanticQueryPipeline {
142 pub fn new(config: PipelineConfig) -> Self {
144 Self {
145 config,
146 stats: PipelineStats::default(),
147 }
148 }
149
150 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 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 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 pub fn stats(&self) -> &PipelineStats {
263 &self.stats
264 }
265}
266
267#[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
379 fn test_rank_sorts_descending() {
380 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 #[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 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 #[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 assert_eq!(run.result_count(), 3);
423 }
424
425 #[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 assert_eq!(run.result_count(), 3);
437 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 #[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 #[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 #[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); }
478
479 #[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 #[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 #[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 #[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 #[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 #[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)); pipeline.run(unit_embedding(4)); assert_eq!(pipeline.stats().total_results_returned, 6);
557 }
558
559 #[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)); pipeline.run(unit_embedding(4)); 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 #[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 #[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 #[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 #[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 #[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 assert!(run.query_embedding.iter().all(|&v| v == 0.0));
617 }
618
619 #[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}