pulsedb/db.rs
1//! PulseDB main struct and lifecycle operations.
2//!
3//! The [`PulseDB`] struct is the primary interface for interacting with
4//! the database. It provides methods for:
5//!
6//! - Opening and closing the database
7//! - Managing collectives (isolation units)
8//! - Recording and querying experiences
9//! - Semantic search and context retrieval
10//!
11//! # Quick Start
12//!
13//! ```rust
14//! # fn main() -> pulsedb::Result<()> {
15//! # let dir = tempfile::tempdir().unwrap();
16//! use pulsedb::{PulseDB, Config, NewExperience};
17//!
18//! // Open or create a database
19//! let db = PulseDB::open(dir.path().join("test.db"), Config::default())?;
20//!
21//! // Create a collective for your project
22//! let collective = db.create_collective("my-project")?;
23//!
24//! // Record an experience
25//! db.record_experience(NewExperience {
26//! collective_id: collective,
27//! content: "Always validate user input".to_string(),
28//! embedding: Some(vec![0.1f32; 384]),
29//! ..Default::default()
30//! })?;
31//!
32//! // Close when done
33//! db.close()?;
34//! # Ok(())
35//! # }
36//! ```
37//!
38//! # Thread Safety
39//!
40//! `PulseDB` is `Send + Sync` and can be shared across threads using `Arc`.
41//! The underlying storage uses MVCC for concurrent reads with exclusive
42//! write locking.
43//!
44//! ```rust
45//! # fn main() -> pulsedb::Result<()> {
46//! # let dir = tempfile::tempdir().unwrap();
47//! use std::sync::Arc;
48//! use pulsedb::{PulseDB, Config};
49//!
50//! let db = Arc::new(PulseDB::open(dir.path().join("test.db"), Config::default())?);
51//!
52//! // Clone Arc for use in another thread
53//! let db_clone = Arc::clone(&db);
54//! std::thread::spawn(move || {
55//! // Safe to use db_clone here
56//! });
57//! # Ok(())
58//! # }
59//! ```
60
61use std::collections::BTreeMap;
62use std::collections::HashMap;
63use std::path::{Path, PathBuf};
64use std::sync::{Arc, RwLock};
65
66#[cfg(feature = "sync")]
67use tracing::debug;
68use tracing::{info, instrument, warn};
69
70use crate::activity::{validate_new_activity, Activity, NewActivity};
71use crate::collective::types::CollectiveStats;
72use crate::collective::{validate_collective_name, Collective};
73use crate::config::{Config, DecayConfig, EmbeddingProvider, RecallWeights};
74use crate::embedding::{create_embedding_service, EmbeddingService};
75use crate::error::{NotFoundError, PulseDBError, Result, ValidationError};
76use crate::experience::{
77 energy as experience_energy, validate_experience_update, validate_new_experience, Experience,
78 ExperienceUpdate, NewExperience,
79};
80use crate::insight::{validate_new_insight, DerivedInsight, NewDerivedInsight};
81#[cfg(feature = "sync")]
82use crate::relation::ExperienceRelation;
83use crate::search::rerank::{self, is_legacy_recall, resolve_recall_weights};
84use crate::search::{ContextCandidates, ContextRequest, SearchFilter, SearchOptions, SearchResult};
85use crate::storage::{open_storage, DatabaseMetadata, StorageEngine};
86#[cfg(feature = "sync")]
87use crate::types::InstanceId;
88#[cfg(feature = "sync")]
89use crate::types::RelationId;
90use crate::types::{CollectiveId, ExperienceId, InsightId, Timestamp};
91use crate::vector::HnswIndex;
92use crate::watch::{WatchEvent, WatchEventType, WatchFilter, WatchService, WatchStream};
93
94/// The main PulseDB database handle.
95///
96/// This is the primary interface for all database operations. Create an
97/// instance with [`PulseDB::open()`] and close it with [`PulseDB::close()`].
98///
99/// # Ownership
100///
101/// `PulseDB` owns its storage and embedding service. When you call `close()`,
102/// the database is consumed and cannot be used afterward. This ensures
103/// resources are properly released.
104pub struct PulseDB {
105 /// Storage engine (redb or mock for testing).
106 storage: Box<dyn StorageEngine>,
107
108 /// Embedding service (external or ONNX).
109 embedding: Box<dyn EmbeddingService>,
110
111 /// Configuration used to open this database.
112 config: Config,
113
114 /// Per-collective HNSW vector indexes for experience semantic search.
115 ///
116 /// Outer RwLock protects the HashMap (add/remove collectives).
117 /// Each HnswIndex has its own internal RwLock for concurrent search+insert.
118 vectors: RwLock<HashMap<CollectiveId, HnswIndex>>,
119
120 /// Per-collective HNSW vector indexes for insight semantic search.
121 ///
122 /// Separate from `vectors` to prevent ID collisions between experiences
123 /// and insights. Uses InsightId→ExperienceId byte conversion for the
124 /// HNSW API (safe because indexes are isolated per collective).
125 insight_vectors: RwLock<HashMap<CollectiveId, HnswIndex>>,
126
127 /// Watch service for real-time experience change notifications.
128 ///
129 /// Arc-wrapped because [`WatchStream`] holds a weak reference for
130 /// cleanup on drop.
131 watch: Arc<WatchService>,
132}
133
134impl std::fmt::Debug for PulseDB {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 let vector_count = self.vectors.read().map(|v| v.len()).unwrap_or(0);
137 let insight_vector_count = self.insight_vectors.read().map(|v| v.len()).unwrap_or(0);
138 f.debug_struct("PulseDB")
139 .field("config", &self.config)
140 .field("embedding_dimension", &self.embedding_dimension())
141 .field("vector_indexes", &vector_count)
142 .field("insight_vector_indexes", &insight_vector_count)
143 .finish_non_exhaustive()
144 }
145}
146
147impl PulseDB {
148 /// Opens or creates a PulseDB database at the specified path.
149 ///
150 /// If the database doesn't exist, it will be created with the given
151 /// configuration. If it exists, the configuration will be validated
152 /// against the stored settings (e.g., embedding dimension must match).
153 ///
154 /// # Arguments
155 ///
156 /// * `path` - Path to the database file (created if it doesn't exist)
157 /// * `config` - Configuration options for the database
158 ///
159 /// # Errors
160 ///
161 /// Returns an error if:
162 /// - Configuration is invalid (see [`Config::validate`])
163 /// - Database file is corrupted
164 /// - Database is locked by another process
165 /// - Schema version doesn't match (needs migration)
166 /// - Embedding dimension doesn't match existing database
167 ///
168 /// # Example
169 ///
170 /// ```rust
171 /// # fn main() -> pulsedb::Result<()> {
172 /// # let dir = tempfile::tempdir().unwrap();
173 /// use pulsedb::{PulseDB, Config, EmbeddingDimension};
174 ///
175 /// // Open with default configuration
176 /// let db = PulseDB::open(dir.path().join("default.db"), Config::default())?;
177 /// # drop(db);
178 ///
179 /// // Open with custom embedding dimension
180 /// let db = PulseDB::open(dir.path().join("custom.db"), Config {
181 /// embedding_dimension: EmbeddingDimension::D768,
182 /// ..Default::default()
183 /// })?;
184 /// # Ok(())
185 /// # }
186 /// ```
187 #[instrument(skip(config), fields(path = %path.as_ref().display()))]
188 pub fn open(path: impl AsRef<Path>, config: Config) -> Result<Self> {
189 // Validate configuration first
190 config.validate().map_err(PulseDBError::from)?;
191
192 info!("Opening PulseDB");
193
194 // Open storage engine
195 let storage = open_storage(&path, &config)?;
196
197 // Create embedding service
198 let embedding = create_embedding_service(&config)?;
199
200 // Load or rebuild HNSW indexes for all existing collectives
201 let vectors = Self::load_all_indexes(&*storage, &config)?;
202 let insight_vectors = Self::load_all_insight_indexes(&*storage, &config)?;
203
204 info!(
205 dimension = config.embedding_dimension.size(),
206 sync_mode = ?config.sync_mode,
207 collectives = vectors.len(),
208 "PulseDB opened successfully"
209 );
210
211 let watch = Arc::new(WatchService::new(
212 config.watch.buffer_size,
213 config.watch.in_process,
214 ));
215
216 Ok(Self {
217 storage,
218 embedding,
219 config,
220 vectors: RwLock::new(vectors),
221 insight_vectors: RwLock::new(insight_vectors),
222 watch,
223 })
224 }
225
226 /// Closes the database, flushing all pending writes.
227 ///
228 /// This method consumes the `PulseDB` instance, ensuring it cannot
229 /// be used after closing. The underlying storage engine flushes all
230 /// buffered data to disk.
231 ///
232 /// # Errors
233 ///
234 /// Returns an error if the storage backend reports a flush failure.
235 /// Note: the current redb backend flushes durably on drop, so this
236 /// always returns `Ok(())` in practice.
237 ///
238 /// # Example
239 ///
240 /// ```rust
241 /// # fn main() -> pulsedb::Result<()> {
242 /// # let dir = tempfile::tempdir().unwrap();
243 /// use pulsedb::{PulseDB, Config};
244 ///
245 /// let db = PulseDB::open(dir.path().join("test.db"), Config::default())?;
246 /// // ... use the database ...
247 /// db.close()?; // db is consumed here
248 /// // db.something() // Compile error: db was moved
249 /// # Ok(())
250 /// # }
251 /// ```
252 #[instrument(skip(self))]
253 pub fn close(self) -> Result<()> {
254 info!("Closing PulseDB");
255
256 // Persist HNSW indexes BEFORE closing storage.
257 // If HNSW save fails, storage is still open for potential recovery.
258 // On next open(), stale/missing HNSW files trigger a rebuild from redb.
259 if let Some(hnsw_dir) = self.hnsw_dir() {
260 // Experience HNSW indexes
261 let vectors = self
262 .vectors
263 .read()
264 .map_err(|_| PulseDBError::vector("Vectors lock poisoned during close"))?;
265 for (collective_id, index) in vectors.iter() {
266 if let Err(e) = index.save_to_dir(&hnsw_dir, &collective_id.to_string()) {
267 warn!(
268 collective = %collective_id,
269 error = %e,
270 "Failed to save HNSW index (will rebuild on next open)"
271 );
272 }
273 }
274 drop(vectors);
275
276 // Insight HNSW indexes (separate files with _insights suffix)
277 let insight_vectors = self
278 .insight_vectors
279 .read()
280 .map_err(|_| PulseDBError::vector("Insight vectors lock poisoned during close"))?;
281 for (collective_id, index) in insight_vectors.iter() {
282 let name = format!("{}_insights", collective_id);
283 if let Err(e) = index.save_to_dir(&hnsw_dir, &name) {
284 warn!(
285 collective = %collective_id,
286 error = %e,
287 "Failed to save insight HNSW index (will rebuild on next open)"
288 );
289 }
290 }
291 }
292
293 // Close storage (flushes pending writes)
294 self.storage.close()?;
295
296 info!("PulseDB closed successfully");
297 Ok(())
298 }
299
300 /// Returns a reference to the database configuration.
301 ///
302 /// This is the configuration that was used to open the database.
303 /// Note that some settings (like embedding dimension) are locked
304 /// on database creation and cannot be changed.
305 #[inline]
306 pub fn config(&self) -> &Config {
307 &self.config
308 }
309
310 /// Returns the database metadata.
311 ///
312 /// Metadata includes schema version, embedding dimension, and timestamps
313 /// for when the database was created and last opened.
314 #[inline]
315 pub fn metadata(&self) -> &DatabaseMetadata {
316 self.storage.metadata()
317 }
318
319 /// Returns the embedding dimension configured for this database.
320 ///
321 /// All embeddings stored in this database must have exactly this
322 /// many dimensions.
323 #[inline]
324 pub fn embedding_dimension(&self) -> usize {
325 self.config.embedding_dimension.size()
326 }
327
328 // =========================================================================
329 // Internal Accessors (for use by feature modules)
330 // =========================================================================
331
332 /// Returns a reference to the storage engine.
333 ///
334 /// This is for internal use by other PulseDB modules.
335 #[inline]
336 #[allow(dead_code)] // Will be used by search (Phase 2) and other modules
337 pub(crate) fn storage(&self) -> &dyn StorageEngine {
338 self.storage.as_ref()
339 }
340
341 /// Returns a reference to the embedding service.
342 ///
343 /// This is for internal use by other PulseDB modules.
344 #[inline]
345 #[allow(dead_code)] // Will be used by search (Phase 2) and other modules
346 pub(crate) fn embedding(&self) -> &dyn EmbeddingService {
347 self.embedding.as_ref()
348 }
349
350 // =========================================================================
351 // HNSW Index Lifecycle
352 // =========================================================================
353
354 /// Returns the directory for HNSW index files.
355 ///
356 /// Derives `{db_path}.hnsw/` from the storage path. Returns `None` if
357 /// the storage has no file path (e.g., in-memory tests).
358 fn hnsw_dir(&self) -> Option<PathBuf> {
359 self.storage.path().map(|p| {
360 let mut hnsw_path = p.as_os_str().to_owned();
361 hnsw_path.push(".hnsw");
362 PathBuf::from(hnsw_path)
363 })
364 }
365
366 /// Loads or rebuilds HNSW indexes for all existing collectives.
367 ///
368 /// For each collective in storage:
369 /// 1. Try loading metadata from `.hnsw.meta` file
370 /// 2. Rebuild the graph from redb embeddings (always, since we can't
371 /// load the graph due to hnsw_rs lifetime constraints)
372 /// 3. Restore deleted set from metadata if available
373 fn load_all_indexes(
374 storage: &dyn StorageEngine,
375 config: &Config,
376 ) -> Result<HashMap<CollectiveId, HnswIndex>> {
377 let collectives = storage.list_collectives()?;
378 let mut vectors = HashMap::with_capacity(collectives.len());
379
380 let hnsw_dir = storage.path().map(|p| {
381 let mut hnsw_path = p.as_os_str().to_owned();
382 hnsw_path.push(".hnsw");
383 PathBuf::from(hnsw_path)
384 });
385
386 for collective in &collectives {
387 let dimension = collective.embedding_dimension as usize;
388
389 // List all experience IDs in this collective
390 let exp_ids = storage.list_experience_ids_in_collective(collective.id)?;
391
392 // Load embeddings from redb (source of truth)
393 let mut embeddings = Vec::with_capacity(exp_ids.len());
394 for exp_id in &exp_ids {
395 if let Some(embedding) = storage.get_embedding(*exp_id)? {
396 embeddings.push((*exp_id, embedding));
397 }
398 }
399
400 // Try loading metadata (for deleted set and ID mappings)
401 let metadata = hnsw_dir
402 .as_ref()
403 .and_then(|dir| HnswIndex::load_metadata(dir, &collective.id.to_string()).ok())
404 .flatten();
405
406 // Rebuild the HNSW graph from embeddings
407 let index = if embeddings.is_empty() {
408 HnswIndex::new(dimension, &config.hnsw)
409 } else {
410 let start = std::time::Instant::now();
411 let idx = HnswIndex::rebuild_from_embeddings(dimension, &config.hnsw, embeddings)?;
412 info!(
413 collective = %collective.id,
414 vectors = idx.active_count(),
415 elapsed_ms = start.elapsed().as_millis() as u64,
416 "Rebuilt HNSW index from redb embeddings"
417 );
418 idx
419 };
420
421 // Restore deleted set from metadata if available
422 if let Some(meta) = metadata {
423 index.restore_deleted_set(&meta.deleted)?;
424 }
425
426 vectors.insert(collective.id, index);
427 }
428
429 Ok(vectors)
430 }
431
432 /// Loads or rebuilds insight HNSW indexes for all existing collectives.
433 ///
434 /// For each collective, loads all insights from storage and rebuilds
435 /// the HNSW graph from their inline embeddings. Uses InsightId→ExperienceId
436 /// byte conversion for the HNSW API.
437 fn load_all_insight_indexes(
438 storage: &dyn StorageEngine,
439 config: &Config,
440 ) -> Result<HashMap<CollectiveId, HnswIndex>> {
441 let collectives = storage.list_collectives()?;
442 let mut insight_vectors = HashMap::with_capacity(collectives.len());
443
444 let hnsw_dir = storage.path().map(|p| {
445 let mut hnsw_path = p.as_os_str().to_owned();
446 hnsw_path.push(".hnsw");
447 PathBuf::from(hnsw_path)
448 });
449
450 for collective in &collectives {
451 let dimension = collective.embedding_dimension as usize;
452
453 // List all insight IDs in this collective
454 let insight_ids = storage.list_insight_ids_in_collective(collective.id)?;
455
456 // Load insights and extract embeddings (converting InsightId → ExperienceId)
457 let mut embeddings = Vec::with_capacity(insight_ids.len());
458 for insight_id in &insight_ids {
459 if let Some(insight) = storage.get_insight(*insight_id)? {
460 let exp_id = ExperienceId::from_bytes(*insight_id.as_bytes());
461 embeddings.push((exp_id, insight.embedding));
462 }
463 }
464
465 // Try loading metadata (for deleted set)
466 let name = format!("{}_insights", collective.id);
467 let metadata = hnsw_dir
468 .as_ref()
469 .and_then(|dir| HnswIndex::load_metadata(dir, &name).ok())
470 .flatten();
471
472 // Rebuild HNSW graph from embeddings
473 let index = if embeddings.is_empty() {
474 HnswIndex::new(dimension, &config.hnsw)
475 } else {
476 let start = std::time::Instant::now();
477 let idx = HnswIndex::rebuild_from_embeddings(dimension, &config.hnsw, embeddings)?;
478 info!(
479 collective = %collective.id,
480 insights = idx.active_count(),
481 elapsed_ms = start.elapsed().as_millis() as u64,
482 "Rebuilt insight HNSW index from stored insights"
483 );
484 idx
485 };
486
487 // Restore deleted set from metadata if available
488 if let Some(meta) = metadata {
489 index.restore_deleted_set(&meta.deleted)?;
490 }
491
492 insight_vectors.insert(collective.id, index);
493 }
494
495 Ok(insight_vectors)
496 }
497
498 /// Executes a closure with the HNSW index for a collective.
499 ///
500 /// This is the primary accessor for vector search operations (used by
501 /// `search_similar()`). The closure runs while the outer RwLock guard
502 /// is held (read lock), so the HnswIndex reference stays valid.
503 /// Returns `None` if no index exists for the collective.
504 #[doc(hidden)]
505 pub fn with_vector_index<F, R>(&self, collective_id: CollectiveId, f: F) -> Result<Option<R>>
506 where
507 F: FnOnce(&HnswIndex) -> Result<R>,
508 {
509 let vectors = self
510 .vectors
511 .read()
512 .map_err(|_| PulseDBError::vector("Vectors lock poisoned"))?;
513 match vectors.get(&collective_id) {
514 Some(index) => Ok(Some(f(index)?)),
515 None => Ok(None),
516 }
517 }
518
519 // =========================================================================
520 // Test Helpers
521 // =========================================================================
522
523 /// Returns a reference to the storage engine for integration testing.
524 ///
525 /// This method is intentionally hidden from documentation. It provides
526 /// test-only access to the storage layer for verifying ACID guarantees
527 /// and crash recovery. Production code should use the public PulseDB API.
528 #[doc(hidden)]
529 #[inline]
530 pub fn storage_for_test(&self) -> &dyn StorageEngine {
531 self.storage.as_ref()
532 }
533
534 /// Returns true if this database is in read-only mode.
535 pub fn is_read_only(&self) -> bool {
536 self.config.read_only
537 }
538
539 /// Checks if the database is read-only and returns an error if so.
540 #[inline]
541 fn check_writable(&self) -> Result<()> {
542 if self.config.read_only {
543 return Err(PulseDBError::ReadOnly);
544 }
545 Ok(())
546 }
547
548 // =========================================================================
549 // Collective Management (E1-S02)
550 // =========================================================================
551
552 /// Creates a new collective with the given name.
553 ///
554 /// The collective's embedding dimension is locked to the database's
555 /// configured dimension at creation time.
556 ///
557 /// # Arguments
558 ///
559 /// * `name` - Human-readable name (1-255 characters, not whitespace-only)
560 ///
561 /// # Errors
562 ///
563 /// Returns a validation error if the name is empty, whitespace-only,
564 /// or exceeds 255 characters.
565 ///
566 /// # Example
567 ///
568 /// ```rust
569 /// # fn main() -> pulsedb::Result<()> {
570 /// # let dir = tempfile::tempdir().unwrap();
571 /// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
572 /// let id = db.create_collective("my-project")?;
573 /// # Ok(())
574 /// # }
575 /// ```
576 #[instrument(skip(self))]
577 pub fn create_collective(&self, name: &str) -> Result<CollectiveId> {
578 self.check_writable()?;
579 validate_collective_name(name)?;
580
581 let dimension = self.config.embedding_dimension.size() as u16;
582 let collective = Collective::new(name, dimension);
583 let id = collective.id;
584
585 // Persist to redb first (source of truth)
586 self.storage.save_collective(&collective)?;
587
588 // Create empty HNSW indexes for this collective
589 let exp_index = HnswIndex::new(dimension as usize, &self.config.hnsw);
590 let insight_index = HnswIndex::new(dimension as usize, &self.config.hnsw);
591 self.vectors
592 .write()
593 .map_err(|_| PulseDBError::vector("Vectors lock poisoned"))?
594 .insert(id, exp_index);
595 self.insight_vectors
596 .write()
597 .map_err(|_| PulseDBError::vector("Insight vectors lock poisoned"))?
598 .insert(id, insight_index);
599
600 info!(id = %id, name = %name, "Collective created");
601 Ok(id)
602 }
603
604 /// Creates a new collective with an owner for multi-tenancy.
605 ///
606 /// Same as [`create_collective`](Self::create_collective) but assigns
607 /// an owner ID, enabling filtering with
608 /// [`list_collectives_by_owner`](Self::list_collectives_by_owner).
609 ///
610 /// # Arguments
611 ///
612 /// * `name` - Human-readable name (1-255 characters)
613 /// * `owner_id` - Owner identifier (must not be empty)
614 ///
615 /// # Errors
616 ///
617 /// Returns a validation error if the name or owner_id is invalid.
618 #[instrument(skip(self))]
619 pub fn create_collective_with_owner(&self, name: &str, owner_id: &str) -> Result<CollectiveId> {
620 self.check_writable()?;
621 validate_collective_name(name)?;
622
623 if owner_id.is_empty() {
624 return Err(PulseDBError::from(
625 crate::error::ValidationError::required_field("owner_id"),
626 ));
627 }
628
629 let dimension = self.config.embedding_dimension.size() as u16;
630 let collective = Collective::with_owner(name, owner_id, dimension);
631 let id = collective.id;
632
633 // Persist to redb first (source of truth)
634 self.storage.save_collective(&collective)?;
635
636 // Create empty HNSW indexes for this collective
637 let exp_index = HnswIndex::new(dimension as usize, &self.config.hnsw);
638 let insight_index = HnswIndex::new(dimension as usize, &self.config.hnsw);
639 self.vectors
640 .write()
641 .map_err(|_| PulseDBError::vector("Vectors lock poisoned"))?
642 .insert(id, exp_index);
643 self.insight_vectors
644 .write()
645 .map_err(|_| PulseDBError::vector("Insight vectors lock poisoned"))?
646 .insert(id, insight_index);
647
648 info!(id = %id, name = %name, owner = %owner_id, "Collective created with owner");
649 Ok(id)
650 }
651
652 /// Returns a collective by ID, or `None` if not found.
653 ///
654 /// # Example
655 ///
656 /// ```rust
657 /// # fn main() -> pulsedb::Result<()> {
658 /// # let dir = tempfile::tempdir().unwrap();
659 /// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
660 /// # let id = db.create_collective("example")?;
661 /// if let Some(collective) = db.get_collective(id)? {
662 /// println!("Found: {}", collective.name);
663 /// }
664 /// # Ok(())
665 /// # }
666 /// ```
667 #[instrument(skip(self))]
668 pub fn get_collective(&self, id: CollectiveId) -> Result<Option<Collective>> {
669 self.storage.get_collective(id)
670 }
671
672 /// Lists all collectives in the database.
673 ///
674 /// Returns an empty vector if no collectives exist.
675 pub fn list_collectives(&self) -> Result<Vec<Collective>> {
676 self.storage.list_collectives()
677 }
678
679 /// Lists collectives filtered by owner ID.
680 ///
681 /// Returns only collectives whose `owner_id` matches the given value.
682 /// Returns an empty vector if no matching collectives exist.
683 pub fn list_collectives_by_owner(&self, owner_id: &str) -> Result<Vec<Collective>> {
684 let all = self.storage.list_collectives()?;
685 Ok(all
686 .into_iter()
687 .filter(|c| c.owner_id.as_deref() == Some(owner_id))
688 .collect())
689 }
690
691 /// Returns statistics for a collective.
692 ///
693 /// # Errors
694 ///
695 /// Returns [`NotFoundError::Collective`] if the collective doesn't exist.
696 #[instrument(skip(self))]
697 pub fn get_collective_stats(&self, id: CollectiveId) -> Result<CollectiveStats> {
698 // Verify collective exists
699 self.storage
700 .get_collective(id)?
701 .ok_or_else(|| PulseDBError::from(NotFoundError::collective(id)))?;
702
703 let experience_count = self.storage.count_experiences_in_collective(id)?;
704
705 Ok(CollectiveStats {
706 experience_count,
707 storage_bytes: 0,
708 oldest_experience: None,
709 newest_experience: None,
710 })
711 }
712
713 /// Deletes a collective and all its associated data.
714 ///
715 /// Performs cascade deletion: removes all experiences belonging to the
716 /// collective before removing the collective record itself.
717 ///
718 /// # Errors
719 ///
720 /// Returns [`NotFoundError::Collective`] if the collective doesn't exist.
721 ///
722 /// # Example
723 ///
724 /// ```rust
725 /// # fn main() -> pulsedb::Result<()> {
726 /// # let dir = tempfile::tempdir().unwrap();
727 /// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
728 /// # let collective_id = db.create_collective("to-delete")?;
729 /// db.delete_collective(collective_id)?;
730 /// assert!(db.get_collective(collective_id)?.is_none());
731 /// # Ok(())
732 /// # }
733 /// ```
734 #[instrument(skip(self))]
735 pub fn delete_collective(&self, id: CollectiveId) -> Result<()> {
736 self.check_writable()?;
737 // Verify collective exists
738 self.storage
739 .get_collective(id)?
740 .ok_or_else(|| PulseDBError::from(NotFoundError::collective(id)))?;
741
742 // Cascade: delete all experiences for this collective
743 let deleted_count = self.storage.delete_experiences_by_collective(id)?;
744 if deleted_count > 0 {
745 info!(count = deleted_count, "Cascade-deleted experiences");
746 }
747
748 // Cascade: delete all insights for this collective
749 let deleted_insights = self.storage.delete_insights_by_collective(id)?;
750 if deleted_insights > 0 {
751 info!(count = deleted_insights, "Cascade-deleted insights");
752 }
753
754 // Cascade: delete all activities for this collective
755 let deleted_activities = self.storage.delete_activities_by_collective(id)?;
756 if deleted_activities > 0 {
757 info!(count = deleted_activities, "Cascade-deleted activities");
758 }
759
760 // Delete the collective record from storage
761 self.storage.delete_collective(id)?;
762
763 // Remove HNSW indexes from memory
764 self.vectors
765 .write()
766 .map_err(|_| PulseDBError::vector("Vectors lock poisoned"))?
767 .remove(&id);
768 self.insight_vectors
769 .write()
770 .map_err(|_| PulseDBError::vector("Insight vectors lock poisoned"))?
771 .remove(&id);
772
773 // Remove HNSW files from disk (non-fatal if fails)
774 if let Some(hnsw_dir) = self.hnsw_dir() {
775 if let Err(e) = HnswIndex::remove_files(&hnsw_dir, &id.to_string()) {
776 warn!(
777 collective = %id,
778 error = %e,
779 "Failed to remove experience HNSW files (non-fatal)"
780 );
781 }
782 let insight_name = format!("{}_insights", id);
783 if let Err(e) = HnswIndex::remove_files(&hnsw_dir, &insight_name) {
784 warn!(
785 collective = %id,
786 error = %e,
787 "Failed to remove insight HNSW files (non-fatal)"
788 );
789 }
790 }
791
792 info!(id = %id, "Collective deleted");
793 Ok(())
794 }
795
796 // =========================================================================
797 // Experience CRUD (E1-S03)
798 // =========================================================================
799
800 /// Records a new experience in the database.
801 ///
802 /// This is the primary method for storing agent-learned knowledge. The method:
803 /// 1. Validates the input (content, scores, tags, embedding)
804 /// 2. Verifies the collective exists
805 /// 3. Resolves the embedding (generates if Builtin, requires if External)
806 /// 4. Stores the experience atomically across 4 tables
807 ///
808 /// # Arguments
809 ///
810 /// * `exp` - The experience to record (see [`NewExperience`])
811 ///
812 /// # Errors
813 ///
814 /// - [`ValidationError`](crate::ValidationError) if input is invalid
815 /// - [`NotFoundError::Collective`] if the collective doesn't exist
816 /// - [`PulseDBError::Embedding`] if embedding generation fails (Builtin mode)
817 #[instrument(skip(self, exp), fields(collective_id = %exp.collective_id))]
818 pub fn record_experience(&self, exp: NewExperience) -> Result<ExperienceId> {
819 self.check_writable()?;
820 let is_external = matches!(self.config.embedding_provider, EmbeddingProvider::External);
821
822 // Verify collective exists and get its dimension
823 let collective = self
824 .storage
825 .get_collective(exp.collective_id)?
826 .ok_or_else(|| PulseDBError::from(NotFoundError::collective(exp.collective_id)))?;
827
828 // Validate input
829 validate_new_experience(&exp, collective.embedding_dimension, is_external)?;
830
831 // Resolve embedding
832 let embedding = match exp.embedding {
833 Some(emb) => emb,
834 None => {
835 // Builtin mode: generate embedding from content
836 self.embedding.embed(&exp.content)?
837 }
838 };
839
840 // Clone embedding for HNSW insertion (~1.5KB for 384d, negligible vs I/O)
841 let embedding_for_hnsw = embedding.clone();
842 let collective_id = exp.collective_id;
843
844 let timestamp = Timestamp::now();
845
846 // Construct the full experience record
847 let experience = Experience {
848 id: ExperienceId::new(),
849 collective_id,
850 content: exp.content,
851 embedding,
852 experience_type: exp.experience_type,
853 importance: exp.importance,
854 confidence: exp.confidence,
855 applications: BTreeMap::new(),
856 domain: exp.domain,
857 related_files: exp.related_files,
858 source_agent: exp.source_agent,
859 source_task: exp.source_task,
860 timestamp,
861 last_reinforced: timestamp,
862 archived: false,
863 };
864
865 let id = experience.id;
866
867 // Write to redb FIRST (source of truth). If crash happens after
868 // this but before HNSW insert, rebuild on next open will include it.
869 self.storage.save_experience(&experience)?;
870
871 // Insert into HNSW index (derived structure)
872 let vectors = self
873 .vectors
874 .read()
875 .map_err(|_| PulseDBError::vector("Vectors lock poisoned"))?;
876 if let Some(index) = vectors.get(&collective_id) {
877 index.insert_experience(id, &embedding_for_hnsw)?;
878 }
879
880 // Emit watch event after both storage and HNSW succeed
881 self.watch.emit(
882 WatchEvent {
883 experience_id: id,
884 collective_id,
885 event_type: WatchEventType::Created,
886 timestamp: experience.timestamp,
887 experience: Some(experience.clone()),
888 },
889 &experience,
890 )?;
891
892 info!(id = %id, "Experience recorded");
893 Ok(id)
894 }
895
896 /// Retrieves an experience by ID, including its embedding.
897 ///
898 /// Returns `None` if no experience with the given ID exists.
899 #[instrument(skip(self))]
900 pub fn get_experience(&self, id: ExperienceId) -> Result<Option<Experience>> {
901 self.storage.get_experience(id)
902 }
903
904 /// Updates mutable fields of an experience.
905 ///
906 /// Only fields set to `Some(...)` in the update are changed.
907 /// Content and embedding are immutable — create a new experience instead.
908 ///
909 /// # Errors
910 ///
911 /// - [`ValidationError`](crate::ValidationError) if updated values are invalid
912 /// - [`NotFoundError::Experience`] if the experience doesn't exist
913 #[instrument(skip(self, update))]
914 pub fn update_experience(&self, id: ExperienceId, update: ExperienceUpdate) -> Result<()> {
915 self.check_writable()?;
916 validate_experience_update(&update)?;
917
918 let updated = self.storage.update_experience(id, &update)?;
919 if !updated {
920 return Err(PulseDBError::from(NotFoundError::experience(id)));
921 }
922
923 // Emit watch event (fetch experience for collective_id + filter matching)
924 if self.watch.has_subscribers() {
925 if let Ok(Some(exp)) = self.storage.get_experience(id) {
926 let event_type = if update.archived == Some(true) {
927 WatchEventType::Archived
928 } else {
929 WatchEventType::Updated
930 };
931 self.watch.emit(
932 WatchEvent {
933 experience_id: id,
934 collective_id: exp.collective_id,
935 event_type,
936 timestamp: Timestamp::now(),
937 experience: Some(exp.clone()),
938 },
939 &exp,
940 )?;
941 }
942 }
943
944 info!(id = %id, "Experience updated");
945 Ok(())
946 }
947
948 /// Archives an experience (soft-delete).
949 ///
950 /// Archived experiences remain in storage but are excluded from search
951 /// results. Use [`unarchive_experience`](Self::unarchive_experience) to restore.
952 ///
953 /// # Errors
954 ///
955 /// Returns [`NotFoundError::Experience`] if the experience doesn't exist.
956 #[instrument(skip(self))]
957 pub fn archive_experience(&self, id: ExperienceId) -> Result<()> {
958 self.check_writable()?;
959 self.update_experience(
960 id,
961 ExperienceUpdate {
962 archived: Some(true),
963 ..Default::default()
964 },
965 )
966 }
967
968 /// Restores an archived experience.
969 ///
970 /// The experience will once again appear in search results.
971 ///
972 /// # Errors
973 ///
974 /// Returns [`NotFoundError::Experience`] if the experience doesn't exist.
975 #[instrument(skip(self))]
976 pub fn unarchive_experience(&self, id: ExperienceId) -> Result<()> {
977 self.check_writable()?;
978 self.update_experience(
979 id,
980 ExperienceUpdate {
981 archived: Some(false),
982 ..Default::default()
983 },
984 )
985 }
986
987 /// Permanently deletes an experience and its embedding.
988 ///
989 /// This removes the experience from all tables and indices.
990 /// Unlike archiving, this is irreversible.
991 ///
992 /// # Errors
993 ///
994 /// Returns [`NotFoundError::Experience`] if the experience doesn't exist.
995 #[instrument(skip(self))]
996 pub fn delete_experience(&self, id: ExperienceId) -> Result<()> {
997 self.check_writable()?;
998 // Read experience first to get collective_id for HNSW lookup.
999 // This adds one extra read, but delete is not a hot path.
1000 let experience = self
1001 .storage
1002 .get_experience(id)?
1003 .ok_or_else(|| PulseDBError::from(NotFoundError::experience(id)))?;
1004
1005 // Cascade-delete any relations involving this experience.
1006 // Done before experience deletion so we can still look up relation data.
1007 let rel_count = self.storage.delete_relations_for_experience(id)?;
1008 if rel_count > 0 {
1009 info!(
1010 count = rel_count,
1011 "Cascade-deleted relations for experience"
1012 );
1013 }
1014
1015 // Delete from redb FIRST (source of truth). If crash happens after
1016 // this but before HNSW soft-delete, on reopen the experience won't be
1017 // loaded from redb, so it's automatically excluded from the rebuilt index.
1018 self.storage.delete_experience(id)?;
1019
1020 // Soft-delete from HNSW index (mark as deleted, not removed from graph).
1021 // This takes effect immediately for the current session's searches.
1022 let vectors = self
1023 .vectors
1024 .read()
1025 .map_err(|_| PulseDBError::vector("Vectors lock poisoned"))?;
1026 if let Some(index) = vectors.get(&experience.collective_id) {
1027 index.delete_experience(id)?;
1028 }
1029
1030 // Emit watch event after storage + HNSW deletion
1031 self.watch.emit(
1032 WatchEvent {
1033 experience_id: id,
1034 collective_id: experience.collective_id,
1035 event_type: WatchEventType::Deleted,
1036 timestamp: Timestamp::now(),
1037 experience: None, // Deleted — no data to include
1038 },
1039 &experience,
1040 )?;
1041
1042 info!(id = %id, "Experience deleted");
1043 Ok(())
1044 }
1045
1046 /// Reinforces an experience by incrementing its application count.
1047 ///
1048 /// Each call atomically increments the `applications` counter by 1.
1049 /// Returns the new application count.
1050 ///
1051 /// # Errors
1052 ///
1053 /// Returns [`NotFoundError::Experience`] if the experience doesn't exist.
1054 #[instrument(skip(self))]
1055 pub fn reinforce_experience(&self, id: ExperienceId) -> Result<u32> {
1056 self.check_writable()?;
1057 let new_count = self
1058 .storage
1059 .reinforce_experience(id)?
1060 .ok_or_else(|| PulseDBError::from(NotFoundError::experience(id)))?;
1061
1062 // Emit watch event (fetch experience for collective_id + filter matching)
1063 if self.watch.has_subscribers() {
1064 if let Ok(Some(exp)) = self.storage.get_experience(id) {
1065 self.watch.emit(
1066 WatchEvent {
1067 experience_id: id,
1068 collective_id: exp.collective_id,
1069 event_type: WatchEventType::Updated,
1070 timestamp: Timestamp::now(),
1071 experience: Some(exp.clone()),
1072 },
1073 &exp,
1074 )?;
1075 }
1076 }
1077
1078 info!(id = %id, applications = new_count, "Experience reinforced");
1079 Ok(new_count)
1080 }
1081
1082 /// Computes the current temporal energy for an experience.
1083 ///
1084 /// This is a read-only diagnostic: it never writes to storage and does not
1085 /// require a writable database handle. Per-collective decay configuration
1086 /// takes precedence over the database's global default.
1087 ///
1088 /// # Errors
1089 ///
1090 /// Returns [`NotFoundError::Experience`] if the experience doesn't exist.
1091 #[instrument(skip(self))]
1092 pub fn energy(&self, id: ExperienceId) -> Result<f32> {
1093 let experience = self
1094 .storage
1095 .get_experience(id)?
1096 .ok_or_else(|| PulseDBError::from(NotFoundError::experience(id)))?;
1097 let decay_config = self
1098 .storage
1099 .get_decay_config(experience.collective_id)?
1100 .unwrap_or_else(|| self.config.decay.clone());
1101
1102 Ok(experience_energy(
1103 experience.importance,
1104 experience.applications(),
1105 experience.last_reinforced,
1106 Timestamp::now(),
1107 &decay_config,
1108 ))
1109 }
1110
1111 /// Surfaces prune-eligible cold experiences in a collective, coldest-first.
1112 ///
1113 /// Returns lightweight `(ExperienceId, energy)` pairs — **never** full
1114 /// [`Experience`] records — for every experience whose current temporal
1115 /// energy is `< below` **and** that is **not already archived**
1116 /// (`energy < below && !archived`). Results are sorted ascending by energy
1117 /// (coldest first) and truncated to `limit`.
1118 ///
1119 /// This is a **human-triggered review tool**, not an automatic actuator: it
1120 /// merely *surfaces* candidates a consumer may choose to archive/prune. It
1121 /// **does not archive** anything and never mutates storage — the
1122 /// `auto_archive_below_floor` flag is inert and read by no actuator.
1123 ///
1124 /// # Archived exclusion
1125 ///
1126 /// Already-archived experiences are excluded even when their energy is
1127 /// `< below`: re-listing them is noise that would double-count a consumer's
1128 /// prune loop. Only *cold AND not-yet-archived* experiences are returned.
1129 ///
1130 /// # Performance
1131 ///
1132 /// This is a **deliberate `O(n)` single-pass full-collective scan** (enumerate
1133 /// all experience IDs → load each → compute scalar energy → filter). There is
1134 /// **no energy index**; the scan is acceptable precisely because this is a
1135 /// human-triggered review tool invoked rarely, not a hot query path. The
1136 /// `DecayConfig` is resolved once and `Timestamp::now()` is captured once for
1137 /// the whole scan (scalar `experience_energy` per candidate), mirroring
1138 /// [`energy()`](Self::energy) — never a per-item `self.energy(id)` re-resolve.
1139 ///
1140 /// # Examples
1141 ///
1142 /// ```rust
1143 /// # fn main() -> pulsedb::Result<()> {
1144 /// # let dir = tempfile::tempdir().unwrap();
1145 /// # let db = pulsedb::PulseDB::open(dir.path().join("cold.db"), pulsedb::Config::default())?;
1146 /// let collective = db.create_collective("my-project")?;
1147 ///
1148 /// // Surface up to 100 prune-eligible candidates with energy < 0.05,
1149 /// // coldest-first. Returns lightweight (ExperienceId, energy) pairs —
1150 /// // not full Experience records. Read-only: nothing is archived.
1151 /// for (id, energy) in db.list_cold_experiences(collective, 0.05, 100)? {
1152 /// println!("cold candidate {id} @ energy {energy}");
1153 /// }
1154 /// # Ok(())
1155 /// # }
1156 /// ```
1157 ///
1158 /// # Arguments
1159 ///
1160 /// * `collective_id` - The collective to scan.
1161 /// * `below` - Energy threshold in `[0.0, 1.0]`; experiences with current
1162 /// energy strictly below this are surfaced.
1163 /// * `limit` - Maximum number of pairs to return (1-1000).
1164 ///
1165 /// # Errors
1166 ///
1167 /// - [`ValidationError::InvalidField`] if `limit` is 0 or > 1000, or if
1168 /// `below` is NaN or outside `[0.0, 1.0]`.
1169 /// - [`NotFoundError::Collective`] if the collective doesn't exist.
1170 #[instrument(skip(self))]
1171 pub fn list_cold_experiences(
1172 &self,
1173 collective_id: CollectiveId,
1174 below: f32,
1175 limit: usize,
1176 ) -> Result<Vec<(ExperienceId, f32)>> {
1177 // Validate limit (mirror get_recent_experiences_filtered).
1178 if limit == 0 || limit > 1000 {
1179 return Err(
1180 ValidationError::invalid_field("limit", "must be between 1 and 1000").into(),
1181 );
1182 }
1183
1184 // Validate threshold: reject NaN and out-of-range.
1185 if below.is_nan() || !(0.0..=1.0).contains(&below) {
1186 return Err(
1187 ValidationError::invalid_field("below", "must be between 0.0 and 1.0").into(),
1188 );
1189 }
1190
1191 // Verify collective exists.
1192 self.storage
1193 .get_collective(collective_id)?
1194 .ok_or_else(|| PulseDBError::from(NotFoundError::collective(collective_id)))?;
1195
1196 // Resolve decay config ONCE and capture `now` ONCE for the whole scan
1197 // (hot-path rule: never re-resolve per item via self.energy(id)).
1198 let decay_config = self
1199 .storage
1200 .get_decay_config(collective_id)?
1201 .unwrap_or_else(|| self.config.decay.clone());
1202 let now = Timestamp::now();
1203
1204 // Single-pass full-collective scan. Stream every experience ID in ONE
1205 // index iteration (limit = usize::MAX, offset = 0) — offset-restart
1206 // pagination was quadratic (each page re-skipped from the start). The
1207 // remaining per-row-txn / embedding-hauling / snapshot-safety rework is
1208 // tracked in #21. Load each, compute scalar energy, keep
1209 // `energy < below && !archived`.
1210 let mut cold: Vec<(ExperienceId, f32)> = Vec::new();
1211 let ids = self
1212 .storage
1213 .list_experience_ids_paginated(collective_id, usize::MAX, 0)?;
1214 for id in ids {
1215 let Some(experience) = self.storage.get_experience(id)? else {
1216 continue;
1217 };
1218 if experience.archived {
1219 continue;
1220 }
1221 let energy = experience_energy(
1222 experience.importance,
1223 experience.applications(),
1224 experience.last_reinforced,
1225 now,
1226 &decay_config,
1227 );
1228 if energy < below {
1229 cold.push((id, energy));
1230 }
1231 }
1232
1233 // Coldest-first (ascending energy), then truncate to limit.
1234 cold.sort_by(|a, b| a.1.total_cmp(&b.1));
1235 cold.truncate(limit);
1236 Ok(cold)
1237 }
1238
1239 // =========================================================================
1240 // Recent Experiences
1241 // =========================================================================
1242
1243 // =========================================================================
1244 // Paginated List Operations (PulseVision)
1245 // =========================================================================
1246
1247 /// Lists experiences in a collective with pagination.
1248 ///
1249 /// Returns full `Experience` records (including embeddings) ordered by
1250 /// timestamp. Use `offset` and `limit` for pagination.
1251 ///
1252 /// Designed for visualization tools (PulseVision) that need to enumerate
1253 /// the entire embedding space of a collective.
1254 #[instrument(skip(self))]
1255 pub fn list_experiences(
1256 &self,
1257 collective_id: CollectiveId,
1258 limit: usize,
1259 offset: usize,
1260 ) -> Result<Vec<Experience>> {
1261 let ids = self
1262 .storage
1263 .list_experience_ids_paginated(collective_id, limit, offset)?;
1264 let mut experiences = Vec::with_capacity(ids.len());
1265 for id in ids {
1266 if let Some(exp) = self.storage.get_experience(id)? {
1267 experiences.push(exp);
1268 }
1269 }
1270 Ok(experiences)
1271 }
1272
1273 /// Lists relations in a collective with pagination.
1274 #[instrument(skip(self))]
1275 pub fn list_relations(
1276 &self,
1277 collective_id: CollectiveId,
1278 limit: usize,
1279 offset: usize,
1280 ) -> Result<Vec<crate::relation::ExperienceRelation>> {
1281 self.storage
1282 .list_relations_in_collective(collective_id, limit, offset)
1283 }
1284
1285 /// Lists insights in a collective with pagination.
1286 ///
1287 /// Returns full `DerivedInsight` records including embeddings.
1288 #[instrument(skip(self))]
1289 pub fn list_insights(
1290 &self,
1291 collective_id: CollectiveId,
1292 limit: usize,
1293 offset: usize,
1294 ) -> Result<Vec<DerivedInsight>> {
1295 let ids = self
1296 .storage
1297 .list_insight_ids_paginated(collective_id, limit, offset)?;
1298 let mut insights = Vec::with_capacity(ids.len());
1299 for id in ids {
1300 if let Some(insight) = self.storage.get_insight(id)? {
1301 insights.push(insight);
1302 }
1303 }
1304 Ok(insights)
1305 }
1306
1307 /// Retrieves the most recent experiences in a collective.
1308 ///
1309 /// Returns full experiences ordered by timestamp (newest first).
1310 #[instrument(skip(self))]
1311 pub fn get_recent_experiences(
1312 &self,
1313 collective_id: CollectiveId,
1314 limit: usize,
1315 ) -> Result<Vec<Experience>> {
1316 self.get_recent_experiences_filtered(collective_id, limit, SearchFilter::default())
1317 }
1318
1319 /// Retrieves the most recent experiences in a collective with filtering.
1320 ///
1321 /// Like [`get_recent_experiences()`](Self::get_recent_experiences), but
1322 /// applies additional filters on domain, experience type, importance,
1323 /// confidence, and timestamp.
1324 ///
1325 /// Over-fetches from storage (2x `limit`) to account for entries removed
1326 /// by post-filtering, then truncates to the requested `limit`.
1327 ///
1328 /// # Arguments
1329 ///
1330 /// * `collective_id` - The collective to query
1331 /// * `limit` - Maximum number of experiences to return (1-1000)
1332 /// * `filter` - Filter criteria to apply
1333 ///
1334 /// # Errors
1335 ///
1336 /// - [`ValidationError::InvalidField`] if `limit` is 0 or > 1000
1337 /// - [`NotFoundError::Collective`] if the collective doesn't exist
1338 #[instrument(skip(self, filter))]
1339 pub fn get_recent_experiences_filtered(
1340 &self,
1341 collective_id: CollectiveId,
1342 limit: usize,
1343 filter: SearchFilter,
1344 ) -> Result<Vec<Experience>> {
1345 // Validate limit
1346 if limit == 0 || limit > 1000 {
1347 return Err(
1348 ValidationError::invalid_field("limit", "must be between 1 and 1000").into(),
1349 );
1350 }
1351
1352 // Verify collective exists
1353 self.storage
1354 .get_collective(collective_id)?
1355 .ok_or_else(|| PulseDBError::from(NotFoundError::collective(collective_id)))?;
1356
1357 // Over-fetch IDs to account for post-filtering losses
1358 let over_fetch = limit.saturating_mul(2).min(2000);
1359 let recent_ids = self
1360 .storage
1361 .get_recent_experience_ids(collective_id, over_fetch)?;
1362
1363 // Load full experiences and apply filter
1364 let mut results = Vec::with_capacity(limit);
1365 for (exp_id, _timestamp) in recent_ids {
1366 if results.len() >= limit {
1367 break;
1368 }
1369
1370 if let Some(experience) = self.storage.get_experience(exp_id)? {
1371 if filter.matches(&experience) {
1372 results.push(experience);
1373 }
1374 }
1375 }
1376
1377 Ok(results)
1378 }
1379
1380 // =========================================================================
1381 // Similarity Search (E2-S02)
1382 // =========================================================================
1383
1384 /// Searches for experiences semantically similar to the query embedding.
1385 ///
1386 /// Uses the HNSW vector index for approximate nearest neighbor search,
1387 /// then fetches full experience records from storage. Archived experiences
1388 /// are excluded by default.
1389 ///
1390 /// Results are sorted by similarity descending (most similar first).
1391 /// Similarity is computed as `1.0 - cosine_distance`.
1392 ///
1393 /// # Arguments
1394 ///
1395 /// * `collective_id` - The collective to search within
1396 /// * `query` - Query embedding vector (must match collective's dimension)
1397 /// * `k` - Maximum number of results to return (1-1000)
1398 ///
1399 /// # Errors
1400 ///
1401 /// - [`ValidationError::InvalidField`] if `k` is 0 or > 1000
1402 /// - [`ValidationError::DimensionMismatch`] if `query.len()` doesn't match
1403 /// the collective's embedding dimension
1404 /// - [`NotFoundError::Collective`] if the collective doesn't exist
1405 ///
1406 /// # Example
1407 ///
1408 /// ```rust
1409 /// # fn main() -> pulsedb::Result<()> {
1410 /// # let dir = tempfile::tempdir().unwrap();
1411 /// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
1412 /// # let collective_id = db.create_collective("example")?;
1413 /// let query = vec![0.1f32; 384]; // Your query embedding
1414 /// let results = db.search_similar(collective_id, &query, 10)?;
1415 /// for result in &results {
1416 /// println!(
1417 /// "[{:.3}] {}",
1418 /// result.similarity, result.experience.content
1419 /// );
1420 /// }
1421 /// # Ok(())
1422 /// # }
1423 /// ```
1424 #[instrument(skip(self, query))]
1425 pub fn search_similar(
1426 &self,
1427 collective_id: CollectiveId,
1428 query: &[f32],
1429 k: usize,
1430 ) -> Result<Vec<SearchResult>> {
1431 self.search_similar_filtered(collective_id, query, k, SearchFilter::default())
1432 }
1433
1434 /// Searches for experiences with optional recall weighting.
1435 ///
1436 /// This is the forward-compatible search entry for VS-3.5.2. When the
1437 /// resolved energy weight is zero, it delegates to the unchanged legacy
1438 /// similarity path. Positive energy weights over-fetch vector candidates,
1439 /// blend similarity with temporal energy, then sort and truncate.
1440 ///
1441 /// # Arguments
1442 ///
1443 /// * `collective_id` - The collective to search within
1444 /// * `query` - Query embedding vector (must match collective's dimension)
1445 /// * `options` - Result limit, filter, and optional recall weights
1446 ///
1447 /// # Errors
1448 ///
1449 /// - [`ValidationError::InvalidField`] if request weights are invalid
1450 /// - [`ValidationError::DimensionMismatch`] if `query.len()` doesn't match
1451 /// the collective's embedding dimension
1452 /// - Legacy search errors from [`search_similar_filtered`](Self::search_similar_filtered)
1453 #[instrument(skip(self, query, options))]
1454 pub fn search(
1455 &self,
1456 collective_id: CollectiveId,
1457 query: &[f32],
1458 options: SearchOptions,
1459 ) -> Result<Vec<SearchResult>> {
1460 // Effective per-collective decay config: a stored per-collective override
1461 // wins; otherwise fall back to the global `Config.decay` — matching the
1462 // record/energy reads elsewhere. This also honors the documented global
1463 // `Config.decay.default_recall_weights` for collectives with no stored
1464 // override (PR #23 review; precedence stored > global > none, relates to #16).
1465 let decay_config = self
1466 .storage
1467 .get_decay_config(collective_id)?
1468 .unwrap_or_else(|| self.config.decay.clone());
1469 let collective_default =
1470 decay_config.default_recall_weights.filter(|weights| {
1471 match weights.validate("decay.default_recall_weights") {
1472 Ok(()) => true,
1473 Err(error) => {
1474 warn!(
1475 ?error,
1476 "ignoring invalid default_recall_weights (issue #14)"
1477 );
1478 false
1479 }
1480 }
1481 });
1482
1483 let effective = resolve_recall_weights(options.weights, collective_default)?;
1484 if is_legacy_recall(effective) {
1485 return self.search_similar_filtered(collective_id, query, options.k, options.filter);
1486 }
1487
1488 let weights = effective.expect("non-legacy recall implies weights are present");
1489 self.search_similar_weighted(
1490 collective_id,
1491 query,
1492 options.k,
1493 options.filter,
1494 weights,
1495 decay_config,
1496 )
1497 }
1498
1499 fn search_similar_weighted(
1500 &self,
1501 collective_id: CollectiveId,
1502 query: &[f32],
1503 k: usize,
1504 filter: SearchFilter,
1505 weights: RecallWeights,
1506 decay_config: DecayConfig,
1507 ) -> Result<Vec<SearchResult>> {
1508 if k == 0 || k > 1000 {
1509 return Err(ValidationError::invalid_field("k", "must be between 1 and 1000").into());
1510 }
1511
1512 let collective = self
1513 .storage
1514 .get_collective(collective_id)?
1515 .ok_or_else(|| PulseDBError::from(NotFoundError::collective(collective_id)))?;
1516
1517 let expected_dim = collective.embedding_dimension as usize;
1518 if query.len() != expected_dim {
1519 return Err(ValidationError::dimension_mismatch(expected_dim, query.len()).into());
1520 }
1521
1522 let over_fetch = std::cmp::max(k.saturating_mul(4), k.saturating_add(16)).min(2000);
1523 let ef_search = self.config.hnsw.ef_search.max(over_fetch);
1524 let now = Timestamp::now();
1525
1526 let candidates = self
1527 .with_vector_index(collective_id, |index| {
1528 index.search_experiences(query, over_fetch, ef_search)
1529 })?
1530 .unwrap_or_default();
1531
1532 let mut scored = Vec::with_capacity(candidates.len());
1533 for (exp_id, distance) in candidates {
1534 if let Some(experience) = self.storage.get_experience(exp_id)? {
1535 if filter.matches(&experience) {
1536 let similarity = 1.0 - distance;
1537 let energy = experience_energy(
1538 experience.importance,
1539 experience.applications(),
1540 experience.last_reinforced,
1541 now,
1542 &decay_config,
1543 );
1544 let score = rerank::blend_score(similarity, energy, weights);
1545 scored.push((
1546 SearchResult {
1547 experience,
1548 similarity,
1549 },
1550 score,
1551 ));
1552 }
1553 }
1554 }
1555
1556 Ok(rerank::rerank(scored, k))
1557 }
1558
1559 /// Searches for semantically similar experiences with additional filtering.
1560 ///
1561 /// Like [`search_similar()`](Self::search_similar), but applies additional
1562 /// filters on domain, experience type, importance, confidence, and timestamp.
1563 ///
1564 /// Over-fetches from the HNSW index (2x `k`) to account for entries removed
1565 /// by post-filtering, then truncates to the requested `k`.
1566 ///
1567 /// # Arguments
1568 ///
1569 /// * `collective_id` - The collective to search within
1570 /// * `query` - Query embedding vector (must match collective's dimension)
1571 /// * `k` - Maximum number of results to return (1-1000)
1572 /// * `filter` - Filter criteria to apply after vector search
1573 ///
1574 /// # Errors
1575 ///
1576 /// - [`ValidationError::InvalidField`] if `k` is 0 or > 1000
1577 /// - [`ValidationError::DimensionMismatch`] if `query.len()` doesn't match
1578 /// the collective's embedding dimension
1579 /// - [`NotFoundError::Collective`] if the collective doesn't exist
1580 ///
1581 /// # Example
1582 ///
1583 /// ```rust
1584 /// # fn main() -> pulsedb::Result<()> {
1585 /// # let dir = tempfile::tempdir().unwrap();
1586 /// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
1587 /// # let collective_id = db.create_collective("example")?;
1588 /// # let query_embedding = vec![0.1f32; 384];
1589 /// use pulsedb::SearchFilter;
1590 ///
1591 /// let filter = SearchFilter {
1592 /// domains: Some(vec!["rust".to_string()]),
1593 /// min_importance: Some(0.5),
1594 /// ..SearchFilter::default()
1595 /// };
1596 /// let results = db.search_similar_filtered(
1597 /// collective_id,
1598 /// &query_embedding,
1599 /// 10,
1600 /// filter,
1601 /// )?;
1602 /// # Ok(())
1603 /// # }
1604 /// ```
1605 #[instrument(skip(self, query, filter))]
1606 pub fn search_similar_filtered(
1607 &self,
1608 collective_id: CollectiveId,
1609 query: &[f32],
1610 k: usize,
1611 filter: SearchFilter,
1612 ) -> Result<Vec<SearchResult>> {
1613 // Validate k
1614 if k == 0 || k > 1000 {
1615 return Err(ValidationError::invalid_field("k", "must be between 1 and 1000").into());
1616 }
1617
1618 // Verify collective exists and check embedding dimension
1619 let collective = self
1620 .storage
1621 .get_collective(collective_id)?
1622 .ok_or_else(|| PulseDBError::from(NotFoundError::collective(collective_id)))?;
1623
1624 let expected_dim = collective.embedding_dimension as usize;
1625 if query.len() != expected_dim {
1626 return Err(ValidationError::dimension_mismatch(expected_dim, query.len()).into());
1627 }
1628
1629 // Over-fetch from HNSW to compensate for post-filtering losses
1630 let over_fetch = k.saturating_mul(2).min(2000);
1631 let ef_search = self.config.hnsw.ef_search;
1632
1633 // Search HNSW index — returns (ExperienceId, cosine_distance) sorted
1634 // by distance ascending (closest first)
1635 let candidates = self
1636 .with_vector_index(collective_id, |index| {
1637 index.search_experiences(query, over_fetch, ef_search)
1638 })?
1639 .unwrap_or_default();
1640
1641 // Fetch full experiences, apply filter, convert distance → similarity
1642 let mut results = Vec::with_capacity(k);
1643 for (exp_id, distance) in candidates {
1644 if results.len() >= k {
1645 break;
1646 }
1647
1648 if let Some(experience) = self.storage.get_experience(exp_id)? {
1649 if filter.matches(&experience) {
1650 results.push(SearchResult {
1651 experience,
1652 similarity: 1.0 - distance,
1653 });
1654 }
1655 }
1656 }
1657
1658 Ok(results)
1659 }
1660
1661 // =========================================================================
1662 // Experience Relations (E3-S01)
1663 // =========================================================================
1664
1665 /// Stores a new relation between two experiences.
1666 ///
1667 /// Relations are typed, directed edges connecting a source experience to a
1668 /// target experience. Both experiences must exist and belong to the same
1669 /// collective. Duplicate relations (same source, target, and type) are
1670 /// rejected.
1671 ///
1672 /// # Arguments
1673 ///
1674 /// * `relation` - The relation to create (source, target, type, strength)
1675 ///
1676 /// # Errors
1677 ///
1678 /// Returns an error if:
1679 /// - Source or target experience doesn't exist ([`NotFoundError::Experience`])
1680 /// - Experiences belong to different collectives ([`ValidationError::InvalidField`])
1681 /// - A relation with the same (source, target, type) already exists
1682 /// - Self-relation attempted (source == target)
1683 /// - Strength is out of range `[0.0, 1.0]`
1684 #[instrument(skip(self, relation))]
1685 pub fn store_relation(
1686 &self,
1687 relation: crate::relation::NewExperienceRelation,
1688 ) -> Result<crate::types::RelationId> {
1689 self.check_writable()?;
1690 use crate::relation::{validate_new_relation, ExperienceRelation};
1691 use crate::types::RelationId;
1692
1693 // Validate input fields (self-relation, strength bounds, metadata size)
1694 validate_new_relation(&relation)?;
1695
1696 // Load source and target experiences to verify existence
1697 let source = self
1698 .storage
1699 .get_experience(relation.source_id)?
1700 .ok_or_else(|| PulseDBError::from(NotFoundError::experience(relation.source_id)))?;
1701 let target = self
1702 .storage
1703 .get_experience(relation.target_id)?
1704 .ok_or_else(|| PulseDBError::from(NotFoundError::experience(relation.target_id)))?;
1705
1706 // Verify same collective
1707 if source.collective_id != target.collective_id {
1708 return Err(PulseDBError::from(ValidationError::invalid_field(
1709 "target_id",
1710 "source and target experiences must belong to the same collective",
1711 )));
1712 }
1713
1714 // Check for duplicate (same source, target, type)
1715 if self.storage.relation_exists(
1716 relation.source_id,
1717 relation.target_id,
1718 relation.relation_type,
1719 )? {
1720 return Err(PulseDBError::from(ValidationError::invalid_field(
1721 "relation_type",
1722 "a relation with this source, target, and type already exists",
1723 )));
1724 }
1725
1726 // Construct the full relation
1727 let id = RelationId::new();
1728 let full_relation = ExperienceRelation {
1729 id,
1730 source_id: relation.source_id,
1731 target_id: relation.target_id,
1732 relation_type: relation.relation_type,
1733 strength: relation.strength,
1734 metadata: relation.metadata,
1735 created_at: Timestamp::now(),
1736 };
1737
1738 self.storage.save_relation(&full_relation)?;
1739
1740 info!(
1741 id = %id,
1742 source = %relation.source_id,
1743 target = %relation.target_id,
1744 relation_type = ?full_relation.relation_type,
1745 "Relation stored"
1746 );
1747 Ok(id)
1748 }
1749
1750 /// Retrieves experiences related to the given experience.
1751 ///
1752 /// Returns pairs of `(Experience, ExperienceRelation)` based on the
1753 /// requested direction:
1754 /// - `Outgoing`: experiences that this experience points TO (as source)
1755 /// - `Incoming`: experiences that point TO this experience (as target)
1756 /// - `Both`: union of outgoing and incoming
1757 ///
1758 /// To filter by relation type, use
1759 /// [`get_related_experiences_filtered`](Self::get_related_experiences_filtered).
1760 ///
1761 /// Silently skips relations where the related experience no longer exists
1762 /// (orphan tolerance).
1763 ///
1764 /// # Errors
1765 ///
1766 /// Returns a storage error if the read transaction fails.
1767 #[instrument(skip(self))]
1768 pub fn get_related_experiences(
1769 &self,
1770 experience_id: ExperienceId,
1771 direction: crate::relation::RelationDirection,
1772 ) -> Result<Vec<(Experience, crate::relation::ExperienceRelation)>> {
1773 self.get_related_experiences_filtered(experience_id, direction, None)
1774 }
1775
1776 /// Retrieves experiences related to the given experience, with optional
1777 /// type filtering.
1778 ///
1779 /// Like [`get_related_experiences()`](Self::get_related_experiences), but
1780 /// accepts an optional [`RelationType`](crate::RelationType) filter.
1781 /// When `Some(rt)`, only relations matching that type are returned.
1782 ///
1783 /// # Arguments
1784 ///
1785 /// * `experience_id` - The experience to query relations for
1786 /// * `direction` - Which direction(s) to traverse
1787 /// * `relation_type` - If `Some`, only return relations of this type
1788 ///
1789 /// # Example
1790 ///
1791 /// ```rust
1792 /// # fn main() -> pulsedb::Result<()> {
1793 /// # let dir = tempfile::tempdir().unwrap();
1794 /// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
1795 /// # let cid = db.create_collective("example")?;
1796 /// # let exp_a = db.record_experience(pulsedb::NewExperience {
1797 /// # collective_id: cid,
1798 /// # content: "a".into(),
1799 /// # embedding: Some(vec![0.1f32; 384]),
1800 /// # ..Default::default()
1801 /// # })?;
1802 /// use pulsedb::{RelationType, RelationDirection};
1803 ///
1804 /// // Only "Supports" relations outgoing from exp_a
1805 /// let supports = db.get_related_experiences_filtered(
1806 /// exp_a,
1807 /// RelationDirection::Outgoing,
1808 /// Some(RelationType::Supports),
1809 /// )?;
1810 /// # Ok(())
1811 /// # }
1812 /// ```
1813 #[instrument(skip(self))]
1814 pub fn get_related_experiences_filtered(
1815 &self,
1816 experience_id: ExperienceId,
1817 direction: crate::relation::RelationDirection,
1818 relation_type: Option<crate::relation::RelationType>,
1819 ) -> Result<Vec<(Experience, crate::relation::ExperienceRelation)>> {
1820 use crate::relation::RelationDirection;
1821
1822 let mut results = Vec::new();
1823
1824 // Outgoing: this experience is the source → fetch target experiences
1825 if matches!(
1826 direction,
1827 RelationDirection::Outgoing | RelationDirection::Both
1828 ) {
1829 let rel_ids = self.storage.get_relation_ids_by_source(experience_id)?;
1830 for rel_id in rel_ids {
1831 if let Some(relation) = self.storage.get_relation(rel_id)? {
1832 if relation_type.is_some_and(|rt| rt != relation.relation_type) {
1833 continue;
1834 }
1835 if let Some(experience) = self.storage.get_experience(relation.target_id)? {
1836 results.push((experience, relation));
1837 }
1838 }
1839 }
1840 }
1841
1842 // Incoming: this experience is the target → fetch source experiences
1843 if matches!(
1844 direction,
1845 RelationDirection::Incoming | RelationDirection::Both
1846 ) {
1847 let rel_ids = self.storage.get_relation_ids_by_target(experience_id)?;
1848 for rel_id in rel_ids {
1849 if let Some(relation) = self.storage.get_relation(rel_id)? {
1850 if relation_type.is_some_and(|rt| rt != relation.relation_type) {
1851 continue;
1852 }
1853 if let Some(experience) = self.storage.get_experience(relation.source_id)? {
1854 results.push((experience, relation));
1855 }
1856 }
1857 }
1858 }
1859
1860 Ok(results)
1861 }
1862
1863 /// Retrieves a relation by ID.
1864 ///
1865 /// Returns `None` if no relation with the given ID exists.
1866 pub fn get_relation(
1867 &self,
1868 id: crate::types::RelationId,
1869 ) -> Result<Option<crate::relation::ExperienceRelation>> {
1870 self.storage.get_relation(id)
1871 }
1872
1873 /// Deletes a relation by ID.
1874 ///
1875 /// # Errors
1876 ///
1877 /// Returns [`NotFoundError::Relation`] if no relation with the given ID exists.
1878 #[instrument(skip(self))]
1879 pub fn delete_relation(&self, id: crate::types::RelationId) -> Result<()> {
1880 self.check_writable()?;
1881 let deleted = self.storage.delete_relation(id)?;
1882 if !deleted {
1883 return Err(PulseDBError::from(NotFoundError::relation(id)));
1884 }
1885 info!(id = %id, "Relation deleted");
1886 Ok(())
1887 }
1888
1889 // =========================================================================
1890 // Derived Insights (E3-S02)
1891 // =========================================================================
1892
1893 /// Stores a new derived insight.
1894 ///
1895 /// Creates a synthesized knowledge record from multiple source experiences.
1896 /// The method:
1897 /// 1. Validates the input (content, confidence, sources)
1898 /// 2. Verifies the collective exists
1899 /// 3. Verifies all source experiences exist and belong to the same collective
1900 /// 4. Resolves the embedding (generates if Builtin, requires if External)
1901 /// 5. Stores the insight with inline embedding
1902 /// 6. Inserts into the insight HNSW index
1903 ///
1904 /// # Arguments
1905 ///
1906 /// * `insight` - The insight to store (see [`NewDerivedInsight`])
1907 ///
1908 /// # Errors
1909 ///
1910 /// - [`ValidationError`](crate::ValidationError) if input is invalid
1911 /// - [`NotFoundError::Collective`] if the collective doesn't exist
1912 /// - [`NotFoundError::Experience`] if any source experience doesn't exist
1913 /// - [`ValidationError::InvalidField`] if source experiences belong to
1914 /// different collectives
1915 /// - [`ValidationError::DimensionMismatch`] if embedding dimension is wrong
1916 #[instrument(skip(self, insight), fields(collective_id = %insight.collective_id))]
1917 pub fn store_insight(&self, insight: NewDerivedInsight) -> Result<InsightId> {
1918 self.check_writable()?;
1919 let is_external = matches!(self.config.embedding_provider, EmbeddingProvider::External);
1920
1921 // Validate input fields
1922 validate_new_insight(&insight)?;
1923
1924 // Verify collective exists
1925 let collective = self
1926 .storage
1927 .get_collective(insight.collective_id)?
1928 .ok_or_else(|| PulseDBError::from(NotFoundError::collective(insight.collective_id)))?;
1929
1930 // Verify all source experiences exist and belong to this collective
1931 for source_id in &insight.source_experience_ids {
1932 let source_exp = self
1933 .storage
1934 .get_experience(*source_id)?
1935 .ok_or_else(|| PulseDBError::from(NotFoundError::experience(*source_id)))?;
1936 if source_exp.collective_id != insight.collective_id {
1937 return Err(PulseDBError::from(ValidationError::invalid_field(
1938 "source_experience_ids",
1939 format!(
1940 "experience {} belongs to collective {}, not {}",
1941 source_id, source_exp.collective_id, insight.collective_id
1942 ),
1943 )));
1944 }
1945 }
1946
1947 // Resolve embedding
1948 let embedding = match insight.embedding {
1949 Some(ref emb) => {
1950 // Validate dimension
1951 let expected_dim = collective.embedding_dimension as usize;
1952 if emb.len() != expected_dim {
1953 return Err(ValidationError::dimension_mismatch(expected_dim, emb.len()).into());
1954 }
1955 emb.clone()
1956 }
1957 None => {
1958 if is_external {
1959 return Err(PulseDBError::embedding(
1960 "embedding is required when using External embedding provider",
1961 ));
1962 }
1963 self.embedding.embed(&insight.content)?
1964 }
1965 };
1966
1967 let embedding_for_hnsw = embedding.clone();
1968 let now = Timestamp::now();
1969 let id = InsightId::new();
1970
1971 // Construct the full insight record
1972 let derived_insight = DerivedInsight {
1973 id,
1974 collective_id: insight.collective_id,
1975 content: insight.content,
1976 embedding,
1977 source_experience_ids: insight.source_experience_ids,
1978 insight_type: insight.insight_type,
1979 confidence: insight.confidence,
1980 domain: insight.domain,
1981 created_at: now,
1982 updated_at: now,
1983 };
1984
1985 // Write to redb FIRST (source of truth)
1986 self.storage.save_insight(&derived_insight)?;
1987
1988 // Insert into insight HNSW index (using InsightId→ExperienceId byte conversion)
1989 let exp_id = ExperienceId::from_bytes(*id.as_bytes());
1990 let insight_vectors = self
1991 .insight_vectors
1992 .read()
1993 .map_err(|_| PulseDBError::vector("Insight vectors lock poisoned"))?;
1994 if let Some(index) = insight_vectors.get(&insight.collective_id) {
1995 index.insert_experience(exp_id, &embedding_for_hnsw)?;
1996 }
1997
1998 info!(id = %id, "Insight stored");
1999 Ok(id)
2000 }
2001
2002 /// Retrieves a derived insight by ID.
2003 ///
2004 /// Returns `None` if no insight with the given ID exists.
2005 #[instrument(skip(self))]
2006 pub fn get_insight(&self, id: InsightId) -> Result<Option<DerivedInsight>> {
2007 self.storage.get_insight(id)
2008 }
2009
2010 /// Searches for insights semantically similar to the query embedding.
2011 ///
2012 /// Uses the insight-specific HNSW index for approximate nearest neighbor
2013 /// search, then fetches full insight records from storage.
2014 ///
2015 /// # Arguments
2016 ///
2017 /// * `collective_id` - The collective to search within
2018 /// * `query` - Query embedding vector (must match collective's dimension)
2019 /// * `k` - Maximum number of results to return
2020 ///
2021 /// # Errors
2022 ///
2023 /// - [`ValidationError::DimensionMismatch`] if `query.len()` doesn't match
2024 /// - [`NotFoundError::Collective`] if the collective doesn't exist
2025 #[instrument(skip(self, query))]
2026 pub fn get_insights(
2027 &self,
2028 collective_id: CollectiveId,
2029 query: &[f32],
2030 k: usize,
2031 ) -> Result<Vec<(DerivedInsight, f32)>> {
2032 // Verify collective exists and check embedding dimension
2033 let collective = self
2034 .storage
2035 .get_collective(collective_id)?
2036 .ok_or_else(|| PulseDBError::from(NotFoundError::collective(collective_id)))?;
2037
2038 let expected_dim = collective.embedding_dimension as usize;
2039 if query.len() != expected_dim {
2040 return Err(ValidationError::dimension_mismatch(expected_dim, query.len()).into());
2041 }
2042
2043 let ef_search = self.config.hnsw.ef_search;
2044
2045 // Search insight HNSW — returns (ExperienceId, distance) pairs
2046 let insight_vectors = self
2047 .insight_vectors
2048 .read()
2049 .map_err(|_| PulseDBError::vector("Insight vectors lock poisoned"))?;
2050
2051 let candidates = match insight_vectors.get(&collective_id) {
2052 Some(index) => index.search_experiences(query, k, ef_search)?,
2053 None => return Ok(vec![]),
2054 };
2055 drop(insight_vectors);
2056
2057 // Convert ExperienceId back to InsightId and fetch records
2058 let mut results = Vec::with_capacity(candidates.len());
2059 for (exp_id, distance) in candidates {
2060 let insight_id = InsightId::from_bytes(*exp_id.as_bytes());
2061 if let Some(insight) = self.storage.get_insight(insight_id)? {
2062 // Convert HNSW distance to similarity (1.0 - distance), matching search_similar pattern
2063 results.push((insight, 1.0 - distance));
2064 }
2065 }
2066
2067 Ok(results)
2068 }
2069
2070 /// Deletes a derived insight by ID.
2071 ///
2072 /// Removes the insight from storage and soft-deletes it from the HNSW index.
2073 ///
2074 /// # Errors
2075 ///
2076 /// Returns [`NotFoundError::Insight`] if no insight with the given ID exists.
2077 #[instrument(skip(self))]
2078 pub fn delete_insight(&self, id: InsightId) -> Result<()> {
2079 self.check_writable()?;
2080 // Read insight first to get collective_id for HNSW lookup
2081 let insight = self
2082 .storage
2083 .get_insight(id)?
2084 .ok_or_else(|| PulseDBError::from(NotFoundError::insight(id)))?;
2085
2086 // Delete from redb FIRST (source of truth)
2087 self.storage.delete_insight(id)?;
2088
2089 // Soft-delete from insight HNSW (using InsightId→ExperienceId byte conversion)
2090 let exp_id = ExperienceId::from_bytes(*id.as_bytes());
2091 let insight_vectors = self
2092 .insight_vectors
2093 .read()
2094 .map_err(|_| PulseDBError::vector("Insight vectors lock poisoned"))?;
2095 if let Some(index) = insight_vectors.get(&insight.collective_id) {
2096 index.delete_experience(exp_id)?;
2097 }
2098
2099 info!(id = %id, "Insight deleted");
2100 Ok(())
2101 }
2102
2103 // =========================================================================
2104 // Activity Tracking (E3-S03)
2105 // =========================================================================
2106
2107 /// Registers an agent's presence in a collective.
2108 ///
2109 /// Creates a new activity record or replaces an existing one for the
2110 /// same `(collective_id, agent_id)` pair (upsert semantics). Both
2111 /// `started_at` and `last_heartbeat` are set to `Timestamp::now()`.
2112 ///
2113 /// # Arguments
2114 ///
2115 /// * `activity` - The activity registration (see [`NewActivity`])
2116 ///
2117 /// # Errors
2118 ///
2119 /// - [`ValidationError`] if agent_id is empty or fields exceed size limits
2120 /// - [`NotFoundError::Collective`] if the collective doesn't exist
2121 ///
2122 /// # Example
2123 ///
2124 /// ```rust
2125 /// # fn main() -> pulsedb::Result<()> {
2126 /// # let dir = tempfile::tempdir().unwrap();
2127 /// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
2128 /// # let collective_id = db.create_collective("example")?;
2129 /// use pulsedb::NewActivity;
2130 ///
2131 /// db.register_activity(NewActivity {
2132 /// agent_id: "claude-opus".to_string(),
2133 /// collective_id,
2134 /// current_task: Some("Reviewing pull request".to_string()),
2135 /// context_summary: None,
2136 /// })?;
2137 /// # Ok(())
2138 /// # }
2139 /// ```
2140 #[instrument(skip(self, activity), fields(agent_id = %activity.agent_id, collective_id = %activity.collective_id))]
2141 pub fn register_activity(&self, activity: NewActivity) -> Result<()> {
2142 // Validate input
2143 validate_new_activity(&activity)?;
2144
2145 // Verify collective exists
2146 self.storage
2147 .get_collective(activity.collective_id)?
2148 .ok_or_else(|| PulseDBError::from(NotFoundError::collective(activity.collective_id)))?;
2149
2150 // Build stored activity with timestamps
2151 let now = Timestamp::now();
2152 let stored = Activity {
2153 agent_id: activity.agent_id,
2154 collective_id: activity.collective_id,
2155 current_task: activity.current_task,
2156 context_summary: activity.context_summary,
2157 started_at: now,
2158 last_heartbeat: now,
2159 };
2160
2161 self.storage.save_activity(&stored)?;
2162
2163 info!(
2164 agent_id = %stored.agent_id,
2165 collective_id = %stored.collective_id,
2166 "Activity registered"
2167 );
2168 Ok(())
2169 }
2170
2171 /// Updates an agent's heartbeat timestamp.
2172 ///
2173 /// Refreshes the `last_heartbeat` to `Timestamp::now()` without changing
2174 /// any other fields. The agent must have an existing activity registered.
2175 ///
2176 /// # Errors
2177 ///
2178 /// - [`NotFoundError::Activity`] if no activity exists for the agent/collective pair
2179 #[instrument(skip(self))]
2180 pub fn update_heartbeat(&self, agent_id: &str, collective_id: CollectiveId) -> Result<()> {
2181 self.check_writable()?;
2182 let mut activity = self
2183 .storage
2184 .get_activity(agent_id, collective_id)?
2185 .ok_or_else(|| {
2186 PulseDBError::from(NotFoundError::activity(format!(
2187 "{} in {}",
2188 agent_id, collective_id
2189 )))
2190 })?;
2191
2192 activity.last_heartbeat = Timestamp::now();
2193 self.storage.save_activity(&activity)?;
2194
2195 info!(agent_id = %agent_id, collective_id = %collective_id, "Heartbeat updated");
2196 Ok(())
2197 }
2198
2199 /// Ends an agent's activity in a collective.
2200 ///
2201 /// Removes the activity record. After calling this, the agent will no
2202 /// longer appear in `get_active_agents()` results.
2203 ///
2204 /// # Errors
2205 ///
2206 /// - [`NotFoundError::Activity`] if no activity exists for the agent/collective pair
2207 #[instrument(skip(self))]
2208 pub fn end_activity(&self, agent_id: &str, collective_id: CollectiveId) -> Result<()> {
2209 let deleted = self.storage.delete_activity(agent_id, collective_id)?;
2210
2211 if !deleted {
2212 return Err(PulseDBError::from(NotFoundError::activity(format!(
2213 "{} in {}",
2214 agent_id, collective_id
2215 ))));
2216 }
2217
2218 info!(agent_id = %agent_id, collective_id = %collective_id, "Activity ended");
2219 Ok(())
2220 }
2221
2222 /// Returns all active (non-stale) agents in a collective.
2223 ///
2224 /// Fetches all activities, filters out those whose `last_heartbeat` is
2225 /// older than `config.activity.stale_threshold`, and returns the rest
2226 /// sorted by `last_heartbeat` descending (most recently active first).
2227 ///
2228 /// # Errors
2229 ///
2230 /// - [`NotFoundError::Collective`] if the collective doesn't exist
2231 #[instrument(skip(self))]
2232 pub fn get_active_agents(&self, collective_id: CollectiveId) -> Result<Vec<Activity>> {
2233 // Verify collective exists
2234 self.storage
2235 .get_collective(collective_id)?
2236 .ok_or_else(|| PulseDBError::from(NotFoundError::collective(collective_id)))?;
2237
2238 let all_activities = self.storage.list_activities_in_collective(collective_id)?;
2239
2240 // Filter stale activities
2241 let now = Timestamp::now();
2242 let threshold_ms = self.config.activity.stale_threshold.as_millis() as i64;
2243 let cutoff = now.as_millis() - threshold_ms;
2244
2245 let mut active: Vec<Activity> = all_activities
2246 .into_iter()
2247 .filter(|a| a.last_heartbeat.as_millis() >= cutoff)
2248 .collect();
2249
2250 // Sort by last_heartbeat descending (most recently active first)
2251 active.sort_by_key(|a| std::cmp::Reverse(a.last_heartbeat));
2252
2253 Ok(active)
2254 }
2255
2256 // =========================================================================
2257 // Context Candidates (E2-S04)
2258 // =========================================================================
2259
2260 /// Retrieves unified context candidates from all retrieval primitives.
2261 ///
2262 /// This is the primary API for context assembly. It orchestrates:
2263 /// 1. Similarity search ([`search_similar_filtered`](Self::search_similar_filtered))
2264 /// 2. Recent experiences ([`get_recent_experiences_filtered`](Self::get_recent_experiences_filtered))
2265 /// 3. Insight search ([`get_insights`](Self::get_insights)) — if requested
2266 /// 4. Relation collection ([`get_related_experiences`](Self::get_related_experiences)) — if requested
2267 /// 5. Active agents ([`get_active_agents`](Self::get_active_agents)) — if requested
2268 ///
2269 /// # Arguments
2270 ///
2271 /// * `request` - Configuration for which primitives to query and limits
2272 ///
2273 /// # Errors
2274 ///
2275 /// - [`ValidationError::InvalidField`] if `max_similar` or `max_recent` is 0 or > 1000
2276 /// - [`ValidationError::DimensionMismatch`] if `query_embedding.len()` doesn't match
2277 /// the collective's embedding dimension
2278 /// - [`NotFoundError::Collective`] if the collective doesn't exist
2279 ///
2280 /// # Performance
2281 ///
2282 /// Target: < 100ms at 100K experiences. The similarity search (~50ms) dominates;
2283 /// all other sub-calls are < 10ms each.
2284 ///
2285 /// # Example
2286 ///
2287 /// ```rust
2288 /// # fn main() -> pulsedb::Result<()> {
2289 /// # let dir = tempfile::tempdir().unwrap();
2290 /// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
2291 /// # let collective_id = db.create_collective("example")?;
2292 /// # let query_vec = vec![0.1f32; 384];
2293 /// use pulsedb::{ContextRequest, SearchFilter};
2294 ///
2295 /// let candidates = db.get_context_candidates(ContextRequest {
2296 /// collective_id,
2297 /// query_embedding: query_vec,
2298 /// max_similar: 10,
2299 /// max_recent: 5,
2300 /// include_insights: true,
2301 /// include_relations: true,
2302 /// include_active_agents: true,
2303 /// filter: SearchFilter {
2304 /// domains: Some(vec!["rust".to_string()]),
2305 /// ..SearchFilter::default()
2306 /// },
2307 /// ..ContextRequest::default()
2308 /// })?;
2309 /// # Ok(())
2310 /// # }
2311 /// ```
2312 #[instrument(skip(self, request), fields(collective_id = %request.collective_id))]
2313 pub fn get_context_candidates(&self, request: ContextRequest) -> Result<ContextCandidates> {
2314 // ── Validate limits ──────────────────────────────────────
2315 if request.max_similar == 0 || request.max_similar > 1000 {
2316 return Err(ValidationError::invalid_field(
2317 "max_similar",
2318 "must be between 1 and 1000",
2319 )
2320 .into());
2321 }
2322 if request.max_recent == 0 || request.max_recent > 1000 {
2323 return Err(
2324 ValidationError::invalid_field("max_recent", "must be between 1 and 1000").into(),
2325 );
2326 }
2327
2328 // ── Verify collective exists and check dimension ─────────
2329 let collective = self
2330 .storage
2331 .get_collective(request.collective_id)?
2332 .ok_or_else(|| PulseDBError::from(NotFoundError::collective(request.collective_id)))?;
2333
2334 let expected_dim = collective.embedding_dimension as usize;
2335 if request.query_embedding.len() != expected_dim {
2336 return Err(ValidationError::dimension_mismatch(
2337 expected_dim,
2338 request.query_embedding.len(),
2339 )
2340 .into());
2341 }
2342
2343 // ── 1. Similar experiences (HNSW vector search) ──────────
2344 let similar_experiences = self.search(
2345 request.collective_id,
2346 &request.query_embedding,
2347 SearchOptions {
2348 k: request.max_similar,
2349 filter: request.filter.clone(),
2350 weights: request.recall_weights,
2351 },
2352 )?;
2353
2354 // ── 2. Recent experiences (timestamp index scan) ─────────
2355 let recent_experiences = self.get_recent_experiences_filtered(
2356 request.collective_id,
2357 request.max_recent,
2358 request.filter,
2359 )?;
2360
2361 // ── 3. Insights (HNSW vector search on insight index) ────
2362 let insights = if request.include_insights {
2363 self.get_insights(
2364 request.collective_id,
2365 &request.query_embedding,
2366 request.max_similar,
2367 )?
2368 .into_iter()
2369 .map(|(insight, _score)| insight)
2370 .collect()
2371 } else {
2372 vec![]
2373 };
2374
2375 // ── 4. Relations (graph traversal from result experiences) ─
2376 let relations = if request.include_relations {
2377 use std::collections::HashSet;
2378
2379 let mut seen = HashSet::new();
2380 let mut all_relations = Vec::new();
2381
2382 // Collect unique experience IDs from both result sets
2383 let exp_ids: Vec<_> = similar_experiences
2384 .iter()
2385 .map(|r| r.experience.id)
2386 .chain(recent_experiences.iter().map(|e| e.id))
2387 .collect();
2388
2389 for exp_id in exp_ids {
2390 let related =
2391 self.get_related_experiences(exp_id, crate::relation::RelationDirection::Both)?;
2392
2393 for (_experience, relation) in related {
2394 if seen.insert(relation.id) {
2395 all_relations.push(relation);
2396 }
2397 }
2398 }
2399
2400 all_relations
2401 } else {
2402 vec![]
2403 };
2404
2405 // ── 5. Active agents (staleness-filtered activity records) ─
2406 let active_agents = if request.include_active_agents {
2407 self.get_active_agents(request.collective_id)?
2408 } else {
2409 vec![]
2410 };
2411
2412 Ok(ContextCandidates {
2413 similar_experiences,
2414 recent_experiences,
2415 insights,
2416 relations,
2417 active_agents,
2418 })
2419 }
2420
2421 /// Inserts a backdated experience fixture into storage and the vector index.
2422 #[cfg(test)]
2423 pub(crate) fn insert_experience_backdated(
2424 &self,
2425 collective_id: CollectiveId,
2426 content: &str,
2427 embedding: Vec<f32>,
2428 importance: f32,
2429 applications: BTreeMap<crate::types::InstanceId, u32>,
2430 last_reinforced: Timestamp,
2431 ) -> Result<ExperienceId> {
2432 self.check_writable()?;
2433 let embedding_for_hnsw = embedding.clone();
2434 let now = Timestamp::now();
2435 let experience = Experience {
2436 id: ExperienceId::new(),
2437 collective_id,
2438 content: content.to_string(),
2439 embedding,
2440 experience_type: crate::experience::ExperienceType::default(),
2441 importance,
2442 confidence: 0.8,
2443 applications,
2444 domain: vec![],
2445 related_files: vec![],
2446 source_agent: crate::types::AgentId::new("test"),
2447 source_task: None,
2448 timestamp: now,
2449 last_reinforced,
2450 archived: false,
2451 };
2452 let id = experience.id;
2453
2454 self.storage.save_experience(&experience)?;
2455 let vectors = self
2456 .vectors
2457 .read()
2458 .map_err(|_| PulseDBError::vector("Vectors lock poisoned"))?;
2459 let index = vectors
2460 .get(&collective_id)
2461 .ok_or_else(|| PulseDBError::vector("HNSW index missing for collective"))?;
2462 index.insert_experience(id, &embedding_for_hnsw)?;
2463
2464 Ok(id)
2465 }
2466
2467 /// Stores a collective decay config fixture for tests.
2468 #[cfg(test)]
2469 pub(crate) fn set_decay_config_for_test(
2470 &self,
2471 collective_id: CollectiveId,
2472 config: DecayConfig,
2473 ) -> Result<()> {
2474 self.storage.set_decay_config(collective_id, config)
2475 }
2476
2477 // =========================================================================
2478 // Watch System (E4-S01)
2479 // =========================================================================
2480
2481 /// Subscribes to all experience changes in a collective.
2482 ///
2483 /// Returns a [`WatchStream`] that yields [`WatchEvent`] values for every
2484 /// create, update, archive, and delete operation. The stream ends when
2485 /// dropped or when the `PulseDB` instance is closed.
2486 ///
2487 /// Multiple subscribers per collective are supported. Each gets an
2488 /// independent copy of every event.
2489 ///
2490 /// # Example
2491 ///
2492 /// ```rust,no_run
2493 /// # #[tokio::main]
2494 /// # async fn main() -> pulsedb::Result<()> {
2495 /// # let dir = tempfile::tempdir().unwrap();
2496 /// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
2497 /// # let collective_id = db.create_collective("example")?;
2498 /// use futures::StreamExt;
2499 ///
2500 /// let mut stream = db.watch_experiences(collective_id)?;
2501 /// while let Some(event) = stream.next().await {
2502 /// println!("{:?}: {}", event.event_type, event.experience_id);
2503 /// }
2504 /// # Ok(())
2505 /// # }
2506 /// ```
2507 pub fn watch_experiences(&self, collective_id: CollectiveId) -> Result<WatchStream> {
2508 self.watch.subscribe(collective_id, None)
2509 }
2510
2511 /// Subscribes to filtered experience changes in a collective.
2512 ///
2513 /// Like [`watch_experiences`](Self::watch_experiences), but only delivers
2514 /// events that match the filter criteria. Filters are applied on the
2515 /// sender side before channel delivery.
2516 ///
2517 /// # Example
2518 ///
2519 /// ```rust
2520 /// # fn main() -> pulsedb::Result<()> {
2521 /// # let dir = tempfile::tempdir().unwrap();
2522 /// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
2523 /// # let collective_id = db.create_collective("example")?;
2524 /// use pulsedb::WatchFilter;
2525 ///
2526 /// let filter = WatchFilter {
2527 /// domains: Some(vec!["security".to_string()]),
2528 /// min_importance: Some(0.7),
2529 /// ..Default::default()
2530 /// };
2531 /// let mut stream = db.watch_experiences_filtered(collective_id, filter)?;
2532 /// # Ok(())
2533 /// # }
2534 /// ```
2535 pub fn watch_experiences_filtered(
2536 &self,
2537 collective_id: CollectiveId,
2538 filter: WatchFilter,
2539 ) -> Result<WatchStream> {
2540 self.watch.subscribe(collective_id, Some(filter))
2541 }
2542
2543 // =========================================================================
2544 // Cross-Process Watch (E4-S02)
2545 // =========================================================================
2546
2547 /// Returns the current WAL sequence number.
2548 ///
2549 /// Use this to establish a baseline before starting to poll for changes.
2550 /// Returns 0 if no experience writes have occurred yet.
2551 ///
2552 /// # Example
2553 ///
2554 /// ```rust
2555 /// # fn main() -> pulsedb::Result<()> {
2556 /// # let dir = tempfile::tempdir().unwrap();
2557 /// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
2558 /// let seq = db.get_current_sequence()?;
2559 /// // ... later ...
2560 /// let (events, new_seq) = db.poll_changes(seq)?;
2561 /// # Ok(())
2562 /// # }
2563 /// ```
2564 pub fn get_current_sequence(&self) -> Result<u64> {
2565 self.storage.get_wal_sequence()
2566 }
2567
2568 /// Polls for experience changes since the given sequence number.
2569 ///
2570 /// Returns a tuple of `(events, new_sequence)`:
2571 /// - `events`: New [`WatchEvent`]s in sequence order
2572 /// - `new_sequence`: Pass this value back on the next call
2573 ///
2574 /// Returns an empty vec and the same sequence if no changes exist.
2575 ///
2576 /// # Arguments
2577 ///
2578 /// * `since_seq` - The last sequence number you received (0 for first call)
2579 ///
2580 /// # Performance
2581 ///
2582 /// Target: < 10ms per call. Internally performs a range scan on the
2583 /// watch_events table, O(k) where k is the number of new events.
2584 ///
2585 /// # Example
2586 ///
2587 /// ```rust,no_run
2588 /// # fn main() -> pulsedb::Result<()> {
2589 /// # let dir = tempfile::tempdir().unwrap();
2590 /// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
2591 /// use std::time::Duration;
2592 ///
2593 /// let mut seq = 0u64;
2594 /// loop {
2595 /// let (events, new_seq) = db.poll_changes(seq)?;
2596 /// seq = new_seq;
2597 /// for event in events {
2598 /// println!("{:?}: {}", event.event_type, event.experience_id);
2599 /// }
2600 /// std::thread::sleep(Duration::from_millis(100));
2601 /// }
2602 /// # }
2603 /// ```
2604 pub fn poll_changes(&self, since_seq: u64) -> Result<(Vec<WatchEvent>, u64)> {
2605 use crate::storage::schema::EntityTypeTag;
2606 let (records, new_seq) = self.storage.poll_watch_events(since_seq, 1000)?;
2607 let events = records
2608 .into_iter()
2609 .filter(|r| r.entity_type == EntityTypeTag::Experience)
2610 .map(WatchEvent::from)
2611 .collect();
2612 Ok((events, new_seq))
2613 }
2614
2615 /// Polls for changes with a custom batch size limit.
2616 ///
2617 /// Same as [`poll_changes`](Self::poll_changes) but returns at most
2618 /// `limit` events per call. Use this for backpressure control.
2619 pub fn poll_changes_batch(
2620 &self,
2621 since_seq: u64,
2622 limit: usize,
2623 ) -> Result<(Vec<WatchEvent>, u64)> {
2624 use crate::storage::schema::EntityTypeTag;
2625 let (records, new_seq) = self.storage.poll_watch_events(since_seq, limit)?;
2626 let events = records
2627 .into_iter()
2628 .filter(|r| r.entity_type == EntityTypeTag::Experience)
2629 .map(WatchEvent::from)
2630 .collect();
2631 Ok((events, new_seq))
2632 }
2633
2634 // =========================================================================
2635 // Sync WAL Compaction (feature: sync)
2636 // =========================================================================
2637
2638 /// Compacts the WAL by removing events that all peers have already synced.
2639 ///
2640 /// Finds the minimum cursor across all known peers and deletes WAL events
2641 /// up to that sequence. If no peers exist, no compaction occurs (events
2642 /// may be needed when a peer connects later).
2643 ///
2644 /// Call this periodically (e.g., daily) to reclaim disk space.
2645 /// Returns the number of WAL events deleted.
2646 ///
2647 /// # Example
2648 ///
2649 /// ```rust,no_run
2650 /// # fn main() -> pulsedb::Result<()> {
2651 /// # let dir = tempfile::tempdir().unwrap();
2652 /// # let db = pulsedb::PulseDB::open(dir.path().join("test.db"), pulsedb::Config::default())?;
2653 /// let deleted = db.compact_wal()?;
2654 /// println!("Compacted {} WAL events", deleted);
2655 /// # Ok(())
2656 /// # }
2657 /// ```
2658 #[cfg(feature = "sync")]
2659 pub fn compact_wal(&self) -> Result<u64> {
2660 let cursors = self
2661 .storage
2662 .list_sync_cursors()
2663 .map_err(|e| PulseDBError::internal(format!("Failed to list sync cursors: {}", e)))?;
2664
2665 if cursors.is_empty() {
2666 // No peers — don't compact (events may be needed later)
2667 return Ok(0);
2668 }
2669
2670 let min_seq = cursors.iter().map(|c| c.last_sequence).min().unwrap_or(0);
2671
2672 if min_seq == 0 {
2673 return Ok(0);
2674 }
2675
2676 let deleted = self.storage.compact_wal_events(min_seq)?;
2677 info!(deleted, min_seq, "WAL compacted");
2678 Ok(deleted)
2679 }
2680
2681 // =========================================================================
2682 // Sync Apply Methods (feature: sync)
2683 // =========================================================================
2684 //
2685 // These methods apply remote changes received via sync. They bypass
2686 // validation and embedding generation (data was validated on the source).
2687 // WAL recording is suppressed by the SyncApplyGuard (entered by the caller).
2688 // Watch emit is skipped (no in-process notifications for sync changes).
2689 //
2690 // These are pub(crate) and will be called by the sync applier in Phase 3.
2691
2692 /// Applies a synced experience from a remote peer.
2693 ///
2694 /// Writes the full experience to storage and inserts into HNSW.
2695 /// Caller must hold `SyncApplyGuard` to suppress WAL recording.
2696 #[cfg(feature = "sync")]
2697 #[allow(dead_code)] // Called by sync applier (Phase 3)
2698 pub fn apply_synced_experience(&self, experience: Experience) -> Result<()> {
2699 let collective_id = experience.collective_id;
2700 let id = experience.id;
2701 let embedding = experience.embedding.clone();
2702
2703 self.storage.save_experience(&experience)?;
2704
2705 // Insert into HNSW index
2706 let vectors = self
2707 .vectors
2708 .read()
2709 .map_err(|_| PulseDBError::vector("Vectors lock poisoned"))?;
2710 if let Some(index) = vectors.get(&collective_id) {
2711 index.insert_experience(id, &embedding)?;
2712 }
2713
2714 debug!(id = %id, "Synced experience applied");
2715 Ok(())
2716 }
2717
2718 /// Applies a synced experience update from a remote peer.
2719 ///
2720 /// Caller must hold `SyncApplyGuard` to suppress WAL recording.
2721 #[cfg(feature = "sync")]
2722 #[allow(dead_code)] // Called by sync applier (Phase 3)
2723 pub fn apply_synced_experience_update(
2724 &self,
2725 id: ExperienceId,
2726 update: ExperienceUpdate,
2727 ) -> Result<()> {
2728 self.storage.update_experience(id, &update)?;
2729 debug!(id = %id, "Synced experience update applied");
2730 Ok(())
2731 }
2732
2733 /// Merges synced G-counter reinforcement fields from a remote peer.
2734 ///
2735 /// Caller must hold `SyncApplyGuard` to suppress WAL recording.
2736 #[cfg(feature = "sync")]
2737 pub(crate) fn apply_synced_experience_counter_merge(
2738 &self,
2739 id: ExperienceId,
2740 applications: &BTreeMap<InstanceId, u32>,
2741 last_reinforced: Option<Timestamp>,
2742 ) -> Result<bool> {
2743 let merged =
2744 self.storage
2745 .merge_experience_applications(id, applications, last_reinforced)?;
2746 if merged {
2747 debug!(id = %id, "Synced experience counter merge applied");
2748 }
2749 Ok(merged)
2750 }
2751
2752 /// Applies a synced experience deletion from a remote peer.
2753 ///
2754 /// Removes from storage and soft-deletes from HNSW.
2755 /// Caller must hold `SyncApplyGuard` to suppress WAL recording.
2756 #[cfg(feature = "sync")]
2757 #[allow(dead_code)] // Called by sync applier (Phase 3)
2758 pub fn apply_synced_experience_delete(&self, id: ExperienceId) -> Result<()> {
2759 // Get collective_id for HNSW lookup before deleting
2760 if let Some(exp) = self.storage.get_experience(id)? {
2761 let collective_id = exp.collective_id;
2762
2763 // Cascade delete relations
2764 self.storage.delete_relations_for_experience(id)?;
2765
2766 self.storage.delete_experience(id)?;
2767
2768 // Soft-delete from HNSW
2769 let vectors = self
2770 .vectors
2771 .read()
2772 .map_err(|_| PulseDBError::vector("Vectors lock poisoned"))?;
2773 if let Some(index) = vectors.get(&collective_id) {
2774 index.delete_experience(id)?;
2775 }
2776 }
2777
2778 debug!(id = %id, "Synced experience delete applied");
2779 Ok(())
2780 }
2781
2782 /// Applies a synced relation from a remote peer.
2783 ///
2784 /// Caller must hold `SyncApplyGuard` to suppress WAL recording.
2785 #[cfg(feature = "sync")]
2786 #[allow(dead_code)] // Called by sync applier (Phase 3)
2787 pub fn apply_synced_relation(&self, relation: ExperienceRelation) -> Result<()> {
2788 let id = relation.id;
2789 self.storage.save_relation(&relation)?;
2790 debug!(id = %id, "Synced relation applied");
2791 Ok(())
2792 }
2793
2794 /// Applies a synced relation deletion from a remote peer.
2795 ///
2796 /// Caller must hold `SyncApplyGuard` to suppress WAL recording.
2797 #[cfg(feature = "sync")]
2798 #[allow(dead_code)] // Called by sync applier (Phase 3)
2799 pub fn apply_synced_relation_delete(&self, id: RelationId) -> Result<()> {
2800 self.storage.delete_relation(id)?;
2801 debug!(id = %id, "Synced relation delete applied");
2802 Ok(())
2803 }
2804
2805 /// Applies a synced insight from a remote peer.
2806 ///
2807 /// Writes to storage and inserts into insight HNSW index.
2808 /// Caller must hold `SyncApplyGuard` to suppress WAL recording.
2809 #[cfg(feature = "sync")]
2810 #[allow(dead_code)] // Called by sync applier (Phase 3)
2811 pub fn apply_synced_insight(&self, insight: DerivedInsight) -> Result<()> {
2812 let id = insight.id;
2813 let collective_id = insight.collective_id;
2814 let embedding = insight.embedding.clone();
2815
2816 self.storage.save_insight(&insight)?;
2817
2818 // Insert into insight HNSW (using InsightId→ExperienceId byte conversion)
2819 let exp_id = ExperienceId::from_bytes(*id.as_bytes());
2820 let insight_vectors = self
2821 .insight_vectors
2822 .read()
2823 .map_err(|_| PulseDBError::vector("Insight vectors lock poisoned"))?;
2824 if let Some(index) = insight_vectors.get(&collective_id) {
2825 index.insert_experience(exp_id, &embedding)?;
2826 }
2827
2828 debug!(id = %id, "Synced insight applied");
2829 Ok(())
2830 }
2831
2832 /// Applies a synced insight deletion from a remote peer.
2833 ///
2834 /// Removes from storage and soft-deletes from insight HNSW.
2835 /// Caller must hold `SyncApplyGuard` to suppress WAL recording.
2836 #[cfg(feature = "sync")]
2837 #[allow(dead_code)] // Called by sync applier (Phase 3)
2838 pub fn apply_synced_insight_delete(&self, id: InsightId) -> Result<()> {
2839 if let Some(insight) = self.storage.get_insight(id)? {
2840 self.storage.delete_insight(id)?;
2841
2842 // Soft-delete from insight HNSW
2843 let exp_id = ExperienceId::from_bytes(*id.as_bytes());
2844 let insight_vectors = self
2845 .insight_vectors
2846 .read()
2847 .map_err(|_| PulseDBError::vector("Insight vectors lock poisoned"))?;
2848 if let Some(index) = insight_vectors.get(&insight.collective_id) {
2849 index.delete_experience(exp_id)?;
2850 }
2851 }
2852
2853 debug!(id = %id, "Synced insight delete applied");
2854 Ok(())
2855 }
2856
2857 /// Applies a synced collective from a remote peer.
2858 ///
2859 /// Writes to storage and creates HNSW indexes for the collective.
2860 /// Caller must hold `SyncApplyGuard` to suppress WAL recording.
2861 #[cfg(feature = "sync")]
2862 #[allow(dead_code)] // Called by sync applier (Phase 3)
2863 pub fn apply_synced_collective(&self, collective: Collective) -> Result<()> {
2864 let id = collective.id;
2865 let dimension = collective.embedding_dimension as usize;
2866
2867 self.storage.save_collective(&collective)?;
2868
2869 // Create HNSW indexes (same as create_collective)
2870 let exp_index = crate::vector::HnswIndex::new(dimension, &self.config.hnsw);
2871 let insight_index = crate::vector::HnswIndex::new(dimension, &self.config.hnsw);
2872 self.vectors
2873 .write()
2874 .map_err(|_| PulseDBError::vector("Vectors lock poisoned"))?
2875 .insert(id, exp_index);
2876 self.insight_vectors
2877 .write()
2878 .map_err(|_| PulseDBError::vector("Insight vectors lock poisoned"))?
2879 .insert(id, insight_index);
2880
2881 debug!(id = %id, "Synced collective applied");
2882 Ok(())
2883 }
2884}
2885
2886// PulseDB is auto Send + Sync: Box<dyn StorageEngine + Send + Sync>,
2887// Box<dyn EmbeddingService + Send + Sync>, and Config are all Send + Sync.
2888
2889#[cfg(test)]
2890mod tests {
2891 use super::*;
2892 use crate::config::EmbeddingDimension;
2893 use tempfile::tempdir;
2894
2895 #[test]
2896 fn test_open_creates_database() {
2897 let dir = tempdir().unwrap();
2898 let path = dir.path().join("test.db");
2899
2900 let db = PulseDB::open(&path, Config::default()).unwrap();
2901
2902 assert!(path.exists());
2903 assert_eq!(db.embedding_dimension(), 384);
2904
2905 db.close().unwrap();
2906 }
2907
2908 #[test]
2909 fn test_open_existing_database() {
2910 let dir = tempdir().unwrap();
2911 let path = dir.path().join("test.db");
2912
2913 // Create
2914 let db = PulseDB::open(&path, Config::default()).unwrap();
2915 db.close().unwrap();
2916
2917 // Reopen
2918 let db = PulseDB::open(&path, Config::default()).unwrap();
2919 assert_eq!(db.embedding_dimension(), 384);
2920 db.close().unwrap();
2921 }
2922
2923 #[test]
2924 fn test_config_validation() {
2925 let dir = tempdir().unwrap();
2926 let path = dir.path().join("test.db");
2927
2928 let invalid_config = Config {
2929 cache_size_mb: 0, // Invalid
2930 ..Default::default()
2931 };
2932
2933 let result = PulseDB::open(&path, invalid_config);
2934 assert!(result.is_err());
2935 }
2936
2937 #[test]
2938 fn test_dimension_mismatch() {
2939 let dir = tempdir().unwrap();
2940 let path = dir.path().join("test.db");
2941
2942 // Create with D384
2943 let db = PulseDB::open(
2944 &path,
2945 Config {
2946 embedding_dimension: EmbeddingDimension::D384,
2947 ..Default::default()
2948 },
2949 )
2950 .unwrap();
2951 db.close().unwrap();
2952
2953 // Try to reopen with D768
2954 let result = PulseDB::open(
2955 &path,
2956 Config {
2957 embedding_dimension: EmbeddingDimension::D768,
2958 ..Default::default()
2959 },
2960 );
2961
2962 assert!(result.is_err());
2963 }
2964
2965 #[test]
2966 fn test_metadata_access() {
2967 let dir = tempdir().unwrap();
2968 let path = dir.path().join("test.db");
2969
2970 let db = PulseDB::open(&path, Config::default()).unwrap();
2971
2972 let metadata = db.metadata();
2973 assert_eq!(metadata.embedding_dimension, EmbeddingDimension::D384);
2974
2975 db.close().unwrap();
2976 }
2977
2978 #[test]
2979 fn test_pulsedb_is_send_sync() {
2980 fn assert_send_sync<T: Send + Sync>() {}
2981 assert_send_sync::<PulseDB>();
2982 }
2983
2984 // =========================================================================
2985 // list_cold_experiences — conservative-lifecycle surfacing (VS-3.5.3 / FR-034)
2986 // =========================================================================
2987
2988 /// A 384-d embedding fixture (dimension must match the default D384 index).
2989 fn cold_test_embedding() -> Vec<f32> {
2990 let mut embedding = vec![0.0f32; 384];
2991 embedding[0] = 1.0;
2992 embedding
2993 }
2994
2995 /// Backdates `last_reinforced` by `days` so the fixture decays well below
2996 /// the default `floor` (0.05) under a 30-day half-life.
2997 fn days_ago(days: i64) -> Timestamp {
2998 Timestamp::from_millis(Timestamp::now().as_millis() - days * 24 * 60 * 60 * 1000)
2999 }
3000
3001 /// Opens a default-config db with a single collective.
3002 fn open_cold_fixture(name: &str) -> (tempfile::TempDir, PulseDB, CollectiveId) {
3003 let dir = tempdir().unwrap();
3004 let db = PulseDB::open(dir.path().join(format!("{name}.db")), Config::default()).unwrap();
3005 let collective_id = db.create_collective(name).unwrap();
3006 (dir, db, collective_id)
3007 }
3008
3009 #[test]
3010 fn list_cold_experiences_surfaces_below_floor() {
3011 let (_dir, db, collective_id) = open_cold_fixture("cold-surfaces");
3012 let floor = Config::default().decay.floor; // 0.05
3013
3014 // A cold experience: importance 0.9 last reinforced ~365 days ago decays
3015 // far below the 0.05 floor under the default 30-day half-life.
3016 let cold_id = db
3017 .insert_experience_backdated(
3018 collective_id,
3019 "cold memory",
3020 cold_test_embedding(),
3021 0.9,
3022 std::collections::BTreeMap::new(),
3023 days_ago(365),
3024 )
3025 .unwrap();
3026
3027 // A warm experience: importance 0.9 reinforced now stays at ~0.9 (> floor).
3028 let warm_id = db
3029 .insert_experience_backdated(
3030 collective_id,
3031 "warm memory",
3032 cold_test_embedding(),
3033 0.9,
3034 std::collections::BTreeMap::new(),
3035 Timestamp::now(),
3036 )
3037 .unwrap();
3038
3039 let cold = db.list_cold_experiences(collective_id, floor, 100).unwrap();
3040
3041 // Only the cold experience is surfaced, with its energy reported.
3042 assert_eq!(cold.len(), 1, "exactly one experience is below the floor");
3043 assert_eq!(cold[0].0, cold_id, "the cold experience is surfaced");
3044 assert!(
3045 cold[0].1 < floor,
3046 "reported energy {} is below floor {floor}",
3047 cold[0].1
3048 );
3049 assert!(
3050 !cold.iter().any(|(id, _)| *id == warm_id),
3051 "the warm experience is not surfaced"
3052 );
3053
3054 // Coldest-first ordering: add a second, even-colder experience and assert
3055 // the result is sorted ascending by energy.
3056 let colder_id = db
3057 .insert_experience_backdated(
3058 collective_id,
3059 "even colder memory",
3060 cold_test_embedding(),
3061 0.1,
3062 std::collections::BTreeMap::new(),
3063 days_ago(365),
3064 )
3065 .unwrap();
3066 let cold = db.list_cold_experiences(collective_id, floor, 100).unwrap();
3067 assert_eq!(cold.len(), 2, "both cold experiences are surfaced");
3068 assert!(
3069 cold[0].1 <= cold[1].1,
3070 "results are coldest-first (ascending energy): {:?}",
3071 cold
3072 );
3073 assert_eq!(cold[0].0, colder_id, "the coldest experience comes first");
3074
3075 // limit/below validation: limit 0 and out-of-range `below` are rejected.
3076 assert!(db.list_cold_experiences(collective_id, floor, 0).is_err());
3077 assert!(db.list_cold_experiences(collective_id, 1.5, 100).is_err());
3078 assert!(db
3079 .list_cold_experiences(collective_id, f32::NAN, 100)
3080 .is_err());
3081
3082 db.close().unwrap();
3083 }
3084
3085 #[test]
3086 fn cold_experience_not_auto_archived_by_default() {
3087 // D3 invariant: under DEFAULT config, recording → searching → listing a
3088 // cold experience NEVER flips `archived` — auto_archive_below_floor is
3089 // inert (read by no actuator).
3090 let (_dir, db, collective_id) = open_cold_fixture("auto-archive-off");
3091 let floor = Config::default().decay.floor;
3092
3093 let cold_id = db
3094 .insert_experience_backdated(
3095 collective_id,
3096 "cold-but-not-archived",
3097 cold_test_embedding(),
3098 0.9,
3099 std::collections::BTreeMap::new(),
3100 days_ago(365),
3101 )
3102 .unwrap();
3103
3104 // Freshly recorded: archived must be false.
3105 assert!(
3106 !db.storage
3107 .get_experience(cold_id)
3108 .unwrap()
3109 .unwrap()
3110 .archived,
3111 "archived is false immediately after record"
3112 );
3113
3114 // search: a query touching the collective must not flip archived.
3115 let _ = db
3116 .search_similar(collective_id, &cold_test_embedding(), 10)
3117 .unwrap();
3118 assert!(
3119 !db.storage
3120 .get_experience(cold_id)
3121 .unwrap()
3122 .unwrap()
3123 .archived,
3124 "archived is false after search"
3125 );
3126
3127 // list_cold_experiences surfaces it but must NOT archive it.
3128 let cold = db.list_cold_experiences(collective_id, floor, 100).unwrap();
3129 assert!(
3130 cold.iter().any(|(id, _)| *id == cold_id),
3131 "the cold experience is surfaced"
3132 );
3133 assert!(
3134 !db.storage
3135 .get_experience(cold_id)
3136 .unwrap()
3137 .unwrap()
3138 .archived,
3139 "archived is STILL false after list_cold_experiences (no auto-archive)"
3140 );
3141
3142 db.close().unwrap();
3143 }
3144
3145 #[test]
3146 fn list_cold_excludes_archived_experiences() {
3147 // C5: an experience that is cold (E < below) AND already archived is
3148 // EXCLUDED from the result (prune-eligible = cold and not yet archived).
3149 let (_dir, db, collective_id) = open_cold_fixture("cold-excludes-archived");
3150 let floor = Config::default().decay.floor;
3151
3152 // Two genuinely-cold experiences (both would match E < below).
3153 let surfaced_id = db
3154 .insert_experience_backdated(
3155 collective_id,
3156 "cold not archived",
3157 cold_test_embedding(),
3158 0.9,
3159 std::collections::BTreeMap::new(),
3160 days_ago(365),
3161 )
3162 .unwrap();
3163 let archived_id = db
3164 .insert_experience_backdated(
3165 collective_id,
3166 "cold already archived",
3167 cold_test_embedding(),
3168 0.9,
3169 std::collections::BTreeMap::new(),
3170 days_ago(365),
3171 )
3172 .unwrap();
3173
3174 // Non-vacuity guard: confirm BOTH are below the floor BEFORE archiving —
3175 // so the exclusion below is genuinely the !archived filter at work, not a
3176 // side-effect of the archived experience being warm.
3177 let before = db.list_cold_experiences(collective_id, floor, 100).unwrap();
3178 assert_eq!(
3179 before.len(),
3180 2,
3181 "both cold experiences match E < below before archiving"
3182 );
3183
3184 // Archive one of them — it now matches E < below but is archived.
3185 db.archive_experience(archived_id).unwrap();
3186
3187 let after = db.list_cold_experiences(collective_id, floor, 100).unwrap();
3188 assert_eq!(
3189 after.len(),
3190 1,
3191 "the archived cold experience is excluded by the !archived filter"
3192 );
3193 assert_eq!(
3194 after[0].0, surfaced_id,
3195 "only the non-archived cold exp remains"
3196 );
3197 assert!(
3198 !after.iter().any(|(id, _)| *id == archived_id),
3199 "the archived cold experience does NOT appear"
3200 );
3201
3202 db.close().unwrap();
3203 }
3204}