Skip to main content

packset_daemon/
milli.rs

1//! The search projection, driven as a separate process.
2//!
3//! The index is a projection and never the store: everything in it is
4//! derivable from the atoms, so a corrupt or missing index costs ranking
5//! quality and nothing else. Every failure here falls back to the linear
6//! scorer rather than answering with a partial index, because a wrong answer
7//! that looks complete is worse than a slower one that is.
8
9use 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
19/// Environment variables naming the search binary.
20pub const BIN_VARS: &[&str] = &["PACKSET_MILLI", "INSIDE_MILLI", "GROK_INSIDE_MILLI"];
21
22/// Sets already backfilled into an index by this process.
23///
24/// The backfill exists for a projection written before atoms carried a `set`,
25/// and one pass over the set fixes that for good: every write since keeps the
26/// field current. Doing it per query instead re-uploads the whole set on every
27/// scoped search, which is the difference between a search and an indexing
28/// job.
29fn 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
36/// Forget what was backfilled into `dir`, because the index is being rebuilt.
37fn forget_backfill(dir: &Path) {
38    if let Ok(mut seen) = backfilled().lock() {
39        seen.retain(|(indexed, _)| indexed != dir);
40    }
41}
42
43/// The search binary, if this seat has one.
44///
45/// Absent is the normal case, not an error: the linear scorer answers the same
46/// question without it.
47#[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    // The same three places the writer being replaced looks, relative to the
58    // tree root. The workspace target directory is deliberately not among
59    // them: neither writer searches it, and a seat that builds there names the
60    // binary with PACKSET_MILLI.
61    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
102/// Run the binary, or nothing when it is absent or unhappy.
103fn 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/// The primary key for one document.
127///
128/// The index accepts `[A-Za-z0-9_-]` only, and a workspace name carries
129/// neither, so the workspace card's key is a digest of the name rather than
130/// the name.
131#[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
140/// The first sixteen hex characters of the SHA-256 of `text`.
141fn 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
150/// SHA-256, so the workspace card keeps the key the other writer gave it.
151fn 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/// The document for one atom.
285#[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/// Every document a workspace projects: the two cards and its live atoms.
314#[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/// Add or replace documents. An empty batch is a success.
352#[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/// Drop documents by id.
366#[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/// The pack a projection is built from or searched against.
381///
382/// The four travel together everywhere, and separating them is how a set's
383/// cards end up written as a workspace's.
384#[derive(Debug, Clone, Copy)]
385pub struct Corpus<'a> {
386    /// The workspace being projected.
387    pub workspace: &'a str,
388    /// The seat card, or a set's stand-in for it.
389    pub user: &'a str,
390    /// The workspace card, or a set's stand-in for it.
391    pub memory: &'a str,
392    /// The live atoms.
393    pub atoms: &'a [Record],
394}
395
396/// Rebuild the projection from a whole workspace.
397///
398/// Never called with a set's cards: they would become the workspace's prose
399/// documents and every unscoped search would then answer with them.
400#[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/// Upsert the live atoms only, leaving the prose documents alone.
414#[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/// Whether the projection exists on disk.
431#[must_use]
432pub fn index_ready(dir: &Path) -> bool {
433    dir.join("data.mdb").exists()
434}
435
436/// Keep the atom hits the pack still says are live and in scope.
437///
438/// The pack decides membership, not the index: a stale projection that
439/// predates the `set` field still scopes correctly after this, and a hit for
440/// an atom that has since been tombstoned never reaches a reader. Prose hits
441/// from the index are dropped because prose always comes from the pack.
442#[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            // The index stores what it scores; the stamp, the kind and the
459            // review date come from the pack's own record.
460            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
473/// What the index needs before this query can be trusted.
474fn ensure_atoms(corpus: Corpus<'_>, dir: &Path, set: Option<&str>) -> bool {
475    let atoms = corpus.atoms;
476    if !index_ready(dir) {
477        // A set-scoped call must never full-replace: its cards are not the
478        // workspace's, and writing them as such poisons every other search.
479        if set.is_some() {
480            return reindex_atoms(atoms, dir);
481        }
482        return replace(corpus, dir);
483    }
484    match set {
485        // Backfill the named set's atoms once, so `--set` sees the field even
486        // on a projection written before it existed. Once is enough: every
487        // write since keeps the field current, and repeating it per query
488        // turns a scoped search into an indexing job.
489        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/// One search against the projection, or nothing when it cannot answer.
516#[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        // Nothing found may mean the projection is behind rather than that the
556        // pack has nothing. Reindex the atoms once and ask again; a failed
557        // reindex means the index is not authoritative, and the caller falls
558        // back to the linear scorer rather than reporting a prose-only miss.
559        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        // hashlib.sha256(b"").hexdigest()[:16]
597        assert_eq!(short_digest(""), "e3b0c44298fc1c14");
598        // hashlib.sha256(b"abc").hexdigest()[:16]
599        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        // A message spanning two blocks exercises the padding.
611        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        // The index accepts [A-Za-z0-9_-] and a workspace name carries neither
623        // a colon nor a slash safely.
624        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        // A stale projection may return an atom the pack has since dropped, or
671        // one written before the set field existed.
672        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        // And out of scope means out, whatever the index said.
683        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}