Skip to main content

heartbit_core/knowledge/
in_memory.rs

1//! In-memory knowledge base backed by BM25 scoring.
2
3use std::collections::HashMap;
4use std::future::Future;
5use std::pin::Pin;
6
7use tokio::sync::RwLock;
8
9use crate::auth::TenantScope;
10use crate::error::Error;
11
12use super::{Chunk, KnowledgeBase, KnowledgeQuery, SearchResult};
13
14/// In-memory knowledge base backed by a `tokio::sync::RwLock<HashMap>`.
15///
16/// Search is keyword-based: tokenizes query into lowercase words, counts
17/// matches per chunk, and sorts by match count descending.
18///
19/// Always used behind `Arc<dyn KnowledgeBase>`, so no inner `Arc` needed.
20pub struct InMemoryKnowledgeBase {
21    // AP1: keyed on `(tenant_id, chunk_id)` — NOT `chunk_id` alone. `chunk_id`
22    // derives from the source URI *string* with no tenant component, so two
23    // tenants indexing the same URI string (e.g. both ingest `"README.md"`)
24    // produce identical ids; a bare-id key would let the second insert clobber
25    // the first, silently dropping a tenant's document.
26    chunks: RwLock<HashMap<(String, String), Chunk>>,
27}
28
29impl InMemoryKnowledgeBase {
30    /// Create an empty in-memory knowledge base.
31    pub fn new() -> Self {
32        Self {
33            chunks: RwLock::new(HashMap::new()),
34        }
35    }
36}
37
38impl Default for InMemoryKnowledgeBase {
39    fn default() -> Self {
40        Self::new()
41    }
42}
43
44/// Tokenize text into deduplicated lowercase words for keyword matching.
45fn tokenize(text: &str) -> Vec<String> {
46    let mut seen = std::collections::HashSet::new();
47    text.split_whitespace()
48        .map(|w| {
49            w.to_lowercase()
50                .trim_matches(|c: char| !c.is_alphanumeric())
51                .to_string()
52        })
53        .filter(|w| !w.is_empty() && seen.insert(w.clone()))
54        .collect()
55}
56
57/// Count how many query tokens appear in the chunk content.
58fn count_matches(query_tokens: &[String], content: &str) -> usize {
59    let lower = content.to_lowercase();
60    query_tokens
61        .iter()
62        .filter(|t| lower.contains(t.as_str()))
63        .count()
64}
65
66impl KnowledgeBase for InMemoryKnowledgeBase {
67    fn index(
68        &self,
69        scope: &TenantScope,
70        mut chunk: Chunk,
71    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
72        // SECURITY (F-KB-1): stamp the chunk with the caller's tenant_id at
73        // index time. The argument is &str → owned String for the async block.
74        let tid = scope.tenant_id.clone();
75        Box::pin(async move {
76            chunk.tenant_id = if tid.is_empty() {
77                None
78            } else {
79                Some(tid.clone())
80            };
81            let key = (tid, chunk.id.clone());
82            let mut data = self.chunks.write().await;
83            data.insert(key, chunk);
84            Ok(())
85        })
86    }
87
88    fn search(
89        &self,
90        scope: &TenantScope,
91        query: KnowledgeQuery,
92    ) -> Pin<Box<dyn Future<Output = Result<Vec<SearchResult>, Error>> + Send + '_>> {
93        let tid = scope.tenant_id.clone();
94        Box::pin(async move {
95            let data = self.chunks.read().await;
96            let tokens = tokenize(&query.text);
97
98            if tokens.is_empty() {
99                return Ok(vec![]);
100            }
101
102            // Tenant filter: keep chunks whose tenant_id matches scope.
103            // Single-tenant scope (`""`) matches `None` and `""`.
104            let tenant_match = move |chunk: &Chunk| -> bool {
105                let chunk_tid = chunk.tenant_id.as_deref().unwrap_or("");
106                chunk_tid == tid.as_str()
107            };
108
109            let mut results: Vec<SearchResult> = data
110                .values()
111                .filter(|chunk| tenant_match(chunk))
112                .filter(|chunk| {
113                    if let Some(ref filter) = query.source_filter {
114                        chunk.source.uri.starts_with(filter)
115                    } else {
116                        true
117                    }
118                })
119                .filter_map(|chunk| {
120                    let matches = count_matches(&tokens, &chunk.content);
121                    if matches > 0 {
122                        Some(SearchResult {
123                            chunk: chunk.clone(),
124                            match_count: matches,
125                        })
126                    } else {
127                        None
128                    }
129                })
130                .collect();
131
132            // Sort by match count descending, then chunk_index, then source URI for full stability
133            results.sort_by(|a, b| {
134                b.match_count
135                    .cmp(&a.match_count)
136                    .then_with(|| a.chunk.chunk_index.cmp(&b.chunk.chunk_index))
137                    .then_with(|| a.chunk.source.uri.cmp(&b.chunk.source.uri))
138            });
139
140            if query.limit > 0 {
141                results.truncate(query.limit);
142            }
143
144            Ok(results)
145        })
146    }
147
148    fn chunk_count(
149        &self,
150        scope: &TenantScope,
151    ) -> Pin<Box<dyn Future<Output = Result<usize, Error>> + Send + '_>> {
152        let tid = scope.tenant_id.clone();
153        Box::pin(async move {
154            let data = self.chunks.read().await;
155            let count = data
156                .values()
157                .filter(|c| c.tenant_id.as_deref().unwrap_or("") == tid.as_str())
158                .count();
159            Ok(count)
160        })
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::knowledge::DocumentSource;
168    use std::sync::Arc;
169
170    fn make_chunk(id: &str, content: &str, uri: &str, index: usize) -> Chunk {
171        Chunk {
172            id: id.into(),
173            content: content.into(),
174            source: DocumentSource {
175                uri: uri.into(),
176                title: uri.into(),
177            },
178            chunk_index: index,
179            tenant_id: None,
180        }
181    }
182
183    fn s() -> TenantScope {
184        TenantScope::default()
185    }
186
187    #[tokio::test]
188    async fn index_and_search_roundtrip() {
189        let kb = InMemoryKnowledgeBase::new();
190        kb.index(
191            &s(),
192            make_chunk(
193                "c1",
194                "Rust is a systems programming language",
195                "docs/rust.md",
196                0,
197            ),
198        )
199        .await
200        .unwrap();
201
202        let results = kb
203            .search(
204                &s(),
205                KnowledgeQuery {
206                    text: "rust programming".into(),
207                    source_filter: None,
208                    limit: 5,
209                },
210            )
211            .await
212            .unwrap();
213
214        assert_eq!(results.len(), 1);
215        assert_eq!(results[0].chunk.id, "c1");
216        assert_eq!(results[0].match_count, 2); // "rust" + "programming"
217    }
218
219    #[tokio::test]
220    async fn search_is_case_insensitive() {
221        let kb = InMemoryKnowledgeBase::new();
222        kb.index(&s(), make_chunk("c1", "RUST is GREAT", "f.md", 0))
223            .await
224            .unwrap();
225
226        let results = kb
227            .search(
228                &s(),
229                KnowledgeQuery {
230                    text: "rust great".into(),
231                    source_filter: None,
232                    limit: 5,
233                },
234            )
235            .await
236            .unwrap();
237
238        assert_eq!(results.len(), 1);
239        assert_eq!(results[0].match_count, 2);
240    }
241
242    #[tokio::test]
243    async fn source_filter_restricts_results() {
244        let kb = InMemoryKnowledgeBase::new();
245        kb.index(&s(), make_chunk("c1", "Rust language", "docs/rust.md", 0))
246            .await
247            .unwrap();
248        kb.index(&s(), make_chunk("c2", "Rust compiler", "api/rust.md", 0))
249            .await
250            .unwrap();
251
252        let results = kb
253            .search(
254                &s(),
255                KnowledgeQuery {
256                    text: "rust".into(),
257                    source_filter: Some("docs/".into()),
258                    limit: 10,
259                },
260            )
261            .await
262            .unwrap();
263
264        assert_eq!(results.len(), 1);
265        assert_eq!(results[0].chunk.source.uri, "docs/rust.md");
266    }
267
268    #[tokio::test]
269    async fn limit_truncates_results() {
270        let kb = InMemoryKnowledgeBase::new();
271        for i in 0..10 {
272            kb.index(
273                &s(),
274                make_chunk(
275                    &format!("c{i}"),
276                    "rust programming language",
277                    "docs/rust.md",
278                    i,
279                ),
280            )
281            .await
282            .unwrap();
283        }
284
285        let results = kb
286            .search(
287                &s(),
288                KnowledgeQuery {
289                    text: "rust".into(),
290                    source_filter: None,
291                    limit: 3,
292                },
293            )
294            .await
295            .unwrap();
296
297        assert_eq!(results.len(), 3);
298    }
299
300    #[tokio::test]
301    async fn sorted_by_match_count_descending() {
302        let kb = InMemoryKnowledgeBase::new();
303        kb.index(&s(), make_chunk("c1", "rust", "f.md", 0))
304            .await
305            .unwrap();
306        kb.index(
307            &s(),
308            make_chunk("c2", "rust programming rust systems", "f.md", 1),
309        )
310        .await
311        .unwrap();
312        kb.index(&s(), make_chunk("c3", "rust programming", "f.md", 2))
313            .await
314            .unwrap();
315
316        let results = kb
317            .search(
318                &s(),
319                KnowledgeQuery {
320                    text: "rust programming systems".into(),
321                    source_filter: None,
322                    limit: 10,
323                },
324            )
325            .await
326            .unwrap();
327
328        assert_eq!(results.len(), 3);
329        assert_eq!(results[0].chunk.id, "c2"); // 3 matches
330        assert_eq!(results[1].chunk.id, "c3"); // 2 matches
331        assert_eq!(results[2].chunk.id, "c1"); // 1 match
332    }
333
334    #[tokio::test]
335    async fn reindex_replaces_chunk() {
336        let kb = InMemoryKnowledgeBase::new();
337        kb.index(&s(), make_chunk("c1", "old content", "f.md", 0))
338            .await
339            .unwrap();
340        kb.index(&s(), make_chunk("c1", "new content about rust", "f.md", 0))
341            .await
342            .unwrap();
343
344        assert_eq!(kb.chunk_count(&s()).await.unwrap(), 1);
345
346        let results = kb
347            .search(
348                &s(),
349                KnowledgeQuery {
350                    text: "rust".into(),
351                    source_filter: None,
352                    limit: 5,
353                },
354            )
355            .await
356            .unwrap();
357
358        assert_eq!(results.len(), 1);
359        assert!(results[0].chunk.content.contains("new content"));
360    }
361
362    #[tokio::test]
363    async fn empty_query_returns_no_results() {
364        let kb = InMemoryKnowledgeBase::new();
365        kb.index(&s(), make_chunk("c1", "some content", "f.md", 0))
366            .await
367            .unwrap();
368
369        let results = kb
370            .search(
371                &s(),
372                KnowledgeQuery {
373                    text: "".into(),
374                    source_filter: None,
375                    limit: 5,
376                },
377            )
378            .await
379            .unwrap();
380
381        assert!(results.is_empty());
382    }
383
384    #[tokio::test]
385    async fn no_match_returns_empty() {
386        let kb = InMemoryKnowledgeBase::new();
387        kb.index(&s(), make_chunk("c1", "hello world", "f.md", 0))
388            .await
389            .unwrap();
390
391        let results = kb
392            .search(
393                &s(),
394                KnowledgeQuery {
395                    text: "zzzznotfound".into(),
396                    source_filter: None,
397                    limit: 5,
398                },
399            )
400            .await
401            .unwrap();
402
403        assert!(results.is_empty());
404    }
405
406    #[tokio::test]
407    async fn chunk_count_tracks_size() {
408        let kb = InMemoryKnowledgeBase::new();
409        assert_eq!(kb.chunk_count(&s()).await.unwrap(), 0);
410
411        kb.index(&s(), make_chunk("c1", "a", "f.md", 0))
412            .await
413            .unwrap();
414        kb.index(&s(), make_chunk("c2", "b", "f.md", 1))
415            .await
416            .unwrap();
417        assert_eq!(kb.chunk_count(&s()).await.unwrap(), 2);
418    }
419
420    #[test]
421    fn is_send_sync() {
422        fn assert_send_sync<T: Send + Sync>() {}
423        assert_send_sync::<InMemoryKnowledgeBase>();
424        fn _accepts_dyn(_kb: &dyn KnowledgeBase) {}
425    }
426
427    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
428    async fn concurrent_index_and_search() {
429        let kb = Arc::new(InMemoryKnowledgeBase::new());
430        let mut handles = Vec::new();
431
432        // Spawn writers
433        for i in 0..20 {
434            let kb = kb.clone();
435            handles.push(tokio::spawn(async move {
436                kb.index(
437                    &s(),
438                    make_chunk(
439                        &format!("c{i}"),
440                        &format!("rust content item {i}"),
441                        "f.md",
442                        i,
443                    ),
444                )
445                .await
446                .unwrap();
447            }));
448        }
449
450        // Spawn readers concurrently
451        for _ in 0..10 {
452            let kb = kb.clone();
453            handles.push(tokio::spawn(async move {
454                let _ = kb
455                    .search(
456                        &s(),
457                        KnowledgeQuery {
458                            text: "rust".into(),
459                            source_filter: None,
460                            limit: 5,
461                        },
462                    )
463                    .await
464                    .unwrap();
465            }));
466        }
467
468        for h in handles {
469            h.await.unwrap();
470        }
471
472        assert_eq!(kb.chunk_count(&s()).await.unwrap(), 20);
473    }
474
475    #[tokio::test]
476    async fn duplicate_query_terms_not_inflated() {
477        let kb = InMemoryKnowledgeBase::new();
478        kb.index(&s(), make_chunk("c1", "rust is great", "f.md", 0))
479            .await
480            .unwrap();
481
482        let results = kb
483            .search(
484                &s(),
485                KnowledgeQuery {
486                    text: "rust rust rust".into(),
487                    source_filter: None,
488                    limit: 5,
489                },
490            )
491            .await
492            .unwrap();
493
494        assert_eq!(results.len(), 1);
495        assert_eq!(results[0].match_count, 1); // deduplicated, not 3
496    }
497
498    /// AP1: two tenants indexing chunks that share the SAME id (because
499    /// `chunk_id` derives from the URI string alone, with no tenant component)
500    /// must NOT clobber each other — the store keys on `(tenant_id, chunk_id)`.
501    #[tokio::test]
502    async fn same_chunk_id_across_tenants_does_not_clobber() {
503        let kb = InMemoryKnowledgeBase::new();
504        let scope_a = TenantScope::new("tenant-a");
505        let scope_b = TenantScope::new("tenant-b");
506
507        // Identical chunk id "readme-0" for both tenants, distinct content.
508        kb.index(
509            &scope_a,
510            make_chunk("readme-0", "alice rust document", "README.md", 0),
511        )
512        .await
513        .unwrap();
514        kb.index(
515            &scope_b,
516            make_chunk("readme-0", "bob rust document", "README.md", 0),
517        )
518        .await
519        .unwrap();
520
521        // Both tenants retain their own document (no overwrite).
522        assert_eq!(kb.chunk_count(&scope_a).await.unwrap(), 1);
523        assert_eq!(kb.chunk_count(&scope_b).await.unwrap(), 1);
524
525        let results_a = kb
526            .search(
527                &scope_a,
528                KnowledgeQuery {
529                    text: "alice".into(),
530                    source_filter: None,
531                    limit: 10,
532                },
533            )
534            .await
535            .unwrap();
536        assert_eq!(results_a.len(), 1, "tenant A's chunk was clobbered");
537        assert!(results_a[0].chunk.content.contains("alice"));
538    }
539
540    /// SECURITY (F-KB-1): tenant A's chunks must not be visible to tenant B
541    /// when both share an `Arc<dyn KnowledgeBase>`. The trait now requires a
542    /// `&TenantScope` for both index and search; without filtering, a daemon
543    /// shared across tenants would leak documents.
544    #[tokio::test]
545    async fn search_isolates_by_tenant() {
546        let kb = InMemoryKnowledgeBase::new();
547        let scope_a = TenantScope::new("tenant-a");
548        let scope_b = TenantScope::new("tenant-b");
549
550        kb.index(
551            &scope_a,
552            make_chunk("a1", "alice secret rust note", "a/notes.md", 0),
553        )
554        .await
555        .unwrap();
556        kb.index(
557            &scope_b,
558            make_chunk("b1", "bob secret rust note", "b/notes.md", 0),
559        )
560        .await
561        .unwrap();
562
563        // Tenant A search must NOT return B's chunk.
564        let results_a = kb
565            .search(
566                &scope_a,
567                KnowledgeQuery {
568                    text: "rust".into(),
569                    source_filter: None,
570                    limit: 10,
571                },
572            )
573            .await
574            .unwrap();
575        assert_eq!(results_a.len(), 1);
576        assert_eq!(results_a[0].chunk.id, "a1");
577
578        // Tenant B sees only B's chunk.
579        let results_b = kb
580            .search(
581                &scope_b,
582                KnowledgeQuery {
583                    text: "rust".into(),
584                    source_filter: None,
585                    limit: 10,
586                },
587            )
588            .await
589            .unwrap();
590        assert_eq!(results_b.len(), 1);
591        assert_eq!(results_b[0].chunk.id, "b1");
592
593        // chunk_count is also tenant-scoped.
594        assert_eq!(kb.chunk_count(&scope_a).await.unwrap(), 1);
595        assert_eq!(kb.chunk_count(&scope_b).await.unwrap(), 1);
596    }
597
598    #[tokio::test]
599    async fn sort_stable_across_sources() {
600        let kb = InMemoryKnowledgeBase::new();
601        kb.index(&s(), make_chunk("c1", "rust programming", "z_file.md", 0))
602            .await
603            .unwrap();
604        kb.index(&s(), make_chunk("c2", "rust programming", "a_file.md", 0))
605            .await
606            .unwrap();
607
608        let results = kb
609            .search(
610                &s(),
611                KnowledgeQuery {
612                    text: "rust".into(),
613                    source_filter: None,
614                    limit: 10,
615                },
616            )
617            .await
618            .unwrap();
619
620        assert_eq!(results.len(), 2);
621        // Same match_count and chunk_index → sorted by source URI
622        assert_eq!(results[0].chunk.source.uri, "a_file.md");
623        assert_eq!(results[1].chunk.source.uri, "z_file.md");
624    }
625}