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