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::ActiveRecall, Some(policy)) => {
225 DurableMemoryRecallPolicy::try_new(
226 policy.max_results(),
227 policy.min_lexical_score(),
228 )?
229 .try_with_related_lookups(policy.max_related_lookups())?;
230 Ok(())
231 }
232 (DurableMemoryMode::ActiveRecall, None) => Err(invalid(
233 "durableMemoryBinding.recallPolicy",
234 "active recall mode requires a recall policy",
235 )),
236 }
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use crate::durable_memory::{
244 DurableMemorySemanticRecall, DurableMemorySemanticRecallPolicy, DurableMemorySession,
245 };
246 use crate::embedding::{
247 EmbeddingBatchRequest, EmbeddingBatchResponse, EmbeddingExecutorConfig,
248 EmbeddingNormalization, EmbeddingProvider, EmbeddingProviderDescriptor,
249 EmbeddingProviderError,
250 };
251 use a3s_memory::repository::InMemoryRepository;
252 use a3s_memory::vector::{InMemoryVectorIndex, VectorIndex, VectorIndexDescriptor};
253 use std::sync::Arc;
254 use tokio_util::sync::CancellationToken;
255
256 struct DescriptorOnlyProvider;
257
258 #[async_trait::async_trait]
259 impl EmbeddingProvider for DescriptorOnlyProvider {
260 fn descriptor(&self) -> EmbeddingProviderDescriptor {
261 EmbeddingProviderDescriptor::new("fixture", "semantic-binding", 2)
262 .with_revision("fixture-r1")
263 .with_normalization(EmbeddingNormalization::Unit)
264 }
265
266 async fn embed(
267 &self,
268 _request: EmbeddingBatchRequest,
269 _cancellation: CancellationToken,
270 ) -> Result<EmbeddingBatchResponse, EmbeddingProviderError> {
271 Err(EmbeddingProviderError::InvalidRequest)
272 }
273 }
274
275 fn hybrid_binding() -> DurableMemoryBindingV1 {
276 let namespace = MemoryNamespace::try_new("tenant", "principal", "semantic").unwrap();
277 let repository = Arc::new(InMemoryRepository::new());
278 let index: Arc<dyn VectorIndex> =
279 Arc::new(InMemoryVectorIndex::new(VectorIndexDescriptor::new(2)).unwrap());
280 let semantic = DurableMemorySemanticRecall::new(
281 format!("sha256:{}", "a".repeat(64)),
282 Arc::new(DescriptorOnlyProvider),
283 EmbeddingExecutorConfig::default(),
284 index,
285 DurableMemorySemanticRecallPolicy::try_new(8, 0.7).unwrap(),
286 )
287 .unwrap();
288 DurableMemorySession::active_recall(
289 repository,
290 namespace,
291 DurableMemoryRecallPolicy::try_new(4, 0.2).unwrap(),
292 )
293 .with_semantic_recall(semantic)
294 .unwrap()
295 .binding()
296 }
297
298 #[test]
299 fn hybrid_schema_requires_a_valid_semantic_binding_and_active_mode() {
300 let binding = hybrid_binding();
301 assert_eq!(
302 binding.schema_version(),
303 DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION
304 );
305 binding.validate().unwrap();
306
307 let encoded = serde_json::to_value(&binding).unwrap();
308 let mut missing = encoded.clone();
309 missing.as_object_mut().unwrap().remove("semanticRecall");
310 let missing: DurableMemoryBindingV1 = serde_json::from_value(missing).unwrap();
311 assert!(missing.validate().is_err());
312
313 let mut legacy_with_semantic = encoded.clone();
314 legacy_with_semantic["schemaVersion"] =
315 serde_json::json!(DURABLE_MEMORY_BINDING_SCHEMA_VERSION);
316 let legacy_with_semantic: DurableMemoryBindingV1 =
317 serde_json::from_value(legacy_with_semantic).unwrap();
318 assert!(legacy_with_semantic.validate().is_err());
319
320 let mut shadow = encoded.clone();
321 shadow["mode"] = serde_json::json!("shadow_candidates");
322 assert!(
323 serde_json::from_value::<DurableMemoryBindingV1>(shadow).is_err(),
324 "shadow_candidates mode must fail closed at decode after HARNESS-CONV4"
325 );
326
327 let mut unsupported_fusion = encoded;
328 unsupported_fusion["semanticRecall"]["fusionProfile"] =
329 serde_json::json!("a3s.code.memory.hybrid.unknown.v1");
330 let unsupported_fusion: DurableMemoryBindingV1 =
331 serde_json::from_value(unsupported_fusion).unwrap();
332 assert!(unsupported_fusion.validate().is_err());
333
334 let mut unpinned_embedding = serde_json::to_value(&binding).unwrap();
335 unpinned_embedding["semanticRecall"]["embedding"]["revision"] = serde_json::Value::Null;
336 let unpinned_embedding: DurableMemoryBindingV1 =
337 serde_json::from_value(unpinned_embedding).unwrap();
338 assert!(unpinned_embedding.validate().is_err());
339
340 let mut mismatched_dimension = serde_json::to_value(&binding).unwrap();
341 mismatched_dimension["semanticRecall"]["vectorIndex"]["dimension"] = serde_json::json!(3);
342 let mismatched_dimension: DurableMemoryBindingV1 =
343 serde_json::from_value(mismatched_dimension).unwrap();
344 assert!(mismatched_dimension.validate().is_err());
345 }
346
347 #[test]
348 fn semantic_generation_identity_round_trips_and_detects_drift() {
349 let binding = hybrid_binding();
350 let encoded = serde_json::to_value(&binding).unwrap();
351 let round_trip: DurableMemoryBindingV1 = serde_json::from_value(encoded.clone()).unwrap();
352 assert_eq!(round_trip, binding);
353
354 let mut drifted = encoded;
355 drifted["semanticRecall"]["embedding"]["model"] = serde_json::json!("semantic-binding-v2");
356 let drifted: DurableMemoryBindingV1 = serde_json::from_value(drifted).unwrap();
357 drifted.validate().unwrap();
358 assert_ne!(drifted, binding);
359 }
360}