a3s_code_core/durable_memory/
binding.rs1use super::{
2 invalid, DurableMemoryMode, DurableMemoryRecallPolicy, DurableMemorySemanticBindingV1,
3 DURABLE_MEMORY_CONTEXT_ID_PROFILE_V1, DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2,
4};
5use a3s_memory::repository::{
6 MemoryNamespace, MemoryRepositoryError, MEMORY_LEXICAL_QUERY_PROFILE_V1,
7};
8use serde::{Deserialize, Serialize};
9
10pub const DURABLE_MEMORY_BINDING_SCHEMA_VERSION: u32 = 4;
13
14pub const DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION: u32 = 5;
16
17pub const DURABLE_MEMORY_RETRIEVAL_PROFILE_V1: &str = MEMORY_LEXICAL_QUERY_PROFILE_V1;
19
20const LEGACY_WORD_RETRIEVAL_PROFILE_V1: &str = "a3s.memory.lexical.word.v1";
21const LEGACY_HOST_CONTEXT_ID_PROFILE_V0: &str = "a3s.code.memory.context.host-id.v0";
22const LEGACY_SESSION_RUN_CONTEXT_BINDING_SCHEMA_VERSION: u32 = 3;
23const LEGACY_RETRIEVAL_BINDING_SCHEMA_VERSION: u32 = 2;
24const LEGACY_BASE_BINDING_SCHEMA_VERSION: u32 = 1;
25
26fn legacy_retrieval_profile() -> String {
27 LEGACY_WORD_RETRIEVAL_PROFILE_V1.to_string()
28}
29
30fn legacy_context_id_profile() -> String {
31 LEGACY_HOST_CONTEXT_ID_PROFILE_V0.to_string()
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct DurableMemoryBindingV1 {
43 schema_version: u32,
44 namespace: MemoryNamespace,
45 mode: DurableMemoryMode,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 recall_policy: Option<DurableMemoryRecallPolicy>,
48 #[serde(default = "legacy_retrieval_profile")]
49 retrieval_profile: String,
50 #[serde(default = "legacy_context_id_profile")]
51 context_id_profile: String,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 semantic_recall: Option<DurableMemorySemanticBindingV1>,
54}
55
56impl DurableMemoryBindingV1 {
57 pub(super) fn new(
58 namespace: MemoryNamespace,
59 mode: DurableMemoryMode,
60 recall_policy: Option<DurableMemoryRecallPolicy>,
61 semantic_recall: Option<DurableMemorySemanticBindingV1>,
62 ) -> Self {
63 let schema_version = if semantic_recall.is_some() {
64 DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION
65 } else {
66 DURABLE_MEMORY_BINDING_SCHEMA_VERSION
67 };
68 Self {
69 schema_version,
70 namespace,
71 mode,
72 recall_policy,
73 retrieval_profile: DURABLE_MEMORY_RETRIEVAL_PROFILE_V1.to_string(),
74 context_id_profile: DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2.to_string(),
75 semantic_recall,
76 }
77 }
78
79 pub fn schema_version(&self) -> u32 {
80 self.schema_version
81 }
82
83 pub fn namespace(&self) -> &MemoryNamespace {
84 &self.namespace
85 }
86
87 pub fn mode(&self) -> DurableMemoryMode {
88 self.mode
89 }
90
91 pub fn recall_policy(&self) -> Option<DurableMemoryRecallPolicy> {
92 self.recall_policy
93 }
94
95 pub fn retrieval_profile(&self) -> &str {
96 &self.retrieval_profile
97 }
98
99 pub fn context_id_profile(&self) -> &str {
100 &self.context_id_profile
101 }
102
103 pub fn semantic_recall(&self) -> Option<&DurableMemorySemanticBindingV1> {
104 self.semantic_recall.as_ref()
105 }
106
107 pub(crate) fn validate(&self) -> Result<(), MemoryRepositoryError> {
108 if self.retrieval_profile != DURABLE_MEMORY_RETRIEVAL_PROFILE_V1
109 && self.retrieval_profile != LEGACY_WORD_RETRIEVAL_PROFILE_V1
110 {
111 return Err(invalid(
112 "durableMemoryBinding.retrievalProfile",
113 format!(
114 "unrecognized durable-memory retrieval profile `{}`",
115 self.retrieval_profile
116 ),
117 ));
118 }
119 if self.context_id_profile != DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2
120 && self.context_id_profile != DURABLE_MEMORY_CONTEXT_ID_PROFILE_V1
121 && self.context_id_profile != LEGACY_HOST_CONTEXT_ID_PROFILE_V0
122 {
123 return Err(invalid(
124 "durableMemoryBinding.contextIdProfile",
125 format!(
126 "unrecognized durable-memory context identity profile `{}`",
127 self.context_id_profile
128 ),
129 ));
130 }
131 let (expected_retrieval_profile, expected_context_id_profile, expects_semantic) = match self
132 .schema_version
133 {
134 DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION => (
135 DURABLE_MEMORY_RETRIEVAL_PROFILE_V1,
136 DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2,
137 true,
138 ),
139 DURABLE_MEMORY_BINDING_SCHEMA_VERSION => (
140 DURABLE_MEMORY_RETRIEVAL_PROFILE_V1,
141 DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2,
142 false,
143 ),
144 LEGACY_SESSION_RUN_CONTEXT_BINDING_SCHEMA_VERSION => (
145 DURABLE_MEMORY_RETRIEVAL_PROFILE_V1,
146 DURABLE_MEMORY_CONTEXT_ID_PROFILE_V1,
147 false,
148 ),
149 LEGACY_RETRIEVAL_BINDING_SCHEMA_VERSION => (
150 DURABLE_MEMORY_RETRIEVAL_PROFILE_V1,
151 LEGACY_HOST_CONTEXT_ID_PROFILE_V0,
152 false,
153 ),
154 LEGACY_BASE_BINDING_SCHEMA_VERSION => (
155 LEGACY_WORD_RETRIEVAL_PROFILE_V1,
156 LEGACY_HOST_CONTEXT_ID_PROFILE_V0,
157 false,
158 ),
159 _ => {
160 return Err(invalid(
161 "durableMemoryBinding.schemaVersion",
162 format!(
163 "unsupported schema version {}; expected {} or {}, legacy {}, legacy {}, or legacy {}",
164 self.schema_version,
165 DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION,
166 DURABLE_MEMORY_BINDING_SCHEMA_VERSION,
167 LEGACY_SESSION_RUN_CONTEXT_BINDING_SCHEMA_VERSION,
168 LEGACY_RETRIEVAL_BINDING_SCHEMA_VERSION,
169 LEGACY_BASE_BINDING_SCHEMA_VERSION
170 ),
171 ));
172 }
173 };
174 if self.retrieval_profile != expected_retrieval_profile {
175 return Err(invalid(
176 "durableMemoryBinding.retrievalProfile",
177 format!(
178 "retrieval profile `{}` is incompatible with schema version {}",
179 self.retrieval_profile, self.schema_version
180 ),
181 ));
182 }
183 if self.context_id_profile != expected_context_id_profile {
184 return Err(invalid(
185 "durableMemoryBinding.contextIdProfile",
186 format!(
187 "context identity profile `{}` is incompatible with schema version {}",
188 self.context_id_profile, self.schema_version
189 ),
190 ));
191 }
192 MemoryNamespace::try_new(
193 self.namespace.tenant_id(),
194 self.namespace.principal_id(),
195 self.namespace.scope_id(),
196 )?;
197 match (expects_semantic, self.semantic_recall.as_ref()) {
198 (true, Some(semantic)) => {
199 semantic.validate().map_err(|error| {
200 invalid("durableMemoryBinding.semanticRecall", error.to_string())
201 })?;
202 if self.mode != DurableMemoryMode::ActiveRecall {
203 return Err(invalid(
204 "durableMemoryBinding.mode",
205 "semantic recall requires active recall mode",
206 ));
207 }
208 }
209 (true, None) => {
210 return Err(invalid(
211 "durableMemoryBinding.semanticRecall",
212 "hybrid schema requires an exact semantic recall binding",
213 ));
214 }
215 (false, Some(_)) => {
216 return Err(invalid(
217 "durableMemoryBinding.semanticRecall",
218 "semantic recall is incompatible with this schema version",
219 ));
220 }
221 (false, None) => {}
222 }
223 match (self.mode, self.recall_policy) {
224 (DurableMemoryMode::ShadowCandidates, None) => Ok(()),
225 (DurableMemoryMode::ActiveRecall, Some(policy)) => {
226 DurableMemoryRecallPolicy::try_new(
227 policy.max_results(),
228 policy.min_lexical_score(),
229 )?
230 .try_with_related_lookups(policy.max_related_lookups())?;
231 Ok(())
232 }
233 (DurableMemoryMode::ShadowCandidates, Some(_)) => Err(invalid(
234 "durableMemoryBinding.recallPolicy",
235 "shadow candidate mode must not carry a recall policy",
236 )),
237 (DurableMemoryMode::ActiveRecall, None) => Err(invalid(
238 "durableMemoryBinding.recallPolicy",
239 "active recall mode requires a recall policy",
240 )),
241 }
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use crate::durable_memory::{
249 DurableMemorySemanticRecall, DurableMemorySemanticRecallPolicy, DurableMemorySession,
250 };
251 use crate::embedding::{
252 EmbeddingBatchRequest, EmbeddingBatchResponse, EmbeddingExecutorConfig,
253 EmbeddingNormalization, EmbeddingProvider, EmbeddingProviderDescriptor,
254 EmbeddingProviderError,
255 };
256 use a3s_memory::repository::InMemoryRepository;
257 use a3s_memory::vector::{InMemoryVectorIndex, VectorIndex, VectorIndexDescriptor};
258 use std::sync::Arc;
259 use tokio_util::sync::CancellationToken;
260
261 struct DescriptorOnlyProvider;
262
263 #[async_trait::async_trait]
264 impl EmbeddingProvider for DescriptorOnlyProvider {
265 fn descriptor(&self) -> EmbeddingProviderDescriptor {
266 EmbeddingProviderDescriptor::new("fixture", "semantic-binding", 2)
267 .with_revision("fixture-r1")
268 .with_normalization(EmbeddingNormalization::Unit)
269 }
270
271 async fn embed(
272 &self,
273 _request: EmbeddingBatchRequest,
274 _cancellation: CancellationToken,
275 ) -> Result<EmbeddingBatchResponse, EmbeddingProviderError> {
276 Err(EmbeddingProviderError::InvalidRequest)
277 }
278 }
279
280 fn hybrid_binding() -> DurableMemoryBindingV1 {
281 let namespace = MemoryNamespace::try_new("tenant", "principal", "semantic").unwrap();
282 let repository = Arc::new(InMemoryRepository::new());
283 let index: Arc<dyn VectorIndex> =
284 Arc::new(InMemoryVectorIndex::new(VectorIndexDescriptor::new(2)).unwrap());
285 let semantic = DurableMemorySemanticRecall::new(
286 format!("sha256:{}", "a".repeat(64)),
287 Arc::new(DescriptorOnlyProvider),
288 EmbeddingExecutorConfig::default(),
289 index,
290 DurableMemorySemanticRecallPolicy::try_new(8, 0.7).unwrap(),
291 )
292 .unwrap();
293 DurableMemorySession::active_recall(
294 repository,
295 namespace,
296 DurableMemoryRecallPolicy::try_new(4, 0.2).unwrap(),
297 )
298 .with_semantic_recall(semantic)
299 .unwrap()
300 .binding()
301 }
302
303 #[test]
304 fn hybrid_schema_requires_a_valid_semantic_binding_and_active_mode() {
305 let binding = hybrid_binding();
306 assert_eq!(
307 binding.schema_version(),
308 DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION
309 );
310 binding.validate().unwrap();
311
312 let encoded = serde_json::to_value(&binding).unwrap();
313 let mut missing = encoded.clone();
314 missing.as_object_mut().unwrap().remove("semanticRecall");
315 let missing: DurableMemoryBindingV1 = serde_json::from_value(missing).unwrap();
316 assert!(missing.validate().is_err());
317
318 let mut legacy_with_semantic = encoded.clone();
319 legacy_with_semantic["schemaVersion"] =
320 serde_json::json!(DURABLE_MEMORY_BINDING_SCHEMA_VERSION);
321 let legacy_with_semantic: DurableMemoryBindingV1 =
322 serde_json::from_value(legacy_with_semantic).unwrap();
323 assert!(legacy_with_semantic.validate().is_err());
324
325 let mut shadow = encoded.clone();
326 shadow["mode"] = serde_json::json!("shadow_candidates");
327 let shadow: DurableMemoryBindingV1 = serde_json::from_value(shadow).unwrap();
328 assert!(shadow.validate().is_err());
329
330 let mut unsupported_fusion = encoded;
331 unsupported_fusion["semanticRecall"]["fusionProfile"] =
332 serde_json::json!("a3s.code.memory.hybrid.unknown.v1");
333 let unsupported_fusion: DurableMemoryBindingV1 =
334 serde_json::from_value(unsupported_fusion).unwrap();
335 assert!(unsupported_fusion.validate().is_err());
336
337 let mut unpinned_embedding = serde_json::to_value(&binding).unwrap();
338 unpinned_embedding["semanticRecall"]["embedding"]["revision"] = serde_json::Value::Null;
339 let unpinned_embedding: DurableMemoryBindingV1 =
340 serde_json::from_value(unpinned_embedding).unwrap();
341 assert!(unpinned_embedding.validate().is_err());
342
343 let mut mismatched_dimension = serde_json::to_value(&binding).unwrap();
344 mismatched_dimension["semanticRecall"]["vectorIndex"]["dimension"] = serde_json::json!(3);
345 let mismatched_dimension: DurableMemoryBindingV1 =
346 serde_json::from_value(mismatched_dimension).unwrap();
347 assert!(mismatched_dimension.validate().is_err());
348 }
349
350 #[test]
351 fn semantic_generation_identity_round_trips_and_detects_drift() {
352 let binding = hybrid_binding();
353 let encoded = serde_json::to_value(&binding).unwrap();
354 let round_trip: DurableMemoryBindingV1 = serde_json::from_value(encoded.clone()).unwrap();
355 assert_eq!(round_trip, binding);
356
357 let mut drifted = encoded;
358 drifted["semanticRecall"]["embedding"]["model"] = serde_json::json!("semantic-binding-v2");
359 let drifted: DurableMemoryBindingV1 = serde_json::from_value(drifted).unwrap();
360 drifted.validate().unwrap();
361 assert_ne!(drifted, binding);
362 }
363}