Skip to main content

ipfrs_semantic/
embedding_pipeline.rs

1//! Embedding Pipeline — preprocess raw content into normalized vectors for HNSW insertion.
2//!
3//! Accepts bytes, text, structured data, or pre-computed embeddings, applies
4//! truncation/padding and a normalization strategy, and returns a `Vec<f32>` ready
5//! for indexing.
6
7use std::collections::HashMap;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::Arc;
10
11use thiserror::Error;
12
13// ---------------------------------------------------------------------------
14// Error type
15// ---------------------------------------------------------------------------
16
17/// Errors produced by [`EmbeddingPipeline`].
18#[derive(Debug, Error)]
19pub enum PipelineError {
20    /// Input was empty and cannot produce a meaningful embedding.
21    #[error("empty input")]
22    EmptyInput,
23
24    /// The supplied embedding has the wrong number of dimensions and
25    /// `truncate_to_dims` is disabled.
26    #[error("dimension mismatch: expected {expected}, got {got}")]
27    DimensionMismatch { expected: usize, got: usize },
28
29    /// The vector contains non-finite values (NaN / infinity).
30    #[error("invalid vector: {0}")]
31    InvalidVector(String),
32}
33
34// ---------------------------------------------------------------------------
35// Input enum
36// ---------------------------------------------------------------------------
37
38/// The kind of content that the pipeline accepts.
39#[derive(Debug, Clone)]
40pub enum EmbeddingInput {
41    /// Raw bytes with an associated MIME type hint.
42    RawBytes { data: Vec<u8>, mime_type: String },
43    /// UTF-8 text, optionally tagged with a BCP-47 language code.
44    Text {
45        content: String,
46        language: Option<String>,
47    },
48    /// Key/value structured data (e.g. document fields).
49    Structured { fields: HashMap<String, String> },
50    /// A pre-computed embedding — passed through dimensionality adjustment only.
51    Embedding { vector: Vec<f32> },
52}
53
54// ---------------------------------------------------------------------------
55// Normalization strategy
56// ---------------------------------------------------------------------------
57
58/// How the raw float vector is normalised before being returned.
59#[derive(Debug, Clone, PartialEq, Eq, Default)]
60pub enum NormalizationStrategy {
61    /// Do not modify values.
62    None,
63    /// Divide each element by the L2 norm so the result has unit length.
64    #[default]
65    L2,
66    /// Scale the vector to the range [0, 1] using min and max.
67    MinMax,
68    /// Subtract the mean and divide by the standard deviation.
69    ZScore,
70}
71
72// ---------------------------------------------------------------------------
73// Config
74// ---------------------------------------------------------------------------
75
76/// Configuration for an [`EmbeddingPipeline`].
77#[derive(Debug, Clone)]
78pub struct EmbeddingPipelineConfig {
79    /// Target dimensionality for all output vectors.
80    pub dimensions: usize,
81    /// Which normalization strategy to apply.
82    pub normalization: NormalizationStrategy,
83    /// Whether to truncate or pad vectors to exactly `dimensions`.
84    pub truncate_to_dims: bool,
85    /// Scalar used to pad vectors that are shorter than `dimensions`.
86    pub pad_value: f32,
87}
88
89impl Default for EmbeddingPipelineConfig {
90    fn default() -> Self {
91        Self {
92            dimensions: 128,
93            normalization: NormalizationStrategy::L2,
94            truncate_to_dims: true,
95            pad_value: 0.0,
96        }
97    }
98}
99
100// ---------------------------------------------------------------------------
101// Stats
102// ---------------------------------------------------------------------------
103
104/// Atomic counters tracking pipeline activity.
105#[derive(Debug, Default)]
106pub struct PipelineStats {
107    /// Number of inputs that produced a successful embedding.
108    pub total_processed: AtomicU64,
109    /// Total number of bytes that flowed through `RawBytes` inputs.
110    pub total_bytes_processed: AtomicU64,
111    /// Number of inputs that resulted in a [`PipelineError`].
112    pub total_errors: AtomicU64,
113}
114
115impl PipelineStats {
116    /// Return a point-in-time snapshot of the counters.
117    pub fn snapshot(&self) -> PipelineStatsSnapshot {
118        PipelineStatsSnapshot {
119            total_processed: self.total_processed.load(Ordering::Relaxed),
120            total_bytes_processed: self.total_bytes_processed.load(Ordering::Relaxed),
121            total_errors: self.total_errors.load(Ordering::Relaxed),
122        }
123    }
124}
125
126/// Non-atomic snapshot of [`PipelineStats`].
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct PipelineStatsSnapshot {
129    pub total_processed: u64,
130    pub total_bytes_processed: u64,
131    pub total_errors: u64,
132}
133
134// ---------------------------------------------------------------------------
135// FNV-1a helper
136// ---------------------------------------------------------------------------
137
138/// Deterministically hash `data` into a fixed-length `f32` vector.
139///
140/// The output length equals `target_len`. Bytes are consumed in non-overlapping
141/// groups whose size is `data.len() / target_len` (minimum 1). Within each
142/// group the bytes are XOR-folded into a single byte, then divided by 255.0 to
143/// produce a value in [0, 1].
144pub fn fnv1a_hash_f32(data: &[u8], target_len: usize) -> Vec<f32> {
145    if data.is_empty() || target_len == 0 {
146        return vec![0.0_f32; target_len];
147    }
148
149    let mut result = Vec::with_capacity(target_len);
150
151    // FNV-1a 64-bit constants
152    const FNV_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
153    const FNV_PRIME: u64 = 1_099_511_628_211;
154
155    for i in 0..target_len {
156        // Seed the hash with the bucket index so each bucket is distinct.
157        let mut hash: u64 = FNV_OFFSET_BASIS;
158        hash ^= i as u64;
159        hash = hash.wrapping_mul(FNV_PRIME);
160
161        // Spread data bytes across buckets using modular indexing.
162        // Every byte contributes to every bucket, weighted by position.
163        let step = data.len().max(1);
164        let start = (i * step) % data.len();
165        for j in 0..step {
166            let byte = data[(start + j) % data.len()];
167            hash ^= u64::from(byte);
168            hash = hash.wrapping_mul(FNV_PRIME);
169        }
170
171        // Map the 64-bit hash into [0, 1].
172        let normalized = (hash % 256) as f32 / 255.0_f32;
173        result.push(normalized);
174    }
175
176    result
177}
178
179// ---------------------------------------------------------------------------
180// Pipeline
181// ---------------------------------------------------------------------------
182
183/// Preprocessing pipeline that converts heterogeneous inputs into fixed-length
184/// normalised float vectors suitable for HNSW indexing.
185#[derive(Debug)]
186pub struct EmbeddingPipeline {
187    /// Configuration driving all pipeline behaviour.
188    pub config: EmbeddingPipelineConfig,
189    /// Operational statistics.
190    pub stats: Arc<PipelineStats>,
191}
192
193impl EmbeddingPipeline {
194    /// Create a new pipeline from the supplied configuration.
195    pub fn new(config: EmbeddingPipelineConfig) -> Self {
196        Self {
197            config,
198            stats: Arc::new(PipelineStats::default()),
199        }
200    }
201
202    /// Create a pipeline with default settings (128-dim, L2 normalisation).
203    pub fn with_defaults() -> Self {
204        Self::new(EmbeddingPipelineConfig::default())
205    }
206
207    // ------------------------------------------------------------------
208    // Public API
209    // ------------------------------------------------------------------
210
211    /// Process a single input and return the resulting embedding.
212    pub fn process(&self, input: EmbeddingInput) -> Result<Vec<f32>, PipelineError> {
213        let result = self.process_inner(input);
214        match &result {
215            Ok(_) => {
216                self.stats.total_processed.fetch_add(1, Ordering::Relaxed);
217            }
218            Err(_) => {
219                self.stats.total_errors.fetch_add(1, Ordering::Relaxed);
220            }
221        }
222        result
223    }
224
225    /// Process a batch of inputs, returning one `Result` per input.
226    pub fn batch_process(
227        &self,
228        inputs: Vec<EmbeddingInput>,
229    ) -> Vec<Result<Vec<f32>, PipelineError>> {
230        inputs.into_iter().map(|inp| self.process(inp)).collect()
231    }
232
233    // ------------------------------------------------------------------
234    // Normalisation
235    // ------------------------------------------------------------------
236
237    /// Apply the configured normalisation strategy **in place**.
238    pub fn normalize(&self, v: &mut [f32]) {
239        match self.config.normalization {
240            NormalizationStrategy::None => {}
241            NormalizationStrategy::L2 => normalize_l2(v),
242            NormalizationStrategy::MinMax => normalize_minmax(v),
243            NormalizationStrategy::ZScore => normalize_zscore(v),
244        }
245    }
246
247    // ------------------------------------------------------------------
248    // Internal helpers
249    // ------------------------------------------------------------------
250
251    fn process_inner(&self, input: EmbeddingInput) -> Result<Vec<f32>, PipelineError> {
252        let mut vec = match input {
253            EmbeddingInput::RawBytes { data, .. } => {
254                if data.is_empty() {
255                    return Err(PipelineError::EmptyInput);
256                }
257                let byte_count = data.len() as u64;
258                self.stats
259                    .total_bytes_processed
260                    .fetch_add(byte_count, Ordering::Relaxed);
261                fnv1a_hash_f32(&data, self.config.dimensions)
262            }
263
264            EmbeddingInput::Text { content, .. } => {
265                if content.is_empty() {
266                    return Err(PipelineError::EmptyInput);
267                }
268                // Unicode code point → f32, normalised to [0, 1] by dividing by U+10FFFF.
269                const MAX_CP: f32 = 0x10FFFF as f32;
270                content
271                    .chars()
272                    .map(|c| c as u32 as f32 / MAX_CP)
273                    .collect::<Vec<f32>>()
274            }
275
276            EmbeddingInput::Structured { fields } => {
277                if fields.is_empty() {
278                    return Err(PipelineError::EmptyInput);
279                }
280                // Sort by key for determinism, then concatenate as "key=value " pairs.
281                let mut pairs: Vec<(&String, &String)> = fields.iter().collect();
282                pairs.sort_by_key(|(k, _)| k.as_str());
283                let combined: String = pairs.iter().map(|(k, v)| format!("{}={} ", k, v)).collect();
284                // Reuse the Text path — recursion is safe because combined is non-empty.
285                const MAX_CP: f32 = 0x10FFFF as f32;
286                combined
287                    .chars()
288                    .map(|c| c as u32 as f32 / MAX_CP)
289                    .collect::<Vec<f32>>()
290            }
291
292            EmbeddingInput::Embedding { vector } => {
293                if vector.is_empty() {
294                    return Err(PipelineError::EmptyInput);
295                }
296                vector
297            }
298        };
299
300        // Validate (no NaN/inf before we try to normalise).
301        validate_finite(&vec)?;
302
303        // Truncate or pad.
304        if self.config.truncate_to_dims {
305            adjust_dimensions(&mut vec, self.config.dimensions, self.config.pad_value);
306        } else if vec.len() != self.config.dimensions {
307            return Err(PipelineError::DimensionMismatch {
308                expected: self.config.dimensions,
309                got: vec.len(),
310            });
311        }
312
313        // Normalise.
314        self.normalize(&mut vec);
315
316        Ok(vec)
317    }
318}
319
320// ---------------------------------------------------------------------------
321// Private utility functions
322// ---------------------------------------------------------------------------
323
324fn adjust_dimensions(v: &mut Vec<f32>, target: usize, pad: f32) {
325    match v.len().cmp(&target) {
326        std::cmp::Ordering::Greater => v.truncate(target),
327        std::cmp::Ordering::Less => v.resize(target, pad),
328        std::cmp::Ordering::Equal => {}
329    }
330}
331
332fn validate_finite(v: &[f32]) -> Result<(), PipelineError> {
333    for (i, &val) in v.iter().enumerate() {
334        if !val.is_finite() {
335            return Err(PipelineError::InvalidVector(format!(
336                "non-finite value at index {i}: {val}"
337            )));
338        }
339    }
340    Ok(())
341}
342
343fn normalize_l2(v: &mut [f32]) {
344    let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
345    if norm >= 1e-10 {
346        let inv = 1.0 / norm;
347        for x in v.iter_mut() {
348            *x *= inv;
349        }
350    }
351}
352
353fn normalize_minmax(v: &mut [f32]) {
354    let min = v.iter().cloned().fold(f32::INFINITY, f32::min);
355    let max = v.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
356    let range = max - min;
357    if range.abs() >= f32::EPSILON {
358        for x in v.iter_mut() {
359            *x = (*x - min) / range;
360        }
361    }
362}
363
364fn normalize_zscore(v: &mut [f32]) {
365    let n = v.len() as f32;
366    if n < 1.0 {
367        return;
368    }
369    let mean = v.iter().sum::<f32>() / n;
370    let variance = v.iter().map(|x| (x - mean).powi(2)).sum::<f32>() / n;
371    let std_dev = variance.sqrt();
372    if std_dev >= 1e-10 {
373        for x in v.iter_mut() {
374            *x = (*x - mean) / std_dev;
375        }
376    }
377}
378
379// ---------------------------------------------------------------------------
380// SemanticEmbeddingPipeline — multi-stage pipeline for normalized embeddings
381// ---------------------------------------------------------------------------
382
383/// A single transformation stage in a [`SemanticEmbeddingPipeline`].
384#[derive(Clone, Debug, PartialEq)]
385pub enum PipelineStage {
386    /// L2-normalize the embedding vector.
387    Normalize,
388    /// Multiply all components by a scalar factor.
389    Scale { factor: f32 },
390    /// Clamp each component to the range [min, max].
391    Clamp { min: f32, max: f32 },
392    /// Pad with 0.0 or truncate the vector to exactly `target_dim` elements.
393    PadOrTruncate { target_dim: usize },
394    /// Element-wise add `bias` to the vector.
395    ///
396    /// If `bias` is shorter than the vector the remaining elements get +0.0;
397    /// if `bias` is longer it is silently truncated.
398    AddBias { bias: Vec<f32> },
399}
400
401/// The result produced by [`SemanticEmbeddingPipeline::process`].
402#[derive(Clone, Debug)]
403pub struct PipelineResult {
404    /// The transformed embedding vector.
405    pub output: Vec<f32>,
406    /// Number of pipeline stages that were applied.
407    pub stages_applied: usize,
408    /// Dimensionality of the input that was fed to the pipeline.
409    pub input_dim: usize,
410    /// Dimensionality of the output embedding.
411    pub output_dim: usize,
412}
413
414impl PipelineResult {
415    /// Returns `true` when the pipeline changed the vector's dimensionality.
416    pub fn dimension_changed(&self) -> bool {
417        self.input_dim != self.output_dim
418    }
419}
420
421/// Aggregated statistics for a [`SemanticEmbeddingPipeline`].
422#[derive(Clone, Debug, Default)]
423pub struct SemanticPipelineStats {
424    /// Total number of vectors processed via [`SemanticEmbeddingPipeline::process`].
425    pub total_processed: u64,
426    /// Total stage-application count across all processed vectors.
427    pub total_stage_applications: u64,
428    /// Running average of the output dimensionality.
429    ///
430    /// Returns `0.0` when no vectors have been processed yet.
431    pub avg_output_dim: f64,
432}
433
434/// Multi-stage pipeline for transforming raw input vectors into
435/// normalized embeddings.
436///
437/// Stages are applied in insertion order.  The pipeline tracks lightweight
438/// statistics so callers can monitor throughput and dimensionality trends
439/// without a separate monitoring side-channel.
440///
441/// # Example
442/// ```
443/// use ipfrs_semantic::embedding_pipeline::{SemanticEmbeddingPipeline, PipelineStage};
444///
445/// let mut pipeline = SemanticEmbeddingPipeline::new();
446/// pipeline
447///     .add_stage(PipelineStage::Clamp { min: -1.0, max: 1.0 })
448///     .add_stage(PipelineStage::Normalize);
449///
450/// let result = pipeline.process(vec![3.0, -5.0, 2.0]);
451/// assert_eq!(result.stages_applied, 2);
452/// ```
453#[derive(Debug)]
454pub struct SemanticEmbeddingPipeline {
455    /// Ordered list of stages that are applied to every input.
456    pub stages: Vec<PipelineStage>,
457    // Internal accumulators — not exposed directly; use `.stats()` instead.
458    total_processed: u64,
459    total_stage_applications: u64,
460    total_output_dims: u64,
461}
462
463impl SemanticEmbeddingPipeline {
464    /// Create a new pipeline with no stages.
465    pub fn new() -> Self {
466        Self {
467            stages: Vec::new(),
468            total_processed: 0,
469            total_stage_applications: 0,
470            total_output_dims: 0,
471        }
472    }
473
474    /// Append a stage to the pipeline and return `&mut self` for chaining.
475    pub fn add_stage(&mut self, stage: PipelineStage) -> &mut Self {
476        self.stages.push(stage);
477        self
478    }
479
480    /// Remove all stages from the pipeline.
481    pub fn clear_stages(&mut self) {
482        self.stages.clear();
483    }
484
485    /// Return the number of stages currently in the pipeline.
486    pub fn stage_count(&self) -> usize {
487        self.stages.len()
488    }
489
490    /// Apply every stage in order to `input` and return a [`PipelineResult`].
491    pub fn process(&mut self, input: Vec<f32>) -> PipelineResult {
492        let input_dim = input.len();
493        let mut vec = input;
494
495        for stage in &self.stages {
496            Self::apply_stage(stage, &mut vec);
497        }
498
499        let output_dim = vec.len();
500        let stages_applied = self.stages.len();
501
502        self.total_processed += 1;
503        self.total_stage_applications += stages_applied as u64;
504        self.total_output_dims += output_dim as u64;
505
506        PipelineResult {
507            output: vec,
508            stages_applied,
509            input_dim,
510            output_dim,
511        }
512    }
513
514    /// Process a batch of input vectors, returning one [`PipelineResult`] each.
515    pub fn process_batch(&mut self, inputs: Vec<Vec<f32>>) -> Vec<PipelineResult> {
516        inputs.into_iter().map(|v| self.process(v)).collect()
517    }
518
519    /// Return a point-in-time snapshot of pipeline statistics.
520    pub fn stats(&self) -> SemanticPipelineStats {
521        let avg_output_dim = if self.total_processed == 0 {
522            0.0
523        } else {
524            self.total_output_dims as f64 / self.total_processed as f64
525        };
526        SemanticPipelineStats {
527            total_processed: self.total_processed,
528            total_stage_applications: self.total_stage_applications,
529            avg_output_dim,
530        }
531    }
532
533    // ------------------------------------------------------------------
534    // Private stage application
535    // ------------------------------------------------------------------
536
537    fn apply_stage(stage: &PipelineStage, vec: &mut Vec<f32>) {
538        match stage {
539            PipelineStage::Normalize => {
540                let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
541                if norm >= 1e-8 {
542                    let inv = 1.0 / norm;
543                    for x in vec.iter_mut() {
544                        *x *= inv;
545                    }
546                }
547            }
548
549            PipelineStage::Scale { factor } => {
550                for x in vec.iter_mut() {
551                    *x *= factor;
552                }
553            }
554
555            PipelineStage::Clamp { min, max } => {
556                for x in vec.iter_mut() {
557                    *x = x.clamp(*min, *max);
558                }
559            }
560
561            PipelineStage::PadOrTruncate { target_dim } => {
562                let current = vec.len();
563                match current.cmp(target_dim) {
564                    std::cmp::Ordering::Less => {
565                        vec.resize(*target_dim, 0.0_f32);
566                    }
567                    std::cmp::Ordering::Greater => {
568                        vec.truncate(*target_dim);
569                    }
570                    std::cmp::Ordering::Equal => {}
571                }
572            }
573
574            PipelineStage::AddBias { bias } => {
575                for (i, x) in vec.iter_mut().enumerate() {
576                    let b = if i < bias.len() { bias[i] } else { 0.0 };
577                    *x += b;
578                }
579            }
580        }
581    }
582}
583
584impl Default for SemanticEmbeddingPipeline {
585    fn default() -> Self {
586        Self::new()
587    }
588}
589
590// ---------------------------------------------------------------------------
591// Tests
592// ---------------------------------------------------------------------------
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    fn default_pipeline() -> EmbeddingPipeline {
599        EmbeddingPipeline::with_defaults()
600    }
601
602    fn pipeline_with(
603        dims: usize,
604        norm: NormalizationStrategy,
605        truncate: bool,
606    ) -> EmbeddingPipeline {
607        EmbeddingPipeline::new(EmbeddingPipelineConfig {
608            dimensions: dims,
609            normalization: norm,
610            truncate_to_dims: truncate,
611            pad_value: 0.0,
612        })
613    }
614
615    // 1. RawBytes produces vector of correct length
616    #[test]
617    fn raw_bytes_correct_length() {
618        let p = default_pipeline();
619        let input = EmbeddingInput::RawBytes {
620            data: vec![1u8, 2, 3, 4, 5, 6, 7, 8],
621            mime_type: "application/octet-stream".to_string(),
622        };
623        let v = p.process(input).expect("should succeed");
624        assert_eq!(v.len(), 128);
625    }
626
627    // 2. Text produces vector of correct length
628    #[test]
629    fn text_correct_length() {
630        let p = default_pipeline();
631        let input = EmbeddingInput::Text {
632            content: "hello world".to_string(),
633            language: None,
634        };
635        let v = p.process(input).expect("should succeed");
636        assert_eq!(v.len(), 128);
637    }
638
639    // 3. Structured produces deterministic output
640    #[test]
641    fn structured_deterministic() {
642        let p = default_pipeline();
643        let mut fields = HashMap::new();
644        fields.insert("name".to_string(), "alice".to_string());
645        fields.insert("age".to_string(), "30".to_string());
646
647        let v1 = p
648            .process(EmbeddingInput::Structured {
649                fields: fields.clone(),
650            })
651            .expect("first call");
652        let v2 = p
653            .process(EmbeddingInput::Structured { fields })
654            .expect("second call");
655        assert_eq!(v1, v2, "structured input must be deterministic");
656    }
657
658    // 4. Embedding pass-through (correct length preserved)
659    #[test]
660    fn embedding_passthrough() {
661        let p = default_pipeline();
662        let vec: Vec<f32> = (0..128).map(|i| i as f32 / 128.0).collect();
663        let input = EmbeddingInput::Embedding {
664            vector: vec.clone(),
665        };
666        let result = p.process(input).expect("should succeed");
667        assert_eq!(result.len(), 128);
668    }
669
670    // 5. L2 normalization: result has unit norm
671    #[test]
672    fn l2_unit_norm() {
673        let p = pipeline_with(16, NormalizationStrategy::L2, true);
674        let input = EmbeddingInput::Embedding {
675            vector: vec![1.0_f32; 16],
676        };
677        let v = p.process(input).expect("should succeed");
678        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
679        assert!(
680            (norm - 1.0).abs() < 1e-6,
681            "L2 norm should be 1.0, got {norm}"
682        );
683    }
684
685    // 6. MinMax normalization: result in [0, 1]
686    #[test]
687    fn minmax_in_range() {
688        let p = pipeline_with(16, NormalizationStrategy::MinMax, true);
689        let input = EmbeddingInput::Embedding {
690            vector: (0..16).map(|i| i as f32 * 3.7 - 10.0).collect(),
691        };
692        let v = p.process(input).expect("should succeed");
693        for &val in &v {
694            assert!(
695                (0.0..=1.0 + 1e-6).contains(&val),
696                "minmax value out of range: {val}"
697            );
698        }
699    }
700
701    // 7. ZScore normalization: approximately zero mean
702    #[test]
703    fn zscore_zero_mean() {
704        let p = pipeline_with(32, NormalizationStrategy::ZScore, true);
705        let input = EmbeddingInput::Embedding {
706            vector: (0..32).map(|i| i as f32).collect(),
707        };
708        let v = p.process(input).expect("should succeed");
709        let mean: f32 = v.iter().sum::<f32>() / v.len() as f32;
710        assert!(mean.abs() < 1e-5, "zscore mean should be ~0, got {mean}");
711    }
712
713    // 8. Truncation to configured dimensions
714    #[test]
715    fn truncation_to_dims() {
716        let p = pipeline_with(8, NormalizationStrategy::None, true);
717        let input = EmbeddingInput::Embedding {
718            vector: vec![0.1_f32; 64],
719        };
720        let v = p.process(input).expect("should succeed");
721        assert_eq!(v.len(), 8, "should be truncated to 8");
722    }
723
724    // 9. Padding to configured dimensions
725    #[test]
726    fn padding_to_dims() {
727        let p = EmbeddingPipeline::new(EmbeddingPipelineConfig {
728            dimensions: 32,
729            normalization: NormalizationStrategy::None,
730            truncate_to_dims: true,
731            pad_value: -1.0,
732        });
733        let input = EmbeddingInput::Embedding {
734            vector: vec![0.5_f32; 4],
735        };
736        let v = p.process(input).expect("should succeed");
737        assert_eq!(v.len(), 32);
738        // Padded portion should be -1.0 (before any normalisation — but None is used).
739        for &val in &v[4..] {
740            assert!((val - (-1.0)).abs() < 1e-7, "pad value mismatch: {val}");
741        }
742    }
743
744    // 10. batch_process handles multiple inputs
745    #[test]
746    fn batch_process_multiple() {
747        let p = default_pipeline();
748        let inputs = vec![
749            EmbeddingInput::Text {
750                content: "alpha".to_string(),
751                language: None,
752            },
753            EmbeddingInput::RawBytes {
754                data: vec![42u8; 20],
755                mime_type: "application/octet-stream".to_string(),
756            },
757            EmbeddingInput::Embedding {
758                vector: vec![0.3_f32; 128],
759            },
760        ];
761        let results = p.batch_process(inputs);
762        assert_eq!(results.len(), 3);
763        for r in &results {
764            assert!(r.is_ok(), "batch result should be Ok: {r:?}");
765        }
766    }
767
768    // 11. Stats accumulate
769    #[test]
770    fn stats_accumulate() {
771        let p = default_pipeline();
772        for _ in 0..5 {
773            let _ = p.process(EmbeddingInput::Text {
774                content: "test".to_string(),
775                language: None,
776            });
777        }
778        // Trigger one error
779        let _ = p.process(EmbeddingInput::Text {
780            content: String::new(),
781            language: None,
782        });
783        let snap = p.stats.snapshot();
784        assert_eq!(snap.total_processed, 5);
785        assert_eq!(snap.total_errors, 1);
786    }
787
788    // 12. Empty Text input returns error
789    #[test]
790    fn empty_text_error() {
791        let p = default_pipeline();
792        let result = p.process(EmbeddingInput::Text {
793            content: String::new(),
794            language: None,
795        });
796        assert!(matches!(result, Err(PipelineError::EmptyInput)));
797    }
798
799    // 13. Empty RawBytes returns error
800    #[test]
801    fn empty_bytes_error() {
802        let p = default_pipeline();
803        let result = p.process(EmbeddingInput::RawBytes {
804            data: vec![],
805            mime_type: "application/octet-stream".to_string(),
806        });
807        assert!(matches!(result, Err(PipelineError::EmptyInput)));
808    }
809
810    // 14. DimensionMismatch when truncate_to_dims is false
811    #[test]
812    fn dimension_mismatch_error() {
813        let p = pipeline_with(64, NormalizationStrategy::None, false);
814        let result = p.process(EmbeddingInput::Embedding {
815            vector: vec![1.0_f32; 32],
816        });
817        assert!(
818            matches!(
819                result,
820                Err(PipelineError::DimensionMismatch {
821                    expected: 64,
822                    got: 32
823                })
824            ),
825            "expected DimensionMismatch, got {result:?}"
826        );
827    }
828
829    // 15. RawBytes stats counter increments correctly
830    #[test]
831    fn raw_bytes_stats_counter() {
832        let p = default_pipeline();
833        let _ = p.process(EmbeddingInput::RawBytes {
834            data: vec![0xAA; 100],
835            mime_type: "application/octet-stream".to_string(),
836        });
837        let snap = p.stats.snapshot();
838        assert_eq!(snap.total_bytes_processed, 100);
839    }
840
841    // 16. None normalization preserves values (after pad/truncate)
842    #[test]
843    fn none_normalization_preserves_values() {
844        let p = pipeline_with(4, NormalizationStrategy::None, true);
845        let vals = vec![2.0_f32, 4.0, 6.0, 8.0];
846        let input = EmbeddingInput::Embedding {
847            vector: vals.clone(),
848        };
849        let v = p.process(input).expect("should succeed");
850        for (a, b) in vals.iter().zip(v.iter()) {
851            assert!((a - b).abs() < 1e-7);
852        }
853    }
854
855    // -----------------------------------------------------------------------
856    // SemanticEmbeddingPipeline tests
857    // -----------------------------------------------------------------------
858
859    // SEP-1: new() starts empty
860    #[test]
861    fn sep_new_starts_empty() {
862        let p = SemanticEmbeddingPipeline::new();
863        assert_eq!(p.stage_count(), 0);
864        assert!(p.stages.is_empty());
865    }
866
867    // SEP-2: add_stage builder returns &mut Self (chain works)
868    #[test]
869    fn sep_add_stage_builder() {
870        let mut p = SemanticEmbeddingPipeline::new();
871        p.add_stage(PipelineStage::Normalize)
872            .add_stage(PipelineStage::Scale { factor: 2.0 });
873        assert_eq!(p.stage_count(), 2);
874    }
875
876    // SEP-3: stage_count correct
877    #[test]
878    fn sep_stage_count_correct() {
879        let mut p = SemanticEmbeddingPipeline::new();
880        assert_eq!(p.stage_count(), 0);
881        p.add_stage(PipelineStage::Normalize);
882        assert_eq!(p.stage_count(), 1);
883        p.add_stage(PipelineStage::Scale { factor: 1.0 });
884        assert_eq!(p.stage_count(), 2);
885    }
886
887    // SEP-4: clear_stages resets
888    #[test]
889    fn sep_clear_stages_resets() {
890        let mut p = SemanticEmbeddingPipeline::new();
891        p.add_stage(PipelineStage::Normalize)
892            .add_stage(PipelineStage::Scale { factor: 2.0 });
893        p.clear_stages();
894        assert_eq!(p.stage_count(), 0);
895    }
896
897    // SEP-5: Normalize produces unit vector
898    #[test]
899    fn sep_normalize_unit_vector() {
900        let mut p = SemanticEmbeddingPipeline::new();
901        p.add_stage(PipelineStage::Normalize);
902        let result = p.process(vec![3.0, 4.0]);
903        let norm: f32 = result.output.iter().map(|x| x * x).sum::<f32>().sqrt();
904        assert!((norm - 1.0).abs() < 1e-6, "expected unit norm, got {norm}");
905    }
906
907    // SEP-6: Normalize zero vector unchanged
908    #[test]
909    fn sep_normalize_zero_vector_unchanged() {
910        let mut p = SemanticEmbeddingPipeline::new();
911        p.add_stage(PipelineStage::Normalize);
912        let result = p.process(vec![0.0, 0.0, 0.0]);
913        assert_eq!(result.output, vec![0.0_f32, 0.0, 0.0]);
914    }
915
916    // SEP-7: Scale multiplies correctly
917    #[test]
918    fn sep_scale_multiplies_correctly() {
919        let mut p = SemanticEmbeddingPipeline::new();
920        p.add_stage(PipelineStage::Scale { factor: 3.0 });
921        let result = p.process(vec![1.0, 2.0, 3.0]);
922        assert_eq!(result.output, vec![3.0_f32, 6.0, 9.0]);
923    }
924
925    // SEP-8: Clamp restricts values
926    #[test]
927    fn sep_clamp_restricts_values() {
928        let mut p = SemanticEmbeddingPipeline::new();
929        p.add_stage(PipelineStage::Clamp {
930            min: -1.0,
931            max: 1.0,
932        });
933        let result = p.process(vec![-5.0, 0.5, 3.0]);
934        assert_eq!(result.output, vec![-1.0_f32, 0.5, 1.0]);
935    }
936
937    // SEP-9: PadOrTruncate pads shorter vector
938    #[test]
939    fn sep_pad_or_truncate_pads_shorter() {
940        let mut p = SemanticEmbeddingPipeline::new();
941        p.add_stage(PipelineStage::PadOrTruncate { target_dim: 6 });
942        let result = p.process(vec![1.0, 2.0, 3.0]);
943        assert_eq!(result.output.len(), 6);
944        assert_eq!(&result.output[3..], &[0.0_f32, 0.0, 0.0]);
945    }
946
947    // SEP-10: PadOrTruncate truncates longer vector
948    #[test]
949    fn sep_pad_or_truncate_truncates_longer() {
950        let mut p = SemanticEmbeddingPipeline::new();
951        p.add_stage(PipelineStage::PadOrTruncate { target_dim: 2 });
952        let result = p.process(vec![1.0, 2.0, 3.0, 4.0]);
953        assert_eq!(result.output, vec![1.0_f32, 2.0]);
954    }
955
956    // SEP-11: PadOrTruncate exact length unchanged
957    #[test]
958    fn sep_pad_or_truncate_exact_unchanged() {
959        let mut p = SemanticEmbeddingPipeline::new();
960        p.add_stage(PipelineStage::PadOrTruncate { target_dim: 3 });
961        let result = p.process(vec![1.0, 2.0, 3.0]);
962        assert_eq!(result.output, vec![1.0_f32, 2.0, 3.0]);
963    }
964
965    // SEP-12: AddBias adds element-wise
966    #[test]
967    fn sep_add_bias_element_wise() {
968        let mut p = SemanticEmbeddingPipeline::new();
969        p.add_stage(PipelineStage::AddBias {
970            bias: vec![0.1, 0.2, 0.3],
971        });
972        let result = p.process(vec![1.0, 1.0, 1.0]);
973        let expected = [1.1_f32, 1.2, 1.3];
974        for (a, b) in result.output.iter().zip(expected.iter()) {
975            assert!((a - b).abs() < 1e-6, "got {a}, expected {b}");
976        }
977    }
978
979    // SEP-13: AddBias handles bias shorter than vec
980    #[test]
981    fn sep_add_bias_shorter_bias() {
982        let mut p = SemanticEmbeddingPipeline::new();
983        p.add_stage(PipelineStage::AddBias {
984            bias: vec![1.0], // shorter
985        });
986        let result = p.process(vec![0.0, 0.0, 0.0]);
987        assert!((result.output[0] - 1.0).abs() < 1e-6);
988        assert!((result.output[1] - 0.0).abs() < 1e-6);
989        assert!((result.output[2] - 0.0).abs() < 1e-6);
990    }
991
992    // SEP-14: AddBias handles bias longer than vec (truncated)
993    #[test]
994    fn sep_add_bias_longer_bias_truncated() {
995        let mut p = SemanticEmbeddingPipeline::new();
996        p.add_stage(PipelineStage::AddBias {
997            bias: vec![1.0, 2.0, 3.0, 4.0, 5.0], // longer
998        });
999        let result = p.process(vec![0.0, 0.0]);
1000        // Only first two bias values applied
1001        assert_eq!(result.output.len(), 2);
1002        assert!((result.output[0] - 1.0).abs() < 1e-6);
1003        assert!((result.output[1] - 2.0).abs() < 1e-6);
1004    }
1005
1006    // SEP-15: Multi-stage pipeline applies in order
1007    #[test]
1008    fn sep_multi_stage_order() {
1009        let mut p = SemanticEmbeddingPipeline::new();
1010        // Scale by 2, then clamp to [0, 3]
1011        p.add_stage(PipelineStage::Scale { factor: 2.0 })
1012            .add_stage(PipelineStage::Clamp { min: 0.0, max: 3.0 });
1013        let result = p.process(vec![-1.0, 1.0, 2.0]);
1014        // After Scale: [-2.0, 2.0, 4.0]
1015        // After Clamp: [0.0, 2.0, 3.0]
1016        assert_eq!(result.output, vec![0.0_f32, 2.0, 3.0]);
1017    }
1018
1019    // SEP-16: PipelineResult stages_applied correct
1020    #[test]
1021    fn sep_result_stages_applied_correct() {
1022        let mut p = SemanticEmbeddingPipeline::new();
1023        p.add_stage(PipelineStage::Normalize)
1024            .add_stage(PipelineStage::Scale { factor: 1.0 })
1025            .add_stage(PipelineStage::Clamp {
1026                min: -1.0,
1027                max: 1.0,
1028            });
1029        let result = p.process(vec![1.0, 0.0]);
1030        assert_eq!(result.stages_applied, 3);
1031    }
1032
1033    // SEP-17: PipelineResult input_dim / output_dim correct (no dim change)
1034    #[test]
1035    fn sep_result_dims_no_change() {
1036        let mut p = SemanticEmbeddingPipeline::new();
1037        p.add_stage(PipelineStage::Normalize);
1038        let result = p.process(vec![1.0, 2.0, 3.0]);
1039        assert_eq!(result.input_dim, 3);
1040        assert_eq!(result.output_dim, 3);
1041    }
1042
1043    // SEP-18: dimension_changed true when PadOrTruncate changes size
1044    #[test]
1045    fn sep_dimension_changed_true() {
1046        let mut p = SemanticEmbeddingPipeline::new();
1047        p.add_stage(PipelineStage::PadOrTruncate { target_dim: 8 });
1048        let result = p.process(vec![1.0, 2.0]);
1049        assert!(result.dimension_changed());
1050        assert_eq!(result.input_dim, 2);
1051        assert_eq!(result.output_dim, 8);
1052    }
1053
1054    // SEP-19: dimension_changed false when same size
1055    #[test]
1056    fn sep_dimension_changed_false() {
1057        let mut p = SemanticEmbeddingPipeline::new();
1058        p.add_stage(PipelineStage::Normalize);
1059        let result = p.process(vec![1.0, 0.0, 0.0]);
1060        assert!(!result.dimension_changed());
1061    }
1062
1063    // SEP-20: process_batch returns correct count
1064    #[test]
1065    fn sep_process_batch_count() {
1066        let mut p = SemanticEmbeddingPipeline::new();
1067        p.add_stage(PipelineStage::Normalize);
1068        let inputs = vec![
1069            vec![1.0, 0.0],
1070            vec![0.0, 1.0],
1071            vec![1.0, 1.0],
1072            vec![2.0, 3.0],
1073        ];
1074        let results = p.process_batch(inputs);
1075        assert_eq!(results.len(), 4);
1076    }
1077
1078    // SEP-21: stats total_processed increments
1079    #[test]
1080    fn sep_stats_total_processed_increments() {
1081        let mut p = SemanticEmbeddingPipeline::new();
1082        p.add_stage(PipelineStage::Normalize);
1083        for _ in 0..7 {
1084            p.process(vec![1.0, 2.0]);
1085        }
1086        let s = p.stats();
1087        assert_eq!(s.total_processed, 7);
1088        assert_eq!(s.total_stage_applications, 7);
1089    }
1090
1091    // SEP-22: stats avg_output_dim computed correctly
1092    #[test]
1093    fn sep_stats_avg_output_dim_correct() {
1094        let mut p = SemanticEmbeddingPipeline::new();
1095        // First: PadOrTruncate to 4 => output_dim = 4
1096        // Second: PadOrTruncate to 4 => output_dim = 4
1097        p.add_stage(PipelineStage::PadOrTruncate { target_dim: 4 });
1098        p.process(vec![1.0, 2.0]); // output_dim = 4
1099        p.process(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); // output_dim = 4
1100        let s = p.stats();
1101        assert_eq!(s.total_processed, 2);
1102        assert!((s.avg_output_dim - 4.0).abs() < 1e-9);
1103    }
1104
1105    // SEP-23: stats avg_output_dim is 0.0 when nothing processed
1106    #[test]
1107    fn sep_stats_avg_output_dim_zero_when_empty() {
1108        let p = SemanticEmbeddingPipeline::new();
1109        let s = p.stats();
1110        assert_eq!(s.total_processed, 0);
1111        assert_eq!(s.avg_output_dim, 0.0);
1112    }
1113}