use serde::{Deserialize, Serialize};
use crate::shared::embed_prefix::EmbedConvention;
pub const CANARY_TEXT: &str = "mindfork embedding canary v1";
pub const CANARY_MATCH: f32 = 0.999;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EmbedFingerprint {
pub canary: Vec<f32>,
pub model_id: Option<String>,
#[serde(default)]
pub convention: Option<String>,
}
impl EmbedFingerprint {
pub fn new(canary: Vec<f32>, model_id: Option<String>, convention: &str) -> Self {
Self {
canary,
model_id,
convention: Some(convention.to_string()),
}
}
pub fn matches(&self, fresh: &EmbedFingerprint) -> bool {
let default = EmbedConvention::default().id();
let stored = self.convention.as_deref().unwrap_or(default);
let current = fresh.convention.as_deref().unwrap_or(default);
stored == current
&& self.canary.len() == fresh.canary.len()
&& cosine(&self.canary, &fresh.canary) >= CANARY_MATCH
}
pub fn display_id(&self) -> &str {
self.model_id.as_deref().unwrap_or("?")
}
}
fn cosine(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if na == 0.0 || nb == 0.0 {
return 0.0;
}
dot / (na * nb)
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(v: &[f32]) -> EmbedFingerprint {
EmbedFingerprint::new(
v.to_vec(),
Some("test-model".into()),
EmbedConvention::None.id(),
)
}
fn fresh(v: &[f32]) -> EmbedFingerprint {
fp(v)
}
#[test]
fn identical_vector_matches() {
let f = fp(&[1.0, 0.0, 0.0]);
assert!(f.matches(&fresh(&[1.0, 0.0, 0.0])));
}
#[test]
fn scaled_vector_still_matches() {
let f = fp(&[1.0, 2.0, 3.0]);
assert!(f.matches(&fresh(&[2.0, 4.0, 6.0])));
}
#[test]
fn tiny_numeric_noise_still_matches() {
let f = fp(&[1.0, 0.0, 0.0]);
assert!(f.matches(&fresh(&[0.9999, 0.001, 0.0])));
}
#[test]
fn different_model_does_not_match() {
let f = fp(&[1.0, 0.0, 0.0]);
assert!(!f.matches(&fresh(&[0.369, 0.929, 0.0])));
}
#[test]
fn different_dimension_never_matches() {
let f = fp(&[1.0, 0.0, 0.0]);
assert!(!f.matches(&fresh(&[1.0, 0.0])));
assert!(!f.matches(&fresh(&[])));
}
#[test]
fn display_id_falls_back_to_placeholder() {
assert_eq!(fp(&[1.0]).display_id(), "test-model");
assert_eq!(
EmbedFingerprint::new(vec![1.0], None, "none").display_id(),
"?"
);
}
#[test]
fn a_changed_convention_is_a_changed_space_even_with_an_identical_canary() {
let stored = EmbedFingerprint::new(vec![1.0, 0.0], Some("e5".into()), "none");
let current = EmbedFingerprint::new(vec![1.0, 0.0], Some("e5".into()), "e5-instruct");
assert!(!stored.matches(¤t));
assert!(current.matches(&EmbedFingerprint::new(
vec![1.0, 0.0],
Some("e5".into()),
"e5-instruct"
)));
}
#[test]
fn a_fingerprint_predating_conventions_reads_as_the_default() {
let legacy = EmbedFingerprint {
canary: vec![1.0, 0.0],
model_id: Some("bge-m3".into()),
convention: None,
};
let current = EmbedFingerprint::new(vec![1.0, 0.0], Some("bge-m3".into()), "none");
assert!(legacy.matches(¤t), "no spurious change on upgrade");
assert!(
!legacy.matches(&EmbedFingerprint::new(
vec![1.0, 0.0],
Some("bge-m3".into()),
"e5"
)),
"but turning a convention on is still a change"
);
}
#[test]
fn legacy_json_without_a_convention_deserializes() {
let json = r#"{"canary":[1.0,0.0],"model_id":"bge-m3"}"#;
let f: EmbedFingerprint = serde_json::from_str(json).unwrap();
assert_eq!(f.convention, None);
}
#[test]
fn canary_text_is_stable() {
assert_eq!(CANARY_TEXT, "mindfork embedding canary v1");
}
}