mif-rh 0.3.0

Compiled ontology resolution/review engine for research-harness-template corpora
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! Tier-2 scored-suggestion queue and tier-3 expansion candidates.
//!
//! Routing for MIF ADR-020's lower two tiers, per this workspace's
//! "2b-routing, 2a-pipeline" decision: the queue and miss store are
//! purpose-built for *scored candidate lists* (rht's `--followup` backlog
//! carries one unscored entity type per entry and is atomically rebuilt
//! every review — structurally wrong for either job), while the surfaces
//! that consume them are rht's existing ones (`/ontology-review --enrich`
//! reads the queue; `author-ontology.sh --from-clusters` mines the
//! expansion candidates).
//!
//! Everything here is written by `mif-rh-cli` paths only — `mif-rh-mcp`
//! stays read-only — and nothing here ever writes a finding's
//! `entity_type`: confirming or rejecting a queue entry is the human/agent
//! `--enrich` step's job (PDD-1).

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;

/// The status every fresh queue entry starts in. Any other status is a
/// human/agent verdict and is never overwritten by a re-suggestion.
pub const STATUS_PENDING: &str = "pending";

/// One queued scored suggestion: a finding that is not durably stamped,
/// with its ranked, tier-annotated candidate list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuggestionEntry {
    /// The finding's id.
    pub finding_id: String,
    /// The finding's file path exactly as the producing review listed it:
    /// absolute when the review ran with an absolute `--reports-dir`,
    /// otherwise relative to that review's working directory. Not
    /// normalized to repo-relative.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub file: Option<String>,
    /// Why the finding needed a suggestion: its followup basis
    /// (`"discovery"`, `"untyped"`, `"gap"`, ...).
    pub basis: String,
    /// The run that produced (or last refreshed) this entry.
    pub run_id: String,
    /// Ranked, tier-annotated candidates.
    pub candidates: Vec<TypeSuggestion>,
    /// Review status: [`STATUS_PENDING`] until a human/agent confirms or
    /// rejects via `/ontology-review --enrich`. Free-form beyond
    /// `pending` — any non-pending value is preserved verbatim on upsert.
    #[serde(default = "default_status")]
    pub status: String,
}

fn default_status() -> String {
    STATUS_PENDING.to_string()
}

/// One topic's suggestion queue (`reports/_meta/suggestions/<topic>.json`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuggestionQueue {
    /// The topic this queue belongs to.
    pub topic: String,
    /// Queue entries, sorted by `finding_id`.
    pub entries: Vec<SuggestionEntry>,
}

/// Upserts `fresh` entries into the topic queue at `path`.
///
/// Preserves human verdicts: an existing entry is replaced only while its
/// status is still [`STATUS_PENDING`]; confirmed/rejected entries are kept
/// verbatim. Existing entries not re-suggested this run are also kept
/// (their findings may simply not have been part of this run's scope).
///
/// A missing queue file starts empty; a present-but-unparsable one is an
/// explicit error, never silently reset — it may carry human verdicts.
///
/// Two contract notes for consumers:
///
/// - **Verdict writers must not race this upsert.** The atomic rename
///   prevents torn files, not lost updates: a verdict written between this
///   function's read and its rename is clobbered. `review --suggest` runs
///   under the review lock; any surface writing verdicts (rht's
///   `/ontology-review --enrich`, a human editor) must not run concurrently
///   with a suggesting review.
/// - **The queue only grows.** Entries not re-suggested are kept even when
///   pending — a `--topic`-scoped run legitimately re-suggests only a
///   subset, and this function cannot tell scope from staleness. Pending
///   entries whose findings have since been stamped or deleted therefore
///   accumulate until a consumer prunes them; treat `pending` as "not yet
///   reviewed", not "still applicable".
///
/// # Errors
///
/// Returns [`MifRhError::Json`] if an existing queue file cannot be
/// parsed, or [`MifRhError::Io`]/[`MifRhError::JsonSerialize`] if the
/// updated queue cannot be written.
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,
            }
        })?;
        // A queue file recorded for another topic (copied, renamed, or a
        // wrong-path caller) must not silently absorb this topic's entries.
        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(_) => {}, // a human verdict — never overwritten
            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)
}

/// One member of an expansion-candidate cluster.
#[derive(Debug, Clone, Serialize)]
pub struct ClusterMember {
    /// The finding whose classification missed.
    pub finding_id: String,
    /// The finding's topic.
    pub topic: String,
    /// The query text that missed.
    pub content: String,
    /// The run that recorded the miss.
    pub run_id: String,
}

/// One ontology-expansion candidate: a recurring cluster of
/// mutually-similar tier-3 misses.
#[derive(Debug, Clone, Serialize)]
pub struct ExpansionCandidate {
    /// Distinct findings in the cluster.
    pub size: usize,
    /// Distinct runs the cluster's misses span.
    pub runs: usize,
    /// The clustered misses.
    pub members: Vec<ClusterMember>,
}

/// Clusters recorded misses into ontology-expansion candidates.
///
/// Greedy mutual-similarity clustering
/// ([`mif_ontology::cluster_by_mutual_similarity`]) over the stored
/// vectors, surfacing only clusters with at least `cfg.min_cluster_size`
/// **distinct findings** spanning at least `cfg.min_distinct_runs`
/// distinct runs — recurrence across runs, never a single low-confidence
/// miss (MIF ADR-020, tier 3).
///
/// Clustering ignores topic boundaries deliberately: a concept missing a
/// type across several topics is exactly the cross-topic signal worth
/// surfacing, and each member carries its `topic` so reviewers see the
/// span. Callers should filter `misses` to one embedding model first
/// ([`Miss::model`]) — vectors from different models do not share a space.
#[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,
            }],
            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");

        // First run: two pending entries.
        upsert_suggestions(
            &path,
            "sec",
            vec![
                entry("f-1", "run-1", STATUS_PENDING),
                entry("f-2", "run-1", STATUS_PENDING),
            ],
        )
        .unwrap();

        // A human confirms f-1.
        let contents = std::fs::read_to_string(&path).unwrap();
        let confirmed = contents.replacen("\"pending\"", "\"confirmed\"", 1);
        std::fs::write(&path, confirmed).unwrap();

        // Second run re-suggests both plus a new finding.
        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);
        // f-1's human verdict survives (run_id still run-1).
        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");
        // f-2 was pending and got refreshed.
        let f2 = queue
            .entries
            .iter()
            .find(|e| e.finding_id == "f-2")
            .unwrap();
        assert_eq!(f2.run_id, "run-2");
        // Entries are sorted by finding_id.
        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,
        };
        // f-1 and f-2 are near-identical misses, but both from run-1: fails
        // the distinct-runs gate.
        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());

        // The same cluster spanning two runs surfaces.
        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]), // dissimilar outlier
        ];
        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,
        };
        // One finding missing twice is recurrence of ONE concept-instance,
        // not a two-finding cluster.
        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() {
        // The default knobs come from the calibration artifact's expansion
        // block — spot-check the defaults documented in mif-ontology.
        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);
    }
}