1use crate::api::SteelDb;
38
39#[derive(Debug, Clone, PartialEq)]
41pub struct Candidate {
42 pub name: String,
44 pub words: Vec<String>,
46 pub rationale: String,
48}
49
50#[derive(Debug, Clone, Default)]
52pub struct Proposal {
53 pub candidates: Vec<Candidate>,
54 pub source: String,
56}
57
58impl std::fmt::Display for Proposal {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 writeln!(f, "proposal from {} — {} candidate(s)", self.source, self.candidates.len())?;
61 for c in &self.candidates {
62 writeln!(f, " {} — {}", c.name, c.rationale)?;
63 writeln!(f, " words: {}", c.words.join(", "))?;
64 }
65 Ok(())
66 }
67}
68
69#[derive(Debug, Clone)]
71pub struct Verdict {
72 pub name: String,
73 pub kept: bool,
74 pub reason: String,
76 pub coverage: f64,
78 pub overlap: f64,
80}
81
82impl SteelDb {
83 pub fn adopt(&mut self, proposal: &Proposal) -> Vec<Verdict> {
92 let mut verdicts = Vec::new();
93 for (round, cand) in proposal.candidates.iter().enumerate() {
94 let c = crate::grow::Candidate {
95 name: cand.name.clone(),
96 parent: None,
97 description: cand.rationale.clone(),
98 examples: cand.words.clone(),
99 worth_adding: true,
100 };
101 let docs = self.documents().to_vec();
102 let spec = self.spec_snapshot();
103 let scored = crate::grow::score_candidate_full(&spec, &docs, &c);
104 let (score, dup) = match scored {
105 Some((s, d)) => (Some(s), d),
106 None => (None, None),
107 };
108 let ev = crate::grow::gate_full(&spec, &c, score.as_ref(), dup, self.min_gain(), round);
109 if ev.kept {
110 self.push_category(cand.name.clone(), cand.words.clone());
111 }
112 verdicts.push(Verdict {
113 name: cand.name.clone(),
114 kept: ev.kept,
115 reason: ev.reason,
116 coverage: ev.coverage,
117 overlap: ev.maxcos,
118 });
119 }
120 if verdicts.iter().any(|v| v.kept) {
121 self.reproject();
123 }
124 verdicts
125 }
126}
127
128pub struct Teacher {
133 kind: Kind,
134}
135
136enum Kind {
137 Fixed(Proposal),
139 #[cfg(feature = "paddock")]
141 Local { base_url: String, model: String },
142 #[cfg(feature = "bedrock")]
143 Bedrock { model_id: String },
144}
145
146#[derive(Debug)]
148pub enum LearnError {
149 NotConfigured(String),
151 BadResponse(String),
153 Transport(String),
155}
156
157impl std::fmt::Display for LearnError {
158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159 match self {
160 LearnError::NotConfigured(m) => write!(f, "not configured for learning: {m}"),
161 LearnError::BadResponse(m) => write!(f, "unusable response: {m}"),
162 LearnError::Transport(m) => write!(f, "call failed: {m}"),
163 }
164 }
165}
166
167impl std::error::Error for LearnError {}
168
169impl Teacher {
170 pub fn fixed(proposal: Proposal) -> Teacher {
173 Teacher { kind: Kind::Fixed(proposal) }
174 }
175
176 #[cfg(feature = "paddock")]
201 pub fn local(base_url: impl Into<String>, model: impl Into<String>) -> Result<Teacher, LearnError> {
202 let base_url = base_url.into();
203 if !base_url.starts_with("http") {
204 return Err(LearnError::NotConfigured(format!(
205 "base_url should be an http(s) endpoint, got {base_url:?}"
206 )));
207 }
208 Ok(Teacher { kind: Kind::Local { base_url, model: model.into() } })
209 }
210
211 #[cfg(feature = "paddock")]
215 pub fn ollama(model: impl Into<String>) -> Result<Teacher, LearnError> {
216 Teacher::local("http://localhost:11434/v1", model)
217 }
218
219 #[cfg(feature = "bedrock")]
227 pub fn bedrock(model_id: impl Into<String>) -> Result<Teacher, LearnError> {
228 if std::env::var("AWS_REGION").is_err() && std::env::var("AWS_DEFAULT_REGION").is_err() {
229 return Err(LearnError::NotConfigured(
230 "set AWS_REGION (or AWS_DEFAULT_REGION) to the region hosting the model".into(),
231 ));
232 }
233 Ok(Teacher { kind: Kind::Bedrock { model_id: model_id.into() } })
234 }
235
236 pub async fn propose_categories(&self, db: &SteelDb) -> Result<Proposal, LearnError> {
240 let _ = db;
242 match &self.kind {
243 Kind::Fixed(p) => Ok(p.clone()),
244 #[cfg(feature = "paddock")]
245 Kind::Local { base_url, model } => {
246 let cfg = crate::agent::config::ProviderConfig::Paddock {
247 base_url: base_url.clone(),
248 model: model.clone(),
249 api_key: None,
250 };
251 Self::propose_via(cfg, db).await
252 }
253 #[cfg(feature = "bedrock")]
254 Kind::Bedrock { model_id } => {
255 let cfg = crate::agent::config::ProviderConfig::Bedrock {
256 model_id: model_id.clone(),
257 region: std::env::var("AWS_REGION").ok(),
258 };
259 Self::propose_via(cfg, db).await
260 }
261 }
262 }
263
264 #[cfg(any(feature = "paddock", feature = "bedrock"))]
267 async fn propose_via(
268 cfg: crate::agent::config::ProviderConfig,
269 db: &SteelDb,
270 ) -> Result<Proposal, LearnError> {
271 let label = match &cfg {
273 #[cfg(feature = "paddock")]
274 crate::agent::config::ProviderConfig::Paddock { model, .. } => format!("local:{model}"),
275 #[cfg(feature = "bedrock")]
276 crate::agent::config::ProviderConfig::Bedrock { model_id, .. } => format!("bedrock:{model_id}"),
277 _ => "model".to_string(),
278 };
279 let provider = cfg.build().await.map_err(LearnError::Transport)?;
280 let sample: Vec<String> = db.documents().iter().take(48).cloned().collect();
281 let spec = crate::vocabulary::propose(provider.as_ref(), "documents", &sample)
282 .await
283 .map_err(LearnError::BadResponse)?;
284 Ok(Proposal {
285 source: label,
286 candidates: spec
287 .entity_facets
288 .into_iter()
289 .map(|f| Candidate { name: f.name, words: f.examples, rationale: f.description })
290 .collect(),
291 })
292 }
293
294 #[cfg(all(feature = "onnx", feature = "embed", any(feature = "paddock", feature = "bedrock")))]
312 pub async fn curate(
313 &self,
314 raw: &crate::tagger_discover::RawSpec,
315 ) -> Result<Proposal, LearnError> {
316 use crate::agent::types::Msg;
317
318 let cfg = self.provider_config()?;
319 let label = match &cfg {
320 #[cfg(feature = "paddock")]
321 crate::agent::config::ProviderConfig::Paddock { model, .. } => format!("curate:local:{model}"),
322 #[cfg(feature = "bedrock")]
323 crate::agent::config::ProviderConfig::Bedrock { model_id, .. } => format!("curate:bedrock:{model_id}"),
324 _ => "curate".to_string(),
325 };
326 let provider = cfg.build().await.map_err(LearnError::Transport)?;
327
328 let ents = raw
329 .entity_clusters
330 .iter()
331 .map(|c| format!("{}: {}", c.label, c.terms.join(", ")))
332 .collect::<Vec<_>>()
333 .join("\n");
334 let rels = raw
335 .relation_clusters
336 .iter()
337 .map(|c| format!("{}: {}", c.label, c.terms.join(", ")))
338 .collect::<Vec<_>>()
339 .join("\n");
340 let prompt = format!("RAW ENTITY-VALUE CLUSTERS:\n{ents}\n\nRAW RELATION CLUSTERS:\n{rels}");
341
342 let schema = curation_schema();
343 let v = provider
344 .chat_json(CURATE_SYSTEM, &[Msg::user_text(prompt)], &schema, "curate")
345 .await
346 .map_err(LearnError::Transport)?
347 .ok_or_else(|| {
348 LearnError::BadResponse(
349 "the model produced no curated ontology; curation needs structured output".into(),
350 )
351 })?;
352
353 let facets = v.get("entity_facets").and_then(|f| f.as_array()).cloned().unwrap_or_default();
354 let candidates: Vec<Candidate> = facets
355 .iter()
356 .filter_map(|f| {
357 let name = crate::projector::slug(f.get("name")?.as_str()?);
358 if name.is_empty() {
359 return None;
360 }
361 let words: Vec<String> = f
362 .get("examples")
363 .and_then(|e| e.as_array())
364 .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
365 .unwrap_or_default();
366 let rationale =
367 f.get("description").and_then(|d| d.as_str()).unwrap_or("").to_string();
368 Some(Candidate { name, words, rationale })
369 })
370 .collect();
371 Ok(Proposal { source: label, candidates })
372 }
373
374 #[cfg(all(feature = "onnx", feature = "embed", any(feature = "paddock", feature = "bedrock")))]
376 fn provider_config(&self) -> Result<crate::agent::config::ProviderConfig, LearnError> {
377 match &self.kind {
378 #[cfg(feature = "paddock")]
379 Kind::Local { base_url, model } => Ok(crate::agent::config::ProviderConfig::Paddock {
380 base_url: base_url.clone(),
381 model: model.clone(),
382 api_key: None,
383 }),
384 #[cfg(feature = "bedrock")]
385 Kind::Bedrock { model_id } => Ok(crate::agent::config::ProviderConfig::Bedrock {
386 model_id: model_id.clone(),
387 region: std::env::var("AWS_REGION").ok(),
388 }),
389 Kind::Fixed(_) => Err(LearnError::NotConfigured(
390 "a fixed teacher has no model to curate with".into(),
391 )),
392 }
393 }
394}
395
396#[cfg(all(feature = "onnx", feature = "embed", any(feature = "paddock", feature = "bedrock")))]
398const CURATE_SYSTEM: &str = "You curate a DISCOVERED ontology into a clean, MECE facet schema for a \
399directed-hypergraph bitset index. You get raw ENTITY-VALUE clusters (each a name plus example terms) and \
400RELATION clusters.\n\
401\n\
402Every fact is stored as a PATH: `facet/value`. So the facet name is the KIND and the cluster terms are its \
403VALUES. Before you accept a name, write the path out and read it:\n\
404 network-generation/6g GOOD - a kind, then one of its values\n\
405 6g/6g WRONG - that is a value naming itself\n\
406 cost-attribute/low-cost GOOD\n\
407 cost-effective/low-cost WRONG - `cost-effective` is a value of some attribute\n\
408 platform/uav GOOD\n\
409If the name you chose could itself appear on the RIGHT of the slash, it is a value: abstract it up to the kind it \
410belongs to and use that instead. This is the single most common mistake — fix it before answering.\n\
411\n\
412Rules:\n\
413- A facet name is a NOUN naming a kind of thing (platform, sensing-modality, vehicle-type, application-domain). \
414Never a verb (translate, deliver), never an adjective (cost-effective, high-speed), never a bare instance (6g, \
415new-south, monash).\n\
416- MERGE synonymous or overlapping clusters into one type: optical/thermal/quantum becomes sensing-modality; \
417payload/uav becomes platform. Set `examples` to the raw cluster terms the type absorbs, copied verbatim.\n\
418- MUTUALLY EXCLUSIVE: each raw cluster belongs to exactly ONE facet. If two of your facets could both claim a \
419cluster, they are the same facet — merge them. Do not emit both a general and a narrower version of the same \
420kind.\n\
421- COLLECTIVELY EXHAUSTIVE: every raw cluster is either absorbed by a facet or listed in `dropped`. Nothing is \
422left unaccounted for.\n\
423- DROP clusters that are boilerplate, noise, or too generic to be a facet: statement, benefits, project, \
424ultimately, consensus, outcomes. A facet that would match nearly every document discriminates nothing.\n\
425- Prefer FEWER, broader facets. Six well-separated kinds beat twelve overlapping ones.\n\
426\n\
427Be decisive. Do not invent content that is not present in the clusters.";
428
429#[cfg(all(feature = "onnx", feature = "embed", any(feature = "paddock", feature = "bedrock")))]
433fn curation_schema() -> serde_json::Value {
434 serde_json::json!({
435 "type": "object",
436 "additionalProperties": false,
437 "properties": {
438 "entity_facets": { "type": "array", "minItems": 1, "maxItems": 8,
443 "items": { "type": "object", "additionalProperties": false, "properties": {
444 "name": {"type": "string"},
445 "description": {"type": "string"},
446 "examples": {"type": "array", "maxItems": 6, "items": {"type": "string"}}
447 }, "required": ["name", "description", "examples"] } },
448 "relation_facets": { "type": "array", "maxItems": 8,
449 "items": { "type": "object", "additionalProperties": false, "properties": {
450 "name": {"type": "string"}, "head": {"type": "string"}, "tail": {"type": "string"}
451 }, "required": ["name", "head", "tail"] } },
452 "dropped": { "type": "array", "maxItems": 24, "items": {"type": "string"} }
453 },
454 "required": ["entity_facets", "dropped"]
455 })
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 fn docs() -> Vec<String> {
463 [
464 "Morty Shade defeated Wallace Gale at Ecruteak City during the Indigo Invitational in 2025.",
465 "Bea Strike defeated Iris Draco at Ecruteak City during the Indigo Invitational in 2025.",
466 "A habitat survey recorded Aggron near Sootopolis City at an elevation of 1082 m.",
467 "A habitat survey recorded Salamence near Sootopolis City at an elevation of 2369 m.",
468 "Milotic is not permitted in Series 1 play for the 2025 season.",
469 ]
470 .iter()
471 .map(|s| s.to_string())
472 .collect()
473 }
474
475 #[test]
476 fn a_proposal_changes_nothing_until_adopted() {
477 let db = SteelDb::ingest(docs()).unwrap();
478 let before = db.categories().len();
479 let _p = Proposal {
480 source: "test".into(),
481 candidates: vec![Candidate {
482 name: "trainer".into(),
483 words: vec!["defeated".into(), "Shade".into()],
484 rationale: "people who compete".into(),
485 }],
486 };
487 assert_eq!(db.categories().len(), before);
489 }
490
491 #[test]
492 fn a_fixed_teacher_needs_no_credentials() {
493 let db = SteelDb::ingest(docs()).unwrap();
496 let p = Proposal {
497 source: "fixed".into(),
498 candidates: vec![Candidate {
499 name: "ruling".into(),
500 words: vec!["permitted".into(), "Series".into(), "season".into()],
501 rationale: "competition rules".into(),
502 }],
503 };
504 let teacher = Teacher::fixed(p.clone());
505 let got = block_on(teacher.propose_categories(&db)).unwrap();
506 assert_eq!(got.candidates, p.candidates);
507 }
508
509 fn block_on<F: std::future::Future>(mut fut: F) -> F::Output {
511 use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
512 fn noop(_: *const ()) {}
513 fn clone(p: *const ()) -> RawWaker {
514 RawWaker::new(p, &VTABLE)
515 }
516 static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
517 let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
518 let mut cx = Context::from_waker(&waker);
519 let mut fut = unsafe { std::pin::Pin::new_unchecked(&mut fut) };
520 loop {
521 match fut.as_mut().poll(&mut cx) {
522 Poll::Ready(v) => return v,
523 Poll::Pending => panic!("the fixed teacher must not yield"),
524 }
525 }
526 }
527
528 #[test]
529 fn adoption_reports_a_verdict_per_candidate_and_can_reject() {
530 let mut db = SteelDb::ingest(docs()).unwrap();
531 let proposal = Proposal {
532 source: "test".into(),
533 candidates: vec![
534 Candidate {
535 name: "ruling".into(),
536 words: vec!["permitted".into(), "Series".into()],
537 rationale: "rules".into(),
538 },
539 Candidate {
541 name: "ruling2".into(),
542 words: vec!["permitted".into(), "Series".into()],
543 rationale: "the same thing again".into(),
544 },
545 ],
546 };
547 let verdicts = db.adopt(&proposal);
548 assert_eq!(verdicts.len(), 2, "one verdict per candidate");
549 for v in &verdicts {
550 assert!(!v.reason.is_empty(), "a rejection must be explicable: {v:?}");
551 }
552 assert!(
554 !verdicts[1].kept || verdicts[1].overlap < 0.99,
555 "an exact duplicate should not be adopted unexamined: {:?}",
556 verdicts[1]
557 );
558 }
559
560 #[test]
561 fn an_adopted_category_becomes_queryable() {
562 let mut db = SteelDb::ingest(docs()).unwrap();
563 let proposal = Proposal {
564 source: "test".into(),
565 candidates: vec![Candidate {
566 name: "ruling".into(),
567 words: vec!["permitted".into(), "season".into(), "Series".into()],
568 rationale: "rules".into(),
569 }],
570 };
571 let verdicts = db.adopt(&proposal);
572 if verdicts[0].kept {
573 let answer = db.query("ruling/*").expect("an adopted category must be queryable");
574 assert!(!answer.is_empty(), "and must actually match documents");
575 }
576 }
577
578 #[test]
579 fn proposals_display_for_review_before_adoption() {
580 let p = Proposal {
581 source: "bedrock:test".into(),
582 candidates: vec![Candidate {
583 name: "trainer".into(),
584 words: vec!["defeated".into()],
585 rationale: "competitors".into(),
586 }],
587 };
588 let shown = p.to_string();
589 assert!(shown.contains("bedrock:test"), "{shown}");
590 assert!(shown.contains("trainer"), "{shown}");
591 assert!(shown.contains("competitors"), "the rationale must be reviewable: {shown}");
592 }
593
594 #[test]
595 fn an_adopted_category_survives_into_an_artefact_and_is_followed_on_reload() {
596 let docs = docs();
600 let mut db = SteelDb::ingest(docs.clone()).unwrap();
601 let before: Vec<String> = db.categories().iter().map(|c| c.name.to_string()).collect();
602
603 let proposal = Proposal {
604 source: "test".into(),
605 candidates: vec![Candidate {
606 name: "ruling".into(),
607 words: vec!["permitted".into(), "season".into(), "Series".into()],
608 rationale: "competition rules".into(),
609 }],
610 };
611 let verdicts = db.adopt(&proposal);
612 if !verdicts[0].kept {
613 return;
615 }
616 assert!(
617 !before.contains(&"ruling".to_string()) && db.askable().contains(&"ruling/*".to_string()),
618 "adoption should have added the category"
619 );
620 let expected = db.query("ruling/*").expect("adopted category must be queryable").len();
621
622 let dir = std::env::temp_dir().join(format!("hsdb_learn_artifact_{}", std::process::id()));
623 let _ = std::fs::remove_dir_all(&dir);
624 db.save(&dir).expect("save");
625
626 let reloaded = SteelDb::ingest_using(docs, &dir).expect("reload");
628 assert!(
629 reloaded.askable().contains(&"ruling/*".to_string()),
630 "the adopted category must come back: {:?}",
631 reloaded.askable()
632 );
633 assert_eq!(
634 reloaded.query("ruling/*").expect("still queryable").len(),
635 expected,
636 "and answer identically without the teacher"
637 );
638 assert_eq!(db.tags(), reloaded.tags(), "tag for tag");
639 let _ = std::fs::remove_dir_all(&dir);
640 }
641}