Skip to main content

mif_rh/
queue.rs

1//! Tier-2 scored-suggestion queue and tier-3 expansion candidates.
2//!
3//! Routing for MIF ADR-020's lower two tiers, per this workspace's
4//! "2b-routing, 2a-pipeline" decision: the queue and miss store are
5//! purpose-built for *scored candidate lists* (rht's `--followup` backlog
6//! carries one unscored entity type per entry and is atomically rebuilt
7//! every review — structurally wrong for either job), while the surfaces
8//! that consume them are rht's existing ones (`/ontology-review --enrich`
9//! reads the queue; `author-ontology.sh --from-clusters` mines the
10//! expansion candidates).
11//!
12//! Everything here is written by `mif-rh-cli` paths only — `mif-rh-mcp`
13//! stays read-only — and nothing here ever writes a finding's
14//! `entity_type`: confirming or rejecting a queue entry is the human/agent
15//! `--enrich` step's job (PDD-1).
16
17use std::collections::HashSet;
18use std::path::Path;
19
20use mif_ontology::ExpansionConfig;
21use serde::{Deserialize, Serialize};
22
23use crate::error::MifRhError;
24use crate::index::Miss;
25use crate::suggest::TypeSuggestion;
26
27/// The status every fresh queue entry starts in. Any other status is a
28/// human/agent verdict and is never overwritten by a re-suggestion.
29pub const STATUS_PENDING: &str = "pending";
30
31/// One queued scored suggestion: a finding that is not durably stamped,
32/// with its ranked, tier-annotated candidate list.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SuggestionEntry {
35    /// The finding's id.
36    pub finding_id: String,
37    /// The finding's file path exactly as the producing review listed it:
38    /// absolute when the review ran with an absolute `--reports-dir`,
39    /// otherwise relative to that review's working directory. Not
40    /// normalized to repo-relative.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub file: Option<String>,
43    /// Why the finding needed a suggestion: its followup basis
44    /// (`"discovery"`, `"untyped"`, `"gap"`, ...).
45    pub basis: String,
46    /// The run that produced (or last refreshed) this entry.
47    pub run_id: String,
48    /// Ranked, tier-annotated candidates.
49    pub candidates: Vec<TypeSuggestion>,
50    /// Review status: [`STATUS_PENDING`] until a human/agent confirms or
51    /// rejects via `/ontology-review --enrich`. Free-form beyond
52    /// `pending` — any non-pending value is preserved verbatim on upsert.
53    #[serde(default = "default_status")]
54    pub status: String,
55}
56
57fn default_status() -> String {
58    STATUS_PENDING.to_string()
59}
60
61/// One topic's suggestion queue (`reports/_meta/suggestions/<topic>.json`).
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct SuggestionQueue {
64    /// The topic this queue belongs to.
65    pub topic: String,
66    /// Queue entries, sorted by `finding_id`.
67    pub entries: Vec<SuggestionEntry>,
68}
69
70/// Upserts `fresh` entries into the topic queue at `path`.
71///
72/// Preserves human verdicts: an existing entry is replaced only while its
73/// status is still [`STATUS_PENDING`]; confirmed/rejected entries are kept
74/// verbatim. Existing entries not re-suggested this run are also kept
75/// (their findings may simply not have been part of this run's scope).
76///
77/// A missing queue file starts empty; a present-but-unparsable one is an
78/// explicit error, never silently reset — it may carry human verdicts.
79///
80/// Two contract notes for consumers:
81///
82/// - **Verdict writers must not race this upsert.** The atomic rename
83///   prevents torn files, not lost updates: a verdict written between this
84///   function's read and its rename is clobbered. `review --suggest` runs
85///   under the review lock; any surface writing verdicts (rht's
86///   `/ontology-review --enrich`, a human editor) must not run concurrently
87///   with a suggesting review.
88/// - **The queue only grows.** Entries not re-suggested are kept even when
89///   pending — a `--topic`-scoped run legitimately re-suggests only a
90///   subset, and this function cannot tell scope from staleness. Pending
91///   entries whose findings have since been stamped or deleted therefore
92///   accumulate until a consumer prunes them; treat `pending` as "not yet
93///   reviewed", not "still applicable".
94///
95/// # Errors
96///
97/// Returns [`MifRhError::Json`] if an existing queue file cannot be
98/// parsed, or [`MifRhError::Io`]/[`MifRhError::JsonSerialize`] if the
99/// updated queue cannot be written.
100pub fn upsert_suggestions(
101    path: &Path,
102    topic: &str,
103    fresh: Vec<SuggestionEntry>,
104) -> Result<SuggestionQueue, MifRhError> {
105    let mut queue = if path.exists() {
106        let contents = std::fs::read_to_string(path).map_err(|source| MifRhError::Io {
107            path: path.display().to_string(),
108            source,
109        })?;
110        let queue = serde_json::from_str::<SuggestionQueue>(&contents).map_err(|source| {
111            MifRhError::Json {
112                path: path.display().to_string(),
113                source,
114            }
115        })?;
116        // A queue file recorded for another topic (copied, renamed, or a
117        // wrong-path caller) must not silently absorb this topic's entries.
118        if queue.topic != topic {
119            return Err(MifRhError::QueueTopicMismatch {
120                path: path.display().to_string(),
121                expected: topic.to_string(),
122                found: queue.topic,
123            });
124        }
125        queue
126    } else {
127        SuggestionQueue {
128            topic: topic.to_string(),
129            entries: Vec::new(),
130        }
131    };
132
133    for entry in fresh {
134        match queue
135            .entries
136            .iter_mut()
137            .find(|existing| existing.finding_id == entry.finding_id)
138        {
139            Some(existing) if existing.status == STATUS_PENDING => *existing = entry,
140            Some(_) => {}, // a human verdict — never overwritten
141            None => queue.entries.push(entry),
142        }
143    }
144    queue
145        .entries
146        .sort_by(|a, b| a.finding_id.cmp(&b.finding_id));
147
148    if let Some(parent) = path.parent() {
149        std::fs::create_dir_all(parent).map_err(|source| MifRhError::Io {
150            path: parent.display().to_string(),
151            source,
152        })?;
153    }
154    crate::write_json_atomic(path, &queue)?;
155    Ok(queue)
156}
157
158/// One member of an expansion-candidate cluster.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct ClusterMember {
161    /// The finding whose classification missed.
162    pub finding_id: String,
163    /// The finding's topic.
164    pub topic: String,
165    /// The query text that missed.
166    pub content: String,
167    /// The run that recorded the miss.
168    pub run_id: String,
169}
170
171/// One ontology-expansion candidate: a recurring cluster of
172/// mutually-similar tier-3 misses.
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct ExpansionCandidate {
175    /// Distinct findings in the cluster.
176    pub size: usize,
177    /// Distinct runs the cluster's misses span.
178    pub runs: usize,
179    /// The clustered misses.
180    pub members: Vec<ClusterMember>,
181}
182
183/// Clusters recorded misses into ontology-expansion candidates.
184///
185/// Greedy mutual-similarity clustering
186/// ([`mif_ontology::cluster_by_mutual_similarity`]) over the stored
187/// vectors, surfacing only clusters with at least `cfg.min_cluster_size`
188/// **distinct findings** spanning at least `cfg.min_distinct_runs`
189/// distinct runs — recurrence across runs, never a single low-confidence
190/// miss (MIF ADR-020, tier 3).
191///
192/// Clustering ignores topic boundaries deliberately: a concept missing a
193/// type across several topics is exactly the cross-topic signal worth
194/// surfacing, and each member carries its `topic` so reviewers see the
195/// span. Callers should filter `misses` to one embedding model first
196/// ([`Miss::model`]) — vectors from different models do not share a space.
197#[must_use]
198pub fn expansion_candidates(misses: &[Miss], cfg: &ExpansionConfig) -> Vec<ExpansionCandidate> {
199    let vectors: Vec<Vec<f32>> = misses.iter().map(|m| m.vector.clone()).collect();
200    let clusters = mif_ontology::cluster_by_mutual_similarity(&vectors, cfg.cluster_similarity);
201
202    clusters
203        .into_iter()
204        .filter_map(|indices| {
205            let members: Vec<ClusterMember> = indices
206                .iter()
207                .map(|&i| ClusterMember {
208                    finding_id: misses[i].finding_id.clone(),
209                    topic: misses[i].topic.clone(),
210                    content: misses[i].content.clone(),
211                    run_id: misses[i].run_id.clone(),
212                })
213                .collect();
214            let size = members
215                .iter()
216                .map(|m| m.finding_id.as_str())
217                .collect::<HashSet<_>>()
218                .len();
219            let runs = members
220                .iter()
221                .map(|m| m.run_id.as_str())
222                .collect::<HashSet<_>>()
223                .len();
224            (size >= cfg.min_cluster_size && runs >= cfg.min_distinct_runs).then_some(
225                ExpansionCandidate {
226                    size,
227                    runs,
228                    members,
229                },
230            )
231        })
232        .collect()
233}
234
235#[cfg(test)]
236mod tests {
237    use mif_ontology::{CalibrationConfig, ConfidenceTier, ExpansionConfig};
238
239    use super::{STATUS_PENDING, SuggestionEntry, expansion_candidates, upsert_suggestions};
240    use crate::index::Miss;
241    use crate::suggest::TypeSuggestion;
242
243    fn entry(finding_id: &str, run_id: &str, status: &str) -> SuggestionEntry {
244        SuggestionEntry {
245            finding_id: finding_id.to_string(),
246            file: None,
247            basis: "untyped".to_string(),
248            run_id: run_id.to_string(),
249            candidates: vec![TypeSuggestion {
250                entity_type: "control".to_string(),
251                ontology_id: "sec".to_string(),
252                score: 0.7,
253                tier: ConfidenceTier::FlagForReview,
254                margin: None,
255                calibrated: false,
256                negative_demoted: false,
257            }],
258            status: status.to_string(),
259        }
260    }
261
262    fn miss(finding_id: &str, run_id: &str, vector: Vec<f32>) -> Miss {
263        Miss {
264            finding_id: finding_id.to_string(),
265            topic: "sec".to_string(),
266            content: format!("content of {finding_id}"),
267            vector,
268            run_id: run_id.to_string(),
269            model: "test-model".to_string(),
270        }
271    }
272
273    #[test]
274    fn upsert_creates_refreshes_pending_and_preserves_verdicts() {
275        let dir = tempfile::tempdir().unwrap();
276        let path = dir.path().join("suggestions/sec.json");
277
278        // First run: two pending entries.
279        upsert_suggestions(
280            &path,
281            "sec",
282            vec![
283                entry("f-1", "run-1", STATUS_PENDING),
284                entry("f-2", "run-1", STATUS_PENDING),
285            ],
286        )
287        .unwrap();
288
289        // A human confirms f-1.
290        let contents = std::fs::read_to_string(&path).unwrap();
291        let confirmed = contents.replacen("\"pending\"", "\"confirmed\"", 1);
292        std::fs::write(&path, confirmed).unwrap();
293
294        // Second run re-suggests both plus a new finding.
295        let queue = upsert_suggestions(
296            &path,
297            "sec",
298            vec![
299                entry("f-1", "run-2", STATUS_PENDING),
300                entry("f-2", "run-2", STATUS_PENDING),
301                entry("f-3", "run-2", STATUS_PENDING),
302            ],
303        )
304        .unwrap();
305
306        assert_eq!(queue.entries.len(), 3);
307        // f-1's human verdict survives (run_id still run-1).
308        let f1 = queue
309            .entries
310            .iter()
311            .find(|e| e.finding_id == "f-1")
312            .unwrap();
313        assert_eq!(f1.status, "confirmed");
314        assert_eq!(f1.run_id, "run-1");
315        // f-2 was pending and got refreshed.
316        let f2 = queue
317            .entries
318            .iter()
319            .find(|e| e.finding_id == "f-2")
320            .unwrap();
321        assert_eq!(f2.run_id, "run-2");
322        // Entries are sorted by finding_id.
323        let ids: Vec<&str> = queue
324            .entries
325            .iter()
326            .map(|e| e.finding_id.as_str())
327            .collect();
328        assert_eq!(ids, ["f-1", "f-2", "f-3"]);
329    }
330
331    #[test]
332    fn a_queue_recorded_for_another_topic_is_rejected_not_absorbed() {
333        let dir = tempfile::tempdir().unwrap();
334        let path = dir.path().join("sec.json");
335        upsert_suggestions(&path, "sec", vec![entry("f-1", "run-1", STATUS_PENDING)]).unwrap();
336
337        let error = upsert_suggestions(&path, "edu", vec![]).unwrap_err();
338        assert!(matches!(
339            error,
340            crate::MifRhError::QueueTopicMismatch { .. }
341        ));
342    }
343
344    #[test]
345    fn a_corrupt_queue_file_is_an_explicit_error_never_a_silent_reset() {
346        let dir = tempfile::tempdir().unwrap();
347        let path = dir.path().join("sec.json");
348        std::fs::write(&path, "{ not json").unwrap();
349
350        let error = upsert_suggestions(&path, "sec", vec![]).unwrap_err();
351        assert!(matches!(error, crate::MifRhError::Json { .. }));
352    }
353
354    #[test]
355    fn expansion_candidates_require_size_and_distinct_run_gates() {
356        let cfg = ExpansionConfig {
357            cluster_similarity: 0.95,
358            min_cluster_size: 2,
359            min_distinct_runs: 2,
360        };
361        // f-1 and f-2 are near-identical misses, but both from run-1: fails
362        // the distinct-runs gate.
363        let one_run = [
364            miss("f-1", "run-1", vec![1.0, 0.0]),
365            miss("f-2", "run-1", vec![1.0, 0.0]),
366        ];
367        assert!(expansion_candidates(&one_run, &cfg).is_empty());
368
369        // The same cluster spanning two runs surfaces.
370        let two_runs = [
371            miss("f-1", "run-1", vec![1.0, 0.0]),
372            miss("f-2", "run-2", vec![1.0, 0.0]),
373            miss("f-3", "run-2", vec![0.0, 1.0]), // dissimilar outlier
374        ];
375        let candidates = expansion_candidates(&two_runs, &cfg);
376        assert_eq!(candidates.len(), 1);
377        assert_eq!(candidates[0].size, 2);
378        assert_eq!(candidates[0].runs, 2);
379    }
380
381    #[test]
382    fn the_same_finding_recurring_across_runs_does_not_inflate_cluster_size() {
383        let cfg = ExpansionConfig {
384            cluster_similarity: 0.95,
385            min_cluster_size: 2,
386            min_distinct_runs: 2,
387        };
388        // One finding missing twice is recurrence of ONE concept-instance,
389        // not a two-finding cluster.
390        let same_finding = [
391            miss("f-1", "run-1", vec![1.0, 0.0]),
392            miss("f-1", "run-2", vec![1.0, 0.0]),
393        ];
394        assert!(expansion_candidates(&same_finding, &cfg).is_empty());
395    }
396
397    #[test]
398    fn calibration_expansion_defaults_flow_through() {
399        // The default knobs come from the calibration artifact's expansion
400        // block — spot-check the defaults documented in mif-ontology.
401        let cfg = CalibrationConfig::default().expansion;
402        assert!((cfg.cluster_similarity - 0.80).abs() < f32::EPSILON);
403        assert_eq!(cfg.min_cluster_size, 3);
404        assert_eq!(cfg.min_distinct_runs, 2);
405    }
406}