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    /// Weight for late interaction (ColBERT MaxSim) in RRF fusion.
169    /// Defaults to 0.0 (disabled). Set to 1.0 to enable as 3rd RRF signal.
170    #[serde(default = "default_zero")]
171    pub late_interaction_weight: f64,
172
173    /// RRF constant (k). Controls rank importance decay.
174    pub rrf_k: f64,
175
176    /// Number of candidates from each search method before fusion.
177    pub candidate_pool_size: usize,
178
179    /// Default number of results to return.
180    pub default_top_k: usize,
181
182    /// Minimum cosine similarity threshold for vector candidates.
183    pub min_similarity: f64,
184
185    /// Optional recency boost. If enabled, results are boosted based on how
186    /// recently they were created/updated. The value is the half-life in days —
187    /// a fact that is `recency_half_life_days` old gets 50% of the recency boost.
188    /// None = no recency weighting (current behavior, default).
189    pub recency_half_life_days: Option<f64>,
190
191    /// Weight of the recency boost relative to BM25 and vector scores in RRF.
192    /// Only used when recency_half_life_days is Some.
193    /// Default: 0.5
194    pub recency_weight: f64,
195
196    /// When true, rerank top HNSW candidates using exact f32 cosine similarity
197    /// from SQLite. Improves recall at the cost of one batched SQL query.
198    /// Only applies when HNSW feature is enabled.
199    /// Default: true
200    pub rerank_from_f32: bool,
201
202    /// Optional derived-vector candidate backend. Disabled by default because
203    /// raw f32 embeddings remain authoritative.
204    #[serde(default)]
205    pub derived_vector_backend: DerivedVectorBackendPolicy,
206
207    /// TurboQuant polar angle bits when the TurboQuant candidate backend is enabled.
208    #[serde(default = "default_turbo_quant_bits")]
209    pub turbo_quant_bits: u8,
210
211    /// TurboQuant QJL projection count when the TurboQuant candidate backend is enabled.
212    #[serde(default = "default_turbo_quant_projections")]
213    pub turbo_quant_projections: usize,
214
215    /// TurboQuant profile seed when the TurboQuant candidate backend is enabled.
216    #[serde(default)]
217    pub turbo_quant_seed: u64,
218
219    /// Require exact f32 rerank for TurboQuant candidates. Defaults to true.
220    #[serde(default = "default_true")]
221    pub turbo_quant_require_exact_rerank: bool,
222}
223
224/// Candidate backend policy for rebuildable derived vector artifacts.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
226#[serde(rename_all = "snake_case")]
227pub enum DerivedVectorBackendPolicy {
228    /// Use authoritative raw f32 embeddings for vector candidate generation.
229    #[default]
230    Disabled,
231    /// Use TurboQuant only to generate candidates, then exact rerank by default.
232    TurboQuantCandidateOnly,
233    /// Use a generation-level proveKV/poly-kv shared pool only to generate candidates,
234    /// then exact-rerank against authoritative f32 embeddings.
235    ///
236    /// This is deliberately not a replacement for SQLite f32 storage or for prompt/KV
237    /// prefix reuse. It is a rebuildable derived artifact over an embedding snapshot.
238    ProveKvPoolCandidateOnly,
239}
240
241const fn default_turbo_quant_bits() -> u8 {
242    8
243}
244
245const fn default_turbo_quant_projections() -> usize {
246    64
247}
248
249const fn default_true() -> bool {
250    true
251}
252
253const fn default_zero() -> f64 {
254    0.0
255}
256
257impl Default for SearchConfig {
258    fn default() -> Self {
259        Self {
260            bm25_weight: 1.0,
261            vector_weight: 1.0,
262            late_interaction_weight: 0.0,
263            rrf_k: 60.0,
264            candidate_pool_size: 50,
265            default_top_k: 5,
266            min_similarity: 0.3,
267            recency_half_life_days: None,
268            recency_weight: 0.5,
269            rerank_from_f32: true,
270            derived_vector_backend: DerivedVectorBackendPolicy::Disabled,
271            turbo_quant_bits: default_turbo_quant_bits(),
272            turbo_quant_projections: default_turbo_quant_projections(),
273            turbo_quant_seed: 0,
274            turbo_quant_require_exact_rerank: true,
275        }
276    }
277}
278
279impl SearchConfig {
280    pub(crate) fn uses_turbo_quant_backend(&self) -> bool {
281        self.derived_vector_backend == DerivedVectorBackendPolicy::TurboQuantCandidateOnly
282    }
283
284    pub(crate) fn uses_provekv_pool_backend(&self) -> bool {
285        self.derived_vector_backend == DerivedVectorBackendPolicy::ProveKvPoolCandidateOnly
286    }
287
288    pub(crate) fn uses_derived_vector_backend(&self) -> bool {
289        self.uses_turbo_quant_backend() || self.uses_provekv_pool_backend()
290    }
291
292    fn normalize_and_validate(&mut self, embedding_dimensions: usize) -> Result<(), MemoryError> {
293        #[cfg(not(feature = "turbo-quant-codec"))]
294        let _ = embedding_dimensions;
295        if self.candidate_pool_size == 0 {
296            self.candidate_pool_size = 1;
297        }
298        if self.default_top_k == 0 {
299            self.default_top_k = 1;
300        }
301        self.candidate_pool_size = self.candidate_pool_size.max(self.default_top_k);
302        if !self.rrf_k.is_finite() || self.rrf_k <= 0.0 {
303            return Err(MemoryError::InvalidConfig {
304                field: "search.rrf_k",
305                reason: "rrf_k must be finite and > 0".to_string(),
306            });
307        }
308        if !self.bm25_weight.is_finite() || self.bm25_weight < 0.0 {
309            return Err(MemoryError::InvalidConfig {
310                field: "search.bm25_weight",
311                reason: "bm25_weight must be finite and >= 0".to_string(),
312            });
313        }
314        if !self.vector_weight.is_finite() || self.vector_weight < 0.0 {
315            return Err(MemoryError::InvalidConfig {
316                field: "search.vector_weight",
317                reason: "vector_weight must be finite and >= 0".to_string(),
318            });
319        }
320        if !self.recency_weight.is_finite() || self.recency_weight < 0.0 {
321            return Err(MemoryError::InvalidConfig {
322                field: "search.recency_weight",
323                reason: "recency_weight must be finite and >= 0".to_string(),
324            });
325        }
326        if !self.min_similarity.is_finite() || !(-1.0..=1.0).contains(&self.min_similarity) {
327            return Err(MemoryError::InvalidConfig {
328                field: "search.min_similarity",
329                reason: "min_similarity must be finite and within [-1.0, 1.0]".to_string(),
330            });
331        }
332        if matches!(self.recency_half_life_days, Some(v) if !v.is_finite()) {
333            return Err(MemoryError::InvalidConfig {
334                field: "search.recency_half_life_days",
335                reason: "recency_half_life_days must be finite".to_string(),
336            });
337        }
338        if matches!(self.recency_half_life_days, Some(v) if v <= 0.0) {
339            return Err(MemoryError::InvalidConfig {
340                field: "search.recency_half_life_days",
341                reason: "recency_half_life_days must be > 0 when enabled".to_string(),
342            });
343        }
344        if self.uses_turbo_quant_backend() {
345            #[cfg(not(feature = "turbo-quant-codec"))]
346            {
347                return Err(MemoryError::InvalidConfig {
348                    field: "search.derived_vector_backend",
349                    reason: "turbo_quant_candidate_only requires the turbo-quant-codec feature"
350                        .to_string(),
351                });
352            }
353            #[cfg(feature = "turbo-quant-codec")]
354            {
355                if embedding_dimensions % 2 != 0 {
356                    return Err(MemoryError::InvalidConfig {
357                        field: "embedding.dimensions",
358                        reason: "TurboQuant requires even embedding dimensions".to_string(),
359                    });
360                }
361                if self.turbo_quant_projections == 0 {
362                    return Err(MemoryError::InvalidConfig {
363                        field: "search.turbo_quant_projections",
364                        reason: "TurboQuant projections must be at least 1".to_string(),
365                    });
366                }
367                if !(2..=16).contains(&self.turbo_quant_bits) {
368                    return Err(MemoryError::InvalidConfig {
369                        field: "search.turbo_quant_bits",
370                        reason: "TurboQuant bits must be within 2..=16".to_string(),
371                    });
372                }
373            }
374        }
375        if self.uses_derived_vector_backend() && !self.turbo_quant_require_exact_rerank {
376            return Err(MemoryError::InvalidConfig {
377                field: "search.turbo_quant_require_exact_rerank",
378                reason: "derived vector candidate backends require exact f32 rerank".to_string(),
379            });
380        }
381        Ok(())
382    }
383}
384
385/// Text chunking parameters.
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct ChunkingConfig {
388    /// Target chunk size in characters.
389    pub target_size: usize,
390
391    /// Minimum chunk size. Chunks smaller than this are merged with neighbors.
392    pub min_size: usize,
393
394    /// Maximum chunk size. Chunks larger than this are force-split.
395    pub max_size: usize,
396
397    /// Overlap between adjacent chunks in characters.
398    pub overlap: usize,
399}
400
401impl Default for ChunkingConfig {
402    fn default() -> Self {
403        Self {
404            target_size: 1000,
405            min_size: 100,
406            max_size: 2000,
407            overlap: 200,
408        }
409    }
410}
411
412impl ChunkingConfig {
413    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
414        if self.min_size == 0 {
415            self.min_size = 1;
416        }
417        if self.max_size == 0 {
418            return Err(MemoryError::InvalidConfig {
419                field: "chunking.max_size",
420                reason: "max_size must be at least 1".to_string(),
421            });
422        }
423        if self.max_size < self.min_size {
424            return Err(MemoryError::InvalidConfig {
425                field: "chunking.max_size",
426                reason: "max_size must be >= min_size".to_string(),
427            });
428        }
429        if self.target_size < self.min_size {
430            self.target_size = self.min_size;
431        }
432        if self.target_size > self.max_size {
433            self.target_size = self.max_size;
434        }
435        if self.overlap >= self.min_size {
436            self.overlap = self.min_size.saturating_sub(1);
437        }
438        Ok(())
439    }
440}
441
442/// Connection pool configuration for SQLite.
443///
444/// Controls busy timeout and WAL checkpoint behavior. These defaults
445/// are tuned for a single-process server on local SSD storage.
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct PoolConfig {
448    /// SQLite busy timeout in milliseconds.
449    /// Default: 5000 (5 seconds).
450    pub busy_timeout_ms: u32,
451
452    /// WAL auto-checkpoint threshold in pages.
453    /// Default: 1000 (~4 MB with 4KB pages).
454    pub wal_autocheckpoint: u32,
455
456    /// Enable WAL mode. Should almost always be true.
457    /// Default: true.
458    pub enable_wal: bool,
459
460    /// Number of reader connections kept in the pool.
461    /// Writes still flow through a single writer connection because SQLite
462    /// allows only one concurrent writer, but readers can proceed concurrently
463    /// under WAL semantics.
464    pub max_read_connections: usize,
465
466    /// Timeout in seconds for acquiring a reader connection from the pool.
467    /// Default: 30 seconds.
468    pub reader_timeout_secs: u64,
469}
470
471impl Default for PoolConfig {
472    fn default() -> Self {
473        Self {
474            busy_timeout_ms: 5000,
475            wal_autocheckpoint: 1000,
476            enable_wal: true,
477            max_read_connections: 4,
478            reader_timeout_secs: 30,
479        }
480    }
481}
482
483impl PoolConfig {
484    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
485        if self.busy_timeout_ms == 0 {
486            self.busy_timeout_ms = 1;
487        }
488        if self.wal_autocheckpoint == 0 {
489            self.wal_autocheckpoint = 1;
490        }
491        if self.max_read_connections == 0 {
492            return Err(MemoryError::InvalidConfig {
493                field: "pool.max_read_connections",
494                reason: "set pool.max_read_connections to at least 1".to_string(),
495            });
496        }
497        if self.reader_timeout_secs == 0 {
498            self.reader_timeout_secs = 1;
499        }
500        self.reader_timeout_secs = self.reader_timeout_secs.min(300);
501        Ok(())
502    }
503}
504
505/// Resource limits for the memory system.
506///
507/// Prevents runaway resource usage. All limits have defaults tuned for
508/// a laptop-class server (8GB RAM, SSD storage).
509#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct MemoryLimits {
511    /// Maximum number of facts per namespace.
512    /// Default: 100_000.
513    pub max_facts_per_namespace: usize,
514
515    /// Maximum number of chunks per document.
516    /// Default: 1_000.
517    pub max_chunks_per_document: usize,
518
519    /// Maximum content size in bytes for a single fact or message.
520    /// Default: 1 MB (1_048_576 bytes).
521    pub max_content_bytes: usize,
522
523    /// Maximum number of concurrent embedding requests.
524    /// Hard-capped at 32 regardless of config.
525    /// Default: 8.
526    pub max_embedding_concurrency: usize,
527
528    /// Maximum total database size in bytes. 0 = unlimited.
529    /// Default: 0 (unlimited).
530    pub max_db_size_bytes: u64,
531
532    /// Embedding request timeout.
533    /// Default: 30 seconds.
534    #[serde(with = "duration_secs")]
535    pub embedding_timeout: Duration,
536}
537
538impl Default for MemoryLimits {
539    fn default() -> Self {
540        Self {
541            max_facts_per_namespace: 100_000,
542            max_chunks_per_document: 1_000,
543            max_content_bytes: 1_048_576,
544            max_embedding_concurrency: 8,
545            max_db_size_bytes: 0,
546            embedding_timeout: Duration::from_secs(30),
547        }
548    }
549}
550
551impl MemoryLimits {
552    /// Normalize and validate limits to hard caps.
553    pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
554        if self.max_facts_per_namespace == 0 {
555            return Err(MemoryError::InvalidConfig {
556                field: "limits.max_facts_per_namespace",
557                reason: "must be at least 1".to_string(),
558            });
559        }
560        if self.max_chunks_per_document == 0 {
561            return Err(MemoryError::InvalidConfig {
562                field: "limits.max_chunks_per_document",
563                reason: "must be at least 1".to_string(),
564            });
565        }
566        if self.max_content_bytes == 0 {
567            return Err(MemoryError::InvalidConfig {
568                field: "limits.max_content_bytes",
569                reason: "must be at least 1".to_string(),
570            });
571        }
572        // Hard cap: concurrency at 32
573        if self.max_embedding_concurrency > 32 {
574            self.max_embedding_concurrency = 32;
575        }
576        if self.max_embedding_concurrency == 0 {
577            self.max_embedding_concurrency = 1;
578        }
579        if self.embedding_timeout.is_zero() {
580            self.embedding_timeout = Duration::from_secs(1);
581        }
582        Ok(self)
583    }
584
585    /// Backward-compatible alias for callers that only need clamped limits.
586    ///
587    /// Falls back to defaults if the caller-provided limits are invalid.
588    /// Default limits are infallible so the fallback path cannot fail.
589    pub fn validated(self) -> Self {
590        self.normalize_and_validate().unwrap_or_else(|err| {
591            tracing::warn!(
592                error = %err,
593                "invalid MemoryLimits supplied to validated(); using defaults"
594            );
595            // Default limits are always valid — this path is infallible.
596            let defaults = Self::default();
597            Self {
598                max_facts_per_namespace: defaults.max_facts_per_namespace,
599                max_chunks_per_document: defaults.max_chunks_per_document,
600                max_content_bytes: defaults.max_content_bytes,
601                max_embedding_concurrency: defaults.max_embedding_concurrency.clamp(1, 32),
602                max_db_size_bytes: defaults.max_db_size_bytes,
603                embedding_timeout: if defaults.embedding_timeout.is_zero() {
604                    std::time::Duration::from_secs(1)
605                } else {
606                    defaults.embedding_timeout
607                },
608            }
609        })
610    }
611}
612
613mod duration_secs {
614    use serde::{Deserialize, Deserializer, Serializer};
615    use std::time::Duration;
616
617    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
618        s.serialize_u64(d.as_secs())
619    }
620
621    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
622        let secs = u64::deserialize(d)?;
623        Ok(Duration::from_secs(secs))
624    }
625}