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}