1use 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
37pub const DEFAULT_K: usize = 10;
41
42pub const SHORTLIST: usize = 30;
46
47const OVER_FETCH: usize = 30;
50
51pub const SEGMENT_BITS: usize = 11;
54
55pub fn k() -> usize {
57 std::env::var("BORG_VECTOR_K").ok().and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_K)
58}
59
60pub fn lesson_text(c: &Challenge) -> String {
62 format!("{}\n{}\n{}", c.question, c.situations.join("\n"), c.consequence)
63}
64
65pub 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
93pub 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 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
110pub 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
116pub 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
138pub 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
168pub 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 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 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 model: String,
283 dims: usize,
284 lessons: BTreeMap<String, String>,
286 }
287
288 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)] enum Tok {
302 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 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 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 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 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 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 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 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 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 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 (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 pub fn query(index: &Path, model: &Path, text: &str, n: usize) -> Result<Vec<String>, String> {
500 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 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 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 inference: Inference::External { dimensions: dims, precision: Precision::F32, quantization: Quantization::None, similarity: VectorSimilarity::Dot },
636 }
637 }
638
639 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 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 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 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 #[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 #[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 #[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 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 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 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 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 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 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}