use crate::api::SteelDb;
#[derive(Debug, Clone, PartialEq)]
pub struct Candidate {
pub name: String,
pub words: Vec<String>,
pub rationale: String,
}
#[derive(Debug, Clone, Default)]
pub struct Proposal {
pub candidates: Vec<Candidate>,
pub source: String,
}
impl std::fmt::Display for Proposal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "proposal from {} — {} candidate(s)", self.source, self.candidates.len())?;
for c in &self.candidates {
writeln!(f, " {} — {}", c.name, c.rationale)?;
writeln!(f, " words: {}", c.words.join(", "))?;
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct Verdict {
pub name: String,
pub kept: bool,
pub reason: String,
pub coverage: f64,
pub overlap: f64,
}
impl SteelDb {
pub fn adopt(&mut self, proposal: &Proposal) -> Vec<Verdict> {
let mut verdicts = Vec::new();
for (round, cand) in proposal.candidates.iter().enumerate() {
let c = crate::grow::Candidate {
name: cand.name.clone(),
parent: None,
description: cand.rationale.clone(),
examples: cand.words.clone(),
worth_adding: true,
};
let docs = self.documents().to_vec();
let spec = self.spec_snapshot();
let scored = crate::grow::score_candidate_full(&spec, &docs, &c);
let (score, dup) = match scored {
Some((s, d)) => (Some(s), d),
None => (None, None),
};
let ev = crate::grow::gate_full(&spec, &c, score.as_ref(), dup, self.min_gain(), round);
if ev.kept {
self.push_category(cand.name.clone(), cand.words.clone());
}
verdicts.push(Verdict {
name: cand.name.clone(),
kept: ev.kept,
reason: ev.reason,
coverage: ev.coverage,
overlap: ev.maxcos,
});
}
if verdicts.iter().any(|v| v.kept) {
self.reproject();
}
verdicts
}
}
pub struct Teacher {
kind: Kind,
}
enum Kind {
Fixed(Proposal),
#[cfg(feature = "paddock")]
Local { base_url: String, model: String },
#[cfg(feature = "bedrock")]
Bedrock { model_id: String },
}
#[derive(Debug)]
pub enum LearnError {
NotConfigured(String),
BadResponse(String),
Transport(String),
}
impl std::fmt::Display for LearnError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LearnError::NotConfigured(m) => write!(f, "not configured for learning: {m}"),
LearnError::BadResponse(m) => write!(f, "unusable response: {m}"),
LearnError::Transport(m) => write!(f, "call failed: {m}"),
}
}
}
impl std::error::Error for LearnError {}
impl Teacher {
pub fn fixed(proposal: Proposal) -> Teacher {
Teacher { kind: Kind::Fixed(proposal) }
}
#[cfg(feature = "paddock")]
pub fn local(base_url: impl Into<String>, model: impl Into<String>) -> Result<Teacher, LearnError> {
let base_url = base_url.into();
if !base_url.starts_with("http") {
return Err(LearnError::NotConfigured(format!(
"base_url should be an http(s) endpoint, got {base_url:?}"
)));
}
Ok(Teacher { kind: Kind::Local { base_url, model: model.into() } })
}
#[cfg(feature = "paddock")]
pub fn ollama(model: impl Into<String>) -> Result<Teacher, LearnError> {
Teacher::local("http://localhost:11434/v1", model)
}
#[cfg(feature = "bedrock")]
pub fn bedrock(model_id: impl Into<String>) -> Result<Teacher, LearnError> {
if std::env::var("AWS_REGION").is_err() && std::env::var("AWS_DEFAULT_REGION").is_err() {
return Err(LearnError::NotConfigured(
"set AWS_REGION (or AWS_DEFAULT_REGION) to the region hosting the model".into(),
));
}
Ok(Teacher { kind: Kind::Bedrock { model_id: model_id.into() } })
}
pub async fn propose_categories(&self, db: &SteelDb) -> Result<Proposal, LearnError> {
let _ = db;
match &self.kind {
Kind::Fixed(p) => Ok(p.clone()),
#[cfg(feature = "paddock")]
Kind::Local { base_url, model } => {
let cfg = crate::agent::config::ProviderConfig::Paddock {
base_url: base_url.clone(),
model: model.clone(),
api_key: None,
};
Self::propose_via(cfg, db).await
}
#[cfg(feature = "bedrock")]
Kind::Bedrock { model_id } => {
let cfg = crate::agent::config::ProviderConfig::Bedrock {
model_id: model_id.clone(),
region: std::env::var("AWS_REGION").ok(),
};
Self::propose_via(cfg, db).await
}
}
}
#[cfg(any(feature = "paddock", feature = "bedrock"))]
async fn propose_via(
cfg: crate::agent::config::ProviderConfig,
db: &SteelDb,
) -> Result<Proposal, LearnError> {
let label = match &cfg {
#[cfg(feature = "paddock")]
crate::agent::config::ProviderConfig::Paddock { model, .. } => format!("local:{model}"),
#[cfg(feature = "bedrock")]
crate::agent::config::ProviderConfig::Bedrock { model_id, .. } => format!("bedrock:{model_id}"),
_ => "model".to_string(),
};
let provider = cfg.build().await.map_err(LearnError::Transport)?;
let sample: Vec<String> = db.documents().iter().take(48).cloned().collect();
let spec = crate::vocabulary::propose(provider.as_ref(), "documents", &sample)
.await
.map_err(LearnError::BadResponse)?;
Ok(Proposal {
source: label,
candidates: spec
.entity_facets
.into_iter()
.map(|f| Candidate { name: f.name, words: f.examples, rationale: f.description })
.collect(),
})
}
#[cfg(all(feature = "onnx", feature = "embed", any(feature = "paddock", feature = "bedrock")))]
pub async fn curate(
&self,
raw: &crate::tagger_discover::RawSpec,
) -> Result<Proposal, LearnError> {
use crate::agent::types::Msg;
let cfg = self.provider_config()?;
let label = match &cfg {
#[cfg(feature = "paddock")]
crate::agent::config::ProviderConfig::Paddock { model, .. } => format!("curate:local:{model}"),
#[cfg(feature = "bedrock")]
crate::agent::config::ProviderConfig::Bedrock { model_id, .. } => format!("curate:bedrock:{model_id}"),
_ => "curate".to_string(),
};
let provider = cfg.build().await.map_err(LearnError::Transport)?;
let ents = raw
.entity_clusters
.iter()
.map(|c| format!("{}: {}", c.label, c.terms.join(", ")))
.collect::<Vec<_>>()
.join("\n");
let rels = raw
.relation_clusters
.iter()
.map(|c| format!("{}: {}", c.label, c.terms.join(", ")))
.collect::<Vec<_>>()
.join("\n");
let prompt = format!("RAW ENTITY-VALUE CLUSTERS:\n{ents}\n\nRAW RELATION CLUSTERS:\n{rels}");
let schema = curation_schema();
let v = provider
.chat_json(CURATE_SYSTEM, &[Msg::user_text(prompt)], &schema, "curate")
.await
.map_err(LearnError::Transport)?
.ok_or_else(|| {
LearnError::BadResponse(
"the model produced no curated ontology; curation needs structured output".into(),
)
})?;
let facets = v.get("entity_facets").and_then(|f| f.as_array()).cloned().unwrap_or_default();
let candidates: Vec<Candidate> = facets
.iter()
.filter_map(|f| {
let name = crate::projector::slug(f.get("name")?.as_str()?);
if name.is_empty() {
return None;
}
let words: Vec<String> = f
.get("examples")
.and_then(|e| e.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
.unwrap_or_default();
let rationale =
f.get("description").and_then(|d| d.as_str()).unwrap_or("").to_string();
Some(Candidate { name, words, rationale })
})
.collect();
Ok(Proposal { source: label, candidates })
}
#[cfg(all(feature = "onnx", feature = "embed", any(feature = "paddock", feature = "bedrock")))]
fn provider_config(&self) -> Result<crate::agent::config::ProviderConfig, LearnError> {
match &self.kind {
#[cfg(feature = "paddock")]
Kind::Local { base_url, model } => Ok(crate::agent::config::ProviderConfig::Paddock {
base_url: base_url.clone(),
model: model.clone(),
api_key: None,
}),
#[cfg(feature = "bedrock")]
Kind::Bedrock { model_id } => Ok(crate::agent::config::ProviderConfig::Bedrock {
model_id: model_id.clone(),
region: std::env::var("AWS_REGION").ok(),
}),
Kind::Fixed(_) => Err(LearnError::NotConfigured(
"a fixed teacher has no model to curate with".into(),
)),
}
}
}
#[cfg(all(feature = "onnx", feature = "embed", any(feature = "paddock", feature = "bedrock")))]
const CURATE_SYSTEM: &str = "You curate a DISCOVERED ontology into a clean, MECE facet schema for a \
directed-hypergraph bitset index. You get raw ENTITY-VALUE clusters (each a name plus example terms) and \
RELATION clusters.\n\
\n\
Every fact is stored as a PATH: `facet/value`. So the facet name is the KIND and the cluster terms are its \
VALUES. Before you accept a name, write the path out and read it:\n\
network-generation/6g GOOD - a kind, then one of its values\n\
6g/6g WRONG - that is a value naming itself\n\
cost-attribute/low-cost GOOD\n\
cost-effective/low-cost WRONG - `cost-effective` is a value of some attribute\n\
platform/uav GOOD\n\
If the name you chose could itself appear on the RIGHT of the slash, it is a value: abstract it up to the kind it \
belongs to and use that instead. This is the single most common mistake — fix it before answering.\n\
\n\
Rules:\n\
- A facet name is a NOUN naming a kind of thing (platform, sensing-modality, vehicle-type, application-domain). \
Never a verb (translate, deliver), never an adjective (cost-effective, high-speed), never a bare instance (6g, \
new-south, monash).\n\
- MERGE synonymous or overlapping clusters into one type: optical/thermal/quantum becomes sensing-modality; \
payload/uav becomes platform. Set `examples` to the raw cluster terms the type absorbs, copied verbatim.\n\
- MUTUALLY EXCLUSIVE: each raw cluster belongs to exactly ONE facet. If two of your facets could both claim a \
cluster, they are the same facet — merge them. Do not emit both a general and a narrower version of the same \
kind.\n\
- COLLECTIVELY EXHAUSTIVE: every raw cluster is either absorbed by a facet or listed in `dropped`. Nothing is \
left unaccounted for.\n\
- DROP clusters that are boilerplate, noise, or too generic to be a facet: statement, benefits, project, \
ultimately, consensus, outcomes. A facet that would match nearly every document discriminates nothing.\n\
- Prefer FEWER, broader facets. Six well-separated kinds beat twelve overlapping ones.\n\
\n\
Be decisive. Do not invent content that is not present in the clusters.";
#[cfg(all(feature = "onnx", feature = "embed", any(feature = "paddock", feature = "bedrock")))]
fn curation_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"entity_facets": { "type": "array", "minItems": 1, "maxItems": 8,
"items": { "type": "object", "additionalProperties": false, "properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"examples": {"type": "array", "maxItems": 6, "items": {"type": "string"}}
}, "required": ["name", "description", "examples"] } },
"relation_facets": { "type": "array", "maxItems": 8,
"items": { "type": "object", "additionalProperties": false, "properties": {
"name": {"type": "string"}, "head": {"type": "string"}, "tail": {"type": "string"}
}, "required": ["name", "head", "tail"] } },
"dropped": { "type": "array", "maxItems": 24, "items": {"type": "string"} }
},
"required": ["entity_facets", "dropped"]
})
}
#[cfg(test)]
mod tests {
use super::*;
fn docs() -> Vec<String> {
[
"Morty Shade defeated Wallace Gale at Ecruteak City during the Indigo Invitational in 2025.",
"Bea Strike defeated Iris Draco at Ecruteak City during the Indigo Invitational in 2025.",
"A habitat survey recorded Aggron near Sootopolis City at an elevation of 1082 m.",
"A habitat survey recorded Salamence near Sootopolis City at an elevation of 2369 m.",
"Milotic is not permitted in Series 1 play for the 2025 season.",
]
.iter()
.map(|s| s.to_string())
.collect()
}
#[test]
fn a_proposal_changes_nothing_until_adopted() {
let db = SteelDb::ingest(docs()).unwrap();
let before = db.categories().len();
let _p = Proposal {
source: "test".into(),
candidates: vec![Candidate {
name: "trainer".into(),
words: vec!["defeated".into(), "Shade".into()],
rationale: "people who compete".into(),
}],
};
assert_eq!(db.categories().len(), before);
}
#[test]
fn a_fixed_teacher_needs_no_credentials() {
let db = SteelDb::ingest(docs()).unwrap();
let p = Proposal {
source: "fixed".into(),
candidates: vec![Candidate {
name: "ruling".into(),
words: vec!["permitted".into(), "Series".into(), "season".into()],
rationale: "competition rules".into(),
}],
};
let teacher = Teacher::fixed(p.clone());
let got = block_on(teacher.propose_categories(&db)).unwrap();
assert_eq!(got.candidates, p.candidates);
}
fn block_on<F: std::future::Future>(mut fut: F) -> F::Output {
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
fn noop(_: *const ()) {}
fn clone(p: *const ()) -> RawWaker {
RawWaker::new(p, &VTABLE)
}
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
let mut cx = Context::from_waker(&waker);
let mut fut = unsafe { std::pin::Pin::new_unchecked(&mut fut) };
loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(v) => return v,
Poll::Pending => panic!("the fixed teacher must not yield"),
}
}
}
#[test]
fn adoption_reports_a_verdict_per_candidate_and_can_reject() {
let mut db = SteelDb::ingest(docs()).unwrap();
let proposal = Proposal {
source: "test".into(),
candidates: vec![
Candidate {
name: "ruling".into(),
words: vec!["permitted".into(), "Series".into()],
rationale: "rules".into(),
},
Candidate {
name: "ruling2".into(),
words: vec!["permitted".into(), "Series".into()],
rationale: "the same thing again".into(),
},
],
};
let verdicts = db.adopt(&proposal);
assert_eq!(verdicts.len(), 2, "one verdict per candidate");
for v in &verdicts {
assert!(!v.reason.is_empty(), "a rejection must be explicable: {v:?}");
}
assert!(
!verdicts[1].kept || verdicts[1].overlap < 0.99,
"an exact duplicate should not be adopted unexamined: {:?}",
verdicts[1]
);
}
#[test]
fn an_adopted_category_becomes_queryable() {
let mut db = SteelDb::ingest(docs()).unwrap();
let proposal = Proposal {
source: "test".into(),
candidates: vec![Candidate {
name: "ruling".into(),
words: vec!["permitted".into(), "season".into(), "Series".into()],
rationale: "rules".into(),
}],
};
let verdicts = db.adopt(&proposal);
if verdicts[0].kept {
let answer = db.query("ruling/*").expect("an adopted category must be queryable");
assert!(!answer.is_empty(), "and must actually match documents");
}
}
#[test]
fn proposals_display_for_review_before_adoption() {
let p = Proposal {
source: "bedrock:test".into(),
candidates: vec![Candidate {
name: "trainer".into(),
words: vec!["defeated".into()],
rationale: "competitors".into(),
}],
};
let shown = p.to_string();
assert!(shown.contains("bedrock:test"), "{shown}");
assert!(shown.contains("trainer"), "{shown}");
assert!(shown.contains("competitors"), "the rationale must be reviewable: {shown}");
}
#[test]
fn an_adopted_category_survives_into_an_artefact_and_is_followed_on_reload() {
let docs = docs();
let mut db = SteelDb::ingest(docs.clone()).unwrap();
let before: Vec<String> = db.categories().iter().map(|c| c.name.to_string()).collect();
let proposal = Proposal {
source: "test".into(),
candidates: vec![Candidate {
name: "ruling".into(),
words: vec!["permitted".into(), "season".into(), "Series".into()],
rationale: "competition rules".into(),
}],
};
let verdicts = db.adopt(&proposal);
if !verdicts[0].kept {
return;
}
assert!(
!before.contains(&"ruling".to_string()) && db.askable().contains(&"ruling/*".to_string()),
"adoption should have added the category"
);
let expected = db.query("ruling/*").expect("adopted category must be queryable").len();
let dir = std::env::temp_dir().join(format!("hsdb_learn_artifact_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
db.save(&dir).expect("save");
let reloaded = SteelDb::ingest_using(docs, &dir).expect("reload");
assert!(
reloaded.askable().contains(&"ruling/*".to_string()),
"the adopted category must come back: {:?}",
reloaded.askable()
);
assert_eq!(
reloaded.query("ruling/*").expect("still queryable").len(),
expected,
"and answer identically without the teacher"
);
assert_eq!(db.tags(), reloaded.tags(), "tag for tag");
let _ = std::fs::remove_dir_all(&dir);
}
}