Skip to main content

mif_rh/
lib.rs

1//! Compiled ontology resolution/review engine for
2//! [research-harness-template](https://github.com/modeled-information-format/research-harness-template)
3//! (rht) corpora, in the [MIF (Modeled Information Format)](https://mif-spec.dev)
4//! ecosystem.
5//!
6//! Reimplements the observable behavior of rht's `scripts/resolve-ontology.sh`
7//! ([`resolve::resolve_finding`]) and `scripts/ontology-review.sh`
8//! ([`review::review`]) — classifying findings against topic-bound domain
9//! ontologies, validating each finding's `entity` payload, and aggregating
10//! per-topic coverage — without any `yq`/`jq`/`ajv` subprocess dependency.
11//!
12//! # Determinism boundary
13//!
14//! [`resolve::resolve_finding`] and [`review::review`] are entirely
15//! deterministic and rule-based: exact string matching against an
16//! ontology's declared entity types, regex matching against discovery
17//! patterns, and JSON Schema validation. **No embeddings are used in this
18//! classification path** — it must stay deterministic both for byte-for-byte
19//! parity with rht's bash output and because rht's own fail-closed gate
20//! (ADR-0011) consumes `ontology-map.json`'s `basis`/`valid` fields
21//! directly to decide whether a finding can ship.
22//!
23//! Embeddings and cosine similarity are used only by the hypothesis layer:
24//! [`index::FindingIndex`] (full-text and embedding search over a corpus)
25//! and [`suggest::suggest_type`] (tier-annotated entity-type hypotheses per
26//! MIF ADR-020's confidence policy, via [`mif_ontology::confidence`]) —
27//! both consumed by the `mif-rh-mcp` server's
28//! `search`/`suggest_type`/`find_similar` tools and by `mif-rh-cli`'s
29//! `suggest-type` subcommand. That layer is read-only and
30//! never-authoritative: it never writes to `ontology-map.json`, and
31//! `resolve`/`review` never call into it.
32
33pub mod author;
34pub mod calibrate;
35pub mod catalog;
36pub mod config;
37mod error;
38pub mod finding;
39pub mod harness_assert_graph;
40pub mod harness_citation_integrity;
41pub mod harness_concordance;
42pub mod harness_corpus;
43pub mod harness_falsify;
44pub mod harness_graph;
45pub mod harness_import;
46pub mod harness_index;
47pub mod harness_markdown;
48pub mod harness_membership;
49pub mod harness_ontology_registry;
50pub mod harness_project;
51pub mod harness_reconcile;
52pub mod harness_relationship_targets;
53pub mod harness_release;
54pub mod harness_render;
55pub mod harness_shippable_typing;
56pub mod harness_synthesize;
57pub mod harness_toggle;
58pub mod harness_topic_metadata;
59pub mod harness_validate_concordance;
60pub mod harness_wrap;
61pub mod index;
62pub mod lock;
63pub mod ontology_pack;
64pub mod queue;
65pub mod resolve;
66pub mod review;
67pub mod suggest;
68pub mod vendor;
69
70pub use author::{DraftReport, draft_from_clusters, draft_from_topic};
71pub use calibrate::{
72    CONFUSION_REPRESENTATIVES, CalibrateOptions, CalibrationSample, ConfusionPair, ConfusionReport,
73    collect_topic_samples, confusions, packs_carry_negatives, subsample, sweep,
74};
75pub use catalog::Catalog;
76pub use config::HarnessConfig;
77pub use error::MifRhError;
78pub use finding::Finding;
79pub use harness_assert_graph::{CheckResult, GraphAssertion, assert_graph_mif_file};
80pub use harness_citation_integrity::{CitationIntegrityReport, check_citation_integrity};
81pub use harness_concordance::build_concordance;
82pub use harness_corpus::{CorpusSynthesis, synthesize_corpus};
83pub use harness_falsify::{FalsifyResult, falsify, falsify_with_now};
84pub use harness_graph::build_graph;
85pub use harness_import::{ImportReport, import_corpus};
86pub use harness_index::build_index;
87pub use harness_membership::{MembershipReport, resolve_membership};
88pub use harness_ontology_registry::{RegistryValidation, validate_ontology_registry};
89pub use harness_project::project_report;
90pub use harness_reconcile::{ReconcileReport, reconcile_session, sort_object_keys};
91pub use harness_relationship_targets::{
92    Orphan, RelationshipTargetsReport, check_relationship_targets,
93};
94pub use harness_release::{
95    BumpOptions, BumpReport, ChangelogLinkChange, ChangelogLinkReport, VersionGateFailure,
96    VersionGateReport, bump_version, check_version_bump, goal_version_id,
97    reconcile_changelog_links,
98};
99pub use harness_render::{RenderInputs, render_artifact};
100pub use harness_shippable_typing::{ShippableTypingReport, check_shippable_typing};
101pub use harness_synthesize::synthesize_artifact;
102pub use harness_toggle::{SITE_PLUGINS, pack_toggle, site_toggle_plugin, site_toggle_primary};
103pub use harness_topic_metadata::{TopicMetadata, topic_metadata};
104pub use harness_validate_concordance::{ConcordanceValidation, validate_concordance};
105pub use harness_wrap::{WrapSourceInputs, read_source_content, wrap_source};
106pub use index::{FindingIndex, IndexStats, IndexedFinding, Miss, SearchMatch, SimilarFinding};
107pub use lock::ReviewLock;
108pub use ontology_pack::{EntityType, OntologyPack};
109pub use queue::{
110    ClusterMember, ExpansionCandidate, SuggestionEntry, SuggestionQueue, expansion_candidates,
111    upsert_suggestions,
112};
113pub use resolve::{Basis, MapRecord, ResolveContext, build_allowed, resolve_finding};
114pub use review::{
115    FollowupBacklog, FollowupEntry, ReviewOptions, ReviewReport, TopicSummary, review,
116    write_followup,
117};
118pub use suggest::{TypeSuggestion, suggest_type};
119pub use vendor::{
120    CatalogSyncReport, DriftEntry, FetchReport, LockCheckReport, LockEntry, LockFile,
121    NewlyRequiredField, PinSafetyGap, PinSafetyReport, RegistrySyncReport, VendoredOntology,
122    check_pin_safety, fetch, lock_check, resolve_source, sync_catalog, sync_registry,
123};
124
125/// Rebuilds the search index for `topic_ids`, embedding every finding's
126/// discovery text (or its entity's `name`, for typed findings with no
127/// standalone content field) via [`mif_embed::Embedder`].
128///
129/// A separate step from [`review::review`] — index rebuilding always
130/// re-embeds every finding, which is far more expensive than the
131/// deterministic classification pass, so callers that only need
132/// classification (e.g. a fail-closed CI gate) are not forced to pay for
133/// it.
134///
135/// # Errors
136///
137/// Returns [`MifRhError`] if a topic's findings directory cannot be read,
138/// a finding fails to parse, the embedding model cannot be loaded or run,
139/// or the index cannot be rebuilt.
140pub fn build_search_index(
141    reports_dir: &std::path::Path,
142    topic_ids: &[String],
143    index: &mut FindingIndex,
144) -> Result<(), MifRhError> {
145    let embedder = mif_embed::Embedder::load()?;
146    let mut indexed = Vec::new();
147
148    for topic in topic_ids {
149        let findings_dir = reports_dir.join(topic).join("findings");
150        if !findings_dir.is_dir() {
151            continue;
152        }
153        for file in review::list_finding_files(&findings_dir)? {
154            let finding = Finding::load(&file)?;
155            let text = index_text(&finding);
156            let vector = embedder.embed(&text)?;
157            indexed.push(IndexedFinding {
158                finding_id: finding.id,
159                topic: topic.clone(),
160                content: text,
161                vector,
162            });
163        }
164    }
165
166    index.rebuild(&indexed)
167}
168
169/// Serializes `value` to pretty JSON and atomically writes it to `path`.
170///
171/// Writes to a `.tmp` sibling, then renames over the destination — so a
172/// reader never observes a partially-written file. Shared by
173/// [`review::write_followup`], `review`'s own internal `ontology-map.json`
174/// writer, and `mif-rh-cli`'s `ontology-map.json` upsert, which all follow
175/// the exact same write-then-rename shape.
176///
177/// # Errors
178///
179/// Returns [`MifRhError::JsonSerialize`] if `value` cannot be serialized,
180/// or [`MifRhError::Io`] if the temporary file cannot be written or
181/// renamed into place.
182pub fn write_json_atomic<T: serde::Serialize>(
183    path: &std::path::Path,
184    value: &T,
185) -> Result<(), MifRhError> {
186    let json = serde_json::to_string_pretty(value).map_err(|source| MifRhError::JsonSerialize {
187        path: path.display().to_string(),
188        source,
189    })?;
190    let tmp_path = path.with_extension("json.tmp");
191    std::fs::write(&tmp_path, json).map_err(|source| MifRhError::Io {
192        path: tmp_path.display().to_string(),
193        source,
194    })?;
195    std::fs::rename(&tmp_path, path).map_err(|source| MifRhError::Io {
196        path: path.display().to_string(),
197        source,
198    })
199}
200
201/// The text embedded and indexed for one finding: its discovery text
202/// (typically `content`) if any, otherwise its entity's `name`, if any.
203///
204/// Public so binary frontends can derive the same query text from a
205/// finding file (e.g. `mif-rh-cli suggest-type --finding <path>`) that the
206/// index itself embeds.
207#[must_use]
208pub fn index_text(finding: &Finding) -> String {
209    let discovery_text = finding.discovery_text();
210    if !discovery_text.is_empty() {
211        return discovery_text;
212    }
213    finding
214        .entity
215        .as_ref()
216        .and_then(|entity| entity.get("name"))
217        .and_then(serde_json::Value::as_str)
218        .unwrap_or_default()
219        .to_string()
220}