1use 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#[derive(Debug, Clone)]
22pub struct DraftReport {
23 pub yaml: String,
25 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
78fn json_escape(text: &str) -> String {
83 serde_json::to_string(text).unwrap_or_else(|_| format!("{text:?}"))
84}
85
86const fn collapse_whitespace(c: char) -> char {
89 if matches!(c, '\r' | '\n' | '\t') {
90 ' '
91 } else {
92 c
93 }
94}
95
96pub 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 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
159pub 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}