Skip to main content

mif_rh/
review.rs

1//! `review()`: rebuild `ontology-map.json` for one or more topics and
2//! aggregate coverage, matching rht's `ontology-review.sh` exactly.
3
4use std::collections::HashMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use serde::Serialize;
10
11use crate::catalog::Catalog;
12use crate::config::HarnessConfig;
13use crate::error::MifRhError;
14use crate::finding::Finding;
15use crate::ontology_pack::OntologyPack;
16use crate::resolve::{Basis, MapRecord, ResolveContext, resolve_finding};
17
18/// Options for one `review()` run.
19pub struct ReviewOptions<'a> {
20    /// Topics to review. `None` reviews every topic in `config`.
21    pub topics: Option<&'a [String]>,
22    /// Root `reports/` directory (`reports/<topic>/findings/`,
23    /// `reports/<topic>/ontology-map.json`).
24    pub reports_dir: &'a Path,
25    /// Every loaded ontology pack, keyed by id. Loading strategy (a flat
26    /// directory scan via [`crate::ontology_pack::load_packs_from_dir`], or
27    /// rht's own catalog-`source`-driven layout via
28    /// [`crate::ontology_pack::load_packs_via_catalog`]) is the caller's
29    /// choice, not `review()`'s — it only classifies against whatever
30    /// corpus it is given.
31    pub ontology_packs: &'a HashMap<String, OntologyPack>,
32    /// The enabled-ontologies catalog.
33    pub catalog: &'a Catalog,
34    /// The harness's topic-to-ontology bindings.
35    pub config: &'a HarnessConfig,
36    /// Optional path to rht's own `scripts/check-relationship-targets.sh`,
37    /// run exactly once across the whole review (not per topic). `None`
38    /// skips the check entirely — used when reviewing a corpus that has no
39    /// rht scripts available (e.g. isolated parity tests).
40    pub check_relationship_targets_script: Option<&'a Path>,
41}
42
43/// Per-topic coverage counts, matching `ontology-review.sh`'s own bucket
44/// definitions exactly.
45#[derive(Debug, Clone)]
46pub struct TopicSummary {
47    /// The topic's id.
48    pub topic: String,
49    /// The topic's directly bound ontology ids, for display.
50    pub bound: Vec<String>,
51    /// Total findings seen (including unprocessable ones).
52    pub total: usize,
53    /// `basis in {declared, resolved} && valid`.
54    pub stamped: usize,
55    /// `basis == discovery && valid`.
56    pub discovery: usize,
57    /// `basis == untyped` (valid is always true for untyped).
58    pub untyped: usize,
59    /// `!valid`, plus any finding that could not even be processed (a
60    /// reconciliation "gap" — never silently dropped).
61    pub bad: usize,
62}
63
64/// The result of one `review()` run.
65#[derive(Debug, Clone)]
66pub struct ReviewReport {
67    /// Per-topic summaries, in review order.
68    pub topics: Vec<TopicSummary>,
69    /// Whether any topic had `bad > 0`, or the relationship-target check
70    /// found orphans.
71    pub any_bad: bool,
72}
73
74impl ReviewReport {
75    /// Total findings across every reviewed topic.
76    #[must_use]
77    pub fn total_findings(&self) -> usize {
78        self.topics.iter().map(|t| t.total).sum()
79    }
80
81    /// Total stamped findings across every reviewed topic.
82    #[must_use]
83    pub fn total_stamped(&self) -> usize {
84        self.topics.iter().map(|t| t.stamped).sum()
85    }
86
87    /// Total discovery-only findings across every reviewed topic.
88    #[must_use]
89    pub fn total_discovery(&self) -> usize {
90        self.topics.iter().map(|t| t.discovery).sum()
91    }
92
93    /// Total untyped findings across every reviewed topic.
94    #[must_use]
95    pub fn total_untyped(&self) -> usize {
96        self.topics.iter().map(|t| t.untyped).sum()
97    }
98
99    /// Total invalid/unresolved findings across every reviewed topic.
100    #[must_use]
101    pub fn total_bad(&self) -> usize {
102        self.topics.iter().map(|t| t.bad).sum()
103    }
104
105    /// The exact summary line format `ontology-review.sh` prints, e.g.
106    /// `"2 topic(s); 4 findings — 1 stamped, 1 discovery-only, 1 untyped, 1 invalid/unresolved"`.
107    #[must_use]
108    pub fn summary_line(&self) -> String {
109        format!(
110            "{} topic(s); {} findings — {} stamped, {} discovery-only, {} untyped, {} invalid/unresolved",
111            self.topics.len(),
112            self.total_findings(),
113            self.total_stamped(),
114            self.total_discovery(),
115            self.total_untyped(),
116            self.total_bad(),
117        )
118    }
119
120    /// Whether `--strict` should fail this review: `any_bad` alone (never
121    /// discovery-only/untyped findings by themselves).
122    #[must_use]
123    pub const fn strict_should_fail(&self) -> bool {
124        self.any_bad
125    }
126}
127
128/// One entry in a `--followup` backlog: a finding that is not durably
129/// stamped, or could not be processed at all (`basis: "gap"`).
130#[derive(Debug, Clone, Serialize)]
131pub struct FollowupEntry {
132    /// The finding's id (best-effort, from the file stem, for a `"gap"`
133    /// entry whose file could not even be parsed).
134    pub finding_id: String,
135    /// The finding's file path, repo-relative if derivable.
136    pub file: Option<String>,
137    /// The record's `Basis` label (`"discovery"`, `"untyped"`,
138    /// `"unresolved"`, `"ambiguous"`, or any other `Basis` variant when
139    /// `!valid`), or the literal `"gap"` for a finding that could not be
140    /// processed at all.
141    pub basis: String,
142    /// The finding's entity type, if identified.
143    pub entity_type: Option<String>,
144    /// The resolved ontology, if any.
145    pub resolved_ontology: Option<String>,
146    /// Whether the finding validated. `true` for discovery/untyped entries
147    /// (included here because they lack a durable on-disk stamp, not
148    /// because they failed validation); `false` for any invalid/unresolved
149    /// entry and for `"gap"` entries, whose file could not even be
150    /// processed.
151    pub valid: bool,
152}
153
154/// A `--followup` backlog: every finding across the reviewed topics that
155/// still needs human/agent attention.
156#[derive(Debug, Clone, Serialize)]
157pub struct FollowupBacklog {
158    /// Entries, keyed by topic id.
159    pub topics: HashMap<String, Vec<FollowupEntry>>,
160    /// Total entries across every topic.
161    pub total_needs_followup: usize,
162}
163
164fn topic_findings_dir(reports_dir: &Path, topic: &str) -> PathBuf {
165    reports_dir.join(topic).join("findings")
166}
167
168pub(crate) fn list_finding_files(dir: &Path) -> Result<Vec<PathBuf>, MifRhError> {
169    let entries = fs::read_dir(dir).map_err(|source| MifRhError::Io {
170        path: dir.display().to_string(),
171        source,
172    })?;
173    let mut files: Vec<PathBuf> = entries
174        .filter_map(Result::ok)
175        .map(|entry| entry.path())
176        .filter(|path| {
177            let is_json = path.extension().and_then(|ext| ext.to_str()) == Some("json");
178            let is_dotfile = path
179                .file_name()
180                .and_then(|name| name.to_str())
181                .is_some_and(|name| name.starts_with('.'));
182            is_json && !is_dotfile
183        })
184        .collect();
185    files.sort();
186    Ok(files)
187}
188
189/// Atomically (over)writes `path` with `records`, sorted by `finding_id` —
190/// `ontology-map.json` is always rebuilt from scratch, never incrementally
191/// patched, matching `ontology-review.sh` exactly.
192fn write_map(path: &Path, records: &[MapRecord]) -> Result<(), MifRhError> {
193    let mut sorted = records.to_vec();
194    sorted.sort_by(|a, b| a.finding_id.cmp(&b.finding_id));
195    crate::write_json_atomic(path, &sorted)
196}
197
198fn review_one_topic(
199    topic: &str,
200    opts: &ReviewOptions<'_>,
201) -> Result<Option<(TopicSummary, Vec<FollowupEntry>)>, MifRhError> {
202    let findings_dir = topic_findings_dir(opts.reports_dir, topic);
203    if !findings_dir.is_dir() {
204        // Not even counted — matches `ontology-review.sh` skipping a topic
205        // whose findings directory doesn't exist.
206        return Ok(None);
207    }
208
209    let files = list_finding_files(&findings_dir)?;
210    let ctx = ResolveContext {
211        topic,
212        catalog: opts.catalog,
213        config: opts.config,
214        ontology_packs: opts.ontology_packs,
215    };
216
217    // (file, Some(record)) on success, (file, None) for a "gap" — a file
218    // that could not be processed at all.
219    let mut items: Vec<(&PathBuf, Option<MapRecord>)> = Vec::with_capacity(files.len());
220    for file in &files {
221        let record = Finding::load(file).and_then(|finding| resolve_finding(&finding, &ctx));
222        items.push((file, record.ok()));
223    }
224
225    let records: Vec<&MapRecord> = items.iter().filter_map(|(_, r)| r.as_ref()).collect();
226    let gap = items.iter().filter(|(_, r)| r.is_none()).count();
227
228    let stamped = records
229        .iter()
230        .filter(|r| matches!(r.basis, Basis::Declared | Basis::Resolved) && r.valid)
231        .count();
232    let discovery = records
233        .iter()
234        .filter(|r| r.basis == Basis::Discovery && r.valid)
235        .count();
236    let untyped = records.iter().filter(|r| r.basis == Basis::Untyped).count();
237    let bad = records.iter().filter(|r| !r.valid).count() + gap;
238
239    write_map(
240        &opts.reports_dir.join(topic).join("ontology-map.json"),
241        &records.iter().map(|r| (*r).clone()).collect::<Vec<_>>(),
242    )?;
243
244    let mut followup: Vec<FollowupEntry> = Vec::new();
245    for (file, record) in &items {
246        match record {
247            Some(r) if matches!(r.basis, Basis::Discovery | Basis::Untyped) || !r.valid => {
248                followup.push(FollowupEntry {
249                    finding_id: r.finding_id.clone(),
250                    file: Some(file.display().to_string()),
251                    basis: r.basis.label().to_string(),
252                    entity_type: r.entity_type.clone(),
253                    resolved_ontology: r.resolved_ontology.clone(),
254                    valid: r.valid,
255                });
256            },
257            None => {
258                let finding_id = file
259                    .file_stem()
260                    .and_then(|s| s.to_str())
261                    .unwrap_or_default()
262                    .to_string();
263                followup.push(FollowupEntry {
264                    finding_id,
265                    file: Some(file.display().to_string()),
266                    basis: "gap".to_string(),
267                    entity_type: None,
268                    resolved_ontology: None,
269                    valid: false,
270                });
271            },
272            Some(_) => {},
273        }
274    }
275    followup.sort_by(|a, b| a.finding_id.cmp(&b.finding_id));
276
277    let bound = opts
278        .config
279        .topic_bindings(topic)
280        .into_iter()
281        .map(|b| b.id)
282        .collect();
283
284    Ok(Some((
285        TopicSummary {
286            topic: topic.to_string(),
287            bound,
288            total: files.len(),
289            stamped,
290            discovery,
291            untyped,
292            bad,
293        },
294        followup,
295    )))
296}
297
298/// Whether `check-relationship-targets.sh` (run once, corpus-wide) found
299/// orphaned relationship targets.
300///
301/// Runs it as an external process rather than reimplementing it —
302/// deliberately out of `mif-rh`'s scope (see this crate's top-level
303/// design notes): it runs once per review, not once per finding, so it
304/// never touches the per-finding subprocess cost this engine exists to
305/// eliminate.
306///
307/// **Unix-only.** `script` is spawned directly (`Command::new(script)`),
308/// relying on the OS to honor its `#!/usr/bin/env bash` shebang — Windows
309/// has no shebang support, so this fails there whenever a real `.sh`
310/// script is configured. Acceptable because this hook is opt-in
311/// (`--relationship-script`) and fails loud (a clean [`MifRhError::Io`]),
312/// never silently: a Windows caller who never sets the flag is unaffected.
313///
314/// # Errors
315///
316/// Returns [`MifRhError::Io`] if the script cannot be executed at all
317/// (including "no such interpreter" on a platform without shebang
318/// support).
319fn relationship_targets_clean(script: &Path, reports_dir: &Path) -> Result<bool, MifRhError> {
320    let status = Command::new(script)
321        .arg("--reports-dir")
322        .arg(reports_dir)
323        .status()
324        .map_err(|source| MifRhError::Io {
325            path: script.display().to_string(),
326            source,
327        })?;
328    Ok(status.success())
329}
330
331/// Reviews every requested topic, rebuilding each one's `ontology-map.json`
332/// from scratch and aggregating coverage.
333///
334/// # Errors
335///
336/// Returns [`MifRhError`] if a topic's findings directory cannot be read,
337/// or if the relationship-targets check script (when configured) cannot be
338/// executed.
339pub fn review(opts: &ReviewOptions<'_>) -> Result<(ReviewReport, FollowupBacklog), MifRhError> {
340    let topic_ids: Vec<String> = opts.topics.map_or_else(
341        || opts.config.topics.iter().map(|t| t.id.clone()).collect(),
342        <[String]>::to_vec,
343    );
344
345    let mut summaries = Vec::new();
346    let mut followup_topics: HashMap<String, Vec<FollowupEntry>> = HashMap::new();
347    let mut any_bad = false;
348
349    for topic in &topic_ids {
350        if let Some((summary, followup)) = review_one_topic(topic, opts)? {
351            if summary.bad > 0 {
352                any_bad = true;
353            }
354            if !followup.is_empty() {
355                followup_topics.insert(topic.clone(), followup);
356            }
357            summaries.push(summary);
358        }
359    }
360
361    if let Some(script) = opts.check_relationship_targets_script
362        && !relationship_targets_clean(script, opts.reports_dir)?
363    {
364        any_bad = true;
365    }
366
367    let total_needs_followup = followup_topics.values().map(Vec::len).sum();
368
369    Ok((
370        ReviewReport {
371            topics: summaries,
372            any_bad,
373        },
374        FollowupBacklog {
375            topics: followup_topics,
376            total_needs_followup,
377        },
378    ))
379}
380
381/// Writes a `--followup` backlog to `path`, atomically.
382///
383/// # Errors
384///
385/// Returns [`MifRhError::Io`] if `path` cannot be written.
386pub fn write_followup(path: &Path, backlog: &FollowupBacklog) -> Result<(), MifRhError> {
387    crate::write_json_atomic(path, backlog)
388}
389
390#[cfg(test)]
391mod tests {
392    use std::fs;
393
394    use super::{ReviewOptions, review};
395    use crate::catalog::{Catalog, CatalogEntry};
396    use crate::config::{HarnessConfig, TopicConfig};
397    use crate::ontology_pack;
398
399    fn write_finding(dir: &std::path::Path, name: &str, contents: &str) {
400        fs::create_dir_all(dir).unwrap();
401        fs::write(dir.join(name), contents).unwrap();
402    }
403
404    #[test]
405    fn review_rebuilds_the_map_and_summarizes_coverage() {
406        let root = tempfile::tempdir().unwrap();
407        let reports_dir = root.path().join("reports");
408        let ontologies_dir = root.path().join("ontologies");
409        fs::create_dir_all(&ontologies_dir).unwrap();
410        fs::write(
411            ontologies_dir.join("edu-fixture.yaml"),
412            "
413ontology:
414  id: edu-fixture
415  version: \"0.1.0\"
416entity_types:
417  - name: title
418    schema:
419      required: [name]
420      properties: {name: {type: string}}
421discovery:
422  enabled: true
423  patterns:
424    - content_pattern: \"ISBN\"
425      suggest_entity: title
426",
427        )
428        .unwrap();
429
430        let findings_dir = reports_dir.join("edu").join("findings");
431        write_finding(
432            &findings_dir,
433            "good.json",
434            r#"{"@id":"f-good","entity":{"name":"Algebra I","entity_type":"title"}}"#,
435        );
436        write_finding(
437            &findings_dir,
438            "disc.json",
439            r#"{"@id":"f-disc","content":"has an ISBN"}"#,
440        );
441        write_finding(
442            &findings_dir,
443            "untyped.json",
444            r#"{"@id":"f-untyped","content":"nothing special"}"#,
445        );
446        write_finding(
447            &findings_dir,
448            "invalid.json",
449            r#"{"@id":"f-invalid","entity":{"entity_type":"title"}}"#,
450        );
451
452        let catalog = Catalog {
453            ontologies: vec![CatalogEntry {
454                id: "edu-fixture".to_string(),
455                version: "0.1.0".to_string(),
456                source: None,
457                core: false,
458            }],
459        };
460        let config = HarnessConfig {
461            topics: vec![TopicConfig {
462                id: "edu".to_string(),
463                ontologies: vec!["edu-fixture".to_string()],
464            }],
465        };
466
467        let ontology_packs = ontology_pack::load_packs_from_dir(&ontologies_dir).unwrap();
468        let opts = ReviewOptions {
469            topics: None,
470            reports_dir: &reports_dir,
471            ontology_packs: &ontology_packs,
472            catalog: &catalog,
473            config: &config,
474            check_relationship_targets_script: None,
475        };
476
477        let (report, backlog) = review(&opts).unwrap();
478        assert_eq!(report.topics.len(), 1);
479        assert_eq!(
480            report.summary_line(),
481            "1 topic(s); 4 findings — 1 stamped, 1 discovery-only, 1 untyped, 1 invalid/unresolved"
482        );
483        assert!(report.strict_should_fail());
484        assert_eq!(backlog.total_needs_followup, 3);
485
486        let map_path = reports_dir.join("edu").join("ontology-map.json");
487        assert!(map_path.exists());
488        let map: Vec<crate::resolve::MapRecord> =
489            serde_json::from_str(&fs::read_to_string(map_path).unwrap()).unwrap();
490        assert_eq!(map.len(), 4);
491    }
492
493    #[test]
494    fn a_topic_with_no_findings_directory_is_skipped_entirely() {
495        let root = tempfile::tempdir().unwrap();
496        let reports_dir = root.path().join("reports");
497
498        let catalog = Catalog { ontologies: vec![] };
499        let config = HarnessConfig {
500            topics: vec![TopicConfig {
501                id: "ghost".to_string(),
502                ontologies: vec![],
503            }],
504        };
505        let ontology_packs = std::collections::HashMap::new();
506        let opts = ReviewOptions {
507            topics: None,
508            reports_dir: &reports_dir,
509            ontology_packs: &ontology_packs,
510            catalog: &catalog,
511            config: &config,
512            check_relationship_targets_script: None,
513        };
514
515        let (report, _backlog) = review(&opts).unwrap();
516        assert_eq!(report.topics.len(), 0);
517        assert_eq!(
518            report.summary_line(),
519            "0 topic(s); 0 findings — 0 stamped, 0 discovery-only, 0 untyped, 0 invalid/unresolved"
520        );
521    }
522}