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}