Skip to main content

av_loopdetect/
onnx_embed.rs

1//! ONNX embedder via tract (pure Rust, no PyTorch/Python runtime).
2//!
3//! Loads a MiniLM-class sentence-embedding ONNX model from a configured path.
4//! Deployment note: model files are customer-supplied artifacts (air-gapped
5//! installs cannot download); the default deployment uses [`crate::HashEmbedder`]
6//! and swapping to ONNX is a config change, not a code change (plan D6).
7//!
8//! Tokenization is supplied by the model's Hugging Face `tokenizer.json`.
9
10use crate::embed::Embedder;
11use std::path::Path;
12use std::sync::Arc;
13use tract_onnx::prelude::*;
14
15type RunnableOnnxModel = Arc<TypedRunnableModel>;
16
17/// Embedder backed by an ONNX sentence-embedding model.
18pub struct OnnxEmbedder {
19    model: RunnableOnnxModel,
20    tokenizer: tokenizers::Tokenizer,
21    input_count: usize,
22    /// `input_order[i]` is the SEMANTIC tensor for graph input
23    /// position `i`: 0 = input_ids, 1 = attention_mask,
24    /// 2 = token_type_ids. See [`resolve_input_order`].
25    input_order: Vec<usize>,
26    dim: usize,
27}
28
29/// Map a graph input name to its semantic slot
30/// (0 = input_ids, 1 = attention_mask, 2 = token_type_ids).
31fn classify_input_name(name: &str) -> Option<usize> {
32    let lower = name.to_ascii_lowercase();
33    if lower.contains("input_id") {
34        Some(0)
35    } else if lower.contains("attention") || lower.contains("mask") {
36        Some(1)
37    } else if lower.contains("token_type") || lower.contains("segment") {
38        Some(2)
39    } else {
40        None
41    }
42}
43
44/// Resolve the semantic binding of the model's graph inputs.
45///
46/// Tensors were previously bound purely by POSITION
47/// (`input_ids, attention_mask[, token_type_ids]`): an otherwise valid
48/// BERT export whose graph declares the inputs in a different order
49/// loaded fine (only the COUNT was checked) and then silently swapped
50/// the mask and token ids at inference — same tensor types and shapes,
51/// so nothing downstream errored; every embedding was just garbage,
52/// and loop detection quietly stopped detecting. Model files are
53/// customer-supplied artifacts, so hostile-or-hasty exports are the
54/// expected input class.
55///
56/// Policy: when every input name is recognizable, bind by NAME in any
57/// order; when none are (minimal/anonymized exports), fall back to the
58/// positional convention; a PARTIALLY recognizable or duplicated set is
59/// ambiguous and refuses to load.
60fn resolve_input_order(names: &[String]) -> Result<Vec<usize>, String> {
61    let classified: Vec<Option<usize>> = names.iter().map(|n| classify_input_name(n)).collect();
62    if classified.iter().all(Option::is_none) {
63        return Ok((0..names.len()).collect());
64    }
65    let Some(order) = classified.into_iter().collect::<Option<Vec<usize>>>() else {
66        return Err(format!(
67            "ONNX sentence model input names {names:?} are only partially recognizable; \
68             refusing an ambiguous tensor binding (expected names containing \
69             input_ids / attention_mask / token_type_ids, or none of them)"
70        ));
71    };
72    let mut seen = vec![false; names.len()];
73    for &slot in &order {
74        if slot >= names.len() || seen.get(slot).copied().unwrap_or(true) {
75            return Err(format!(
76                "ONNX sentence model input names {names:?} do not form exactly one each of \
77                 input_ids / attention_mask{}",
78                if names.len() == 3 { " / token_type_ids" } else { "" }
79            ));
80        }
81        if let Some(flag) = seen.get_mut(slot) {
82            *flag = true;
83        }
84    }
85    Ok(order)
86}
87
88impl OnnxEmbedder {
89    /// Load a model and its paired tokenizer. `dim` must match the output width.
90    pub fn load(path: &Path, tokenizer_path: &Path, dim: usize) -> Result<Self, String> {
91        if dim == 0 {
92            return Err("ONNX embedding dimension must be greater than zero".to_owned());
93        }
94        let model = tract_onnx::onnx()
95            .model_for_path(path)
96            .map_err(|error| error.to_string())?
97            .into_optimized()
98            .map_err(|error| error.to_string())?;
99        let input_outlets = model.input_outlets().map_err(|error| error.to_string())?.to_vec();
100        let input_count = input_outlets.len();
101        if !(2..=3).contains(&input_count) {
102            return Err(format!(
103                "ONNX sentence model must have 2 or 3 inputs, found {input_count}"
104            ));
105        }
106        let input_names: Vec<String> = input_outlets
107            .iter()
108            .map(|outlet| model.node(outlet.node).name.clone())
109            .collect();
110        let input_order = resolve_input_order(&input_names)?;
111        let model = model.into_runnable().map_err(|error| error.to_string())?;
112        let tokenizer =
113            tokenizers::Tokenizer::from_file(tokenizer_path).map_err(|error| error.to_string())?;
114        Ok(Self {
115            model,
116            tokenizer,
117            input_count,
118            input_order,
119            dim,
120        })
121    }
122
123    fn infer(&self, text: &str) -> Result<Vec<f32>, String> {
124        let encoding = self
125            .tokenizer
126            .encode(text, true)
127            .map_err(|error| error.to_string())?;
128        let ids: Vec<i64> = encoding
129            .get_ids()
130            .iter()
131            .take(512)
132            .map(|id| i64::from(*id))
133            .collect();
134        if ids.is_empty() {
135            return Ok(vec![0.0; self.dim]);
136        }
137        let len = ids.len();
138        let input =
139            tract_ndarray::Array2::from_shape_vec((1, len), ids).map_err(|error| error.to_string())?;
140        let mask_values: Vec<i64> = encoding
141            .get_attention_mask()
142            .iter()
143            .take(len)
144            .map(|value| i64::from(*value))
145            .collect();
146        let mask = tract_ndarray::Array2::from_shape_vec((1, len), mask_values.clone())
147            .map_err(|error| error.to_string())?;
148        let inputs = {
149            // Semantic tensors: slot 0 = input_ids, 1 = attention_mask,
150            // 2 = token_type_ids; arranged into the model's declared
151            // graph-input order (see `resolve_input_order`).
152            let mut semantic: Vec<TValue> = vec![Tensor::from(input).into(), Tensor::from(mask).into()];
153            if self.input_count == 3 {
154                let token_types: Vec<i64> = encoding
155                    .get_type_ids()
156                    .iter()
157                    .take(len)
158                    .map(|value| i64::from(*value))
159                    .collect();
160                let token_types = tract_ndarray::Array2::from_shape_vec((1, len), token_types)
161                    .map_err(|error| error.to_string())?;
162                semantic.push(Tensor::from(token_types).into());
163            }
164            self.input_order
165                .iter()
166                .map(|&slot| {
167                    semantic
168                        .get(slot)
169                        .cloned()
170                        .ok_or_else(|| format!("ONNX input slot {slot} out of range"))
171                })
172                .collect::<Result<TVec<TValue>, String>>()?
173        };
174        let outputs = self.model.run(inputs).map_err(|error| error.to_string())?;
175        let output = outputs
176            .first()
177            .ok_or_else(|| "ONNX model returned no output".to_owned())?;
178        let view = output
179            .to_plain_array_view::<f32>()
180            .map_err(|error| error.to_string())?;
181        let mut vector = pool_output(view, &mask_values, self.dim)?;
182        // f64 accumulation: an f32 sum-of-squares underflows to 0.0 for
183        // tiny finite activations, which skipped normalization and
184        // emitted the exact vector shape that used to defeat `cosine`
185        // (see embed.rs::cosine). Keep the two computations consistent.
186        let norm = vector
187            .iter()
188            .map(|value| f64::from(*value) * f64::from(*value))
189            .sum::<f64>()
190            .sqrt();
191        if norm > 0.0 {
192            for value in &mut vector {
193                // Intentional f64→f32 rounding: unit-normalized
194                // components are in [-1, 1].
195                #[allow(clippy::cast_possible_truncation)]
196                {
197                    *value = (f64::from(*value) / norm) as f32;
198                }
199            }
200        }
201        Ok(vector)
202    }
203}
204
205fn pool_output(
206    output: tract_ndarray::ArrayViewD<'_, f32>,
207    attention_mask: &[i64],
208    expected_dim: usize,
209) -> Result<Vec<f32>, String> {
210    let shape = output.shape();
211    let vector = match shape {
212        [width] if *width == expected_dim => output.iter().copied().collect(),
213        [1, width] if *width == expected_dim => output.iter().copied().collect(),
214        [1, tokens, width] if *width == expected_dim && *tokens == attention_mask.len() => {
215            let mut pooled = vec![0.0f32; expected_dim];
216            let mut weight = 0.0f32;
217            for (token, mask) in attention_mask.iter().enumerate() {
218                if *mask <= 0 {
219                    continue;
220                }
221                let token_weight = *mask as f32;
222                weight += token_weight;
223                for (feature, value) in pooled.iter_mut().enumerate() {
224                    let index = tract_ndarray::IxDyn(&[0, token, feature]);
225                    let embedding = output
226                        .get(index)
227                        .ok_or_else(|| "ONNX output index escaped validated shape".to_owned())?;
228                    *value += *embedding * token_weight;
229                }
230            }
231            if weight == 0.0 {
232                return Err("ONNX attention mask contains no active tokens".to_owned());
233            }
234            for value in &mut pooled {
235                *value /= weight;
236            }
237            pooled
238        }
239        _ => {
240            return Err(format!(
241                "ONNX output shape {shape:?} is incompatible with embedding dimension {expected_dim} and token count {}",
242                attention_mask.len()
243            ));
244        }
245    };
246    if vector.iter().any(|value| !value.is_finite()) {
247        return Err("ONNX output contains non-finite values".to_owned());
248    }
249    Ok(vector)
250}
251
252impl Embedder for OnnxEmbedder {
253    fn dim(&self) -> usize {
254        self.dim
255    }
256
257    fn embed(&self, text: &str) -> Vec<f32> {
258        self.infer(text)
259            .unwrap_or_else(|error| fallback_hash_embedding(self.dim, text, &error))
260    }
261
262    fn try_embed(&self, text: &str) -> Result<Vec<f32>, String> {
263        // Production callers go through
264        // `try_embed`, so a fallback that lived only in `embed()` was
265        // dead code — every ONNX inference failure bricked the session
266        // upstream. Apply the same
267        // HashEmbedder fallback here so we always return Ok. The sink
268        // path is now warn+continue anyway, but this preserves breaker
269        // observations on transient ONNX errors.
270        Ok(self
271            .infer(text)
272            .unwrap_or_else(|error| fallback_hash_embedding(self.dim, text, &error)))
273    }
274}
275
276/// The prior fallback returned a zero vector on ONNX
277/// inference failure, which the breaker interprets as an
278/// empty/degenerate input signal (`delta ≈ 0`) — so an outage could
279/// trip the loop-breaker mid-flight. HashEmbedder returns a non-zero
280/// content-derived vector: same text → same vector, distinct texts →
281/// distinct vectors, breaker semantics preserved through the outage.
282fn fallback_hash_embedding(dim: usize, text: &str, error: &str) -> Vec<f32> {
283    tracing::warn!(
284        error = %error,
285        dim,
286        "ONNX inference failed; falling back to deterministic HashEmbedder \
287         (non-zero so the breaker does not treat outages as false duplicates)"
288    );
289    crate::HashEmbedder::new(dim).embed(text)
290}
291
292#[cfg(test)]
293mod tests {
294    #![allow(clippy::indexing_slicing, clippy::unwrap_used)]
295
296    use super::*;
297
298    #[test]
299    fn token_embeddings_use_masked_mean_pooling() {
300        let output = tract_ndarray::Array3::from_shape_vec((1, 3, 2), vec![1.0, 0.0, 1.0, 2.0, 100.0, 100.0])
301            .unwrap()
302            .into_dyn();
303        let mut vector = pool_output(output.view(), &[1, 1, 0], 2).unwrap();
304        let norm = vector.iter().map(|value| value * value).sum::<f32>().sqrt();
305        for value in &mut vector {
306            *value /= norm;
307        }
308        let expected = 1.0 / 2.0f32.sqrt();
309        assert!((vector[0] - expected).abs() < 1e-6);
310        assert!((vector[1] - expected).abs() < 1e-6);
311    }
312
313    #[test]
314    fn pooled_export_requires_exact_embedding_width() {
315        let output = tract_ndarray::Array2::from_shape_vec((1, 3), vec![1.0, 2.0, 3.0])
316            .unwrap()
317            .into_dyn();
318        let error = pool_output(output.view(), &[1], 2).unwrap_err();
319        assert!(error.contains("output shape"));
320    }
321
322    #[test]
323    fn token_output_requires_matching_attention_mask() {
324        let output = tract_ndarray::Array3::zeros((1, 2, 3)).into_dyn();
325        let error = pool_output(output.view(), &[1], 3).unwrap_err();
326        assert!(error.contains("token count 1"));
327    }
328
329    fn names(list: &[&str]) -> Vec<String> {
330        list.iter().map(|s| (*s).to_owned()).collect()
331    }
332
333    /// Inputs were previously bound purely by position; a reordered but
334    /// otherwise valid export silently swapped mask and token ids
335    /// (identical tensor types/shapes — nothing errored, embeddings
336    /// were garbage). Recognizable names must bind semantically in any
337    /// order; anonymized exports keep the positional convention; a
338    /// partially recognizable or duplicated set refuses to load.
339    #[test]
340    fn input_order_resolves_names_in_any_order() {
341        assert_eq!(
342            resolve_input_order(&names(&["input_ids", "attention_mask", "token_type_ids"])).unwrap(),
343            vec![0, 1, 2]
344        );
345        assert_eq!(
346            resolve_input_order(&names(&["attention_mask", "input_ids", "token_type_ids"])).unwrap(),
347            vec![1, 0, 2]
348        );
349        assert_eq!(
350            resolve_input_order(&names(&["input_ids", "attention_mask"])).unwrap(),
351            vec![0, 1]
352        );
353        // tract-optimized graphs may decorate source names; substring
354        // classification must survive that.
355        assert_eq!(
356            resolve_input_order(&names(&["input_ids.cast", "ATTENTION_MASK_0"])).unwrap(),
357            vec![0, 1]
358        );
359    }
360
361    #[test]
362    fn input_order_falls_back_to_position_for_anonymized_exports() {
363        assert_eq!(
364            resolve_input_order(&names(&["input.1", "input.3", "input.5"])).unwrap(),
365            vec![0, 1, 2]
366        );
367    }
368
369    #[test]
370    fn input_order_refuses_ambiguous_name_sets() {
371        // Partially recognizable.
372        assert!(resolve_input_order(&names(&["input_ids", "input.3"]))
373            .unwrap_err()
374            .contains("partially recognizable"));
375        // Two masks, no ids.
376        assert!(resolve_input_order(&names(&["attention_mask", "mask_two"]))
377            .unwrap_err()
378            .contains("exactly one each"));
379        // token_type_ids in a 2-input model.
380        assert!(resolve_input_order(&names(&["input_ids", "token_type_ids"]))
381            .unwrap_err()
382            .contains("exactly one each"));
383    }
384}