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;