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    /// **The model must support tool calling.** `learn` asks for a structured ontology, and a model without
183    /// tool calling replies in prose or with nothing — measured on a local ollama, `qwen2.5:0.5b` returns
184    /// `tool_calls: null` for every request, while `qwen3.5:0.8b`, `granite3-moe:3b` and `functiongemma` work.
185    ///
186    /// Beyond that, a small model is a reasonable choice *because* of the gate. Proposals are judged by the same
187    /// deterministic MECE test as local discovery, so a weak model produces rejected candidates rather than a
188    /// polluted vocabulary. The failure mode of choosing badly is wasted effort, not a wrong answer: on an
189    /// eight-document corpus `qwen3.5:0.8b` proposed a category that appears nowhere in the text and one that
190    /// restated an existing category, and the gate refused both.
191    ///
192    /// ```no_run
193    /// # #[cfg(feature = "paddock")]
194    /// # fn demo() -> Result<(), Box<dyn std::error::Error>> {
195    /// use steeldb::learn::Teacher;
196    /// // a function-calling model small enough to run on a laptop
197    /// let teacher = Teacher::local("http://localhost:11434/v1", "qwen3.5:0.8b")?;
198    /// # Ok(()) }
199    /// ```
200    #[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    /// A teacher backed by a model served by Ollama on the default port.
212    ///
213    /// Shorthand for [`Teacher::local`] against `http://localhost:11434/v1`.
214    #[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    /// A teacher backed by Amazon Bedrock.
220    ///
221    /// Requires AWS credentials resolvable by the standard chain (environment, profile, or instance role) and
222    /// `AWS_REGION`. Checked here so the failure is immediate and names what is missing.
223    ///
224    /// This costs money per call. The amount is small for vocabulary proposal — one request over a sample of
225    /// documents — but it is a real charge and worth saying out loud.
226    #[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    /// Ask for categories the local discovery may have missed.
237    ///
238    /// Returns a [`Proposal`]; nothing changes until you [`SteelDb::adopt`] it.
239    pub async fn propose_categories(&self, db: &SteelDb) -> Result<Proposal, LearnError> {
240        // only the network paths read the corpus; without them this is deliberately unused
241        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    /// The shared path: whichever provider, the prompt, parsing and shaping are identical. Only the transport
265    /// differs, which is what lets a local 270M model and a hosted frontier model be swapped freely.
266    #[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        // label the proposal by its transport, so a logged proposal says which model produced it
272        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        // holding a proposal must not alter the database
325        assert_eq!(db.categories().len(), before);
326    }
327
328    #[test]
329    fn a_fixed_teacher_needs_no_credentials() {
330        // deliberately driven without an async runtime: the offline path must not require tokio, which is an
331        // optional dependency, so the default build can still test learning end to end
332        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    /// Drive a future to completion without a runtime. Sound here because the fixed teacher never yields.
347    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                // deliberately a near-duplicate of the first, judged after it
377                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        // the gate is the point: a model cannot add what a deterministic test would refuse
390        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        // The whole point of pairing `learn` with artefacts: the expensive, non-deterministic step runs once,
434        // and every run afterwards is offline and identical. That only holds if what a teacher contributed is
435        // actually written to the files that `ingest` follows — otherwise `learn` is a change you lose.
436        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            // the gate is allowed to reject; there is then nothing to persist and nothing to assert
451            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        // a fresh process, no teacher, no network: the reload must follow what learning produced
464        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}