use std::collections::HashSet;
use std::path::Path;
use mif_ontology::ExpansionConfig;
use serde::{Deserialize, Serialize};
use crate::error::MifRhError;
use crate::index::Miss;
use crate::suggest::TypeSuggestion;
pub const STATUS_PENDING: &str = "pending";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuggestionEntry {
pub finding_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
pub basis: String,
pub run_id: String,
pub candidates: Vec<TypeSuggestion>,
#[serde(default = "default_status")]
pub status: String,
}
fn default_status() -> String {
STATUS_PENDING.to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuggestionQueue {
pub topic: String,
pub entries: Vec<SuggestionEntry>,
}
pub fn upsert_suggestions(
path: &Path,
topic: &str,
fresh: Vec<SuggestionEntry>,
) -> Result<SuggestionQueue, MifRhError> {
let mut queue = if path.exists() {
let contents = std::fs::read_to_string(path).map_err(|source| MifRhError::Io {
path: path.display().to_string(),
source,
})?;
let queue = serde_json::from_str::<SuggestionQueue>(&contents).map_err(|source| {
MifRhError::Json {
path: path.display().to_string(),
source,
}
})?;
if queue.topic != topic {
return Err(MifRhError::QueueTopicMismatch {
path: path.display().to_string(),
expected: topic.to_string(),
found: queue.topic,
});
}
queue
} else {
SuggestionQueue {
topic: topic.to_string(),
entries: Vec::new(),
}
};
for entry in fresh {
match queue
.entries
.iter_mut()
.find(|existing| existing.finding_id == entry.finding_id)
{
Some(existing) if existing.status == STATUS_PENDING => *existing = entry,
Some(_) => {}, None => queue.entries.push(entry),
}
}
queue
.entries
.sort_by(|a, b| a.finding_id.cmp(&b.finding_id));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| MifRhError::Io {
path: parent.display().to_string(),
source,
})?;
}
crate::write_json_atomic(path, &queue)?;
Ok(queue)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClusterMember {
pub finding_id: String,
pub topic: String,
pub content: String,
pub run_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExpansionCandidate {
pub size: usize,
pub runs: usize,
pub members: Vec<ClusterMember>,
}
#[must_use]
pub fn expansion_candidates(misses: &[Miss], cfg: &ExpansionConfig) -> Vec<ExpansionCandidate> {
let vectors: Vec<Vec<f32>> = misses.iter().map(|m| m.vector.clone()).collect();
let clusters = mif_ontology::cluster_by_mutual_similarity(&vectors, cfg.cluster_similarity);
clusters
.into_iter()
.filter_map(|indices| {
let members: Vec<ClusterMember> = indices
.iter()
.map(|&i| ClusterMember {
finding_id: misses[i].finding_id.clone(),
topic: misses[i].topic.clone(),
content: misses[i].content.clone(),
run_id: misses[i].run_id.clone(),
})
.collect();
let size = members
.iter()
.map(|m| m.finding_id.as_str())
.collect::<HashSet<_>>()
.len();
let runs = members
.iter()
.map(|m| m.run_id.as_str())
.collect::<HashSet<_>>()
.len();
(size >= cfg.min_cluster_size && runs >= cfg.min_distinct_runs).then_some(
ExpansionCandidate {
size,
runs,
members,
},
)
})
.collect()
}
#[cfg(test)]
mod tests {
use mif_ontology::{CalibrationConfig, ConfidenceTier, ExpansionConfig};
use super::{STATUS_PENDING, SuggestionEntry, expansion_candidates, upsert_suggestions};
use crate::index::Miss;
use crate::suggest::TypeSuggestion;
fn entry(finding_id: &str, run_id: &str, status: &str) -> SuggestionEntry {
SuggestionEntry {
finding_id: finding_id.to_string(),
file: None,
basis: "untyped".to_string(),
run_id: run_id.to_string(),
candidates: vec![TypeSuggestion {
entity_type: "control".to_string(),
ontology_id: "sec".to_string(),
score: 0.7,
tier: ConfidenceTier::FlagForReview,
margin: None,
calibrated: false,
negative_demoted: false,
}],
status: status.to_string(),
}
}
fn miss(finding_id: &str, run_id: &str, vector: Vec<f32>) -> Miss {
Miss {
finding_id: finding_id.to_string(),
topic: "sec".to_string(),
content: format!("content of {finding_id}"),
vector,
run_id: run_id.to_string(),
model: "test-model".to_string(),
}
}
#[test]
fn upsert_creates_refreshes_pending_and_preserves_verdicts() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("suggestions/sec.json");
upsert_suggestions(
&path,
"sec",
vec![
entry("f-1", "run-1", STATUS_PENDING),
entry("f-2", "run-1", STATUS_PENDING),
],
)
.unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
let confirmed = contents.replacen("\"pending\"", "\"confirmed\"", 1);
std::fs::write(&path, confirmed).unwrap();
let queue = upsert_suggestions(
&path,
"sec",
vec![
entry("f-1", "run-2", STATUS_PENDING),
entry("f-2", "run-2", STATUS_PENDING),
entry("f-3", "run-2", STATUS_PENDING),
],
)
.unwrap();
assert_eq!(queue.entries.len(), 3);
let f1 = queue
.entries
.iter()
.find(|e| e.finding_id == "f-1")
.unwrap();
assert_eq!(f1.status, "confirmed");
assert_eq!(f1.run_id, "run-1");
let f2 = queue
.entries
.iter()
.find(|e| e.finding_id == "f-2")
.unwrap();
assert_eq!(f2.run_id, "run-2");
let ids: Vec<&str> = queue
.entries
.iter()
.map(|e| e.finding_id.as_str())
.collect();
assert_eq!(ids, ["f-1", "f-2", "f-3"]);
}
#[test]
fn a_queue_recorded_for_another_topic_is_rejected_not_absorbed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sec.json");
upsert_suggestions(&path, "sec", vec![entry("f-1", "run-1", STATUS_PENDING)]).unwrap();
let error = upsert_suggestions(&path, "edu", vec![]).unwrap_err();
assert!(matches!(
error,
crate::MifRhError::QueueTopicMismatch { .. }
));
}
#[test]
fn a_corrupt_queue_file_is_an_explicit_error_never_a_silent_reset() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sec.json");
std::fs::write(&path, "{ not json").unwrap();
let error = upsert_suggestions(&path, "sec", vec![]).unwrap_err();
assert!(matches!(error, crate::MifRhError::Json { .. }));
}
#[test]
fn expansion_candidates_require_size_and_distinct_run_gates() {
let cfg = ExpansionConfig {
cluster_similarity: 0.95,
min_cluster_size: 2,
min_distinct_runs: 2,
};
let one_run = [
miss("f-1", "run-1", vec![1.0, 0.0]),
miss("f-2", "run-1", vec![1.0, 0.0]),
];
assert!(expansion_candidates(&one_run, &cfg).is_empty());
let two_runs = [
miss("f-1", "run-1", vec![1.0, 0.0]),
miss("f-2", "run-2", vec![1.0, 0.0]),
miss("f-3", "run-2", vec![0.0, 1.0]), ];
let candidates = expansion_candidates(&two_runs, &cfg);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].size, 2);
assert_eq!(candidates[0].runs, 2);
}
#[test]
fn the_same_finding_recurring_across_runs_does_not_inflate_cluster_size() {
let cfg = ExpansionConfig {
cluster_similarity: 0.95,
min_cluster_size: 2,
min_distinct_runs: 2,
};
let same_finding = [
miss("f-1", "run-1", vec![1.0, 0.0]),
miss("f-1", "run-2", vec![1.0, 0.0]),
];
assert!(expansion_candidates(&same_finding, &cfg).is_empty());
}
#[test]
fn calibration_expansion_defaults_flow_through() {
let cfg = CalibrationConfig::default().expansion;
assert!((cfg.cluster_similarity - 0.80).abs() < f32::EPSILON);
assert_eq!(cfg.min_cluster_size, 3);
assert_eq!(cfg.min_distinct_runs, 2);
}
}