use mif_ontology::{CalibrationConfig, ConfidenceTier, assign_tier_with_negatives};
use serde::{Deserialize, Serialize};
use crate::error::MifRhError;
use crate::index::cosine_similarity;
use crate::resolve::{ResolveContext, build_allowed};
pub const SUGGESTION_DEPTH: usize = 5;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TypeSuggestion {
pub entity_type: String,
pub ontology_id: String,
pub score: f32,
pub tier: ConfidenceTier,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub margin: Option<f32>,
pub calibrated: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub negative_demoted: bool,
}
#[derive(Debug)]
pub struct CandidateSet {
candidates: Vec<Candidate>,
calibrated: bool,
}
#[derive(Debug)]
struct Candidate {
name: String,
ontology_id: String,
vector: Vec<f32>,
negative_vectors: Vec<Vec<f32>>,
}
pub fn build_candidates(
ctx: &ResolveContext<'_>,
embedder: &mif_embed::Embedder,
cal: &CalibrationConfig,
) -> Result<CandidateSet, MifRhError> {
let allowed = build_allowed(ctx)?;
let mut candidates = Vec::new();
for pack in &allowed {
for entity_type in &pack.entity_types {
let Some(doc) = entity_type.embedding_doc() else {
continue;
};
candidates.push(Candidate {
name: entity_type.name.clone(),
ontology_id: pack.id.clone(),
vector: embedder.embed(&doc)?,
negative_vectors: embed_negatives(entity_type, embedder)?,
});
}
}
Ok(CandidateSet {
candidates,
calibrated: cal.governs(mif_embed::MODEL_ID),
})
}
fn embed_negatives(
entity_type: &mif_ontology::EntityType,
embedder: &mif_embed::Embedder,
) -> Result<Vec<Vec<f32>>, MifRhError> {
let mut vectors = Vec::new();
for negative in &entity_type.negative_examples {
if negative.trim().is_empty() {
continue;
}
vectors.push(embedder.embed(negative)?);
}
Ok(vectors)
}
#[must_use]
pub fn suggest_from_candidates(
query_vector: &[f32],
set: &CandidateSet,
cal: &CalibrationConfig,
limit: usize,
) -> Vec<TypeSuggestion> {
let mut scored: Vec<(&str, &str, f32, &[Vec<f32>])> = set
.candidates
.iter()
.map(|candidate| {
(
candidate.name.as_str(),
candidate.ontology_id.as_str(),
cosine_similarity(query_vector, &candidate.vector),
candidate.negative_vectors.as_slice(),
)
})
.collect();
scored.sort_by(|a, b| {
b.2.total_cmp(&a.2)
.then_with(|| a.1.cmp(b.1))
.then_with(|| a.0.cmp(b.0))
});
let second_best = scored.get(1).map(|(_, _, score, _)| *score);
scored.truncate(limit);
scored
.into_iter()
.enumerate()
.map(
|(rank, (entity_type, ontology_id, score, negative_vectors))| {
let negative_similarity = negative_vectors
.iter()
.map(|negative| cosine_similarity(query_vector, negative))
.reduce(f32::max);
TypeSuggestion {
entity_type: entity_type.to_string(),
ontology_id: ontology_id.to_string(),
score,
tier: assign_tier_with_negatives(
rank,
score,
second_best,
negative_similarity,
cal,
),
margin: (rank == 0)
.then(|| second_best.map(|second| score - second))
.flatten(),
calibrated: set.calibrated,
negative_demoted: mif_ontology::negative_demotes(score, negative_similarity),
}
},
)
.collect()
}
pub fn suggest_type(
text: &str,
ctx: &ResolveContext<'_>,
embedder: &mif_embed::Embedder,
cal: &CalibrationConfig,
limit: usize,
) -> Result<Vec<TypeSuggestion>, MifRhError> {
let set = build_candidates(ctx, embedder, cal)?;
let query_vector = embedder.embed(text)?;
Ok(suggest_from_candidates(&query_vector, &set, cal, limit))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use mif_ontology::{CalibrationConfig, ConfidenceTier};
use super::{Candidate, CandidateSet, suggest_from_candidates, suggest_type};
use crate::catalog::{Catalog, CatalogEntry};
use crate::config::HarnessConfig;
use crate::ontology_pack::parse_pack;
use crate::resolve::ResolveContext;
const ENRICHED_PACK_YAML: &str = "
ontology:
id: sec-fixture
version: \"0.1.0\"
entity_types:
- name: control
description: A security safeguard applied to a system
aliases: [countermeasure]
exemplars:
- Enforce MFA for all administrative access
- name: incident
description: A security event that caused harm
- name: undescribed
";
fn fixture() -> (Catalog, HarnessConfig, HashMap<String, crate::OntologyPack>) {
let pack = parse_pack(ENRICHED_PACK_YAML, "sec-fixture.yaml").unwrap();
let catalog = Catalog {
ontologies: vec![CatalogEntry {
id: "sec-fixture".to_string(),
version: "0.1.0".to_string(),
source: None,
core: true,
}],
};
let config: HarnessConfig = serde_json::from_value(serde_json::json!({
"topics": [{ "id": "sec", "ontologies": [] }]
}))
.unwrap();
let mut packs = HashMap::new();
packs.insert(pack.id.clone(), pack);
(catalog, config, packs)
}
fn calibrated() -> CalibrationConfig {
CalibrationConfig {
calibrated: true,
..CalibrationConfig::default()
}
}
fn candidate(name: &str, vector: Vec<f32>, negative_vectors: Vec<Vec<f32>>) -> Candidate {
Candidate {
name: name.to_string(),
ontology_id: "pack".to_string(),
vector,
negative_vectors,
}
}
#[test]
fn negative_evidence_demotes_the_top_candidate_without_reordering() {
let query = [1.0, 0.0];
let set = CandidateSet {
candidates: vec![
candidate("a", vec![1.0, 0.0], vec![vec![0.0, 1.0], vec![1.0, 0.0]]),
candidate("b", vec![0.6, 0.8], vec![]),
],
calibrated: true,
};
let suggestions = suggest_from_candidates(&query, &set, &calibrated(), 10);
assert_eq!(suggestions[0].entity_type, "a");
assert!(suggestions[0].negative_demoted);
assert_eq!(suggestions[0].tier, ConfidenceTier::FlagForReview);
assert!(suggestions[0].margin.is_some());
assert!(!suggestions[1].negative_demoted);
}
#[test]
fn weak_negative_evidence_leaves_tier_one_reachable() {
let query = [1.0, 0.0];
let set = CandidateSet {
candidates: vec![
candidate("a", vec![1.0, 0.0], vec![vec![0.0, 1.0]]),
candidate("b", vec![0.6, 0.8], vec![]),
],
calibrated: true,
};
let suggestions = suggest_from_candidates(&query, &set, &calibrated(), 10);
assert!(!suggestions[0].negative_demoted);
assert_eq!(suggestions[0].tier, ConfidenceTier::AutoClassifyEligible);
}
#[test]
fn candidates_without_negatives_score_exactly_as_before_the_gate() {
let query = [1.0, 0.0];
let bare = CandidateSet {
candidates: vec![
candidate("a", vec![1.0, 0.0], vec![]),
candidate("b", vec![0.6, 0.8], vec![]),
],
calibrated: true,
};
let with_unrelated_negative = CandidateSet {
candidates: vec![
candidate("a", vec![1.0, 0.0], vec![]),
candidate("b", vec![0.6, 0.8], vec![vec![0.0, 1.0]]),
],
calibrated: true,
};
let before = suggest_from_candidates(&query, &bare, &calibrated(), 10);
let after = suggest_from_candidates(&query, &with_unrelated_negative, &calibrated(), 10);
assert_eq!(before, after);
assert_eq!(before[0].tier, ConfidenceTier::AutoClassifyEligible);
}
#[test]
fn the_default_calibration_model_names_the_embedder_actually_in_use() {
assert_eq!(
mif_ontology::confidence::DEFAULT_EMBEDDING_MODEL,
mif_embed::MODEL_ID
);
}
#[test]
fn suggests_tier_annotated_candidates_skipping_signal_less_types() {
let Ok(embedder) = mif_embed::Embedder::load() else {
eprintln!("skipping: embedding model unavailable in this environment");
return;
};
let (catalog, config, packs) = fixture();
let ctx = ResolveContext {
topic: "sec",
catalog: &catalog,
config: &config,
ontology_packs: &packs,
};
let cal = CalibrationConfig::default();
let suggestions = suggest_type(
"Require multi-factor authentication as a countermeasure on admin logins",
&ctx,
&embedder,
&cal,
10,
)
.unwrap();
assert_eq!(suggestions.len(), 2);
assert!(suggestions.iter().all(|s| s.entity_type != "undescribed"));
assert_eq!(suggestions[0].entity_type, "control");
assert!(suggestions[0].score >= suggestions[1].score);
assert!(suggestions[0].margin.is_some());
assert!(suggestions[1].margin.is_none());
assert!(!suggestions[0].calibrated);
}
const NEGATIVES_PACK_YAML: &str = "
ontology:
id: sec-fixture
version: \"0.1.0\"
entity_types:
- name: control
description: A security safeguard applied to a system
aliases: [countermeasure]
- name: incident
description: A security event that caused harm
negative_examples:
- Require multi-factor authentication on admin logins
";
#[test]
fn a_curated_negative_demotes_its_type_end_to_end() {
let Ok(embedder) = mif_embed::Embedder::load() else {
eprintln!("skipping: embedding model unavailable in this environment");
return;
};
let pack = parse_pack(NEGATIVES_PACK_YAML, "sec-fixture.yaml").unwrap();
let catalog = Catalog {
ontologies: vec![CatalogEntry {
id: "sec-fixture".to_string(),
version: "0.1.0".to_string(),
source: None,
core: true,
}],
};
let config: HarnessConfig = serde_json::from_value(serde_json::json!({
"topics": [{ "id": "sec", "ontologies": [] }]
}))
.unwrap();
let mut packs = HashMap::new();
packs.insert(pack.id.clone(), pack);
let ctx = ResolveContext {
topic: "sec",
catalog: &catalog,
config: &config,
ontology_packs: &packs,
};
let suggestions = suggest_type(
"Require multi-factor authentication on admin logins",
&ctx,
&embedder,
&CalibrationConfig::default(),
10,
)
.unwrap();
let incident = suggestions
.iter()
.find(|s| s.entity_type == "incident")
.unwrap();
assert!(incident.negative_demoted);
assert_ne!(incident.tier, ConfidenceTier::AutoClassifyEligible);
let control = suggestions
.iter()
.find(|s| s.entity_type == "control")
.unwrap();
assert!(!control.negative_demoted);
}
#[test]
fn calibration_for_a_different_model_reads_as_uncalibrated() {
let Ok(embedder) = mif_embed::Embedder::load() else {
eprintln!("skipping: embedding model unavailable in this environment");
return;
};
let (catalog, config, packs) = fixture();
let ctx = ResolveContext {
topic: "sec",
catalog: &catalog,
config: &config,
ontology_packs: &packs,
};
let cal = CalibrationConfig {
calibrated: true,
embedding_model: "some-other/model".to_string(),
..CalibrationConfig::default()
};
let suggestions = suggest_type("MFA on admin logins", &ctx, &embedder, &cal, 10).unwrap();
assert!(suggestions.iter().all(|s| !s.calibrated));
}
}