use super::{
invalid, DurableMemoryMode, DurableMemoryRecallPolicy, DurableMemorySemanticBindingV1,
DURABLE_MEMORY_CONTEXT_ID_PROFILE_V1, DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2,
};
use a3s_memory::repository::{
MemoryNamespace, MemoryRepositoryError, MEMORY_LEXICAL_QUERY_PROFILE_V1,
};
use serde::{Deserialize, Serialize};
pub const DURABLE_MEMORY_BINDING_SCHEMA_VERSION: u32 = 4;
pub const DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION: u32 = 5;
pub const DURABLE_MEMORY_RETRIEVAL_PROFILE_V1: &str = MEMORY_LEXICAL_QUERY_PROFILE_V1;
const LEGACY_WORD_RETRIEVAL_PROFILE_V1: &str = "a3s.memory.lexical.word.v1";
const LEGACY_HOST_CONTEXT_ID_PROFILE_V0: &str = "a3s.code.memory.context.host-id.v0";
const LEGACY_SESSION_RUN_CONTEXT_BINDING_SCHEMA_VERSION: u32 = 3;
const LEGACY_RETRIEVAL_BINDING_SCHEMA_VERSION: u32 = 2;
const LEGACY_BASE_BINDING_SCHEMA_VERSION: u32 = 1;
fn legacy_retrieval_profile() -> String {
LEGACY_WORD_RETRIEVAL_PROFILE_V1.to_string()
}
fn legacy_context_id_profile() -> String {
LEGACY_HOST_CONTEXT_ID_PROFILE_V0.to_string()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DurableMemoryBindingV1 {
schema_version: u32,
namespace: MemoryNamespace,
mode: DurableMemoryMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
recall_policy: Option<DurableMemoryRecallPolicy>,
#[serde(default = "legacy_retrieval_profile")]
retrieval_profile: String,
#[serde(default = "legacy_context_id_profile")]
context_id_profile: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
semantic_recall: Option<DurableMemorySemanticBindingV1>,
}
impl DurableMemoryBindingV1 {
pub(super) fn new(
namespace: MemoryNamespace,
mode: DurableMemoryMode,
recall_policy: Option<DurableMemoryRecallPolicy>,
semantic_recall: Option<DurableMemorySemanticBindingV1>,
) -> Self {
let schema_version = if semantic_recall.is_some() {
DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION
} else {
DURABLE_MEMORY_BINDING_SCHEMA_VERSION
};
Self {
schema_version,
namespace,
mode,
recall_policy,
retrieval_profile: DURABLE_MEMORY_RETRIEVAL_PROFILE_V1.to_string(),
context_id_profile: DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2.to_string(),
semantic_recall,
}
}
pub fn schema_version(&self) -> u32 {
self.schema_version
}
pub fn namespace(&self) -> &MemoryNamespace {
&self.namespace
}
pub fn mode(&self) -> DurableMemoryMode {
self.mode
}
pub fn recall_policy(&self) -> Option<DurableMemoryRecallPolicy> {
self.recall_policy
}
pub fn retrieval_profile(&self) -> &str {
&self.retrieval_profile
}
pub fn context_id_profile(&self) -> &str {
&self.context_id_profile
}
pub fn semantic_recall(&self) -> Option<&DurableMemorySemanticBindingV1> {
self.semantic_recall.as_ref()
}
pub(crate) fn validate(&self) -> Result<(), MemoryRepositoryError> {
if self.retrieval_profile != DURABLE_MEMORY_RETRIEVAL_PROFILE_V1
&& self.retrieval_profile != LEGACY_WORD_RETRIEVAL_PROFILE_V1
{
return Err(invalid(
"durableMemoryBinding.retrievalProfile",
format!(
"unrecognized durable-memory retrieval profile `{}`",
self.retrieval_profile
),
));
}
if self.context_id_profile != DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2
&& self.context_id_profile != DURABLE_MEMORY_CONTEXT_ID_PROFILE_V1
&& self.context_id_profile != LEGACY_HOST_CONTEXT_ID_PROFILE_V0
{
return Err(invalid(
"durableMemoryBinding.contextIdProfile",
format!(
"unrecognized durable-memory context identity profile `{}`",
self.context_id_profile
),
));
}
let (expected_retrieval_profile, expected_context_id_profile, expects_semantic) = match self
.schema_version
{
DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION => (
DURABLE_MEMORY_RETRIEVAL_PROFILE_V1,
DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2,
true,
),
DURABLE_MEMORY_BINDING_SCHEMA_VERSION => (
DURABLE_MEMORY_RETRIEVAL_PROFILE_V1,
DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2,
false,
),
LEGACY_SESSION_RUN_CONTEXT_BINDING_SCHEMA_VERSION => (
DURABLE_MEMORY_RETRIEVAL_PROFILE_V1,
DURABLE_MEMORY_CONTEXT_ID_PROFILE_V1,
false,
),
LEGACY_RETRIEVAL_BINDING_SCHEMA_VERSION => (
DURABLE_MEMORY_RETRIEVAL_PROFILE_V1,
LEGACY_HOST_CONTEXT_ID_PROFILE_V0,
false,
),
LEGACY_BASE_BINDING_SCHEMA_VERSION => (
LEGACY_WORD_RETRIEVAL_PROFILE_V1,
LEGACY_HOST_CONTEXT_ID_PROFILE_V0,
false,
),
_ => {
return Err(invalid(
"durableMemoryBinding.schemaVersion",
format!(
"unsupported schema version {}; expected {} or {}, legacy {}, legacy {}, or legacy {}",
self.schema_version,
DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION,
DURABLE_MEMORY_BINDING_SCHEMA_VERSION,
LEGACY_SESSION_RUN_CONTEXT_BINDING_SCHEMA_VERSION,
LEGACY_RETRIEVAL_BINDING_SCHEMA_VERSION,
LEGACY_BASE_BINDING_SCHEMA_VERSION
),
));
}
};
if self.retrieval_profile != expected_retrieval_profile {
return Err(invalid(
"durableMemoryBinding.retrievalProfile",
format!(
"retrieval profile `{}` is incompatible with schema version {}",
self.retrieval_profile, self.schema_version
),
));
}
if self.context_id_profile != expected_context_id_profile {
return Err(invalid(
"durableMemoryBinding.contextIdProfile",
format!(
"context identity profile `{}` is incompatible with schema version {}",
self.context_id_profile, self.schema_version
),
));
}
MemoryNamespace::try_new(
self.namespace.tenant_id(),
self.namespace.principal_id(),
self.namespace.scope_id(),
)?;
match (expects_semantic, self.semantic_recall.as_ref()) {
(true, Some(semantic)) => {
semantic.validate().map_err(|error| {
invalid("durableMemoryBinding.semanticRecall", error.to_string())
})?;
if self.mode != DurableMemoryMode::ActiveRecall {
return Err(invalid(
"durableMemoryBinding.mode",
"semantic recall requires active recall mode",
));
}
}
(true, None) => {
return Err(invalid(
"durableMemoryBinding.semanticRecall",
"hybrid schema requires an exact semantic recall binding",
));
}
(false, Some(_)) => {
return Err(invalid(
"durableMemoryBinding.semanticRecall",
"semantic recall is incompatible with this schema version",
));
}
(false, None) => {}
}
match (self.mode, self.recall_policy) {
(DurableMemoryMode::ShadowCandidates, None) => Ok(()),
(DurableMemoryMode::ActiveRecall, Some(policy)) => {
DurableMemoryRecallPolicy::try_new(
policy.max_results(),
policy.min_lexical_score(),
)?
.try_with_related_lookups(policy.max_related_lookups())?;
Ok(())
}
(DurableMemoryMode::ShadowCandidates, Some(_)) => Err(invalid(
"durableMemoryBinding.recallPolicy",
"shadow candidate mode must not carry a recall policy",
)),
(DurableMemoryMode::ActiveRecall, None) => Err(invalid(
"durableMemoryBinding.recallPolicy",
"active recall mode requires a recall policy",
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::durable_memory::{
DurableMemorySemanticRecall, DurableMemorySemanticRecallPolicy, DurableMemorySession,
};
use crate::embedding::{
EmbeddingBatchRequest, EmbeddingBatchResponse, EmbeddingExecutorConfig,
EmbeddingNormalization, EmbeddingProvider, EmbeddingProviderDescriptor,
EmbeddingProviderError,
};
use a3s_memory::repository::InMemoryRepository;
use a3s_memory::vector::{InMemoryVectorIndex, VectorIndex, VectorIndexDescriptor};
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
struct DescriptorOnlyProvider;
#[async_trait::async_trait]
impl EmbeddingProvider for DescriptorOnlyProvider {
fn descriptor(&self) -> EmbeddingProviderDescriptor {
EmbeddingProviderDescriptor::new("fixture", "semantic-binding", 2)
.with_revision("fixture-r1")
.with_normalization(EmbeddingNormalization::Unit)
}
async fn embed(
&self,
_request: EmbeddingBatchRequest,
_cancellation: CancellationToken,
) -> Result<EmbeddingBatchResponse, EmbeddingProviderError> {
Err(EmbeddingProviderError::InvalidRequest)
}
}
fn hybrid_binding() -> DurableMemoryBindingV1 {
let namespace = MemoryNamespace::try_new("tenant", "principal", "semantic").unwrap();
let repository = Arc::new(InMemoryRepository::new());
let index: Arc<dyn VectorIndex> =
Arc::new(InMemoryVectorIndex::new(VectorIndexDescriptor::new(2)).unwrap());
let semantic = DurableMemorySemanticRecall::new(
format!("sha256:{}", "a".repeat(64)),
Arc::new(DescriptorOnlyProvider),
EmbeddingExecutorConfig::default(),
index,
DurableMemorySemanticRecallPolicy::try_new(8, 0.7).unwrap(),
)
.unwrap();
DurableMemorySession::active_recall(
repository,
namespace,
DurableMemoryRecallPolicy::try_new(4, 0.2).unwrap(),
)
.with_semantic_recall(semantic)
.unwrap()
.binding()
}
#[test]
fn hybrid_schema_requires_a_valid_semantic_binding_and_active_mode() {
let binding = hybrid_binding();
assert_eq!(
binding.schema_version(),
DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION
);
binding.validate().unwrap();
let encoded = serde_json::to_value(&binding).unwrap();
let mut missing = encoded.clone();
missing.as_object_mut().unwrap().remove("semanticRecall");
let missing: DurableMemoryBindingV1 = serde_json::from_value(missing).unwrap();
assert!(missing.validate().is_err());
let mut legacy_with_semantic = encoded.clone();
legacy_with_semantic["schemaVersion"] =
serde_json::json!(DURABLE_MEMORY_BINDING_SCHEMA_VERSION);
let legacy_with_semantic: DurableMemoryBindingV1 =
serde_json::from_value(legacy_with_semantic).unwrap();
assert!(legacy_with_semantic.validate().is_err());
let mut shadow = encoded.clone();
shadow["mode"] = serde_json::json!("shadow_candidates");
let shadow: DurableMemoryBindingV1 = serde_json::from_value(shadow).unwrap();
assert!(shadow.validate().is_err());
let mut unsupported_fusion = encoded;
unsupported_fusion["semanticRecall"]["fusionProfile"] =
serde_json::json!("a3s.code.memory.hybrid.unknown.v1");
let unsupported_fusion: DurableMemoryBindingV1 =
serde_json::from_value(unsupported_fusion).unwrap();
assert!(unsupported_fusion.validate().is_err());
let mut unpinned_embedding = serde_json::to_value(&binding).unwrap();
unpinned_embedding["semanticRecall"]["embedding"]["revision"] = serde_json::Value::Null;
let unpinned_embedding: DurableMemoryBindingV1 =
serde_json::from_value(unpinned_embedding).unwrap();
assert!(unpinned_embedding.validate().is_err());
let mut mismatched_dimension = serde_json::to_value(&binding).unwrap();
mismatched_dimension["semanticRecall"]["vectorIndex"]["dimension"] = serde_json::json!(3);
let mismatched_dimension: DurableMemoryBindingV1 =
serde_json::from_value(mismatched_dimension).unwrap();
assert!(mismatched_dimension.validate().is_err());
}
#[test]
fn semantic_generation_identity_round_trips_and_detects_drift() {
let binding = hybrid_binding();
let encoded = serde_json::to_value(&binding).unwrap();
let round_trip: DurableMemoryBindingV1 = serde_json::from_value(encoded.clone()).unwrap();
assert_eq!(round_trip, binding);
let mut drifted = encoded;
drifted["semanticRecall"]["embedding"]["model"] = serde_json::json!("semantic-binding-v2");
let drifted: DurableMemoryBindingV1 = serde_json::from_value(drifted).unwrap();
drifted.validate().unwrap();
assert_ne!(drifted, binding);
}
}