use std::collections::BTreeMap;
use std::fmt::Write as _;
use crate::error::MifRhError;
use crate::queue::ExpansionCandidate;
use crate::resolve::MapRecord;
const 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";
#[derive(Debug, Clone)]
pub struct DraftReport {
pub yaml: String,
pub type_count: usize,
}
fn render_header(new_id: &str, origin: &str, from_clusters: bool) -> String {
let mut out = String::from("---\n");
if from_clusters {
let _ = writeln!(
out,
"# {new_id} ontology — DRAFT scaffolded from recurring tier-3 misses."
);
let _ = writeln!(
out,
"# Authored by `mif-rh-cli ontology author --from-clusters` ({origin})."
);
out.push_str(
"# TODO before contributing upstream: NAME each todo-cluster-N candidate for\n\
# the concept its excerpts share, define it, fill its grounding\n\
# (source_vocab / source_class / prior_art) with a cited authority, and\n\
# refine base/traits/schema. disposition stays 'mint' for newly-minted types.\n",
);
} else {
let _ = writeln!(
out,
"# {new_id} ontology — DRAFT scaffolded from research topic '{origin}'."
);
let _ = writeln!(
out,
"# Authored by `mif-rh-cli ontology author` from reports/{origin}/ontology-map.json."
);
out.push_str(
"# TODO before contributing upstream: fill each entity type's grounding\n\
# (source_vocab / source_class / prior_art) with a cited authority, and\n\
# refine base/traits/schema. disposition stays 'mint' for newly-minted types.\n",
);
}
out.push_str("ontology:\n");
let _ = writeln!(out, " id: {new_id}");
out.push_str(" version: \"0.1.0\"\n");
let scaffolded_from = if from_clusters {
format!("tier-3 expansion clusters ({origin})")
} else {
format!("research topic '{origin}'")
};
let _ = writeln!(
out,
" description: \"DRAFT {new_id} domain ontology scaffolded from {scaffolded_from}\""
);
out.push_str(" extends:\n - mif-base\n - shared-traits\n");
out.push_str("entity_types:\n");
out
}
fn json_escape(text: &str) -> String {
serde_json::to_string(text).unwrap_or_else(|_| format!("{text:?}"))
}
const fn collapse_whitespace(c: char) -> char {
if matches!(c, '\r' | '\n' | '\t') {
' '
} else {
c
}
}
pub fn draft_from_topic(
records: &[MapRecord],
new_id: &str,
topic: &str,
) -> Result<DraftReport, MifRhError> {
let mut groups: BTreeMap<&str, bool> = BTreeMap::new();
for record in records {
let Some(entity_type) = record.entity_type.as_deref() else {
continue;
};
let generic = record
.resolved_ontology
.as_deref()
.is_some_and(|o| o.starts_with("mif-generic"));
let entry = groups.entry(entity_type).or_insert(false);
*entry = *entry || generic;
}
if groups.is_empty() {
return Err(MifRhError::NoEntityTypesFound {
topic: topic.to_string(),
});
}
let mut ordered: Vec<&str> = groups
.iter()
.filter(|(_, generic)| **generic)
.map(|(id, _)| *id)
.collect();
ordered.extend(
groups
.iter()
.filter(|(_, generic)| !**generic)
.map(|(id, _)| *id),
);
let mut yaml = render_header(new_id, topic, false);
for entity_type in &ordered {
let _ = writeln!(yaml, " - name: {entity_type}");
let _ = writeln!(
yaml,
" description: \"TODO: define {entity_type} (observed in topic {topic})\""
);
yaml.push_str(TYPE_FOOTER);
}
Ok(DraftReport {
yaml,
type_count: ordered.len(),
})
}
pub fn draft_from_clusters(
clusters: &[ExpansionCandidate],
new_id: &str,
clusters_source_name: &str,
) -> Result<DraftReport, MifRhError> {
if clusters.is_empty() {
return Err(MifRhError::NoClustersFound {
path: clusters_source_name.to_string(),
});
}
let mut yaml = render_header(new_id, clusters_source_name, true);
for (index, cluster) in clusters.iter().enumerate() {
let n = index + 1;
let _ = writeln!(yaml, " - name: todo-cluster-{n}");
yaml.push_str(" description: |-\n");
let _ = writeln!(
yaml,
" TODO: name and define this candidate type — {} recurring tier-3 miss(es) \
across {} run(s).",
cluster.size, cluster.runs
);
yaml.push_str(" Member excerpts:\n");
for member in cluster.members.iter().take(3) {
let cleaned: String = member
.content
.chars()
.map(collapse_whitespace)
.take(140)
.collect();
let _ = writeln!(yaml, " - {}", json_escape(&cleaned));
}
let mut finding_ids: Vec<&str> = cluster
.members
.iter()
.map(|member| member.finding_id.as_str())
.collect();
finding_ids.sort_unstable();
finding_ids.dedup();
let _ = writeln!(yaml, " Member findings: {}", finding_ids.join(", "));
yaml.push_str(TYPE_FOOTER);
}
Ok(DraftReport {
yaml,
type_count: clusters.len(),
})
}
#[cfg(test)]
mod tests {
use super::{draft_from_clusters, draft_from_topic};
use crate::queue::{ClusterMember, ExpansionCandidate};
use crate::resolve::{Basis, MapRecord};
fn record(finding_id: &str, entity_type: Option<&str>, resolved: Option<&str>) -> MapRecord {
MapRecord {
finding_id: finding_id.to_string(),
entity_type: entity_type.map(str::to_string),
resolved_ontology: resolved.map(str::to_string),
basis: Basis::Resolved,
valid: true,
}
}
#[test]
fn draft_from_topic_lists_generic_fallback_types_first() {
let records = vec![
record("f1", Some("widget"), Some("edu-fixture@0.1.0")),
record("f2", Some("gadget"), Some("mif-generic@1.0.0")),
record("f3", None, None),
];
let report = draft_from_topic(&records, "new-domain", "edu").unwrap();
assert_eq!(report.type_count, 2);
let gadget_pos = report.yaml.find("name: gadget").unwrap();
let widget_pos = report.yaml.find("name: widget").unwrap();
assert!(
gadget_pos < widget_pos,
"generic-fallback type must be listed first"
);
assert!(report.yaml.contains("id: new-domain"));
assert!(report.yaml.contains("observed in topic edu"));
}
#[test]
fn draft_from_topic_fails_closed_with_no_typed_entities() {
let records = vec![record("f1", None, None)];
let error = draft_from_topic(&records, "new-domain", "edu").unwrap_err();
assert!(matches!(
error,
super::MifRhError::NoEntityTypesFound { .. }
));
}
#[test]
fn draft_from_clusters_renders_excerpts_and_findings() {
let clusters = vec![ExpansionCandidate {
size: 2,
runs: 2,
members: vec![
ClusterMember {
finding_id: "f2".to_string(),
topic: "edu".to_string(),
content: "a recurring miss".to_string(),
run_id: "r1".to_string(),
},
ClusterMember {
finding_id: "f1".to_string(),
topic: "eng".to_string(),
content: "another miss".to_string(),
run_id: "r2".to_string(),
},
],
}];
let report = draft_from_clusters(&clusters, "new-domain", "clusters.json").unwrap();
assert_eq!(report.type_count, 1);
assert!(report.yaml.contains("name: todo-cluster-1"));
assert!(report.yaml.contains("\"a recurring miss\""));
assert!(report.yaml.contains("Member findings: f1, f2"));
}
#[test]
fn draft_from_clusters_fails_closed_when_empty() {
let error = draft_from_clusters(&[], "new-domain", "clusters.json").unwrap_err();
assert!(matches!(error, super::MifRhError::NoClustersFound { .. }));
}
}