use mif_ontology::{CalibrationConfig, ConfidenceTier, assign_tier};
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,
}
#[derive(Debug)]
pub struct CandidateSet {
candidates: Vec<(String, String, Vec<f32>)>,
calibrated: bool,
}
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((
entity_type.name.clone(),
pack.id.clone(),
embedder.embed(&doc)?,
));
}
}
Ok(CandidateSet {
candidates,
calibrated: cal.governs(mif_embed::MODEL_ID),
})
}
#[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)> = set
.candidates
.iter()
.map(|(name, ontology_id, vector)| {
(
name.as_str(),
ontology_id.as_str(),
cosine_similarity(query_vector, vector),
)
})
.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);
let mut suggestions: Vec<TypeSuggestion> = scored
.into_iter()
.enumerate()
.map(|(rank, (entity_type, ontology_id, score))| TypeSuggestion {
entity_type: entity_type.to_string(),
ontology_id: ontology_id.to_string(),
score,
tier: assign_tier(rank, score, second_best, cal),
margin: (rank == 0)
.then(|| second_best.map(|second| score - second))
.flatten(),
calibrated: set.calibrated,
})
.collect();
suggestions.truncate(limit);
suggestions
}
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;
use super::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)
}
#[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);
}
#[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));
}
}