Skip to main content

wm_memory/
reindex.rs

1//! Index rebuild — reconstruct the Tantivy full-text index from LMDB.
2//!
3//! The Tantivy index can drift from the LMDB store (stale entries survive
4//! deletes, binary migration artifacts pollute results). `rebuild_index`
5//! rebuilds it from scratch: every memory in every galaxy is re-indexed
6//! through the same sanitization gate used at write time, so garbage content
7//! is skipped and deleted memories disappear.
8//!
9//! The caller is responsible for backing up the existing index directory
10//! before rebuilding (the `wm reindex` CLI does this automatically).
11
12use serde::{Deserialize, Serialize};
13use wm_core::{CoreError, Galaxy, Result};
14
15use crate::memory::Memory;
16use crate::search::{SearchEngine, sanitize_content_for_index};
17use crate::store::MemoryStore;
18
19/// Batch size for scanning galaxies during rebuild (unused by `scan_all`
20/// today; kept for callers that stream batches).
21pub const REINDEX_BATCH: usize = 2_000;
22
23/// Per-galaxy statistics for an index rebuild.
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
25pub struct GalaxyRebuildStats {
26    /// Galaxy database name.
27    pub galaxy: String,
28    /// Memories scanned from LMDB.
29    pub scanned: usize,
30    /// Documents added to the index.
31    pub indexed: usize,
32    /// Memories skipped (failed content sanitization).
33    pub skipped: usize,
34}
35
36/// Report of a full index rebuild.
37#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
38pub struct IndexRebuildReport {
39    /// Memories scanned from LMDB across all galaxies.
40    pub scanned: usize,
41    /// Documents added to the index.
42    pub indexed: usize,
43    /// Memories skipped because content failed sanitization.
44    pub skipped: usize,
45    /// Per-galaxy breakdown.
46    pub galaxies: Vec<GalaxyRebuildStats>,
47}
48
49/// Per-galaxy consistency check result.
50#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
51pub struct GalaxyConsistency {
52    /// Galaxy database name.
53    pub galaxy: String,
54    /// Memories in LMDB.
55    pub lmdb_count: usize,
56    /// Documents in Tantivy.
57    pub tantivy_count: usize,
58    /// True when counts differ (index is stale or has orphan documents).
59    pub drift: bool,
60}
61
62/// Consistency check report comparing LMDB to Tantivy.
63#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ConsistencyReport {
65    /// Per-galaxy comparison.
66    pub galaxies: Vec<GalaxyConsistency>,
67    /// Total LMDB memories across all galaxies.
68    pub total_lmdb: usize,
69    /// Total Tantivy documents across all galaxies.
70    pub total_tantivy: usize,
71    /// True if any galaxy has drift.
72    pub has_drift: bool,
73}
74
75/// Check consistency between LMDB store and Tantivy index.
76///
77/// Compares memory counts in LMDB to document counts in Tantivy for each
78/// galaxy. A mismatch indicates the index is stale (LMDB has memories that
79/// Tantivy doesn't) or has orphan documents (Tantivy has documents that
80/// LMDB doesn't — e.g. from a failed delete).
81///
82/// Note: content that fails sanitization is intentionally not indexed, so
83/// a small drift is expected when memories contain binary/garbage content.
84/// The caller should use `IndexHealth::failures` to distinguish best-effort
85/// skips from actual indexing failures.
86#[must_use]
87pub fn check_consistency(store: &MemoryStore, search: &SearchEngine) -> ConsistencyReport {
88    let mut report = ConsistencyReport::default();
89    for galaxy in Galaxy::memory_galaxies() {
90        let lmdb_count = store.count(galaxy).unwrap_or(0);
91        let tantivy_count = search.count_docs_in_galaxy(galaxy.db_name()).unwrap_or(0);
92        let drift = lmdb_count != tantivy_count;
93        report.total_lmdb += lmdb_count;
94        report.total_tantivy += tantivy_count;
95        if drift {
96            report.has_drift = true;
97        }
98        report.galaxies.push(GalaxyConsistency {
99            galaxy: galaxy.db_name().to_string(),
100            lmdb_count,
101            tantivy_count,
102            drift,
103        });
104    }
105    report
106}
107
108/// Per-galaxy drift classification — the truthfulness layer over
109/// [`check_consistency`].
110///
111/// A count mismatch is not automatically healable drift: docs the index
112/// gate refuses (null bytes / printable ratio < [`MIN_PRINTABLE_RATIO`])
113/// are **never indexable as-is** and survive every rebuild by design. This
114/// classification separates that documented reserve from real drift —
115/// `healable_gap != 0` means the index differs from what a rebuild would
116/// actually produce.
117#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
118pub struct GalaxyDriftClass {
119    /// Galaxy database name.
120    pub galaxy: String,
121    /// Memories in LMDB.
122    pub lmdb_count: usize,
123    /// Documents in Tantivy.
124    pub tantivy_count: usize,
125    /// LMDB docs that fail the index gate — the documented reserve, never
126    /// indexable as-is. Counted only in the LMDB > Tantivy direction (a
127    /// gate-failing doc cannot exist in the index).
128    pub skip_reserve: usize,
129    /// Signed gap between what a rebuild WOULD index (`lmdb - skip_reserve`)
130    /// and what the index holds. `0` = the index is exactly rebuild output;
131    /// positive = missing indexable docs; negative = orphan documents.
132    pub healable_gap: i64,
133}
134
135/// Classification report across all memory galaxies.
136#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
137pub struct DriftClassification {
138    /// Per-galaxy classification.
139    pub galaxies: Vec<GalaxyDriftClass>,
140    /// Σ skip-reserve docs across galaxies.
141    pub skip_reserve_total: usize,
142    /// Σ |healable_gap| across galaxies — the docs a rebuild would change.
143    pub healable_total: usize,
144}
145
146/// Classify per-galaxy count mismatches into healable drift vs the
147/// sanitization-skip reserve.
148///
149/// The skip-reserve count requires one LMDB scan + gate evaluation per
150/// galaxy in the `lmdb > tantivy` direction only (galaxies whose counts
151/// match, or that have orphans, cannot contain gate-failing docs — those
152/// are never indexed). Cheap when consistent, one scan pass when drifted.
153#[must_use]
154pub fn classify_drift(store: &MemoryStore, search: &SearchEngine) -> DriftClassification {
155    let mut out = DriftClassification::default();
156    for galaxy in Galaxy::memory_galaxies() {
157        let lmdb_count = store.count(galaxy).unwrap_or(0);
158        let tantivy_count = search.count_docs_in_galaxy(galaxy.db_name()).unwrap_or(0);
159        let mut skip_reserve = 0usize;
160        if lmdb_count > tantivy_count {
161            for mem in store.scan(galaxy, lmdb_count).unwrap_or_default() {
162                if sanitize_content_for_index(&mem.content).is_none() {
163                    skip_reserve += 1;
164                }
165            }
166        }
167        let indexable = usize::try_into(lmdb_count - skip_reserve).unwrap_or(i64::MAX);
168        let indexed = usize::try_into(tantivy_count).unwrap_or(i64::MAX);
169        let healable_gap = indexable - indexed;
170        out.skip_reserve_total += skip_reserve;
171        out.healable_total += healable_gap.unsigned_abs() as usize;
172        out.galaxies.push(GalaxyDriftClass {
173            galaxy: galaxy.db_name().to_string(),
174            lmdb_count,
175            tantivy_count,
176            skip_reserve,
177            healable_gap,
178        });
179    }
180    out
181}
182
183/// Rebuild the Tantivy index from LMDB contents.
184///
185/// With no filter, all existing index documents are deleted and every memory
186/// in every galaxy is re-indexed. With a filter, only the selected galaxies
187/// are deleted and re-indexed — documents belonging to other galaxies are
188/// left untouched. Content that fails [`sanitize_content_for_index`] is
189/// skipped (counted in the report).
190///
191/// NOTE: the existing index directory must be backed up by the caller before
192/// this runs — deletion is permanent once committed.
193pub fn rebuild_index(
194    store: &MemoryStore,
195    search: &SearchEngine,
196    galaxy_filter: &[String],
197) -> Result<IndexRebuildReport> {
198    let mut report = IndexRebuildReport::default();
199    {
200        let mut writer = search.writer()?;
201        if galaxy_filter.is_empty() {
202            writer
203                .as_mut()
204                .ok_or_else(|| {
205                    CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
206                })?
207                .delete_all_documents()
208                .map_err(|e| CoreError::Memory(format!("Tantivy delete_all_documents: {e}")))?;
209        } else {
210            // Filtered rebuild: remove only the selected galaxies' documents.
211            // The old behavior deleted everything first, so `--galaxy codex`
212            // silently wiped search documents for every other galaxy.
213            for galaxy in Galaxy::all() {
214                if galaxy_filter.iter().any(|g| g == galaxy.db_name()) {
215                    search.delete_by_galaxy(&mut writer, galaxy.db_name())?;
216                }
217            }
218        }
219
220        for galaxy in Galaxy::all() {
221            if !galaxy_filter.is_empty() && !galaxy_filter.iter().any(|g| g == galaxy.db_name()) {
222                continue;
223            }
224            let memories = store.scan_all(galaxy)?;
225            let mut stats = GalaxyRebuildStats {
226                galaxy: galaxy.db_name().to_string(),
227                ..GalaxyRebuildStats::default()
228            };
229            for mem in &memories {
230                stats.scanned += 1;
231                if index_memory(search, &mut writer, galaxy, mem)?.is_some() {
232                    stats.indexed += 1;
233                } else {
234                    stats.skipped += 1;
235                }
236            }
237            report.scanned += stats.scanned;
238            report.indexed += stats.indexed;
239            report.skipped += stats.skipped;
240            report.galaxies.push(stats);
241        }
242
243        search.commit(&mut writer)?;
244        drop(writer);
245    }
246    Ok(report)
247}
248
249/// Index a single memory, returning `Ok(Some(()))` when indexed and
250/// `Ok(None)` when the content was skipped by sanitization.
251fn index_memory(
252    search: &SearchEngine,
253    writer: &mut Option<tantivy::IndexWriter>,
254    galaxy: Galaxy,
255    mem: &Memory,
256) -> Result<Option<()>> {
257    let Some(content) = sanitize_content_for_index(&mem.content) else {
258        return Ok(None);
259    };
260    let timestamp = mem.metadata.created_at.timestamp();
261    let id = mem.metadata.id.to_string();
262    search.add_document(
263        writer,
264        &id,
265        galaxy.db_name(),
266        &content,
267        &mem.metadata.tags,
268        timestamp,
269    )?;
270    Ok(Some(()))
271}
272
273/// Heal index drift by rebuilding only the galaxies with a **healable**
274/// gap (see [`classify_drift`]).
275///
276/// Whole-galaxy drift is systematic, not exceptional: session tools, dream
277/// consolidation, and research cycles write to LMDB without a search engine,
278/// and best-effort indexing failures are swallowed at the tool layer. Call
279/// this on writable server startup (and periodically in the daemon) so search
280/// stays complete without manual `wm reindex` runs.
281///
282/// Returns `Ok(None)` when nothing is healable — either the index matches
283/// LMDB, or the only gap is the documented sanitization-skip reserve
284/// (gate-failing content that every rebuild re-skips; healing those
285/// galaxies would be pure churn). Use [`repair_content`] to shrink the
286/// reserve itself.
287pub fn heal_index_drift(
288    store: &MemoryStore,
289    search: &SearchEngine,
290) -> Result<Option<IndexRebuildReport>> {
291    // Classify, don't just count: galaxies whose entire gap is the
292    // documented sanitization-skip reserve reproduce the same index on
293    // every rebuild — re-healing them each startup is pure churn. Only a
294    // nonzero healable gap (missing indexable docs, or orphans) triggers.
295    let class = classify_drift(store, search);
296    let drifted: Vec<String> = class
297        .galaxies
298        .iter()
299        .filter(|g| g.healable_gap != 0)
300        .map(|g| g.galaxy.clone())
301        .collect();
302    if drifted.is_empty() {
303        return Ok(None);
304    }
305    rebuild_index(store, search, &drifted).map(Some)
306}
307
308// ── Content repair ─────────────────────────────────────────────────────
309
310/// Per-galaxy content-repair stats.
311#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
312pub struct GalaxyContentRepairStats {
313    /// Galaxy database name.
314    pub galaxy: String,
315    /// Memories scanned.
316    pub scanned: usize,
317    /// Rows rewritten in place with gate-passing content and indexed.
318    pub repaired: usize,
319    /// Majority-binary content left untouched (scrubbing would only
320    /// manufacture searchable noise).
321    pub unrepairable: usize,
322    /// Memories that already passed the gate — untouched.
323    pub already_clean: usize,
324}
325
326/// Content-repair report.
327#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
328pub struct ContentRepairReport {
329    /// Memories scanned across all targeted galaxies.
330    pub scanned: usize,
331    /// Rows rewritten in place and indexed.
332    pub repaired: usize,
333    /// True-binary rows left as-is (the permanent reserve).
334    pub unrepairable: usize,
335    /// Rows that already passed the gate.
336    pub already_clean: usize,
337    /// Per-galaxy breakdown.
338    pub galaxies: Vec<GalaxyContentRepairStats>,
339}
340
341/// Clean content for a repair attempt: control characters → spaces
342/// (mirroring `scrub_text`'s keep-set) WITHOUT the index length cap — the
343/// stored content stays full-length; only the index caps.
344fn clean_for_repair(content: &str) -> String {
345    content
346        .chars()
347        .map(|c| {
348            if c.is_control() && c != '\n' && c != '\t' && c != '\r' {
349                ' '
350            } else {
351                c
352            }
353        })
354        .collect()
355}
356
357/// Repair gate-failing memory content **in place** (V8 drift fix, part 2).
358///
359/// For every memory whose content fails [`sanitize_content_for_index`], the
360/// cleaner replaces control characters with spaces and re-runs the gate;
361/// rows that pass are rewritten under the SAME id (content + recomputed
362/// content_hash — `store.put` refreshes the secondary indexes) and indexed.
363/// In-place is deliberate: the B5 recovery's alongside-copies are what
364/// created the standing reserve, and the raw originals remain recoverable
365/// from the upstream heritage sources.
366///
367/// Majority-binary content (printable ratio < 0.5 before cleaning) is left
368/// untouched: scrubbing it would only manufacture searchable noise. These
369/// are the documented true-binary docs — the permanent reserve.
370///
371/// Indexing uses one writer and a single commit at the end; the caller
372/// must hold the writer lock (no writable serve on the store). The report
373/// gives exact per-galaxy counts; a fresh `wm backup` before applying is
374/// the operator's responsibility.
375///
376/// # Errors
377/// Propagates store/index errors; a mid-run failure leaves earlier
378/// repairs committed only at the end (single transaction on the index;
379/// LMDB rows are committed per-put — take a backup first).
380pub fn repair_content(
381    store: &MemoryStore,
382    search: &SearchEngine,
383    galaxies: &[Galaxy],
384) -> Result<ContentRepairReport> {
385    let mut report = ContentRepairReport::default();
386    let mut writer = search.writer()?;
387    for galaxy in galaxies {
388        let mut stats = GalaxyContentRepairStats {
389            galaxy: galaxy.db_name().to_string(),
390            ..Default::default()
391        };
392        for mem in store.scan_all(*galaxy)? {
393            stats.scanned += 1;
394            if sanitize_content_for_index(&mem.content).is_some() {
395                stats.already_clean += 1;
396                continue;
397            }
398            // Majority-text rule: control-char scrubbing must not manufacture
399            // searchable noise out of binary garbage.
400            let total = mem.content.chars().count();
401            let printable = mem.content.chars().filter(|c| !c.is_control()).count();
402            let cleaned = clean_for_repair(&mem.content);
403            if total == 0
404                || (printable as f32 / total as f32) < 0.5
405                || sanitize_content_for_index(&cleaned).is_none()
406            {
407                stats.unrepairable += 1;
408                continue;
409            }
410            let mut repaired_mem = mem;
411            let old_hash = repaired_mem.metadata.content_hash.clone();
412            repaired_mem.content = cleaned;
413            repaired_mem.metadata.content_hash = crate::content_hash(&repaired_mem.content);
414            repaired_mem.metadata.revision_count =
415                repaired_mem.metadata.revision_count.saturating_add(1);
416            store.put(*galaxy, &repaired_mem)?;
417            // V8 S11c: an operator repair IS a content change — chain it
418            // like any update so `memory.revisions verify` stays truthful
419            // afterwards instead of crying tamper on repaired docs.
420            store.record_revision(
421                *galaxy,
422                repaired_mem.metadata.id,
423                &old_hash,
424                &repaired_mem.metadata.content_hash,
425                crate::revision::RevisionActor {
426                    session: None,
427                    user: Some("wm-repair-content".to_string()),
428                    compartment: None,
429                },
430            )?;
431            let id_str = repaired_mem.metadata.id.to_string();
432            // Defensive delete-then-add: gate-failing docs have no index
433            // doc, but a prior partial repair could have left one.
434            search.delete_document(&mut writer, &id_str)?;
435            search.add_document(
436                &mut writer,
437                &id_str,
438                galaxy.db_name(),
439                &repaired_mem.content,
440                &repaired_mem.metadata.tags,
441                repaired_mem.metadata.created_at.timestamp(),
442            )?;
443            stats.repaired += 1;
444        }
445        report.scanned += stats.scanned;
446        report.repaired += stats.repaired;
447        report.unrepairable += stats.unrepairable;
448        report.already_clean += stats.already_clean;
449        report.galaxies.push(stats);
450    }
451    search.commit(&mut writer)?;
452    Ok(report)
453}
454
455/// Helper for the `wm reindex` CLI: validate that the tantivy index directory
456/// exists next to the LMDB store.
457#[must_use]
458pub fn tantivy_path_for(store_path: &std::path::Path) -> std::path::PathBuf {
459    store_path.join("tantivy")
460}
461
462/// Error message used when the index directory is missing.
463#[must_use]
464pub fn missing_index_error(store_path: &std::path::Path) -> CoreError {
465    CoreError::Memory(format!(
466        "Tantivy index not found at {} — run 'wm serve' once to create it",
467        tantivy_path_for(store_path).display()
468    ))
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::Memory;
475    use tempfile::tempdir;
476
477    fn setup() -> (tempfile::TempDir, MemoryStore, SearchEngine) {
478        let tmp = tempdir().unwrap();
479        let store = MemoryStore::open_default(tmp.path()).unwrap();
480        let tantivy_dir = tmp.path().join("tantivy");
481        std::fs::create_dir_all(&tantivy_dir).unwrap();
482        let search = SearchEngine::open(&tantivy_dir).unwrap();
483        (tmp, store, search)
484    }
485
486    fn put_and_index(store: &MemoryStore, search: &SearchEngine, galaxy: Galaxy, content: &str) {
487        let mem = Memory::new(galaxy, content.to_string());
488        let id = mem.metadata.id;
489        store.put(galaxy, &mem).unwrap();
490        let mut writer = search.writer().unwrap();
491        search
492            .add_document(
493                &mut writer,
494                &id.to_string(),
495                galaxy.db_name(),
496                content,
497                &mem.metadata.tags,
498                mem.metadata.created_at.timestamp(),
499            )
500            .unwrap();
501        search.commit(&mut writer).unwrap();
502    }
503
504    #[test]
505    fn rebuild_repopulates_index_from_lmdb() {
506        let (_tmp, store, search) = setup();
507        put_and_index(&store, &search, Galaxy::Codex, "rust memory one");
508        put_and_index(&store, &search, Galaxy::Codex, "python memory two");
509        put_and_index(&store, &search, Galaxy::Research, "research notes");
510
511        // Inject a stale document that exists in the index but not in LMDB —
512        // the rebuild must remove it.
513        {
514            let mut writer = search.writer().unwrap();
515            search
516                .add_document(
517                    &mut writer,
518                    "99999999-9999-9999-9999-999999999999",
519                    "codex",
520                    "stale ghost document",
521                    &[],
522                    1000,
523                )
524                .unwrap();
525            search.commit(&mut writer).unwrap();
526        }
527        let ghost = search.search("ghost", 10).unwrap();
528        assert_eq!(ghost.len(), 1);
529
530        let report = rebuild_index(&store, &search, &[]).unwrap();
531        assert_eq!(report.indexed, 3);
532        assert_eq!(report.scanned, 3);
533        assert_eq!(report.galaxies.len(), Galaxy::COUNT);
534
535        let ghost = search.search("ghost", 10).unwrap();
536        assert!(ghost.is_empty(), "stale index entry must be purged");
537
538        let rust = search.search("rust memory one", 10).unwrap();
539        assert_eq!(rust.len(), 1);
540        assert_eq!(rust[0].content, "rust memory one");
541    }
542
543    #[test]
544    fn rebuild_skips_binary_garbage() {
545        let (_tmp, store, search) = setup();
546        put_and_index(&store, &search, Galaxy::Codex, "clean text entry");
547        let mem = Memory::new(Galaxy::Codex, "\u{00}\u{01}\u{02}raw bytes".to_string());
548        store.put(Galaxy::Codex, &mem).unwrap();
549
550        let report = rebuild_index(&store, &search, &[]).unwrap();
551        assert_eq!(report.indexed, 1, "garbage content must be skipped");
552        assert_eq!(report.skipped, 1);
553
554        let results = search.search("raw", 10).unwrap();
555        assert!(results.is_empty());
556    }
557
558    #[test]
559    fn rebuild_respects_galaxy_filter() {
560        let (_tmp, store, search) = setup();
561        put_and_index(&store, &search, Galaxy::Codex, "codex memory");
562        put_and_index(&store, &search, Galaxy::Research, "research memory");
563
564        let report = rebuild_index(&store, &search, &["codex".to_string()]).unwrap();
565        assert_eq!(report.indexed, 1);
566        assert_eq!(report.galaxies.len(), 1);
567        assert_eq!(report.galaxies[0].galaxy, "codex");
568
569        // Regression: the filtered rebuild used to delete ALL documents first,
570        // so documents from unselected galaxies vanished from the index.
571        // Use galaxy-scoped search since OR semantics returns partial matches
572        // for 2-term queries (both docs contain "memory").
573        let codex = search
574            .search_in_galaxy("codex memory", Some(Galaxy::Codex), 10)
575            .unwrap();
576        assert_eq!(codex.len(), 1);
577        let research = search
578            .search_in_galaxy("research memory", Some(Galaxy::Research), 10)
579            .unwrap();
580        assert_eq!(
581            research.len(),
582            1,
583            "filtered rebuild must preserve documents in unselected galaxies"
584        );
585    }
586
587    #[test]
588    fn consistency_check_no_drift_when_indexed() {
589        let (_tmp, store, search) = setup();
590        put_and_index(&store, &search, Galaxy::Codex, "hello world");
591        put_and_index(&store, &search, Galaxy::Codex, "another memory");
592
593        let report = check_consistency(&store, &search);
594        assert!(!report.has_drift, "no drift expected when all indexed");
595        let codex = report
596            .galaxies
597            .iter()
598            .find(|g| g.galaxy == "codex")
599            .unwrap();
600        assert_eq!(codex.lmdb_count, 2);
601        assert_eq!(codex.tantivy_count, 2);
602    }
603
604    #[test]
605    fn consistency_check_detects_drift() {
606        let (_tmp, store, search) = setup();
607        // Write to LMDB without indexing → drift.
608        let mem = Memory::new(Galaxy::Codex, "unindexed".to_string());
609        store.put(Galaxy::Codex, &mem).unwrap();
610
611        let report = check_consistency(&store, &search);
612        assert!(
613            report.has_drift,
614            "drift expected when LMDB has unindexed memory"
615        );
616        let codex = report
617            .galaxies
618            .iter()
619            .find(|g| g.galaxy == "codex")
620            .unwrap();
621        assert_eq!(codex.lmdb_count, 1);
622        assert_eq!(codex.tantivy_count, 0);
623    }
624
625    #[test]
626    fn heal_repairs_only_drifted_galaxies() {
627        let (_tmp, store, search) = setup();
628
629        // LMDB-only writes (the session-tool pattern) — never touch the index.
630        store
631            .put(
632                Galaxy::Sessions,
633                &Memory::new(Galaxy::Sessions, "session needle".into()),
634            )
635            .unwrap();
636        store
637            .put(
638                Galaxy::Research,
639                &Memory::new(Galaxy::Research, "research needle".into()),
640            )
641            .unwrap();
642        // A healthy galaxy that must not be rebuilt.
643        put_and_index(&store, &search, Galaxy::Codex, "healthy codex entry");
644
645        let report = heal_index_drift(&store, &search)
646            .unwrap()
647            .expect("drift expected before heal");
648        let healed: Vec<_> = report.galaxies.iter().map(|g| g.galaxy.as_str()).collect();
649        assert!(healed.contains(&"sessions"));
650        assert!(healed.contains(&"research"));
651        assert!(
652            !healed.contains(&"codex"),
653            "healthy galaxy must be untouched"
654        );
655        assert_eq!(report.indexed, 2);
656
657        assert!(
658            heal_index_drift(&store, &search).unwrap().is_none(),
659            "second heal must be a no-op once consistent"
660        );
661        assert_eq!(
662            search
663                .search_in_galaxy("session needle", Some(Galaxy::Sessions), 10)
664                .unwrap()
665                .len(),
666            1
667        );
668        assert_eq!(
669            search
670                .search_in_galaxy("healthy codex", Some(Galaxy::Codex), 10)
671                .unwrap()
672                .len(),
673            1
674        );
675    }
676
677    #[test]
678    fn heal_noop_when_consistent() {
679        let (_tmp, store, search) = setup();
680        put_and_index(&store, &search, Galaxy::Codex, "indexed entry");
681        put_and_index(&store, &search, Galaxy::Dreams, "dream entry");
682
683        assert!(heal_index_drift(&store, &search).unwrap().is_none());
684    }
685
686    #[test]
687    fn index_health_tracks_successes() {
688        let (_tmp, store, search) = setup();
689        put_and_index(&store, &search, Galaxy::Codex, "test content");
690
691        let health = search.health().snapshot();
692        let successes = health
693            .get("successes")
694            .and_then(serde_json::Value::as_u64)
695            .unwrap_or(0);
696        assert!(successes > 0, "expected at least one success");
697        let failures = health
698            .get("failures")
699            .and_then(serde_json::Value::as_u64)
700            .unwrap_or(0);
701        assert_eq!(failures, 0);
702        assert_eq!(
703            health.get("degraded").and_then(serde_json::Value::as_bool),
704            Some(false)
705        );
706    }
707
708    #[test]
709    fn consistency_check_ignores_non_memory_galaxies() {
710        let (_tmp, store, search) = setup();
711        put_and_index(&store, &search, Galaxy::Codex, "indexed memory");
712
713        // Write raw bytes into the Karma galaxy (non-memory data).
714        // Karma is not a memory galaxy and is intentionally not indexed in Tantivy.
715        store.put_raw(Galaxy::Karma, b"key1", b"value1").unwrap();
716
717        let report = check_consistency(&store, &search);
718        assert!(
719            !report.has_drift,
720            "karma entries should not cause drift — non-memory galaxies are excluded"
721        );
722        // Only memory galaxies should appear in the report.
723        let galaxy_names: Vec<_> = report.galaxies.iter().map(|g| g.galaxy.as_str()).collect();
724        assert!(
725            !galaxy_names.contains(&"karma"),
726            "karma should not appear in consistency report"
727        );
728        assert!(
729            !galaxy_names.contains(&"dharma"),
730            "dharma should not appear in consistency report"
731        );
732    }
733    // ── Drift classification + content repair (V8 truthfulness fix) ────────
734
735    #[test]
736    fn classify_separates_skip_reserve_from_healable_drift() {
737        let (tmp, store, search) = setup();
738        // 2 clean docs indexed + 1 gate-failing doc NOT indexed.
739        put_and_index(&store, &search, Galaxy::Codex, "clean doc one");
740        put_and_index(&store, &search, Galaxy::Codex, "clean doc two");
741        store
742            .put(
743                Galaxy::Codex,
744                &Memory::new(Galaxy::Codex, "bad \u{1}\u{2} doc".into()),
745            )
746            .unwrap();
747        search.commit(&mut search.writer().unwrap()).unwrap();
748
749        let class = classify_drift(&store, &search);
750        let codex = class.galaxies.iter().find(|g| g.galaxy == "codex").unwrap();
751        assert_eq!(codex.lmdb_count, 3);
752        assert_eq!(codex.tantivy_count, 2);
753        assert_eq!(codex.skip_reserve, 1);
754        assert_eq!(codex.healable_gap, 0, "skip reserve fully explains the gap");
755        assert_eq!(class.healable_total, 0);
756        drop(tmp);
757    }
758
759    #[test]
760    fn classify_flags_missing_indexable_docs_as_healable() {
761        let (tmp, store, search) = setup();
762        put_and_index(&store, &search, Galaxy::Codex, "indexed doc");
763        // A second clean doc that never reached the index — real drift.
764        store
765            .put(
766                Galaxy::Codex,
767                &Memory::new(Galaxy::Codex, "unindexed clean doc".into()),
768            )
769            .unwrap();
770        search.commit(&mut search.writer().unwrap()).unwrap();
771
772        let class = classify_drift(&store, &search);
773        let codex = class.galaxies.iter().find(|g| g.galaxy == "codex").unwrap();
774        assert_eq!(codex.skip_reserve, 0);
775        assert_eq!(codex.healable_gap, 1);
776        assert_eq!(class.healable_total, 1);
777        drop(tmp);
778    }
779
780    #[test]
781    fn heal_ignores_pure_skip_reserve_and_heals_real_gaps() {
782        let (tmp, store, search) = setup();
783        put_and_index(&store, &search, Galaxy::Codex, "clean doc");
784        // Only a skip-reserve gap: heal must be a no-op (no churn).
785        // The \0 makes this genuinely gate-failing (null byte → immediate refuse).
786        store
787            .put(
788                Galaxy::Codex,
789                &Memory::new(Galaxy::Codex, "gate\u{0} fails".into()),
790            )
791            .unwrap();
792        search.commit(&mut search.writer().unwrap()).unwrap();
793        let healed = heal_index_drift(&store, &search).unwrap();
794        assert!(
795            healed.is_none(),
796            "skip-reserve-only drift must not trigger a rebuild"
797        );
798
799        // Now a real gap: an unindexed clean doc — heal must rebuild.
800        store
801            .put(
802                Galaxy::Codex,
803                &Memory::new(Galaxy::Codex, "genuinely missing doc".into()),
804            )
805            .unwrap();
806        let healed = heal_index_drift(&store, &search).unwrap();
807        assert!(healed.is_some(), "healable drift must trigger a rebuild");
808        // Rebuild re-adds both indexable docs (clean + missing); the \0 doc is
809        // re-skipped — that is the whole point of the classification.
810        assert_eq!(healed.unwrap().indexed, 2);
811        drop(tmp);
812    }
813
814    #[test]
815    fn repair_rewrites_in_place_and_indexes_clean_content() {
816        let (tmp, store, search) = setup();
817        // Repairable: majority text with a null byte (immediate gate refuse)
818        // plus a control char — printable ratio ≥ 0.5.
819        let mut repairable = Memory::new(
820            Galaxy::Codex,
821            "kumquat\u{0} ratchet \u{1} repair end".into(),
822        );
823        // True-binary: majority control chars — must be left untouched.
824        let mut binary = Memory::new(Galaxy::Codex, "\u{1}\u{2}\u{3}\u{4}\u{5}\u{6}".into());
825        // Already clean.
826        let mut clean = Memory::new(Galaxy::Codex, "perfectly fine prose".into());
827        let (id_r, id_b, id_c) = (
828            repairable.metadata.id,
829            binary.metadata.id,
830            clean.metadata.id,
831        );
832        for m in [&mut repairable, &mut binary, &mut clean] {
833            store.put(Galaxy::Codex, m).unwrap();
834        }
835
836        let report = repair_content(&store, &search, &[Galaxy::Codex]).unwrap();
837        assert_eq!(report.scanned, 3);
838        assert_eq!(report.repaired, 1, "{report:?}");
839        assert_eq!(report.unrepairable, 1, "{report:?}");
840        assert_eq!(report.already_clean, 1);
841
842        // The repaired row kept its id, got clean content + fresh hash, and is
843        // now gate-passing.
844        let row = store.get(Galaxy::Codex, id_r).unwrap().unwrap();
845        assert_eq!(row.content, "kumquat  ratchet   repair end");
846        assert_eq!(row.metadata.content_hash, crate::content_hash(&row.content));
847        assert!(sanitize_content_for_index(&row.content).is_some());
848        assert_eq!(row.metadata.revision_count, 1);
849
850        // V8 S11c: the repair chained itself — old hash preserved, operator
851        // actor labeled, head verifies against the repaired content.
852        let chain = store.revisions(Galaxy::Codex, id_r).unwrap();
853        assert_eq!(chain.len(), 1);
854        assert_eq!(
855            chain[0].old_hash,
856            crate::content_hash("kumquat\u{0} ratchet \u{1} repair end")
857        );
858        assert_eq!(chain[0].new_hash, row.metadata.content_hash);
859        assert_eq!(chain[0].actor_user.as_deref(), Some("wm-repair-content"));
860        assert_eq!(chain[0].actor_session, None);
861        let verdict = store
862            .verify_revision_chain(Galaxy::Codex, id_r, &row.metadata.content_hash)
863            .unwrap();
864        assert!(verdict.valid, "{:?}", verdict.breaks);
865
866        // Untouched rows chained nothing.
867        assert!(store.revisions(Galaxy::Codex, id_b).unwrap().is_empty());
868        assert!(store.revisions(Galaxy::Codex, id_c).unwrap().is_empty());
869
870        // True-binary row untouched.
871        let untouched = store.get(Galaxy::Codex, id_b).unwrap().unwrap();
872        assert_eq!(untouched.content, "\u{1}\u{2}\u{3}\u{4}\u{5}\u{6}");
873
874        // Clean row untouched.
875        let kept = store.get(Galaxy::Codex, id_c).unwrap().unwrap();
876        assert_eq!(kept.content, "perfectly fine prose");
877
878        // The repaired doc is now findable through the index.
879        let hits = search.search("kumquat ratchet repair", 10).unwrap();
880        assert!(
881            hits.iter().any(|h| h.memory_id == id_r.to_string()),
882            "repaired doc must be indexed: {hits:?}"
883        );
884
885        // Re-run: the repaired doc is already clean; nothing new happens.
886        let again = repair_content(&store, &search, &[Galaxy::Codex]).unwrap();
887        assert_eq!(again.repaired, 0);
888        assert_eq!(again.already_clean, 2);
889        assert_eq!(
890            store.revisions(Galaxy::Codex, id_r).unwrap().len(),
891            1,
892            "idempotent re-run must not append"
893        );
894        drop(tmp);
895    }
896}