1use gonzalo_core::{KeyPrefix, Record, RecordKind, Result, Store};
19use gonzalo_domain::{MemoryTier, RecordCodec, Session, Ticket, TicketEvent, Topic};
20use gonzalo_vector::{Embedder, VectorIndex};
21
22#[derive(Debug, Clone)]
25pub struct Hit {
26 pub record: Record,
27 pub score: f32,
28}
29
30#[derive(Debug, Clone)]
34pub struct ChunkHit {
35 pub record: Record,
36 pub score: f32,
37 pub ordinal: usize,
38 pub text: String,
39}
40
41pub struct KnowledgeStore<S, V, E> {
44 store: S,
45 index: V,
46 embedder: E,
47 chunk_counts: std::sync::Mutex<std::collections::HashMap<gonzalo_core::RecordKey, usize>>,
54}
55
56impl<S: Store, V: VectorIndex, E: Embedder> KnowledgeStore<S, V, E> {
57 pub fn new(store: S, index: V, embedder: E) -> Self {
58 Self {
59 store,
60 index,
61 embedder,
62 chunk_counts: std::sync::Mutex::new(std::collections::HashMap::new()),
63 }
64 }
65
66 pub async fn open(store: S, index: V, embedder: E) -> Result<Self> {
75 let mut counts: std::collections::HashMap<gonzalo_core::RecordKey, usize> =
76 std::collections::HashMap::new();
77 for chunk in index.keys(&KeyPrefix::default()).await? {
78 let (parent, ordinal) = parent_key(&chunk);
79 let entry = counts.entry(parent).or_insert(0);
80 *entry = (*entry).max(ordinal + 1);
81 }
82 Ok(Self {
83 store,
84 index,
85 embedder,
86 chunk_counts: std::sync::Mutex::new(counts),
87 })
88 }
89
90 pub fn store(&self) -> &S {
92 &self.store
93 }
94
95 pub async fn ingest(&self, key: &gonzalo_core::RecordKey) -> Result<bool> {
118 let Some(record) = self.store.get(key).await? else {
119 return Ok(false);
120 };
121 let Some(chunks) = chunk(&record)? else {
122 return Ok(false);
123 };
124
125 let old = {
128 let counts = self.chunk_counts.lock().unwrap();
129 counts.get(key).copied().unwrap_or(0)
130 };
131 for ordinal in chunks.len()..old {
132 self.index.remove(&chunk_key(key, ordinal)).await?;
133 }
134
135 let mut items = Vec::with_capacity(chunks.len());
136 for (ordinal, text) in chunks.iter().enumerate() {
137 items.push((chunk_key(key, ordinal), self.embedder.embed(text).await?));
138 }
139 if !items.is_empty() {
140 self.index.upsert_many(items).await?;
141 }
142
143 self.chunk_counts
144 .lock()
145 .unwrap()
146 .insert(key.clone(), chunks.len());
147 Ok(true)
148 }
149
150 pub async fn remove(&self, key: &gonzalo_core::RecordKey) -> Result<()> {
159 let count = {
161 let counts = self.chunk_counts.lock().unwrap();
162 counts.get(key).copied().unwrap_or(0)
163 };
164 for ordinal in 0..count {
165 self.index.remove(&chunk_key(key, ordinal)).await?;
166 }
167 self.chunk_counts.lock().unwrap().remove(key);
168 Ok(())
169 }
170
171 pub async fn query(&self, text: &str, k: usize, filter: &KeyPrefix) -> Result<Vec<Hit>> {
178 if k == 0 {
179 return Ok(Vec::new());
180 }
181 let query_vec = self.embedder.embed(text).await?;
182 let fetch = k.saturating_mul(OVERFETCH);
183 let matches = self.index.query(&query_vec, fetch, filter).await?;
184
185 let mut best: std::collections::BTreeMap<gonzalo_core::RecordKey, f32> =
187 std::collections::BTreeMap::new();
188 for m in matches {
189 let (parent, _ordinal) = parent_key(&m.key);
190 best.entry(parent)
191 .and_modify(|s| *s = s.max(m.score))
192 .or_insert(m.score);
193 }
194
195 let mut ranked: Vec<(gonzalo_core::RecordKey, f32)> = best.into_iter().collect();
197 ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
198 ranked.truncate(k);
199
200 let mut hits = Vec::with_capacity(ranked.len());
201 for (parent, score) in ranked {
202 if let Some(record) = self.store.get(&parent).await? {
203 hits.push(Hit { record, score });
204 }
205 }
206 Ok(hits)
207 }
208
209 pub async fn query_chunks(
213 &self,
214 text: &str,
215 k: usize,
216 filter: &KeyPrefix,
217 ) -> Result<Vec<ChunkHit>> {
218 if k == 0 {
219 return Ok(Vec::new());
220 }
221 let query_vec = self.embedder.embed(text).await?;
222 let matches = self.index.query(&query_vec, k, filter).await?;
223 let mut hits = Vec::with_capacity(matches.len());
224 for m in matches {
225 let (parent, ordinal) = parent_key(&m.key);
226 if let Some(record) = self.store.get(&parent).await? {
227 let text = chunk(&record)?
228 .and_then(|cs| cs.into_iter().nth(ordinal))
229 .unwrap_or_default();
230 hits.push(ChunkHit {
231 record,
232 score: m.score,
233 ordinal,
234 text,
235 });
236 }
237 }
238 Ok(hits)
239 }
240
241 #[cfg(feature = "graph")]
251 pub async fn query_in_subgraph(
252 &self,
253 text: &str,
254 k: usize,
255 graph: &dyn gonzalo_graph::GraphStore,
256 root: &str,
257 ) -> Result<Vec<Hit>> {
258 use std::collections::BTreeSet;
259 let mut neighborhood: BTreeSet<String> = BTreeSet::from([root.to_string()]);
260 neighborhood.extend(graph.callers_of(root));
261 neighborhood.extend(graph.callees(root));
262
263 let hits = self.query(text, k, &KeyPrefix::default()).await?;
264 Ok(hits
265 .into_iter()
266 .filter(|h| neighborhood.contains(&h.record.key.id))
267 .collect())
268 }
269}
270
271pub fn knowledge_text(record: &Record) -> Option<String> {
275 chunk(record).ok().flatten().map(|cs| cs.join("\n"))
279}
280
281pub fn chunk(record: &Record) -> Result<Option<Vec<String>>> {
291 let chunks = match record.kind {
292 RecordKind::MemoryTier => {
293 let t = MemoryTier::from_body(&record.body)?;
294 prepend_header(&t.name, split_paragraphs(&t.content))
295 }
296 RecordKind::Topic => {
297 let t = Topic::from_body(&record.body)?;
298 prepend_header(&t.slug, t.bullets)
299 }
300 RecordKind::Session => {
301 let s = Session::from_body(&record.body)?;
302 prepend_header(&s.name, s.turns.into_iter().map(|turn| turn.text).collect())
303 }
304 RecordKind::Ticket => {
305 let t = Ticket::from_body(&record.body)?;
306 let mut cs = vec![format!("{}\n{}", t.title, t.labels.join(" "))];
307 cs.extend(split_paragraphs(&t.body.markdown));
308 cs
309 }
310 RecordKind::TicketEvent => {
311 let e = TicketEvent::from_body(&record.body)?;
312 vec![e.body]
313 }
314 RecordKind::Checkpoint
327 | RecordKind::GraphManifest
328 | RecordKind::VectorManifest
329 | RecordKind::Tombstone
330 | RecordKind::Person
331 | RecordKind::IdentityBinding
332 | RecordKind::RoleGrant
333 | RecordKind::ChannelConfig
334 | RecordKind::LinkToken
335 | RecordKind::AuditEntry => {
336 return Ok(None);
337 }
338 };
339 Ok(Some(chunks))
340}
341
342fn prepend_header(header: &str, mut pieces: Vec<String>) -> Vec<String> {
345 match pieces.first_mut() {
346 Some(first) => *first = format!("{header}\n{first}"),
347 None => pieces.push(header.to_string()),
348 }
349 pieces
350}
351
352const OVERFETCH: usize = 8;
356
357const CHUNK_SEP: char = '\u{1f}';
361
362fn chunk_key(parent: &gonzalo_core::RecordKey, ordinal: usize) -> gonzalo_core::RecordKey {
368 gonzalo_core::RecordKey::new(
369 &parent.namespace,
370 &parent.collection,
371 format!("{}{CHUNK_SEP}{ordinal}", parent.id),
372 )
373}
374
375fn parent_key(chunk: &gonzalo_core::RecordKey) -> (gonzalo_core::RecordKey, usize) {
378 match chunk.id.rsplit_once(CHUNK_SEP) {
379 Some((id, ord)) => {
380 let ordinal = ord.parse().unwrap_or(0);
381 (
382 gonzalo_core::RecordKey::new(&chunk.namespace, &chunk.collection, id),
383 ordinal,
384 )
385 }
386 None => (chunk.clone(), 0),
387 }
388}
389
390fn split_paragraphs(text: &str) -> Vec<String> {
392 text.split("\n\n")
393 .map(str::trim)
394 .filter(|p| !p.is_empty())
395 .map(str::to_string)
396 .collect()
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use async_trait::async_trait;
403 use gonzalo_core::{Body, Identity, Meta, PutResult, RecordKey, Revision};
404 use gonzalo_domain::{BodyFormat, Provider, State, StateCategory, TicketBody};
405 use gonzalo_store_fs::FsStore;
406 use gonzalo_vector::MemoryVectorIndex;
407 use std::collections::BTreeMap;
408
409 struct Bow;
412 #[async_trait]
413 impl Embedder for Bow {
414 async fn embed(&self, text: &str) -> Result<Vec<f32>> {
415 let mut v = vec![0f32; 32];
416 for word in text.split_whitespace() {
417 let h = word.bytes().map(|b| b as usize).sum::<usize>() % 32;
418 v[h] += 1.0;
419 }
420 Ok(v)
421 }
422 }
423
424 fn record(key: &RecordKey, kind: RecordKind, body: Body) -> Record {
425 Record {
426 revision: Revision::initial(body.bytes()),
427 parent: None,
428 body,
429 kind,
430 meta: Meta {
431 author: Identity::new("t"),
432 origin_system: "test".into(),
433 created: 0,
434 updated: 0,
435 labels: BTreeMap::new(),
436 },
437 links: Vec::new(),
438 key: key.clone(),
439 ancestors: Vec::new(),
440 deleted_at: None,
441 deleted_blob: None,
442 deleted_kind: None,
443 }
444 }
445
446 #[test]
447 fn chunk_splits_session_by_turn() {
448 use gonzalo_domain::Turn;
449 let session = Session {
450 name: "sess".into(),
451 turns: vec![
452 Turn {
453 role: "user".into(),
454 text: "first turn about rust".into(),
455 },
456 Turn {
457 role: "assistant".into(),
458 text: "second turn about cooking".into(),
459 },
460 Turn {
461 role: "user".into(),
462 text: "third turn about music".into(),
463 },
464 ],
465 };
466 let key = RecordKey::new("caliban", "sessions", "s1");
467 let chunks = chunk(&record(
468 &key,
469 RecordKind::Session,
470 session.to_body().unwrap(),
471 ))
472 .unwrap()
473 .unwrap();
474 assert_eq!(chunks.len(), 3);
475 assert!(chunks[0].contains("sess"));
477 assert!(chunks[0].contains("first turn about rust"));
478 assert!(chunks[1].contains("second turn about cooking"));
479 assert!(chunks[2].contains("third turn about music"));
480 }
481
482 #[test]
483 fn chunk_splits_topic_by_bullet_and_falls_back_to_one() {
484 let topic = Topic {
486 slug: "rust".into(),
487 bullets: vec!["use clippy".into(), "run rustfmt".into()],
488 };
489 let key = RecordKey::new("caliban", "topics", "rust");
490 let chunks = chunk(&record(&key, RecordKind::Topic, topic.to_body().unwrap()))
491 .unwrap()
492 .unwrap();
493 assert_eq!(chunks.len(), 2);
494 assert!(chunks[0].contains("rust"));
495 assert!(chunks[0].contains("use clippy"));
496 assert!(chunks[1].contains("run rustfmt"));
497
498 let one = Topic {
500 slug: "solo".into(),
501 bullets: vec!["only bullet".into()],
502 };
503 let chunks = chunk(&record(&key, RecordKind::Topic, one.to_body().unwrap()))
504 .unwrap()
505 .unwrap();
506 assert_eq!(chunks.len(), 1);
507
508 let ck = record(&key, RecordKind::Checkpoint, Body::Inline(b"{}".to_vec()));
510 assert_eq!(chunk(&ck).unwrap(), None);
511 }
512
513 #[test]
514 fn knowledge_text_per_kind() {
515 let topic = Topic {
516 slug: "rust".into(),
517 bullets: vec!["use clippy".into()],
518 };
519 let key = RecordKey::new("caliban", "topics", "rust");
520 let text = knowledge_text(&record(&key, RecordKind::Topic, topic.to_body().unwrap()));
521 assert_eq!(text.as_deref(), Some("rust\nuse clippy"));
522
523 let ck = record(&key, RecordKind::Checkpoint, Body::Inline(b"{}".to_vec()));
525 assert_eq!(knowledge_text(&ck), None);
526 }
527
528 #[test]
529 fn ticket_text_includes_title_body_labels() {
530 let t = Ticket {
531 provider: Provider::GitHub,
532 uid: "o/r#1".into(),
533 display: "#1".into(),
534 item_type: "issue".into(),
535 title: "fix the parser".into(),
536 state: State {
537 category: StateCategory::Open,
538 resolution: None,
539 raw_name: "open".into(),
540 raw_id: None,
541 },
542 priority: None,
543 actors: vec![],
544 labels: vec!["bug".into()],
545 containers: vec![],
546 links: vec![],
547 body: TicketBody {
548 markdown: "the parser panics".into(),
549 format: BodyFormat::Markdown,
550 raw: None,
551 },
552 fields: BTreeMap::new(),
553 };
554 let key = RecordKey::new("tickets", "github", "o/r#1");
555 let text = knowledge_text(&record(&key, RecordKind::Ticket, t.to_body().unwrap())).unwrap();
556 assert!(text.contains("fix the parser"));
557 assert!(text.contains("the parser panics"));
558 assert!(text.contains("bug"));
559 }
560
561 async fn put(store: &FsStore, rec: Record) {
562 assert!(matches!(
563 store.put(rec, None).await.unwrap(),
564 PutResult::Committed(_)
565 ));
566 }
567
568 #[derive(Default)]
574 struct CountingIndex {
575 inner: MemoryVectorIndex,
576 upsert: std::sync::atomic::AtomicUsize,
577 upsert_many: std::sync::atomic::AtomicUsize,
578 remove: std::sync::atomic::AtomicUsize,
579 }
580
581 impl CountingIndex {
582 fn counts(&self) -> (usize, usize, usize) {
584 use std::sync::atomic::Ordering::SeqCst;
585 (
586 self.upsert.load(SeqCst),
587 self.upsert_many.load(SeqCst),
588 self.remove.load(SeqCst),
589 )
590 }
591 }
592
593 #[async_trait]
594 impl gonzalo_vector::VectorIndex for CountingIndex {
595 async fn upsert(&self, key: RecordKey, vector: Vec<f32>) -> Result<()> {
596 self.upsert
597 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
598 self.inner.upsert(key, vector).await
599 }
600 async fn remove(&self, key: &RecordKey) -> Result<()> {
601 self.remove
602 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
603 self.inner.remove(key).await
604 }
605 async fn query(
606 &self,
607 query: &[f32],
608 k: usize,
609 filter: &KeyPrefix,
610 ) -> Result<Vec<gonzalo_vector::Match>> {
611 self.inner.query(query, k, filter).await
612 }
613 async fn keys(&self, filter: &KeyPrefix) -> Result<Vec<RecordKey>> {
614 self.inner.keys(filter).await
615 }
616 async fn upsert_many(&self, items: Vec<(RecordKey, Vec<f32>)>) -> Result<()> {
617 self.upsert_many
618 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
619 self.inner.upsert_many(items).await
620 }
621 }
622
623 fn topic_record(key: &RecordKey, bullets: &[&str]) -> Record {
624 record(
625 key,
626 RecordKind::Topic,
627 Topic {
628 slug: key.id.clone(),
629 bullets: bullets.iter().map(|b| (*b).to_string()).collect(),
630 }
631 .to_body()
632 .unwrap(),
633 )
634 }
635
636 #[tokio::test]
638 async fn ingest_batches_a_records_chunks_into_one_upsert() {
639 let dir = tempfile::tempdir().unwrap();
640 let store = FsStore::new(dir.path());
641 let key = RecordKey::new("caliban", "topics", "rust");
642 put(
643 &store,
644 topic_record(&key, &["alpha one", "beta two", "gamma three"]),
645 )
646 .await;
647
648 let index = std::sync::Arc::new(CountingIndex::default());
649 let ks = KnowledgeStore::new(store, std::sync::Arc::clone(&index), Bow);
650 assert!(ks.ingest(&key).await.unwrap());
651
652 assert_eq!(
653 index.counts(),
654 (0, 1, 0),
655 "one batch for the whole record, no per-chunk upsert"
656 );
657 assert_eq!(index.keys(&KeyPrefix::default()).await.unwrap().len(), 3);
658 }
659
660 #[tokio::test]
665 async fn re_ingest_after_a_shrink_batches_the_upserts_and_drops_the_orphans() {
666 let dir = tempfile::tempdir().unwrap();
667 let store = FsStore::new(dir.path());
668 let key = RecordKey::new("caliban", "topics", "rust");
669 put(
670 &store,
671 topic_record(&key, &["alpha one", "beta two", "gamma three"]),
672 )
673 .await;
674
675 let index = std::sync::Arc::new(CountingIndex::default());
676 let ks = KnowledgeStore::new(FsStore::new(dir.path()), std::sync::Arc::clone(&index), Bow);
677 assert!(ks.ingest(&key).await.unwrap());
678
679 let current = store.get(&key).await.unwrap().unwrap();
681 assert!(matches!(
682 store
683 .put(topic_record(&key, &["alpha one"]), Some(current.revision))
684 .await
685 .unwrap(),
686 PutResult::Committed(_)
687 ));
688 assert!(ks.ingest(&key).await.unwrap());
689
690 let (upsert, upsert_many, remove) = index.counts();
691 assert_eq!(upsert_many, 2, "one batch per ingest");
692 assert_eq!(upsert, 0, "no per-chunk upsert");
693 assert_eq!(remove, 2, "the two orphaned ordinals");
694 assert_eq!(
695 index.keys(&KeyPrefix::default()).await.unwrap().len(),
696 1,
697 "only the surviving chunk is indexed"
698 );
699 }
700
701 #[tokio::test]
704 async fn ingest_commits_a_durable_manifest_once_per_record() {
705 use gonzalo_vector::RecordVectorIndex;
706
707 let dir = tempfile::tempdir().unwrap();
708 let store = FsStore::new(dir.path());
709 let key = RecordKey::new("caliban", "topics", "rust");
710 put(
711 &store,
712 topic_record(&key, &["alpha one", "beta two", "gamma three"]),
713 )
714 .await;
715
716 let manifest_key = gonzalo_core::VectorManifest::key("caliban", "topics");
717 let index =
718 RecordVectorIndex::open(FsStore::new(dir.path()), manifest_key.clone(), "bow-32", 32)
719 .await
720 .unwrap();
721 let ks = KnowledgeStore::new(FsStore::new(dir.path()), index, Bow);
722
723 assert!(ks.ingest(&key).await.unwrap());
724
725 let manifest = store.get(&manifest_key).await.unwrap().expect("manifest");
726 assert_eq!(
727 manifest.revision.counter, 0,
728 "one commit for a three-chunk record: the first commit on a fresh \
729 manifest lands at counter 0 (`Revision::initial`), so a per-chunk \
730 loop would show 2 here"
731 );
732 }
733
734 #[tokio::test]
735 async fn ingest_then_query_returns_matching_record() {
736 let dir = tempfile::tempdir().unwrap();
737 let store = FsStore::new(dir.path());
738
739 let rust = RecordKey::new("caliban", "topics", "rust");
740 let cooking = RecordKey::new("caliban", "topics", "cooking");
741 put(
742 &store,
743 record(
744 &rust,
745 RecordKind::Topic,
746 Topic {
747 slug: "rust".into(),
748 bullets: vec!["use clippy and cargo".into()],
749 }
750 .to_body()
751 .unwrap(),
752 ),
753 )
754 .await;
755 put(
756 &store,
757 record(
758 &cooking,
759 RecordKind::Topic,
760 Topic {
761 slug: "cooking".into(),
762 bullets: vec!["simmer the sauce slowly".into()],
763 }
764 .to_body()
765 .unwrap(),
766 ),
767 )
768 .await;
769
770 let ks = KnowledgeStore::new(store, MemoryVectorIndex::default(), Bow);
771 assert!(ks.ingest(&rust).await.unwrap());
772 assert!(ks.ingest(&cooking).await.unwrap());
773
774 let hits = ks
775 .query("clippy cargo", 1, &KeyPrefix::default())
776 .await
777 .unwrap();
778 assert_eq!(hits.len(), 1);
779 assert_eq!(hits[0].record.key, rust);
780 }
781
782 fn turn(text: &str) -> gonzalo_domain::Turn {
783 gonzalo_domain::Turn {
784 role: "user".into(),
785 text: text.into(),
786 }
787 }
788
789 #[tokio::test]
790 async fn query_ranks_a_matching_turn_over_a_diluted_decoy_and_dedups() {
791 let dir = tempfile::tempdir().unwrap();
792 let store = FsStore::new(dir.path());
793
794 let sess = RecordKey::new("caliban", "sessions", "s1");
798 put(
799 &store,
800 record(
801 &sess,
802 RecordKind::Session,
803 Session {
804 name: "s".into(),
805 turns: vec![
806 turn("gamma"),
807 turn("gamma"),
808 turn("alpha alpha alpha alpha alpha"),
809 ],
810 }
811 .to_body()
812 .unwrap(),
813 ),
814 )
815 .await;
816
817 let decoy = RecordKey::new("caliban", "topics", "decoy");
821 put(
822 &store,
823 record(
824 &decoy,
825 RecordKind::Topic,
826 Topic {
827 slug: "gamma".into(),
828 bullets: vec!["alpha".into()],
829 }
830 .to_body()
831 .unwrap(),
832 ),
833 )
834 .await;
835
836 let ks = KnowledgeStore::new(store, MemoryVectorIndex::default(), Bow);
837 assert!(ks.ingest(&sess).await.unwrap());
838 assert!(ks.ingest(&decoy).await.unwrap());
839
840 let hits = ks.query("gamma", 5, &KeyPrefix::default()).await.unwrap();
841
842 assert_eq!(hits[0].record.key, sess, "matching turn should rank first");
845 assert_eq!(
847 hits.iter().filter(|h| h.record.key == sess).count(),
848 1,
849 "session should appear exactly once"
850 );
851 }
852
853 #[tokio::test]
854 async fn query_chunks_returns_individual_chunks_with_ordinals() {
855 let dir = tempfile::tempdir().unwrap();
856 let store = FsStore::new(dir.path());
857 let sess = RecordKey::new("caliban", "sessions", "s1");
858 put(
859 &store,
860 record(
861 &sess,
862 RecordKind::Session,
863 Session {
864 name: "s".into(),
865 turns: vec![turn("gamma"), turn("gamma"), turn("alpha")],
866 }
867 .to_body()
868 .unwrap(),
869 ),
870 )
871 .await;
872
873 let ks = KnowledgeStore::new(store, MemoryVectorIndex::default(), Bow);
874 assert!(ks.ingest(&sess).await.unwrap());
875
876 let hits = ks
879 .query_chunks("gamma", 2, &KeyPrefix::default())
880 .await
881 .unwrap();
882 assert_eq!(hits.len(), 2);
883 assert!(hits.iter().all(|h| h.record.key == sess));
884 let ordinals: std::collections::BTreeSet<usize> = hits.iter().map(|h| h.ordinal).collect();
885 assert_eq!(ordinals, std::collections::BTreeSet::from([0, 1]));
886 assert!(hits.iter().all(|h| h.text.contains("gamma")));
887 }
888
889 #[tokio::test]
890 async fn reingest_shrink_removes_orphan_chunks() {
891 let dir = tempfile::tempdir().unwrap();
892 let sess = RecordKey::new("caliban", "sessions", "s1");
893 let ks = KnowledgeStore::new(FsStore::new(dir.path()), MemoryVectorIndex::default(), Bow);
894
895 put(
897 ks.store(),
898 record(
899 &sess,
900 RecordKind::Session,
901 Session {
902 name: "s".into(),
903 turns: vec![turn("gamma"), turn("delta"), turn("epsilon")],
904 }
905 .to_body()
906 .unwrap(),
907 ),
908 )
909 .await;
910 assert!(ks.ingest(&sess).await.unwrap());
911
912 let before = ks
914 .query_chunks("epsilon", 10, &KeyPrefix::default())
915 .await
916 .unwrap();
917 assert!(before.iter().any(|h| h.text.contains("epsilon")));
918
919 let current = ks.store().get(&sess).await.unwrap().unwrap();
922 let body = Session {
923 name: "s".into(),
924 turns: vec![turn("gamma")],
925 }
926 .to_body()
927 .unwrap();
928 let mut v2 = record(&sess, RecordKind::Session, body.clone());
929 v2.parent = Some(current.revision.clone());
930 v2.revision = current.revision.next(body.bytes());
931 assert!(matches!(
932 ks.store().put(v2, Some(current.revision)).await.unwrap(),
933 PutResult::Committed(_)
934 ));
935 assert!(ks.ingest(&sess).await.unwrap());
936
937 let after = ks
940 .query_chunks("epsilon", 10, &KeyPrefix::default())
941 .await
942 .unwrap();
943 assert!(
944 !after.iter().any(|h| h.text.contains("epsilon")),
945 "orphaned chunk should have been removed on re-ingest"
946 );
947 assert!(
948 !after.iter().any(|h| h.text.contains("delta")),
949 "orphaned chunk should have been removed on re-ingest"
950 );
951 assert!(
952 after.iter().any(|h| h.text.contains("gamma")),
953 "the surviving turn should still be indexed"
954 );
955 }
956
957 #[tokio::test]
958 async fn ingest_skips_non_knowledge_kinds() {
959 let dir = tempfile::tempdir().unwrap();
960 let store = FsStore::new(dir.path());
961 let key = RecordKey::new("caliban", "checkpoints", "c1");
962 put(
963 &store,
964 record(&key, RecordKind::Checkpoint, Body::Inline(b"{}".to_vec())),
965 )
966 .await;
967 let ks = KnowledgeStore::new(store, MemoryVectorIndex::default(), Bow);
968 assert!(!ks.ingest(&key).await.unwrap(), "checkpoint is not indexed");
969 }
970
971 #[tokio::test]
972 async fn ingest_surfaces_body_parse_error_and_gates_opaque_kinds() {
973 let dir = tempfile::tempdir().unwrap();
974 let store = FsStore::new(dir.path());
975
976 let corrupt = RecordKey::new("caliban", "topics", "corrupt");
979 put(
980 &store,
981 record(
982 &corrupt,
983 RecordKind::Topic,
984 Body::Inline(b"not a valid topic".to_vec()),
985 ),
986 )
987 .await;
988
989 let ck = RecordKey::new("caliban", "checkpoints", "c1");
991 put(
992 &store,
993 record(
994 &ck,
995 RecordKind::Checkpoint,
996 Body::Inline(b"opaque".to_vec()),
997 ),
998 )
999 .await;
1000
1001 let ks = KnowledgeStore::new(store, MemoryVectorIndex::default(), Bow);
1002 assert!(
1003 ks.ingest(&corrupt).await.is_err(),
1004 "a corrupt knowledge-bearing body must surface an error, not Ok(false)"
1005 );
1006 assert!(
1007 !ks.ingest(&ck).await.unwrap(),
1008 "a genuinely-opaque kind is non-indexable: Ok(false)"
1009 );
1010 }
1011
1012 #[tokio::test]
1013 async fn remove_deindexes_a_records_chunk_vectors() {
1014 let dir = tempfile::tempdir().unwrap();
1015 let store = FsStore::new(dir.path());
1016
1017 let removed = RecordKey::new("caliban", "topics", "removed");
1020 let live1 = RecordKey::new("caliban", "topics", "live1");
1021 let live2 = RecordKey::new("caliban", "topics", "live2");
1022 for key in [&removed, &live1, &live2] {
1023 put(
1024 &store,
1025 record(
1026 key,
1027 RecordKind::Topic,
1028 Topic {
1029 slug: "gamma".into(),
1030 bullets: vec!["gamma".into()],
1031 }
1032 .to_body()
1033 .unwrap(),
1034 ),
1035 )
1036 .await;
1037 }
1038
1039 let ks = KnowledgeStore::new(store, MemoryVectorIndex::default(), Bow);
1040 for key in [&removed, &live1, &live2] {
1041 assert!(ks.ingest(key).await.unwrap());
1042 }
1043
1044 ks.remove(&removed).await.unwrap();
1048
1049 let hits = ks.query("gamma", 2, &KeyPrefix::default()).await.unwrap();
1050 assert_eq!(hits.len(), 2, "k=2 should be filled by the live records");
1051 let keys: std::collections::BTreeSet<RecordKey> =
1052 hits.iter().map(|h| h.record.key.clone()).collect();
1053 assert!(
1054 !keys.contains(&removed),
1055 "removed record must not appear after de-indexing"
1056 );
1057 assert_eq!(
1058 keys,
1059 std::collections::BTreeSet::from([live1, live2]),
1060 "only the live records should remain"
1061 );
1062 }
1063
1064 #[tokio::test]
1068 async fn counts_rebuilt_from_a_durable_index_still_drop_orphans() {
1069 use std::sync::Arc;
1070
1071 let dir = tempfile::tempdir().unwrap();
1072 let index = Arc::new(MemoryVectorIndex::default());
1073 let key = RecordKey::new("ns", "coll", "doc");
1074
1075 let three = Topic {
1078 slug: "doc".into(),
1079 bullets: vec!["alpha".into(), "beta".into(), "gamma".into()],
1080 };
1081 put(
1082 &FsStore::new(dir.path()),
1083 record(&key, RecordKind::Topic, three.to_body().unwrap()),
1084 )
1085 .await;
1086
1087 let first = KnowledgeStore::new(FsStore::new(dir.path()), Arc::clone(&index), Bow);
1089 assert!(first.ingest(&key).await.unwrap());
1090 assert_eq!(index.keys(&KeyPrefix::default()).await.unwrap().len(), 3);
1091 drop(first);
1092
1093 let one = Topic {
1095 slug: "doc".into(),
1096 bullets: vec!["alpha".into()],
1097 };
1098 let handle = FsStore::new(dir.path());
1099 let existing = handle.get(&key).await.unwrap().unwrap();
1100 let shrunk = existing.update(
1101 one.to_body().unwrap(),
1102 Meta::new(Identity::new("test"), "test"),
1103 );
1104 assert!(matches!(
1105 handle.put(shrunk, Some(existing.revision)).await.unwrap(),
1106 PutResult::Committed(_)
1107 ));
1108
1109 let second = KnowledgeStore::open(FsStore::new(dir.path()), Arc::clone(&index), Bow)
1112 .await
1113 .unwrap();
1114 assert!(second.ingest(&key).await.unwrap());
1115
1116 assert_eq!(index.keys(&KeyPrefix::default()).await.unwrap().len(), 1);
1117 }
1118
1119 #[tokio::test]
1127 async fn open_rebuilds_the_count_across_a_non_contiguous_ordinal_gap() {
1128 use std::sync::Arc;
1129
1130 let dir = tempfile::tempdir().unwrap();
1131 let index = Arc::new(MemoryVectorIndex::default());
1132 let parent = RecordKey::new("ns", "coll", "doc");
1133
1134 index
1138 .upsert(chunk_key(&parent, 0), vec![1.0; 32])
1139 .await
1140 .unwrap();
1141 index
1142 .upsert(chunk_key(&parent, 2), vec![1.0; 32])
1143 .await
1144 .unwrap();
1145
1146 put(
1147 &FsStore::new(dir.path()),
1148 record(
1149 &parent,
1150 RecordKind::Topic,
1151 Topic {
1152 slug: "doc".into(),
1153 bullets: vec!["only bullet".into()],
1154 }
1155 .to_body()
1156 .unwrap(),
1157 ),
1158 )
1159 .await;
1160
1161 let ks = KnowledgeStore::open(FsStore::new(dir.path()), Arc::clone(&index), Bow)
1162 .await
1163 .unwrap();
1164
1165 assert!(ks.ingest(&parent).await.unwrap());
1171
1172 assert_eq!(
1173 index.keys(&KeyPrefix::default()).await.unwrap().len(),
1174 1,
1175 "the gap-created ordinal-2 orphan must have been removed on re-ingest"
1176 );
1177 }
1178
1179 #[cfg(feature = "graph")]
1180 #[tokio::test]
1181 async fn query_in_subgraph_restricts_to_the_neighborhood() {
1182 use gonzalo_graph::{GraphStore, InMemoryGraphStore, build_rust};
1183
1184 let dir = tempfile::tempdir().unwrap();
1185 let store = FsStore::new(dir.path());
1186
1187 for id in ["root", "helper", "faraway"] {
1190 let key = RecordKey::new("code", "symbols", id);
1191 let topic = Topic {
1192 slug: id.into(),
1193 bullets: vec!["touches the database layer".into()],
1194 };
1195 put(
1196 &store,
1197 record(&key, RecordKind::Topic, topic.to_body().unwrap()),
1198 )
1199 .await;
1200 }
1201 let ks = KnowledgeStore::new(store, MemoryVectorIndex::default(), Bow);
1202 for id in ["root", "helper", "faraway"] {
1203 assert!(
1204 ks.ingest(&RecordKey::new("code", "symbols", id))
1205 .await
1206 .unwrap()
1207 );
1208 }
1209
1210 let mut graph = InMemoryGraphStore::new();
1213 graph.insert(
1214 "lib.rs",
1215 build_rust("fn root() { helper(); }\nfn helper() {}\nfn faraway() {}"),
1216 );
1217
1218 let hits = ks
1219 .query_in_subgraph("database", 10, &graph, "root")
1220 .await
1221 .unwrap();
1222 let ids: BTreeMap<String, ()> =
1223 hits.iter().map(|h| (h.record.key.id.clone(), ())).collect();
1224 assert!(ids.contains_key("root"));
1225 assert!(ids.contains_key("helper"));
1226 assert!(
1227 !ids.contains_key("faraway"),
1228 "faraway is semantically similar but outside root's neighborhood"
1229 );
1230 }
1231}