1use std::collections::BTreeMap;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13
14use packset_core::record;
15use serde_json::{json, Value};
16
17use crate::store::Record;
18
19pub const BIN_VARS: &[&str] = &["PACKSET_MILLI", "INSIDE_MILLI", "GROK_INSIDE_MILLI"];
21
22fn backfilled() -> &'static std::sync::Mutex<std::collections::HashSet<(PathBuf, String)>> {
30 static SEEN: std::sync::OnceLock<
31 std::sync::Mutex<std::collections::HashSet<(PathBuf, String)>>,
32 > = std::sync::OnceLock::new();
33 SEEN.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
34}
35
36fn forget_backfill(dir: &Path) {
38 if let Ok(mut seen) = backfilled().lock() {
39 seen.retain(|(indexed, _)| indexed != dir);
40 }
41}
42
43#[must_use]
48pub fn binary() -> Option<PathBuf> {
49 for var in BIN_VARS {
50 if let Some(raw) = std::env::var_os(var) {
51 let path = PathBuf::from(raw);
52 if is_executable(&path) {
53 return Some(path);
54 }
55 }
56 }
57 let here = std::env::current_exe().ok()?;
62 let root = here.parent()?.parent()?.parent()?;
63 for candidate in [
64 root.join("bin/packset-milli"),
65 root.join("crates/packset-milli/target/release/packset-milli"),
66 root.join("bin/inside-milli"),
67 ] {
68 if is_executable(&candidate) {
69 return Some(candidate);
70 }
71 }
72 for name in ["packset-milli", "inside-milli"] {
73 if let Some(found) = which(name) {
74 return Some(found);
75 }
76 }
77 None
78}
79
80fn is_executable(path: &Path) -> bool {
81 if !path.is_file() {
82 return false;
83 }
84 #[cfg(unix)]
85 {
86 use std::os::unix::fs::PermissionsExt;
87 std::fs::metadata(path)
88 .map(|m| m.permissions().mode() & 0o111 != 0)
89 .unwrap_or(false)
90 }
91 #[cfg(not(unix))]
92 true
93}
94
95fn which(name: &str) -> Option<PathBuf> {
96 let paths = std::env::var_os("PATH")?;
97 std::env::split_paths(&paths)
98 .map(|dir| dir.join(name))
99 .find(|candidate| is_executable(candidate))
100}
101
102fn run(argv: &[String], stdin: Option<&str>) -> Option<Value> {
104 let binary = binary()?;
105 let mut child = Command::new(binary)
106 .args(argv)
107 .stdin(Stdio::piped())
108 .stdout(Stdio::piped())
109 .stderr(Stdio::piped())
110 .spawn()
111 .ok()?;
112 if let Some(text) = stdin {
113 child.stdin.take()?.write_all(text.as_bytes()).ok()?;
114 } else {
115 drop(child.stdin.take());
116 }
117 let out = child.wait_with_output().ok()?;
118 if !out.status.success() {
119 return None;
120 }
121 let raw = String::from_utf8_lossy(&out.stdout);
122 let value: Value = serde_json::from_str(raw.trim()).ok()?;
123 value.is_object().then_some(value)
124}
125
126#[must_use]
132pub fn document_id(field: &str, workspace: &str, atom_id: &str) -> String {
133 match field {
134 "user" => "user".into(),
135 "memory" => format!("memory_{}", short_digest(workspace)),
136 _ => atom_id.to_string(),
137 }
138}
139
140fn short_digest(text: &str) -> String {
142 let digest = sha256(text.as_bytes());
143 digest
144 .iter()
145 .take(8)
146 .map(|b| format!("{b:02x}"))
147 .collect::<String>()
148}
149
150fn sha256(message: &[u8]) -> [u8; 32] {
152 const K: [u32; 64] = [
153 0x428a_2f98,
154 0x7137_4491,
155 0xb5c0_fbcf,
156 0xe9b5_dba5,
157 0x3956_c25b,
158 0x59f1_11f1,
159 0x923f_82a4,
160 0xab1c_5ed5,
161 0xd807_aa98,
162 0x1283_5b01,
163 0x2431_85be,
164 0x550c_7dc3,
165 0x72be_5d74,
166 0x80de_b1fe,
167 0x9bdc_06a7,
168 0xc19b_f174,
169 0xe49b_69c1,
170 0xefbe_4786,
171 0x0fc1_9dc6,
172 0x240c_a1cc,
173 0x2de9_2c6f,
174 0x4a74_84aa,
175 0x5cb0_a9dc,
176 0x76f9_88da,
177 0x983e_5152,
178 0xa831_c66d,
179 0xb003_27c8,
180 0xbf59_7fc7,
181 0xc6e0_0bf3,
182 0xd5a7_9147,
183 0x06ca_6351,
184 0x1429_2967,
185 0x27b7_0a85,
186 0x2e1b_2138,
187 0x4d2c_6dfc,
188 0x5338_0d13,
189 0x650a_7354,
190 0x766a_0abb,
191 0x81c2_c92e,
192 0x9272_2c85,
193 0xa2bf_e8a1,
194 0xa81a_664b,
195 0xc24b_8b70,
196 0xc76c_51a3,
197 0xd192_e819,
198 0xd699_0624,
199 0xf40e_3585,
200 0x106a_a070,
201 0x19a4_c116,
202 0x1e37_6c08,
203 0x2748_774c,
204 0x34b0_bcb5,
205 0x391c_0cb3,
206 0x4ed8_aa4a,
207 0x5b9c_ca4f,
208 0x682e_6ff3,
209 0x748f_82ee,
210 0x78a5_636f,
211 0x84c8_7814,
212 0x8cc7_0208,
213 0x90be_fffa,
214 0xa450_6ceb,
215 0xbef9_a3f7,
216 0xc671_78f2,
217 ];
218 let mut h: [u32; 8] = [
219 0x6a09_e667,
220 0xbb67_ae85,
221 0x3c6e_f372,
222 0xa54f_f53a,
223 0x510e_527f,
224 0x9b05_688c,
225 0x1f83_d9ab,
226 0x5be0_cd19,
227 ];
228 let mut data = message.to_vec();
229 let bits = (message.len() as u64) * 8;
230 data.push(0x80);
231 while data.len() % 64 != 56 {
232 data.push(0);
233 }
234 data.extend_from_slice(&bits.to_be_bytes());
235
236 let (blocks, _) = data.as_chunks::<64>();
237 for block in blocks {
238 let mut w = [0u32; 64];
239 let (words, _) = block.as_chunks::<4>();
240 for (i, chunk) in words.iter().enumerate() {
241 w[i] = u32::from_be_bytes(*chunk);
242 }
243 for i in 16..64 {
244 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
245 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
246 w[i] = w[i - 16]
247 .wrapping_add(s0)
248 .wrapping_add(w[i - 7])
249 .wrapping_add(s1);
250 }
251 let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) =
252 (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);
253 for i in 0..64 {
254 let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
255 let ch = (e & f) ^ ((!e) & g);
256 let t1 = hh
257 .wrapping_add(s1)
258 .wrapping_add(ch)
259 .wrapping_add(K[i])
260 .wrapping_add(w[i]);
261 let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
262 let maj = (a & b) ^ (a & c) ^ (b & c);
263 let t2 = s0.wrapping_add(maj);
264 hh = g;
265 g = f;
266 f = e;
267 e = d.wrapping_add(t1);
268 d = c;
269 c = b;
270 b = a;
271 a = t1.wrapping_add(t2);
272 }
273 for (slot, value) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) {
274 *slot = slot.wrapping_add(value);
275 }
276 }
277 let mut out = [0u8; 32];
278 for (i, word) in h.iter().enumerate() {
279 out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
280 }
281 out
282}
283
284#[must_use]
286pub fn atom_document(atom: &Record) -> Value {
287 let entities: Vec<String> = atom
288 .get("entities")
289 .and_then(Value::as_array)
290 .map(|items| {
291 items
292 .iter()
293 .map(|i| i.as_str().map_or_else(|| i.to_string(), str::to_string))
294 .collect()
295 })
296 .unwrap_or_default();
297 let id = atom.get("id").and_then(Value::as_str).unwrap_or("");
298 json!({
299 "id": document_id("atom", "", id),
300 "field": "atom",
301 "kind": atom.get("kind").and_then(Value::as_str).unwrap_or("atom"),
302 "text": atom.get("text").and_then(Value::as_str).unwrap_or(""),
303 "entities": entities.join(" "),
304 "workspace": atom.get("workspace").and_then(Value::as_str).unwrap_or(""),
305 "set": atom.get("set").and_then(Value::as_str).unwrap_or(""),
306 "trust": match atom.get("trust") {
307 None | Some(Value::Null) => 1.0,
308 Some(other) => other.as_f64().unwrap_or(1.0),
309 },
310 })
311}
312
313#[must_use]
315pub fn pack_documents(workspace: &str, user: &str, memory: &str, atoms: &[Record]) -> Vec<Value> {
316 let mut docs = Vec::new();
317 if !user.is_empty() {
318 docs.push(json!({
319 "id": document_id("user", workspace, ""),
320 "field": "user", "kind": "user", "text": user, "entities": "",
321 "workspace": workspace, "trust": 1.5,
322 }));
323 }
324 if !memory.is_empty() {
325 docs.push(json!({
326 "id": document_id("memory", workspace, ""),
327 "field": "memory", "kind": "memory", "text": memory, "entities": "",
328 "workspace": workspace, "trust": 1.25,
329 }));
330 }
331 let now = packset_core::clock::utcnow();
332 for atom in atoms {
333 if record::is_live(atom, &now) || record::is_due(atom, &now) {
334 docs.push(atom_document(atom));
335 }
336 }
337 docs
338}
339
340fn jsonl(docs: &[Value]) -> String {
341 let mut out = String::new();
342 for doc in docs {
343 if let Ok(line) = serde_json::to_string(doc) {
344 out.push_str(&line);
345 out.push('\n');
346 }
347 }
348 out
349}
350
351#[must_use]
353pub fn upsert(docs: &[Value], index_dir: &Path) -> bool {
354 if docs.is_empty() {
355 return true;
356 }
357 let argv = vec![
358 "index".into(),
359 "--index".into(),
360 index_dir.display().to_string(),
361 ];
362 run(&argv, Some(&jsonl(docs))).is_some()
363}
364
365#[must_use]
367pub fn delete(ids: &[String], index_dir: &Path) -> bool {
368 if ids.is_empty() {
369 return true;
370 }
371 let argv = vec![
372 "delete".into(),
373 "--index".into(),
374 index_dir.display().to_string(),
375 ];
376 let payload = serde_json::to_string(ids).unwrap_or_else(|_| "[]".into());
377 run(&argv, Some(&payload)).is_some()
378}
379
380#[derive(Debug, Clone, Copy)]
385pub struct Corpus<'a> {
386 pub workspace: &'a str,
388 pub user: &'a str,
390 pub memory: &'a str,
392 pub atoms: &'a [Record],
394}
395
396#[must_use]
401pub fn replace(corpus: Corpus<'_>, dir: &Path) -> bool {
402 forget_backfill(dir);
403 let docs = pack_documents(corpus.workspace, corpus.user, corpus.memory, corpus.atoms);
404 let argv = vec![
405 "index".into(),
406 "--index".into(),
407 dir.display().to_string(),
408 "--replace".into(),
409 ];
410 run(&argv, Some(&jsonl(&docs))).is_some()
411}
412
413#[must_use]
415pub fn reindex_atoms(atoms: &[Record], dir: &Path) -> bool {
416 let now = packset_core::clock::utcnow();
417 let docs: Vec<Value> = atoms
418 .iter()
419 .filter(|a| {
420 a.get("id")
421 .and_then(Value::as_str)
422 .is_some_and(|id| !id.is_empty())
423 && (record::is_live(a, &now) || record::is_due(a, &now))
424 })
425 .map(atom_document)
426 .collect();
427 upsert(&docs, dir)
428}
429
430#[must_use]
432pub fn index_ready(dir: &Path) -> bool {
433 dir.join("data.mdb").exists()
434}
435
436#[must_use]
443pub fn filter_atom_hits(hits: &[Value], live: &[Record], set: Option<&str>) -> Vec<Value> {
444 let by_id: BTreeMap<&str, &Record> = live
445 .iter()
446 .filter_map(|a| a.get("id").and_then(Value::as_str).map(|id| (id, a)))
447 .collect();
448 hits.iter()
449 .filter(|hit| !matches!(hit["field"].as_str(), Some("user" | "memory")))
450 .filter_map(|hit| {
451 let id = hit["id"].as_str()?;
452 let atom = by_id.get(id)?;
453 if let Some(name) = set {
454 if atom.get("set").and_then(Value::as_str) != Some(name) {
455 return None;
456 }
457 }
458 let mut hit = hit.clone();
461 for key in ["ts", "kind", "due_at"] {
462 if hit.get(key).is_none_or(Value::is_null) {
463 if let Some(value) = atom.get(key) {
464 hit[key] = value.clone();
465 }
466 }
467 }
468 Some(hit)
469 })
470 .collect()
471}
472
473fn ensure_atoms(corpus: Corpus<'_>, dir: &Path, set: Option<&str>) -> bool {
475 let atoms = corpus.atoms;
476 if !index_ready(dir) {
477 if set.is_some() {
480 return reindex_atoms(atoms, dir);
481 }
482 return replace(corpus, dir);
483 }
484 match set {
485 Some(name) => {
490 let key = (dir.to_path_buf(), name.to_string());
491 if backfilled().lock().is_ok_and(|seen| seen.contains(&key)) {
492 return true;
493 }
494 let now = packset_core::clock::utcnow();
495 let docs: Vec<Value> = atoms
496 .iter()
497 .filter(|a| {
498 a.get("set").and_then(Value::as_str) == Some(name)
499 && (record::is_live(a, &now) || record::is_due(a, &now))
500 })
501 .map(atom_document)
502 .collect();
503 let done = upsert(&docs, dir);
504 if done {
505 if let Ok(mut seen) = backfilled().lock() {
506 seen.insert(key);
507 }
508 }
509 done
510 }
511 None => true,
512 }
513}
514
515#[must_use]
517pub fn search(
518 corpus: Corpus<'_>,
519 query: &str,
520 limit: usize,
521 dir: &Path,
522 set: Option<&str>,
523) -> Option<Vec<Value>> {
524 let (workspace, atoms) = (corpus.workspace, corpus.atoms);
525 binary()?;
526 if !ensure_atoms(corpus, dir, set) {
527 return None;
528 }
529 let once = |q: &str| -> Option<Vec<Value>> {
530 let mut argv = vec![
531 "search".into(),
532 "--index".into(),
533 dir.display().to_string(),
534 "--q".into(),
535 q.to_string(),
536 "--limit".into(),
537 limit.to_string(),
538 ];
539 if !workspace.is_empty() {
540 argv.push("--workspace".into());
541 argv.push(workspace.to_string());
542 }
543 if let Some(name) = set {
544 argv.push("--set".into());
545 argv.push(name.to_string());
546 }
547 let payload = run(&argv, None)?;
548 let raw = payload.get("hits")?.as_array()?;
549 let hits: Vec<Value> = raw.iter().filter(|h| h.is_object()).cloned().collect();
550 Some(filter_atom_hits(&hits, atoms, set))
551 };
552
553 let mut atom_hits = once(query)?;
554 if atom_hits.is_empty() && !query.trim().is_empty() {
555 if !reindex_atoms(atoms, dir) {
560 return None;
561 }
562 atom_hits = once(query)?;
563 }
564 Some(atom_hits)
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570
571 fn record(value: Value) -> Record {
572 value.as_object().unwrap().clone()
573 }
574
575 #[test]
576 fn an_index_hit_carries_the_records_stamp() {
577 let live = vec![record(json!({
578 "id": "a1", "kind": "lesson", "text": "one",
579 "ts": "2026-09-01T00:00:00.000Z", "due_at": "2026-09-20T00:00:00.000Z"
580 }))];
581 let hits = vec![
582 json!({"field": "atom", "id": "a1", "text": "one", "score": 1.0}),
583 json!({"field": "atom", "id": "gone", "text": "two", "score": 0.5}),
584 json!({"field": "user", "id": null, "text": "card", "score": 0.4}),
585 ];
586 let kept = filter_atom_hits(&hits, &live, None);
587 assert_eq!(kept.len(), 1, "{kept:?}");
588 assert_eq!(kept[0]["ts"], json!("2026-09-01T00:00:00.000Z"));
589 assert_eq!(kept[0]["kind"], json!("lesson"));
590 assert_eq!(kept[0]["due_at"], json!("2026-09-20T00:00:00.000Z"));
591 assert_eq!(kept[0]["score"], json!(1.0));
592 }
593
594 #[test]
595 fn the_digest_is_the_one_the_other_writer_computes() {
596 assert_eq!(short_digest(""), "e3b0c44298fc1c14");
598 assert_eq!(short_digest("abc"), "ba7816bf8f01cfea");
600 }
601
602 #[test]
603 fn sha256_matches_the_known_vectors() {
604 let empty = sha256(b"");
605 assert_eq!(empty[0], 0xe3);
606 assert_eq!(empty[31], 0x55);
607 let abc = sha256(b"abc");
608 assert_eq!(abc[0], 0xba);
609 assert_eq!(abc[31], 0xad);
610 let long = sha256(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq");
612 assert_eq!(long[0], 0x24);
613 assert_eq!(long[31], 0xc1);
614 assert_eq!(
615 short_digest("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
616 "248d6a61d20638b8"
617 );
618 }
619
620 #[test]
621 fn a_workspace_card_keys_on_a_digest_not_a_name() {
622 let key = document_id("memory", "git:github.com/HaoZeke/vissue", "");
625 assert!(key.starts_with("memory_"), "{key}");
626 assert!(
627 key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
628 "{key}"
629 );
630 assert_eq!(document_id("user", "anything", ""), "user");
631 assert_eq!(document_id("atom", "w", "abc123"), "abc123");
632 }
633
634 #[test]
635 fn an_atom_document_flattens_the_entities() {
636 let doc = atom_document(&record(json!({
637 "id": "a", "kind": "voice", "text": "x",
638 "entities": ["one", "two"], "workspace": "w", "trust": 2.0
639 })));
640 assert_eq!(doc["entities"], json!("one two"));
641 assert_eq!(doc["trust"], json!(2.0));
642 assert_eq!(doc["set"], json!(""), "absent is empty, not missing");
643 }
644
645 #[test]
646 fn a_pack_projects_its_cards_only_when_they_say_something() {
647 let docs = pack_documents("w", "", "", &[]);
648 assert!(docs.is_empty(), "{docs:?}");
649 let docs = pack_documents("w", "seat card", "workspace card", &[]);
650 assert_eq!(docs.len(), 2);
651 assert!(docs[0]["trust"].as_f64().unwrap() > docs[1]["trust"].as_f64().unwrap());
652 }
653
654 #[test]
655 fn an_expired_atom_is_not_projected() {
656 let atoms = vec![
657 record(json!({"id": "live", "text": "a", "workspace": "w"})),
658 record(json!({
659 "id": "gone", "text": "b", "workspace": "w",
660 "valid_to": "2000-01-01T00:00:00.000Z"
661 })),
662 ];
663 let docs = pack_documents("w", "", "", &atoms);
664 let ids: Vec<&str> = docs.iter().filter_map(|d| d["id"].as_str()).collect();
665 assert_eq!(ids, vec!["live"], "{docs:?}");
666 }
667
668 #[test]
669 fn the_pack_decides_membership_and_not_the_index() {
670 let live = vec![record(json!({"id": "kept", "text": "a", "set": "review"}))];
673 let hits = vec![
674 json!({"field": "atom", "id": "kept", "text": "a"}),
675 json!({"field": "atom", "id": "vanished", "text": "b"}),
676 json!({"field": "user", "id": "user", "text": "prose"}),
677 ];
678 let filtered = filter_atom_hits(&hits, &live, Some("review"));
679 let ids: Vec<&str> = filtered.iter().filter_map(|h| h["id"].as_str()).collect();
680 assert_eq!(ids, vec!["kept"], "{filtered:?}");
681
682 assert!(filter_atom_hits(&hits, &live, Some("other")).is_empty());
684 }
685
686 #[test]
687 fn prose_from_the_index_is_always_dropped() {
688 let live = vec![record(json!({"id": "a", "text": "x"}))];
689 let hits = vec![
690 json!({"field": "user", "id": "user", "text": "p"}),
691 json!({"field": "memory", "id": "memory_x", "text": "q"}),
692 ];
693 assert!(
694 filter_atom_hits(&hits, &live, None).is_empty(),
695 "prose comes from the pack, so the index copy can be stale"
696 );
697 }
698
699 #[test]
700 fn an_empty_batch_is_a_success_without_running_anything() {
701 let dir = tempfile::tempdir().unwrap();
702 assert!(upsert(&[], dir.path()));
703 assert!(delete(&[], dir.path()));
704 }
705
706 #[test]
707 fn a_missing_index_directory_is_not_ready() {
708 let dir = tempfile::tempdir().unwrap();
709 assert!(!index_ready(&dir.path().join("nothing")));
710 std::fs::write(dir.path().join("data.mdb"), b"x").unwrap();
711 assert!(index_ready(dir.path()));
712 }
713}