Skip to main content

semantic_memory/
config.rs

1use crate::error::MemoryError;
2use crate::tokenizer::TokenCounter;
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::time::Duration;
7
8/// Configuration for the memory system.
9#[derive(Clone, Serialize, Deserialize)]
10pub struct MemoryConfig {
11    /// Base directory for all storage files (SQLite + HNSW sidecar files).
12    /// Replaces the v0.1.0 `database_path` field.
13    pub base_dir: PathBuf,
14
15    /// Embedding provider configuration.
16    pub embedding: EmbeddingConfig,
17
18    /// Search tuning parameters.
19    pub search: SearchConfig,
20
21    /// Chunking parameters.
22    pub chunking: ChunkingConfig,
23
24    /// Connection pool configuration.
25    pub pool: PoolConfig,
26
27    /// Resource limits.
28    pub limits: MemoryLimits,
29
30    /// Custom token counter. None = use EstimateTokenCounter (chars / 4).
31    #[serde(skip)]
32    pub token_counter: Option<Arc<dyn TokenCounter>>,
33
34    /// HNSW index configuration.
35    #[cfg(feature = "hnsw")]
36    #[serde(skip)]
37    pub hnsw: crate::hnsw::HnswConfig,
38}
39
40impl std::fmt::Debug for MemoryConfig {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        let mut s = f.debug_struct("MemoryConfig");
43        s.field("base_dir", &self.base_dir)
44            .field("embedding", &self.embedding)
45            .field("search", &self.search)
46            .field("chunking", &self.chunking)
47            .field("pool", &self.pool)
48            .field("limits", &self.limits)
49            .field(
50                "token_counter",
51                &self.token_counter.as_ref().map(|_| "custom"),
52            );
53        #[cfg(feature = "hnsw")]
54        s.field("hnsw", &self.hnsw);
55        s.finish()
56    }
57}
58
59impl Default for MemoryConfig {
60    fn default() -> Self {
61        Self {
62            base_dir: PathBuf::from("memory"),
63            embedding: EmbeddingConfig::default(),
64            search: SearchConfig::default(),
65            chunking: ChunkingConfig::default(),
66            pool: PoolConfig::default(),
67            limits: MemoryLimits::default(),
68            token_counter: None,
69            #[cfg(feature = "hnsw")]
70            hnsw: crate::hnsw::HnswConfig::default(),
71        }
72    }
73}
74
75impl MemoryConfig {
76    /// Normalize and validate configuration into a concrete runtime shape.
77    ///
78    /// This is the single canonical config entry point used by store creation.
79    pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
80        self.embedding.normalize_and_validate()?;
81        self.limits = self.limits.normalize_and_validate()?;
82        let timeout_cap_secs = self.limits.embedding_timeout.as_secs().max(1);
83        self.embedding.timeout_secs = self.embedding.timeout_secs.min(timeout_cap_secs);
84        self.search
85            .normalize_and_validate(self.embedding.dimensions)?;
86        self.chunking.normalize_and_validate()?;
87        self.pool.normalize_and_validate()?;
88        #[cfg(feature = "hnsw")]
89        {
90            self.hnsw.dimensions = self.embedding.dimensions;
91        }
92        Ok(self)
93    }
94}
95
96/// Embedding provider configuration.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct EmbeddingConfig {
99    /// Ollama base URL.
100    pub ollama_url: String,
101
102    /// Embedding model name.
103    pub model: String,
104
105    /// Expected embedding dimensions.
106    pub dimensions: usize,
107
108    /// Maximum texts to embed in a single API call.
109    pub batch_size: usize,
110
111    /// Timeout for embedding requests in seconds.
112    pub timeout_secs: u64,
113}
114
115impl Default for EmbeddingConfig {
116    fn default() -> Self {
117        Self {
118            ollama_url: "http://localhost:11434".to_string(),
119            model: "nomic-embed-text".to_string(),
120            dimensions: 768,
121            batch_size: 32,
122            timeout_secs: 30,
123        }
124    }
125}
126
127impl EmbeddingConfig {
128    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
129        if self.dimensions == 0 {
130            return Err(MemoryError::InvalidConfig {
131                field: "embedding.dimensions",
132                reason: "dimensions must be at least 1".to_string(),
133            });
134        }
135        if self.batch_size == 0 {
136            self.batch_size = 1;
137        }
138        if self.timeout_secs == 0 {
139            self.timeout_secs = 1;
140        }
141        let parsed =
142            reqwest::Url::parse(&self.ollama_url).map_err(|_| MemoryError::InvalidConfig {
143                field: "embedding.ollama_url",
144                reason: "must be an absolute http:// or https:// URL".to_string(),
145            })?;
146        match parsed.scheme() {
147            "http" | "https" if parsed.host_str().is_some() => {}
148            _ => {
149                return Err(MemoryError::InvalidConfig {
150                    field: "embedding.ollama_url",
151                    reason: "must be an absolute http:// or https:// URL".to_string(),
152                })
153            }
154        }
155        Ok(())
156    }
157}
158
159/// Search tuning parameters.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct SearchConfig {
162    /// Weight for BM25 score in RRF fusion.
163    pub bm25_weight: f64,
164
165    /// Weight for vector similarity in RRF fusion.
166    pub vector_weight: f64,
167
168    /// RRF constant (k). Controls rank importance decay.
169    pub rrf_k: f64,
170
171    /// Number of candidates from each search method before fusion.
172    pub candidate_pool_size: usize,
173
174    /// Default number of results to return.
175    pub default_top_k: usize,
176
177    /// Minimum cosine similarity threshold for vector candidates.
178    pub min_similarity: f64,
179
180    /// Optional recency boost. If enabled, results are boosted based on how
181    /// recently they were created/updated. The value is the half-life in days —
182    /// a fact that is `recency_half_life_days` old gets 50% of the recency boost.
183    /// None = no recency weighting (current behavior, default).
184    pub recency_half_life_days: Option<f64>,
185
186    /// Weight of the recency boost relative to BM25 and vector scores in RRF.
187    /// Only used when recency_half_life_days is Some.
188    /// Default: 0.5
189    pub recency_weight: f64,
190
191    /// When true, rerank top HNSW candidates using exact f32 cosine similarity
192    /// from SQLite. Improves recall at the cost of one batched SQL query.
193    /// Only applies when HNSW feature is enabled.
194    /// Default: true
195    pub rerank_from_f32: bool,
196
197    /// Optional derived-vector candidate backend. Disabled by default because
198    /// raw f32 embeddings remain authoritative.
199    #[serde(default)]
200    pub derived_vector_backend: DerivedVectorBackendPolicy,
201
202    /// TurboQuant polar angle bits when the TurboQuant candidate backend is enabled.
203    #[serde(default = "default_turbo_quant_bits")]
204    pub turbo_quant_bits: u8,
205
206    /// TurboQuant QJL projection count when the TurboQuant candidate backend is enabled.
207    #[serde(default = "default_turbo_quant_projections")]
208    pub turbo_quant_projections: usize,
209
210    /// TurboQuant profile seed when the TurboQuant candidate backend is enabled.
211    #[serde(default)]
212    pub turbo_quant_seed: u64,
213
214    /// Require exact f32 rerank for TurboQuant candidates. Defaults to true.
215    #[serde(default = "default_true")]
216    pub turbo_quant_require_exact_rerank: bool,
217
218    /// Require exact f32 reranking when the FibQuant candidate backend is enabled.
219    #[serde(default = "default_true")]
220    pub fib_quant_require_exact_rerank: bool,
221
222    /// Number of FibQuant blocks when the FibQuant candidate backend is enabled.
223    #[serde(default = "default_fib_quant_block_count")]
224    pub fib_quant_block_count: usize,
225
226    #[serde(default = "default_per_dim_bits")]
227    pub per_dim_bits: u8,
228    #[serde(default = "default_true")]
229    pub per_dim_require_exact_rerank: bool,
230}
231
232/// Candidate backend policy for rebuildable derived vector artifacts.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
234#[serde(rename_all = "snake_case")]
235pub enum DerivedVectorBackendPolicy {
236    /// Use authoritative raw f32 embeddings for vector candidate generation.
237    #[default]
238    Disabled,
239    /// Use TurboQuant only to generate candidates, then exact rerank by default.
240    TurboQuantCandidateOnly,
241    /// Use FibQuant only to generate candidates, then exact rerank by default.
242    FibQuantCandidateOnly,
243    /// Use PerDimScorer only to generate candidates, then exact rerank by default.
244    PerDimCandidateOnly,
245}
246
247const fn default_per_dim_bits() -> u8 {
248    8
249}
250
251const fn default_turbo_quant_bits() -> u8 {
252    8
253}
254
255const fn default_turbo_quant_projections() -> usize {
256    64
257}
258
259const fn default_true() -> bool {
260    true
261}
262
263const fn default_fib_quant_block_count() -> usize {
264    12
265}
266
267impl Default for SearchConfig {
268    fn default() -> Self {
269        Self {
270            bm25_weight: 1.0,
271            vector_weight: 1.0,
272            rrf_k: 60.0,
273            candidate_pool_size: 50,
274            default_top_k: 5,
275            min_similarity: 0.3,
276            recency_half_life_days: None,
277            recency_weight: 0.5,
278            rerank_from_f32: true,
279            derived_vector_backend: DerivedVectorBackendPolicy::PerDimCandidateOnly,
280            turbo_quant_bits: default_turbo_quant_bits(),
281            turbo_quant_projections: default_turbo_quant_projections(),
282            turbo_quant_seed: 0,
283            turbo_quant_require_exact_rerank: true,
284            fib_quant_require_exact_rerank: true,
285            fib_quant_block_count: default_fib_quant_block_count(),
286            per_dim_bits: default_per_dim_bits(),
287            per_dim_require_exact_rerank: true,
288        }
289    }
290}
291
292impl SearchConfig {
293    pub(crate) fn uses_turbo_quant_backend(&self) -> bool {
294        self.derived_vector_backend == DerivedVectorBackendPolicy::TurboQuantCandidateOnly
295    }
296
297    fn normalize_and_validate(&mut self, embedding_dimensions: usize) -> Result<(), MemoryError> {
298        #[cfg(not(feature = "turbo-quant-codec"))]
299        let _ = embedding_dimensions;
300        if self.candidate_pool_size == 0 {
301            self.candidate_pool_size = 1;
302        }
303        if self.default_top_k == 0 {
304            self.default_top_k = 1;
305        }
306        self.candidate_pool_size = self.candidate_pool_size.max(self.default_top_k);
307        if !self.rrf_k.is_finite() || self.rrf_k <= 0.0 {
308            return Err(MemoryError::InvalidConfig {
309                field: "search.rrf_k",
310                reason: "rrf_k must be finite and > 0".to_string(),
311            });
312        }
313        if !self.bm25_weight.is_finite() || self.bm25_weight < 0.0 {
314            return Err(MemoryError::InvalidConfig {
315                field: "search.bm25_weight",
316                reason: "bm25_weight must be finite and >= 0".to_string(),
317            });
318        }
319        if !self.vector_weight.is_finite() || self.vector_weight < 0.0 {
320            return Err(MemoryError::InvalidConfig {
321                field: "search.vector_weight",
322                reason: "vector_weight must be finite and >= 0".to_string(),
323            });
324        }
325        if !self.recency_weight.is_finite() || self.recency_weight < 0.0 {
326            return Err(MemoryError::InvalidConfig {
327                field: "search.recency_weight",
328                reason: "recency_weight must be finite and >= 0".to_string(),
329            });
330        }
331        if !self.min_similarity.is_finite() || !(-1.0..=1.0).contains(&self.min_similarity) {
332            return Err(MemoryError::InvalidConfig {
333                field: "search.min_similarity",
334                reason: "min_similarity must be finite and within [-1.0, 1.0]".to_string(),
335            });
336        }
337        if matches!(self.recency_half_life_days, Some(v) if !v.is_finite()) {
338            return Err(MemoryError::InvalidConfig {
339                field: "search.recency_half_life_days",
340                reason: "recency_half_life_days must be finite".to_string(),
341            });
342        }
343        if matches!(self.recency_half_life_days, Some(v) if v <= 0.0) {
344            return Err(MemoryError::InvalidConfig {
345                field: "search.recency_half_life_days",
346                reason: "recency_half_life_days must be > 0 when enabled".to_string(),
347            });
348        }
349        if self.uses_turbo_quant_backend() {
350            #[cfg(not(feature = "turbo-quant-codec"))]
351            {
352                return Err(MemoryError::InvalidConfig {
353                    field: "search.derived_vector_backend",
354                    reason: "turbo_quant_candidate_only requires the turbo-quant-codec feature"
355                        .to_string(),
356                });
357            }
358            #[cfg(feature = "turbo-quant-codec")]
359            {
360                if embedding_dimensions % 2 != 0 {
361                    return Err(MemoryError::InvalidConfig {
362                        field: "embedding.dimensions",
363                        reason: "TurboQuant requires even embedding dimensions".to_string(),
364                    });
365                }
366                if self.turbo_quant_projections == 0 {
367                    return Err(MemoryError::InvalidConfig {
368                        field: "search.turbo_quant_projections",
369                        reason: "TurboQuant projections must be at least 1".to_string(),
370                    });
371                }
372                if !(2..=16).contains(&self.turbo_quant_bits) {
373                    return Err(MemoryError::InvalidConfig {
374                        field: "search.turbo_quant_bits",
375                        reason: "TurboQuant bits must be within 2..=16".to_string(),
376                    });
377                }
378                if !self.turbo_quant_require_exact_rerank {
379                    return Err(MemoryError::InvalidConfig {
380                        field: "search.turbo_quant_require_exact_rerank",
381                        reason: "TurboQuant candidate backend requires exact f32 rerank"
382                            .to_string(),
383                    });
384                }
385            }
386        }
387        if self.derived_vector_backend == DerivedVectorBackendPolicy::PerDimCandidateOnly {
388            #[cfg(not(feature = "per-dim-codec"))]
389            return Err(MemoryError::InvalidConfig {
390                field: "search.derived_vector_backend",
391                reason: "per_dim_candidate_only requires the per-dim-codec feature".into(),
392            });
393            if !(1..=8).contains(&self.per_dim_bits) {
394                return Err(MemoryError::InvalidConfig {
395                    field: "search.per_dim_bits",
396                    reason: "PerDim bits must be within 1..=8".into(),
397                });
398            }
399            if !self.per_dim_require_exact_rerank {
400                return Err(MemoryError::InvalidConfig {
401                    field: "search.per_dim_require_exact_rerank",
402                    reason: "PerDim candidate backend requires exact f32 rerank".into(),
403                });
404            }
405        }
406        Ok(())
407    }
408}
409
410/// Text chunking parameters.
411#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct ChunkingConfig {
413    /// Target chunk size in characters.
414    pub target_size: usize,
415
416    /// Minimum chunk size. Chunks smaller than this are merged with neighbors.
417    pub min_size: usize,
418
419    /// Maximum chunk size. Chunks larger than this are force-split.
420    pub max_size: usize,
421
422    /// Overlap between adjacent chunks in characters.
423    pub overlap: usize,
424}
425
426impl Default for ChunkingConfig {
427    fn default() -> Self {
428        Self {
429            target_size: 1000,
430            min_size: 100,
431            max_size: 2000,
432            overlap: 200,
433        }
434    }
435}
436
437impl ChunkingConfig {
438    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
439        if self.min_size == 0 {
440            self.min_size = 1;
441        }
442        if self.max_size == 0 {
443            return Err(MemoryError::InvalidConfig {
444                field: "chunking.max_size",
445                reason: "max_size must be at least 1".to_string(),
446            });
447        }
448        if self.max_size < self.min_size {
449            return Err(MemoryError::InvalidConfig {
450                field: "chunking.max_size",
451                reason: "max_size must be >= min_size".to_string(),
452            });
453        }
454        if self.target_size < self.min_size {
455            self.target_size = self.min_size;
456        }
457        if self.target_size > self.max_size {
458            self.target_size = self.max_size;
459        }
460        if self.overlap >= self.min_size {
461            self.overlap = self.min_size.saturating_sub(1);
462        }
463        Ok(())
464    }
465}
466
467/// Connection pool configuration for SQLite.
468///
469/// Controls busy timeout and WAL checkpoint behavior. These defaults
470/// are tuned for a single-process server on local SSD storage.
471#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct PoolConfig {
473    /// SQLite busy timeout in milliseconds.
474    /// Default: 5000 (5 seconds).
475    pub busy_timeout_ms: u32,
476
477    /// WAL auto-checkpoint threshold in pages.
478    /// Default: 1000 (~4 MB with 4KB pages).
479    pub wal_autocheckpoint: u32,
480
481    /// Enable WAL mode. Should almost always be true.
482    /// Default: true.
483    pub enable_wal: bool,
484
485    /// Number of reader connections kept in the pool.
486    /// Writes still flow through a single writer connection because SQLite
487    /// allows only one concurrent writer, but readers can proceed concurrently
488    /// under WAL semantics.
489    pub max_read_connections: usize,
490
491    /// Timeout in seconds for acquiring a reader connection from the pool.
492    /// Default: 30 seconds.
493    pub reader_timeout_secs: u64,
494}
495
496impl Default for PoolConfig {
497    fn default() -> Self {
498        Self {
499            busy_timeout_ms: 5000,
500            wal_autocheckpoint: 1000,
501            enable_wal: true,
502            max_read_connections: 4,
503            reader_timeout_secs: 30,
504        }
505    }
506}
507
508impl PoolConfig {
509    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
510        if self.busy_timeout_ms == 0 {
511            self.busy_timeout_ms = 1;
512        }
513        if self.wal_autocheckpoint == 0 {
514            self.wal_autocheckpoint = 1;
515        }
516        if self.max_read_connections == 0 {
517            return Err(MemoryError::InvalidConfig {
518                field: "pool.max_read_connections",
519                reason: "set pool.max_read_connections to at least 1".to_string(),
520            });
521        }
522        if self.reader_timeout_secs == 0 {
523            self.reader_timeout_secs = 1;
524        }
525        self.reader_timeout_secs = self.reader_timeout_secs.min(300);
526        Ok(())
527    }
528}
529
530/// Resource limits for the memory system.
531///
532/// Prevents runaway resource usage. All limits have defaults tuned for
533/// a laptop-class server (8GB RAM, SSD storage).
534#[derive(Debug, Clone, Serialize, Deserialize)]
535pub struct MemoryLimits {
536    /// Maximum number of facts per namespace.
537    /// Default: 100_000.
538    pub max_facts_per_namespace: usize,
539
540    /// Maximum number of chunks per document.
541    /// Default: 1_000.
542    pub max_chunks_per_document: usize,
543
544    /// Maximum content size in bytes for a single fact or message.
545    /// Default: 1 MB (1_048_576 bytes).
546    pub max_content_bytes: usize,
547
548    /// Maximum number of concurrent embedding requests.
549    /// Hard-capped at 32 regardless of config.
550    /// Default: 8.
551    pub max_embedding_concurrency: usize,
552
553    /// Maximum total database size in bytes. 0 = unlimited.
554    /// Default: 0 (unlimited).
555    pub max_db_size_bytes: u64,
556
557    /// Embedding request timeout.
558    /// Default: 30 seconds.
559    #[serde(with = "duration_secs")]
560    pub embedding_timeout: Duration,
561}
562
563impl Default for MemoryLimits {
564    fn default() -> Self {
565        Self {
566            max_facts_per_namespace: 100_000,
567            max_chunks_per_document: 1_000,
568            max_content_bytes: 1_048_576,
569            max_embedding_concurrency: 8,
570            max_db_size_bytes: 0,
571            embedding_timeout: Duration::from_secs(30),
572        }
573    }
574}
575
576impl MemoryLimits {
577    /// Normalize and validate limits to hard caps.
578    pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
579        if self.max_facts_per_namespace == 0 {
580            return Err(MemoryError::InvalidConfig {
581                field: "limits.max_facts_per_namespace",
582                reason: "must be at least 1".to_string(),
583            });
584        }
585        if self.max_chunks_per_document == 0 {
586            return Err(MemoryError::InvalidConfig {
587                field: "limits.max_chunks_per_document",
588                reason: "must be at least 1".to_string(),
589            });
590        }
591        if self.max_content_bytes == 0 {
592            return Err(MemoryError::InvalidConfig {
593                field: "limits.max_content_bytes",
594                reason: "must be at least 1".to_string(),
595            });
596        }
597        // Hard cap: concurrency at 32
598        if self.max_embedding_concurrency > 32 {
599            self.max_embedding_concurrency = 32;
600        }
601        if self.max_embedding_concurrency == 0 {
602            self.max_embedding_concurrency = 1;
603        }
604        if self.embedding_timeout.is_zero() {
605            self.embedding_timeout = Duration::from_secs(1);
606        }
607        Ok(self)
608    }
609
610    /// Backward-compatible alias for callers that only need clamped limits.
611    ///
612    /// Falls back to defaults if the caller-provided limits are invalid.
613    /// Default limits are infallible so the fallback path cannot fail.
614    pub fn validated(self) -> Self {
615        self.normalize_and_validate().unwrap_or_else(|err| {
616            tracing::warn!(
617                error = %err,
618                "invalid MemoryLimits supplied to validated(); using defaults"
619            );
620            // Default limits are always valid — this path is infallible.
621            let defaults = Self::default();
622            Self {
623                max_facts_per_namespace: defaults.max_facts_per_namespace,
624                max_chunks_per_document: defaults.max_chunks_per_document,
625                max_content_bytes: defaults.max_content_bytes,
626                max_embedding_concurrency: defaults.max_embedding_concurrency.clamp(1, 32),
627                max_db_size_bytes: defaults.max_db_size_bytes,
628                embedding_timeout: if defaults.embedding_timeout.is_zero() {
629                    std::time::Duration::from_secs(1)
630                } else {
631                    defaults.embedding_timeout
632                },
633            }
634        })
635    }
636}
637
638mod duration_secs {
639    use serde::{Deserialize, Deserializer, Serializer};
640    use std::time::Duration;
641
642    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
643        s.serialize_u64(d.as_secs())
644    }
645
646    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
647        let secs = u64::deserialize(d)?;
648        Ok(Duration::from_secs(secs))
649    }
650}