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
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 fn docs() -> Vec<String> {
300 [
301 "Morty Shade defeated Wallace Gale at Ecruteak City during the Indigo Invitational in 2025.",
302 "Bea Strike defeated Iris Draco at Ecruteak City during the Indigo Invitational in 2025.",
303 "A habitat survey recorded Aggron near Sootopolis City at an elevation of 1082 m.",
304 "A habitat survey recorded Salamence near Sootopolis City at an elevation of 2369 m.",
305 "Milotic is not permitted in Series 1 play for the 2025 season.",
306 ]
307 .iter()
308 .map(|s| s.to_string())
309 .collect()
310 }
311
312 #[test]
313 fn a_proposal_changes_nothing_until_adopted() {
314 let db = SteelDb::ingest(docs()).unwrap();
315 let before = db.categories().len();
316 let _p = Proposal {
317 source: "test".into(),
318 candidates: vec![Candidate {
319 name: "trainer".into(),
320 words: vec!["defeated".into(), "Shade".into()],
321 rationale: "people who compete".into(),
322 }],
323 };
324 assert_eq!(db.categories().len(), before);
326 }
327
328 #[test]
329 fn a_fixed_teacher_needs_no_credentials() {
330 let db = SteelDb::ingest(docs()).unwrap();
333 let p = Proposal {
334 source: "fixed".into(),
335 candidates: vec![Candidate {
336 name: "ruling".into(),
337 words: vec!["permitted".into(), "Series".into(), "season".into()],
338 rationale: "competition rules".into(),
339 }],
340 };
341 let teacher = Teacher::fixed(p.clone());
342 let got = block_on(teacher.propose_categories(&db)).unwrap();
343 assert_eq!(got.candidates, p.candidates);
344 }
345
346 fn block_on<F: std::future::Future>(mut fut: F) -> F::Output {
348 use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
349 fn noop(_: *const ()) {}
350 fn clone(p: *const ()) -> RawWaker {
351 RawWaker::new(p, &VTABLE)
352 }
353 static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
354 let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
355 let mut cx = Context::from_waker(&waker);
356 let mut fut = unsafe { std::pin::Pin::new_unchecked(&mut fut) };
357 loop {
358 match fut.as_mut().poll(&mut cx) {
359 Poll::Ready(v) => return v,
360 Poll::Pending => panic!("the fixed teacher must not yield"),
361 }
362 }
363 }
364
365 #[test]
366 fn adoption_reports_a_verdict_per_candidate_and_can_reject() {
367 let mut db = SteelDb::ingest(docs()).unwrap();
368 let proposal = Proposal {
369 source: "test".into(),
370 candidates: vec![
371 Candidate {
372 name: "ruling".into(),
373 words: vec!["permitted".into(), "Series".into()],
374 rationale: "rules".into(),
375 },
376 Candidate {
378 name: "ruling2".into(),
379 words: vec!["permitted".into(), "Series".into()],
380 rationale: "the same thing again".into(),
381 },
382 ],
383 };
384 let verdicts = db.adopt(&proposal);
385 assert_eq!(verdicts.len(), 2, "one verdict per candidate");
386 for v in &verdicts {
387 assert!(!v.reason.is_empty(), "a rejection must be explicable: {v:?}");
388 }
389 assert!(
391 !verdicts[1].kept || verdicts[1].overlap < 0.99,
392 "an exact duplicate should not be adopted unexamined: {:?}",
393 verdicts[1]
394 );
395 }
396
397 #[test]
398 fn an_adopted_category_becomes_queryable() {
399 let mut db = SteelDb::ingest(docs()).unwrap();
400 let proposal = Proposal {
401 source: "test".into(),
402 candidates: vec![Candidate {
403 name: "ruling".into(),
404 words: vec!["permitted".into(), "season".into(), "Series".into()],
405 rationale: "rules".into(),
406 }],
407 };
408 let verdicts = db.adopt(&proposal);
409 if verdicts[0].kept {
410 let answer = db.query("ruling/*").expect("an adopted category must be queryable");
411 assert!(!answer.is_empty(), "and must actually match documents");
412 }
413 }
414
415 #[test]
416 fn proposals_display_for_review_before_adoption() {
417 let p = Proposal {
418 source: "bedrock:test".into(),
419 candidates: vec![Candidate {
420 name: "trainer".into(),
421 words: vec!["defeated".into()],
422 rationale: "competitors".into(),
423 }],
424 };
425 let shown = p.to_string();
426 assert!(shown.contains("bedrock:test"), "{shown}");
427 assert!(shown.contains("trainer"), "{shown}");
428 assert!(shown.contains("competitors"), "the rationale must be reviewable: {shown}");
429 }
430
431 #[test]
432 fn an_adopted_category_survives_into_an_artefact_and_is_followed_on_reload() {
433 let docs = docs();
437 let mut db = SteelDb::ingest(docs.clone()).unwrap();
438 let before: Vec<String> = db.categories().iter().map(|c| c.name.to_string()).collect();
439
440 let proposal = Proposal {
441 source: "test".into(),
442 candidates: vec![Candidate {
443 name: "ruling".into(),
444 words: vec!["permitted".into(), "season".into(), "Series".into()],
445 rationale: "competition rules".into(),
446 }],
447 };
448 let verdicts = db.adopt(&proposal);
449 if !verdicts[0].kept {
450 return;
452 }
453 assert!(
454 !before.contains(&"ruling".to_string()) && db.askable().contains(&"ruling/*".to_string()),
455 "adoption should have added the category"
456 );
457 let expected = db.query("ruling/*").expect("adopted category must be queryable").len();
458
459 let dir = std::env::temp_dir().join(format!("hsdb_learn_artifact_{}", std::process::id()));
460 let _ = std::fs::remove_dir_all(&dir);
461 db.save(&dir).expect("save");
462
463 let reloaded = SteelDb::ingest_using(docs, &dir).expect("reload");
465 assert!(
466 reloaded.askable().contains(&"ruling/*".to_string()),
467 "the adopted category must come back: {:?}",
468 reloaded.askable()
469 );
470 assert_eq!(
471 reloaded.query("ruling/*").expect("still queryable").len(),
472 expected,
473 "and answer identically without the teacher"
474 );
475 assert_eq!(db.tags(), reloaded.tags(), "tag for tag");
476 let _ = std::fs::remove_dir_all(&dir);
477 }
478}