Skip to main content

a3s_code_core/durable_memory/
binding.rs

1use 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
10/// Schema version for the secret-free durable-memory binding persisted with a
11/// session snapshot.
12pub const DURABLE_MEMORY_BINDING_SCHEMA_VERSION: u32 = 4;
13
14/// Schema version for sessions that bind an exact semantic serving generation.
15pub const DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION: u32 = 5;
16
17/// Current A3S Memory query algorithm bound into new durable-memory sessions.
18pub 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/// Exact Code-visible durable-memory authority and serving policy.
35///
36/// The live repository is intentionally absent. Hosts own repository
37/// construction and must re-inject it after restart, while Code persists this
38/// descriptor to reject namespace, mode, recall-policy, query-semantics, or
39/// admission-identity drift.
40#[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}