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")]
195 pub fn local(base_url: impl Into<String>, model: impl Into<String>) -> Result<Teacher, LearnError> {
196 let base_url = base_url.into();
197 if !base_url.starts_with("http") {
198 return Err(LearnError::NotConfigured(format!(
199 "base_url should be an http(s) endpoint, got {base_url:?}"
200 )));
201 }
202 Ok(Teacher { kind: Kind::Local { base_url, model: model.into() } })
203 }
204
205 #[cfg(feature = "paddock")]
209 pub fn ollama(model: impl Into<String>) -> Result<Teacher, LearnError> {
210 Teacher::local("http://localhost:11434/v1", model)
211 }
212
213 #[cfg(feature = "bedrock")]
221 pub fn bedrock(model_id: impl Into<String>) -> Result<Teacher, LearnError> {
222 if std::env::var("AWS_REGION").is_err() && std::env::var("AWS_DEFAULT_REGION").is_err() {
223 return Err(LearnError::NotConfigured(
224 "set AWS_REGION (or AWS_DEFAULT_REGION) to the region hosting the model".into(),
225 ));
226 }
227 Ok(Teacher { kind: Kind::Bedrock { model_id: model_id.into() } })
228 }
229
230 pub async fn propose_categories(&self, db: &SteelDb) -> Result<Proposal, LearnError> {
234 let _ = db;
236 match &self.kind {
237 Kind::Fixed(p) => Ok(p.clone()),
238 #[cfg(feature = "paddock")]
239 Kind::Local { base_url, model } => {
240 let cfg = crate::agent::config::ProviderConfig::Paddock {
241 base_url: base_url.clone(),
242 model: model.clone(),
243 api_key: None,
244 };
245 Self::propose_via(cfg, db).await
246 }
247 #[cfg(feature = "bedrock")]
248 Kind::Bedrock { model_id } => {
249 let cfg = crate::agent::config::ProviderConfig::Bedrock {
250 model_id: model_id.clone(),
251 region: std::env::var("AWS_REGION").ok(),
252 };
253 Self::propose_via(cfg, db).await
254 }
255 }
256 }
257
258 #[cfg(any(feature = "paddock", feature = "bedrock"))]
261 async fn propose_via(
262 cfg: crate::agent::config::ProviderConfig,
263 db: &SteelDb,
264 ) -> Result<Proposal, LearnError> {
265 let label = match &cfg {
267 #[cfg(feature = "paddock")]
268 crate::agent::config::ProviderConfig::Paddock { model, .. } => format!("local:{model}"),
269 #[cfg(feature = "bedrock")]
270 crate::agent::config::ProviderConfig::Bedrock { model_id, .. } => format!("bedrock:{model_id}"),
271 _ => "model".to_string(),
272 };
273 let provider = cfg.build().await.map_err(LearnError::Transport)?;
274 let sample: Vec<String> = db.documents().iter().take(48).cloned().collect();
275 let spec = crate::vocabulary::propose(provider.as_ref(), "documents", &sample)
276 .await
277 .map_err(LearnError::BadResponse)?;
278 Ok(Proposal {
279 source: label,
280 candidates: spec
281 .entity_facets
282 .into_iter()
283 .map(|f| Candidate { name: f.name, words: f.examples, rationale: f.description })
284 .collect(),
285 })
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 fn docs() -> Vec<String> {
294 [
295 "Morty Shade defeated Wallace Gale at Ecruteak City during the Indigo Invitational in 2025.",
296 "Bea Strike defeated Iris Draco at Ecruteak City during the Indigo Invitational in 2025.",
297 "A habitat survey recorded Aggron near Sootopolis City at an elevation of 1082 m.",
298 "A habitat survey recorded Salamence near Sootopolis City at an elevation of 2369 m.",
299 "Milotic is not permitted in Series 1 play for the 2025 season.",
300 ]
301 .iter()
302 .map(|s| s.to_string())
303 .collect()
304 }
305
306 #[test]
307 fn a_proposal_changes_nothing_until_adopted() {
308 let db = SteelDb::ingest(docs()).unwrap();
309 let before = db.categories().len();
310 let _p = Proposal {
311 source: "test".into(),
312 candidates: vec![Candidate {
313 name: "trainer".into(),
314 words: vec!["defeated".into(), "Shade".into()],
315 rationale: "people who compete".into(),
316 }],
317 };
318 assert_eq!(db.categories().len(), before);
320 }
321
322 #[test]
323 fn a_fixed_teacher_needs_no_credentials() {
324 let db = SteelDb::ingest(docs()).unwrap();
327 let p = Proposal {
328 source: "fixed".into(),
329 candidates: vec![Candidate {
330 name: "ruling".into(),
331 words: vec!["permitted".into(), "Series".into(), "season".into()],
332 rationale: "competition rules".into(),
333 }],
334 };
335 let teacher = Teacher::fixed(p.clone());
336 let got = block_on(teacher.propose_categories(&db)).unwrap();
337 assert_eq!(got.candidates, p.candidates);
338 }
339
340 fn block_on<F: std::future::Future>(mut fut: F) -> F::Output {
342 use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
343 fn noop(_: *const ()) {}
344 fn clone(p: *const ()) -> RawWaker {
345 RawWaker::new(p, &VTABLE)
346 }
347 static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
348 let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
349 let mut cx = Context::from_waker(&waker);
350 let mut fut = unsafe { std::pin::Pin::new_unchecked(&mut fut) };
351 loop {
352 match fut.as_mut().poll(&mut cx) {
353 Poll::Ready(v) => return v,
354 Poll::Pending => panic!("the fixed teacher must not yield"),
355 }
356 }
357 }
358
359 #[test]
360 fn adoption_reports_a_verdict_per_candidate_and_can_reject() {
361 let mut db = SteelDb::ingest(docs()).unwrap();
362 let proposal = Proposal {
363 source: "test".into(),
364 candidates: vec![
365 Candidate {
366 name: "ruling".into(),
367 words: vec!["permitted".into(), "Series".into()],
368 rationale: "rules".into(),
369 },
370 Candidate {
372 name: "ruling2".into(),
373 words: vec!["permitted".into(), "Series".into()],
374 rationale: "the same thing again".into(),
375 },
376 ],
377 };
378 let verdicts = db.adopt(&proposal);
379 assert_eq!(verdicts.len(), 2, "one verdict per candidate");
380 for v in &verdicts {
381 assert!(!v.reason.is_empty(), "a rejection must be explicable: {v:?}");
382 }
383 assert!(
385 !verdicts[1].kept || verdicts[1].overlap < 0.99,
386 "an exact duplicate should not be adopted unexamined: {:?}",
387 verdicts[1]
388 );
389 }
390
391 #[test]
392 fn an_adopted_category_becomes_queryable() {
393 let mut db = SteelDb::ingest(docs()).unwrap();
394 let proposal = Proposal {
395 source: "test".into(),
396 candidates: vec![Candidate {
397 name: "ruling".into(),
398 words: vec!["permitted".into(), "season".into(), "Series".into()],
399 rationale: "rules".into(),
400 }],
401 };
402 let verdicts = db.adopt(&proposal);
403 if verdicts[0].kept {
404 let answer = db.query("ruling/*").expect("an adopted category must be queryable");
405 assert!(!answer.is_empty(), "and must actually match documents");
406 }
407 }
408
409 #[test]
410 fn proposals_display_for_review_before_adoption() {
411 let p = Proposal {
412 source: "bedrock:test".into(),
413 candidates: vec![Candidate {
414 name: "trainer".into(),
415 words: vec!["defeated".into()],
416 rationale: "competitors".into(),
417 }],
418 };
419 let shown = p.to_string();
420 assert!(shown.contains("bedrock:test"), "{shown}");
421 assert!(shown.contains("trainer"), "{shown}");
422 assert!(shown.contains("competitors"), "the rationale must be reviewable: {shown}");
423 }
424
425 #[test]
426 fn an_adopted_category_survives_into_an_artefact_and_is_followed_on_reload() {
427 let docs = docs();
431 let mut db = SteelDb::ingest(docs.clone()).unwrap();
432 let before: Vec<String> = db.categories().iter().map(|c| c.name.to_string()).collect();
433
434 let proposal = Proposal {
435 source: "test".into(),
436 candidates: vec![Candidate {
437 name: "ruling".into(),
438 words: vec!["permitted".into(), "season".into(), "Series".into()],
439 rationale: "competition rules".into(),
440 }],
441 };
442 let verdicts = db.adopt(&proposal);
443 if !verdicts[0].kept {
444 return;
446 }
447 assert!(
448 !before.contains(&"ruling".to_string()) && db.askable().contains(&"ruling/*".to_string()),
449 "adoption should have added the category"
450 );
451 let expected = db.query("ruling/*").expect("adopted category must be queryable").len();
452
453 let dir = std::env::temp_dir().join(format!("hsdb_learn_artifact_{}", std::process::id()));
454 let _ = std::fs::remove_dir_all(&dir);
455 db.save(&dir).expect("save");
456
457 let reloaded = SteelDb::ingest_using(docs, &dir).expect("reload");
459 assert!(
460 reloaded.askable().contains(&"ruling/*".to_string()),
461 "the adopted category must come back: {:?}",
462 reloaded.askable()
463 );
464 assert_eq!(
465 reloaded.query("ruling/*").expect("still queryable").len(),
466 expected,
467 "and answer identically without the teacher"
468 );
469 assert_eq!(db.tags(), reloaded.tags(), "tag for tag");
470 let _ = std::fs::remove_dir_all(&dir);
471 }
472}