Skip to main content

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}