Skip to main content

mif_rh/
author.rs

1//! Drafts a new domain ontology YAML from research.
2//!
3//! Ports rht's `scripts/author-ontology.sh` drafting logic (Story #277):
4//! either mining a topic's `ontology-map.json` for entity types actually
5//! used, or scaffolding one candidate type per `mif-rh-cli
6//! expansion-candidates` cluster. The `--open-pr` concierge step (branching
7//! the ontologies repo, committing, opening a draft PR) stays in rht's own
8//! script — it orchestrates `git`/`gh`, not `jq`, so it is out of this
9//! cutover's scope.
10
11use std::collections::BTreeMap;
12use std::fmt::Write as _;
13
14use crate::error::MifRhError;
15use crate::queue::ExpansionCandidate;
16use crate::resolve::MapRecord;
17
18const TYPE_FOOTER: &str = "    base: semantic\n    traits:\n      - cited\n    schema:\n      required: []\n      properties: {}\n    source_vocab: TODO\n    source_class: TODO\n    prior_art: TODO\n    disposition: mint\n";
19
20/// The outcome of a [`draft_from_topic`] or [`draft_from_clusters`] call.
21#[derive(Debug, Clone)]
22pub struct DraftReport {
23    /// The rendered ontology YAML draft.
24    pub yaml: String,
25    /// How many candidate entity types were drafted.
26    pub type_count: usize,
27}
28
29fn render_header(new_id: &str, origin: &str, from_clusters: bool) -> String {
30    let mut out = String::from("---\n");
31    if from_clusters {
32        let _ = writeln!(
33            out,
34            "# {new_id} ontology — DRAFT scaffolded from recurring tier-3 misses."
35        );
36        let _ = writeln!(
37            out,
38            "# Authored by `mif-rh-cli ontology author --from-clusters` ({origin})."
39        );
40        out.push_str(
41            "# TODO before contributing upstream: NAME each todo-cluster-N candidate for\n\
42             #   the concept its excerpts share, define it, fill its grounding\n\
43             #   (source_vocab / source_class / prior_art) with a cited authority, and\n\
44             #   refine base/traits/schema. disposition stays 'mint' for newly-minted types.\n",
45        );
46    } else {
47        let _ = writeln!(
48            out,
49            "# {new_id} ontology — DRAFT scaffolded from research topic '{origin}'."
50        );
51        let _ = writeln!(
52            out,
53            "# Authored by `mif-rh-cli ontology author` from reports/{origin}/ontology-map.json."
54        );
55        out.push_str(
56            "# TODO before contributing upstream: fill each entity type's grounding\n\
57             #   (source_vocab / source_class / prior_art) with a cited authority, and\n\
58             #   refine base/traits/schema. disposition stays 'mint' for newly-minted types.\n",
59        );
60    }
61    out.push_str("ontology:\n");
62    let _ = writeln!(out, "  id: {new_id}");
63    out.push_str("  version: \"0.1.0\"\n");
64    let scaffolded_from = if from_clusters {
65        format!("tier-3 expansion clusters ({origin})")
66    } else {
67        format!("research topic '{origin}'")
68    };
69    let _ = writeln!(
70        out,
71        "  description: \"DRAFT {new_id} domain ontology scaffolded from {scaffolded_from}\""
72    );
73    out.push_str("  extends:\n    - mif-base\n    - shared-traits\n");
74    out.push_str("entity_types:\n");
75    out
76}
77
78/// A JSON-quoted string literal, matching jq's `@json` filter for a plain
79/// string. Serializing a `String` to JSON cannot fail; the fallback exists
80/// only to satisfy this workspace's `unwrap_used`/`expect_used` lints, not
81/// because the error path is reachable.
82fn json_escape(text: &str) -> String {
83    serde_json::to_string(text).unwrap_or_else(|_| format!("{text:?}"))
84}
85
86/// Replaces a CR/LF/tab with a plain space, for a one-line excerpt quoted
87/// in a YAML block scalar.
88const fn collapse_whitespace(c: char) -> char {
89    if matches!(c, '\r' | '\n' | '\t') {
90        ' '
91    } else {
92        c
93    }
94}
95
96/// Drafts a new ontology from the entity types a topic's findings used.
97///
98/// Mines `records` (a parsed `reports/<topic>/ontology-map.json`).
99/// Generic-fallback types (resolved under `mif-generic*`) are listed
100/// first — those most need a domain home of their own.
101///
102/// # Errors
103///
104/// Returns [`MifRhError::NoEntityTypesFound`] if `records` carries no typed
105/// entities.
106pub fn draft_from_topic(
107    records: &[MapRecord],
108    new_id: &str,
109    topic: &str,
110) -> Result<DraftReport, MifRhError> {
111    let mut groups: BTreeMap<&str, bool> = BTreeMap::new();
112    for record in records {
113        let Some(entity_type) = record.entity_type.as_deref() else {
114            continue;
115        };
116        let generic = record
117            .resolved_ontology
118            .as_deref()
119            .is_some_and(|o| o.starts_with("mif-generic"));
120        let entry = groups.entry(entity_type).or_insert(false);
121        *entry = *entry || generic;
122    }
123    if groups.is_empty() {
124        return Err(MifRhError::NoEntityTypesFound {
125            topic: topic.to_string(),
126        });
127    }
128
129    // BTreeMap iterates in alphabetical key order already; splitting into
130    // generic-first then the rest (each still alphabetical) matches
131    // `author-ontology.sh`'s own `group_by` + `sort_by(.generic | not)`.
132    let mut ordered: Vec<&str> = groups
133        .iter()
134        .filter(|(_, generic)| **generic)
135        .map(|(id, _)| *id)
136        .collect();
137    ordered.extend(
138        groups
139            .iter()
140            .filter(|(_, generic)| !**generic)
141            .map(|(id, _)| *id),
142    );
143
144    let mut yaml = render_header(new_id, topic, false);
145    for entity_type in &ordered {
146        let _ = writeln!(yaml, "  - name: {entity_type}");
147        let _ = writeln!(
148            yaml,
149            "    description: \"TODO: define {entity_type} (observed in topic {topic})\""
150        );
151        yaml.push_str(TYPE_FOOTER);
152    }
153    Ok(DraftReport {
154        yaml,
155        type_count: ordered.len(),
156    })
157}
158
159/// Drafts a new ontology from recurring tier-3 miss clusters.
160///
161/// Renders one candidate `todo-cluster-N` type per cluster in `clusters`
162/// (`mif-rh-cli expansion-candidates` output), quoting up to three
163/// representative member excerpts and every distinct member finding id.
164///
165/// # Errors
166///
167/// Returns [`MifRhError::NoClustersFound`] if `clusters` is empty.
168pub fn draft_from_clusters(
169    clusters: &[ExpansionCandidate],
170    new_id: &str,
171    clusters_source_name: &str,
172) -> Result<DraftReport, MifRhError> {
173    if clusters.is_empty() {
174        return Err(MifRhError::NoClustersFound {
175            path: clusters_source_name.to_string(),
176        });
177    }
178
179    let mut yaml = render_header(new_id, clusters_source_name, true);
180    for (index, cluster) in clusters.iter().enumerate() {
181        let n = index + 1;
182        let _ = writeln!(yaml, "  - name: todo-cluster-{n}");
183        yaml.push_str("    description: |-\n");
184        let _ = writeln!(
185            yaml,
186            "      TODO: name and define this candidate type — {} recurring tier-3 miss(es) \
187             across {} run(s).",
188            cluster.size, cluster.runs
189        );
190        yaml.push_str("      Member excerpts:\n");
191        for member in cluster.members.iter().take(3) {
192            let cleaned: String = member
193                .content
194                .chars()
195                .map(collapse_whitespace)
196                .take(140)
197                .collect();
198            let _ = writeln!(yaml, "      - {}", json_escape(&cleaned));
199        }
200        let mut finding_ids: Vec<&str> = cluster
201            .members
202            .iter()
203            .map(|member| member.finding_id.as_str())
204            .collect();
205        finding_ids.sort_unstable();
206        finding_ids.dedup();
207        let _ = writeln!(yaml, "      Member findings: {}", finding_ids.join(", "));
208        yaml.push_str(TYPE_FOOTER);
209    }
210    Ok(DraftReport {
211        yaml,
212        type_count: clusters.len(),
213    })
214}
215
216#[cfg(test)]
217mod tests {
218    use super::{draft_from_clusters, draft_from_topic};
219    use crate::queue::{ClusterMember, ExpansionCandidate};
220    use crate::resolve::{Basis, MapRecord};
221
222    fn record(finding_id: &str, entity_type: Option<&str>, resolved: Option<&str>) -> MapRecord {
223        MapRecord {
224            finding_id: finding_id.to_string(),
225            entity_type: entity_type.map(str::to_string),
226            resolved_ontology: resolved.map(str::to_string),
227            basis: Basis::Resolved,
228            valid: true,
229        }
230    }
231
232    #[test]
233    fn draft_from_topic_lists_generic_fallback_types_first() {
234        let records = vec![
235            record("f1", Some("widget"), Some("edu-fixture@0.1.0")),
236            record("f2", Some("gadget"), Some("mif-generic@1.0.0")),
237            record("f3", None, None),
238        ];
239
240        let report = draft_from_topic(&records, "new-domain", "edu").unwrap();
241
242        assert_eq!(report.type_count, 2);
243        let gadget_pos = report.yaml.find("name: gadget").unwrap();
244        let widget_pos = report.yaml.find("name: widget").unwrap();
245        assert!(
246            gadget_pos < widget_pos,
247            "generic-fallback type must be listed first"
248        );
249        assert!(report.yaml.contains("id: new-domain"));
250        assert!(report.yaml.contains("observed in topic edu"));
251    }
252
253    #[test]
254    fn draft_from_topic_fails_closed_with_no_typed_entities() {
255        let records = vec![record("f1", None, None)];
256        let error = draft_from_topic(&records, "new-domain", "edu").unwrap_err();
257        assert!(matches!(
258            error,
259            super::MifRhError::NoEntityTypesFound { .. }
260        ));
261    }
262
263    #[test]
264    fn draft_from_clusters_renders_excerpts_and_findings() {
265        let clusters = vec![ExpansionCandidate {
266            size: 2,
267            runs: 2,
268            members: vec![
269                ClusterMember {
270                    finding_id: "f2".to_string(),
271                    topic: "edu".to_string(),
272                    content: "a recurring miss".to_string(),
273                    run_id: "r1".to_string(),
274                },
275                ClusterMember {
276                    finding_id: "f1".to_string(),
277                    topic: "eng".to_string(),
278                    content: "another miss".to_string(),
279                    run_id: "r2".to_string(),
280                },
281            ],
282        }];
283
284        let report = draft_from_clusters(&clusters, "new-domain", "clusters.json").unwrap();
285
286        assert_eq!(report.type_count, 1);
287        assert!(report.yaml.contains("name: todo-cluster-1"));
288        assert!(report.yaml.contains("\"a recurring miss\""));
289        assert!(report.yaml.contains("Member findings: f1, f2"));
290    }
291
292    #[test]
293    fn draft_from_clusters_fails_closed_when_empty() {
294        let error = draft_from_clusters(&[], "new-domain", "clusters.json").unwrap_err();
295        assert!(matches!(error, super::MifRhError::NoClustersFound { .. }));
296    }
297}