Skip to main content

chio_guards/
embedding_anomaly.rs

1//! EmbeddingAnomaly embedding detector - cosine-similarity anomaly detection.
2//!
3//! Wrapped in Chio's synchronous [`chio_kernel::Guard`] trait.
4//!
5//! The guard compares a per-request embedding vector against a pre-loaded
6//! pattern database of known-threat embeddings using cosine similarity.
7//! Top-K scoring + thresholded verdict with a configurable ambiguity band:
8//!
9//! - `top_score >= threshold + ambiguity_band` → [`Verdict::Deny`];
10//! - `top_score <= threshold - ambiguity_band` → [`Verdict::Allow`];
11//! - scores inside the band fall back to the configured
12//!   [`AmbiguousPolicy`] (default: [`AmbiguousPolicy::Allow`]).
13//!
14//! Embedding extraction from tool-call arguments:
15//!
16//! 1. A top-level `embedding` / `vector` array of numbers is preferred.
17//! 2. An `embeddings` field of shape `[[f32; D], ...]` is averaged.
18//! 3. Otherwise the guard returns [`Verdict::Allow`] (no embedding → no
19//!    signal; the guard does not try to hash text into a pseudo-embedding
20//!    because downstream consumers rely on explicit embeddings from the
21//!    upstream EmbeddingAnomaly model).
22//!
23//! Fail-closed semantics:
24//!
25//! - malformed pattern JSON at construction time → [`EmbeddingAnomalyError`];
26//! - malformed explicit request embedding fields → [`Verdict::Deny`];
27//! - non-finite values in a request embedding → [`Verdict::Deny`];
28//! - embedding-dimension mismatch with the pattern DB → [`Verdict::Deny`];
29//! - cosine norm collapse (zero vector) → similarity score `0.0` (not
30//!   NaN).
31//!
32//! Hand-rolled f64-accumulated dot product avoids any dependency on
33//! `ndarray` / BLAS.
34
35use std::sync::Arc;
36
37use serde::{Deserialize, Serialize};
38use serde_json::Value;
39use thiserror::Error;
40
41use chio_kernel::{Guard, GuardContext, GuardDecision, KernelError, Verdict};
42
43/// Default cosine similarity threshold.
44pub const DEFAULT_SIMILARITY_THRESHOLD: f64 = 0.85;
45/// Default ambiguity band half-width around the threshold.
46pub const DEFAULT_AMBIGUITY_BAND: f64 = 0.10;
47/// Default top-K pattern matches to score.
48pub const DEFAULT_TOP_K: usize = 5;
49
50/// Policy for scores landing inside the ambiguity band.
51#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
52#[serde(rename_all = "snake_case")]
53pub enum AmbiguousPolicy {
54    /// Treat ambiguous scores as benign (default).
55    #[default]
56    Allow,
57    /// Treat ambiguous scores as threats.
58    Deny,
59}
60
61/// Errors from [`EmbeddingAnomalyGuard`] construction.
62#[derive(Debug, Error)]
63pub enum EmbeddingAnomalyError {
64    /// Pattern JSON failed to parse.
65    #[error("pattern database parse error: {0}")]
66    Parse(String),
67    /// Pattern database is empty or has inconsistent dimensionality.
68    #[error("pattern database is invalid: {0}")]
69    Invalid(String),
70    /// Configuration value is out of range.
71    #[error("invalid configuration: {0}")]
72    Config(String),
73    /// I/O error reading the pattern database from disk.
74    #[error("failed to read pattern database: {0}")]
75    Io(String),
76}
77
78/// Configuration for [`EmbeddingAnomalyGuard`].
79#[derive(Clone, Debug, Deserialize, Serialize)]
80#[serde(deny_unknown_fields)]
81pub struct EmbeddingAnomalyConfig {
82    /// Cosine similarity threshold.  Scores ≥ `threshold + ambiguity_band`
83    /// are denied; scores ≤ `threshold - ambiguity_band` are allowed.
84    #[serde(default = "default_threshold")]
85    pub similarity_threshold: f64,
86    /// Half-width of the ambiguity band around the threshold.
87    #[serde(default = "default_band")]
88    pub ambiguity_band: f64,
89    /// Number of top matches retained per query.
90    #[serde(default = "default_top_k")]
91    pub top_k: usize,
92    /// Policy for ambiguous scores (inside the band).
93    #[serde(default)]
94    pub ambiguous_policy: AmbiguousPolicy,
95}
96
97fn default_threshold() -> f64 {
98    DEFAULT_SIMILARITY_THRESHOLD
99}
100fn default_band() -> f64 {
101    DEFAULT_AMBIGUITY_BAND
102}
103fn default_top_k() -> usize {
104    DEFAULT_TOP_K
105}
106
107impl Default for EmbeddingAnomalyConfig {
108    fn default() -> Self {
109        Self {
110            similarity_threshold: DEFAULT_SIMILARITY_THRESHOLD,
111            ambiguity_band: DEFAULT_AMBIGUITY_BAND,
112            top_k: DEFAULT_TOP_K,
113            ambiguous_policy: AmbiguousPolicy::Allow,
114        }
115    }
116}
117
118/// A single entry in the pattern database.
119#[derive(Clone, Debug, Deserialize, Serialize)]
120pub struct PatternEntry {
121    /// Stable identifier.
122    pub id: String,
123    /// Threat category (e.g., `prompt_injection`, `data_exfiltration`).
124    pub category: String,
125    /// EmbeddingAnomaly stage (perception / cognition / action / feedback).
126    pub stage: String,
127    /// Human-readable label.
128    pub label: String,
129    /// Pre-computed embedding vector.
130    pub embedding: Vec<f32>,
131}
132
133/// Immutable pattern database loaded at construction time.
134#[derive(Clone, Debug)]
135pub struct EmbeddingAnomalyPatternDb {
136    entries: Arc<Vec<PatternEntry>>,
137    dim: usize,
138}
139
140impl EmbeddingAnomalyPatternDb {
141    /// Parse a JSON array of [`PatternEntry`] values.  Validates:
142    ///
143    /// - array is non-empty;
144    /// - all embeddings share the same non-zero dimensionality;
145    /// - every embedding value is finite.
146    pub fn from_json(json: &str) -> Result<Self, EmbeddingAnomalyError> {
147        let entries: Vec<PatternEntry> =
148            serde_json::from_str(json).map_err(|e| EmbeddingAnomalyError::Parse(e.to_string()))?;
149        Self::from_entries(entries)
150    }
151
152    /// Build from an explicit entry vector (convenience for tests).
153    pub fn from_entries(entries: Vec<PatternEntry>) -> Result<Self, EmbeddingAnomalyError> {
154        if entries.is_empty() {
155            return Err(EmbeddingAnomalyError::Invalid(
156                "pattern database must contain at least one entry".into(),
157            ));
158        }
159        let dim = entries[0].embedding.len();
160        if dim == 0 {
161            return Err(EmbeddingAnomalyError::Invalid(
162                "pattern embeddings must be non-empty".into(),
163            ));
164        }
165        for (i, entry) in entries.iter().enumerate() {
166            if entry.embedding.len() != dim {
167                return Err(EmbeddingAnomalyError::Invalid(format!(
168                    "dimension mismatch at index {i}: expected {dim}, got {}",
169                    entry.embedding.len()
170                )));
171            }
172            if let Some(j) = entry.embedding.iter().position(|v| !v.is_finite()) {
173                return Err(EmbeddingAnomalyError::Invalid(format!(
174                    "entry {i} has non-finite embedding value at dimension {j}"
175                )));
176            }
177        }
178        Ok(Self {
179            entries: Arc::new(entries),
180            dim,
181        })
182    }
183
184    /// Expected embedding dimensionality.
185    pub fn dim(&self) -> usize {
186        self.dim
187    }
188
189    /// Number of patterns in the database.
190    pub fn len(&self) -> usize {
191        self.entries.len()
192    }
193
194    /// Whether the database is empty (always `false` after construction).
195    pub fn is_empty(&self) -> bool {
196        self.entries.is_empty()
197    }
198}
199
200/// EmbeddingAnomaly embedding detector guard.
201pub struct EmbeddingAnomalyGuard {
202    db: EmbeddingAnomalyPatternDb,
203    upper: f64,
204    lower: f64,
205    ambiguous_policy: AmbiguousPolicy,
206}
207
208impl EmbeddingAnomalyGuard {
209    /// Build a guard from a pattern database and configuration.
210    pub fn new(
211        db: EmbeddingAnomalyPatternDb,
212        config: EmbeddingAnomalyConfig,
213    ) -> Result<Self, EmbeddingAnomalyError> {
214        if !config.similarity_threshold.is_finite()
215            || !(0.0..=1.0).contains(&config.similarity_threshold)
216        {
217            return Err(EmbeddingAnomalyError::Config(format!(
218                "similarity_threshold must be finite in [0.0, 1.0], got {}",
219                config.similarity_threshold
220            )));
221        }
222        if !config.ambiguity_band.is_finite() || !(0.0..=1.0).contains(&config.ambiguity_band) {
223            return Err(EmbeddingAnomalyError::Config(format!(
224                "ambiguity_band must be finite in [0.0, 1.0], got {}",
225                config.ambiguity_band
226            )));
227        }
228        let upper = config.similarity_threshold + config.ambiguity_band;
229        let lower = config.similarity_threshold - config.ambiguity_band;
230        if !(0.0..=1.0).contains(&upper) || !(0.0..=1.0).contains(&lower) {
231            return Err(EmbeddingAnomalyError::Config(format!(
232                "threshold ± band must stay inside [0.0, 1.0]; got lower={lower:.3}, upper={upper:.3}"
233            )));
234        }
235        if config.top_k == 0 {
236            return Err(EmbeddingAnomalyError::Config("top_k must be ≥ 1".into()));
237        }
238        Ok(Self {
239            db,
240            upper,
241            lower,
242            ambiguous_policy: config.ambiguous_policy,
243        })
244    }
245
246    /// Convenience: build from a JSON pattern database string and defaults.
247    pub fn from_json(json: &str) -> Result<Self, EmbeddingAnomalyError> {
248        let db = EmbeddingAnomalyPatternDb::from_json(json)?;
249        Self::new(db, EmbeddingAnomalyConfig::default())
250    }
251
252    /// Read a pattern database from a JSON file on disk.
253    pub fn from_json_file(path: &str) -> Result<Self, EmbeddingAnomalyError> {
254        let data = std::fs::read_to_string(path)
255            .map_err(|e| EmbeddingAnomalyError::Io(format!("{path}: {e}")))?;
256        Self::from_json(&data)
257    }
258
259    /// Score an embedding against the pattern database. Returns the
260    /// cosine similarity of the best-matching pattern (0.0 if the
261    /// embedding is invalid).
262    pub fn score(&self, embedding: &[f32]) -> f64 {
263        if embedding.len() != self.db.dim {
264            return 0.0;
265        }
266        if embedding.iter().any(|v| !v.is_finite()) {
267            return 0.0;
268        }
269        let mut best = 0.0_f64;
270        for entry in self.db.entries.iter() {
271            let score = cosine_similarity(embedding, &entry.embedding);
272            if score > best {
273                best = score;
274            }
275        }
276        best
277    }
278
279    /// Decide a verdict for a given top-score.
280    fn verdict_for(&self, score: f64) -> Verdict {
281        if !score.is_finite() {
282            return Verdict::Deny;
283        }
284        if score >= self.upper {
285            Verdict::Deny
286        } else if score <= self.lower {
287            Verdict::Allow
288        } else {
289            match self.ambiguous_policy {
290                AmbiguousPolicy::Allow => Verdict::Allow,
291                AmbiguousPolicy::Deny => Verdict::Deny,
292            }
293        }
294    }
295
296    /// Number of patterns in the database.
297    pub fn pattern_count(&self) -> usize {
298        self.db.len()
299    }
300
301    /// Pattern database dimensionality.
302    pub fn dim(&self) -> usize {
303        self.db.dim()
304    }
305}
306
307impl Guard for EmbeddingAnomalyGuard {
308    fn name(&self) -> &str {
309        "embedding-anomaly"
310    }
311
312    fn evaluate(&self, ctx: &GuardContext) -> Result<GuardDecision, KernelError> {
313        let embedding = match extract_embedding_status(&ctx.request.arguments) {
314            EmbeddingExtraction::Present(e) => e,
315            EmbeddingExtraction::Missing => return Ok(GuardDecision::allow()),
316            EmbeddingExtraction::Invalid => return Ok(GuardDecision::deny(Vec::new())),
317        };
318        if embedding.len() != self.db.dim {
319            // Dimension mismatch = fail-closed.
320            return Ok(GuardDecision::deny(Vec::new()));
321        }
322        if embedding.iter().any(|v| !v.is_finite()) {
323            return Ok(GuardDecision::deny(Vec::new()));
324        }
325        let score = self.score(&embedding);
326        Ok(GuardDecision::from_verdict(self.verdict_for(score)))
327    }
328}
329
330/// Extract a query embedding vector from tool-call arguments.
331///
332/// Preferred shapes (first match wins):
333///
334/// 1. `embedding: [f32; D]`
335/// 2. `vector: [f32; D]`
336/// 3. `embeddings: [[f32; D], ...]` → mean-pooled to a single vector
337///
338/// Returns `None` if no recognised embedding field is present or the supplied
339/// embedding field is malformed.
340pub fn extract_embedding(arguments: &Value) -> Option<Vec<f32>> {
341    match extract_embedding_status(arguments) {
342        EmbeddingExtraction::Present(embedding) => Some(embedding),
343        EmbeddingExtraction::Missing | EmbeddingExtraction::Invalid => None,
344    }
345}
346
347enum EmbeddingExtraction {
348    Missing,
349    Invalid,
350    Present(Vec<f32>),
351}
352
353fn extract_embedding_status(arguments: &Value) -> EmbeddingExtraction {
354    if let Some(value) = arguments.get("embedding") {
355        return match array_as_f32_vec(value) {
356            Some(vec) => EmbeddingExtraction::Present(vec),
357            None => EmbeddingExtraction::Invalid,
358        };
359    }
360    if let Some(value) = arguments.get("vector") {
361        return match array_as_f32_vec(value) {
362            Some(vec) => EmbeddingExtraction::Present(vec),
363            None => EmbeddingExtraction::Invalid,
364        };
365    }
366    if let Some(value) = arguments.get("embeddings") {
367        return match value.as_array() {
368            Some(array) => mean_pool_embeddings(array),
369            None => EmbeddingExtraction::Invalid,
370        };
371    }
372    EmbeddingExtraction::Missing
373}
374
375fn mean_pool_embeddings(array: &[Value]) -> EmbeddingExtraction {
376    if array.is_empty() {
377        return EmbeddingExtraction::Invalid;
378    }
379    let mut vectors = Vec::with_capacity(array.len());
380    for value in array {
381        let Some(vector) = array_as_f32_vec(value) else {
382            return EmbeddingExtraction::Invalid;
383        };
384        vectors.push(vector);
385    }
386    let dim = vectors[0].len();
387    if dim == 0 || vectors.iter().any(|v| v.len() != dim) {
388        return EmbeddingExtraction::Invalid;
389    }
390    let mut sum = vec![0.0_f64; dim];
391    for vector in &vectors {
392        for (i, value) in vector.iter().enumerate() {
393            sum[i] += f64::from(*value);
394        }
395    }
396    let n = vectors.len() as f64;
397    EmbeddingExtraction::Present(sum.into_iter().map(|s| (s / n) as f32).collect())
398}
399
400fn array_as_f32_vec(value: &Value) -> Option<Vec<f32>> {
401    let array = value.as_array()?;
402    if array.is_empty() {
403        return None;
404    }
405    let mut out = Vec::with_capacity(array.len());
406    for v in array {
407        let n = v.as_f64()?;
408        if !n.is_finite() {
409            return None;
410        }
411        out.push(n as f32);
412    }
413    Some(out)
414}
415
416/// Cosine similarity of two `f32` vectors with `f64` accumulation.
417///
418/// Returns `0.0` when:
419/// - lengths differ;
420/// - either vector's L2 norm is not a normal `f64` (zero / subnormal);
421/// - the result is non-finite (NaN / ±inf).
422///
423/// This is intentionally hand-rolled (no `ndarray`) to keep the
424/// dependency surface minimal and WASM-friendly.
425pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f64 {
426    if a.len() != b.len() || a.is_empty() {
427        return 0.0;
428    }
429    let mut dot: f64 = 0.0;
430    let mut na: f64 = 0.0;
431    let mut nb: f64 = 0.0;
432    for (x, y) in a.iter().zip(b.iter()) {
433        let xd = f64::from(*x);
434        let yd = f64::from(*y);
435        if !xd.is_finite() || !yd.is_finite() {
436            return 0.0;
437        }
438        dot += xd * yd;
439        na += xd * xd;
440        nb += yd * yd;
441    }
442    let denom = na.sqrt() * nb.sqrt();
443    if !denom.is_normal() {
444        return 0.0;
445    }
446    let r = dot / denom;
447    if r.is_finite() {
448        r
449    } else {
450        0.0
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    fn sample_db() -> EmbeddingAnomalyPatternDb {
459        EmbeddingAnomalyPatternDb::from_json(
460            r#"[
461                {"id":"a","category":"x","stage":"perception","label":"l","embedding":[1.0,0.0,0.0]},
462                {"id":"b","category":"y","stage":"action","label":"l","embedding":[0.0,1.0,0.0]}
463            ]"#,
464        )
465        .expect("sample DB parses")
466    }
467
468    #[test]
469    fn cosine_basics() {
470        assert!((cosine_similarity(&[1.0, 0.0], &[1.0, 0.0]) - 1.0).abs() < 1e-9);
471        assert!(cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]).abs() < 1e-9);
472        assert_eq!(cosine_similarity(&[0.0, 0.0], &[1.0, 2.0]), 0.0);
473        assert_eq!(cosine_similarity(&[f32::NAN, 0.0], &[1.0, 0.0]), 0.0);
474        assert_eq!(cosine_similarity(&[f32::INFINITY, 0.0], &[1.0, 0.0]), 0.0);
475        assert_eq!(cosine_similarity(&[1.0], &[1.0, 0.0]), 0.0);
476    }
477
478    #[test]
479    fn pattern_db_rejects_empty() {
480        assert!(matches!(
481            EmbeddingAnomalyPatternDb::from_json("[]"),
482            Err(EmbeddingAnomalyError::Invalid(_))
483        ));
484    }
485
486    #[test]
487    fn pattern_db_rejects_dim_mismatch() {
488        let json = r#"[
489            {"id":"a","category":"x","stage":"s","label":"l","embedding":[1.0,0.0]},
490            {"id":"b","category":"y","stage":"s","label":"l","embedding":[1.0]}
491        ]"#;
492        assert!(matches!(
493            EmbeddingAnomalyPatternDb::from_json(json),
494            Err(EmbeddingAnomalyError::Invalid(_))
495        ));
496    }
497
498    #[test]
499    fn guard_denies_identical_vector() {
500        let guard = EmbeddingAnomalyGuard::new(sample_db(), EmbeddingAnomalyConfig::default())
501            .expect("build");
502        let score = guard.score(&[1.0, 0.0, 0.0]);
503        assert!((score - 1.0).abs() < 1e-9);
504        assert!(matches!(guard.verdict_for(score), Verdict::Deny));
505    }
506
507    #[test]
508    fn guard_allows_orthogonal_vector() {
509        let guard = EmbeddingAnomalyGuard::new(sample_db(), EmbeddingAnomalyConfig::default())
510            .expect("build");
511        let score = guard.score(&[0.0, 0.0, 1.0]);
512        assert!(score.abs() < 1e-9);
513        assert!(matches!(guard.verdict_for(score), Verdict::Allow));
514    }
515
516    #[test]
517    fn guard_dim_mismatch_denies() {
518        let guard = EmbeddingAnomalyGuard::new(sample_db(), EmbeddingAnomalyConfig::default())
519            .expect("build");
520        let score = guard.score(&[1.0, 0.0]);
521        assert_eq!(score, 0.0);
522        assert!(matches!(guard.verdict_for(score), Verdict::Allow));
523    }
524
525    #[test]
526    fn guard_nan_score_denies() {
527        let guard = EmbeddingAnomalyGuard::new(sample_db(), EmbeddingAnomalyConfig::default())
528            .expect("build");
529        assert!(matches!(guard.verdict_for(f64::NAN), Verdict::Deny));
530    }
531
532    #[test]
533    fn ambiguous_respects_policy() {
534        let db = sample_db();
535        let config = EmbeddingAnomalyConfig {
536            similarity_threshold: 0.5,
537            ambiguity_band: 0.1,
538            top_k: 5,
539            ambiguous_policy: AmbiguousPolicy::Deny,
540        };
541        let guard = EmbeddingAnomalyGuard::new(db, config).unwrap();
542        // score between 0.4 and 0.6 → Deny under this policy
543        assert!(matches!(guard.verdict_for(0.5), Verdict::Deny));
544    }
545
546    #[test]
547    fn extract_embedding_from_args() {
548        let args = serde_json::json!({"embedding": [0.1, 0.2, 0.3]});
549        let e = extract_embedding(&args).unwrap();
550        assert_eq!(e.len(), 3);
551    }
552
553    #[test]
554    fn extract_embedding_averages_list() {
555        let args = serde_json::json!({"embeddings": [[1.0, 0.0], [0.0, 1.0]]});
556        let e = extract_embedding(&args).unwrap();
557        assert_eq!(e.len(), 2);
558        assert!((e[0] - 0.5).abs() < 1e-6);
559        assert!((e[1] - 0.5).abs() < 1e-6);
560    }
561
562    #[test]
563    fn extract_embedding_none_when_absent() {
564        assert!(extract_embedding(&serde_json::json!({"foo": "bar"})).is_none());
565    }
566
567    #[test]
568    fn reject_bad_config() {
569        let db = sample_db();
570        let bad = EmbeddingAnomalyConfig {
571            similarity_threshold: 1.5,
572            ..EmbeddingAnomalyConfig::default()
573        };
574        assert!(EmbeddingAnomalyGuard::new(db, bad).is_err());
575    }
576}