Skip to main content

steeldb/
grow.rs

1//! **Step 3: ontology growth** — the MDL-style split criterion from `design.py`.
2//!
3//! The seed spec (step 0) is deliberately minimal ("3-4 SEED entity facets … growth adds more later").
4//! Growth then asks an agent for one candidate facet covering *what the existing facets leave
5//! unexplained*, and keeps it **only if it earns its place**:
6//!
7//! ```text
8//!   gain = coverage × (1 − maxcos)         (Collectively-Exhaustive × Mutually-Exclusive)
9//!   keep ⟺ gain ≥ threshold
10//! ```
11//!
12//! The reference measures orthogonality between *trained SPLADE decoder weights*, which means finetuning
13//! a head per candidate before you can score it. That is the expensive part, and it is not necessary to
14//! decide the question the gate asks: *does this facet pick out corpus content the others miss?* Here each
15//! facet is given a **detector** — its name plus the example surfaces the proposing agent supplied — and
16//! the candidate is scored on the real corpus incidence those detectors produce ([`crate::mece`]).
17//! Cheap, deterministic, and measured on the corpus as it actually is rather than on model weights.
18//!
19//! The honest limitation of that substitution: a detector built from a handful of example surfaces
20//! under-measures a facet whose vocabulary is broad, so `gain` is a lower bound. A candidate that clears
21//! the threshold has definitely earned its place; one that narrowly fails might have earned it with a
22//! trained head.
23
24use crate::mece;
25use crate::vocabulary::{EntityFacet, VocabularySpace};
26use crate::{InfonIndex, RoarPostings};
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29
30/// A growth candidate — mirrors `design.py::propose_candidate`'s schema.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Candidate {
33    pub name: String,
34    /// the existing facet this specialises — the source of the URI hierarchy
35    #[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/// One decision in the growth log — kept for the registry so a spec's provenance is auditable.
50#[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
64/// Detector terms for a facet: its own name plus any example surfaces, lowercased. This is what stands in
65/// for a trained retrieval head when scoring the MECE gate.
66fn 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
72/// Project sampled documents into a facet-incidence index using detector matching: one situation per
73/// document, one `facet/term` token per detector hit. Word-boundary matched so short names don't fire
74/// inside longer words.
75fn 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
94/// Word-boundary containment (so `org` doesn't match inside `organic`).
95/// Whole-word containment. A substring test would match "it" inside "submitted", which silently
96/// corrupts both training labels and projected tokens.
97pub 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
115/// Score a candidate against the current spec on a document sample. Returns the MECE numbers for the
116/// candidate facet, or `None` when its detectors never fire (nothing to measure).
117pub fn score_candidate(spec: &VocabularySpace, docs: &[String], cand: &Candidate) -> Option<mece::FacetScore> {
118    score_candidate_full(spec, docs, cand).map(|(s, _)| s)
119}
120
121/// Score a candidate and also report **parent duplication**: `(candidate_coverage / parent_coverage,
122/// maxcos_against_parent)`. A specialisation must be strictly narrower than the facet it refines — if it
123/// reproduces the parent's coverage *and* its incidence, it is a rename, not a new node type.
124pub 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    // A candidate declares a `parent` it SPECIALISES, so overlap with that parent (and its ancestors) is by
135    // design, not redundancy — the reference gates a new head against its siblings. Excluding the ancestor
136    // chain from the comparison is what keeps a legitimate child facet from being scored as a duplicate of
137    // the thing it refines.
138    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    // Parent-duplication check. Measured on an index containing ONLY the candidate and its parent, so the
148    // reported `maxcos` is necessarily against the parent — relying on the global `nearest` would hide
149    // parent duplication whenever some unrelated facet happened to be closer.
150    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
171/// Apply the gate to a scored candidate: keep iff `gain ≥ threshold`, the candidate is wanted, its parent
172/// exists, and its name isn't already taken.
173pub 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
177/// Full gate including the parent-duplication signal from [`score_candidate_full`].
178pub 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
226/// Add a kept candidate to the spec.
227pub 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/// Run the growth loop: propose → score → gate → adopt, stopping when a round keeps nothing (the
238/// reference's "Else stop"). Returns the grown spec and the full decision log.
239#[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; // the reference stops as soon as a round earns nothing
313        }
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        // "battery" appears in 3/4 docs and is not what org/system detect → real gain
357        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        // `battery` specialises `system`; its detectors necessarily co-occur with system's. Judged against
369        // siblings (not the parent) it must still be able to earn its place.
370        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        // a facet whose detectors duplicate `system`'s ("radar") must be squeezed out by maxcos
384        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        // agent says stop
404        assert!(!gate(&s, &mk("battery", Some("system"), false), None, 0.1, 1).kept);
405        // duplicate name
406        assert!(!gate(&s, &mk("org", Some("system"), true), None, 0.1, 1).kept);
407        // dangling parent
408        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        // detectors that never fire
412        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}