Skip to main content

clawft_kernel/
context_graft.rs

1//! Session-scoped context graft layer (ADR-058 Phase 3).
2//!
3//! The agent loop never gets a parallel store; it gets a **query/graft layer**
4//! over ExoChain's ECC indexes, scoped to one conversation. This module is the
5//! kernel side of that layer:
6//!
7//! - **3.1 [`SessionView`]** — a session-scoped, *ephemeral* semantic index
8//!   (a per-session HNSW). Because the index holds only this conversation's
9//!   chunks, scoping is structural: a query can only return this session's
10//!   chunks. The view is disposable and **rebuildable from the chain**
11//!   (re-embed this session's events), so killing/rebuilding a loop costs only
12//!   time — ExoChain remains the source of truth.
13//! - **3.2 graft API** — query → candidate branches → COW-reference graft,
14//!   keyed by chain sequence, large payloads as content-addressed blobs.
15//! - **3.3 index population** — embed `agent.chat.turn` / tool-output events
16//!   with the ADR-059 embedder, store f32 vectors keyed by chain sequence.
17//!
18//! ## This IS the ECC causal model
19//!
20//! Graft / prune / promote map onto the node lifecycle ([`NodeState`]):
21//! `Speculative → Frontier → Committed` (collapse to trunk) or `→ Stale →
22//! Pruned` (evicted off `main_line`). v1 is **single semantic index** per
23//! conversation (ADR-058 phasing); fusion (causal + temporal + BVH) is v2 and
24//! additive — every chunk is already keyed by chain sequence.
25//!
26//! The session view is deliberately separate from the durable global
27//! [`CausalGraph`](crate::causal): it is the L2 "warm, this-run" tier. L2→L3
28//! promotion onto the durable trunk is Phase 4.
29
30use std::collections::HashSet;
31
32use dashmap::DashMap;
33use serde_json::json;
34use tracing::warn;
35
36use crate::artifact_store::{ArtifactStore, ArtifactType};
37use crate::embedding::{EmbeddingError, EmbeddingProvider};
38use crate::hnsw_service::{HnswService, HnswServiceConfig};
39
40// Node lifecycle state ([`NodeState`]) + cross-substrate [`mirror_state`] live
41// in a sibling file to keep this module under the 500-line cap.
42#[path = "context_graft_state.rs"]
43mod state;
44pub use state::{NodeState, mirror_state};
45
46/// Metadata for one chunk indexed in a [`SessionView`].
47///
48/// Keyed by `chain_seq` (the universal ExoChain key). `content_hash` is the
49/// content-addressed identity used for dedup and provenance; large chunk text
50/// is held as a `blob_hash` (RVF content-addressed blob), small text inline.
51#[derive(Debug, Clone)]
52pub struct ChunkMeta {
53    /// ExoChain sequence this chunk originates from.
54    pub chain_seq: u64,
55    /// BLAKE3 hex of the chunk text — content-addressed identity.
56    pub content_hash: String,
57    /// Causal-model state of this chunk.
58    pub state: NodeState,
59    /// Originating event kind, e.g. `"agent.chat.turn"` / `"tool.output"`.
60    pub kind: String,
61    /// Content-addressed blob hash when the text was externalized (large).
62    pub blob_hash: Option<String>,
63    /// Inline chunk text when small enough to keep in the manifest.
64    pub inline: Option<String>,
65}
66
67/// A hit from a scoped query: the matched chunk plus its similarity score.
68#[derive(Debug, Clone)]
69pub struct ScopedHit {
70    /// ExoChain sequence of the matched chunk.
71    pub chain_seq: u64,
72    /// Cosine similarity (higher is closer).
73    pub score: f32,
74    /// Full chunk metadata (provenance + content reference).
75    pub meta: ChunkMeta,
76}
77
78/// How a grafted item's content is carried into the working set.
79///
80/// The graft is **by COW reference** — the origin chain entry is never removed.
81/// Small text rides inline; large text is referenced by its content-addressed
82/// blob hash (load via [`ArtifactStore`](crate::artifact_store::ArtifactStore));
83/// a chunk whose text was not retained in this view is a bare reference to be
84/// recovered from the chain.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum GraftContent {
87    /// Inline chunk text (small).
88    Inline(String),
89    /// RVF content-addressed blob hash (large); load via the artifact store.
90    Blob(String),
91    /// Indexed but text not retained here; recover from the origin chain.
92    Reference,
93}
94
95/// One item grafted into the working set (ADR-058 Phase 3.2).
96///
97/// Keyed by chain sequence and carrying a verifiable provenance backref
98/// (`chain_seq` + `content_hash`): the agent can produce the witness chain for
99/// any grafted context.
100#[derive(Debug, Clone)]
101pub struct GraftedItem {
102    /// ExoChain sequence — the universal key / provenance backref.
103    pub chain_seq: u64,
104    /// BLAKE3 content identity — dedup key + provenance.
105    pub content_hash: String,
106    /// Originating event kind.
107    pub kind: String,
108    /// Causal-model state of the grafted node.
109    pub state: NodeState,
110    /// Similarity score that selected this item (higher is closer).
111    pub score: f32,
112    /// COW reference to the item's content.
113    pub content: GraftContent,
114}
115
116/// Compute the content-addressed identity of a chunk's text.
117pub fn content_hash(text: &str) -> String {
118    blake3::hash(text.as_bytes()).to_hex().to_string()
119}
120
121/// Resolve a chunk's content into a COW reference (blob hash > inline > bare ref).
122fn graft_content(meta: &ChunkMeta) -> GraftContent {
123    if let Some(ref h) = meta.blob_hash {
124        GraftContent::Blob(h.clone())
125    } else if let Some(ref t) = meta.inline {
126        GraftContent::Inline(t.clone())
127    } else {
128        GraftContent::Reference
129    }
130}
131
132/// A session-scoped, ephemeral semantic view over this conversation's chain
133/// chunks (ADR-058 Phase 3.1).
134///
135/// Holds a per-session HNSW (so queries are structurally scoped) plus a
136/// manifest of `chain_seq → [`ChunkMeta`]`. Disposable: drop it at session end;
137/// rebuild it from the chain by re-inserting this session's events.
138pub struct SessionView {
139    session_id: String,
140    dims: usize,
141    index: HnswService,
142    /// chain_seq → chunk metadata (this session's manifest).
143    chunks: DashMap<u64, ChunkMeta>,
144    /// chain_seq → number of times grafted/recalled (promotion signal, 4.2).
145    ref_counts: DashMap<u64, u32>,
146    /// Chunks explicitly marked "important to remember" (promotion signal, 4.2).
147    important: DashMap<u64, ()>,
148}
149
150impl SessionView {
151    /// Create an empty session view sized to `embedder`'s output dimension
152    /// (ADR-059 Qwen3 = 512; Mock fallback = its own dim). Preferred
153    /// constructor so the index and the embedder can never disagree on width.
154    pub fn for_embedder(session_id: impl Into<String>, embedder: &dyn EmbeddingProvider) -> Self {
155        Self::new(session_id, embedder.dimensions())
156    }
157
158    /// Create an empty session view whose ephemeral index expects `dims`-wide
159    /// vectors (use the embedder's [`dimensions`](crate::embedding::EmbeddingProvider::dimensions);
160    /// ADR-059 Qwen3 = 512, Mock fallback = its own dim).
161    pub fn new(session_id: impl Into<String>, dims: usize) -> Self {
162        let config = HnswServiceConfig {
163            ef_search: 100,
164            ef_construction: 200,
165            default_dimensions: dims,
166        };
167        Self {
168            session_id: session_id.into(),
169            dims,
170            index: HnswService::new(config),
171            chunks: DashMap::new(),
172            ref_counts: DashMap::new(),
173            important: DashMap::new(),
174        }
175    }
176
177    /// The conversation/session id this view is scoped to.
178    pub fn session_id(&self) -> &str {
179        &self.session_id
180    }
181
182    /// Vector dimensionality of the ephemeral index.
183    pub fn dims(&self) -> usize {
184        self.dims
185    }
186
187    /// Number of chunks indexed in this session view.
188    pub fn len(&self) -> usize {
189        self.chunks.len()
190    }
191
192    /// `true` when no chunks have been indexed yet.
193    pub fn is_empty(&self) -> bool {
194        self.chunks.is_empty()
195    }
196
197    /// The chain sequences indexed in this view (the rebuild manifest).
198    pub fn chain_seqs(&self) -> Vec<u64> {
199        let mut seqs: Vec<u64> = self.chunks.iter().map(|e| *e.key()).collect();
200        seqs.sort_unstable();
201        seqs
202    }
203
204    /// Fetch a chunk's metadata by chain sequence.
205    pub fn chunk(&self, chain_seq: u64) -> Option<ChunkMeta> {
206        self.chunks.get(&chain_seq).map(|e| e.value().clone())
207    }
208
209    /// Insert a pre-embedded chunk into the ephemeral index, keyed by chain
210    /// sequence. Low-level building block: 3.3 wraps this with the embedder and
211    /// blob externalization. Replaces any existing chunk for the same
212    /// `chain_seq` (idempotent re-graft / rebuild).
213    ///
214    /// Returns `false` (and does not index) when `vector` does not match the
215    /// view's dimensionality — callers must embed with the configured model.
216    pub fn insert_vector(&self, vector: Vec<f32>, meta: ChunkMeta) -> bool {
217        if vector.len() != self.dims {
218            return false;
219        }
220        let chain_seq = meta.chain_seq;
221        let metadata = json!({
222            "chain_seq": chain_seq,
223            "content_hash": meta.content_hash,
224            "state": meta.state.as_str(),
225            "kind": meta.kind,
226            "session_id": self.session_id,
227        });
228        self.index.insert(chain_seq.to_string(), vector, metadata);
229        self.chunks.insert(chain_seq, meta);
230        true
231    }
232
233    /// Query the session-scoped index for the `top_k` nearest chunks.
234    ///
235    /// Because the index holds only this session's chunks, results are
236    /// structurally scoped — a hit can only be from this conversation.
237    pub fn query(&self, query: &[f32], top_k: usize) -> Vec<ScopedHit> {
238        if query.len() != self.dims {
239            return Vec::new();
240        }
241        self.index
242            .search(query, top_k)
243            .into_iter()
244            .filter_map(|hit| {
245                let chain_seq: u64 = hit.id.parse().ok()?;
246                let meta = self.chunk(chain_seq)?;
247                Some(ScopedHit {
248                    chain_seq,
249                    score: hit.score,
250                    meta,
251                })
252            })
253            .collect()
254    }
255
256    /// Graft the `top_k` most relevant chunks into the working set (ADR-058
257    /// Phase 3.2).
258    ///
259    /// Scoped query → candidate branches → **COW-reference** graft: returns
260    /// chain-sequence-keyed [`GraftedItem`]s with provenance backrefs
261    /// (`chain_seq` + `content_hash`) and a content reference (inline / blob /
262    /// bare). Candidates are **deduplicated by content hash** — identical tool
263    /// outputs or repeated file reads collapse to one item (the highest-scoring
264    /// occurrence). The origin is never removed; this only references it.
265    pub fn graft(&self, query: &[f32], top_k: usize) -> Vec<GraftedItem> {
266        if top_k == 0 {
267            return Vec::new();
268        }
269        // Over-fetch so dedup-by-content does not starve the result below top_k.
270        let raw = self.query(query, top_k.saturating_mul(2));
271        let mut seen: HashSet<String> = HashSet::new();
272        let mut out: Vec<GraftedItem> = Vec::with_capacity(top_k);
273        // `query` returns hits sorted by descending score, so the first time a
274        // content hash is seen is its best-scoring occurrence.
275        for hit in raw {
276            if !seen.insert(hit.meta.content_hash.clone()) {
277                continue; // duplicate content — already grafted at a higher score
278            }
279            // Recall is a promotion signal (4.2): count every graft.
280            *self.ref_counts.entry(hit.chain_seq).or_insert(0) += 1;
281            out.push(GraftedItem {
282                chain_seq: hit.chain_seq,
283                content_hash: hit.meta.content_hash.clone(),
284                kind: hit.meta.kind.clone(),
285                state: hit.meta.state,
286                score: hit.score,
287                content: graft_content(&hit.meta),
288            });
289            if out.len() == top_k {
290                break;
291            }
292        }
293        out
294    }
295
296    /// Embed and index one chain event into the session view (ADR-058 Phase
297    /// 3.3).
298    ///
299    /// Embeds `text` with the ADR-059 `embedder` (store **f32**; Qwen3 = @512)
300    /// and indexes the vector keyed by `chain_seq`. When `text` exceeds
301    /// `inline_max` bytes and an [`ArtifactStore`] is supplied, the full text is
302    /// written as an RVF **content-addressed blob** and the chunk holds only the
303    /// blob hash (no chain bloat, free dedup); otherwise the text rides inline.
304    /// The chunk enters as [`NodeState::Frontier`] (live in the window).
305    ///
306    /// Returns `Ok(false)` only if the embedder produced a vector whose width
307    /// disagrees with the view (guarded against; use [`for_embedder`]).
308    ///
309    /// [`for_embedder`]: Self::for_embedder
310    pub async fn index_chunk(
311        &self,
312        embedder: &dyn EmbeddingProvider,
313        store: Option<&ArtifactStore>,
314        chain_seq: u64,
315        kind: impl Into<String>,
316        text: &str,
317        inline_max: usize,
318    ) -> Result<bool, EmbeddingError> {
319        // Default: embed the same string that is stored.
320        self.index_chunk_with_embed(embedder, store, chain_seq, kind, text, text, inline_max)
321            .await
322    }
323
324    /// Like [`index_chunk`](Self::index_chunk), but embeds `embed_input` while
325    /// storing / hashing `text` (WEFT-640).
326    ///
327    /// Used when the passage lane is a **verbalized** atom (text +
328    /// classification + voice cues) but the grafted payload must remain the
329    /// original turn text for the prompt. Content hash is over `text` so
330    /// dedup stays content-stable across verbalizer format bumps.
331    #[allow(clippy::too_many_arguments)] // embed vs store text split needs distinct params
332    pub async fn index_chunk_with_embed(
333        &self,
334        embedder: &dyn EmbeddingProvider,
335        store: Option<&ArtifactStore>,
336        chain_seq: u64,
337        kind: impl Into<String>,
338        text: &str,
339        embed_input: &str,
340        inline_max: usize,
341    ) -> Result<bool, EmbeddingError> {
342        let vector = embedder.embed(embed_input).await?;
343        let hash = content_hash(text);
344
345        // Externalize large payloads to a content-addressed blob; fall back to
346        // inline if there is no store or the write fails (chunk stays usable).
347        let (inline, blob_hash) = if text.len() > inline_max {
348            match store {
349                Some(s) => match s.store(text.as_bytes(), ArtifactType::Generic) {
350                    Ok(h) => (None, Some(h)),
351                    Err(e) => {
352                        warn!(
353                            chain_seq,
354                            error = %e,
355                            "context_graft: blob store failed; keeping chunk inline"
356                        );
357                        (Some(text.to_string()), None)
358                    }
359                },
360                None => (Some(text.to_string()), None),
361            }
362        } else {
363            (Some(text.to_string()), None)
364        };
365
366        let meta = ChunkMeta {
367            chain_seq,
368            content_hash: hash,
369            state: NodeState::Frontier,
370            kind: kind.into(),
371            blob_hash,
372            inline,
373        };
374        Ok(self.insert_vector(vector, meta))
375    }
376
377    /// Convenience: embed a free-text query with `embedder` and graft the
378    /// `top_k` nearest chunks (ADR-058 Phase 3.2 + 3.3, query side).
379    ///
380    /// Uses [`EmbeddingProvider::embed_query`] (not plain `embed`) so asymmetric
381    /// providers (e5 `query:` prefix, Qwen3 instruction) land queries in the
382    /// correct half of the space (WEFT-640). Symmetric backends default
383    /// `embed_query` → `embed`.
384    pub async fn graft_text(
385        &self,
386        embedder: &dyn EmbeddingProvider,
387        query_text: &str,
388        top_k: usize,
389    ) -> Result<Vec<GraftedItem>, EmbeddingError> {
390        let query = embedder.embed_query(query_text).await?;
391        Ok(self.graft(&query, top_k))
392    }
393
394    /// Set a chunk's causal-model [`NodeState`] unconditionally. Returns
395    /// `false` if the chunk is unknown. Prefer [`transition`](Self::transition)
396    /// on the per-turn path so illegal lifecycle jumps are rejected.
397    pub fn set_state(&self, chain_seq: u64, state: NodeState) -> bool {
398        match self.chunks.get_mut(&chain_seq) {
399            Some(mut e) => {
400                e.state = state;
401                true
402            }
403            None => false,
404        }
405    }
406
407    /// The current causal-model state of a chunk, if known.
408    pub fn state(&self, chain_seq: u64) -> Option<NodeState> {
409        self.chunks.get(&chain_seq).map(|e| e.value().state)
410    }
411
412    /// Mark a chunk [`NodeState::Speculative`] — above the wavefront, mutable,
413    /// not yet hash-chained (ADR-062 D2). This is the per-turn entry state the
414    /// kernel never assigned before P0.2. Returns `false` if unknown.
415    pub fn set_speculative(&self, chain_seq: u64) -> bool {
416        self.set_state(chain_seq, NodeState::Speculative)
417    }
418
419    /// Advance a chunk through a **validated** lifecycle transition
420    /// ([`NodeState::can_transition_to`], ADR-062 D2). Returns `false` if the
421    /// chunk is unknown or the transition is illegal (the chunk is left
422    /// unchanged in that case).
423    pub fn transition(&self, chain_seq: u64, next: NodeState) -> bool {
424        match self.chunks.get_mut(&chain_seq) {
425            Some(mut e) if e.state.can_transition_to(next) => {
426                e.state = next;
427                true
428            }
429            _ => false,
430        }
431    }
432
433    /// Commit a chunk to the durable trunk ([`NodeState::Committed`]) — the
434    /// Frontier→Committed step fired at EOU (ADR-062 D5). Validated; returns
435    /// `false` if unknown or not currently `Frontier`.
436    pub fn commit(&self, chain_seq: u64) -> bool {
437        self.transition(chain_seq, NodeState::Committed)
438    }
439
440    /// Prune a chunk from the live window (ADR-058 Phase 4.1).
441    ///
442    /// Marks it [`NodeState::Stale`] but **keeps it in the index** — the origin
443    /// stays on the chain and the chunk is re-graftable via retrieval. This is
444    /// the application-level eviction that bounds L1 with no engine
445    /// `--context-shift`. Returns `false` if the chunk is unknown.
446    pub fn prune(&self, chain_seq: u64) -> bool {
447        self.set_state(chain_seq, NodeState::Stale)
448    }
449
450    /// Re-graft a previously pruned chunk back into the live window
451    /// ([`NodeState::Frontier`]). The chunk was never removed from the index, so
452    /// this only flips its state. Returns `false` if unknown.
453    pub fn regraft(&self, chain_seq: u64) -> bool {
454        self.set_state(chain_seq, NodeState::Frontier)
455    }
456
457    /// Chain sequences currently live in the window ([`NodeState::Frontier`]),
458    /// sorted ascending.
459    pub fn live_seqs(&self) -> Vec<u64> {
460        let mut v: Vec<u64> = self
461            .chunks
462            .iter()
463            .filter(|e| e.value().state == NodeState::Frontier)
464            .map(|e| *e.key())
465            .collect();
466        v.sort_unstable();
467        v
468    }
469
470    /// Mark a chunk "important to remember" — an explicit promotion signal
471    /// (ADR-058 Phase 4.2, e.g. from a `memory_store` tool call). Returns
472    /// `false` if the chunk is unknown.
473    pub fn mark_important(&self, chain_seq: u64) -> bool {
474        if self.chunks.contains_key(&chain_seq) {
475            self.important.insert(chain_seq, ());
476            true
477        } else {
478            false
479        }
480    }
481
482    /// Whether a chunk was explicitly marked important.
483    pub fn is_important(&self, chain_seq: u64) -> bool {
484        self.important.contains_key(&chain_seq)
485    }
486
487    /// How many times a chunk has been grafted/recalled (a promotion signal).
488    pub fn ref_count(&self, chain_seq: u64) -> u32 {
489        self.ref_counts
490            .get(&chain_seq)
491            .map(|e| *e.value())
492            .unwrap_or(0)
493    }
494}
495
496// ── Tests (no model required; split to a sibling file for the <500-line rule) ─
497
498#[cfg(test)]
499#[path = "context_graft_tests.rs"]
500mod tests;