Skip to main content

pulsedb/search/
context.rs

1//! Context candidates retrieval types.
2//!
3//! [`ContextRequest`] and [`ContextCandidates`] enable a single call to
4//! [`PulseDB::get_context_candidates()`](crate::PulseDB::get_context_candidates)
5//! that orchestrates all retrieval primitives (similarity search, recent
6//! experiences, insights, relations, active agents) into one response.
7
8use crate::activity::Activity;
9use crate::config::RecallWeights;
10use crate::experience::Experience;
11use crate::insight::DerivedInsight;
12use crate::relation::ExperienceRelation;
13use crate::search::{SearchFilter, SearchResult};
14use crate::types::CollectiveId;
15
16/// Request for unified context retrieval.
17///
18/// Configures which primitives to query and how many results to return.
19/// Pass this to [`PulseDB::get_context_candidates()`](crate::PulseDB::get_context_candidates).
20///
21/// # Required Fields
22///
23/// - `collective_id` - The collective to search within (must exist)
24/// - `query_embedding` - Embedding vector for similarity and insight search
25///   (must match the collective's embedding dimension)
26///
27/// # Example
28///
29/// ```rust
30/// # fn main() -> pulsedb::Result<()> {
31/// # let dir = tempfile::tempdir().unwrap();
32/// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
33/// # let collective_id = db.create_collective("example")?;
34/// # let query_vec = vec![0.1f32; 384];
35/// use pulsedb::{ContextRequest, SearchFilter};
36///
37/// let candidates = db.get_context_candidates(ContextRequest {
38///     collective_id,
39///     query_embedding: query_vec,
40///     max_similar: 10,
41///     max_recent: 5,
42///     include_insights: true,
43///     filter: SearchFilter {
44///         domains: Some(vec!["rust".to_string()]),
45///         ..SearchFilter::default()
46///     },
47///     ..ContextRequest::default()
48/// })?;
49/// # Ok(())
50/// # }
51/// ```
52#[derive(Clone, Debug)]
53pub struct ContextRequest {
54    /// The collective to search within (must exist).
55    pub collective_id: CollectiveId,
56
57    /// Query embedding vector for similarity search and insight retrieval.
58    ///
59    /// Must match the collective's configured embedding dimension.
60    pub query_embedding: Vec<f32>,
61
62    /// Maximum number of similar experiences to return (1-1000, default: 20).
63    pub max_similar: usize,
64
65    /// Maximum number of recent experiences to return (1-1000, default: 10).
66    pub max_recent: usize,
67
68    /// Whether to include derived insights in the response (default: true).
69    pub include_insights: bool,
70
71    /// Whether to include relations for returned experiences (default: true).
72    pub include_relations: bool,
73
74    /// Whether to include active agent activities (default: true).
75    pub include_active_agents: bool,
76
77    /// Filter criteria applied to similar and recent experience queries.
78    pub filter: SearchFilter,
79
80    /// Optional recall weights for similarity and temporal energy.
81    ///
82    /// Forwarded to `search()` for the similar-experience query. Precedence:
83    /// these weights > the collective's configured `default_recall_weights`
84    /// (per-collective stored config, else the global `Config.decay`) > legacy
85    /// pure-similarity ranking. So `None` preserves legacy ranking only when no
86    /// default is configured.
87    pub recall_weights: Option<RecallWeights>,
88}
89
90impl Default for ContextRequest {
91    fn default() -> Self {
92        Self {
93            collective_id: CollectiveId::nil(),
94            query_embedding: vec![],
95            max_similar: 20,
96            max_recent: 10,
97            include_insights: true,
98            include_relations: true,
99            include_active_agents: true,
100            filter: SearchFilter::default(),
101            recall_weights: None,
102        }
103    }
104}
105
106/// Aggregated context candidates from all retrieval primitives.
107///
108/// Returned by [`PulseDB::get_context_candidates()`](crate::PulseDB::get_context_candidates).
109/// Each field may be empty if no results were found or the corresponding
110/// feature was disabled in the [`ContextRequest`].
111///
112/// # Field Semantics
113///
114/// - `similar_experiences` - Sorted by similarity descending (most similar first)
115/// - `recent_experiences` - Sorted by timestamp descending (newest first)
116/// - `insights` - Similar insights found via HNSW vector search
117/// - `relations` - Relations involving any returned experience (deduplicated)
118/// - `active_agents` - Non-stale agents in the collective
119#[derive(Clone, Debug)]
120pub struct ContextCandidates {
121    /// Semantically similar experiences. Ordered by descending cosine similarity
122    /// for legacy recall; when recall weights are active, ordered by the blended
123    /// similarity+energy score — so `SearchResult.similarity` (always the raw
124    /// cosine value) is not necessarily monotonically descending.
125    pub similar_experiences: Vec<SearchResult>,
126
127    /// Most recent experiences, sorted by timestamp descending.
128    pub recent_experiences: Vec<Experience>,
129
130    /// Derived insights similar to the query embedding.
131    ///
132    /// Empty if `include_insights` was `false` in the request.
133    pub insights: Vec<DerivedInsight>,
134
135    /// Relations involving the returned similar and recent experiences.
136    ///
137    /// Deduplicated by `RelationId`. Empty if `include_relations` was `false`.
138    pub relations: Vec<ExperienceRelation>,
139
140    /// Currently active (non-stale) agents in the collective.
141    ///
142    /// Empty if `include_active_agents` was `false` in the request.
143    pub active_agents: Vec<Activity>,
144}
145
146#[cfg(test)]
147mod tests {
148    use std::collections::BTreeMap;
149
150    use super::*;
151    use crate::config::{Config, RecallWeights};
152    use crate::types::{InstanceId, Timestamp};
153    use crate::PulseDB;
154
155    fn embedding_with_query_cosine(cosine: f32) -> Vec<f32> {
156        let mut embedding = vec![0.0; 384];
157        embedding[0] = cosine;
158        embedding[1] = (1.0 - cosine.powi(2)).sqrt();
159        embedding
160    }
161
162    #[test]
163    fn test_context_request_default_values() {
164        let req = ContextRequest::default();
165        assert_eq!(req.collective_id, CollectiveId::nil());
166        assert!(req.query_embedding.is_empty());
167        assert_eq!(req.max_similar, 20);
168        assert_eq!(req.max_recent, 10);
169        assert!(req.include_insights);
170        assert!(req.include_relations);
171        assert!(req.include_active_agents);
172        assert!(req.filter.exclude_archived);
173    }
174
175    #[test]
176    fn test_context_request_clone_and_debug() {
177        let req = ContextRequest {
178            collective_id: CollectiveId::new(),
179            query_embedding: vec![0.1; 384],
180            max_similar: 5,
181            ..ContextRequest::default()
182        };
183        let cloned = req.clone();
184        assert_eq!(cloned.max_similar, 5);
185        assert_eq!(cloned.query_embedding.len(), 384);
186
187        let debug = format!("{:?}", req);
188        assert!(debug.contains("ContextRequest"));
189    }
190
191    #[test]
192    fn test_context_candidates_clone_and_debug() {
193        let candidates = ContextCandidates {
194            similar_experiences: vec![],
195            recent_experiences: vec![],
196            insights: vec![],
197            relations: vec![],
198            active_agents: vec![],
199        };
200        let cloned = candidates.clone();
201        assert!(cloned.similar_experiences.is_empty());
202
203        let debug = format!("{:?}", candidates);
204        assert!(debug.contains("ContextCandidates"));
205    }
206
207    #[test]
208    fn context_candidates_energy_reranks_fresh_ahead() {
209        let dir = tempfile::tempdir().unwrap();
210        let db = PulseDB::open(dir.path().join("context-rerank.db"), Config::default()).unwrap();
211        let collective_id = db.create_collective("context-rerank").unwrap();
212        let query_embedding = embedding_with_query_cosine(1.0);
213        let now = Timestamp::now();
214        let stale_last_reinforced =
215            Timestamp::from_millis(now.as_millis() - 90 * 24 * 60 * 60 * 1000);
216        let fresh_last_reinforced = now;
217
218        let stale_id = db
219            .insert_experience_backdated(
220                collective_id,
221                "stale but most similar",
222                embedding_with_query_cosine(0.95),
223                0.8,
224                BTreeMap::new(),
225                stale_last_reinforced,
226            )
227            .unwrap();
228        let fresh_id = db
229            .insert_experience_backdated(
230                collective_id,
231                "fresh reinforced",
232                embedding_with_query_cosine(0.90),
233                0.8,
234                BTreeMap::from([(InstanceId::new(), 24)]),
235                fresh_last_reinforced,
236            )
237            .unwrap();
238
239        let weighted = db
240            .get_context_candidates(ContextRequest {
241                collective_id,
242                query_embedding: query_embedding.clone(),
243                max_similar: 2,
244                max_recent: 2,
245                include_insights: false,
246                include_relations: false,
247                include_active_agents: false,
248                recall_weights: Some(RecallWeights::new(0.5, 0.5)),
249                ..ContextRequest::default()
250            })
251            .unwrap();
252        let legacy = db
253            .get_context_candidates(ContextRequest {
254                collective_id,
255                query_embedding,
256                max_similar: 2,
257                max_recent: 2,
258                include_insights: false,
259                include_relations: false,
260                include_active_agents: false,
261                recall_weights: None,
262                ..ContextRequest::default()
263            })
264            .unwrap();
265
266        assert_eq!(weighted.similar_experiences[0].experience.id, fresh_id);
267        assert_eq!(legacy.similar_experiences[0].experience.id, stale_id);
268    }
269}