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    /// **Curate a raw spec into canonical facet types.** The judgment half of neural discovery.
295    ///
296    /// The tagger and the transport solver do the mechanical work: they find typed spans and group them. What
297    /// comes out is a list of raw entity-value clusters, and a cluster label is a VALUE. The reference's own
298    /// committed spec has raw clusters named `missile`, `bo`, `guidance control`, `ph`, `ge`; its finished
299    /// ontology reads `weapon-platform`, `control-system`, with those terms demoted to examples. The step
300    /// between the two is this one, and skipping it puts `ph` in the index as a retrieval dimension.
301    ///
302    /// Three things are asked of the model, following the reference's curator exactly:
303    ///
304    /// * **merge** synonymous or overlapping clusters into one facet TYPE — a short lowercase slug naming a kind
305    ///   of thing, not one of its values;
306    /// * **drop** clusters that are boilerplate, noise, or too generic to be a facet;
307    /// * name each surviving facet, listing the raw cluster terms it absorbs as its examples.
308    ///
309    /// This is judgment, not arithmetic, which is why it is a model call and not a formula — and why what comes
310    /// back is a [`Proposal`] that still faces the MECE gate in [`SteelDb::adopt`] before anything is indexed.
311    #[cfg(all(feature = "onnx", feature = "embed", any(feature = "paddock", feature = "bedrock")))]
312    pub async fn curate(
313        &self,
314        raw: &crate::tagger_discover::RawSpec,
315    ) -> Result<Proposal, LearnError> {
316        use crate::agent::types::Msg;
317
318        let cfg = self.provider_config()?;
319        let label = match &cfg {
320            #[cfg(feature = "paddock")]
321            crate::agent::config::ProviderConfig::Paddock { model, .. } => format!("curate:local:{model}"),
322            #[cfg(feature = "bedrock")]
323            crate::agent::config::ProviderConfig::Bedrock { model_id, .. } => format!("curate:bedrock:{model_id}"),
324            _ => "curate".to_string(),
325        };
326        let provider = cfg.build().await.map_err(LearnError::Transport)?;
327
328        let ents = raw
329            .entity_clusters
330            .iter()
331            .map(|c| format!("{}: {}", c.label, c.terms.join(", ")))
332            .collect::<Vec<_>>()
333            .join("\n");
334        let rels = raw
335            .relation_clusters
336            .iter()
337            .map(|c| format!("{}: {}", c.label, c.terms.join(", ")))
338            .collect::<Vec<_>>()
339            .join("\n");
340        let prompt = format!("RAW ENTITY-VALUE CLUSTERS:\n{ents}\n\nRAW RELATION CLUSTERS:\n{rels}");
341
342        let schema = curation_schema();
343        let v = provider
344            .chat_json(CURATE_SYSTEM, &[Msg::user_text(prompt)], &schema, "curate")
345            .await
346            .map_err(LearnError::Transport)?
347            .ok_or_else(|| {
348                LearnError::BadResponse(
349                    "the model produced no curated ontology; curation needs structured output".into(),
350                )
351            })?;
352
353        let facets = v.get("entity_facets").and_then(|f| f.as_array()).cloned().unwrap_or_default();
354        let candidates: Vec<Candidate> = facets
355            .iter()
356            .filter_map(|f| {
357                let name = crate::projector::slug(f.get("name")?.as_str()?);
358                if name.is_empty() {
359                    return None;
360                }
361                let words: Vec<String> = f
362                    .get("examples")
363                    .and_then(|e| e.as_array())
364                    .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
365                    .unwrap_or_default();
366                let rationale =
367                    f.get("description").and_then(|d| d.as_str()).unwrap_or("").to_string();
368                Some(Candidate { name, words, rationale })
369            })
370            .collect();
371        Ok(Proposal { source: label, candidates })
372    }
373
374    /// The provider configuration behind this teacher, shared by `propose_categories` and `curate`.
375    #[cfg(all(feature = "onnx", feature = "embed", any(feature = "paddock", feature = "bedrock")))]
376    fn provider_config(&self) -> Result<crate::agent::config::ProviderConfig, LearnError> {
377        match &self.kind {
378            #[cfg(feature = "paddock")]
379            Kind::Local { base_url, model } => Ok(crate::agent::config::ProviderConfig::Paddock {
380                base_url: base_url.clone(),
381                model: model.clone(),
382                api_key: None,
383            }),
384            #[cfg(feature = "bedrock")]
385            Kind::Bedrock { model_id } => Ok(crate::agent::config::ProviderConfig::Bedrock {
386                model_id: model_id.clone(),
387                region: std::env::var("AWS_REGION").ok(),
388            }),
389            Kind::Fixed(_) => Err(LearnError::NotConfigured(
390                "a fixed teacher has no model to curate with".into(),
391            )),
392        }
393    }
394}
395
396/// The curator's brief, following the reference's `curate.ts` system prompt.
397#[cfg(all(feature = "onnx", feature = "embed", any(feature = "paddock", feature = "bedrock")))]
398const CURATE_SYSTEM: &str = "You curate a DISCOVERED ontology into a clean, MECE facet schema for a \
399directed-hypergraph bitset index. You get raw ENTITY-VALUE clusters (each a name plus example terms) and \
400RELATION clusters.\n\
401\n\
402Every fact is stored as a PATH: `facet/value`. So the facet name is the KIND and the cluster terms are its \
403VALUES. Before you accept a name, write the path out and read it:\n\
404  network-generation/6g      GOOD - a kind, then one of its values\n\
405  6g/6g                      WRONG - that is a value naming itself\n\
406  cost-attribute/low-cost    GOOD\n\
407  cost-effective/low-cost    WRONG - `cost-effective` is a value of some attribute\n\
408  platform/uav               GOOD\n\
409If the name you chose could itself appear on the RIGHT of the slash, it is a value: abstract it up to the kind it \
410belongs to and use that instead. This is the single most common mistake — fix it before answering.\n\
411\n\
412Rules:\n\
413- A facet name is a NOUN naming a kind of thing (platform, sensing-modality, vehicle-type, application-domain). \
414Never a verb (translate, deliver), never an adjective (cost-effective, high-speed), never a bare instance (6g, \
415new-south, monash).\n\
416- MERGE synonymous or overlapping clusters into one type: optical/thermal/quantum becomes sensing-modality; \
417payload/uav becomes platform. Set `examples` to the raw cluster terms the type absorbs, copied verbatim.\n\
418- MUTUALLY EXCLUSIVE: each raw cluster belongs to exactly ONE facet. If two of your facets could both claim a \
419cluster, they are the same facet — merge them. Do not emit both a general and a narrower version of the same \
420kind.\n\
421- COLLECTIVELY EXHAUSTIVE: every raw cluster is either absorbed by a facet or listed in `dropped`. Nothing is \
422left unaccounted for.\n\
423- DROP clusters that are boilerplate, noise, or too generic to be a facet: statement, benefits, project, \
424ultimately, consensus, outcomes. A facet that would match nearly every document discriminates nothing.\n\
425- Prefer FEWER, broader facets. Six well-separated kinds beat twelve overlapping ones.\n\
426\n\
427Be decisive. Do not invent content that is not present in the clusters.";
428
429/// Bounded schema for curation. Every array has a `maxItems`: constraining a small model's decoding is not
430/// enough on its own, because an unbounded array lets it run into the token limit and return
431/// grammatically-valid but truncated JSON.
432#[cfg(all(feature = "onnx", feature = "embed", any(feature = "paddock", feature = "bedrock")))]
433fn curation_schema() -> serde_json::Value {
434    serde_json::json!({
435        "type": "object",
436        "additionalProperties": false,
437        "properties": {
438            // 8 facets of 6 examples, not 12 of 12. The looser bound let a small model emit 144 long example
439            // phrases and run past even a 4096-token budget, returning `finish_reason: length` with nothing
440            // usable. The bound also agrees with the prompt, which asks for FEWER, broader facets — a schema
441            // that permits what the instructions discourage is just an invitation to ignore them.
442            "entity_facets": { "type": "array", "minItems": 1, "maxItems": 8,
443              "items": { "type": "object", "additionalProperties": false, "properties": {
444                "name": {"type": "string"},
445                "description": {"type": "string"},
446                "examples": {"type": "array", "maxItems": 6, "items": {"type": "string"}}
447              }, "required": ["name", "description", "examples"] } },
448            "relation_facets": { "type": "array", "maxItems": 8,
449              "items": { "type": "object", "additionalProperties": false, "properties": {
450                "name": {"type": "string"}, "head": {"type": "string"}, "tail": {"type": "string"}
451              }, "required": ["name", "head", "tail"] } },
452            "dropped": { "type": "array", "maxItems": 24, "items": {"type": "string"} }
453        },
454        "required": ["entity_facets", "dropped"]
455    })
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    fn docs() -> Vec<String> {
463        [
464            "Morty Shade defeated Wallace Gale at Ecruteak City during the Indigo Invitational in 2025.",
465            "Bea Strike defeated Iris Draco at Ecruteak City during the Indigo Invitational in 2025.",
466            "A habitat survey recorded Aggron near Sootopolis City at an elevation of 1082 m.",
467            "A habitat survey recorded Salamence near Sootopolis City at an elevation of 2369 m.",
468            "Milotic is not permitted in Series 1 play for the 2025 season.",
469        ]
470        .iter()
471        .map(|s| s.to_string())
472        .collect()
473    }
474
475    #[test]
476    fn a_proposal_changes_nothing_until_adopted() {
477        let db = SteelDb::ingest(docs()).unwrap();
478        let before = db.categories().len();
479        let _p = Proposal {
480            source: "test".into(),
481            candidates: vec![Candidate {
482                name: "trainer".into(),
483                words: vec!["defeated".into(), "Shade".into()],
484                rationale: "people who compete".into(),
485            }],
486        };
487        // holding a proposal must not alter the database
488        assert_eq!(db.categories().len(), before);
489    }
490
491    #[test]
492    fn a_fixed_teacher_needs_no_credentials() {
493        // deliberately driven without an async runtime: the offline path must not require tokio, which is an
494        // optional dependency, so the default build can still test learning end to end
495        let db = SteelDb::ingest(docs()).unwrap();
496        let p = Proposal {
497            source: "fixed".into(),
498            candidates: vec![Candidate {
499                name: "ruling".into(),
500                words: vec!["permitted".into(), "Series".into(), "season".into()],
501                rationale: "competition rules".into(),
502            }],
503        };
504        let teacher = Teacher::fixed(p.clone());
505        let got = block_on(teacher.propose_categories(&db)).unwrap();
506        assert_eq!(got.candidates, p.candidates);
507    }
508
509    /// Drive a future to completion without a runtime. Sound here because the fixed teacher never yields.
510    fn block_on<F: std::future::Future>(mut fut: F) -> F::Output {
511        use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
512        fn noop(_: *const ()) {}
513        fn clone(p: *const ()) -> RawWaker {
514            RawWaker::new(p, &VTABLE)
515        }
516        static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
517        let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
518        let mut cx = Context::from_waker(&waker);
519        let mut fut = unsafe { std::pin::Pin::new_unchecked(&mut fut) };
520        loop {
521            match fut.as_mut().poll(&mut cx) {
522                Poll::Ready(v) => return v,
523                Poll::Pending => panic!("the fixed teacher must not yield"),
524            }
525        }
526    }
527
528    #[test]
529    fn adoption_reports_a_verdict_per_candidate_and_can_reject() {
530        let mut db = SteelDb::ingest(docs()).unwrap();
531        let proposal = Proposal {
532            source: "test".into(),
533            candidates: vec![
534                Candidate {
535                    name: "ruling".into(),
536                    words: vec!["permitted".into(), "Series".into()],
537                    rationale: "rules".into(),
538                },
539                // deliberately a near-duplicate of the first, judged after it
540                Candidate {
541                    name: "ruling2".into(),
542                    words: vec!["permitted".into(), "Series".into()],
543                    rationale: "the same thing again".into(),
544                },
545            ],
546        };
547        let verdicts = db.adopt(&proposal);
548        assert_eq!(verdicts.len(), 2, "one verdict per candidate");
549        for v in &verdicts {
550            assert!(!v.reason.is_empty(), "a rejection must be explicable: {v:?}");
551        }
552        // the gate is the point: a model cannot add what a deterministic test would refuse
553        assert!(
554            !verdicts[1].kept || verdicts[1].overlap < 0.99,
555            "an exact duplicate should not be adopted unexamined: {:?}",
556            verdicts[1]
557        );
558    }
559
560    #[test]
561    fn an_adopted_category_becomes_queryable() {
562        let mut db = SteelDb::ingest(docs()).unwrap();
563        let proposal = Proposal {
564            source: "test".into(),
565            candidates: vec![Candidate {
566                name: "ruling".into(),
567                words: vec!["permitted".into(), "season".into(), "Series".into()],
568                rationale: "rules".into(),
569            }],
570        };
571        let verdicts = db.adopt(&proposal);
572        if verdicts[0].kept {
573            let answer = db.query("ruling/*").expect("an adopted category must be queryable");
574            assert!(!answer.is_empty(), "and must actually match documents");
575        }
576    }
577
578    #[test]
579    fn proposals_display_for_review_before_adoption() {
580        let p = Proposal {
581            source: "bedrock:test".into(),
582            candidates: vec![Candidate {
583                name: "trainer".into(),
584                words: vec!["defeated".into()],
585                rationale: "competitors".into(),
586            }],
587        };
588        let shown = p.to_string();
589        assert!(shown.contains("bedrock:test"), "{shown}");
590        assert!(shown.contains("trainer"), "{shown}");
591        assert!(shown.contains("competitors"), "the rationale must be reviewable: {shown}");
592    }
593
594    #[test]
595    fn an_adopted_category_survives_into_an_artefact_and_is_followed_on_reload() {
596        // The whole point of pairing `learn` with artefacts: the expensive, non-deterministic step runs once,
597        // and every run afterwards is offline and identical. That only holds if what a teacher contributed is
598        // actually written to the files that `ingest` follows — otherwise `learn` is a change you lose.
599        let docs = docs();
600        let mut db = SteelDb::ingest(docs.clone()).unwrap();
601        let before: Vec<String> = db.categories().iter().map(|c| c.name.to_string()).collect();
602
603        let proposal = Proposal {
604            source: "test".into(),
605            candidates: vec![Candidate {
606                name: "ruling".into(),
607                words: vec!["permitted".into(), "season".into(), "Series".into()],
608                rationale: "competition rules".into(),
609            }],
610        };
611        let verdicts = db.adopt(&proposal);
612        if !verdicts[0].kept {
613            // the gate is allowed to reject; there is then nothing to persist and nothing to assert
614            return;
615        }
616        assert!(
617            !before.contains(&"ruling".to_string()) && db.askable().contains(&"ruling/*".to_string()),
618            "adoption should have added the category"
619        );
620        let expected = db.query("ruling/*").expect("adopted category must be queryable").len();
621
622        let dir = std::env::temp_dir().join(format!("hsdb_learn_artifact_{}", std::process::id()));
623        let _ = std::fs::remove_dir_all(&dir);
624        db.save(&dir).expect("save");
625
626        // a fresh process, no teacher, no network: the reload must follow what learning produced
627        let reloaded = SteelDb::ingest_using(docs, &dir).expect("reload");
628        assert!(
629            reloaded.askable().contains(&"ruling/*".to_string()),
630            "the adopted category must come back: {:?}",
631            reloaded.askable()
632        );
633        assert_eq!(
634            reloaded.query("ruling/*").expect("still queryable").len(),
635            expected,
636            "and answer identically without the teacher"
637        );
638        assert_eq!(db.tags(), reloaded.tags(), "tag for tag");
639        let _ = std::fs::remove_dir_all(&dir);
640    }
641}