Skip to main content

borg/
vectors.rs

1//! Vector recall for the Stop step (internal evaluation "vectors", not published).
2//!
3//! Word matching (BM25) shortlists the lessons the consequence model reads at a turn end. It misses lessons
4//! that share no rare word with the work: paraphrased or abstract ones, German work against English
5//! lessons, renamed identifiers. Vectors add those, and only those:
6//!
7//! - **Where:** only on the Stop path, after the free gate passed and right before the consequence model
8//!   call (seconds), where loading the embedder is small next to it. Prompt, early delivery and the free
9//!   gate stay BM25 only.
10//! - **How:** no rank fusion. The shortlist is BM25's top 20 as before, plus up to `k` vector hits not
11//!   already in it, under the same scope rule and done set (`extend`). The model decides relevance;
12//!   vectors only add recall. R3b's widening stays BM25-based.
13//! - **Index:** an embedded SeekStorm vector index per store (`<store>/vectors/`), one vector per lesson
14//!   (question + situations + consequence), updated after `borg learn` / `borg assimilate --apply`, after a
15//!   mesh sync brings new lessons, and by `borg vectors --rebuild`; a missing index is built by the first
16//!   update. A lesson without a vector is simply BM25-only.
17//! - **Model:** Model2Vec potion-multilingual-128M from a local directory (`BORG_VECTOR_MODEL`), fetched
18//!   once by `borg vectors --fetch`; never downloaded inside a hook. The embedding matrix is mapped, not
19//!   read: a query touches only the rows of its own tokens.
20//! - **Fail open:** no model, no index, a locked index or any error = BM25 only. On by default:
21//!   `borg doctor` warns about a missing model or index (doctor.rs); `BORG_VECTORS=off` disables it; a lean
22//!   build (`--no-default-features`) never has it.
23
24use std::collections::HashSet;
25use std::path::{Path, PathBuf};
26
27use crate::conscience::{self, Challenge, Names};
28use crate::trust;
29
30pub mod doctor;
31pub mod model;
32#[cfg(feature = "vectors")]
33mod unigram;
34
35pub use model::{MODEL_FILES, MODEL_REPO, MODEL_REVISION, enabled, index_dir, model_dir, model_present};
36
37/// Vector hits added to the shortlist at most (`BORG_VECTOR_K`). Baseline: the internal evaluation
38/// "vectors" setting; with the whole-store pool (whole.rs) the shortlist only orders what fills the
39/// budget when the pool overflows.
40pub const DEFAULT_K: usize = 10;
41
42/// BM25 lessons the Stop's consequence model sees before vector additions. Baseline: top 30 found as
43/// much as top 20 plus vectors (internal evaluation "vectors"); with the whole-store pool it is the
44/// overflow order's head (whole.rs `select`).
45pub const SHORTLIST: usize = 30;
46
47/// Vector hits fetched beyond `k` + the shortlist (`shortlist`). Guard: done, out-of-scope and
48/// BM25-listed hits are dropped after the search, so a search of exactly `k` could come back short.
49const OVER_FETCH: usize = 30;
50
51/// SeekStorm's segment number bits of the index (2^11 segments). Method: SeekStorm's own default for an
52/// index of this size; borg-search's evaluation builds its indexes with the same layout.
53pub const SEGMENT_BITS: usize = 11;
54
55/// How many vector hits the Stop step adds at most (`BORG_VECTOR_K`, default `DEFAULT_K`).
56pub fn k() -> usize {
57    std::env::var("BORG_VECTOR_K").ok().and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_K)
58}
59
60/// The text a lesson is embedded from: question, situations, consequence.
61pub fn lesson_text(c: &Challenge) -> String {
62    format!("{}\n{}\n{}", c.question, c.situations.join("\n"), c.consequence)
63}
64
65/// The Stop shortlist: `bm25` (BM25's top 20, as `select_scoped` returns it) and then, in vector order,
66/// up to `k` lessons of `ranked` (lesson ids, best first) that `bm25` lacks. Each added lesson must be in
67/// `pool` (the lessons not done this session), in scope for `scope_text` (`rank_scoped`'s rule), not
68/// superseded and not dormant (a dormant lesson needs a strong word match, which a vector hit is not).
69pub fn extend<'a>(bm25: Vec<&'a Challenge>, ranked: &[String], pool: &'a [Challenge], scope_text: &str, k: usize) -> Vec<&'a Challenge> {
70    if k == 0 || ranked.is_empty() {
71        return bm25;
72    }
73    let have = Names::of(scope_text);
74    let successors = trust::successors(pool);
75    let mut seen: HashSet<&str> = bm25.iter().map(|c| c.id.as_str()).collect();
76    let mut out = bm25;
77    let mut added = 0;
78    for id in ranked {
79        if added == k {
80            break;
81        }
82        let Some(c) = pool.iter().find(|c| &c.id == id) else { continue };
83        if seen.contains(c.id.as_str()) || !conscience::in_scope(c, &have) || c.dormant || successors.contains_key(&c.id) {
84            continue;
85        }
86        seen.insert(c.id.as_str());
87        out.push(c);
88        added += 1;
89    }
90    out
91}
92
93/// The Stop step's shortlist for the lessons file `lessons`: BM25's top `n` over `context` (scoped by
94/// `scope_text`) extended by the vector index's hits (`extend`). Without vectors (off, no model, no index,
95/// any error) it is exactly BM25's top `n`. Also returns how many lessons the vectors added.
96pub fn shortlist<'a>(lessons: &Path, pool: &'a [Challenge], context: &str, scope_text: &str, n: usize) -> (Vec<&'a Challenge>, usize) {
97    let bm25 = conscience::select_scoped(pool, context, scope_text, n);
98    let k = k();
99    if !enabled() || k == 0 {
100        return (bm25, 0);
101    }
102    // Ask for more than `k`: done, out-of-scope and BM25-listed hits are dropped after the search.
103    let ranked = recall(lessons, &query_tail(context), k + n + OVER_FETCH);
104    let before = bm25.len();
105    let out = extend(bm25, &ranked, pool, scope_text, k);
106    let added = out.len() - before;
107    (out, added)
108}
109
110/// What the consequence model reads of the work (predict.rs `prompt`): the last `TAIL` characters.
111pub fn query_tail(context: &str) -> String {
112    let chars: Vec<char> = context.chars().collect();
113    chars[chars.len().saturating_sub(crate::predict::TAIL)..].iter().collect()
114}
115
116/// The ids of the lessons nearest to `text`, best first, at most `n`; empty whenever vectors are not
117/// available (fail open: no model, no index, the index being written, any error or panic).
118pub fn recall(lessons: &Path, text: &str, n: usize) -> Vec<String> {
119    #[cfg(feature = "vectors")]
120    {
121        if !enabled() {
122            return Vec::new();
123        }
124        let (index, model) = (index_dir(lessons), model_dir());
125        if !index.join(engine::MANIFEST).is_file() || !model_present(&model) {
126            return Vec::new();
127        }
128        let text = text.to_string();
129        std::panic::catch_unwind(move || engine::query(&index, &model, &text, n).unwrap_or_default()).unwrap_or_default()
130    }
131    #[cfg(not(feature = "vectors"))]
132    {
133        let _ = (lessons, text, n);
134        Vec::new()
135    }
136}
137
138/// Bring the index of `lessons` up to date after lessons were written (`borg learn`, `borg assimilate
139/// --apply`, a mesh sync), building it if it is missing (the first lesson after `--fetch` needs no
140/// `--rebuild`). Quiet and fail-open: without the model or with vectors off it does nothing; an error is
141/// one line on stderr. Returns the number of lessons embedded.
142pub fn refresh(lessons: &Path) -> usize {
143    #[cfg(feature = "vectors")]
144    {
145        if !enabled() || !lessons.is_file() || !model_present(&model_dir()) {
146            return 0;
147        }
148        let lessons = lessons.to_path_buf();
149        match std::panic::catch_unwind(move || engine::update(&lessons, &model_dir(), false)) {
150            Ok(Ok(n)) => n,
151            Ok(Err(e)) => {
152                eprintln!("borg vectors: index not updated ({e}); `borg vectors --rebuild` retries");
153                0
154            }
155            Err(_) => 0,
156        }
157    }
158    #[cfg(not(feature = "vectors"))]
159    {
160        let _ = lessons;
161        0
162    }
163}
164
165#[cfg(feature = "vectors")]
166const USAGE: &str = "usage: borg vectors [--store LESSONS.jsonl] [--rebuild] | borg vectors --fetch";
167
168/// `borg vectors`: status of the model and the store's index; `--rebuild` (re)builds the index;
169/// `--fetch` downloads the model (pinned revision, sha256 checked); `--query` (reached only as
170/// `borg debug vectors --query`) prints the Stop step's shortlist for a context on stdin (offline
171/// checks, latency). The exit code.
172pub fn main(args: &[&str]) -> i32 {
173    let flag = |f: &str| args.iter().position(|a| *a == f).and_then(|i| args.get(i + 1)).copied();
174    // Without --store: the collective the current directory routes to (as the hooks do).
175    let store = flag("--store").map(PathBuf::from).unwrap_or_else(|| {
176        let cwd = std::env::current_dir().map(|d| d.display().to_string()).unwrap_or_default();
177        let base = crate::learn::home();
178        match crate::collective::load().ok().flatten() {
179            Some(cfg) => match crate::collective::route(Some(&cfg), &cwd, &base) {
180                crate::collective::Route::Collective { home, .. } => home.join("lessons.jsonl"),
181                crate::collective::Route::Off => base.join("lessons.jsonl"),
182            },
183            None => base.join("lessons.jsonl"),
184        }
185    });
186    #[cfg(not(feature = "vectors"))]
187    {
188        let _ = store;
189        eprintln!("borg vectors: this borg is a lean build (--no-default-features); reinstall without that flag: cargo install borg-ai --locked --force");
190        1
191    }
192    #[cfg(feature = "vectors")]
193    {
194        if args.contains(&"--fetch") {
195            return match engine::fetch(&model_dir()) {
196                Ok(()) => {
197                    println!("model ready at {}", model_dir().display());
198                    0
199                }
200                Err(e) => {
201                    eprintln!("borg vectors --fetch: {e}");
202                    1
203                }
204            };
205        }
206        if args.contains(&"--query") {
207            use std::io::Read;
208            let mut context = String::new();
209            let _ = std::io::stdin().read_to_string(&mut context);
210            let mut pool = conscience::load(&store, None);
211            crate::absorb::withhold_beside(&store, &mut pool, None);
212            let scope_text = flag("--scope").map(str::to_string).unwrap_or_else(|| context.clone());
213            let t = std::time::Instant::now();
214            let (list, added) = shortlist(&store, &pool, &context, &scope_text, SHORTLIST);
215            for (i, c) in list.iter().enumerate() {
216                println!("{}\t{}", c.id, if i >= list.len() - added { "vector" } else { "bm25" });
217            }
218            eprintln!("{} bm25 + {added} vector in {:.1} ms", list.len() - added, t.elapsed().as_secs_f64() * 1000.0);
219            return 0;
220        }
221        if args.iter().any(|a| a.starts_with("--") && !["--store", "--rebuild", "--scope"].contains(a)) {
222            eprintln!("{USAGE}");
223            return 2;
224        }
225        let model = model_dir();
226        if !model_present(&model) {
227            println!("model: missing at {} (`borg vectors --fetch`); the Stop step uses BM25 only", model.display());
228            return i32::from(args.contains(&"--rebuild"));
229        }
230        println!("model: {}", model.display());
231        if !enabled() {
232            println!("vectors: off (BORG_VECTORS=off)");
233        }
234        if args.contains(&"--rebuild") {
235            let t = std::time::Instant::now();
236            return match engine::update(&store, &model, true) {
237                Ok(n) => {
238                    println!("index rebuilt: {n} lessons embedded in {:.2} s ({})", t.elapsed().as_secs_f64(), index_dir(&store).display());
239                    0
240                }
241                Err(e) => {
242                    eprintln!("borg vectors --rebuild: {e}");
243                    1
244                }
245            };
246        }
247        match engine::status(&store, &model) {
248            Ok(s) => println!("{s}"),
249            Err(e) => println!("index: {e}"),
250        }
251        0
252    }
253}
254
255#[cfg(feature = "vectors")]
256pub mod engine {
257    //! The embedder (Model2Vec on a mapped model file) and the SeekStorm index.
258
259    use std::collections::BTreeMap;
260    use std::fs::File;
261    use std::path::Path;
262
263    use seekstorm::commit::Commit;
264    use seekstorm::index::{
265        AccessType, Close, Clustering, Document, DocumentCompression, FrequentwordType, IndexDocuments, IndexMetaObject, LexicalSimilarity, NgramSet,
266        SchemaField, StemmerType, StopwordType, TokenizerType, create_index, open_index,
267    };
268    use seekstorm::search::{QueryRewriting, QueryType, ResultType, Search, SearchMode};
269    use seekstorm::vector::{Embedding, Inference, Precision, Quantization};
270    use seekstorm::vector_similarity::{AnnMode, VectorSimilarity};
271    use serde::{Deserialize, Serialize};
272    use serde_json::Value;
273
274    pub use super::model::MANIFEST;
275    use super::model::identity;
276    use super::{MODEL_FILES, MODEL_REPO, MODEL_REVISION, lesson_text};
277    use crate::conscience;
278
279    #[derive(Serialize, Deserialize, Default)]
280    struct Manifest {
281        /// The model's identity (`identity`); another model's vectors are not comparable.
282        model: String,
283        dims: usize,
284        /// Lesson id -> hash of the text its vector was made from.
285        lessons: BTreeMap<String, String>,
286    }
287
288    /// A Model2Vec static model: tokenizer + embedding matrix, mean pooling, normalised. The matrix stays
289    /// in the mapped file; only the rows of a text's tokens are read.
290    pub struct Embedder {
291        tokenizer: Tok,
292        map: memmap2::Mmap,
293        embeddings: Tensor,
294        weights: Option<Tensor>,
295        mapping: Option<Tensor>,
296        unk: Option<u32>,
297        normalize: bool,
298    }
299
300    #[allow(clippy::large_enum_variant)] // one per process
301    enum Tok {
302        /// Unigram models (potion-multilingual-128M): the fast path (unigram.rs).
303        Fast(super::unigram::Unigram),
304        Full(tokenizers::Tokenizer),
305    }
306
307    struct Tensor {
308        dtype: safetensors::Dtype,
309        shape: Vec<usize>,
310        start: usize,
311    }
312
313    impl Embedder {
314        pub fn load(dir: &Path) -> Result<Embedder, String> {
315            let json = std::fs::read_to_string(dir.join("tokenizer.json")).map_err(|e| format!("tokenizer: {e}"))?;
316            let tokenizer = match super::unigram::Unigram::parse(&json) {
317                Some(u) => Tok::Fast(u),
318                None => Tok::Full(json.parse().map_err(|e| format!("tokenizer: {e}"))?),
319            };
320            let file = File::open(dir.join("model.safetensors")).map_err(|e| format!("model: {e}"))?;
321            // SAFETY: the model file is read-only data that borg never writes; `--fetch` replaces it by rename.
322            let map = unsafe { memmap2::Mmap::map(&file) }.map_err(|e| format!("model: {e}"))?;
323            let (header, meta) = safetensors::SafeTensors::read_metadata(&map).map_err(|e| format!("model: {e}"))?;
324            let tensor = |name: &str| meta.info(name).map(|i| Tensor { dtype: i.dtype, shape: i.shape.clone(), start: 8 + header + i.data_offsets.0 });
325            let embeddings = tensor("embeddings").or_else(|| tensor("0")).or_else(|| tensor("embedding.weight")).ok_or("model: no embeddings tensor")?;
326            if embeddings.shape.len() != 2 || size(embeddings.dtype).is_none() {
327                return Err("model: unsupported embeddings tensor".into());
328            }
329            let config: Value = std::fs::read(dir.join("config.json")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null);
330            let unk = match &tokenizer {
331                Tok::Full(t) => match t.get_model() {
332                    tokenizers::ModelWrapper::WordPiece(m) => t.token_to_id(&m.unk_token),
333                    tokenizers::ModelWrapper::WordLevel(m) => t.token_to_id(&m.unk_token),
334                    tokenizers::ModelWrapper::BPE(m) => m.unk_token.as_deref().and_then(|u| t.token_to_id(u)),
335                    tokenizers::ModelWrapper::Unigram(_) => None,
336                },
337                // As model2vec-rs: a Unigram model names no unk token, and no token is dropped.
338                Tok::Fast(_) => None,
339            };
340            Ok(Embedder {
341                tokenizer,
342                embeddings,
343                weights: tensor("weights"),
344                mapping: tensor("mapping"),
345                map,
346                unk,
347                normalize: config["normalize"].as_bool().unwrap_or(true),
348            })
349        }
350
351        /// The text's token ids (no special tokens).
352        pub fn ids(&self, text: &str) -> Option<Vec<u32>> {
353            match &self.tokenizer {
354                Tok::Fast(u) => u.ids(text),
355                Tok::Full(t) => t.encode_fast(text, false).ok().map(|e| e.get_ids().to_vec()),
356            }
357        }
358
359        pub fn dims(&self) -> usize {
360            self.embeddings.shape[1]
361        }
362
363        fn value(&self, t: &Tensor, i: usize) -> Option<f64> {
364            let w = size(t.dtype)?;
365            let b = self.map.get(t.start + i * w..t.start + (i + 1) * w)?;
366            Some(match t.dtype {
367                safetensors::Dtype::F64 => f64::from_le_bytes(b.try_into().ok()?),
368                safetensors::Dtype::F32 => f32::from_le_bytes(b.try_into().ok()?) as f64,
369                safetensors::Dtype::F16 => half::f16::from_le_bytes(b.try_into().ok()?).to_f64(),
370                safetensors::Dtype::I8 => b[0] as i8 as f64,
371                safetensors::Dtype::I32 => i32::from_le_bytes(b.try_into().ok()?) as f64,
372                safetensors::Dtype::I64 => i64::from_le_bytes(b.try_into().ok()?) as f64,
373                _ => return None,
374            })
375        }
376
377        /// Model2Vec's `encode` without the 512-token cut: mean of the token rows (each scaled by its
378        /// weight, if the model has weights), normalised.
379        pub fn embed(&self, text: &str) -> Vec<f32> {
380            let (rows, dims) = (self.embeddings.shape[0], self.dims());
381            let mut sum = vec![0.0f64; dims];
382            let Some(ids) = self.ids(text) else { return vec![0.0; dims] };
383            let mut n = 0usize;
384            for id in ids {
385                if Some(id) == self.unk {
386                    continue;
387                }
388                let tok = id as usize;
389                let row = self.mapping.as_ref().and_then(|m| self.value(m, tok)).map_or(tok, |r| r as usize);
390                if row >= rows {
391                    continue;
392                }
393                let scale = self.weights.as_ref().and_then(|w| self.value(w, tok)).unwrap_or(1.0);
394                for (d, s) in sum.iter_mut().enumerate() {
395                    *s += self.value(&self.embeddings, row * dims + d).unwrap_or(0.0) * scale;
396                }
397                n += 1;
398            }
399            let mut v: Vec<f32> = sum.into_iter().map(|s| (s / n.max(1) as f64) as f32).collect();
400            if self.normalize {
401                let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt().max(1e-12);
402                v.iter_mut().for_each(|x| *x /= norm);
403            }
404            v
405        }
406    }
407
408    fn size(d: safetensors::Dtype) -> Option<usize> {
409        use safetensors::Dtype::*;
410        match d {
411            F64 | I64 => Some(8),
412            F32 | I32 => Some(4),
413            F16 => Some(2),
414            I8 => Some(1),
415            _ => None,
416        }
417    }
418
419    /// FNV-1a over the lesson text: whether a lesson's vector is current.
420    fn hash(text: &str) -> String {
421        let mut h: u64 = 0xcbf29ce484222325;
422        for b in text.bytes() {
423            h ^= b as u64;
424            h = h.wrapping_mul(0x100000001b3);
425        }
426        format!("{h:016x}")
427    }
428
429    fn ms(d: std::time::Duration) -> f64 {
430        d.as_secs_f64() * 1000.0
431    }
432
433    /// Stdout goes to /dev/null while it lives. SeekStorm prints status lines (commits) to stdout, which
434    /// is the hook's answer channel; it cannot be muted on an opened index.
435    /// One redirection at a time: two overlapping ones would save and restore each other's /dev/null,
436    /// leaving stdout muted for good (threads in one process, as in the test harness).
437    static QUIET: std::sync::Mutex<()> = std::sync::Mutex::new(());
438
439    struct Quiet(Option<i32>, #[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
440
441    impl Quiet {
442        fn new() -> Quiet {
443            use std::io::Write;
444            let guard = QUIET.lock().unwrap_or_else(|e| e.into_inner());
445            let _ = std::io::stdout().flush();
446            // SAFETY: plain fd calls on stdout and a /dev/null descriptor this function opens and closes.
447            unsafe {
448                let saved = libc::dup(1);
449                let null = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
450                if saved < 0 || null < 0 || libc::dup2(null, 1) < 0 {
451                    for fd in [saved, null] {
452                        if fd >= 0 {
453                            libc::close(fd);
454                        }
455                    }
456                    return Quiet(None, guard);
457                }
458                libc::close(null);
459                Quiet(Some(saved), guard)
460            }
461        }
462    }
463
464    impl Drop for Quiet {
465        fn drop(&mut self) {
466            use std::io::Write;
467            let _ = std::io::stdout().flush();
468            if let Some(saved) = self.0 {
469                // SAFETY: restores the descriptor saved in `new`.
470                unsafe {
471                    libc::dup2(saved, 1);
472                    libc::close(saved);
473                }
474            }
475        }
476    }
477
478    fn runtime() -> Result<tokio::runtime::Runtime, String> {
479        tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| e.to_string())
480    }
481
482    /// An advisory lock beside the index (`vectors.lock`): exclusive while writing, shared while reading.
483    struct Lock(#[allow(dead_code)] File);
484
485    fn lock(index: &Path, exclusive: bool, wait: bool) -> Option<Lock> {
486        use std::os::fd::AsRawFd;
487        let path = index.with_file_name("vectors.lock");
488        let f = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(path).ok()?;
489        let op = if exclusive { libc::LOCK_EX } else { libc::LOCK_SH } | if wait { 0 } else { libc::LOCK_NB };
490        // SAFETY: flock on a file descriptor this function owns; released when the file is closed.
491        (unsafe { libc::flock(f.as_raw_fd(), op) } == 0).then_some(Lock(f))
492    }
493
494    fn read_manifest(index: &Path) -> Option<Manifest> {
495        serde_json::from_str(&std::fs::read_to_string(index.join(MANIFEST)).ok()?).ok()
496    }
497
498    /// The query: embed `text`, search the index, map hits to lesson ids (stored with each vector).
499    pub fn query(index: &Path, model: &Path, text: &str, n: usize) -> Result<Vec<String>, String> {
500        // A write (after `borg learn`) takes well under a second: wait briefly rather than give up.
501        let _lock = (0..15)
502            .find_map(|i| {
503                if i > 0 {
504                    std::thread::sleep(std::time::Duration::from_millis(20));
505                }
506                lock(index, false, false)
507            })
508            .ok_or("index is being written")?;
509        let manifest = read_manifest(index).ok_or("no manifest")?;
510        if manifest.model != identity(model) {
511            return Err("index was built with another model".into());
512        }
513        let t = std::time::Instant::now();
514        let embedder = Embedder::load(model)?;
515        let loaded = t.elapsed();
516        let v = embedder.embed(text);
517        let embedded = t.elapsed() - loaded;
518        // A hook process ends right after: freeing the vocabulary would only cost time.
519        std::mem::forget(embedder);
520        let _quiet = Quiet::new();
521        runtime()?.block_on(async {
522            let idx = open_index(index).await?;
523            let r = idx
524                .search(
525                    String::new(),
526                    Some(Embedding::F32(v)),
527                    QueryType::Union,
528                    SearchMode::Vector { similarity_threshold: None, ann_mode: AnnMode::All },
529                    false,
530                    0,
531                    n,
532                    ResultType::Topk,
533                    false,
534                    Vec::new(),
535                    Vec::new(),
536                    Vec::new(),
537                    Vec::new(),
538                    QueryRewriting::SearchOnly,
539                )
540                .await;
541            let fields = std::collections::HashSet::from(["id".to_string()]);
542            let mut ids = Vec::new();
543            for hit in &r.results {
544                let doc = idx.read().await.get_document(hit.doc_id, false, &None, &fields, &[]).await;
545                if let Some(id) = doc.ok().and_then(|d| d.get("id").and_then(Value::as_str).map(str::to_string))
546                    && !ids.contains(&id)
547                {
548                    ids.push(id);
549                }
550            }
551            idx.close().await;
552            if std::env::var_os("BORG_VECTORS_TRACE").is_some() {
553                eprintln!("vectors: load {:.1} ms, embed {:.1} ms, index {:.1} ms", ms(loaded), ms(embedded), ms(t.elapsed() - loaded - embedded));
554            }
555            Ok(ids)
556        })
557    }
558
559    /// Bring the index of `lessons` up to date: new lessons are added; a changed or removed lesson, a model
560    /// change or `rebuild` rebuilds it (into a fresh directory, swapped in under the lock). The number of
561    /// lessons embedded.
562    pub fn update(lessons: &Path, model: &Path, rebuild: bool) -> Result<usize, String> {
563        let index = super::index_dir(lessons);
564        let _lock = lock(&index, true, true).ok_or("cannot lock the index")?;
565        let current: Vec<conscience::Challenge> = conscience::load_unweighted(lessons, None);
566        let wanted: BTreeMap<String, String> = current.iter().map(|c| (c.id.clone(), hash(&lesson_text(c)))).collect();
567        let old = read_manifest(&index).filter(|_| !rebuild && super::model::built_with(lessons, model) == Some(true));
568        let (fresh, mut manifest) = match old {
569            Some(m) if m.lessons.iter().all(|(id, h)| wanted.get(id) == Some(h)) => (false, m),
570            _ => (true, Manifest { model: identity(model), ..Default::default() }),
571        };
572        let add: Vec<&conscience::Challenge> = current.iter().filter(|c| !manifest.lessons.contains_key(&c.id)).collect();
573        if add.is_empty() && !fresh {
574            return Ok(0);
575        }
576        let embedder = Embedder::load(model)?;
577        manifest.dims = embedder.dims();
578        let docs: Vec<Document> = add
579            .iter()
580            .map(|c| {
581                let v: Vec<Value> = embedder.embed(&lesson_text(c)).into_iter().map(|x| Value::from(x as f64)).collect();
582                Document::from([("vector".to_string(), Value::Array(v)), ("id".to_string(), Value::String(c.id.clone()))])
583            })
584            .collect();
585        let target = if fresh { index.with_file_name("vectors.new") } else { index.clone() };
586        let _quiet = Quiet::new();
587        runtime()?.block_on(async {
588            let idx = if fresh {
589                let _ = std::fs::remove_dir_all(&target);
590                std::fs::create_dir_all(&target).map_err(|e| e.to_string())?;
591                create_index(&target, meta(manifest.dims), &schema(), &Vec::new(), crate::vectors::SEGMENT_BITS, true, Some(1)).await?
592            } else {
593                open_index(&target).await?
594            };
595            idx.index_documents(docs).await;
596            idx.commit().await;
597            idx.close().await;
598            Ok::<(), String>(())
599        })?;
600        for c in &add {
601            manifest.lessons.insert(c.id.clone(), wanted[&c.id].clone());
602        }
603        std::fs::write(target.join(MANIFEST), serde_json::to_string(&manifest).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
604        if fresh {
605            let _ = std::fs::remove_dir_all(&index);
606            std::fs::rename(&target, &index).map_err(|e| e.to_string())?;
607        }
608        Ok(add.len())
609    }
610
611    fn schema() -> Vec<SchemaField> {
612        serde_json::from_str(
613            r#"[{"field":"vector","field_type":"Json","store":false,"index_lexical":false,"index_vector":true},
614                {"field":"id","field_type":"Text","store":true,"index_lexical":false,"index_vector":false}]"#,
615        )
616        .expect("schema")
617    }
618
619    fn meta(dims: usize) -> IndexMetaObject {
620        IndexMetaObject {
621            id: 0,
622            name: "borg-lesson-vectors".into(),
623            lexical_similarity: LexicalSimilarity::Bm25f,
624            tokenizer: TokenizerType::UnicodeAlphanumeric,
625            stemmer: StemmerType::None,
626            stop_words: StopwordType::None,
627            frequent_words: FrequentwordType::None,
628            ngram_indexing: NgramSet::SingleTerm as u8,
629            document_compression: DocumentCompression::Snappy,
630            access_type: AccessType::Mmap,
631            spelling_correction: None,
632            query_completion: None,
633            clustering: Clustering::None,
634            // Vectors come normalised from the embedder: dot product = cosine.
635            inference: Inference::External { dimensions: dims, precision: Precision::F32, quantization: Quantization::None, similarity: VectorSimilarity::Dot },
636        }
637    }
638
639    /// One line per fact: the index's size and how many lessons lack a current vector.
640    pub fn status(lessons: &Path, model: &Path) -> Result<String, String> {
641        let index = super::index_dir(lessons);
642        let m = read_manifest(&index).ok_or(format!("none at {} (`borg vectors --rebuild`)", index.display()))?;
643        let current = conscience::load_unweighted(lessons, None);
644        let stale = current.iter().filter(|c| m.lessons.get(&c.id) != Some(&hash(&lesson_text(c)))).count();
645        let other = if super::model::built_with(lessons, model) == Some(false) { " (built with another model: `borg vectors --rebuild`)" } else { "" };
646        Ok(format!("index: {} lessons with vectors, {stale} of {} without a current one{other} ({})", m.lessons.len(), current.len(), index.display()))
647    }
648
649    /// The offline check of the fast Unigram path (unigram.rs): the token ids of each text against
650    /// `tokenizers` itself. (texts with identical ids, texts compared, the first text that differs)
651    pub fn parity(model: &Path, texts: &[String]) -> Result<(usize, usize, Option<String>), String> {
652        let json = std::fs::read_to_string(model.join("tokenizer.json")).map_err(|e| e.to_string())?;
653        let fast = super::unigram::Unigram::parse(&json).ok_or("not a Unigram tokenizer")?;
654        let full: tokenizers::Tokenizer = json.parse().map_err(|e| format!("{e}"))?;
655        let (mut same, mut first) = (0, None);
656        for t in texts {
657            let want = full.encode_fast(t.as_str(), false).map_err(|e| e.to_string())?.get_ids().to_vec();
658            if fast.ids(t).as_deref() == Some(want.as_slice()) {
659                same += 1;
660            } else if first.is_none() {
661                first = Some(t.clone());
662            }
663        }
664        Ok((same, texts.len(), first))
665    }
666
667    /// Download the model's files at the pinned revision into `dir`, each checked against its sha256
668    /// before it replaces anything (curl; never called by a hook).
669    pub fn fetch(dir: &Path) -> Result<(), String> {
670        std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
671        let base = std::env::var("BORG_VECTOR_URL").unwrap_or_else(|_| format!("https://huggingface.co/{MODEL_REPO}/resolve/{MODEL_REVISION}"));
672        for (name, sha) in MODEL_FILES {
673            let path = dir.join(name);
674            let part = dir.join(format!("{name}.part"));
675            if crate::update::sha256_file(&path).as_deref() == Some(sha) {
676                continue;
677            }
678            eprintln!("fetching {name}");
679            let ok = std::process::Command::new("curl")
680                .args(["-fL", "--retry", "3", "-o"])
681                .arg(&part)
682                .arg(format!("{base}/{name}"))
683                .status()
684                .map_err(|e| format!("curl: {e}"))?
685                .success();
686            let got = crate::update::sha256_file(&part);
687            if !ok || got.as_deref() != Some(sha) {
688                let _ = std::fs::remove_file(&part);
689                return Err(format!("{name}: download failed or sha256 mismatch (got {}, want {sha})", got.unwrap_or_default()));
690            }
691            std::fs::rename(&part, &path).map_err(|e| e.to_string())?;
692        }
693        std::fs::write(dir.join("REVISION"), MODEL_REVISION).map_err(|e| e.to_string())?;
694        Ok(())
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    fn lesson(id: &str, text: &str) -> Challenge {
703        Challenge { id: id.into(), question: text.into(), situations: vec![text.into()], consequence: text.into(), ..Default::default() }
704    }
705
706    fn ids(v: &[&Challenge]) -> Vec<String> {
707        v.iter().map(|c| c.id.clone()).collect()
708    }
709
710    fn ranked(v: &[&str]) -> Vec<String> {
711        v.iter().map(|s| s.to_string()).collect()
712    }
713
714    #[test]
715    fn extend_appends_vector_hits_after_bm25_once_each_up_to_k() {
716        let pool = vec![lesson("a", "x"), lesson("b", "x"), lesson("c", "x"), lesson("d", "x")];
717        let bm25 = vec![&pool[0], &pool[1]];
718        let r = ranked(&["b", "c", "gone", "d", "c"]);
719        assert_eq!(ids(&extend(bm25.clone(), &r, &pool, "", 1)), ["a", "b", "c"]);
720        assert_eq!(ids(&extend(bm25.clone(), &r, &pool, "", 10)), ["a", "b", "c", "d"]);
721        assert_eq!(ids(&extend(bm25.clone(), &r, &pool, "", 0)), ["a", "b"]);
722        assert_eq!(ids(&extend(bm25, &[], &pool, "", 10)), ["a", "b"]);
723    }
724
725    #[test]
726    fn extend_keeps_the_scope_rule_and_drops_done_dormant_and_superseded() {
727        let mut scoped = lesson("scoped", "x");
728        scoped.scope = vec!["Orion".into()];
729        let mut dormant = lesson("dormant", "x");
730        dormant.dormant = true;
731        let old = lesson("old", "x");
732        let mut new = lesson("new", "x");
733        new.supersedes = vec!["old".into()];
734        // "done" is not in the pool: the Stop step passes only the lessons not done this session.
735        let pool = vec![scoped, dormant, old, new, lesson("free", "x")];
736        let r = ranked(&["done", "scoped", "dormant", "old", "new", "free"]);
737        assert_eq!(ids(&extend(vec![], &r, &pool, "work in the payment service", 10)), ["new", "free"]);
738        assert_eq!(ids(&extend(vec![], &r, &pool, "edit OrionConfig.java", 10)), ["scoped", "new", "free"]);
739    }
740
741    #[test]
742    fn shortlist_without_an_index_is_bm25_alone() {
743        let dir = std::env::temp_dir().join(format!("borg-vectors-none-{}", std::process::id()));
744        let pool = vec![lesson("a", "deadlock in the pool"), lesson("b", "retry budget")];
745        let (list, added) = shortlist(&dir.join("lessons.jsonl"), &pool, "a deadlock", "a deadlock", 20);
746        assert_eq!((ids(&list), added), (vec!["a".to_string()], 0));
747        assert!(recall(&dir.join("lessons.jsonl"), "a deadlock", 10).is_empty());
748    }
749
750    #[test]
751    fn query_tail_keeps_what_the_model_reads() {
752        let long = "x".repeat(crate::predict::TAIL + 5) + "end";
753        let t = query_tail(&long);
754        assert_eq!(t.chars().count(), crate::predict::TAIL);
755        assert!(t.ends_with("end"));
756    }
757
758    /// A tiny Model2Vec model: German and English words for the same thing share a row.
759    #[cfg(feature = "vectors")]
760    fn fake_model(dir: &Path) {
761        let words = [
762            ("<unk>", [0.0f32, 0.0, 0.0, 0.0]),
763            ("\u{2581}deadlock", [1.0, 0.0, 0.0, 0.0]),
764            ("\u{2581}verklemmung", [1.0, 0.0, 0.0, 0.0]),
765            ("\u{2581}retry", [0.0, 1.0, 0.0, 0.0]),
766            ("\u{2581}wiederholung", [0.0, 1.0, 0.0, 0.0]),
767            ("\u{2581}cache", [0.0, 0.0, 1.0, 0.0]),
768            ("\u{2581}orion", [0.0, 0.0, 0.0, 1.0]),
769            ("\u{2581}", [0.0, 0.0, 0.0, 0.0]),
770        ];
771        std::fs::create_dir_all(dir).unwrap();
772        let vocab: Vec<serde_json::Value> = words.iter().map(|(w, _)| serde_json::json!([w, -1.0])).collect();
773        let tok = serde_json::json!({"normalizer": null,
774            "pre_tokenizer": {"type": "Metaspace", "replacement": "\u{2581}", "prepend_scheme": "always", "split": false},
775            "model": {"type": "Unigram", "unk_id": 0, "byte_fallback": false, "vocab": vocab}});
776        std::fs::write(dir.join("tokenizer.json"), tok.to_string()).unwrap();
777        std::fs::write(dir.join("config.json"), r#"{"normalize": true}"#).unwrap();
778        let bytes: Vec<u8> = words.iter().flat_map(|(_, v)| v.iter().flat_map(|x| x.to_le_bytes())).collect();
779        let view = safetensors::tensor::TensorView::new(safetensors::Dtype::F32, vec![words.len(), 4], &bytes).unwrap();
780        std::fs::write(dir.join("model.safetensors"), safetensors::serialize([("embeddings", view)], &None).unwrap()).unwrap();
781    }
782
783    #[cfg(feature = "vectors")]
784    fn write_lessons(path: &Path, lessons: &[(&str, &str)]) {
785        let lines: Vec<String> = lessons
786            .iter()
787            .enumerate()
788            .map(|(i, (id, t))| serde_json::json!({"id": id, "origin_ms": i, "question": t, "situations": [t], "consequence": t}).to_string())
789            .collect();
790        std::fs::write(path, lines.join("\n") + "\n").unwrap();
791    }
792
793    /// `borg doctor`'s lines for a given model directory (no environment read).
794    #[cfg(feature = "vectors")]
795    #[test]
796    fn doctor_warns_about_a_missing_model_or_index_with_its_fix() {
797        use crate::collective::{Route, Share};
798        use crate::harness::Level;
799        let levels = |v: &[(Level, String)]| v.iter().map(|(l, _)| *l).collect::<Vec<_>>();
800        let root = std::env::temp_dir().join(format!("borg-vectors-doctor-{}", std::process::id()));
801        let _ = std::fs::remove_dir_all(&root);
802        let (model, team) = (root.join("model"), root.join("team"));
803        let route = |name: &str, home: PathBuf| Route::Collective { name: name.into(), home, share: Share::Local };
804        let routes = vec![route("team", team.clone()), route("empty", root.join("empty")), Route::Off];
805        let none = doctor::against(&routes, &root.join("no-model"));
806        assert_eq!(levels(&none), [Level::Warn]);
807        assert!(none[0].1.contains("borg vectors --fetch"), "{none:?}");
808
809        fake_model(&model);
810        std::fs::create_dir_all(&team).unwrap();
811        let lessons = team.join("lessons.jsonl");
812        write_lessons(&lessons, &[("dl", "deadlock")]);
813        let missing = doctor::against(&routes, &model);
814        assert_eq!(levels(&missing), [Level::Ok, Level::Warn], "only a store with lessons needs an index: {missing:?}");
815        assert!(missing[1].1.contains(&format!("borg vectors --rebuild --store {}", lessons.display())), "{missing:?}");
816        engine::update(&lessons, &model, false).unwrap();
817        assert_eq!(levels(&doctor::against(&routes, &model)), [Level::Ok]);
818        std::fs::write(model.join("REVISION"), "another").unwrap();
819        let other = doctor::against(&routes, &model);
820        assert!(levels(&other) == [Level::Ok, Level::Warn] && other[1].1.contains("another model"), "{other:?}");
821        let _ = std::fs::remove_dir_all(&root);
822    }
823
824    /// One test for everything that reads the environment (BORG_VECTOR_MODEL, BORG_VECTORS), so no two
825    /// tests race on it.
826    #[cfg(feature = "vectors")]
827    #[test]
828    fn index_recalls_across_languages_updates_incrementally_and_fails_open() {
829        let root = std::env::temp_dir().join(format!("borg-vectors-e2e-{}", std::process::id()));
830        let _ = std::fs::remove_dir_all(&root);
831        let (model, store) = (root.join("model"), root.join("store"));
832        fake_model(&model);
833        std::fs::create_dir_all(&store).unwrap();
834        let lessons = store.join("lessons.jsonl");
835        write_lessons(&lessons, &[("dl", "deadlock"), ("rt", "retry"), ("ca", "cache")]);
836        // SAFETY: only this test sets these variables (see above).
837        unsafe {
838            std::env::set_var("BORG_VECTOR_MODEL", &model);
839            std::env::remove_var("BORG_VECTORS");
840        }
841        assert_eq!(doctor::hint(), None, "the model is there");
842        assert_eq!(refresh(&lessons), 3, "no index yet: the first refresh builds it");
843        assert_eq!(refresh(&lessons), 0, "nothing new, nothing embedded");
844        // German work, English lessons: BM25 finds nothing, the vectors find the deadlock lesson.
845        let pool = conscience::load(&lessons, None);
846        assert!(conscience::select_scoped(&pool, "verklemmung", "verklemmung", 20).is_empty());
847        assert_eq!(recall(&lessons, "verklemmung", 1), ["dl"]);
848        let (list, added) = shortlist(&lessons, &pool, "verklemmung wiederholung", "", 20);
849        assert_eq!(added, 3);
850        let top2: HashSet<String> = ids(&list[..2]).into_iter().collect();
851        assert_eq!(top2, HashSet::from(["dl".to_string(), "rt".to_string()]));
852        // A new lesson is added; a changed one rebuilds; both are found.
853        write_lessons(&lessons, &[("dl", "deadlock"), ("rt", "retry"), ("ca", "cache"), ("mv", "orion")]);
854        assert_eq!(refresh(&lessons), 1);
855        assert_eq!(recall(&lessons, "orion", 1), ["mv"]);
856        write_lessons(&lessons, &[("dl", "cache"), ("rt", "retry"), ("ca", "deadlock"), ("mv", "orion")]);
857        assert_eq!(refresh(&lessons), 4, "a changed lesson rebuilds the index");
858        assert_eq!(recall(&lessons, "verklemmung", 1), ["ca"]);
859        // BORG_VECTORS=off: BM25 only.
860        unsafe { std::env::set_var("BORG_VECTORS", "off") };
861        assert!(recall(&lessons, "verklemmung", 5).is_empty());
862        let (list, added) = shortlist(&lessons, &pool, "verklemmung", "", 20);
863        assert_eq!((list.len(), added), (0, 0));
864        let off = doctor::checks(&[]);
865        assert!(off.len() == 1 && off[0].0 == crate::harness::Level::Ok && off[0].1.contains("off"), "{off:?}");
866        unsafe { std::env::set_var("BORG_VECTOR_MODEL", root.join("no-model")) };
867        assert_eq!(doctor::hint(), None, "off by choice: no hint");
868        unsafe { std::env::set_var("BORG_VECTOR_MODEL", &model) };
869        unsafe { std::env::remove_var("BORG_VECTORS") };
870        // Fail open: another model's index, a missing model, a missing index.
871        std::fs::write(model.join("REVISION"), "another").unwrap();
872        assert!(recall(&lessons, "verklemmung", 5).is_empty(), "vectors of another model are not compared");
873        std::fs::remove_file(model.join("REVISION")).unwrap();
874        assert_eq!(recall(&lessons, "verklemmung", 1), ["ca"]);
875        unsafe { std::env::set_var("BORG_VECTOR_MODEL", root.join("no-model")) };
876        assert!(recall(&lessons, "verklemmung", 5).is_empty());
877        assert_eq!(refresh(&lessons), 0);
878        assert!(doctor::hint().is_some_and(|h| h.contains("borg vectors --fetch")));
879        assert_eq!(doctor::checks(&[])[0].0, crate::harness::Level::Warn);
880        unsafe { std::env::set_var("BORG_VECTOR_MODEL", &model) };
881        std::fs::remove_dir_all(index_dir(&lessons)).unwrap();
882        assert!(recall(&lessons, "verklemmung", 5).is_empty());
883        // As after a mesh sync (borg-mesh sync.rs `reindex`): from a blocking thread of a running runtime.
884        let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
885        let path = lessons.clone();
886        let n = rt.block_on(async { tokio::task::spawn_blocking(move || refresh(&path)).await.unwrap() });
887        assert_eq!(n, 4, "a lost index is rebuilt by the next refresh");
888        assert_eq!(recall(&lessons, "verklemmung", 1), ["ca"]);
889        unsafe { std::env::remove_var("BORG_VECTOR_MODEL") };
890        let _ = std::fs::remove_dir_all(&root);
891    }
892}