Skip to main content

rightkit_memory_client/backend/
conformance.rs

1//! Shared, implementation-neutral conformance runner.
2
3use crate::backend::contract::{CallContext, MemoryBackend};
4use crate::backend::error::{MemoryBackendError, MemoryBackendErrorKind};
5use crate::backend::types::{BackendKind, MemoryTier};
6use async_trait::async_trait;
7use std::time::Duration;
8use tokio_util::sync::CancellationToken;
9
10pub const REQUIRED_OPERATIONS: &[&str] = &[
11    "activity_json",
12    "delete",
13    "embedder_dim",
14    "entries",
15    "get_full",
16    "log_recall_with_replay",
17    "metrics_json",
18    "put",
19    "recall_scored",
20    "record_injections",
21    "remember",
22    "scopes",
23    "search",
24    "try_put",
25    "list",
26    "remember_consolidated",
27];
28
29pub const REQUIRED_ERROR_KINDS: &[MemoryBackendErrorKind] = &[
30    MemoryBackendErrorKind::Unavailable,
31    MemoryBackendErrorKind::Invalid,
32    MemoryBackendErrorKind::Denied,
33    MemoryBackendErrorKind::Timeout,
34    MemoryBackendErrorKind::Cancelled,
35    MemoryBackendErrorKind::Incompatible,
36    MemoryBackendErrorKind::Store,
37    MemoryBackendErrorKind::Internal,
38];
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct OperationObservation {
42    pub operation: String,
43    pub error_kind: Option<MemoryBackendErrorKind>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ConformanceFailure {
48    pub operation: String,
49    /// `None` means the operation was required to succeed.
50    pub expected: Option<MemoryBackendErrorKind>,
51    pub observed: Option<MemoryBackendErrorKind>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct TypedErrorObservation {
56    pub error_name: String,
57    pub expected: MemoryBackendErrorKind,
58    pub observed: Option<MemoryBackendErrorKind>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ConformanceReport {
63    pub backend: BackendKind,
64    pub operations: Vec<OperationObservation>,
65    pub failures: Vec<ConformanceFailure>,
66    pub typed_errors: Vec<TypedErrorObservation>,
67    pub cancellation_kind: Option<MemoryBackendErrorKind>,
68    pub deadline_kind: Option<MemoryBackendErrorKind>,
69    pub local_open_count: usize,
70    pub no_local_open: bool,
71}
72
73impl ConformanceReport {
74    pub fn passed(&self) -> bool {
75        self.failures.is_empty()
76            && self.operations.len() == REQUIRED_OPERATIONS.len()
77            && self.typed_errors.len() == REQUIRED_ERROR_KINDS.len()
78            && REQUIRED_ERROR_KINDS.iter().all(|expected| {
79                self.typed_errors
80                    .iter()
81                    .any(|case| case.expected == *expected && case.observed == Some(*expected))
82            })
83            && self.cancellation_kind == Some(MemoryBackendErrorKind::Cancelled)
84            && self.deadline_kind == Some(MemoryBackendErrorKind::Timeout)
85            && self.no_local_open
86    }
87}
88
89/// Concrete backends supply their own boundary fixtures through this contract.
90/// CR-010 deliberately does not construct storage or service clients.
91#[async_trait]
92pub trait ConformanceFactory: Send + Sync {
93    type Backend: MemoryBackend;
94
95    async fn create(&self) -> Result<Self::Backend, MemoryBackendError>;
96    fn local_open_count(&self) -> usize;
97    async fn typed_error_cases(&self) -> Vec<TypedErrorObservation>;
98}
99
100fn observe<T>(operation: &str, result: Result<T, MemoryBackendError>) -> OperationObservation {
101    OperationObservation {
102        operation: operation.to_string(),
103        error_kind: result.err().map(|error| error.kind()),
104    }
105}
106
107fn expected_error(
108    operation: &str,
109    expected: Option<MemoryBackendErrorKind>,
110    observed: Option<MemoryBackendErrorKind>,
111) -> Option<ConformanceFailure> {
112    (observed != expected).then(|| ConformanceFailure {
113        operation: operation.to_string(),
114        expected,
115        observed,
116    })
117}
118
119/// Build one backend through supplied factory, then invoke every operation.
120/// Unexpected operation errors fail the report; only missing-row `get_full`
121/// Store/Invalid is accepted as an explicit fresh-backend fixture outcome.
122/// Concrete factories own typed error fixtures, while cancellation, deadline,
123/// operation, & no-local-open probes remain shared here.
124pub async fn run_conformance<F>(factory: &F) -> Result<ConformanceReport, MemoryBackendError>
125where
126    F: ConformanceFactory,
127{
128    let backend = factory.create().await?;
129    let context = CallContext::unbounded();
130    let mut operations = Vec::with_capacity(REQUIRED_OPERATIONS.len());
131
132    operations.push(observe(
133        "activity_json",
134        backend.activity_json(&context, 20).await,
135    ));
136    operations.push(observe(
137        "delete",
138        backend.delete(&context, "conformance-missing").await,
139    ));
140    operations.push(observe(
141        "embedder_dim",
142        backend.embedder_dim(&context).await,
143    ));
144    operations.push(observe("entries", backend.entries(&context, 20).await));
145    operations.push(observe(
146        "get_full",
147        backend.get_full(&context, "conformance-missing").await,
148    ));
149    operations.push(observe(
150        "log_recall_with_replay",
151        backend
152            .log_recall_with_replay(
153                &context,
154                "1970-01-01T00:00:00Z",
155                None,
156                0,
157                0,
158                0,
159                0,
160                "conformance",
161                None,
162                None,
163                None,
164                None,
165                None,
166                None,
167                Some("actor"),
168                "test",
169                "[]",
170                "[]",
171            )
172            .await,
173    ));
174    operations.push(observe(
175        "metrics_json",
176        backend.metrics_json(&context).await,
177    ));
178    operations.push(observe(
179        "put",
180        backend
181            .put(
182                &context,
183                "conformance",
184                "fixture",
185                "global",
186                MemoryTier::Semantic,
187            )
188            .await,
189    ));
190    operations.push(observe(
191        "recall_scored",
192        backend.recall_scored(&context, "fixture", 5, &[]).await,
193    ));
194    operations.push(observe(
195        "record_injections",
196        backend.record_injections(&context, &[]).await,
197    ));
198    operations.push(observe(
199        "remember",
200        backend
201            .remember(&context, "fixture", vec!["conformance".into()])
202            .await,
203    ));
204    operations.push(observe("scopes", backend.scopes(&context).await));
205    operations.push(observe(
206        "search",
207        backend.search(&context, "fixture", 5).await,
208    ));
209    operations.push(observe(
210        "try_put",
211        backend
212            .try_put(
213                &context,
214                "conformance",
215                "fixture",
216                "global",
217                MemoryTier::Semantic,
218            )
219            .await,
220    ));
221    operations.push(observe("list", backend.list(&context, None).await));
222    operations.push(observe(
223        "remember_consolidated",
224        backend
225            .remember_consolidated(&context, "conformance", "fixture", vec![], 0.1)
226            .await,
227    ));
228
229    let cancellation = CancellationToken::new();
230    cancellation.cancel();
231    let cancelled = CallContext::unbounded().with_cancellation(cancellation);
232    let cancellation_kind = backend
233        .metrics_json(&cancelled)
234        .await
235        .err()
236        .map(|error| error.kind());
237
238    let expired = CallContext::after(Duration::ZERO);
239    let deadline_kind = backend
240        .metrics_json(&expired)
241        .await
242        .err()
243        .map(|error| error.kind());
244
245    let mut failures = Vec::new();
246    for observation in &operations {
247        let accepted_missing_row = observation.operation == "get_full"
248            && matches!(
249                observation.error_kind,
250                Some(MemoryBackendErrorKind::Store | MemoryBackendErrorKind::Invalid)
251            );
252        if observation.error_kind.is_some() && !accepted_missing_row {
253            failures.push(ConformanceFailure {
254                operation: observation.operation.clone(),
255                expected: None,
256                observed: observation.error_kind,
257            });
258        }
259    }
260    failures.extend(expected_error(
261        "cancellation",
262        Some(MemoryBackendErrorKind::Cancelled),
263        cancellation_kind,
264    ));
265    failures.extend(expected_error(
266        "deadline",
267        Some(MemoryBackendErrorKind::Timeout),
268        deadline_kind,
269    ));
270
271    let typed_errors = factory.typed_error_cases().await;
272    for case in &typed_errors {
273        if case.observed != Some(case.expected) {
274            failures.push(ConformanceFailure {
275                operation: format!("typed_error:{}", case.error_name),
276                expected: Some(case.expected),
277                observed: case.observed,
278            });
279        }
280    }
281
282    let local_open_count = factory.local_open_count();
283    let no_local_open = backend.binding().kind() != BackendKind::Service || local_open_count == 0;
284    if !no_local_open {
285        failures.push(ConformanceFailure {
286            operation: "no_local_open".into(),
287            expected: Some(MemoryBackendErrorKind::Unavailable),
288            observed: Some(MemoryBackendErrorKind::Store),
289        });
290    }
291
292    Ok(ConformanceReport {
293        backend: backend.binding().kind(),
294        operations,
295        failures,
296        typed_errors,
297        cancellation_kind,
298        deadline_kind,
299        local_open_count,
300        no_local_open,
301    })
302}