1use 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
43pub const DEFAULT_SIMILARITY_THRESHOLD: f64 = 0.85;
45pub const DEFAULT_AMBIGUITY_BAND: f64 = 0.10;
47pub const DEFAULT_TOP_K: usize = 5;
49
50#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
52#[serde(rename_all = "snake_case")]
53pub enum AmbiguousPolicy {
54 #[default]
56 Allow,
57 Deny,
59}
60
61#[derive(Debug, Error)]
63pub enum EmbeddingAnomalyError {
64 #[error("pattern database parse error: {0}")]
66 Parse(String),
67 #[error("pattern database is invalid: {0}")]
69 Invalid(String),
70 #[error("invalid configuration: {0}")]
72 Config(String),
73 #[error("failed to read pattern database: {0}")]
75 Io(String),
76}
77
78#[derive(Clone, Debug, Deserialize, Serialize)]
80#[serde(deny_unknown_fields)]
81pub struct EmbeddingAnomalyConfig {
82 #[serde(default = "default_threshold")]
85 pub similarity_threshold: f64,
86 #[serde(default = "default_band")]
88 pub ambiguity_band: f64,
89 #[serde(default = "default_top_k")]
91 pub top_k: usize,
92 #[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#[derive(Clone, Debug, Deserialize, Serialize)]
120pub struct PatternEntry {
121 pub id: String,
123 pub category: String,
125 pub stage: String,
127 pub label: String,
129 pub embedding: Vec<f32>,
131}
132
133#[derive(Clone, Debug)]
135pub struct EmbeddingAnomalyPatternDb {
136 entries: Arc<Vec<PatternEntry>>,
137 dim: usize,
138}
139
140impl EmbeddingAnomalyPatternDb {
141 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 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 pub fn dim(&self) -> usize {
186 self.dim
187 }
188
189 pub fn len(&self) -> usize {
191 self.entries.len()
192 }
193
194 pub fn is_empty(&self) -> bool {
196 self.entries.is_empty()
197 }
198}
199
200pub struct EmbeddingAnomalyGuard {
202 db: EmbeddingAnomalyPatternDb,
203 upper: f64,
204 lower: f64,
205 ambiguous_policy: AmbiguousPolicy,
206}
207
208impl EmbeddingAnomalyGuard {
209 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 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 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 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 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 pub fn pattern_count(&self) -> usize {
298 self.db.len()
299 }
300
301 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 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
330pub 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
416pub 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 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}