Skip to main content

wm_memory/
episodic.rs

1//! LMDB persistence for the v6 lossless episodic memory lane.
2
3use lmdb::{Cursor, Database, Environment, Transaction, WriteFlags};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::collections::HashMap;
7use std::sync::{Arc, RwLock};
8use wm_core::{
9    CoreError, EpisodicCapturePolicy, EpisodicId, EpisodicKind, EpisodicRecord, MemoryTransition,
10    Result, ValidityState,
11};
12
13use crate::embedder::Embedder;
14use crate::enrichment::VocabularyEnrichment;
15use crate::episodic_keys::{AdaptiveAliases, key_index_terms_with_aliases};
16use crate::query_planner::QueryPlan;
17use crate::search::strip_stopwords;
18
19/// Test-only pause points around the authoritative raw episodic commit.
20///
21/// Absent from production builds. A no-op unless a child test process sets
22/// `WM_Q06_CASE` to the matching boundary, in which case it prints exactly one
23/// marker line and blocks on stdin until the parent kills it. It must not
24/// write to the store or call sync.
25#[cfg(test)]
26#[derive(Clone, Copy, PartialEq, Eq)]
27enum CommitBoundary {
28    BeforeRawCommit,
29    AfterRawCommit,
30}
31
32#[cfg(test)]
33fn commit_boundary_test_hook(boundary: CommitBoundary, records: &[EpisodicRecord]) -> Result<()> {
34    use std::io::{Read, Write};
35    let Ok(case) = std::env::var("WM_Q06_CASE") else {
36        return Ok(());
37    };
38    let expected = match boundary {
39        CommitBoundary::BeforeRawCommit => "before_raw_commit",
40        CommitBoundary::AfterRawCommit => "after_raw_commit",
41    };
42    if case != expected {
43        return Ok(());
44    }
45    let uuid = std::env::var("WM_Q06_UUID")
46        .map_err(|_| CoreError::Memory("q06 hook: WM_Q06_UUID is required".into()))?;
47    let names: Vec<String> = records.iter().map(|record| record.id.to_string()).collect();
48    if names.len() != 1 || names[0] != uuid {
49        return Err(CoreError::Memory(
50            "q06 hook: candidate set must be the single WM_Q06_UUID record".into(),
51        ));
52    }
53    println!("WM_Q06_BOUNDARY {expected} {uuid}");
54    let _ = std::io::stdout().flush();
55    let mut release = [0_u8; 1];
56    match std::io::stdin().read(&mut release) {
57        Ok(0) => Err(CoreError::Memory(
58            "q06 hook: stdin closed before release".into(),
59        )),
60        Ok(_) => Ok(()),
61        Err(e) => Err(CoreError::Memory(format!(
62            "q06 hook: stdin read failed: {e}"
63        ))),
64    }
65}
66
67/// Deterministic raw episodic search result.
68#[derive(Debug, Clone)]
69pub struct EpisodicSearchResult {
70    pub record: EpisodicRecord,
71    pub score: f32,
72    pub matched_terms: usize,
73}
74
75/// Dedicated persistence view over the episodic-record LMDB database.
76pub struct EpisodicStore<'a> {
77    env: &'a Environment,
78    db: Database,
79    term_db: Database,
80    term_cache: Arc<RwLock<HashMap<String, Vec<EpisodicId>>>>,
81    mutation_count: &'a std::sync::atomic::AtomicU64,
82    embedder: Option<Arc<dyn Embedder + Send + Sync>>,
83    aliases: Option<AdaptiveAliases>,
84    enrichment: Option<VocabularyEnrichment>,
85}
86
87impl<'a> EpisodicStore<'a> {
88    pub(crate) fn new(
89        env: &'a Environment,
90        db: Database,
91        term_db: Database,
92        term_cache: Arc<RwLock<HashMap<String, Vec<EpisodicId>>>>,
93        mutation_count: &'a std::sync::atomic::AtomicU64,
94    ) -> Self {
95        Self {
96            env,
97            db,
98            term_db,
99            term_cache,
100            mutation_count,
101            embedder: None,
102            aliases: None,
103            enrichment: None,
104        }
105    }
106
107    /// Attach adaptive aliases for query and ingest-time key expansion.
108    #[must_use]
109    pub fn with_adaptive_aliases(mut self, aliases: AdaptiveAliases) -> Self {
110        if !aliases.is_empty() {
111            self.aliases = Some(aliases);
112        }
113        self
114    }
115
116    /// Attach vocabulary enrichment for index-time term expansion.
117    #[must_use]
118    pub fn with_enrichment(mut self, enrichment: VocabularyEnrichment) -> Self {
119        if !enrichment.is_empty() {
120            self.enrichment = Some(enrichment);
121        }
122        self
123    }
124
125    /// Attach an embedder for vector reranking.
126    #[must_use]
127    pub fn with_embedder(mut self, embedder: Arc<dyn Embedder + Send + Sync>) -> Self {
128        self.embedder = Some(embedder);
129        self
130    }
131
132    /// Append a source record without allowing an existing raw ID to be overwritten.
133    pub fn append(&self, record: &EpisodicRecord) -> Result<()> {
134        self.append_batch(std::slice::from_ref(record))
135    }
136
137    /// Append many source records, then project them into the sidecar in one
138    /// term-index transaction.
139    pub fn append_batch(&self, records: &[EpisodicRecord]) -> Result<()> {
140        if records.is_empty() {
141            return Ok(());
142        }
143        let serialized = records
144            .iter()
145            .map(|record| {
146                rmp_serde::to_vec(record)
147                    .map(|value| (record, value))
148                    .map_err(|e| CoreError::Memory(format!("episodic serialize failed: {e}")))
149            })
150            .collect::<Result<Vec<_>>>()?;
151        let mut tx = self
152            .env
153            .begin_rw_txn()
154            .map_err(|e| CoreError::Memory(format!("episodic rw_txn failed: {e}")))?;
155        for (record, value) in &serialized {
156            match tx.put(
157                self.db,
158                record.id.as_bytes(),
159                value,
160                WriteFlags::NO_OVERWRITE,
161            ) {
162                Ok(()) => {}
163                Err(lmdb::Error::KeyExist) => {
164                    tx.abort();
165                    return Err(CoreError::InvalidArgs(format!(
166                        "episodic record {} already exists",
167                        record.id
168                    )));
169                }
170                Err(e) => {
171                    tx.abort();
172                    return Err(CoreError::Memory(format!("episodic append failed: {e}")));
173                }
174            }
175        }
176        #[cfg(test)]
177        commit_boundary_test_hook(CommitBoundary::BeforeRawCommit, records)?;
178        tx.commit()
179            .map_err(|e| CoreError::Memory(format!("episodic commit failed: {e}")))?;
180        #[cfg(test)]
181        commit_boundary_test_hook(CommitBoundary::AfterRawCommit, records)?;
182        self.mutation_count
183            .fetch_add(records.len() as u64, std::sync::atomic::Ordering::Relaxed);
184        // The raw record is authoritative. A projection failure is returned
185        // after the raw commit so callers can rebuild the sidecar without
186        // losing the source record.
187        self.index_records(records)?;
188        self.clear_term_cache();
189        Ok(())
190    }
191
192    /// Read the posting list for a term from the DUP_SORT sidecar.
193    ///
194    /// A dup-sorted database stores one (term, id) pair per posting, so a
195    /// term's list is materialized by iterating its duplicate values. This
196    /// keeps append O(new pairs) instead of rewriting a serialized Vec that
197    /// grows with the store.
198    fn term_postings(&self, term: &str) -> Result<Vec<EpisodicId>> {
199        // Normalize exactly as the index does (H2, 2026-09-19 review): a
200        // long token must map to the same hashed key on both sides.
201        let term = index_safe_term(term);
202        if let Ok(cache) = self.term_cache.read() {
203            if let Some(ids) = cache.get(&term) {
204                return Ok(ids.clone());
205            }
206        }
207
208        let tx = self
209            .env
210            .begin_ro_txn()
211            .map_err(|e| CoreError::Memory(format!("episodic index ro_txn failed: {e}")))?;
212        let term_key = term;
213        let mut ids: Vec<EpisodicId> = Vec::new();
214        {
215            // Existence pre-check: iter_from() unwraps MDB_SET_RANGE, which
216            // legitimately fails NotFound when the key sorts past every entry.
217            match tx.get(self.term_db, &term_key) {
218                Ok(_) => {}
219                Err(lmdb::Error::NotFound) => {
220                    tx.commit().map_err(|e| {
221                        CoreError::Memory(format!("episodic index commit failed: {e}"))
222                    })?;
223                    if let Ok(mut cache) = self.term_cache.write() {
224                        cache.insert(term_key, Vec::new());
225                    }
226                    return Ok(ids);
227                }
228                Err(e) => {
229                    return Err(CoreError::Memory(format!(
230                        "episodic index read failed: {e}"
231                    )));
232                }
233            }
234            let mut cursor = tx
235                .open_ro_cursor(self.term_db)
236                .map_err(|e| CoreError::Memory(format!("episodic index cursor failed: {e}")))?;
237            for (key, value) in cursor.iter_from(term_key.as_bytes()) {
238                if key != term_key.as_bytes() {
239                    break;
240                }
241                if value.len() == std::mem::size_of::<EpisodicId>() {
242                    if let Ok(id) = EpisodicId::from_slice(value) {
243                        ids.push(id);
244                    }
245                }
246            }
247        }
248        tx.commit()
249            .map_err(|e| CoreError::Memory(format!("episodic index commit failed: {e}")))?;
250        if let Ok(mut cache) = self.term_cache.write() {
251            cache.insert(term_key, ids.clone());
252        }
253        Ok(ids)
254    }
255
256    fn clear_term_cache(&self) {
257        if let Ok(mut cache) = self.term_cache.write() {
258            cache.clear();
259        }
260    }
261
262    fn index_records(&self, records: &[EpisodicRecord]) -> Result<()> {
263        let public: Vec<&EpisodicRecord> = records
264            .iter()
265            .filter(|record| !record.is_private && !record.model_exclude)
266            .collect();
267        if public.is_empty() {
268            return Ok(());
269        }
270        let mut pending: HashMap<String, Vec<&EpisodicRecord>> = HashMap::new();
271        for record in &public {
272            let base_terms = index_terms_with_aliases(&record.content, self.aliases.as_ref());
273            let enriched: Vec<String> = if let Some(ref enrichment) = self.enrichment {
274                let mut all = base_terms.clone();
275                let extra = enrichment.enrich(&base_terms);
276                all.extend(extra);
277                all.sort();
278                all.dedup();
279                all
280            } else {
281                base_terms
282            };
283            for term in enriched {
284                // H2 (2026-09-19 review): keys longer than LMDB's 511-byte
285                // limit abort the sidecar write with MDB_BAD_VALSIZE. Terms
286                // are canonicalized here so the raw record still indexes.
287                pending
288                    .entry(index_safe_term(&term))
289                    .or_default()
290                    .push(record);
291            }
292        }
293        let mut tx = self
294            .env
295            .begin_rw_txn()
296            .map_err(|e| CoreError::Memory(format!("episodic index rw_txn failed: {e}")))?;
297        for (term, records_for_term) in pending {
298            for record in records_for_term {
299                // DUP_SORT: inserting an existing (term, id) pair overwrites
300                // in place, so re-appends are idempotent and cost O(log n)
301                // instead of rewriting the whole posting list.
302                if let Err(e) = tx.put(
303                    self.term_db,
304                    &term,
305                    &record.id.as_bytes(),
306                    WriteFlags::default(),
307                ) {
308                    tx.abort();
309                    return Err(CoreError::Memory(format!(
310                        "episodic term index write failed: {e}"
311                    )));
312                }
313            }
314        }
315        tx.commit()
316            .map_err(|e| CoreError::Memory(format!("episodic term index commit failed: {e}")))?;
317        Ok(())
318    }
319
320    /// Rebuild the DUP_SORT sidecar from the authoritative raw records.
321    ///
322    /// Used when a store contains records but an empty v2 sidecar (legacy
323    /// stores whose postings lived in the retired v1 database, or stores
324    /// whose sidecar was lost). The raw lane is never touched; a failure
325    /// leaves the sidecar empty and search falls back to the raw scan.
326    pub fn rebuild_sidecar(&self) -> Result<usize> {
327        let records = self.scan(None, usize::MAX)?;
328        let mut indexed = 0usize;
329        for chunk in records.chunks(5_000) {
330            self.index_records(chunk)?;
331            indexed += chunk.len();
332        }
333        self.clear_term_cache();
334        Ok(indexed)
335    }
336
337    /// Number of (term, id) postings in the sidecar.
338    ///
339    /// LMDB 0.8 exposes no per-database stat, so emptiness is checked by
340    /// peeking the first entry (cheap; whole-db counts are not needed).
341    /// `iter()` (not `iter_start`) is used because `iter_start` unwraps
342    /// MDB_FIRST, which fails NotFound on an empty database.
343    pub fn sidecar_is_empty(&self) -> Result<bool> {
344        let tx = self
345            .env
346            .begin_ro_txn()
347            .map_err(|e| CoreError::Memory(format!("episodic index ro_txn failed: {e}")))?;
348        let mut cursor = tx
349            .open_ro_cursor(self.term_db)
350            .map_err(|e| CoreError::Memory(format!("episodic index cursor failed: {e}")))?;
351        Ok(cursor.iter().next().is_none())
352    }
353
354    /// Number of raw records in the authoritative lane.
355    pub fn record_count(&self) -> Result<u64> {
356        let tx = self
357            .env
358            .begin_ro_txn()
359            .map_err(|e| CoreError::Memory(format!("episodic ro_txn failed: {e}")))?;
360        let mut cursor = tx
361            .open_ro_cursor(self.db)
362            .map_err(|e| CoreError::Memory(format!("episodic cursor failed: {e}")))?;
363        let mut count = 0u64;
364        for _ in cursor.iter() {
365            count += 1;
366        }
367        Ok(count)
368    }
369
370    /// Append an explicit record according to the capture policy.
371    pub fn append_explicit(
372        &self,
373        record: &EpisodicRecord,
374        policy: EpisodicCapturePolicy,
375    ) -> Result<bool> {
376        let prepared = record
377            .clone()
378            .with_content(policy.prepare_content(&record.content));
379        self.append(&prepared)?;
380        Ok(true)
381    }
382
383    /// Append many explicit records according to the capture policy.
384    pub fn append_explicit_batch(
385        &self,
386        records: &[EpisodicRecord],
387        policy: EpisodicCapturePolicy,
388    ) -> Result<usize> {
389        if records.is_empty() {
390            return Ok(0);
391        }
392        let prepared: Vec<EpisodicRecord> = records
393            .iter()
394            .map(|record| {
395                record
396                    .clone()
397                    .with_content(policy.prepare_content(&record.content))
398            })
399            .collect();
400        self.append_batch(&prepared)?;
401        Ok(prepared.len())
402    }
403
404    /// Read an episodic record by its canonical ID.
405    pub fn get(&self, id: EpisodicId) -> Result<Option<EpisodicRecord>> {
406        let tx = self
407            .env
408            .begin_ro_txn()
409            .map_err(|e| CoreError::Memory(format!("episodic ro_txn failed: {e}")))?;
410        let result = tx.get(self.db, id.as_bytes());
411        match result {
412            Ok(bytes) => {
413                let record: EpisodicRecord = rmp_serde::from_slice(bytes)
414                    .map_err(|e| CoreError::Memory(format!("episodic deserialize failed: {e}")))?;
415                tx.commit()
416                    .map_err(|e| CoreError::Memory(format!("episodic commit failed: {e}")))?;
417                Ok(Some(record))
418            }
419            Err(lmdb::Error::NotFound) => {
420                tx.commit()
421                    .map_err(|e| CoreError::Memory(format!("episodic commit failed: {e}")))?;
422                Ok(None)
423            }
424            Err(e) => Err(CoreError::Memory(format!("episodic get failed: {e}"))),
425        }
426    }
427
428    /// Apply an explicit lifecycle transition to a persisted record.
429    pub fn transition(&self, id: EpisodicId, transition: MemoryTransition) -> Result<()> {
430        let mut tx = self
431            .env
432            .begin_rw_txn()
433            .map_err(|e| CoreError::Memory(format!("episodic rw_txn failed: {e}")))?;
434        let bytes = match tx.get(self.db, id.as_bytes()) {
435            Ok(bytes) => bytes,
436            Err(lmdb::Error::NotFound) => {
437                tx.abort();
438                return Err(CoreError::InvalidArgs(format!(
439                    "episodic record {id} does not exist"
440                )));
441            }
442            Err(e) => {
443                tx.abort();
444                return Err(CoreError::Memory(format!("episodic get failed: {e}")));
445            }
446        };
447        let mut record: EpisodicRecord = rmp_serde::from_slice(bytes)
448            .map_err(|e| CoreError::Memory(format!("episodic deserialize failed: {e}")))?;
449        record
450            .transition(transition)
451            .map_err(|e| CoreError::InvalidArgs(format!("episodic transition rejected: {e}")))?;
452        let value = rmp_serde::to_vec(&record)
453            .map_err(|e| CoreError::Memory(format!("episodic serialize failed: {e}")))?;
454        tx.put(self.db, id.as_bytes(), &value, WriteFlags::default())
455            .map_err(|e| CoreError::Memory(format!("episodic transition write failed: {e}")))?;
456        tx.commit()
457            .map_err(|e| CoreError::Memory(format!("episodic commit failed: {e}")))?;
458        self.mutation_count
459            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
460        Ok(())
461    }
462
463    /// Return records in sequence order, optionally restricted to a session.
464    pub fn scan(
465        &self,
466        session_id: Option<uuid::Uuid>,
467        limit: usize,
468    ) -> Result<Vec<EpisodicRecord>> {
469        if limit == 0 {
470            return Ok(Vec::new());
471        }
472        let tx = self
473            .env
474            .begin_ro_txn()
475            .map_err(|e| CoreError::Memory(format!("episodic ro_txn failed: {e}")))?;
476        let mut cursor = tx
477            .open_ro_cursor(self.db)
478            .map_err(|e| CoreError::Memory(format!("episodic cursor failed: {e}")))?;
479        let mut records = Vec::new();
480        for item in cursor.iter() {
481            let (_, bytes) = item;
482            let record: EpisodicRecord = rmp_serde::from_slice(bytes)
483                .map_err(|e| CoreError::Memory(format!("episodic deserialize failed: {e}")))?;
484            if session_id.is_none_or(|id| record.session_id == Some(id)) {
485                records.push(record);
486            }
487        }
488        drop(cursor);
489        tx.commit()
490            .map_err(|e| CoreError::Memory(format!("episodic commit failed: {e}")))?;
491        records.sort_by_key(|record| (record.sequence, record.created_at, record.id));
492        records.truncate(limit);
493        Ok(records)
494    }
495
496    /// Search current episodic records using deterministic token overlap.
497    ///
498    /// This is a v6 library path only. It does not alter the v5 MCP search
499    /// route and keeps source records attached to every hit.
500    ///
501    /// When the query asks for the current/latest value of something
502    /// (see [`is_current_query`]), the topic cluster is reordered by
503    /// deterministic chronology so the most recent statement outranks older
504    /// ones — post-retrieval temporal resolution, not a scoring change.
505    pub fn search(
506        &self,
507        query: &str,
508        limit: usize,
509        include_historical: bool,
510    ) -> Result<Vec<EpisodicSearchResult>> {
511        self.search_with_limits(query, limit, limit.saturating_mul(2), include_historical)
512    }
513
514    /// Search with an explicit candidate budget before selective reranking.
515    ///
516    /// Uses multi-query pool widening: the original query plus sub-queries
517    /// focused on key content words generate candidate IDs. All candidates are
518    /// then scored with the primary query's deterministic scoring. This helps
519    /// answer turns that match few original query terms but match the key
520    /// entity strongly enter the candidate pool.
521    pub fn search_with_limits(
522        &self,
523        query: &str,
524        limit: usize,
525        candidate_limit: usize,
526        include_historical: bool,
527    ) -> Result<Vec<EpisodicSearchResult>> {
528        if limit == 0 {
529            return Ok(Vec::new());
530        }
531        let mut results = self.search_scored(query, limit, candidate_limit, include_historical)?;
532        if is_current_query(query) {
533            self.resolve_current(&mut results);
534        }
535        results.truncate(limit);
536        Ok(results)
537    }
538
539    /// Deterministic scoring pipeline without temporal resolution or
540    /// truncation: candidates → scoring → boosts → score sort. `limit` is
541    /// used only for query-class planning (candidate budgets); the caller
542    /// truncates.
543    fn search_scored(
544        &self,
545        query: &str,
546        limit: usize,
547        candidate_limit: usize,
548        include_historical: bool,
549    ) -> Result<Vec<EpisodicSearchResult>> {
550        let plan = QueryPlan::plan(query, limit);
551        let candidate_limit = candidate_limit.max(plan.candidate_limit);
552        let query_terms = tokenize(query);
553        let query_keys = key_index_terms_with_aliases(query, self.aliases.as_ref());
554        if query_terms.is_empty() && query_keys.is_empty() || limit == 0 {
555            return Ok(Vec::new());
556        }
557        let mut candidate_scores: HashMap<EpisodicId, usize> = HashMap::new();
558        for term in query_terms.iter().chain(query_keys.iter()) {
559            for id in self.term_postings(term)? {
560                *candidate_scores.entry(id).or_default() += 1;
561            }
562        }
563
564        let records = if candidate_scores.is_empty() {
565            // A populated sidecar with no postings for any query term means
566            // the query genuinely matches nothing public — do not fall back
567            // to an O(store) raw scan. The scan fallback exists only for
568            // degraded or legacy stores whose sidecar is empty/missing.
569            if !matches!(self.sidecar_is_empty(), Ok(true)) {
570                return Ok(Vec::new());
571            }
572            // Existing stores or a degraded projection can still be searched.
573            self.scan(None, usize::MAX)?
574        } else {
575            let mut ranked_candidates: Vec<(EpisodicId, usize)> =
576                candidate_scores.into_iter().collect();
577            ranked_candidates.sort_by(|(left_id, left_count), (right_id, right_count)| {
578                right_count
579                    .cmp(left_count)
580                    .then_with(|| left_id.cmp(right_id))
581            });
582            ranked_candidates.truncate(candidate_limit);
583            self.load_records(
584                &ranked_candidates
585                    .into_iter()
586                    .map(|(id, _)| id)
587                    .collect::<Vec<_>>(),
588            )?
589        };
590
591        let mut results = Vec::new();
592        for record in records {
593            if !include_historical && !matches!(record.validity, ValidityState::Active) {
594                continue;
595            }
596            let content_terms = tokenize(&record.content);
597            let content_keys = key_index_terms_with_aliases(&record.content, self.aliases.as_ref());
598            // For UserStatement records, also count reverse-enrichment matches:
599            // if the query has "play" and the content has "production", count
600            // it as a match. This bridges the vocabulary gap for answer turns
601            // without boosting competing Assistant turns.
602            let reverse_map: HashMap<&String, Vec<String>> =
603                if let Some(ref enrichment) = self.enrichment {
604                    if matches!(record.kind, EpisodicKind::UserStatement) {
605                        query_terms
606                            .iter()
607                            .map(|qt| (qt, enrichment.reverse_enrich(qt)))
608                            .collect()
609                    } else {
610                        HashMap::new()
611                    }
612                } else {
613                    HashMap::new()
614                };
615            let mut reverse_match_count = 0usize;
616            let matched_terms = query_terms
617                .iter()
618                .filter(|term| {
619                    if content_terms.iter().any(|candidate| candidate == *term)
620                        || content_keys.iter().any(|candidate| candidate == *term)
621                    {
622                        return true;
623                    }
624                    // Check reverse enrichment: does the content have any term
625                    // that maps to this query term?
626                    if let Some(reverse_terms) = reverse_map.get(term) {
627                        let found = reverse_terms.iter().any(|rt| {
628                            content_terms.iter().any(|candidate| candidate == rt)
629                                || content_keys.iter().any(|candidate| candidate == rt)
630                        });
631                        if found {
632                            reverse_match_count += 1;
633                        }
634                        return found;
635                    }
636                    false
637                })
638                .count();
639            let matched_keys = query_keys
640                .iter()
641                .filter(|term| {
642                    content_keys.iter().any(|candidate| candidate == *term)
643                        || content_terms.iter().any(|candidate| candidate == *term)
644                })
645                .count();
646            if matched_terms == 0 && matched_keys == 0 {
647                continue;
648            }
649            let key_bonus = if query_keys.is_empty() {
650                0.0
651            } else {
652                matched_keys as f32 / query_keys.len() as f32 * plan.key_weight
653            };
654            let role_boost = match record.kind {
655                EpisodicKind::UserStatement => 0.12,
656                _ => 0.0,
657            };
658            let effective_matched = if matches!(record.kind, EpisodicKind::UserStatement) {
659                (matched_terms + 2).min(query_terms.len())
660            } else {
661                matched_terms
662            };
663            let coverage = if query_terms.is_empty() {
664                0.0
665            } else {
666                effective_matched as f32 / query_terms.len() as f32
667            };
668            let number_bonus = if plan.number_query {
669                let has_digit = content_terms
670                    .iter()
671                    .any(|term| term.chars().any(|c| c.is_ascii_digit()));
672                if has_digit || contains_number_word(&record.content) {
673                    0.03
674                } else {
675                    0.0
676                }
677            } else {
678                0.0
679            };
680            let density = matched_terms as f32 / content_terms.len().max(1) as f32;
681            results.push(EpisodicSearchResult {
682                record,
683                score: coverage
684                    + key_bonus
685                    + role_boost
686                    + number_bonus
687                    + (reverse_match_count as f32).mul_add(0.05, density * 0.03),
688                matched_terms: matched_terms.max(matched_keys),
689            });
690        }
691        // Session-aware RRF boost: turns from sessions with multiple matching
692        // turns get a small score boost. This is a simplified RRF that preserves
693        // the deterministic score scale for the reranking pipeline.
694        let mut session_counts: HashMap<Option<uuid::Uuid>, usize> = HashMap::new();
695        for r in &results {
696            *session_counts.entry(r.record.session_id).or_default() += 1;
697        }
698        for r in &mut results {
699            let count = session_counts
700                .get(&r.record.session_id)
701                .copied()
702                .unwrap_or(1);
703            if count > 1 {
704                r.score = 0.02f32.mul_add((count - 1).min(3) as f32, r.score);
705            }
706        }
707        // Content-frequency boost (consolidation): if the same content hash
708        // appears multiple times in the result set, boost all instances. This
709        // simulates consolidation — facts mentioned repeatedly are more
710        // important. The boost is small (0.03 per duplicate, max 0.09) to
711        // avoid distorting the score scale.
712        let mut hash_counts: HashMap<&str, usize> = HashMap::new();
713        for r in &results {
714            *hash_counts
715                .entry(r.record.content_hash.as_str())
716                .or_default() += 1;
717        }
718        let hash_boosts: HashMap<String, f32> = results
719            .iter()
720            .map(|r| {
721                let count = hash_counts
722                    .get(r.record.content_hash.as_str())
723                    .copied()
724                    .unwrap_or(1);
725                let boost = if count > 1 {
726                    0.03 * (count - 1).min(3) as f32
727                } else {
728                    0.0
729                };
730                (r.record.id.to_string(), boost)
731            })
732            .collect();
733        for r in &mut results {
734            if let Some(boost) = hash_boosts.get(&r.record.id.to_string()) {
735                r.score += boost;
736            }
737        }
738        results.sort_by(|a, b| {
739            b.score
740                .partial_cmp(&a.score)
741                .unwrap_or(std::cmp::Ordering::Equal)
742                .then_with(|| b.matched_terms.cmp(&a.matched_terms))
743                .then_with(|| a.record.content.len().cmp(&b.record.content.len()))
744                .then_with(|| a.record.sequence.cmp(&b.record.sequence))
745                .then_with(|| a.record.id.cmp(&b.record.id))
746        });
747        Ok(results)
748    }
749
750    /// Search with vector reranking on top of deterministic scoring.
751    ///
752    /// Pipeline: deterministic scoring → top-N candidates → embed query +
753    /// candidates → reranking → top-K.
754    ///
755    /// Modes by `alpha`:
756    /// - `alpha >= 2.0` — **protected top-K** (ConvMemory v2 pattern):
757    ///   fully reorder only the deterministic top-`limit` set by cosine
758    ///   similarity. Membership is fixed, so recall@limit is preserved by
759    ///   construction; only ordering (R@1/MRR) can change.
760    /// - `1.0 <= alpha < 2.0` — **tiebreaker mode**: only reorder adjacent
761    ///   candidates whose deterministic scores are within δ=0.05, using
762    ///   cosine as the tiebreaker.
763    /// - `alpha < 1.0` — **hybrid blending**: score = α·det_norm + (1-α)·cosine.
764    ///
765    /// Falls back to `search_with_limits()` when no embedder is attached.
766    pub fn search_with_rerank(
767        &self,
768        query: &str,
769        limit: usize,
770        candidate_limit: usize,
771        include_historical: bool,
772        alpha: f32,
773        rerank_pool: usize,
774    ) -> Result<Vec<EpisodicSearchResult>> {
775        let Some(ref embedder) = self.embedder else {
776            return self.search_with_limits(query, limit, candidate_limit, include_historical);
777        };
778        if !embedder.is_available() || limit == 0 {
779            return self.search_with_limits(query, limit, candidate_limit, include_historical);
780        }
781
782        // `candidate_limit` is the deterministic retrieval width (how deep
783        // the scorer looks); `rerank_pool` bounds only how many of those
784        // candidates are embedded and reordered (0 = auto). Decoupled so a
785        // wide deterministic membership can back a small embed pool — the
786        // old shared value also shrank the deterministic set, which broke
787        // protected mode's fixed-membership claim against the baseline
788        // (T0 finding F-T0-1).
789        let rerank_pool = if rerank_pool == 0 {
790            limit.max(candidate_limit).min(50)
791        } else {
792            rerank_pool.max(limit).min(50)
793        };
794        // `search_scored` does not truncate (its limit arg is planning-only),
795        // so the pool is enforced here explicitly.
796        let deterministic: Vec<EpisodicSearchResult> = self
797            .search_scored(query, rerank_pool, candidate_limit, include_historical)?
798            .into_iter()
799            .take(rerank_pool)
800            .collect();
801        if deterministic.is_empty() {
802            return Ok(Vec::new());
803        }
804
805        // Batch-embed query + all candidate contents in one call.
806        let contents: Vec<&str> = std::iter::once(query)
807            .chain(deterministic.iter().map(|r| r.record.content.as_str()))
808            .collect();
809        let embeddings = embedder.embed_batch(&contents)?;
810        if embeddings.len() != deterministic.len() + 1 {
811            return Err(CoreError::Memory(format!(
812                "embedder returned {} vectors, expected {}",
813                embeddings.len(),
814                deterministic.len() + 1
815            )));
816        }
817        let query_vec = &embeddings[0];
818        let candidate_vecs = &embeddings[1..];
819
820        if alpha >= 2.0 {
821            // Protected top-K rerank (ConvMemory v2 pattern): fully reorder
822            // ONLY the deterministic top-`limit` set by cosine similarity.
823            // Set membership is fixed, so recall@limit is preserved by
824            // construction — only the ordering (R@1/MRR) can change.
825            let protected: Vec<EpisodicSearchResult> =
826                deterministic.into_iter().take(limit).collect();
827            let cosines: Vec<f32> = protected
828                .iter()
829                .enumerate()
830                .map(|(i, _)| cosine_sim(query_vec, &candidate_vecs[i]))
831                .collect();
832            let mut order: Vec<usize> = (0..protected.len()).collect();
833            order.sort_by(|&a, &b| {
834                cosines[b]
835                    .partial_cmp(&cosines[a])
836                    .unwrap_or(std::cmp::Ordering::Equal)
837                    // Stable within equal cosine: keep deterministic order.
838                    .then_with(|| a.cmp(&b))
839            });
840            let mut slots: Vec<Option<EpisodicSearchResult>> =
841                protected.into_iter().map(Some).collect();
842            let reranked: Vec<EpisodicSearchResult> =
843                order.into_iter().filter_map(|i| slots[i].take()).collect();
844            Ok(reranked)
845        } else if alpha >= 1.0 {
846            // Tiebreaker mode: only reorder adjacent candidates with close det scores.
847            let delta = 0.05;
848            let mut reranked = deterministic;
849            let cosines: Vec<f32> = candidate_vecs
850                .iter()
851                .map(|v| cosine_sim(query_vec, v))
852                .collect();
853            // Bubble-sort adjacent swaps only when det scores are within delta.
854            let n = reranked.len();
855            for _ in 0..n {
856                let mut swapped = false;
857                for i in 0..n.saturating_sub(1) {
858                    let det_gap = (reranked[i].score - reranked[i + 1].score).abs();
859                    if det_gap < delta && cosines[i + 1] > cosines[i] {
860                        reranked.swap(i, i + 1);
861                        swapped = true;
862                    }
863                }
864                if !swapped {
865                    break;
866                }
867            }
868            if is_current_query(query) {
869                self.resolve_current(&mut reranked);
870            }
871            reranked.truncate(limit);
872            Ok(reranked)
873        } else {
874            // Hybrid blending mode.
875            let max_det = deterministic
876                .iter()
877                .map(|r| r.score)
878                .fold(0.0f32, f32::max)
879                .max(1e-9);
880
881            let mut reranked: Vec<EpisodicSearchResult> = deterministic
882                .into_iter()
883                .enumerate()
884                .map(|(i, mut r)| {
885                    let cosine = cosine_sim(query_vec, &candidate_vecs[i]);
886                    let det_norm = r.score / max_det;
887                    r.score = alpha.mul_add(det_norm, (1.0 - alpha) * cosine);
888                    r
889                })
890                .collect();
891
892            reranked.sort_by(|a, b| {
893                b.score
894                    .partial_cmp(&a.score)
895                    .unwrap_or(std::cmp::Ordering::Equal)
896                    .then_with(|| b.matched_terms.cmp(&a.matched_terms))
897                    .then_with(|| a.record.content.len().cmp(&b.record.content.len()))
898                    .then_with(|| a.record.sequence.cmp(&b.record.sequence))
899                    .then_with(|| a.record.id.cmp(&b.record.id))
900            });
901            if is_current_query(query) {
902                self.resolve_current(&mut reranked);
903            }
904            reranked.truncate(limit);
905            Ok(reranked)
906        }
907    }
908
909    /// Post-retrieval temporal resolution for "current value" queries.
910    ///
911    /// When the user asks "What's my current favorite X?", the current-value
912    /// statement often matches *fewer* query terms than an older statement
913    /// ("my favorite coffee is dark roast" matches favorite+coffee, while
914    /// "I switched to cold brew for coffee" matches only coffee), so pure
915    /// score order prefers the stale fact. This layer instead promotes the
916    /// currency signal directly:
917    ///
918    /// 1. Anchor set: `UserStatement` records containing a change marker
919    ///    ("switched to", "changed my", "now prefer", "used to", ...) — the
920    ///    user's own words that a value moved. Only user statements anchor:
921    ///    the user's own statement is the authority for their current state,
922    ///    and assistant echoes must not hijack chronology.
923    /// 2. Anchors are ordered by deterministic chronology — `(created_at,
924    ///    sequence)` descending — so the most recent change outranks earlier
925    ///    ones (v1→v2→v3 chains resolve to v3).
926    /// 3. Remaining results keep their deterministic score order behind the
927    ///    anchors.
928    ///
929    /// Scoring is untouched and non-current queries take the identical path,
930    /// so behavior for historical questions is unchanged by construction
931    /// (see `docs/notes/research-2026-08-20-agent-memory.md`: the
932    /// Post-Retrieval Assembly paper found the LongMemEval effect of
933    /// temporal machinery insignificant, p=0.45 — the gain is on
934    /// current-value questions, which is exactly what this targets).
935    fn resolve_current(&self, results: &mut Vec<EpisodicSearchResult>) {
936        if results.len() < 2 {
937            return;
938        }
939        let mut anchors: Vec<EpisodicSearchResult> = Vec::new();
940        let mut rest: Vec<EpisodicSearchResult> = Vec::new();
941        for result in results.drain(..) {
942            let is_anchor = matches!(result.record.kind, EpisodicKind::UserStatement)
943                && contains_change_marker(&result.record.content);
944            if is_anchor {
945                anchors.push(result);
946            } else {
947                rest.push(result);
948            }
949        }
950        if anchors.is_empty() {
951            // No currency signal — keep the deterministic score order.
952            *results = rest;
953            return;
954        }
955        anchors.sort_by(|a, b| {
956            b.record
957                .created_at
958                .cmp(&a.record.created_at)
959                .then_with(|| b.record.sequence.cmp(&a.record.sequence))
960                .then_with(|| a.record.id.cmp(&b.record.id))
961        });
962        anchors.extend(rest);
963        *results = anchors;
964    }
965
966    fn load_records(&self, ids: &[EpisodicId]) -> Result<Vec<EpisodicRecord>> {
967        let tx = self
968            .env
969            .begin_ro_txn()
970            .map_err(|e| CoreError::Memory(format!("episodic ro_txn failed: {e}")))?;
971        let mut records = Vec::with_capacity(ids.len());
972        for id in ids {
973            match tx.get(self.db, id.as_bytes()) {
974                Ok(bytes) => {
975                    records.push(rmp_serde::from_slice(bytes).map_err(|e| {
976                        CoreError::Memory(format!("episodic deserialize failed: {e}"))
977                    })?);
978                }
979                Err(lmdb::Error::NotFound) => {}
980                Err(e) => return Err(CoreError::Memory(format!("episodic get failed: {e}"))),
981            }
982        }
983        tx.commit()
984            .map_err(|e| CoreError::Memory(format!("episodic commit failed: {e}")))?;
985        Ok(records)
986    }
987}
988
989fn index_terms_with_aliases(text: &str, aliases: Option<&AdaptiveAliases>) -> Vec<String> {
990    tokenize(text)
991        .into_iter()
992        .chain(key_index_terms_with_aliases(text, aliases))
993        .fold(Vec::new(), |mut terms, term| {
994            if !terms.contains(&term) {
995                terms.push(term);
996            }
997            terms
998        })
999}
1000
1001/// LMDB's default maximum key size is 511 bytes; longer keys abort the
1002/// write with `MDB_BAD_VALSIZE`. A whitespace-free 512-character session
1003/// title reproduced exactly that on 2026-09-19: the authoritative record and
1004/// Tantivy index committed, the sidecar write failed, and the tool still
1005/// reported unqualified success. Terms longer than this bound are replaced
1006/// by a deterministic hash form — same bytes at index and query time — so
1007/// the sidecar stays writable and exact-match semantics are preserved.
1008///
1009/// The bound leaves margin under 511 for any future key framing.
1010const MAX_TERM_KEY_BYTES: usize = 480;
1011
1012/// Canonical sidecar key for a term: unchanged when it fits under the LMDB
1013/// key limit, deterministic `~h:<sha256hex>` otherwise. The `~` prefix
1014/// cannot collide with a tokenizer term: `tokenize` splits on
1015/// non-alphanumeric characters, so no real term contains `~`.
1016fn index_safe_term(term: &str) -> String {
1017    if term.len() <= MAX_TERM_KEY_BYTES {
1018        return term.to_string();
1019    }
1020    let digest = Sha256::digest(term.as_bytes());
1021    format!("~h:{digest:x}")
1022}
1023
1024fn tokenize(text: &str) -> Vec<String> {
1025    strip_stopwords(text)
1026        .split(|c: char| !c.is_alphanumeric())
1027        .filter(|term| term.len() > 1)
1028        .map(|term| simple_stem(&term.to_ascii_lowercase()))
1029        .fold(Vec::new(), |mut terms, term| {
1030            if !terms.contains(&term) {
1031                terms.push(term);
1032            }
1033            terms
1034        })
1035}
1036
1037/// Single-word cues that ask for the current value of something.
1038const CURRENT_QUERY_WORD_CUES: &[&str] = &["current", "currently", "latest", "nowadays"];
1039
1040/// Multi-word cues that ask for the current value of something.
1041const CURRENT_QUERY_PHRASE_CUES: &[&str] = &["these days", "right now", "at the moment"];
1042
1043/// True when the query asks for the current/latest value of something,
1044/// e.g. "What's my current favorite coffee?".
1045///
1046/// Only such queries trigger post-retrieval temporal resolution
1047/// ([`EpisodicStore::resolve_current`]); all other queries take the
1048/// deterministic score order unchanged.
1049#[must_use]
1050pub fn is_current_query(query: &str) -> bool {
1051    let lowered = query.to_ascii_lowercase();
1052    let has_word = lowered
1053        .split(|c: char| !c.is_alphanumeric())
1054        .any(|token| CURRENT_QUERY_WORD_CUES.contains(&token));
1055    has_word
1056        || CURRENT_QUERY_PHRASE_CUES
1057            .iter()
1058            .any(|cue| lowered.contains(cue))
1059}
1060
1061/// Phrase markers that a stated value has changed — the currency signal
1062/// used by [`EpisodicStore::resolve_current`]. Kept deliberately specific:
1063/// these phrases indicate a *transition*, not merely a preference
1064/// statement, so plain "my favorite X is Y" statements never anchor.
1065const CHANGE_MARKERS: &[&str] = &[
1066    "switched to",
1067    "switch to",
1068    "switching to",
1069    "switched from",
1070    "changed my",
1071    "change my",
1072    "changed from",
1073    "now prefer",
1074    "now i prefer",
1075    "now i'm",
1076    "now im",
1077    "no longer",
1078    "used to",
1079    "moved to",
1080    "not anymore",
1081    "instead of",
1082    "replaced",
1083    "gave up",
1084];
1085
1086/// True when the content contains a phrase marking a value transition.
1087fn contains_change_marker(content: &str) -> bool {
1088    let lowered = content.to_ascii_lowercase();
1089    CHANGE_MARKERS.iter().any(|marker| lowered.contains(marker))
1090}
1091
1092/// Markers that explicitly contradict a previously stated value — signals
1093/// that two retrieved statements may conflict and should be surfaced
1094/// together (TANGLE semantics: preserve both, never silently resolve).
1095const CONTRADICTION_MARKERS: &[&str] = &[
1096    "no longer",
1097    "anymore",
1098    "changed my mind",
1099    "changed my",
1100    "used to",
1101    "gave up",
1102    "just a phase",
1103    "not really",
1104    "but i",
1105];
1106
1107/// A read-time contradiction between two retrieved user statements.
1108///
1109/// Detection is deliberately conservative: one statement must carry an
1110/// explicit contradiction marker, and the pair must share at least two
1111/// content terms (the topic cluster). The report preserves both sides with
1112/// full provenance — it never adjudicates.
1113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1114pub struct EpisodicConflict {
1115    /// The chronologically later statement (carries the contradiction
1116    /// marker in the common case).
1117    pub later_record: EpisodicId,
1118    /// The statement it appears to contradict.
1119    pub earlier_record: EpisodicId,
1120    /// The contradiction phrase that triggered the detection.
1121    pub marker: String,
1122    /// Content terms shared by the pair (the topic cluster).
1123    pub shared_terms: Vec<String>,
1124    /// Full content of the later statement (provenance).
1125    pub later_content: String,
1126    /// Full content of the earlier statement (provenance).
1127    pub earlier_content: String,
1128}
1129
1130/// Detect contradictions among retrieved results.
1131///
1132/// For every `UserStatement` carrying an explicit contradiction marker,
1133/// pair it with other `UserStatement` results sharing at least two content
1134/// terms. Statements with identical content hashes (the same fact seen
1135/// twice) are not conflicts. Results are capped to keep tool output
1136/// bounded.
1137#[must_use]
1138pub fn detect_conflicts(results: &[EpisodicSearchResult]) -> Vec<EpisodicConflict> {
1139    const MAX_CONFLICTS: usize = 10;
1140    let mut conflicts: Vec<EpisodicConflict> = Vec::new();
1141    let mut seen_pairs: Vec<(EpisodicId, EpisodicId)> = Vec::new();
1142    for (i, marked) in results.iter().enumerate() {
1143        if !matches!(marked.record.kind, EpisodicKind::UserStatement) {
1144            continue;
1145        }
1146        let lowered = marked.record.content.to_ascii_lowercase();
1147        let Some(marker) = CONTRADICTION_MARKERS
1148            .iter()
1149            .find(|m| lowered.contains(*m))
1150            .copied()
1151        else {
1152            continue;
1153        };
1154        let marked_terms = tokenize(&marked.record.content);
1155        for (j, other) in results.iter().enumerate() {
1156            if i == j || !matches!(other.record.kind, EpisodicKind::UserStatement) {
1157                continue;
1158            }
1159            if other.record.content_hash == marked.record.content_hash {
1160                continue;
1161            }
1162            let other_terms = tokenize(&other.record.content);
1163            let shared: Vec<String> = marked_terms
1164                .iter()
1165                .filter(|t| other_terms.contains(t))
1166                .cloned()
1167                .collect();
1168            if shared.len() < 2 {
1169                continue;
1170            }
1171            // Label by deterministic chronology: the later statement is the
1172            // one the user said most recently.
1173            let (later, earlier) = if (marked.record.created_at, marked.record.sequence)
1174                > (other.record.created_at, other.record.sequence)
1175            {
1176                (&marked.record, &other.record)
1177            } else {
1178                (&other.record, &marked.record)
1179            };
1180            let pair_key = if later.id < earlier.id {
1181                (later.id, earlier.id)
1182            } else {
1183                (earlier.id, later.id)
1184            };
1185            if seen_pairs.contains(&pair_key) {
1186                continue;
1187            }
1188            seen_pairs.push(pair_key);
1189            conflicts.push(EpisodicConflict {
1190                later_record: later.id,
1191                earlier_record: earlier.id,
1192                marker: marker.to_string(),
1193                shared_terms: shared,
1194                later_content: later.content.clone(),
1195                earlier_content: earlier.content.clone(),
1196            });
1197            if conflicts.len() >= MAX_CONFLICTS {
1198                return conflicts;
1199            }
1200        }
1201    }
1202    conflicts
1203}
1204
1205fn simple_stem(word: &str) -> String {
1206    if word.len() <= 3 {
1207        return word.to_string();
1208    }
1209    for suffix in ["ies", "ied", "ing", "edly", "ed", "ly", "es", "s"] {
1210        if let Some(stem) = word.strip_suffix(suffix) {
1211            if suffix == "ies" || suffix == "ied" {
1212                return format!("{stem}y");
1213            }
1214            if stem.len() >= 2 {
1215                return stem.to_string();
1216            }
1217        }
1218    }
1219    word.to_string()
1220}
1221
1222fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
1223    let dot = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum::<f32>();
1224    let norm_a = a.iter().map(|x| x * x).sum::<f32>().sqrt();
1225    let norm_b = b.iter().map(|x| x * x).sum::<f32>().sqrt();
1226    if norm_a < 1e-9 || norm_b < 1e-9 {
1227        0.0
1228    } else {
1229        dot / (norm_a * norm_b)
1230    }
1231}
1232
1233fn contains_number_word(text: &str) -> bool {
1234    const NUMBER_WORDS: &[&str] = &[
1235        "one",
1236        "two",
1237        "three",
1238        "four",
1239        "five",
1240        "six",
1241        "seven",
1242        "eight",
1243        "nine",
1244        "ten",
1245        "eleven",
1246        "twelve",
1247        "thirteen",
1248        "fourteen",
1249        "fifteen",
1250        "sixteen",
1251        "seventeen",
1252        "eighteen",
1253        "nineteen",
1254        "twenty",
1255        "thirty",
1256        "forty",
1257        "fifty",
1258        "sixty",
1259        "seventy",
1260        "eighty",
1261        "ninety",
1262        "hundred",
1263        "thousand",
1264        "million",
1265        "billion",
1266        "dozen",
1267        "couple",
1268        "half",
1269        "quarter",
1270        "double",
1271        "triple",
1272        "twice",
1273    ];
1274    for word in text.split(|c: char| !c.is_alphanumeric()) {
1275        if word.len() >= 3 && NUMBER_WORDS.iter().any(|nw| word.eq_ignore_ascii_case(nw)) {
1276            return true;
1277        }
1278    }
1279    false
1280}
1281
1282#[cfg(test)]
1283mod tests {
1284    use super::*;
1285    use crate::MemoryStore;
1286    // Used only by the Q06 commit-boundary experiment, which is Unix-only
1287    // (SIGKILL semantics); ungated imports are unused on Windows and fail the
1288    // CI `-D warnings` build.
1289    #[cfg(unix)]
1290    use chrono::{DateTime, Utc};
1291    use tempfile::tempdir;
1292    #[cfg(unix)]
1293    use uuid::Uuid;
1294    use wm_core::{EpisodicKind, Provenance, ProvenanceSource, ValidityState};
1295
1296    fn sample_record(sequence: u64, content: &str) -> EpisodicRecord {
1297        EpisodicRecord::new(
1298            None,
1299            sequence,
1300            EpisodicKind::Observation,
1301            content,
1302            Provenance::new(ProvenanceSource::User),
1303        )
1304    }
1305
1306    fn user_statement(sequence: u64, content: &str) -> EpisodicRecord {
1307        EpisodicRecord::new(
1308            None,
1309            sequence,
1310            EpisodicKind::UserStatement,
1311            content,
1312            Provenance::new(ProvenanceSource::User),
1313        )
1314    }
1315
1316    fn assistant_response(sequence: u64, content: &str) -> EpisodicRecord {
1317        EpisodicRecord::new(
1318            None,
1319            sequence,
1320            EpisodicKind::AssistantResponse,
1321            content,
1322            Provenance::new(ProvenanceSource::Agent),
1323        )
1324    }
1325
1326    // ── Q06 commit-boundary subprocess experiment ──────────────────────────
1327    // Design: docs/V9_3_Q06_COMMIT_BOUNDARY_EXPERIMENT.md. One parent test plus
1328    // a child branch selected by WM_Q06_CASE. The parent re-executes its own
1329    // test binary, kills the child at an exact commit-boundary hook, reopens
1330    // the store, and classifies the candidate. SIGKILL models abrupt process
1331    // termination only — not power loss.
1332
1333    #[cfg(unix)]
1334    fn q06_record(id: u128, sequence: u64, content: &str, created_at: &str) -> EpisodicRecord {
1335        let mut record = EpisodicRecord::new(
1336            None,
1337            sequence,
1338            EpisodicKind::Observation,
1339            content,
1340            Provenance::new(ProvenanceSource::User),
1341        )
1342        .with_id(Uuid::from_u128(id));
1343        record.created_at = DateTime::parse_from_rfc3339(created_at)
1344            .unwrap()
1345            .with_timezone(&Utc);
1346        record
1347    }
1348
1349    #[cfg(unix)]
1350    fn q06_acknowledged() -> EpisodicRecord {
1351        q06_record(601, 601, "q06 acknowledged control", "2026-01-01T00:10:01Z")
1352    }
1353
1354    #[cfg(unix)]
1355    fn run_q06_child(case: &str, store_path: &std::path::Path) {
1356        use std::io::Write;
1357        let expected_uuid = std::env::var("WM_Q06_UUID").expect("WM_Q06_UUID");
1358        let uuid = Uuid::parse_str(&expected_uuid).expect("WM_Q06_UUID must parse");
1359        let (sequence, content, created_at) = match case {
1360            "before_raw_commit" => (602, "q06 precommit candidate", "2026-01-01T00:10:02Z"),
1361            "after_raw_commit" => (603, "q06 uncertain candidate", "2026-01-01T00:10:03Z"),
1362            other => panic!("q06 child: unknown case {other}"),
1363        };
1364        let record = q06_record(uuid.as_u128(), sequence, content, created_at);
1365        let store = MemoryStore::open_default(store_path).expect("q06 child store");
1366        match store.episodic().append(&record) {
1367            Ok(()) => {
1368                // Only legal when the termination window was missed; the
1369                // parent treats this line as a hard failure.
1370                println!("WM_Q06_CALLER_ACK {uuid}");
1371                let _ = std::io::stdout().flush();
1372            }
1373            Err(e) => {
1374                eprintln!("q06 child append failed: {e}");
1375                std::process::exit(2);
1376            }
1377        }
1378    }
1379
1380    #[cfg(unix)]
1381    fn run_q06_killed_case(
1382        test_filter: &str,
1383        store_path: &std::path::Path,
1384        case: &str,
1385        uuid: Uuid,
1386    ) -> Vec<String> {
1387        use std::io::{BufRead, BufReader};
1388        let exe = std::env::current_exe().expect("q06 current_exe");
1389        let mut child = std::process::Command::new(exe)
1390            .arg(test_filter)
1391            .arg("--exact")
1392            .arg("--nocapture")
1393            .env("WM_Q06_CASE", case)
1394            .env("WM_Q06_STORE", store_path)
1395            .env("WM_Q06_UUID", uuid.to_string())
1396            .stdout(std::process::Stdio::piped())
1397            .stdin(std::process::Stdio::piped())
1398            .stderr(std::process::Stdio::inherit())
1399            .spawn()
1400            .expect("q06 spawn child");
1401
1402        let stdout = child.stdout.take().expect("q06 child stdout");
1403        let (tx, rx) = std::sync::mpsc::channel::<String>();
1404        let reader = std::thread::spawn(move || {
1405            for line in BufReader::new(stdout).lines() {
1406                match line {
1407                    Ok(line) => {
1408                        if tx.send(line).is_err() {
1409                            break;
1410                        }
1411                    }
1412                    Err(_) => break,
1413                }
1414            }
1415        });
1416
1417        let expected = format!("WM_Q06_BOUNDARY {case} {uuid}");
1418        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1419        let mut lines = Vec::new();
1420        let mut seen = false;
1421        while !seen {
1422            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1423            if remaining.is_zero() {
1424                break;
1425            }
1426            match rx.recv_timeout(remaining) {
1427                Ok(line) if line == expected => seen = true,
1428                Ok(line) => lines.push(line),
1429                Err(_) => break,
1430            }
1431        }
1432        if !seen {
1433            let _ = child.kill();
1434            let _ = child.wait();
1435            reader.join().ok();
1436            panic!("q06 case {case}: boundary {expected:?} not observed; lines={lines:?}");
1437        }
1438
1439        child.kill().expect("q06 kill blocked child");
1440        let status = child.wait().expect("q06 reap child");
1441        assert!(
1442            !status.success(),
1443            "q06 case {case}: killed child must not exit successfully: {status:?}"
1444        );
1445        reader.join().ok();
1446        while let Ok(line) = rx.try_recv() {
1447            lines.push(line);
1448        }
1449        assert!(
1450            !lines.iter().any(|line| line.contains("WM_Q06_CALLER_ACK")),
1451            "q06 case {case}: caller acknowledgement in a killed case invalidates the experiment: {lines:?}"
1452        );
1453        lines
1454    }
1455
1456    #[cfg(unix)]
1457    #[test]
1458    fn q06_commit_boundary_sigkill_classification() {
1459        if let Ok(case) = std::env::var("WM_Q06_CASE") {
1460            let store_path = std::env::var("WM_Q06_STORE").expect("WM_Q06_STORE");
1461            run_q06_child(&case, std::path::Path::new(&store_path));
1462            return;
1463        }
1464
1465        let dir = tempdir().unwrap();
1466        let store_path = dir.path().join("lmdb");
1467        let acknowledged = q06_acknowledged();
1468
1469        // Acknowledged pre-state: normal append, exact read, store dropped.
1470        {
1471            let store = MemoryStore::open_default(&store_path).unwrap();
1472            store.episodic().append(&acknowledged).unwrap();
1473            let read = store
1474                .episodic()
1475                .get(acknowledged.id)
1476                .unwrap()
1477                .expect("acknowledged record");
1478            assert_eq!(read, acknowledged);
1479        }
1480
1481        let test_filter = "episodic::tests::q06_commit_boundary_sigkill_classification";
1482
1483        // Case A — killed immediately before the raw commit: rejected/uncommitted.
1484        let candidate_before =
1485            q06_record(602, 602, "q06 precommit candidate", "2026-01-01T00:10:02Z");
1486        run_q06_killed_case(
1487            test_filter,
1488            &store_path,
1489            "before_raw_commit",
1490            candidate_before.id,
1491        );
1492        {
1493            let store = MemoryStore::open_default(&store_path).unwrap();
1494            assert_eq!(
1495                store.episodic().get(acknowledged.id).unwrap(),
1496                Some(acknowledged.clone()),
1497                "acknowledged record must survive a pre-commit kill unchanged"
1498            );
1499            assert_eq!(
1500                store.episodic().get(candidate_before.id).unwrap(),
1501                None,
1502                "pre-commit candidate must be absent after reopen"
1503            );
1504        }
1505
1506        // Case B — killed after the raw commit, before acknowledgement:
1507        // storage committed, caller outcome uncertain.
1508        let candidate_after =
1509            q06_record(603, 603, "q06 uncertain candidate", "2026-01-01T00:10:03Z");
1510        run_q06_killed_case(
1511            test_filter,
1512            &store_path,
1513            "after_raw_commit",
1514            candidate_after.id,
1515        );
1516        {
1517            let store = MemoryStore::open_default(&store_path).unwrap();
1518            assert_eq!(
1519                store.episodic().get(acknowledged.id).unwrap(),
1520                Some(acknowledged),
1521                "acknowledged record must survive a post-commit kill unchanged"
1522            );
1523            assert_eq!(
1524                store.episodic().get(candidate_before.id).unwrap(),
1525                None,
1526                "pre-commit candidate must stay absent"
1527            );
1528            assert_eq!(
1529                store.episodic().get(candidate_after.id).unwrap(),
1530                Some(candidate_after),
1531                "post-commit candidate must be present and byte-equal after reopen"
1532            );
1533        }
1534    }
1535
1536    #[test]
1537    fn current_query_detection() {
1538        assert!(is_current_query("What's my current favorite coffee?"));
1539        assert!(is_current_query("What am I currently reading these days?"));
1540        assert!(is_current_query("What's the latest book I mentioned?"));
1541        assert!(is_current_query("What's my job right now?"));
1542        assert!(is_current_query("What am I eating at the moment?"));
1543        assert!(!is_current_query("What's my favorite coffee?"));
1544        assert!(!is_current_query("Where did I volunteer in February?"));
1545        assert!(!is_current_query("What did I say about the trip?"));
1546        // Word-boundary match: "current" inside another word must not fire.
1547        assert!(!is_current_query("What currency did I use in Japan?"));
1548    }
1549
1550    #[test]
1551    fn protected_rerank_preserves_membership_with_a_small_pool() {
1552        // T0 F-T0-1: `rerank_pool` bounds only the embedded/reordered set;
1553        // `candidate_limit` stays the deterministic retrieval width. The old
1554        // code used the pool for both, so shrinking the pool also shrank the
1555        // candidate set and protected mode lost baseline membership.
1556        use crate::embedder::Embedder;
1557        use std::sync::Arc;
1558        use std::sync::atomic::{AtomicUsize, Ordering};
1559
1560        struct CountingEmbedder(Arc<AtomicUsize>);
1561        impl Embedder for CountingEmbedder {
1562            fn embed_batch(&self, texts: &[&str]) -> wm_core::Result<Vec<Vec<f32>>> {
1563                self.0.store(texts.len(), Ordering::Relaxed);
1564                Ok(texts
1565                    .iter()
1566                    .map(|t| {
1567                        let mut v = vec![0.0_f32; 8];
1568                        for (i, b) in t.bytes().enumerate() {
1569                            v[i % 8] += f32::from(b) / 255.0;
1570                        }
1571                        v
1572                    })
1573                    .collect())
1574            }
1575            fn dimension(&self) -> usize {
1576                8
1577            }
1578            fn is_available(&self) -> bool {
1579                true
1580            }
1581            fn backend_name(&self) -> &'static str {
1582                "counting-test"
1583            }
1584        }
1585
1586        let tmp = tempdir().unwrap();
1587        let store = MemoryStore::open_default(tmp.path()).unwrap();
1588        for i in 0..20 {
1589            store
1590                .episodic()
1591                .append(&user_statement(
1592                    i,
1593                    &format!("Record {i} discusses topic alpha beta gamma delta epsilon"),
1594                ))
1595                .unwrap();
1596        }
1597
1598        let query = "topic alpha beta gamma delta epsilon";
1599        // Deterministic top-5 from a 20-wide candidate scoring — the
1600        // membership protected mode must preserve.
1601        let expected: Vec<uuid::Uuid> = store
1602            .episodic()
1603            .search_scored(query, 5, 20, false)
1604            .unwrap()
1605            .iter()
1606            .take(5)
1607            .map(|r| r.record.id)
1608            .collect();
1609        assert_eq!(expected.len(), 5);
1610
1611        let batches = Arc::new(AtomicUsize::new(0));
1612        store.set_episodic_embedder(Arc::new(CountingEmbedder(batches.clone())));
1613        let reranked = store
1614            .episodic()
1615            .search_with_rerank(query, 5, 20, false, 2.0, 5)
1616            .unwrap();
1617        assert_eq!(
1618            batches.load(Ordering::Relaxed),
1619            6,
1620            "small rerank pool must embed pool + query only"
1621        );
1622        let mut got: Vec<uuid::Uuid> = reranked.iter().map(|r| r.record.id).collect();
1623        let mut want = expected;
1624        got.sort();
1625        want.sort();
1626        assert_eq!(
1627            got, want,
1628            "protected mode must keep the deterministic candidate set (membership), only reorder it"
1629        );
1630    }
1631
1632    #[test]
1633    fn current_query_resolution_prefers_latest_statement() {
1634        let tmp = tempdir().unwrap();
1635        let store = MemoryStore::open_default(tmp.path()).unwrap();
1636        let episodic = store.episodic();
1637        // Old-value statements match MORE query terms (favorite + coffee)
1638        // than the change statement (coffee only), so pure score order
1639        // prefers the stale fact — the exact T1 failure mode.
1640        episodic
1641            .append(&user_statement(1, "My favorite coffee is dark roast."))
1642            .unwrap();
1643        episodic
1644            .append(&user_statement(
1645                2,
1646                "I really love dark roast when it comes to coffee.",
1647            ))
1648            .unwrap();
1649        episodic
1650            .append(&user_statement(3, "I've been jogging lately."))
1651            .unwrap();
1652        episodic
1653            .append(&user_statement(4, "I've switched to cold brew for coffee."))
1654            .unwrap();
1655
1656        let results = episodic
1657            .search("What's my current favorite coffee?", 5, false)
1658            .unwrap();
1659        assert!(!results.is_empty());
1660        assert!(
1661            results[0].record.content.contains("cold brew"),
1662            "current query must rank the latest statement first, got: {}",
1663            results[0].record.content
1664        );
1665    }
1666
1667    #[test]
1668    fn current_query_anchors_switched_from_template() {
1669        // Regression: "I've actually switched from X to Y" (MemoraStrict
1670        // change-template 1) contains "switched from", which was missing
1671        // from CHANGE_MARKERS — the anchor set stayed empty and the stale
1672        // value won on score order. Found by static miss analysis
1673        // 2026-08-20: 12/40 T1+T6 questions failed on exactly this phrase.
1674        let tmp = tempdir().unwrap();
1675        let store = MemoryStore::open_default(tmp.path()).unwrap();
1676        let episodic = store.episodic();
1677        episodic
1678            .append(&user_statement(1, "My favorite coffee is espresso."))
1679            .unwrap();
1680        episodic
1681            .append(&user_statement(
1682                2,
1683                "I've actually switched from espresso to latte for coffee.",
1684            ))
1685            .unwrap();
1686        episodic
1687            .append(&user_statement(3, "My favorite coffee is latte."))
1688            .unwrap();
1689
1690        let results = episodic
1691            .search("What's my current favorite coffee?", 5, false)
1692            .unwrap();
1693        assert!(!results.is_empty());
1694        assert!(
1695            results[0].record.content.contains("latte"),
1696            "'switched from' must anchor the current value, got: {}",
1697            results[0].record.content
1698        );
1699    }
1700
1701    #[test]
1702    fn non_current_query_keeps_score_order() {
1703        let tmp = tempdir().unwrap();
1704        let store = MemoryStore::open_default(tmp.path()).unwrap();
1705        let episodic = store.episodic();
1706        episodic
1707            .append(&user_statement(1, "My favorite coffee is dark roast."))
1708            .unwrap();
1709        episodic
1710            .append(&user_statement(2, "I've switched to cold brew for coffee."))
1711            .unwrap();
1712
1713        // Without a current-value cue the deterministic score order holds:
1714        // the statement matching more query terms (favorite + coffee) wins.
1715        let results = episodic
1716            .search("What's my favorite coffee?", 5, false)
1717            .unwrap();
1718        assert!(!results.is_empty());
1719        assert!(
1720            results[0].record.content.contains("dark roast"),
1721            "non-current query must keep score order, got: {}",
1722            results[0].record.content
1723        );
1724    }
1725
1726    #[test]
1727    fn current_resolution_anchors_on_user_statements_only() {
1728        let tmp = tempdir().unwrap();
1729        let store = MemoryStore::open_default(tmp.path()).unwrap();
1730        let episodic = store.episodic();
1731        episodic
1732            .append(&user_statement(1, "My favorite coffee is dark roast."))
1733            .unwrap();
1734        // An assistant echo created LATER must not hijack the anchor set.
1735        episodic
1736            .append(&assistant_response(
1737                2,
1738                "Got it, dark roast is your favorite coffee!",
1739            ))
1740            .unwrap();
1741        episodic
1742            .append(&user_statement(3, "I've switched to cold brew for coffee."))
1743            .unwrap();
1744
1745        let results = episodic
1746            .search("What's my current favorite coffee?", 5, false)
1747            .unwrap();
1748        assert!(
1749            results[0].record.content.contains("cold brew"),
1750            "user statements anchor chronology, got: {}",
1751            results[0].record.content
1752        );
1753        assert_eq!(results[0].record.kind, EpisodicKind::UserStatement);
1754    }
1755
1756    #[test]
1757    fn current_query_without_change_markers_keeps_score_order() {
1758        let tmp = tempdir().unwrap();
1759        let store = MemoryStore::open_default(tmp.path()).unwrap();
1760        let episodic = store.episodic();
1761        // No change markers anywhere: resolution must not reorder anything.
1762        episodic
1763            .append(&user_statement(
1764                1,
1765                "My favorite hiking trail is Eagle Ridge.",
1766            ))
1767            .unwrap();
1768        episodic
1769            .append(&user_statement(2, "I go hiking every weekend."))
1770            .unwrap();
1771
1772        let results = episodic
1773            .search("What's my current favorite hiking trail?", 5, false)
1774            .unwrap();
1775        assert!(!results.is_empty());
1776        assert!(
1777            results[0].record.content.contains("Eagle Ridge"),
1778            "no change markers → deterministic score order, got: {}",
1779            results[0].record.content
1780        );
1781    }
1782
1783    #[test]
1784    fn detect_conflicts_flags_contradiction_with_shared_topic() {
1785        let tmp = tempdir().unwrap();
1786        let store = MemoryStore::open_default(tmp.path()).unwrap();
1787        let episodic = store.episodic();
1788        episodic
1789            .append(&user_statement(
1790                1,
1791                "I'm vegetarian now. I decided to stop eating animal products.",
1792            ))
1793            .unwrap();
1794        episodic
1795            .append(&user_statement(
1796                2,
1797                "I'm not really vegetarian anymore, I eat steak now.",
1798            ))
1799            .unwrap();
1800        episodic
1801            .append(&user_statement(3, "I went hiking yesterday."))
1802            .unwrap();
1803
1804        let results = episodic
1805            .search("vegetarian steak eating", 10, false)
1806            .unwrap();
1807        let conflicts = detect_conflicts(&results);
1808        assert_eq!(
1809            conflicts.len(),
1810            1,
1811            "the vegetarian/steak pair must be flagged, got {conflicts:?}"
1812        );
1813        let conflict = &conflicts[0];
1814        assert!(conflict.later_content.contains("steak"));
1815        assert!(conflict.earlier_content.contains("animal products"));
1816        assert!(
1817            conflict.shared_terms.iter().any(|t| t == "vegetarian"),
1818            "shared terms must include the topic: {:?}",
1819            conflict.shared_terms
1820        );
1821    }
1822
1823    #[test]
1824    fn detect_conflicts_ignores_plain_statements_and_assistant_turns() {
1825        let tmp = tempdir().unwrap();
1826        let store = MemoryStore::open_default(tmp.path()).unwrap();
1827        let episodic = store.episodic();
1828        // Two statements about the same topic, but neither contradicts.
1829        episodic
1830            .append(&user_statement(1, "My favorite coffee is dark roast."))
1831            .unwrap();
1832        episodic
1833            .append(&user_statement(2, "I love coffee with breakfast."))
1834            .unwrap();
1835        // An assistant echo with a contradiction marker must not initiate.
1836        episodic
1837            .append(&assistant_response(
1838                3,
1839                "You mentioned you no longer like tea!",
1840            ))
1841            .unwrap();
1842
1843        let results = episodic.search("coffee tea breakfast", 10, false).unwrap();
1844        assert!(detect_conflicts(&results).is_empty());
1845    }
1846
1847    #[test]
1848    fn detect_conflicts_skips_identical_content() {
1849        let tmp = tempdir().unwrap();
1850        let store = MemoryStore::open_default(tmp.path()).unwrap();
1851        let episodic = store.episodic();
1852        let record = user_statement(1, "I'm vegetarian now, but I changed my mind.");
1853        let duplicate = user_statement(2, "I'm vegetarian now, but I changed my mind.");
1854        episodic.append(&record).unwrap();
1855        episodic.append(&duplicate).unwrap();
1856
1857        let results = episodic.search("vegetarian", 10, false).unwrap();
1858        // Same fact seen twice is a duplicate, not a conflict.
1859        assert!(detect_conflicts(&results).is_empty());
1860    }
1861
1862    #[test]
1863    fn append_get_transition_and_reopen_roundtrip() {
1864        let tmp = tempdir().unwrap();
1865        let session = uuid::Uuid::new_v4();
1866        let record = EpisodicRecord::new(
1867            Some(session),
1868            2,
1869            EpisodicKind::Decision,
1870            "use the raw episodic lane",
1871            Provenance::new(ProvenanceSource::User).with_actor("test"),
1872        );
1873        let id = record.id;
1874        {
1875            let store = MemoryStore::open_default(tmp.path()).unwrap();
1876            let episodic = store.episodic();
1877            episodic.append(&record).unwrap();
1878            assert_eq!(episodic.get(id).unwrap().unwrap(), record);
1879            episodic
1880                .transition(
1881                    id,
1882                    MemoryTransition::Supersede {
1883                        replacement: uuid::Uuid::new_v4(),
1884                    },
1885                )
1886                .unwrap();
1887            assert!(matches!(
1888                episodic.get(id).unwrap().unwrap().validity,
1889                ValidityState::Superseded { .. }
1890            ));
1891        }
1892        let reopened = MemoryStore::open_default(tmp.path()).unwrap();
1893        let records = reopened.episodic().scan(Some(session), 10).unwrap();
1894        assert_eq!(records.len(), 1);
1895        assert_eq!(records[0].id, id);
1896    }
1897
1898    #[test]
1899    fn duplicate_append_is_rejected() {
1900        let tmp = tempdir().unwrap();
1901        let store = MemoryStore::open_default(tmp.path()).unwrap();
1902        let record = sample_record(1, "once");
1903        store.episodic().append(&record).unwrap();
1904        let error = store.episodic().append(&record).unwrap_err();
1905        assert!(error.to_string().contains("already exists"));
1906    }
1907
1908    #[test]
1909    fn raw_search_returns_canonical_records_and_skips_revoked_by_default() {
1910        let tmp = tempdir().unwrap();
1911        let store = MemoryStore::open_default(tmp.path()).unwrap();
1912        let active = sample_record(1, "Rust memory retrieval");
1913        let revoked = sample_record(2, "Rust memory retrieval old");
1914        let revoked_id = revoked.id;
1915        store.episodic().append(&active).unwrap();
1916        store.episodic().append(&revoked).unwrap();
1917        store
1918            .episodic()
1919            .transition(
1920                revoked_id,
1921                MemoryTransition::Revoke {
1922                    reason: "stale".into(),
1923                },
1924            )
1925            .unwrap();
1926
1927        let current = store
1928            .episodic()
1929            .search("memory retrieval", 10, false)
1930            .unwrap();
1931        assert_eq!(current.len(), 1);
1932        assert_eq!(current[0].record.id, active.id);
1933
1934        let all = store
1935            .episodic()
1936            .search("memory retrieval", 10, true)
1937            .unwrap();
1938        assert_eq!(all.len(), 2);
1939    }
1940
1941    /// H2 (2026-09-19 review): a whitespace-free 512-character title aborted
1942    /// the sidecar write with `MDB_BAD_VALSIZE` while the authoritative record
1943    /// committed — an unqualified-success partial write. Long terms are now
1944    /// canonicalized to a hashed key at both index and query time.
1945    #[test]
1946    fn overlong_terms_index_and_search_without_bad_valsize() {
1947        let tmp = tempdir().unwrap();
1948        let store = MemoryStore::open_default(tmp.path()).unwrap();
1949        let long_title = "x".repeat(512);
1950        let record = sample_record(1, &format!("session title {long_title}"));
1951        store.episodic().append(&record).unwrap();
1952
1953        // The authoritative lane committed and the sidecar accepted the term
1954        // (an append error would have propagated here before the fix).
1955        assert_eq!(
1956            store.episodic().get(record.id).unwrap(),
1957            Some(record.clone())
1958        );
1959
1960        // The same long token still retrieves the record.
1961        let hits = store.episodic().search(&long_title, 10, false).unwrap();
1962        assert_eq!(hits.len(), 1, "long-token query must still match");
1963        assert_eq!(hits[0].record.id, record.id);
1964
1965        // Boundary sanity: 480 bytes stays verbatim, 481 hashes.
1966        assert_eq!(index_safe_term(&"a".repeat(480)).len(), 480);
1967        let hashed = index_safe_term(&"a".repeat(481));
1968        assert!(hashed.starts_with("~h:"), "{hashed}");
1969        assert_eq!(hashed.len(), 67);
1970    }
1971
1972    /// H2 (2026-09-19 review): a failed sidecar rebuild leaves the raw lane
1973    /// intact with zero term postings. The health probe must expose that
1974    /// state (doctor grades DEGRADED) and the rebuild path must heal it.
1975    #[test]
1976    fn sidecar_health_exposes_incomplete_derived_index_and_rebuild_heals() {
1977        let tmp = tempdir().unwrap();
1978        let store = MemoryStore::open_default(tmp.path()).unwrap();
1979        store
1980            .episodic()
1981            .append(&sample_record(1, "durable raw fact"))
1982            .unwrap();
1983        assert_eq!(store.episodic_sidecar_health().unwrap(), (1, false));
1984
1985        // Simulate a failed / never-run sidecar rebuild: clear the derived
1986        // postings behind the store; the raw record stays authoritative.
1987        {
1988            let db = store.env().open_db(Some("episodic_terms_v2")).unwrap();
1989            let mut tx = store.env().begin_rw_txn().unwrap();
1990            tx.clear_db(db).unwrap();
1991            tx.commit().unwrap();
1992        }
1993        assert_eq!(
1994            store.episodic_sidecar_health().unwrap(),
1995            (1, true),
1996            "records without postings must be visible as degraded"
1997        );
1998
1999        // Repair path (what `wm reindex` runs) restores the derived view.
2000        let rebuilt = store.episodic().rebuild_sidecar().unwrap();
2001        assert_eq!(rebuilt, 1);
2002        assert_eq!(store.episodic_sidecar_health().unwrap(), (1, false));
2003        let hits = store.episodic().search("durable fact", 10, false).unwrap();
2004        assert_eq!(hits.len(), 1);
2005    }
2006
2007    #[test]
2008    fn append_batch_indexes_once_and_preserves_search() {
2009        let tmp = tempdir().unwrap();
2010        let store = MemoryStore::open_default(tmp.path()).unwrap();
2011        let first = sample_record(1, "Dr. Patel scheduled a follow-up appointment");
2012        let second = sample_record(2, "unrelated grocery list");
2013        let first_id = first.id;
2014        store.episodic().append_batch(&[first, second]).unwrap();
2015        let hits = store
2016            .episodic()
2017            .search("patel appointment", 10, false)
2018            .unwrap();
2019        assert_eq!(hits.len(), 1);
2020        assert_eq!(hits[0].record.id, first_id);
2021    }
2022
2023    #[test]
2024    fn rejected_append_batch_preserves_prior_state_after_reopen() {
2025        // Exercise the real NO_OVERWRITE transaction with a disposable LMDB
2026        // directory.  The new record is queued before the duplicate so this
2027        // proves the transaction aborts rather than leaving an early write.
2028        let tmp = tempdir().unwrap();
2029        let original = sample_record(1, "acknowledged original record");
2030        let rejected_new = sample_record(2, "must not survive rejected batch");
2031        {
2032            let store = MemoryStore::open_default(tmp.path()).unwrap();
2033            store.episodic().append(&original).unwrap();
2034            let error = store
2035                .episodic()
2036                .append_batch(&[rejected_new.clone(), original.clone()])
2037                .unwrap_err();
2038            assert!(error.to_string().contains("already exists"));
2039            assert_eq!(
2040                store.episodic().get(original.id).unwrap(),
2041                Some(original.clone())
2042            );
2043            assert_eq!(store.episodic().get(rejected_new.id).unwrap(), None);
2044        }
2045
2046        let reopened = MemoryStore::open_default(tmp.path()).unwrap();
2047        assert_eq!(
2048            reopened.episodic().get(original.id).unwrap(),
2049            Some(original)
2050        );
2051        assert_eq!(reopened.episodic().get(rejected_new.id).unwrap(), None);
2052    }
2053
2054    #[test]
2055    fn lost_episodic_sidecar_rebuilds_from_raw_records_after_reopen() {
2056        // This models loss of a *derived* sidecar only.  It deliberately does
2057        // not model an LMDB/process crash or make a filesystem-durability
2058        // claim; the authoritative raw records remain in the same temporary
2059        // store and the next process rebuilds the projection from them.
2060        let tmp = tempdir().unwrap();
2061        let record = sample_record(1, "sidecar recovery preserves searchable evidence");
2062        {
2063            let store = MemoryStore::open_default(tmp.path()).unwrap();
2064            let episodic = store.episodic();
2065            episodic.append(&record).unwrap();
2066            assert!(!episodic.sidecar_is_empty().unwrap());
2067
2068            let mut tx = store.env().begin_rw_txn().unwrap();
2069            tx.clear_db(episodic.term_db).unwrap();
2070            tx.commit().unwrap();
2071            assert!(episodic.sidecar_is_empty().unwrap());
2072        }
2073
2074        let reopened = MemoryStore::open_default(tmp.path()).unwrap();
2075        let episodic = reopened.episodic();
2076        assert_eq!(episodic.get(record.id).unwrap(), Some(record.clone()));
2077        assert!(!episodic.sidecar_is_empty().unwrap());
2078        let hits = episodic
2079            .search("sidecar searchable evidence", 10, false)
2080            .unwrap();
2081        assert_eq!(hits.len(), 1);
2082        assert_eq!(hits[0].record.id, record.id);
2083    }
2084
2085    #[test]
2086    fn append_explicit_batch_redacts_and_skips_private() {
2087        let tmp = tempdir().unwrap();
2088        let store = MemoryStore::open_default(tmp.path()).unwrap();
2089        let public = sample_record(1, "api_key=supersecret rust retrieval");
2090        let private = sample_record(2, "private rust retrieval").with_visibility(true, false);
2091        let public_id = public.id;
2092        store
2093            .episodic()
2094            .append_explicit_batch(&[public, private], EpisodicCapturePolicy::explicit_only())
2095            .unwrap();
2096        let stored = store.episodic().get(public_id).unwrap().unwrap();
2097        assert!(stored.content.contains("<REDACTED>"));
2098        let hits = store
2099            .episodic()
2100            .search("rust retrieval", 10, false)
2101            .unwrap();
2102        assert_eq!(hits.len(), 1);
2103        assert_eq!(hits[0].record.id, public_id);
2104    }
2105
2106    #[test]
2107    fn typed_keys_retrieve_vocabulary_mismatch() {
2108        let tmp = tempdir().unwrap();
2109        let store = MemoryStore::open_default(tmp.path()).unwrap();
2110        let dog = sample_record(1, "My Golden Retriever loves the park");
2111        let other = sample_record(2, "I bought a yellow dress");
2112        let dog_id = dog.id;
2113        store.episodic().append_batch(&[dog, other]).unwrap();
2114        let hits = store
2115            .episodic()
2116            .search("What breed is my dog?", 5, false)
2117            .unwrap();
2118        assert_eq!(hits[0].record.id, dog_id);
2119    }
2120
2121    #[test]
2122    fn planner_boosts_temporal_date_match() {
2123        let tmp = tempdir().unwrap();
2124        let store = MemoryStore::open_default(tmp.path()).unwrap();
2125        let dated = sample_record(1, "I volunteered on February 14th at the animal shelter");
2126        let other = sample_record(2, "I volunteered at the community garden last summer");
2127        let dated_id = dated.id;
2128        store.episodic().append_batch(&[dated, other]).unwrap();
2129        let hits = store
2130            .episodic()
2131            .search("When did I volunteer at the animal shelter?", 5, false)
2132            .unwrap();
2133        assert_eq!(hits[0].record.id, dated_id);
2134    }
2135
2136    #[test]
2137    #[ignore = "manual in-process latency profile"]
2138    fn profile_ingest_and_search_latency() {
2139        fn timed_ms(label: &str, repeats: u32, mut work: impl FnMut()) {
2140            let start = std::time::Instant::now();
2141            for _ in 0..repeats {
2142                work();
2143            }
2144            let elapsed = start.elapsed();
2145            println!(
2146                "{label}: {:.3} ms (n={repeats})",
2147                elapsed.as_secs_f64() * 1000.0 / f64::from(repeats)
2148            );
2149        }
2150
2151        let search_records: Vec<EpisodicRecord> = (0..10_000)
2152            .map(|n| {
2153                sample_record(
2154                    n,
2155                    if n % 5 == 0 {
2156                        "Rust memory retrieval benchmark item"
2157                    } else {
2158                        "Unrelated episodic record"
2159                    },
2160                )
2161            })
2162            .collect();
2163
2164        timed_ms("append_single_1000", 1, || {
2165            let tmp = tempdir().unwrap();
2166            let store = MemoryStore::open_default(tmp.path()).unwrap();
2167            for n in 0..1_000 {
2168                store.episodic().append(&sample_record(n, "once")).unwrap();
2169            }
2170        });
2171        timed_ms("append_batch_1000", 1, || {
2172            let tmp = tempdir().unwrap();
2173            let store = MemoryStore::open_default(tmp.path()).unwrap();
2174            let records: Vec<EpisodicRecord> =
2175                (0..1_000).map(|n| sample_record(n, "once")).collect();
2176            store.episodic().append_batch(&records).unwrap();
2177        });
2178
2179        let tmp = tempdir().unwrap();
2180        {
2181            let store = MemoryStore::open_default(tmp.path()).unwrap();
2182            store.episodic().append_batch(&search_records).unwrap();
2183        }
2184        let cold = MemoryStore::open_default(tmp.path()).unwrap();
2185        timed_ms("cold_search_10000", 1, || {
2186            let hits = cold
2187                .episodic()
2188                .search("rust memory retrieval", 10, false)
2189                .unwrap();
2190            assert!(!hits.is_empty());
2191        });
2192        timed_ms("warm_search_10000", 50, || {
2193            let hits = cold
2194                .episodic()
2195                .search("rust memory retrieval", 10, false)
2196                .unwrap();
2197            assert!(!hits.is_empty());
2198        });
2199    }
2200
2201    /// Realistic-scale profile: 25k records of sentence-length content with
2202    /// per-session entity diversity (mimics a persistent server accumulating
2203    /// 50 LongMemEval haystacks). Measures whether episodic search stays
2204    /// bounded as the store grows, warm and cold, and whether queries that
2205    /// miss the sidecar entirely (full-scan fallback) degrade differently.
2206    #[test]
2207    #[ignore = "manual in-process latency profile at realistic scale"]
2208    fn profile_search_latency_25k_realistic() {
2209        const TOTAL: u64 = 25_000;
2210        const SESSIONS: u64 = 50;
2211        let topics = [
2212            "bookshelf",
2213            "guitar",
2214            "vegetarian",
2215            "portfolio",
2216            "commute",
2217            "grandmother",
2218            "chemistry",
2219            "marathon",
2220            "internship",
2221            "yoga",
2222            "spam filter",
2223            "projector",
2224            "swimming",
2225            "cousin",
2226            "bank account",
2227            "book club",
2228            "recipe",
2229            "journal subscription",
2230            "laptop",
2231            "hiking",
2232        ];
2233        let fillers = [
2234            "We discussed the plan for the weekend and agreed on the schedule.",
2235            "The meeting notes were circulated and everyone acknowledged them.",
2236            "I explained my reasoning and the group considered the proposal.",
2237            "After the presentation we reviewed the feedback together.",
2238            "She mentioned the deadline and we adjusted the timeline accordingly.",
2239        ];
2240
2241        let records: Vec<EpisodicRecord> = (0..TOTAL)
2242            .map(|n| {
2243                let session = n * SESSIONS / TOTAL;
2244                let topic = topics[(n as usize) % topics.len()];
2245                let filler = fillers[(n as usize) % fillers.len()];
2246                let content = format!(
2247                    "Session {session} note {n}: my friend Alice mentioned {topic} while {filler}"
2248                );
2249                let session_id = if n % 4 == 0 {
2250                    None
2251                } else {
2252                    Some(uuid::Uuid::new_v4())
2253                };
2254                EpisodicRecord::new(
2255                    session_id,
2256                    n,
2257                    if n % 3 == 0 {
2258                        EpisodicKind::UserStatement
2259                    } else {
2260                        EpisodicKind::AssistantResponse
2261                    },
2262                    content,
2263                    Provenance::new(ProvenanceSource::User),
2264                )
2265            })
2266            .collect();
2267
2268        let tmp = tempdir().unwrap();
2269        {
2270            let store = MemoryStore::open_default(tmp.path()).unwrap();
2271            let t = std::time::Instant::now();
2272            store.episodic().append_batch(&records).unwrap();
2273            println!("ingest 25k: {:.1} ms", t.elapsed().as_secs_f64() * 1000.0);
2274        }
2275
2276        // Cold process semantics: reopen, then a query.
2277        let cold = MemoryStore::open_default(tmp.path()).unwrap();
2278        let episodic = cold.episodic();
2279        let t = std::time::Instant::now();
2280        let hits = episodic
2281            .search("guitar grandmother recipe", 10, false)
2282            .unwrap();
2283        println!(
2284            "cold_search: {:.1} ms (hits={})",
2285            t.elapsed().as_secs_f64() * 1000.0,
2286            hits.len()
2287        );
2288
2289        // Warm varied queries: topics rotate so postings cache does not hide
2290        // per-term loads, and one no-match query to time the full-scan fallback.
2291        let queries: Vec<String> = (0..20)
2292            .map(|i| {
2293                let a = topics[i * 7 % topics.len()];
2294                let b = topics[(i * 7 + 5) % topics.len()];
2295                format!("{a} {b} weekend plan")
2296            })
2297            .collect();
2298        let start = std::time::Instant::now();
2299        for q in &queries {
2300            let hits = episodic.search(q, 10, false).unwrap();
2301            assert!(!hits.is_empty(), "no hits for {q}");
2302        }
2303        println!(
2304            "warm_search varied p50: {:.2} ms/query",
2305            start.elapsed().as_secs_f64() * 1000.0 / queries.len() as f64
2306        );
2307
2308        let t = std::time::Instant::now();
2309        let hits = episodic
2310            .search("zzzterm zzzother zzzthird", 10, false)
2311            .unwrap();
2312        println!(
2313            "no_match_query (full-scan fallback path): {:.1} ms (hits={})",
2314            t.elapsed().as_secs_f64() * 1000.0,
2315            hits.len()
2316        );
2317    }
2318
2319    #[test]
2320    fn enrichment_bridges_vocabulary_gap_for_theater() {
2321        let tmp = tempdir().unwrap();
2322        let store = MemoryStore::open_default(tmp.path()).unwrap();
2323        store.set_episodic_enrichment(crate::enrichment::VocabularyEnrichment::with_defaults());
2324        // Answer turn says "production" not "play" — enrichment bridges this
2325        let answer = sample_record(1, "The production I attended was The Glass Menagerie");
2326        let competing = sample_record(2, "I went to a play at the local community theater");
2327        let answer_id = answer.id;
2328        store.episodic().append_batch(&[answer, competing]).unwrap();
2329        let hits = store
2330            .episodic()
2331            .search(
2332                "What play did I attend at the local community theater?",
2333                5,
2334                false,
2335            )
2336            .unwrap();
2337        // With enrichment, "production" in the answer turn gets postings for
2338        // "play", "theater", "performance" — so it should now match more terms
2339        assert!(hits.iter().any(|h| h.record.id == answer_id));
2340    }
2341
2342    #[test]
2343    fn enrichment_bridges_vocabulary_gap_for_shelter() {
2344        let tmp = tempdir().unwrap();
2345        let store = MemoryStore::open_default(tmp.path()).unwrap();
2346        store.set_episodic_enrichment(crate::enrichment::VocabularyEnrichment::with_defaults());
2347        let answer = sample_record(1, "I rescued a dog from the humane society last week");
2348        let other = sample_record(2, "I bought groceries at the store");
2349        let answer_id = answer.id;
2350        store.episodic().append_batch(&[answer, other]).unwrap();
2351        let hits = store
2352            .episodic()
2353            .search("When did I volunteer at the animal shelter?", 5, false)
2354            .unwrap();
2355        assert!(hits.iter().any(|h| h.record.id == answer_id));
2356    }
2357
2358    #[test]
2359    fn session_boost_favors_sessions_with_multiple_matches() {
2360        let tmp = tempdir().unwrap();
2361        let store = MemoryStore::open_default(tmp.path()).unwrap();
2362        let session_a = uuid::Uuid::new_v4();
2363        let session_b = uuid::Uuid::new_v4();
2364        // Session A has two matching turns (same kind to isolate session boost)
2365        let a1 = EpisodicRecord::new(
2366            Some(session_a),
2367            1,
2368            EpisodicKind::Observation,
2369            "I love hiking in the mountains",
2370            Provenance::new(ProvenanceSource::User),
2371        );
2372        let a2 = EpisodicRecord::new(
2373            Some(session_a),
2374            2,
2375            EpisodicKind::Observation,
2376            "Hiking in the mountains is great exercise",
2377            Provenance::new(ProvenanceSource::User),
2378        );
2379        // Session B has one matching turn with same kind
2380        let b1 = EpisodicRecord::new(
2381            Some(session_b),
2382            1,
2383            EpisodicKind::Observation,
2384            "Hiking is fun",
2385            Provenance::new(ProvenanceSource::User),
2386        );
2387        store.episodic().append_batch(&[a1, a2, b1]).unwrap();
2388        let hits = store
2389            .episodic()
2390            .search("hiking mountains", 10, false)
2391            .unwrap();
2392        // Session A turns should be boosted over session B turn because
2393        // session A has 2 matching turns vs 1 for session B
2394        let a_ranks: Vec<usize> = hits
2395            .iter()
2396            .enumerate()
2397            .filter(|(_, h)| h.record.session_id == Some(session_a))
2398            .map(|(i, _)| i)
2399            .collect();
2400        let b_rank = hits
2401            .iter()
2402            .position(|h| h.record.session_id == Some(session_b));
2403        if let Some(br) = b_rank {
2404            assert!(a_ranks.iter().all(|&ar| ar < br));
2405        }
2406    }
2407}