Skip to main content

smos_application/testkit/
facts.rs

1//! In-memory `FactRepository` double.
2//!
3//! Unifies the three former in-tree copies (`finalize_session::tests`,
4//! `extract_facts_from_response::tests`, `import_opencode_session::tests`).
5//! The list/search methods follow the [`FactRepository`] contract literally:
6//! `list_accepted` / `list_pending` filter the store by status, and
7//! `list_memory_keys_for_session` deduplicates memory keys in insertion order.
8//!
9//! This is a deliberate, safe widening over the former `extract`/`import`
10//! copies, which stubbed those methods to `Ok(Vec::new())`: neither use case
11//! calls them (verified — only `finalize` reads the accepted/pending pools),
12//! so the finalize-driving real implementation preserves every observable
13//! behavior while letting one type back all three suites.
14//!
15//! `search_similar` returns an empty `Vec` by default: the finalize / extract
16//! / import use cases never call it (they go through `search_for_dedup`).
17//! `EnrichRequest::execute` DOES call it, so its unit tests script the
18//! response via [`InMemoryFacts::script_search_hits`]; the default empty
19//! value keeps the fake honest for every other caller and mirrors the
20//! production accepted-only contract. Layer 2 dedup tests script the dedup
21//! response via [`InMemoryFacts::script_dedup_hits`].
22
23use std::collections::{HashMap, HashSet};
24use std::sync::{Arc, Mutex};
25
26use smos_domain::{Fact, FactId, FactStatus, Heat, MemoryKey, SessionId, Timestamp};
27
28use crate::errors::RepoError;
29use crate::ports::FactRepository;
30use crate::types::SearchHit;
31
32#[derive(Default, Clone)]
33pub struct InMemoryFacts {
34    store: Arc<Mutex<HashMap<String, Fact>>>,
35    /// Optional scripted `search_for_dedup` response (semantic-dedup tests
36    /// only). Empty by default so Layer 2 stays inert for exact-match and
37    /// new-fact tests.
38    dedup_hits: Arc<Mutex<Vec<SearchHit>>>,
39    /// Optional scripted `search_similar` response (`EnrichRequest` tests).
40    /// Empty by default so callers that do not script it see no vector hits.
41    search_hits: Arc<Mutex<Vec<SearchHit>>>,
42}
43
44impl InMemoryFacts {
45    /// Insert a fact bypassing `save` (no async, no `Result`) — used to seed
46    /// fixtures before the use case runs.
47    pub fn seed(&self, fact: Fact) {
48        self.store
49            .lock()
50            .unwrap()
51            .insert(fact.id().as_str().to_string(), fact);
52    }
53
54    /// Read-only snapshot of a stored fact by id.
55    pub fn get_clone(&self, id: &FactId) -> Option<Fact> {
56        self.store.lock().unwrap().get(id.as_str()).cloned()
57    }
58
59    /// Program the response returned by `search_for_dedup`.
60    pub fn script_dedup_hits(&self, hits: Vec<SearchHit>) {
61        *self.dedup_hits.lock().unwrap() = hits;
62    }
63
64    /// Program the response returned by `search_similar`. Used by
65    /// `EnrichRequest::execute` unit tests to push vector-search survivors
66    /// into the rerank stage.
67    pub fn script_search_hits(&self, hits: Vec<SearchHit>) {
68        *self.search_hits.lock().unwrap() = hits;
69    }
70
71    pub fn is_empty(&self) -> bool {
72        self.store.lock().unwrap().is_empty()
73    }
74
75    pub fn contains(&self, id: &FactId) -> bool {
76        self.store.lock().unwrap().contains_key(id.as_str())
77    }
78}
79
80impl FactRepository for InMemoryFacts {
81    async fn save(&self, fact: &Fact) -> Result<(), RepoError> {
82        self.store
83            .lock()
84            .unwrap()
85            .insert(fact.id().as_str().to_string(), fact.clone());
86        Ok(())
87    }
88
89    async fn get(&self, id: &FactId, _memory_key: &MemoryKey) -> Result<Option<Fact>, RepoError> {
90        Ok(self.get_clone(id))
91    }
92
93    async fn list_accepted(&self, _memory_key: &MemoryKey) -> Result<Vec<Fact>, RepoError> {
94        Ok(self
95            .store
96            .lock()
97            .unwrap()
98            .values()
99            .filter(|f| f.status() == FactStatus::Accepted)
100            .cloned()
101            .collect())
102    }
103
104    async fn list_pending(&self, _memory_key: &MemoryKey) -> Result<Vec<Fact>, RepoError> {
105        Ok(self
106            .store
107            .lock()
108            .unwrap()
109            .values()
110            .filter(|f| f.status() == FactStatus::Pending)
111            .cloned()
112            .collect())
113    }
114
115    async fn list_memory_keys_for_session(
116        &self,
117        session_id: &SessionId,
118    ) -> Result<Vec<MemoryKey>, RepoError> {
119        let mut out: Vec<MemoryKey> = Vec::new();
120        let mut seen: HashSet<String> = HashSet::new();
121        for fact in self.store.lock().unwrap().values() {
122            if !fact.source_sessions().iter().any(|s| s == session_id) {
123                continue;
124            }
125            let mk_str = fact.memory_key().as_str().to_string();
126            if seen.insert(mk_str) {
127                out.push(fact.memory_key().clone());
128            }
129        }
130        Ok(out)
131    }
132
133    async fn list_memory_keys(&self) -> Result<Vec<MemoryKey>, RepoError> {
134        let mut out: Vec<MemoryKey> = Vec::new();
135        let mut seen: HashSet<String> = HashSet::new();
136        for fact in self.store.lock().unwrap().values() {
137            let mk_str = fact.memory_key().as_str().to_string();
138            if seen.insert(mk_str) {
139                out.push(fact.memory_key().clone());
140            }
141        }
142        Ok(out)
143    }
144
145    async fn search_similar(
146        &self,
147        _embedding: Vec<f32>,
148        _memory_key: &MemoryKey,
149        _limit: usize,
150    ) -> Result<Vec<SearchHit>, RepoError> {
151        Ok(self.search_hits.lock().unwrap().clone())
152    }
153
154    async fn search_for_dedup(
155        &self,
156        _embedding: Vec<f32>,
157        _memory_key: &MemoryKey,
158        _limit: usize,
159    ) -> Result<Vec<SearchHit>, RepoError> {
160        Ok(self.dedup_hits.lock().unwrap().clone())
161    }
162
163    async fn update_heat_batch(
164        &self,
165        _ids: &[FactId],
166        _memory_key: &MemoryKey,
167        _heat_base: Heat,
168        _last_access: Timestamp,
169    ) -> Result<(), RepoError> {
170        Ok(())
171    }
172}