1use super::{
2 fusion::fuse_lexical_semantic, DurableMemoryMode, DurableMemoryRecallChannel,
3 DurableMemoryRecallHit, DurableMemoryRecallPreview, DurableMemorySession,
4};
5use crate::context::{ContextAssembly, ContextItem, ContextResult, ContextType};
6use a3s_memory::repository::{
7 DurableMemoryKind, MemoryAccessEvent, MemoryNode, MemoryQuery, MemoryRelationKind,
8 MemoryRepositoryError, MemoryStatus,
9};
10use chrono::{DateTime, Utc};
11use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
12use sha2::{Digest, Sha256};
13use std::collections::HashSet;
14use tokio_util::sync::CancellationToken;
15
16pub const DURABLE_MEMORY_CONTEXT_ID_PROFILE_V1: &str =
19 "a3s.code.memory.context.session-run-sequence-sha256.v1";
20
21pub const DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2: &str =
25 "a3s.code.memory.context.session-run-invocation-sequence-sha256.v2";
26
27const PROVIDER: &str = "durable_memory_v2";
28const RELATED_SCORE_FACTOR: f32 = 0.75;
29
30#[derive(Clone)]
31pub(super) struct RecallCandidate {
32 pub(super) node: MemoryNode,
33 pub(super) score: f32,
34 pub(super) channel: DurableMemoryRecallChannel,
35 pub(super) related_from: Option<String>,
36}
37
38impl RecallCandidate {
39 fn into_preview_hit(self) -> DurableMemoryRecallHit {
40 DurableMemoryRecallHit {
41 node_id: self.node.id,
42 node_revision: self.node.revision,
43 kind: self.node.kind,
44 content: self.node.content,
45 score: self.score,
46 channel: self.channel,
47 related_from: self.related_from,
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
53pub(crate) struct DurableMemoryRecallIdentity {
54 pub(crate) item_id: String,
55 source: String,
56 pub(crate) node_id: String,
57 pub(crate) node_revision: u64,
58 content_digest: String,
59}
60
61impl DurableMemoryRecallIdentity {
62 fn matches(&self, item: &ContextItem) -> bool {
63 item.id == self.item_id
64 && item.source.as_deref() == Some(self.source.as_str())
65 && digest(&item.content) == self.content_digest
66 }
67}
68
69pub(crate) struct DurableMemoryContextBatch {
70 pub(crate) result: ContextResult,
71 pub(crate) identities: Vec<DurableMemoryRecallIdentity>,
72}
73
74impl DurableMemorySession {
75 pub async fn preview_recall(
79 &self,
80 text: &str,
81 ) -> Result<DurableMemoryRecallPreview, MemoryRepositoryError> {
82 Ok(DurableMemoryRecallPreview {
83 hits: self
84 .query_recall_candidates(text, CancellationToken::new())
85 .await?
86 .into_iter()
87 .map(RecallCandidate::into_preview_hit)
88 .collect(),
89 })
90 }
91
92 #[cfg(test)]
93 pub(crate) async fn query_active_context(
94 &self,
95 text: &str,
96 ) -> Result<DurableMemoryContextBatch, MemoryRepositoryError> {
97 self.query_active_context_with_cancellation(text, CancellationToken::new())
98 .await
99 }
100
101 pub(crate) async fn query_active_context_with_cancellation(
102 &self,
103 text: &str,
104 cancellation: CancellationToken,
105 ) -> Result<DurableMemoryContextBatch, MemoryRepositoryError> {
106 let hits = self.query_recall_candidates(text, cancellation).await?;
107 let mut result = ContextResult::new(PROVIDER);
108 let mut identities = Vec::new();
109 for hit in hits {
110 let node = hit.node;
111 let encoded_id = utf8_percent_encode(&node.id, NON_ALPHANUMERIC);
112 let source = format!("a3s-memory://{encoded_id}?revision={}", node.revision);
113 let item_id = format!("a3s-memory-v2:{}:r{}", node.id, node.revision);
114 let content_digest = digest(&node.content);
115 let token_count = (node.content.len() / 4).max(1);
116 let item = ContextItem::new(&item_id, ContextType::Memory, &node.content)
117 .with_relevance(hit.score)
118 .with_token_count(token_count)
119 .with_source(&source)
120 .with_metadata("memory_node_id", serde_json::json!(node.id))
121 .with_metadata("memory_node_revision", serde_json::json!(node.revision))
122 .with_metadata("memory_kind", serde_json::json!(kind_label(node.kind)))
123 .with_metadata("evidence_count", serde_json::json!(node.evidence.len()))
124 .with_metadata(
125 "retrieval_channel",
126 serde_json::json!(channel_label(hit.channel)),
127 )
128 .with_provenance(PROVIDER)
129 .with_priority(0.4)
130 .with_trust(0.8)
131 .with_freshness(0.6);
132 let item = match hit.related_from {
133 Some(source_id) => item.with_metadata("related_from", serde_json::json!(source_id)),
134 None => item,
135 };
136 identities.push(DurableMemoryRecallIdentity {
137 item_id,
138 source,
139 node_id: node.id,
140 node_revision: node.revision,
141 content_digest,
142 });
143 result.add_item(item);
144 }
145 Ok(DurableMemoryContextBatch { result, identities })
146 }
147
148 async fn query_recall_candidates(
149 &self,
150 text: &str,
151 cancellation: CancellationToken,
152 ) -> Result<Vec<RecallCandidate>, MemoryRepositoryError> {
153 let Some(policy) = self.recall_policy() else {
154 return Ok(Vec::new());
155 };
156 if self.mode() != DurableMemoryMode::ActiveRecall
157 || !text.chars().any(char::is_alphanumeric)
158 {
159 return Ok(Vec::new());
160 }
161
162 let query = MemoryQuery::new(self.namespace().clone())
163 .with_text(text)
164 .with_limit(policy.max_results());
165 let lexical = self
166 .repository()
167 .query(query)
168 .await?
169 .hits
170 .into_iter()
171 .filter(|hit| hit.score.total >= policy.min_lexical_score())
172 .map(|hit| RecallCandidate {
173 node: hit.node,
174 score: hit.score.total,
175 channel: DurableMemoryRecallChannel::Lexical,
176 related_from: None,
177 })
178 .collect::<Vec<_>>();
179 let semantic = match self.semantic_recall() {
180 Some(semantic) => match semantic
181 .query_verified(
182 self.repository().as_ref(),
183 self.namespace(),
184 text,
185 cancellation.clone(),
186 )
187 .await
188 {
189 Ok(candidates) => candidates,
190 Err(error) => {
191 tracing::warn!(
192 reason = error.redacted_message(),
193 "Semantic durable-memory recall degraded to lexical recall"
194 );
195 Vec::new()
196 }
197 },
198 None => Vec::new(),
199 };
200 let mut candidates = fuse_lexical_semantic(lexical, semantic);
201 if policy.max_related_lookups() == 0 {
202 candidates.truncate(policy.max_results());
203 return Ok(candidates);
204 }
205 let mut known_ids = candidates
206 .iter()
207 .map(|candidate| candidate.node.id.clone())
208 .collect::<HashSet<_>>();
209 let mut looked_up = HashSet::new();
210 let mut lookup_count = 0;
211 let recall_seeds = candidates.clone();
212 'seeds: for seed in &recall_seeds {
213 for relation in &seed.node.relations {
214 if cancellation.is_cancelled() {
215 break 'seeds;
216 }
217 if relation.kind != MemoryRelationKind::RelatedTo
218 || known_ids.contains(&relation.target_id)
219 || !looked_up.insert(relation.target_id.clone())
220 {
221 continue;
222 }
223 if lookup_count >= policy.max_related_lookups() {
224 break 'seeds;
225 }
226 lookup_count += 1;
227 let Some(node) = self
228 .repository()
229 .get(self.namespace(), &relation.target_id)
230 .await?
231 else {
232 continue;
233 };
234 if node.status != MemoryStatus::Active {
235 continue;
236 }
237 known_ids.insert(node.id.clone());
238 candidates.push(RecallCandidate {
239 node,
240 score: (seed.score * RELATED_SCORE_FACTOR).clamp(0.0, 1.0),
241 channel: DurableMemoryRecallChannel::Related,
242 related_from: Some(seed.node.id.clone()),
243 });
244 }
245 }
246 candidates.sort_by(|left, right| {
247 right
248 .score
249 .total_cmp(&left.score)
250 .then_with(|| channel_rank(left.channel).cmp(&channel_rank(right.channel)))
251 .then_with(|| right.node.updated_at.cmp(&left.node.updated_at))
252 .then_with(|| left.node.id.cmp(&right.node.id))
253 });
254 candidates.truncate(policy.max_results());
255 Ok(candidates)
256 }
257
258 pub(crate) async fn admit_selected_context(
259 &self,
260 assembly: &mut ContextAssembly,
261 identities: &[DurableMemoryRecallIdentity],
262 context_id: Option<&str>,
263 occurred_at: Option<DateTime<Utc>>,
264 ) -> usize {
265 if identities.is_empty() {
266 return 0;
267 }
268 let mut admitted = HashSet::new();
269 if let (Some(context_id), Some(occurred_at)) = (context_id, occurred_at) {
270 for item in &assembly.items {
271 let Some(identity) = identities.iter().find(|identity| identity.matches(item))
272 else {
273 continue;
274 };
275 let event_id = admission_id(context_id, &identity.node_id, identity.node_revision);
276 let event = MemoryAccessEvent::new(
277 event_id,
278 self.namespace().clone(),
279 &identity.node_id,
280 identity.node_revision,
281 occurred_at,
282 )
283 .with_context_id(context_id);
284 match self.repository().record_admission(event).await {
285 Ok(()) => {
286 admitted.insert(identity.item_id.clone());
287 }
288 Err(error) => {
289 tracing::warn!(
290 %error,
291 memory_id = %identity.node_id,
292 memory_revision = identity.node_revision,
293 "Dropping V2 memory that could not be admitted"
294 );
295 }
296 }
297 }
298 } else if context_id.is_none() {
299 tracing::warn!("Dropping V2 memory context because invocation identity is unavailable");
300 } else {
301 tracing::warn!("Dropping V2 memory context because host time is invalid");
302 }
303
304 assembly.items.retain(|item| {
305 let recalled = identities.iter().any(|identity| identity.matches(item));
306 !recalled || admitted.contains(&item.id)
307 });
308 assembly.total_tokens = assembly
309 .items
310 .iter()
311 .map(|item| {
312 if item.token_count > 0 {
313 item.token_count
314 } else {
315 item.content.split_whitespace().count().max(1)
316 }
317 })
318 .sum();
319 admitted.len()
320 }
321}
322
323pub(crate) fn durable_memory_context_id(
324 session_id: &str,
325 run_id: &str,
326 invocation_incarnation: &str,
327 context_sequence: u64,
328) -> String {
329 let mut hasher = Sha256::new();
330 hasher.update(DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2.as_bytes());
331 hasher.update(b"\0session\0");
332 hasher.update(Sha256::digest(session_id.as_bytes()));
333 hasher.update(b"\0run\0");
334 hasher.update(Sha256::digest(run_id.as_bytes()));
335 hasher.update(b"\0invocation\0");
336 hasher.update(Sha256::digest(invocation_incarnation.as_bytes()));
337 hasher.update(b"\0sequence\0");
338 hasher.update(context_sequence.to_le_bytes());
339 format!("a3s-code-context-v2-{:x}", hasher.finalize())
340}
341
342fn kind_label(kind: DurableMemoryKind) -> &'static str {
343 match kind {
344 DurableMemoryKind::Episodic => "episodic",
345 DurableMemoryKind::Semantic => "semantic",
346 DurableMemoryKind::Procedural => "procedural",
347 }
348}
349
350fn channel_label(channel: DurableMemoryRecallChannel) -> &'static str {
351 match channel {
352 DurableMemoryRecallChannel::Lexical => "lexical",
353 DurableMemoryRecallChannel::Semantic => "semantic",
354 DurableMemoryRecallChannel::Hybrid => "hybrid",
355 DurableMemoryRecallChannel::Related => "related",
356 }
357}
358
359pub(super) fn channel_rank(channel: DurableMemoryRecallChannel) -> u8 {
360 match channel {
361 DurableMemoryRecallChannel::Hybrid => 0,
362 DurableMemoryRecallChannel::Lexical => 1,
363 DurableMemoryRecallChannel::Semantic => 2,
364 DurableMemoryRecallChannel::Related => 3,
365 }
366}
367
368fn digest(content: &str) -> String {
369 format!("sha256:{:x}", Sha256::digest(content.as_bytes()))
370}
371
372fn admission_id(context_id: &str, node_id: &str, node_revision: u64) -> String {
373 let mut hasher = Sha256::new();
374 hasher.update(b"a3s.code.memory.admission.v1\0");
375 hasher.update(context_id.as_bytes());
376 hasher.update(b"\0");
377 hasher.update(node_id.as_bytes());
378 hasher.update(b"\0");
379 hasher.update(node_revision.to_le_bytes());
380 format!("a3s-code-admission-{:x}", hasher.finalize())
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 fn context_identity_is_deterministic_for_an_exact_tuple() {
389 let identity = durable_memory_context_id("session-a", "run-1", "invocation-a", 1);
390 assert_eq!(
391 identity,
392 durable_memory_context_id("session-a", "run-1", "invocation-a", 1)
393 );
394 assert_eq!(
395 identity,
396 "a3s-code-context-v2-92a5ba699eac46c932d2b4bc8e02a15603380336c7f2719ace2204b5e1301f5c"
397 );
398 }
399
400 #[test]
401 fn context_identity_separates_sessions_with_colliding_local_run_ids() {
402 assert_ne!(
403 durable_memory_context_id("session-a", "run-local-1", "invocation-a", 1),
404 durable_memory_context_id("session-b", "run-local-1", "invocation-a", 1)
405 );
406 }
407
408 #[test]
409 fn context_identity_separates_runs_and_is_repository_safe() {
410 let first = durable_memory_context_id("session-a", "run-1", "invocation-a", 1);
411 let second = durable_memory_context_id("session-a", "run-2", "invocation-a", 1);
412 assert_ne!(first, second);
413 assert!(first.starts_with("a3s-code-context-v2-"));
414 assert!(first.len() <= a3s_memory::repository::MAX_IDENTIFIER_BYTES);
415 }
416
417 #[test]
418 fn context_identity_separates_multiple_contexts_in_one_run() {
419 assert_ne!(
420 durable_memory_context_id("session-a", "run-1", "invocation-a", 1),
421 durable_memory_context_id("session-a", "run-1", "invocation-a", 2)
422 );
423 }
424
425 #[test]
426 fn context_identity_separates_reconstructed_invocations_with_reused_run_ids() {
427 assert_ne!(
428 durable_memory_context_id("session-a", "run-reused", "invocation-a", 1),
429 durable_memory_context_id("session-a", "run-reused", "invocation-b", 1)
430 );
431 }
432}