Skip to main content

steeldb/
learn.rs

1//! **Learn** — the third verb, and the one that works differently on purpose.
2//!
3//! `ingest` and `query` are offline, deterministic and free. `learn` is none of those: it calls a language
4//! model, which means credentials, network, latency and a bill. Hiding that behind a method that looks like the
5//! other two would be a trap, so this module makes all four facts visible in the shape of the API.
6//!
7//! ```no_run
8//! # #[cfg(feature = "bedrock")]
9//! # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
10//! use steeldb::{SteelDb, learn::Teacher};
11//!
12//! let mut db = SteelDb::ingest(["…documents…"])?;
13//!
14//! // credentials are checked when the teacher is built, not when it is used
15//! let teacher = Teacher::bedrock("us.anthropic.claude-sonnet-4-5-20250929-v1:0")?;
16//!
17//! // a proposal is returned, NOT applied
18//! let proposal = teacher.propose_categories(&db).await?;
19//! println!("{proposal}");
20//!
21//! // you decide, and the same MECE test that gates local discovery gates this too
22//! let adopted = db.adopt(&proposal);
23//! println!("kept {} of {}", adopted.len(), proposal.candidates.len());
24//! # Ok(()) }
25//! ```
26//!
27//! ## Why a proposal instead of a mutation
28//!
29//! A model suggesting categories is a suggestion, not an authority. Returning a [`Proposal`] means you can
30//! print it, diff it, log it, or reject it before your vocabulary changes — and it keeps the model advisory,
31//! which is the same separation the query planner has. `adopt` then applies the *same* gate that local
32//! discovery uses, so a model cannot sneak in a category that a deterministic test would have rejected.
33//!
34//! Without a network-capable feature enabled, this module still compiles: [`Proposal`] and [`SteelDb::adopt`]
35//! work with candidates from any source, so the offline path is testable.
36
37use crate::api::SteelDb;
38
39/// A candidate category, from wherever.
40#[derive(Debug, Clone, PartialEq)]
41pub struct Candidate {
42    /// the category name, which becomes its query stem
43    pub name: String,
44    /// words that should put a document in this category
45    pub words: Vec<String>,
46    /// why the proposer thinks it belongs, for a human reading the diff
47    pub rationale: String,
48}
49
50/// What a teacher suggests. Inert until adopted.
51#[derive(Debug, Clone, Default)]
52pub struct Proposal {
53    pub candidates: Vec<Candidate>,
54    /// what produced this, so a log entry is traceable
55    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/// The outcome of adopting one candidate. Reported per candidate so a rejection is explicable.
70#[derive(Debug, Clone)]
71pub struct Verdict {
72    pub name: String,
73    pub kept: bool,
74    /// the gate's own words
75    pub reason: String,
76    /// share of documents the candidate covers
77    pub coverage: f64,
78    /// how much it duplicates a category already accepted
79    pub overlap: f64,
80}
81
82impl SteelDb {
83    /// Apply a proposal, keeping only what the MECE gate accepts.
84    ///
85    /// The gate is the same one [`SteelDb::ingest`] uses, so a model-proposed category has to earn its place on
86    /// the same terms as a locally-discovered one: enough coverage, and not a near-duplicate of something
87    /// already present. Candidates are judged in order, so the second of two similar suggestions is rejected
88    /// against the first.
89    ///
90    /// Returns a verdict per candidate; the kept ones are queryable immediately.
91    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            // the index must be rebuilt: a new category changes what every document projects to
122            self.reproject();
123        }
124        verdicts
125    }
126}
127
128/// A source of proposals.
129///
130/// Constructing one is where credentials are checked, so a missing configuration fails before any work is
131/// queued rather than part-way through a corpus.
132pub struct Teacher {
133    kind: Kind,
134}
135
136enum Kind {
137    /// proposals supplied by the caller — the offline path, and what the tests use
138    Fixed(Proposal),
139    /// any OpenAI-compatible endpoint: llama.cpp, vLLM, Ollama, LM Studio. No credentials, no bill.
140    #[cfg(feature = "paddock")]
141    Local { base_url: String, model: String },
142    #[cfg(feature = "bedrock")]
143    Bedrock { model_id: String },
144}
145
146/// Why learning could not proceed.
147#[derive(Debug)]
148pub enum LearnError {
149    /// no credentials, or the region/model is not configured
150    NotConfigured(String),
151    /// the model answered, but not with something usable
152    BadResponse(String),
153    /// the call itself failed
154    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    /// A teacher that returns a fixed proposal. Useful for tests, for replaying a recorded proposal, and for
171    /// feeding in candidates from a source of your own.
172    pub fn fixed(proposal: Proposal) -> Teacher {
173        Teacher { kind: Kind::Fixed(proposal) }
174    }
175
176    /// A teacher backed by a **local** model behind an OpenAI-compatible endpoint.
177    ///
178    /// This is the preferred route. It needs no credentials, sends nothing off the machine, and costs nothing,
179    /// which removes every objection to `learn` except quality. Works with llama.cpp, vLLM, LM Studio, Ollama —
180    /// anything speaking `/v1/chat/completions`.
181    ///
182    /// A small model is a reasonable choice here *because* of the gate. Proposals are judged by the same
183    /// deterministic MECE test as local discovery, so a weak model produces rejected candidates rather than a
184    /// polluted vocabulary. The failure mode of choosing badly is wasted effort, not a wrong answer.
185    ///
186    /// ```no_run
187    /// # #[cfg(feature = "paddock")]
188    /// # fn demo() -> Result<(), Box<dyn std::error::Error>> {
189    /// use steeldb::learn::Teacher;
190    /// // a function-calling model small enough to run on a laptop
191    /// let teacher = Teacher::local("http://localhost:11434/v1", "functiongemma-270m-it")?;
192    /// # Ok(()) }
193    /// ```
194    #[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    /// A teacher backed by a model served by Ollama on the default port.
206    ///
207    /// Shorthand for [`Teacher::local`] against `http://localhost:11434/v1`.
208    #[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    /// A teacher backed by Amazon Bedrock.
214    ///
215    /// Requires AWS credentials resolvable by the standard chain (environment, profile, or instance role) and
216    /// `AWS_REGION`. Checked here so the failure is immediate and names what is missing.
217    ///
218    /// This costs money per call. The amount is small for vocabulary proposal — one request over a sample of
219    /// documents — but it is a real charge and worth saying out loud.
220    #[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    /// Ask for categories the local discovery may have missed.
231    ///
232    /// Returns a [`Proposal`]; nothing changes until you [`SteelDb::adopt`] it.
233    pub async fn propose_categories(&self, db: &SteelDb) -> Result<Proposal, LearnError> {
234        // only the network paths read the corpus; without them this is deliberately unused
235        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    /// The shared path: whichever provider, the prompt, parsing and shaping are identical. Only the transport
259    /// differs, which is what lets a local 270M model and a hosted frontier model be swapped freely.
260    #[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        // label the proposal by its transport, so a logged proposal says which model produced it
266        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        // holding a proposal must not alter the database
319        assert_eq!(db.categories().len(), before);
320    }
321
322    #[test]
323    fn a_fixed_teacher_needs_no_credentials() {
324        // deliberately driven without an async runtime: the offline path must not require tokio, which is an
325        // optional dependency, so the default build can still test learning end to end
326        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    /// Drive a future to completion without a runtime. Sound here because the fixed teacher never yields.
341    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                // deliberately a near-duplicate of the first, judged after it
371                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        // the gate is the point: a model cannot add what a deterministic test would refuse
384        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        // The whole point of pairing `learn` with artefacts: the expensive, non-deterministic step runs once,
428        // and every run afterwards is offline and identical. That only holds if what a teacher contributed is
429        // actually written to the files that `ingest` follows — otherwise `learn` is a change you lose.
430        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            // the gate is allowed to reject; there is then nothing to persist and nothing to assert
445            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        // a fresh process, no teacher, no network: the reload must follow what learning produced
458        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}