1use 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#[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#[derive(Debug, Clone)]
69pub struct EpisodicSearchResult {
70 pub record: EpisodicRecord,
71 pub score: f32,
72 pub matched_terms: usize,
73}
74
75pub 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 #[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 #[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 #[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 pub fn append(&self, record: &EpisodicRecord) -> Result<()> {
134 self.append_batch(std::slice::from_ref(record))
135 }
136
137 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 self.index_records(records)?;
188 self.clear_term_cache();
189 Ok(())
190 }
191
192 fn term_postings(&self, term: &str) -> Result<Vec<EpisodicId>> {
199 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if !matches!(self.sidecar_is_empty(), Ok(true)) {
570 return Ok(Vec::new());
571 }
572 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 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 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 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 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 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 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 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 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 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 .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 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 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 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 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 *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
1001const MAX_TERM_KEY_BYTES: usize = 480;
1011
1012fn 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
1037const CURRENT_QUERY_WORD_CUES: &[&str] = &["current", "currently", "latest", "nowadays"];
1039
1040const CURRENT_QUERY_PHRASE_CUES: &[&str] = &["these days", "right now", "at the moment"];
1042
1043#[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
1061const 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
1086fn contains_change_marker(content: &str) -> bool {
1088 let lowered = content.to_ascii_lowercase();
1089 CHANGE_MARKERS.iter().any(|marker| lowered.contains(marker))
1090}
1091
1092const 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1114pub struct EpisodicConflict {
1115 pub later_record: EpisodicId,
1118 pub earlier_record: EpisodicId,
1120 pub marker: String,
1122 pub shared_terms: Vec<String>,
1124 pub later_content: String,
1126 pub earlier_content: String,
1128}
1129
1130#[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 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 #[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 #[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 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 {
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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 assert_eq!(
1956 store.episodic().get(record.id).unwrap(),
1957 Some(record.clone())
1958 );
1959
1960 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 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 #[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 {
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 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 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 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 #[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 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 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 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 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 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 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 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}