Skip to main content

pulsedb/
config.rs

1//! Configuration types for PulseDB.
2//!
3//! The [`Config`] struct controls database behavior including:
4//! - Embedding provider (builtin ONNX or external)
5//! - Embedding dimension (384, 768, or custom)
6//! - Cache size and durability settings
7//!
8//! # Example
9//! ```rust
10//! use pulsedb::{Config, EmbeddingProvider, EmbeddingDimension, SyncMode};
11//!
12//! // Use defaults (External provider, 384 dimensions)
13//! let config = Config::default();
14//!
15//! // Customize for production
16//! let config = Config {
17//!     embedding_dimension: EmbeddingDimension::D768,
18//!     cache_size_mb: 128,
19//!     sync_mode: SyncMode::Normal,
20//!     ..Default::default()
21//! };
22//! ```
23
24use std::path::PathBuf;
25use std::time::Duration;
26
27use serde::{Deserialize, Serialize};
28
29use crate::error::ValidationError;
30use crate::types::CollectiveId;
31
32/// Database configuration options.
33///
34/// All fields have sensible defaults. Use struct update syntax to override
35/// specific settings:
36///
37/// ```rust
38/// use pulsedb::Config;
39///
40/// let config = Config {
41///     cache_size_mb: 256,
42///     ..Default::default()
43/// };
44/// ```
45#[derive(Clone, Debug)]
46pub struct Config {
47    /// How embeddings are generated or provided.
48    pub embedding_provider: EmbeddingProvider,
49
50    /// Embedding vector dimension (must match provider output).
51    pub embedding_dimension: EmbeddingDimension,
52
53    /// Default collective for operations when none specified.
54    pub default_collective: Option<CollectiveId>,
55
56    /// Cache size in megabytes for the storage engine.
57    ///
58    /// Higher values improve read performance but use more memory.
59    /// Default: 64 MB
60    pub cache_size_mb: usize,
61
62    /// Durability mode for write operations.
63    pub sync_mode: SyncMode,
64
65    /// HNSW vector index parameters.
66    ///
67    /// Controls the quality and performance of semantic search.
68    /// See [`HnswConfig`] for tuning guidelines.
69    pub hnsw: HnswConfig,
70
71    /// Agent activity tracking parameters.
72    ///
73    /// Controls staleness detection for agent heartbeats.
74    /// See [`ActivityConfig`] for details.
75    pub activity: ActivityConfig,
76
77    /// Watch system parameters.
78    ///
79    /// Controls the in-process event notification channel.
80    /// See [`WatchConfig`] for details.
81    pub watch: WatchConfig,
82
83    /// Temporal decay parameters.
84    ///
85    /// Controls closed-form energy decay for experiences. See [`DecayConfig`]
86    /// for defaults and tuning guidance.
87    pub decay: DecayConfig,
88
89    /// Read-only mode.
90    ///
91    /// When `true`, all mutation methods (`record_experience`, `store_relation`,
92    /// etc.) return `PulseDBError::ReadOnly`. Read operations work normally.
93    ///
94    /// Use this for read-only consumers like PulseVision that open the same
95    /// database file a writer is using.
96    ///
97    /// Default: false
98    pub read_only: bool,
99
100    /// Declared available memory (in bytes) for the one-time storage codec
101    /// migration (bincode→postcard, VS-4.0.3).
102    ///
103    /// The migration re-encodes every serde-blob row in a single transaction by
104    /// default, which must buffer a dirty set proportional to the store size.
105    /// The conservative peak-RSS estimate is `store_size × 0.10`. Below an
106    /// absolute store-size floor (1 GiB) the single-transaction path is always
107    /// taken; above it, the migration **fails closed with zero destructive
108    /// writes** unless the embedder *declares* an available-memory budget here
109    /// large enough to cover the projected peak (with a 0.5 safety margin).
110    ///
111    /// This is **config-first** by design: host-memory auto-detection is
112    /// unreliable for an embedded library (a cgroup-limited container reports the
113    /// host's RAM and would over-estimate → OOM). The embedder, who knows their
114    /// deployment, declares the budget to opt into single-txn migration of a
115    /// large store.
116    ///
117    /// `None` (the default) ⇒ rely on the conservative store-size floor only.
118    ///
119    /// Default: `None`
120    pub migration_available_memory_bytes: Option<u64>,
121}
122
123impl Default for Config {
124    fn default() -> Self {
125        Self {
126            // External is the safe default - no ONNX dependency required
127            embedding_provider: EmbeddingProvider::External,
128            // 384 matches all-MiniLM-L6-v2, the default builtin model
129            embedding_dimension: EmbeddingDimension::D384,
130            default_collective: None,
131            cache_size_mb: 64,
132            sync_mode: SyncMode::Normal,
133            hnsw: HnswConfig::default(),
134            activity: ActivityConfig::default(),
135            watch: WatchConfig::default(),
136            decay: DecayConfig::default(),
137            read_only: false,
138            migration_available_memory_bytes: None,
139        }
140    }
141}
142
143impl Config {
144    /// Creates a new Config with default settings.
145    pub fn new() -> Self {
146        Self::default()
147    }
148
149    /// Creates a Config for read-only access.
150    ///
151    /// All mutation methods will return `PulseDBError::ReadOnly`.
152    /// Use this for read-only consumers like visualization tools that
153    /// open the same database file a writer is using.
154    ///
155    /// # Example
156    /// ```rust
157    /// use pulsedb::Config;
158    ///
159    /// let config = Config::read_only();
160    /// assert!(config.read_only);
161    /// ```
162    pub fn read_only() -> Self {
163        Self {
164            read_only: true,
165            ..Default::default()
166        }
167    }
168
169    /// Creates a Config for builtin embedding generation.
170    ///
171    /// This requires the `builtin-embeddings` feature to be enabled.
172    ///
173    /// # Example
174    /// ```rust
175    /// use pulsedb::Config;
176    ///
177    /// let config = Config::with_builtin_embeddings();
178    /// ```
179    pub fn with_builtin_embeddings() -> Self {
180        Self {
181            embedding_provider: EmbeddingProvider::Builtin { model_path: None },
182            ..Default::default()
183        }
184    }
185
186    /// Creates a Config for external embedding provider.
187    ///
188    /// When using external embeddings, you must provide pre-computed
189    /// embedding vectors when recording experiences.
190    ///
191    /// # Example
192    /// ```rust
193    /// use pulsedb::{Config, EmbeddingDimension};
194    ///
195    /// // OpenAI ada-002 uses 1536 dimensions
196    /// let config = Config::with_external_embeddings(EmbeddingDimension::Custom(1536));
197    /// ```
198    pub fn with_external_embeddings(dimension: EmbeddingDimension) -> Self {
199        Self {
200            embedding_provider: EmbeddingProvider::External,
201            embedding_dimension: dimension,
202            ..Default::default()
203        }
204    }
205
206    /// Validates the configuration.
207    ///
208    /// Called automatically by `PulseDB::open()`. You can also call this
209    /// explicitly to check configuration before attempting to open.
210    ///
211    /// # Errors
212    /// Returns `ValidationError` if:
213    /// - `cache_size_mb` is 0
214    /// - Custom dimension is 0 or > 4096
215    pub fn validate(&self) -> Result<(), ValidationError> {
216        // Cache size must be positive
217        if self.cache_size_mb == 0 {
218            return Err(ValidationError::invalid_field(
219                "cache_size_mb",
220                "must be greater than 0",
221            ));
222        }
223
224        // Validate HNSW parameters
225        if self.hnsw.max_nb_connection == 0 {
226            return Err(ValidationError::invalid_field(
227                "hnsw.max_nb_connection",
228                "must be greater than 0",
229            ));
230        }
231        if self.hnsw.ef_construction == 0 {
232            return Err(ValidationError::invalid_field(
233                "hnsw.ef_construction",
234                "must be greater than 0",
235            ));
236        }
237        if self.hnsw.ef_search == 0 {
238            return Err(ValidationError::invalid_field(
239                "hnsw.ef_search",
240                "must be greater than 0",
241            ));
242        }
243
244        // Validate watch buffer size
245        if self.watch.buffer_size == 0 {
246            return Err(ValidationError::invalid_field(
247                "watch.buffer_size",
248                "must be greater than 0",
249            ));
250        }
251        if self.watch.poll_interval_ms == 0 {
252            return Err(ValidationError::invalid_field(
253                "watch.poll_interval_ms",
254                "must be greater than 0",
255            ));
256        }
257
258        // Validate temporal decay parameters
259        if self.decay.half_life.is_zero() {
260            return Err(ValidationError::invalid_field(
261                "decay.half_life",
262                "must be greater than 0",
263            ));
264        }
265        if !self.decay.freq_weight.is_finite() || self.decay.freq_weight < 0.0 {
266            return Err(ValidationError::invalid_field(
267                "decay.freq_weight",
268                "must be finite and non-negative",
269            ));
270        }
271        if !self.decay.floor.is_finite() || !(0.0..=1.0).contains(&self.decay.floor) {
272            return Err(ValidationError::invalid_field(
273                "decay.floor",
274                "must be finite and between 0 and 1",
275            ));
276        }
277        if let Some(weights) = self.decay.default_recall_weights {
278            weights.validate("decay.default_recall_weights")?;
279        }
280
281        // Validate custom dimension bounds
282        if let EmbeddingDimension::Custom(dim) = self.embedding_dimension {
283            if dim == 0 {
284                return Err(ValidationError::invalid_field(
285                    "embedding_dimension",
286                    "custom dimension must be greater than 0",
287                ));
288            }
289            if dim > 4096 {
290                return Err(ValidationError::invalid_field(
291                    "embedding_dimension",
292                    "custom dimension must not exceed 4096",
293                ));
294            }
295        }
296
297        Ok(())
298    }
299
300    /// Returns the embedding dimension as a numeric value.
301    pub fn dimension(&self) -> usize {
302        self.embedding_dimension.size()
303    }
304}
305
306/// Embedding provider configuration.
307///
308/// Determines how embedding vectors are generated for experiences.
309#[derive(Clone, Debug)]
310pub enum EmbeddingProvider {
311    /// PulseDB generates embeddings using a built-in ONNX model.
312    ///
313    /// Requires the `builtin-embeddings` feature. The default model is
314    /// all-MiniLM-L6-v2 (384 dimensions).
315    Builtin {
316        /// Custom ONNX model path. If `None`, uses the bundled model.
317        model_path: Option<PathBuf>,
318    },
319
320    /// Caller provides pre-computed embedding vectors.
321    ///
322    /// Use this when you have your own embedding service (OpenAI, Cohere, etc.)
323    /// or want to use a model not bundled with PulseDB.
324    External,
325}
326
327impl EmbeddingProvider {
328    /// Returns true if this is the builtin provider.
329    pub fn is_builtin(&self) -> bool {
330        matches!(self, Self::Builtin { .. })
331    }
332
333    /// Returns true if this is the external provider.
334    pub fn is_external(&self) -> bool {
335        matches!(self, Self::External)
336    }
337}
338
339/// Embedding vector dimensions.
340///
341/// Standard dimensions are provided for common models. Use `Custom` for
342/// other embedding services.
343#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
344pub enum EmbeddingDimension {
345    /// 384 dimensions (all-MiniLM-L6-v2, default builtin model).
346    #[default]
347    D384,
348
349    /// 768 dimensions (bge-base-en-v1.5, BERT-base).
350    D768,
351
352    /// Custom dimension for other embedding models.
353    ///
354    /// Must be between 1 and 4096.
355    Custom(usize),
356}
357
358impl EmbeddingDimension {
359    /// Returns the numeric size of this dimension.
360    ///
361    /// # Example
362    /// ```rust
363    /// use pulsedb::EmbeddingDimension;
364    ///
365    /// assert_eq!(EmbeddingDimension::D384.size(), 384);
366    /// assert_eq!(EmbeddingDimension::D768.size(), 768);
367    /// assert_eq!(EmbeddingDimension::Custom(1536).size(), 1536);
368    /// ```
369    #[inline]
370    pub const fn size(&self) -> usize {
371        match self {
372            Self::D384 => 384,
373            Self::D768 => 768,
374            Self::Custom(n) => *n,
375        }
376    }
377}
378
379/// Durability mode for write operations.
380///
381/// Controls the trade-off between write performance and crash safety.
382#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
383pub enum SyncMode {
384    /// Sync to disk on transaction commit.
385    ///
386    /// This is the default and recommended setting. Provides good performance
387    /// while ensuring committed data survives crashes.
388    #[default]
389    Normal,
390
391    /// Async sync (faster writes, may lose recent data on crash).
392    ///
393    /// Use for development or when you can tolerate losing the last few
394    /// seconds of writes. Significantly faster than `Normal`.
395    Fast,
396
397    /// Sync every write operation (slowest, maximum durability).
398    ///
399    /// Use when data loss is absolutely unacceptable. Very slow for
400    /// high write volumes.
401    Paranoid,
402}
403
404impl SyncMode {
405    /// Returns true if this mode syncs on every write.
406    pub fn is_paranoid(&self) -> bool {
407        matches!(self, Self::Paranoid)
408    }
409
410    /// Returns true if this mode is async (may lose data on crash).
411    pub fn is_fast(&self) -> bool {
412        matches!(self, Self::Fast)
413    }
414}
415
416/// Configuration for the HNSW vector index.
417///
418/// Controls the trade-off between index build time, memory usage,
419/// and search accuracy. The defaults are tuned for PulseDB's target
420/// scale (10K-500K experiences per collective).
421///
422/// # Tuning Guide
423///
424/// | Use Case     | M  | ef_construction | ef_search |
425/// |--------------|----|-----------------|-----------|
426/// | Low memory   |  8 |             100 |        30 |
427/// | Balanced     | 16 |             200 |        50 |
428/// | High recall  | 32 |             400 |       100 |
429#[derive(Clone, Debug)]
430pub struct HnswConfig {
431    /// Maximum bidirectional connections per node (M parameter).
432    ///
433    /// Higher values improve recall but increase memory and build time.
434    /// Each node stores up to M links, so memory per node is O(M).
435    /// Default: 16
436    pub max_nb_connection: usize,
437
438    /// Number of candidates tracked during index construction.
439    ///
440    /// Higher values produce a better quality graph but slow down insertion.
441    /// Rule of thumb: ef_construction >= 2 * max_nb_connection.
442    /// Default: 200
443    pub ef_construction: usize,
444
445    /// Number of candidates tracked during search.
446    ///
447    /// Higher values improve recall but increase search latency.
448    /// Must be >= k (the number of results requested).
449    /// Default: 50
450    pub ef_search: usize,
451
452    /// Maximum number of layers in the skip-list structure.
453    ///
454    /// Lower layers are dense, upper layers are sparse "express lanes."
455    /// Default 16 handles datasets up to ~1M vectors with M=16.
456    /// Default: 16
457    pub max_layer: usize,
458
459    /// Initial pre-allocated capacity (number of vectors).
460    ///
461    /// The index grows beyond this automatically, but pre-allocation
462    /// avoids reallocations for known workloads.
463    /// Default: 10_000
464    pub max_elements: usize,
465}
466
467impl Default for HnswConfig {
468    fn default() -> Self {
469        Self {
470            max_nb_connection: 16,
471            ef_construction: 200,
472            ef_search: 50,
473            max_layer: 16,
474            max_elements: 10_000,
475        }
476    }
477}
478
479/// Configuration for agent activity tracking.
480///
481/// Controls how stale activities are detected and filtered.
482///
483/// # Example
484/// ```rust
485/// use std::time::Duration;
486/// use pulsedb::Config;
487///
488/// let config = Config {
489///     activity: pulsedb::ActivityConfig {
490///         stale_threshold: Duration::from_secs(120), // 2 minutes
491///     },
492///     ..Default::default()
493/// };
494/// ```
495#[derive(Clone, Debug)]
496pub struct ActivityConfig {
497    /// Duration after which an activity with no heartbeat is considered stale.
498    ///
499    /// Activities whose `last_heartbeat` is older than `now - stale_threshold`
500    /// are excluded from `get_active_agents()` results. They remain in storage
501    /// until explicitly ended or the collective is deleted.
502    ///
503    /// Default: 5 minutes (300 seconds)
504    pub stale_threshold: Duration,
505}
506
507impl Default for ActivityConfig {
508    fn default() -> Self {
509        Self {
510            stale_threshold: Duration::from_secs(300),
511        }
512    }
513}
514
515/// Configuration for the watch system (in-process and cross-process).
516///
517/// Controls whether in-process channel subscriptions are enabled, the
518/// channel buffer size for real-time experience notifications, and the
519/// poll interval for cross-process change detection.
520///
521/// # Example
522/// ```rust
523/// use pulsedb::Config;
524///
525/// let config = Config {
526///     watch: pulsedb::WatchConfig {
527///         in_process: true,
528///         buffer_size: 500,
529///         poll_interval_ms: 200,
530///     },
531///     ..Default::default()
532/// };
533/// ```
534#[derive(Clone, Debug)]
535pub struct WatchConfig {
536    /// Enable in-process watch subscriptions via crossbeam channels.
537    ///
538    /// When `true` (default), [`watch_experiences()`](crate::PulseDB::watch_experiences)
539    /// streams receive real-time events. When `false`, in-process event
540    /// dispatch is skipped entirely — only cross-process
541    /// [`poll_changes()`](crate::PulseDB::poll_changes) remains available.
542    ///
543    /// Default: true
544    pub in_process: bool,
545
546    /// Maximum number of events buffered per subscriber (in-process).
547    ///
548    /// When a subscriber's channel is full, new events are dropped for
549    /// that subscriber (with a warning log). The publisher never blocks.
550    ///
551    /// Default: 1000
552    pub buffer_size: usize,
553
554    /// Poll interval in milliseconds for cross-process change detection.
555    ///
556    /// Reader processes call `poll_changes()` at this interval to check
557    /// for new experiences written by the writer process.
558    ///
559    /// Default: 100
560    pub poll_interval_ms: u64,
561}
562
563impl Default for WatchConfig {
564    fn default() -> Self {
565        Self {
566            in_process: true,
567            buffer_size: 1000,
568            poll_interval_ms: 100,
569        }
570    }
571}
572
573/// Weighting factors for energy-aware recall.
574///
575/// This config type is stored as part of [`DecayConfig`], but recall ranking
576/// uses it only once the energy-weighted search surface is enabled.
577#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
578pub struct RecallWeights {
579    /// Similarity-score contribution.
580    pub similarity: f32,
581
582    /// Energy-score contribution.
583    pub energy: f32,
584}
585
586impl RecallWeights {
587    /// Creates recall weights.
588    pub const fn new(similarity: f32, energy: f32) -> Self {
589        Self { similarity, energy }
590    }
591
592    pub(crate) fn validate(&self, field: &'static str) -> Result<(), ValidationError> {
593        if !self.similarity.is_finite()
594            || !self.energy.is_finite()
595            || self.similarity < 0.0
596            || self.energy < 0.0
597        {
598            return Err(ValidationError::invalid_field(
599                field,
600                "weights must be finite and non-negative",
601            ));
602        }
603
604        let sum = self.similarity + self.energy;
605        if (sum - 1.0).abs() > 0.000_1 {
606            return Err(ValidationError::invalid_field(
607                field,
608                "similarity and energy weights must sum to 1",
609            ));
610        }
611
612        Ok(())
613    }
614}
615
616impl Default for RecallWeights {
617    fn default() -> Self {
618        Self {
619            similarity: 0.7,
620            energy: 0.3,
621        }
622    }
623}
624
625/// Temporal decay configuration.
626///
627/// The defaults model a 30-day half-life, logarithmic reinforcement boost,
628/// and a conservative cold-memory floor without automatic archiving.
629#[derive(Clone, Debug, PartialEq)]
630pub struct DecayConfig {
631    /// Half-life for exponential energy decay.
632    ///
633    /// Default: 30 days.
634    pub half_life: Duration,
635
636    /// Logarithmic frequency weight `k` in `1 + k * ln(1 + applications)`.
637    ///
638    /// Default: `0.25`.
639    pub freq_weight: f32,
640
641    /// Energy floor below which experiences are cold.
642    ///
643    /// Default: `0.05`.
644    pub floor: f32,
645
646    /// Reserved for future automatic archiving of cold experiences.
647    ///
648    /// **Inert in v0.5.0** — this flag round-trips through config but wires **no**
649    /// automatic archive behavior; setting it `true` is currently a no-op.
650    /// `list_cold_experiences()` only *surfaces* prune candidates — archiving
651    /// remains the consumer's explicit decision. (Tracked: issues #20/#22.)
652    ///
653    /// Default: `false`.
654    pub auto_archive_below_floor: bool,
655
656    /// Default recall weights for energy-aware ranking.
657    ///
658    /// `None` preserves legacy pure-similarity ranking.
659    pub default_recall_weights: Option<RecallWeights>,
660}
661
662impl Default for DecayConfig {
663    fn default() -> Self {
664        Self {
665            half_life: Duration::from_secs(30 * 24 * 60 * 60),
666            freq_weight: 0.25,
667            floor: 0.05,
668            auto_archive_below_floor: false,
669            default_recall_weights: None,
670        }
671    }
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    #[test]
679    fn test_default_config() {
680        let config = Config::default();
681        assert!(config.embedding_provider.is_external());
682        assert_eq!(config.embedding_dimension, EmbeddingDimension::D384);
683        assert_eq!(config.cache_size_mb, 64);
684        assert_eq!(config.sync_mode, SyncMode::Normal);
685        assert!(config.default_collective.is_none());
686    }
687
688    #[test]
689    fn test_with_builtin_embeddings() {
690        let config = Config::with_builtin_embeddings();
691        assert!(config.embedding_provider.is_builtin());
692    }
693
694    #[test]
695    fn test_with_external_embeddings() {
696        let config = Config::with_external_embeddings(EmbeddingDimension::Custom(1536));
697        assert!(config.embedding_provider.is_external());
698        assert_eq!(config.dimension(), 1536);
699    }
700
701    #[test]
702    fn test_validate_success() {
703        let config = Config::default();
704        assert!(config.validate().is_ok());
705    }
706
707    #[test]
708    fn test_validate_cache_size_zero() {
709        let config = Config {
710            cache_size_mb: 0,
711            ..Default::default()
712        };
713        let err = config.validate().unwrap_err();
714        assert!(
715            matches!(err, ValidationError::InvalidField { field, .. } if field == "cache_size_mb")
716        );
717    }
718
719    #[test]
720    fn test_validate_custom_dimension_zero() {
721        let config = Config {
722            embedding_dimension: EmbeddingDimension::Custom(0),
723            ..Default::default()
724        };
725        assert!(config.validate().is_err());
726    }
727
728    #[test]
729    fn test_validate_custom_dimension_too_large() {
730        let config = Config {
731            embedding_dimension: EmbeddingDimension::Custom(5000),
732            ..Default::default()
733        };
734        assert!(config.validate().is_err());
735    }
736
737    #[test]
738    fn test_validate_custom_dimension_valid() {
739        let config = Config {
740            embedding_dimension: EmbeddingDimension::Custom(1536),
741            ..Default::default()
742        };
743        assert!(config.validate().is_ok());
744    }
745
746    #[test]
747    fn test_embedding_dimension_sizes() {
748        assert_eq!(EmbeddingDimension::D384.size(), 384);
749        assert_eq!(EmbeddingDimension::D768.size(), 768);
750        assert_eq!(EmbeddingDimension::Custom(512).size(), 512);
751    }
752
753    #[test]
754    fn test_sync_mode_checks() {
755        assert!(!SyncMode::Normal.is_fast());
756        assert!(!SyncMode::Normal.is_paranoid());
757        assert!(SyncMode::Fast.is_fast());
758        assert!(SyncMode::Paranoid.is_paranoid());
759    }
760
761    #[test]
762    fn test_hnsw_config_defaults() {
763        let config = HnswConfig::default();
764        assert_eq!(config.max_nb_connection, 16);
765        assert_eq!(config.ef_construction, 200);
766        assert_eq!(config.ef_search, 50);
767        assert_eq!(config.max_layer, 16);
768        assert_eq!(config.max_elements, 10_000);
769    }
770
771    #[test]
772    fn test_config_includes_hnsw() {
773        let config = Config::default();
774        assert_eq!(config.hnsw.max_nb_connection, 16);
775    }
776
777    #[test]
778    fn test_decay_config_defaults() {
779        let config = DecayConfig::default();
780        assert_eq!(config.half_life, Duration::from_secs(30 * 24 * 60 * 60));
781        assert_eq!(config.freq_weight, 0.25);
782        assert_eq!(config.floor, 0.05);
783        assert!(!config.auto_archive_below_floor);
784        assert!(config.default_recall_weights.is_none());
785    }
786
787    #[test]
788    fn test_config_includes_decay() {
789        let config = Config::default();
790        assert_eq!(
791            config.decay.half_life,
792            Duration::from_secs(30 * 24 * 60 * 60)
793        );
794    }
795
796    #[test]
797    fn test_validate_decay_zero_half_life() {
798        let config = Config {
799            decay: DecayConfig {
800                half_life: Duration::ZERO,
801                ..Default::default()
802            },
803            ..Default::default()
804        };
805        let err = config.validate().unwrap_err();
806        assert!(matches!(
807            err,
808            ValidationError::InvalidField { field, .. } if field == "decay.half_life"
809        ));
810    }
811
812    #[test]
813    fn test_validate_decay_negative_freq_weight() {
814        let config = Config {
815            decay: DecayConfig {
816                freq_weight: -0.01,
817                ..Default::default()
818            },
819            ..Default::default()
820        };
821        let err = config.validate().unwrap_err();
822        assert!(matches!(
823            err,
824            ValidationError::InvalidField { field, .. } if field == "decay.freq_weight"
825        ));
826    }
827
828    #[test]
829    fn test_validate_decay_floor_out_of_range() {
830        let config = Config {
831            decay: DecayConfig {
832                floor: 1.01,
833                ..Default::default()
834            },
835            ..Default::default()
836        };
837        let err = config.validate().unwrap_err();
838        assert!(matches!(
839            err,
840            ValidationError::InvalidField { field, .. } if field == "decay.floor"
841        ));
842    }
843
844    #[test]
845    fn test_validate_hnsw_zero_max_nb_connection() {
846        let config = Config {
847            hnsw: HnswConfig {
848                max_nb_connection: 0,
849                ..Default::default()
850            },
851            ..Default::default()
852        };
853        let err = config.validate().unwrap_err();
854        assert!(matches!(
855            err,
856            ValidationError::InvalidField { field, .. } if field == "hnsw.max_nb_connection"
857        ));
858    }
859
860    #[test]
861    fn test_validate_hnsw_zero_ef_construction() {
862        let config = Config {
863            hnsw: HnswConfig {
864                ef_construction: 0,
865                ..Default::default()
866            },
867            ..Default::default()
868        };
869        assert!(config.validate().is_err());
870    }
871
872    #[test]
873    fn test_validate_hnsw_zero_ef_search() {
874        let config = Config {
875            hnsw: HnswConfig {
876                ef_search: 0,
877                ..Default::default()
878            },
879            ..Default::default()
880        };
881        assert!(config.validate().is_err());
882    }
883
884    #[test]
885    fn test_embedding_dimension_serialization() {
886        let dim = EmbeddingDimension::D768;
887        let bytes = postcard::to_stdvec(&dim).unwrap();
888        let restored: EmbeddingDimension = postcard::from_bytes(&bytes).unwrap();
889        assert_eq!(dim, restored);
890    }
891
892    #[test]
893    fn test_watch_config_defaults() {
894        let config = WatchConfig::default();
895        assert!(config.in_process);
896        assert_eq!(config.buffer_size, 1000);
897        assert_eq!(config.poll_interval_ms, 100);
898    }
899
900    #[test]
901    fn test_validate_watch_zero_buffer_size() {
902        let config = Config {
903            watch: WatchConfig {
904                buffer_size: 0,
905                ..Default::default()
906            },
907            ..Default::default()
908        };
909        let err = config.validate().unwrap_err();
910        assert!(matches!(
911            err,
912            ValidationError::InvalidField { field, .. } if field == "watch.buffer_size"
913        ));
914    }
915
916    #[test]
917    fn test_validate_watch_zero_poll_interval() {
918        let config = Config {
919            watch: WatchConfig {
920                poll_interval_ms: 0,
921                ..Default::default()
922            },
923            ..Default::default()
924        };
925        let err = config.validate().unwrap_err();
926        assert!(matches!(
927            err,
928            ValidationError::InvalidField { field, .. } if field == "watch.poll_interval_ms"
929        ));
930    }
931}