Skip to main content

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}