Skip to main content

gonzalo_knowledge/
lib.rs

1//! Knowledge-store capability for gonzalo.
2//!
3//! A single "what do we know about X" surface that composes the existing
4//! capability layers (ADR 0011): a [`Store`] for records, a [`VectorIndex`] for
5//! semantic retrieval, and an [`Embedder`] for turning text into vectors — all
6//! addressed by the shared [`RecordKey`](gonzalo_core::RecordKey). Queries return first-class
7//! [`Record`]s as [`Hit`]s, not bare ids.
8//!
9//! Which record kinds are knowledge-bearing, and how their text is split into
10//! embeddable pieces, lives in [`chunk`] (with [`knowledge_text`] as the joined
11//! one-document view). Records are embedded at chunk granularity — a `Session`
12//! by turn, a long `MemoryTier`/`Ticket` by paragraph, a `Topic` by bullet — so
13//! a query matching one turn/section isn't drowned out by a document average;
14//! [`KnowledgeStore::query`] de-dups chunk matches back to one hit per record.
15//! A `graph`-backed join ([`KnowledgeStore::query_in_subgraph`]) is a further
16//! refinement behind this same surface.
17
18use gonzalo_core::{KeyPrefix, Record, RecordKind, Result, Store};
19use gonzalo_domain::{MemoryTier, RecordCodec, Session, Ticket, TicketEvent, Topic};
20use gonzalo_vector::{Embedder, VectorIndex};
21
22/// One search hit: a first-class record and its similarity score (higher is
23/// more similar).
24#[derive(Debug, Clone)]
25pub struct Hit {
26    pub record: Record,
27    pub score: f32,
28}
29
30/// One chunk-granular search hit: the parent [`Record`], the matching chunk's
31/// score, its `ordinal` within the record, and that chunk's `text`. Unlike a
32/// [`Hit`], chunk hits are **not** de-duped to one-per-record.
33#[derive(Debug, Clone)]
34pub struct ChunkHit {
35    pub record: Record,
36    pub score: f32,
37    pub ordinal: usize,
38    pub text: String,
39}
40
41/// Composes a [`Store`], a [`VectorIndex`], and an [`Embedder`] into one
42/// retrieval surface keyed by [`RecordKey`](gonzalo_core::RecordKey).
43pub struct KnowledgeStore<S, V, E> {
44    store: S,
45    index: V,
46    embedder: E,
47    /// Last-seen chunk count per record, so a re-ingest that shrinks a record
48    /// can remove the now-orphaned high-ordinal chunks from the index. Always
49    /// held in memory, so [`new`](Self::new) starts it empty — correct for a
50    /// fresh index with nothing indexed yet. [`open`](Self::open) rebuilds it
51    /// instead by scanning a durable index's existing keys, so counts survive
52    /// a restart even though this field itself does not.
53    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    /// Open over an index that may already hold vectors, rebuilding the chunk
67    /// counts from it.
68    ///
69    /// [`new`](Self::new) starts those counts empty, which is right for a fresh
70    /// in-memory index and wrong for a durable one: a re-ingest that shrinks a
71    /// record would not know how many chunks to remove, leaving orphans that
72    /// still match queries (#150). `KeyPrefix` cannot narrow below a collection,
73    /// so this scans once here rather than per ingest.
74    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    /// Borrow the underlying store (e.g. to put records before ingesting them).
91    pub fn store(&self) -> &S {
92        &self.store
93    }
94
95    /// Ingest the record at `key`: split it into per-kind [`chunk`]s, embed each,
96    /// and index it under a derived per-chunk key. Returns `Ok(false)` (without
97    /// indexing) if the record is absent or its kind is definitionally not
98    /// knowledge-bearing; returns `Err` if a knowledge-bearing body fails to
99    /// parse — a corrupt record is a real failure, not "not indexable" (#139).
100    ///
101    /// **One batch per record, not one call per chunk** (gonzalo#326). On a
102    /// durable index every `upsert` is a full OCC commit — re-serialise the
103    /// shard, upload it as a blob, write a new manifest revision — so a
104    /// three-chunk record used to advance the manifest three times and leave two
105    /// superseded shard blobs for `gc`. Every chunk is now embedded first, then
106    /// written with one [`VectorIndex::upsert_many`]. A consequence of embedding
107    /// first: if an embedding fails part way, nothing is written at all, where
108    /// before the earlier chunks were already committed.
109    ///
110    /// The orphan removals below are **not** batched yet. Both candidate shapes
111    /// — a `remove_many` on the trait, or folding removals into the upsert
112    /// commit — add a `VectorIndex` method, and `cargo publish --workspace
113    /// --dry-run` verifies each packaged crate against the *published*
114    /// `gonzalo-vector`, so a new trait method cannot be called from this crate
115    /// until the workspace version is bumped. Orphans only occur when a record
116    /// shrinks, so the common path commits once either way. Tracked separately.
117    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        // Remove chunks orphaned by a shrink since the last ingest. Read the old
126        // count under a scoped lock — never held across an `.await`.
127        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    /// De-index the record at `key`: remove every chunk vector it contributed to
151    /// the index and forget its chunk count. This is the counterpart to a record
152    /// being deleted from the store — without it, the record's chunk vectors are
153    /// orphaned, still matching in [`query`] but de-duping to a parent that no
154    /// longer resolves, so they silently shrink the result set (#150).
155    ///
156    /// Idempotent: removing a record that was never ingested (chunk count 0)
157    /// removes nothing. Uses the same [`chunk_key`] derivation as [`ingest`].
158    pub async fn remove(&self, key: &gonzalo_core::RecordKey) -> Result<()> {
159        // Read the count under a scoped lock — never held across an `.await`.
160        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    /// Semantic query, one hit per record. Embed `text`, over-fetch chunk
172    /// matches (restricted to `filter`), collapse them to their parent records
173    /// keeping each record's best chunk score, and return the top-`k` records.
174    ///
175    /// Because a record with many matching chunks can crowd the over-fetch
176    /// window, pathological cases may return fewer than `k` hits.
177    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        // Collapse chunk matches to parents, keeping the best score per parent.
186        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        // Rank parents by best chunk score, resolve the top-k to records.
196        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    /// Chunk-granular query: the top-`k` matching chunks (restricted to
210    /// `filter`), **not** de-duped to their parents. Each hit carries the parent
211    /// record, the chunk's ordinal, and the chunk's text.
212    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    /// **Vector⋈graph** (ADR 0011): rank the top-`k` candidates by semantic
242    /// similarity, then keep only those whose record falls in `root`'s
243    /// call-graph neighborhood in `graph` — `root` itself, its callers, and its
244    /// callees. Composition is by shared key: a record is in the neighborhood
245    /// when its [`RecordKey`](gonzalo_core::RecordKey)`.id` equals a symbol name
246    /// in the neighborhood (no new cross-store linkage, ADR 0008/0011).
247    ///
248    /// So "semantically similar to X **and** structurally near `root`" is one
249    /// call. The result is the in-neighborhood subset of the top-`k` (≤ `k`).
250    #[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
271/// The embeddable text for a record, or `None` if its kind is not
272/// knowledge-bearing (ADR 0011). Kept as the one-document view: the [`chunk`]s
273/// joined back into a single string.
274pub fn knowledge_text(record: &Record) -> Option<String> {
275    // A body that fails to parse is not a "one document"; collapse it to `None`
276    // for this convenience view. The indexing path ([`chunk`]/[`ingest`])
277    // surfaces the error instead (#139).
278    chunk(record).ok().flatten().map(|cs| cs.join("\n"))
279}
280
281/// Split a record into ordered chunk texts. Returns `Ok(None)` if the kind is
282/// definitionally not knowledge-bearing (`Checkpoint`/`GraphManifest`, ADR
283/// 0011), and `Err` if a knowledge-bearing body fails to parse — so a corrupt
284/// record is distinguishable from a genuinely non-indexable one (#139).
285/// Chunking is per-kind: a `Session` by turn, a long `MemoryTier`/`Ticket` by
286/// section/paragraph, a `Topic` by bullet. The kind's title/name is folded into
287/// the first chunk so it stays searchable. A kind that yields a single piece is
288/// the one-document fallback — identical to phase-1 behavior. Extraction goes
289/// through the `gonzalo-domain` typed views.
290pub 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        // Not knowledge-bearing: a checkpoint is opaque state; a graph manifest
315        // is a path -> content-hash map, not natural-language text (ADR 0011/0012).
316        // Checkpoint and GraphManifest are the ONLY definitionally-opaque
317        // kinds — a parse failure on a knowledge-bearing kind above
318        // propagates as an error rather than being silently indistinguishable
319        // from "not indexable" (#139).
320        // Tombstone is listed here defensively, not definitionally: consumer
321        // reads never surface one, so indexing never actually sees this kind
322        // (ADR 0021).
323        // Fleet access-control records are configuration and audit data, and
324        // hold personal data (emails, handles), so they are never indexed for
325        // semantic search (ADR 0022).
326        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
342/// Fold `header` into the first `pieces` chunk so it stays searchable; if
343/// `pieces` is empty, the header stands alone as the single chunk.
344fn 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
352/// How many chunk matches to over-fetch per requested record in [`query`], so
353/// de-duping chunks back to their parents still yields up to `k` distinct
354/// records.
355const OVERFETCH: usize = 8;
356
357/// Separates a parent id from a chunk ordinal in a derived index key. ASCII Unit
358/// Separator (never appears in real ids, which may themselves contain `#`/`/`),
359/// so [`parent_key`] recovers the parent unambiguously.
360const CHUNK_SEP: char = '\u{1f}';
361
362/// The index key for chunk `ordinal` of the record at `parent`: same
363/// namespace/collection, with the ordinal folded into the `id` (so a
364/// [`KeyPrefix`] filter, which matches on namespace/collection only, is
365/// unaffected). Every chunk — including a single-chunk fallback — carries an
366/// ordinal, so keys are always parseable.
367fn 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
375/// Inverse of [`chunk_key`]: recover the parent key and chunk ordinal. A key
376/// without the separator (defensive) is treated as parent, ordinal 0.
377fn 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
390/// Split text on blank-line boundaries into trimmed, non-empty paragraphs.
391fn 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    /// A deterministic bag-of-words embedder: cosine similarity tracks word
410    /// overlap, enough to rank a matching record first.
411    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        // Session name is prepended to the first chunk so it stays searchable.
476        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        // Multiple bullets -> one chunk each.
485        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        // A single-piece record is the one-document fallback.
499        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        // Non-knowledge kinds still gate to None.
509        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        // Checkpoint is not knowledge-bearing.
524        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    // --- gonzalo#326: ingest batches its index writes ---
569
570    /// Counts how `KnowledgeStore` actually drives the index, so a regression to
571    /// per-chunk upserts fails here rather than only showing up as extra
572    /// manifest revisions on a durable backend.
573    #[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        /// `(upsert, upsert_many, remove)` call counts.
583        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    /// A three-chunk record is one batched upsert, not three.
637    #[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    /// A re-ingest after a shrink is still one batched upsert. The orphan
661    /// removals remain one call each — see `ingest`'s note on why batching them
662    /// waits on a workspace version bump — so this pins the current shape rather
663    /// than claiming they are batched.
664    #[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        // Shrink 3 chunks -> 1, orphaning ordinals 1 and 2.
680        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    /// The claim #326 actually makes, measured on the durable backend: ingesting
702    /// one three-chunk record advances the vector manifest once, not three times.
703    #[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        // A session whose one-document average is heavily diluted: two short
795        // turns match "gamma", one long turn is all "alpha". Averaged into one
796        // vector, the alpha turn drowns out the gamma signal.
797        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        // A decoy whose whole (short) document is closer to "gamma" than the
818        // session's diluted average — so under one-document embedding the decoy
819        // outranks the session.
820        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        // Chunking lets the matching turn score on its own, so the session ranks
843        // first...
844        assert_eq!(hits[0].record.key, sess, "matching turn should rank first");
845        // ...and the two matching turns de-dup to a single hit for the record.
846        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        // The two "gamma" turns are the two closest chunks — returned
877        // individually, NOT de-duped to the record.
878        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        // v1: three turns -> three chunks.
896        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        // The third turn's content is indexed while it exists.
913        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        // v2: shrink to a single turn. The record now has one chunk; the "delta"
920        // and "epsilon" chunks are orphaned in the index.
921        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        // The orphaned "epsilon" (and "delta") chunks are gone from the index;
938        // only the surviving "gamma" chunk remains.
939        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        // A knowledge-bearing kind (Topic) whose body bytes are not a valid
977        // Topic: a corrupt record is a real failure, not "not indexable" (#139).
978        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        // A definitionally-opaque kind still gates cleanly to Ok(false).
990        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        // Three topics all matching "gamma". One will be removed; the other two
1018        // are the live set.
1019        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        // De-index the one record. Its chunk vectors must no longer match, so
1045        // the k=2 query is filled entirely by the live records — no shrinkage
1046        // from orphaned vectors, and the removed record never appears (#150).
1047        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    // Re-ingesting a shrunk record must drop the chunks it no longer has — even
1065    // when the counts were not built in this process. Before `open`, a restart
1066    // resets them to zero and the orphans survive (#150, via #323).
1067    #[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        // `chunk()` maps a Topic to one chunk per bullet, so bullet count is
1076        // chunk count (crates/gonzalo-knowledge/src/lib.rs:250).
1077        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        // A first "process" ingests all three chunks.
1088        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        // The record shrinks to one bullet.
1094        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        // A second "process" opens over the same durable index and re-ingests.
1110        // Chunks 1 and 2 must go; with `new` instead of `open` they would stay.
1111        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    // F9 (#323 fix-round): a gap in stored ordinals must not undercount.
1120    // `open` rebuilds each record's count as `max(ordinal + 1)` over its
1121    // chunks, not the number of keys present, so ordinals {0, 2} (1 absent)
1122    // must rebuild to 3. A gap like this is reachable in practice: `ingest`'s
1123    // orphan-removal loop (`for ordinal in chunks.len()..old`) removes
1124    // ordinals one at a time in ascending order, so a crash partway through
1125    // can leave a lower ordinal removed while a higher one survives.
1126    #[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        // Ordinals {0, 2} present, 1 absent. `MemoryVectorIndex` enforces one
1135        // dimension across everything it holds, so these must match what
1136        // `Bow` (32) will later embed on re-ingest.
1137        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        // The record now has one chunk (ordinal 0). Re-ingesting must remove
1166        // ordinals 1 and 2 as orphans -- reachable only if `open` rebuilt the
1167        // count as 3 (the highest surviving ordinal + 1). A naive count of 2
1168        // (the number of keys present) would leave ordinal 2's entry behind,
1169        // since the removal loop would only run `1..2`.
1170        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        // Three topic records keyed by symbol name, all mentioning "database",
1188        // so all three are semantically similar to the query.
1189        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        // Call graph: root -> helper; faraway is unrelated. So root's
1211        // neighborhood is {root, helper}.
1212        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}