1use crate::mece;
25use crate::vocabulary::{EntityFacet, VocabularySpace};
26use crate::{InfonIndex, RoarPostings};
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Candidate {
33 pub name: String,
34 #[serde(default)]
36 pub parent: Option<String>,
37 #[serde(default)]
38 pub description: String,
39 #[serde(default)]
40 pub examples: Vec<String>,
41 #[serde(default = "yes")]
42 pub worth_adding: bool,
43}
44
45fn yes() -> bool {
46 true
47}
48
49#[derive(Debug, Clone, Serialize)]
51pub struct GrowEvent {
52 pub round: usize,
53 pub name: String,
54 pub parent: Option<String>,
55 pub coverage: f64,
56 pub maxcos: f64,
57 pub nearest: String,
58 pub gain: f64,
59 pub threshold: f64,
60 pub kept: bool,
61 pub reason: String,
62}
63
64fn detectors(f: &EntityFacet) -> Vec<String> {
67 let mut v = vec![f.name.replace(['-', '_'], " ")];
68 v.extend(f.examples.iter().map(|e| e.to_lowercase()));
69 v.into_iter().map(|s| s.trim().to_lowercase()).filter(|s| s.len() >= 3).collect()
70}
71
72fn incidence_index(spec: &VocabularySpace, docs: &[String]) -> InfonIndex<RoarPostings> {
76 let mut raw: HashMap<String, Vec<u32>> = HashMap::new();
77 for (sid, doc) in docs.iter().enumerate() {
78 let low = doc.to_lowercase();
79 for f in &spec.entity_facets {
80 for term in detectors(f) {
81 if contains_word(&low, &term) {
82 raw.entry(format!("{}/{}", f.name, crate::projector::slug(&term))).or_default().push(sid as u32);
83 }
84 }
85 }
86 }
87 for v in raw.values_mut() {
88 v.sort_unstable();
89 v.dedup();
90 }
91 InfonIndex::from_postings(raw, docs.len() as u32)
92}
93
94pub fn contains_word(hay: &str, needle: &str) -> bool {
98 let mut from = 0usize;
99 while let Some(rel) = hay[from..].find(needle) {
100 let s = from + rel;
101 let e = s + needle.len();
102 let before_ok = s == 0 || !hay[..s].chars().next_back().map(|c| c.is_alphanumeric()).unwrap_or(false);
103 let after_ok = e >= hay.len() || !hay[e..].chars().next().map(|c| c.is_alphanumeric()).unwrap_or(false);
104 if before_ok && after_ok {
105 return true;
106 }
107 from = s + needle.len().max(1);
108 if from >= hay.len() {
109 break;
110 }
111 }
112 false
113}
114
115pub fn score_candidate(spec: &VocabularySpace, docs: &[String], cand: &Candidate) -> Option<mece::FacetScore> {
118 score_candidate_full(spec, docs, cand).map(|(s, _)| s)
119}
120
121pub fn score_candidate_full(spec: &VocabularySpace, docs: &[String], cand: &Candidate) -> Option<(mece::FacetScore, Option<(f64, f64)>)> {
125 let mut trial = spec.clone();
126 trial.entity_facets.push(EntityFacet {
127 name: cand.name.clone(),
128 parent: cand.parent.clone(),
129 description: cand.description.clone(),
130 examples: cand.examples.clone(),
131 structural: false,
132 });
133 let ix = incidence_index(&trial, docs);
134 let mut excluded: Vec<String> = vec!["src".to_string()];
139 if let Some(p) = &cand.parent {
140 excluded.push(p.clone());
141 excluded.extend(trial.ancestors(p));
142 }
143 let skip: Vec<&str> = excluded.iter().map(|s| s.as_str()).collect();
144 let rep = mece::report(&ix, &skip);
145 let score = rep.facets.into_iter().find(|f| f.facet == cand.name)?;
146
147 let dup = cand.parent.as_ref().and_then(|p| {
151 let parent_facet = trial.entity_facets.iter().find(|f| f.name == *p)?.clone();
152 let cand_facet = trial.entity_facets.iter().find(|f| f.name == cand.name)?.clone();
153 let pair_spec = VocabularySpace {
154 version: trial.version,
155 corpus: trial.corpus.clone(),
156 entity_facets: vec![parent_facet, cand_facet],
157 relation_facets: Vec::new(),
158 gazetteer: Vec::new(),
159 metrics: None,
160 };
161 let pix = incidence_index(&pair_spec, docs);
162 let rep2 = mece::report(&pix, &["src"]);
163 let c = rep2.facets.iter().find(|f| f.facet == cand.name)?;
164 let par = rep2.facets.iter().find(|f| f.facet == *p)?;
165 let ratio = if par.coverage > 0.0 { c.coverage / par.coverage } else { 0.0 };
166 Some((ratio, c.maxcos))
167 });
168 Some((score, dup))
169}
170
171pub fn gate(spec: &VocabularySpace, cand: &Candidate, score: Option<&mece::FacetScore>, threshold: f64, round: usize) -> GrowEvent {
174 gate_full(spec, cand, score, None, threshold, round)
175}
176
177pub fn gate_full(
179 spec: &VocabularySpace,
180 cand: &Candidate,
181 score: Option<&mece::FacetScore>,
182 parent_dup: Option<(f64, f64)>,
183 threshold: f64,
184 round: usize,
185) -> GrowEvent {
186 let s = score.cloned().unwrap_or(mece::FacetScore {
187 facet: cand.name.clone(),
188 tokens: 0,
189 coverage: 0.0,
190 maxcos: 1.0,
191 nearest: String::new(),
192 gain: 0.0,
193 });
194 let (kept, reason) = if !cand.worth_adding {
195 (false, "agent reported the corpus already covered".to_string())
196 } else if cand.name.trim().is_empty() {
197 (false, "empty name".to_string())
198 } else if spec.has_entity_facet(&cand.name) {
199 (false, format!("facet '{}' already declared", cand.name))
200 } else if cand.parent.as_deref().map(|p| !spec.has_entity_facet(p)).unwrap_or(false) {
201 (false, format!("parent '{}' is not an existing facet", cand.parent.clone().unwrap_or_default()))
202 } else if s.tokens == 0 {
203 (false, "detectors never fired on the sample".to_string())
204 } else if parent_dup.map(|(ratio, cos)| cos > 0.95 || (ratio > 0.9 && cos > 0.9)).unwrap_or(false) {
205 let (ratio, cos) = parent_dup.unwrap();
206 (false, format!("duplicates its parent '{}' (cos {cos:.2}, coverage ratio {ratio:.2}) — a specialisation must add vocabulary, not restate the parent", cand.parent.clone().unwrap_or_default()))
207 } else if s.gain < threshold {
208 (false, format!("gain {:.3} < threshold {:.3} (coverage {:.3}, maxcos {:.3} vs '{}')", s.gain, threshold, s.coverage, s.maxcos, s.nearest))
209 } else {
210 (true, format!("gain {:.3} ≥ {:.3}", s.gain, threshold))
211 };
212 GrowEvent {
213 round,
214 name: cand.name.clone(),
215 parent: cand.parent.clone(),
216 coverage: s.coverage,
217 maxcos: s.maxcos,
218 nearest: s.nearest,
219 gain: s.gain,
220 threshold,
221 kept,
222 reason,
223 }
224}
225
226pub fn adopt(spec: &mut VocabularySpace, cand: &Candidate) {
228 spec.entity_facets.push(EntityFacet {
229 name: cand.name.clone(),
230 parent: cand.parent.clone(),
231 description: cand.description.clone(),
232 examples: cand.examples.clone(),
233 structural: false,
234 });
235}
236
237#[cfg(feature = "agent")]
240pub async fn grow(
241 provider: &dyn crate::agent::provider::LlmProvider,
242 spec: &VocabularySpace,
243 docs: &[String],
244 rounds: usize,
245 threshold: f64,
246) -> (VocabularySpace, Vec<GrowEvent>) {
247 use crate::agent::types::{Msg, ToolSpec};
248 let mut spec = spec.clone();
249 let mut log: Vec<GrowEvent> = Vec::new();
250 let tools = vec![ToolSpec {
251 name: "emit_candidate".into(),
252 description: "Emit one candidate facet that specialises an existing facet.".into(),
253 schema: crate::vocabulary::candidate_schema(),
254 }];
255 for round in 1..=rounds {
256 let existing: Vec<String> = spec
257 .taggable_facets()
258 .iter()
259 .map(|f| match &f.parent {
260 Some(p) => format!("{} (parent {p})", f.name),
261 None => f.name.clone(),
262 })
263 .collect();
264 let sample: Vec<&str> = docs.iter().take(10).map(|s| s.as_str()).collect();
265 let prompt = format!(
266 "Existing facets: {}\nEach new facet specialises one of these (set 'parent').\n\nCorpus sample:\n{}",
267 existing.join(", "),
268 sample.join("\n---\n")
269 );
270 let turn = match provider.chat(crate::vocabulary::CANDIDATE_SYSTEM, &[Msg::user_text(prompt)], &tools).await {
271 Ok(t) => t,
272 Err(e) => {
273 log.push(GrowEvent {
274 round,
275 name: String::new(),
276 parent: None,
277 coverage: 0.0,
278 maxcos: 1.0,
279 nearest: String::new(),
280 gain: 0.0,
281 threshold,
282 kept: false,
283 reason: format!("provider error: {e}"),
284 });
285 break;
286 }
287 };
288 let payload = turn
289 .tool_uses
290 .first()
291 .map(|(_, _, v)| v.clone())
292 .or_else(|| crate::vocabulary::extract_json(&turn.text));
293 let Some(v) = payload else { break };
294 let mut cand: Candidate = match serde_json::from_value(v) {
295 Ok(c) => c,
296 Err(_) => break,
297 };
298 cand.name = crate::projector::slug(&cand.name);
299 cand.parent = cand.parent.map(|p| crate::projector::slug(&p)).filter(|p| !p.is_empty());
300
301 let scored = score_candidate_full(&spec, docs, &cand);
302 let (score, dup) = match &scored {
303 Some((s, d)) => (Some(s), *d),
304 None => (None, None),
305 };
306 let ev = gate_full(&spec, &cand, score, dup, threshold, round);
307 let kept = ev.kept;
308 log.push(ev);
309 if kept {
310 adopt(&mut spec, &cand);
311 } else {
312 break; }
314 }
315 spec.metrics = Some(serde_json::json!({
316 "source": "grow",
317 "hierarchy": spec.entity_facets.iter().any(|f| f.parent.is_some()),
318 "rounds": log.len(),
319 "kept": log.iter().filter(|e| e.kept).count(),
320 }));
321 (spec, log)
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use crate::vocabulary::RelationFacet;
328
329 fn spec() -> VocabularySpace {
330 VocabularySpace {
331 version: 1,
332 corpus: "defence".into(),
333 entity_facets: vec![
334 EntityFacet { name: "org".into(), parent: None, description: "companies".into(), examples: vec!["Boeing".into(), "Airbus".into()], structural: false },
335 EntityFacet { name: "system".into(), parent: None, description: "platforms".into(), examples: vec!["drone".into(), "radar".into()], structural: false },
336 ],
337 relation_facets: vec![RelationFacet { name: "develops".into(), head: "org".into(), tail: "system".into() }],
338 gazetteer: vec![],
339 metrics: None,
340 }
341 }
342
343 fn docs() -> Vec<String> {
344 vec![
345 "Boeing builds a drone with new radar and a lithium battery pack.".into(),
346 "Airbus tested the radar under a thermal battery fault.".into(),
347 "A drone carried a battery to altitude; Boeing observed.".into(),
348 "Airbus and Boeing both use radar.".into(),
349 ]
350 }
351
352 #[test]
353 fn a_novel_facet_earns_its_place() {
354 let s = spec();
355 let d = docs();
356 let cand = Candidate { name: "battery".into(), parent: Some("system".into()), description: "cells".into(), examples: vec!["battery".into()], worth_adding: true };
358 let score = score_candidate(&s, &d, &cand).expect("scored");
359 eprintln!("candidate score: {score:?}");
360 assert!(score.coverage > 0.5, "battery covers most docs: {score:?}");
361 let ev = gate(&s, &cand, Some(&score), 0.1, 1);
362 assert!(ev.kept, "{}", ev.reason);
363 assert_eq!(ev.parent.as_deref(), Some("system"));
364 }
365
366 #[test]
367 fn a_child_facet_is_not_penalised_for_overlapping_its_parent() {
368 let s = spec();
371 let d = docs();
372 let child = Candidate { name: "battery".into(), parent: Some("system".into()), description: "cells".into(), examples: vec!["battery".into()], worth_adding: true };
373 let scored = score_candidate(&s, &d, &child).expect("scored");
374 eprintln!("child score (parent excluded): {scored:?}");
375 assert_ne!(scored.nearest, "system", "the parent must be excluded from the redundancy comparison");
376 assert!(gate(&s, &child, Some(&scored), 0.1, 1).kept);
377 }
378
379 #[test]
380 fn a_redundant_facet_is_rejected() {
381 let s = spec();
382 let d = docs();
383 let cand = Candidate { name: "sensor".into(), parent: Some("system".into()), description: "dupe".into(), examples: vec!["radar".into()], worth_adding: true };
385 let (score, dup) = score_candidate_full(&s, &d, &cand).expect("scored");
386 eprintln!("redundant score: {score:?} parent_dup={dup:?}");
387 let ev = gate_full(&s, &cand, Some(&score), dup, 0.1, 1);
388 assert!(!ev.kept, "a facet that merely renames its parent must be rejected");
389 assert!(ev.reason.contains("duplicates its parent") || ev.reason.contains("gain"), "{}", ev.reason);
390 }
391
392 #[test]
393 fn gate_enforces_structural_invariants() {
394 let s = spec();
395 let d = docs();
396 let mk = |name: &str, parent: Option<&str>, worth: bool| Candidate {
397 name: name.into(),
398 parent: parent.map(String::from),
399 description: String::new(),
400 examples: vec!["battery".into()],
401 worth_adding: worth,
402 };
403 assert!(!gate(&s, &mk("battery", Some("system"), false), None, 0.1, 1).kept);
405 assert!(!gate(&s, &mk("org", Some("system"), true), None, 0.1, 1).kept);
407 let c = mk("battery", Some("nonexistent"), true);
409 let ev = gate(&s, &c, score_candidate(&s, &d, &c).as_ref(), 0.1, 1);
410 assert!(!ev.kept && ev.reason.contains("parent"), "{}", ev.reason);
411 let c2 = Candidate { name: "quantum".into(), parent: Some("system".into()), description: String::new(), examples: vec!["tachyon".into()], worth_adding: true };
413 let ev2 = gate(&s, &c2, score_candidate(&s, &d, &c2).as_ref(), 0.1, 1);
414 assert!(!ev2.kept, "{}", ev2.reason);
415 }
416
417 #[test]
418 fn adopt_extends_the_hierarchy() {
419 let mut s = spec();
420 let cand = Candidate { name: "battery".into(), parent: Some("system".into()), description: "cells".into(), examples: vec![], worth_adding: true };
421 adopt(&mut s, &cand);
422 assert_eq!(s.facet_path("battery"), "system/battery");
423 assert!(s.valid_prefixes().contains(&"system/battery".to_string()));
424 assert!(s.validate().is_ok());
425 }
426
427 #[test]
428 fn word_boundary_detectors() {
429 assert!(contains_word("a battery pack", "battery"));
430 assert!(!contains_word("organic material", "org"));
431 assert!(contains_word("the org chart", "org"));
432 }
433}