converge_core/traits/recall.rs
1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! # Recall Capability Traits
5//!
6//! This module defines the capability boundary traits for Recall (semantic
7//! memory). Recall provides **hints** to guide reasoning, not citations to
8//! justify claims.
9//!
10//! ## Split Trait Pattern
11//!
12//! Recall is split by authority boundary:
13//!
14//! - [`RecallReader`]: Query-only read access for validation, audit, replay
15//! - [`RecallWriter`]: Store/delete mutation for ingestion pipelines
16//! - [`Recall`]: Umbrella combining RecallReader + RecallWriter
17//!
18//! This separation enables read-only contexts (validators, auditors) to depend
19//! only on `RecallReader`, preventing accidental mutations.
20//!
21//! ## GAT Async Pattern
22//!
23//! All traits use Generic Associated Types (GATs) for zero-cost async:
24//!
25//! ```ignore
26//! pub trait RecallReader: Send + Sync {
27//! type QueryFut<'a>: Future<Output = Result<Vec<RecallCandidate>, RecallError>> + Send + 'a
28//! where
29//! Self: 'a;
30//!
31//! fn query<'a>(&'a self, query: &'a RecallQuery) -> Self::QueryFut<'a>;
32//! }
33//! ```
34//!
35//! This enables static dispatch async without `async_trait` proc macros or
36//! tokio runtime dependency in converge-core.
37//!
38//! ## Thread Safety
39//!
40//! All traits require `Send + Sync` to enable use in concurrent contexts.
41//! Implementations with non-thread-safe state use `Arc<Mutex<...>>`.
42//!
43//! ## Error Handling
44//!
45//! [`RecallError`] implements [`CapabilityError`] for generic retry logic.
46//! It provides `is_transient()` and `is_retryable()` classification.
47
48use std::future::Future;
49use std::time::Duration;
50
51use super::error::{CapabilityError, ErrorCategory};
52use crate::recall::{RecallCandidate, RecallQuery};
53
54// ============================================================================
55// Recall Error
56// ============================================================================
57
58/// Error type for recall operations.
59///
60/// All variants implement [`CapabilityError`] for generic error handling.
61#[derive(Debug, Clone)]
62pub enum RecallError {
63 /// Index or vector store is temporarily unavailable.
64 IndexUnavailable {
65 /// Human-readable description of the unavailability.
66 message: String,
67 },
68 /// Query embedding dimensions don't match index dimensions.
69 DimensionMismatch {
70 /// Expected dimension count.
71 expected: usize,
72 /// Actual dimension count from query.
73 got: usize,
74 },
75 /// Embedding operation failed (e.g., rate limited, timeout).
76 EmbeddingFailed {
77 /// Human-readable error message.
78 message: String,
79 /// Whether this failure is transient.
80 transient: bool,
81 },
82 /// Query was malformed or invalid.
83 InvalidQuery {
84 /// Description of what was invalid.
85 message: String,
86 },
87 /// Authentication with the recall backend failed.
88 AuthFailed {
89 /// Human-readable error message.
90 message: String,
91 },
92 /// Rate limit exceeded; retry after delay.
93 RateLimited {
94 /// Suggested delay before retry.
95 retry_after: Duration,
96 },
97 /// Operation timed out.
98 Timeout {
99 /// How long the operation ran before timing out.
100 elapsed: Duration,
101 /// The configured deadline.
102 deadline: Duration,
103 },
104 /// Record not found for store/delete operation.
105 NotFound {
106 /// The ID that was not found.
107 id: String,
108 },
109 /// Internal error with no specific category.
110 Internal {
111 /// Human-readable error message.
112 message: String,
113 },
114}
115
116impl std::fmt::Display for RecallError {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 match self {
119 Self::IndexUnavailable { message } => {
120 write!(f, "recall index unavailable: {}", message)
121 }
122 Self::DimensionMismatch { expected, got } => {
123 write!(
124 f,
125 "dimension mismatch: expected {} dimensions, got {}",
126 expected, got
127 )
128 }
129 Self::EmbeddingFailed { message, .. } => {
130 write!(f, "embedding failed: {}", message)
131 }
132 Self::InvalidQuery { message } => {
133 write!(f, "invalid recall query: {}", message)
134 }
135 Self::AuthFailed { message } => {
136 write!(f, "recall auth failed: {}", message)
137 }
138 Self::RateLimited { retry_after } => {
139 write!(f, "rate limited, retry after {:?}", retry_after)
140 }
141 Self::Timeout { elapsed, deadline } => {
142 write!(
143 f,
144 "recall operation timed out after {:?} (deadline: {:?})",
145 elapsed, deadline
146 )
147 }
148 Self::NotFound { id } => {
149 write!(f, "recall record not found: {}", id)
150 }
151 Self::Internal { message } => {
152 write!(f, "internal recall error: {}", message)
153 }
154 }
155 }
156}
157
158impl std::error::Error for RecallError {}
159
160impl CapabilityError for RecallError {
161 fn category(&self) -> ErrorCategory {
162 match self {
163 Self::IndexUnavailable { .. } => ErrorCategory::Unavailable,
164 Self::DimensionMismatch { .. } => ErrorCategory::InvalidInput,
165 Self::EmbeddingFailed { .. } => ErrorCategory::Internal,
166 Self::InvalidQuery { .. } => ErrorCategory::InvalidInput,
167 Self::AuthFailed { .. } => ErrorCategory::Auth,
168 Self::RateLimited { .. } => ErrorCategory::RateLimit,
169 Self::Timeout { .. } => ErrorCategory::Timeout,
170 Self::NotFound { .. } => ErrorCategory::NotFound,
171 Self::Internal { .. } => ErrorCategory::Internal,
172 }
173 }
174
175 fn is_transient(&self) -> bool {
176 match self {
177 Self::IndexUnavailable { .. } => true,
178 Self::DimensionMismatch { .. } => false,
179 Self::EmbeddingFailed { transient, .. } => *transient,
180 Self::InvalidQuery { .. } => false,
181 Self::AuthFailed { .. } => false,
182 Self::RateLimited { .. } => true,
183 Self::Timeout { .. } => true,
184 Self::NotFound { .. } => false,
185 Self::Internal { .. } => false,
186 }
187 }
188
189 fn is_retryable(&self) -> bool {
190 self.is_transient()
191 }
192
193 fn retry_after(&self) -> Option<Duration> {
194 match self {
195 Self::RateLimited { retry_after } => Some(*retry_after),
196 _ => None,
197 }
198 }
199}
200
201// ============================================================================
202// Recall Record (for storage operations)
203// ============================================================================
204
205/// A record to store in the recall index.
206///
207/// This type is used by [`RecallWriter::store`] to add new records to the
208/// semantic memory. It contains the content, embedding, and metadata needed
209/// for later retrieval.
210#[derive(Debug, Clone)]
211pub struct RecallRecord {
212 /// Unique identifier for this record.
213 pub id: String,
214 /// The text content to store.
215 pub content: String,
216 /// Pre-computed embedding vector (optional; backend may compute if missing).
217 pub embedding: Option<Vec<f32>>,
218 /// Source metadata for provenance.
219 pub metadata: RecallRecordMetadata,
220}
221
222/// Metadata for a recall record.
223#[derive(Debug, Clone, Default)]
224pub struct RecallRecordMetadata {
225 /// Source type (e.g., "failure", "runbook", "adapter").
226 pub source_type: Option<String>,
227 /// Source chain ID if from a chain execution.
228 pub source_chain_id: Option<String>,
229 /// Tenant scope for multi-tenant isolation.
230 pub tenant_id: Option<String>,
231 /// ISO 8601 timestamp when record was created.
232 pub created_at: Option<String>,
233}
234
235// ============================================================================
236// Recall Traits
237// ============================================================================
238
239/// Read-only recall capability trait.
240///
241/// This trait provides query access to semantic memory. It is designed for
242/// read-only contexts like validators, auditors, and replay engines.
243///
244/// # Thread Safety
245///
246/// Implementations must be `Send + Sync` for use in concurrent contexts.
247///
248/// # GAT Async Pattern
249///
250/// The `QueryFut` associated type enables static dispatch async:
251///
252/// ```ignore
253/// impl RecallReader for MyRecallBackend {
254/// type QueryFut<'a> = impl Future<Output = Result<Vec<RecallCandidate>, RecallError>> + Send + 'a;
255///
256/// fn query<'a>(&'a self, query: &'a RecallQuery) -> Self::QueryFut<'a> {
257/// async move {
258/// // ... implementation
259/// }
260/// }
261/// }
262/// ```
263pub trait RecallReader: Send + Sync {
264 /// Future type for query operations.
265 type QueryFut<'a>: Future<Output = Result<Vec<RecallCandidate>, RecallError>> + Send + 'a
266 where
267 Self: 'a;
268
269 /// Query the recall index for similar content.
270 ///
271 /// # Arguments
272 ///
273 /// * `query` - The recall query containing search text and parameters
274 ///
275 /// # Returns
276 ///
277 /// A vector of recall candidates ranked by relevance.
278 fn query<'a>(&'a self, query: &'a RecallQuery) -> Self::QueryFut<'a>;
279}
280
281/// Write access recall capability trait.
282///
283/// This trait provides mutation access to semantic memory. It is designed for
284/// ingestion pipelines and administrative operations.
285///
286/// # Authority Boundary
287///
288/// Store and delete operations are governance boundaries. Most runtime contexts
289/// should use only [`RecallReader`] to prevent accidental data modification.
290///
291/// # Thread Safety
292///
293/// Implementations must be `Send + Sync` for use in concurrent contexts.
294pub trait RecallWriter: Send + Sync {
295 /// Future type for store operations.
296 type StoreFut<'a>: Future<Output = Result<(), RecallError>> + Send + 'a
297 where
298 Self: 'a;
299
300 /// Future type for delete operations.
301 type DeleteFut<'a>: Future<Output = Result<(), RecallError>> + Send + 'a
302 where
303 Self: 'a;
304
305 /// Store a record in the recall index.
306 ///
307 /// # Arguments
308 ///
309 /// * `record` - The recall record to store
310 ///
311 /// # Idempotency
312 ///
313 /// Storing a record with the same ID should overwrite the existing record.
314 fn store<'a>(&'a self, record: RecallRecord) -> Self::StoreFut<'a>;
315
316 /// Delete a record from the recall index by ID.
317 ///
318 /// # Arguments
319 ///
320 /// * `id` - The ID of the record to delete
321 ///
322 /// # Errors
323 ///
324 /// Returns `RecallError::NotFound` if the record does not exist.
325 fn delete<'a>(&'a self, id: &'a str) -> Self::DeleteFut<'a>;
326}
327
328/// Umbrella trait combining read and write recall capabilities.
329///
330/// This trait is for contexts that need both read and write access.
331/// Most contexts should prefer the narrower [`RecallReader`] or
332/// [`RecallWriter`] traits.
333///
334/// # Blanket Implementation
335///
336/// Any type implementing both `RecallReader` and `RecallWriter` automatically
337/// implements `Recall`.
338pub trait Recall: RecallReader + RecallWriter {}
339
340// Blanket impl: any type with both Reader and Writer is Recall
341impl<T: RecallReader + RecallWriter> Recall for T {}
342
343// ============================================================================
344// Dyn-Safe Wrapper (for runtime polymorphism)
345// ============================================================================
346
347/// Boxed future type for dyn-safe trait objects.
348pub type BoxFuture<'a, T> = std::pin::Pin<Box<dyn Future<Output = T> + Send + 'a>>;
349
350/// Dyn-safe recall reader trait for runtime polymorphism.
351///
352/// Use this when you need `dyn RecallReader` (e.g., heterogeneous backends,
353/// plugin systems). The cost is one heap allocation per call.
354///
355/// # Blanket Implementation
356///
357/// All `RecallReader` implementations automatically implement `DynRecallReader`.
358pub trait DynRecallReader: Send + Sync {
359 /// Query the recall index for similar content.
360 fn query<'a>(
361 &'a self,
362 query: &'a RecallQuery,
363 ) -> BoxFuture<'a, Result<Vec<RecallCandidate>, RecallError>>;
364}
365
366impl<T: RecallReader> DynRecallReader for T {
367 fn query<'a>(
368 &'a self,
369 query: &'a RecallQuery,
370 ) -> BoxFuture<'a, Result<Vec<RecallCandidate>, RecallError>> {
371 Box::pin(RecallReader::query(self, query))
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn recall_error_display() {
381 let err = RecallError::DimensionMismatch {
382 expected: 1536,
383 got: 768,
384 };
385 assert!(err.to_string().contains("1536"));
386 assert!(err.to_string().contains("768"));
387 }
388
389 #[test]
390 fn recall_error_category_classification() {
391 assert_eq!(
392 RecallError::IndexUnavailable {
393 message: "test".to_string()
394 }
395 .category(),
396 ErrorCategory::Unavailable
397 );
398 assert_eq!(
399 RecallError::DimensionMismatch {
400 expected: 1536,
401 got: 768
402 }
403 .category(),
404 ErrorCategory::InvalidInput
405 );
406 assert_eq!(
407 RecallError::AuthFailed {
408 message: "test".to_string()
409 }
410 .category(),
411 ErrorCategory::Auth
412 );
413 assert_eq!(
414 RecallError::RateLimited {
415 retry_after: Duration::from_secs(60)
416 }
417 .category(),
418 ErrorCategory::RateLimit
419 );
420 }
421
422 #[test]
423 fn recall_error_transient_classification() {
424 assert!(
425 RecallError::IndexUnavailable {
426 message: "test".to_string()
427 }
428 .is_transient()
429 );
430 assert!(
431 RecallError::RateLimited {
432 retry_after: Duration::from_secs(60)
433 }
434 .is_transient()
435 );
436 assert!(
437 RecallError::Timeout {
438 elapsed: Duration::from_secs(30),
439 deadline: Duration::from_secs(30),
440 }
441 .is_transient()
442 );
443
444 assert!(
445 !RecallError::DimensionMismatch {
446 expected: 1536,
447 got: 768
448 }
449 .is_transient()
450 );
451 assert!(
452 !RecallError::AuthFailed {
453 message: "test".to_string()
454 }
455 .is_transient()
456 );
457 assert!(
458 !RecallError::NotFound {
459 id: "test".to_string()
460 }
461 .is_transient()
462 );
463 }
464
465 #[test]
466 fn recall_error_retry_after() {
467 let err = RecallError::RateLimited {
468 retry_after: Duration::from_secs(60),
469 };
470 assert_eq!(err.retry_after(), Some(Duration::from_secs(60)));
471
472 let err2 = RecallError::IndexUnavailable {
473 message: "test".to_string(),
474 };
475 assert_eq!(err2.retry_after(), None);
476 }
477
478 #[test]
479 fn recall_record_metadata_default() {
480 let meta = RecallRecordMetadata::default();
481 assert!(meta.source_type.is_none());
482 assert!(meta.tenant_id.is_none());
483 }
484}