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/// One deterministic correction applied while normalizing numeric configuration.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub struct ConfigCorrection {
11    /// Fully-qualified configuration field name.
12    pub field: String,
13    /// Why the supplied value was invalid.
14    pub reason: String,
15    /// Human-readable description of the replacement or clamp.
16    pub action: String,
17}
18
19/// Structured record of all corrections applied during normalization.
20#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
21pub struct ConfigCorrectionReport {
22    /// Corrections in deterministic field order.
23    pub corrections: Vec<ConfigCorrection>,
24}
25
26impl ConfigCorrectionReport {
27    /// True when no caller-supplied field required correction.
28    pub fn is_empty(&self) -> bool {
29        self.corrections.is_empty()
30    }
31
32    fn corrected(&mut self, field: &str, reason: &str, action: String) {
33        self.corrections.push(ConfigCorrection {
34            field: field.to_string(),
35            reason: reason.to_string(),
36            action,
37        });
38    }
39}
40
41/// Configuration for the memory system.
42#[derive(Clone, Serialize, Deserialize)]
43pub struct MemoryConfig {
44    /// Base directory for all storage files (SQLite + HNSW sidecar files).
45    /// Replaces the v0.1.0 `database_path` field.
46    pub base_dir: PathBuf,
47
48    /// Embedding provider configuration.
49    pub embedding: EmbeddingConfig,
50
51    /// Search tuning parameters.
52    pub search: SearchConfig,
53
54    /// Chunking parameters.
55    pub chunking: ChunkingConfig,
56
57    /// Connection pool configuration.
58    pub pool: PoolConfig,
59
60    /// Resource limits.
61    pub limits: MemoryLimits,
62
63    /// Custom token counter. None = use EstimateTokenCounter (chars / 4).
64    #[serde(skip)]
65    pub token_counter: Option<Arc<dyn TokenCounter>>,
66
67    /// HNSW index configuration.
68    #[cfg(feature = "hnsw")]
69    #[serde(skip)]
70    pub hnsw: crate::hnsw::HnswConfig,
71}
72
73impl std::fmt::Debug for MemoryConfig {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        let mut s = f.debug_struct("MemoryConfig");
76        s.field("base_dir", &self.base_dir)
77            .field("embedding", &self.embedding)
78            .field("search", &self.search)
79            .field("chunking", &self.chunking)
80            .field("pool", &self.pool)
81            .field("limits", &self.limits)
82            .field(
83                "token_counter",
84                &self.token_counter.as_ref().map(|_| "custom"),
85            );
86        #[cfg(feature = "hnsw")]
87        s.field("hnsw", &self.hnsw);
88        s.finish()
89    }
90}
91
92impl Default for MemoryConfig {
93    fn default() -> Self {
94        Self {
95            base_dir: PathBuf::from("memory"),
96            embedding: EmbeddingConfig::default(),
97            search: SearchConfig::default(),
98            chunking: ChunkingConfig::default(),
99            pool: PoolConfig::default(),
100            limits: MemoryLimits::default(),
101            token_counter: None,
102            #[cfg(feature = "hnsw")]
103            hnsw: crate::hnsw::HnswConfig::default(),
104        }
105    }
106}
107
108impl MemoryConfig {
109    /// Normalize configuration and return every recoverable numeric correction.
110    pub fn normalize_with_report(mut self) -> Result<(Self, ConfigCorrectionReport), MemoryError> {
111        self.embedding.normalize_and_validate()?;
112        let (limits, report) = self.limits.normalize_with_report();
113        self.limits = limits;
114        let timeout_cap_secs = self.limits.embedding_timeout.as_secs().max(1);
115        self.embedding.timeout_secs = self.embedding.timeout_secs.min(timeout_cap_secs);
116        self.search
117            .normalize_and_validate(self.embedding.dimensions)?;
118        self.chunking.normalize_and_validate()?;
119        self.pool.normalize_and_validate()?;
120        #[cfg(feature = "hnsw")]
121        {
122            self.hnsw.dimensions = self.embedding.dimensions;
123        }
124        Ok((self, report))
125    }
126
127    /// Normalize and validate configuration into a concrete runtime shape.
128    ///
129    /// This is the single canonical config entry point used by store creation.
130    pub fn normalize_and_validate(self) -> Result<Self, MemoryError> {
131        Ok(self.normalize_with_report()?.0)
132    }
133}
134
135/// Embedding provider configuration.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct EmbeddingConfig {
138    /// Ollama base URL. Only required when using OllamaEmbedder.
139    /// When using CandleEmbedder (default with `candle-embedder` feature),
140    /// this field is ignored. Defaults to `http://localhost:11434`.
141    pub ollama_url: String,
142
143    /// Embedding model name.
144    pub model: String,
145
146    /// Expected embedding dimensions.
147    pub dimensions: usize,
148
149    /// Maximum texts to embed in a single API call.
150    pub batch_size: usize,
151
152    /// Timeout for embedding requests in seconds.
153    pub timeout_secs: u64,
154}
155
156impl Default for EmbeddingConfig {
157    fn default() -> Self {
158        Self {
159            ollama_url: "http://localhost:11434".to_string(),
160            model: "nomic-embed-text".to_string(),
161            dimensions: 768,
162            batch_size: 32,
163            timeout_secs: 30,
164        }
165    }
166}
167
168impl EmbeddingConfig {
169    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
170        if self.dimensions == 0 {
171            return Err(MemoryError::InvalidConfig {
172                field: "embedding.dimensions",
173                reason: "dimensions must be at least 1".to_string(),
174            });
175        }
176        if self.batch_size == 0 {
177            self.batch_size = 1;
178        }
179        if self.timeout_secs == 0 {
180            self.timeout_secs = 1;
181        }
182        // Validate ollama_url only when it will be used. With the
183        // candle-embedder feature, the default embedder is CandleEmbedder
184        // which does not use Ollama, so a placeholder URL is fine.
185        #[cfg(not(feature = "candle-embedder"))]
186        {
187            let parsed =
188                reqwest::Url::parse(&self.ollama_url).map_err(|_| MemoryError::InvalidConfig {
189                    field: "embedding.ollama_url",
190                    reason: "must be an absolute http:// or https:// URL".to_string(),
191                })?;
192            match parsed.scheme() {
193                "http" | "https" if parsed.host_str().is_some() => {}
194                _ => {
195                    return Err(MemoryError::InvalidConfig {
196                        field: "embedding.ollama_url",
197                        reason: "must be an absolute http:// or https:// URL".to_string(),
198                    })
199                }
200            }
201        }
202        // With candle-embedder, skip URL validation — the field is ignored
203        // by CandleEmbedder. If OllamaEmbedder is used explicitly via
204        // open_with_embedder, it does its own URL handling.
205        #[cfg(feature = "candle-embedder")]
206        {
207            let _ = &self.ollama_url; // suppress unused field warning
208        }
209        Ok(())
210    }
211}
212
213/// Search tuning parameters.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct SearchConfig {
216    /// Weight for BM25 score in RRF fusion.
217    pub bm25_weight: f64,
218
219    /// Weight for vector similarity in RRF fusion.
220    pub vector_weight: f64,
221
222    /// Weight for sparse dot-product ranking in RRF fusion.
223    /// Defaults to 0.0 so existing BM25+dense behavior is unchanged.
224    #[serde(default = "default_zero")]
225    pub sparse_weight: f64,
226
227    /// Maximum sparse candidates admitted to fusion.
228    #[serde(default = "default_sparse_top_k")]
229    pub sparse_top_k: usize,
230
231    /// Minimum sparse dot-product score admitted to fusion.
232    #[serde(default = "default_zero")]
233    pub sparse_min_score: f64,
234
235    /// Explicitly allow dense-only embedders to derive generic sparse weights.
236    /// This is disabled by default and the result must not be described as SPLADE.
237    #[serde(default)]
238    pub derive_sparse_from_dense: bool,
239
240    /// Maximum dense dimensions retained by explicit generic sparse derivation.
241    #[serde(default = "default_sparse_derive_top_k")]
242    pub sparse_derive_top_k: usize,
243
244    /// Minimum absolute dense value retained by generic sparse derivation.
245    #[serde(default = "default_sparse_derive_min_weight")]
246    pub sparse_derive_min_weight: f32,
247
248    /// Weight for late interaction (ColBERT MaxSim) in RRF fusion.
249    /// Defaults to 0.0 (disabled). Set to 1.0 to enable as 3rd RRF signal.
250    #[serde(default = "default_zero")]
251    pub late_interaction_weight: f64,
252
253    /// BM25 k1 parameter. Controls term frequency saturation.
254    /// Default: 1.2 (FTS5 standard). Lower (0.8-1.0) helps with technical content.
255    pub bm25_k1: f64,
256
257    /// BM25 b parameter. Controls document length normalization.
258    /// Default: 0.75 (FTS5 standard).
259    pub bm25_b: f64,
260
261    /// Optional per-namespace weight multipliers.
262    /// Empty = no weighting (all namespaces scored equally).
263    pub namespace_weights: std::collections::HashMap<String, f64>,
264
265    /// RRF constant (k). Controls rank importance decay.
266    pub rrf_k: f64,
267
268    /// Number of candidates from each search method before fusion.
269    pub candidate_pool_size: usize,
270
271    /// Default number of results to return.
272    pub default_top_k: usize,
273
274    /// Minimum cosine similarity threshold for vector candidates.
275    pub min_similarity: f64,
276
277    /// Optional recency boost. If enabled, results are boosted based on how
278    /// recently they were created/updated. The value is the half-life in days —
279    /// a fact that is `recency_half_life_days` old gets 50% of the recency boost.
280    /// None = no recency weighting (current behavior, default).
281    pub recency_half_life_days: Option<f64>,
282
283    /// Weight of the recency boost relative to BM25 and vector scores in RRF.
284    /// Only used when recency_half_life_days is Some.
285    /// Default: 0.5
286    pub recency_weight: f64,
287
288    /// When true, rerank top HNSW candidates using exact f32 cosine similarity
289    /// from SQLite. Improves recall at the cost of one batched SQL query.
290    /// Only applies when HNSW feature is enabled.
291    /// Default: true
292    pub rerank_from_f32: bool,
293
294    /// Optional derived-vector candidate backend. Disabled by default because
295    /// raw f32 embeddings remain authoritative.
296    #[serde(default)]
297    pub derived_vector_backend: DerivedVectorBackendPolicy,
298
299    /// TurboQuant polar angle bits when the TurboQuant candidate backend is enabled.
300    #[serde(default = "default_turbo_quant_bits")]
301    pub turbo_quant_bits: u8,
302
303    /// TurboQuant QJL projection count when the TurboQuant candidate backend is enabled.
304    #[serde(default = "default_turbo_quant_projections")]
305    pub turbo_quant_projections: usize,
306
307    /// TurboQuant profile seed when the TurboQuant candidate backend is enabled.
308    #[serde(default)]
309    pub turbo_quant_seed: u64,
310
311    /// Require exact f32 rerank for TurboQuant candidates. Defaults to true.
312    #[serde(default = "default_true")]
313    pub turbo_quant_require_exact_rerank: bool,
314
315    /// Matryoshka candidate-stage embedding dimensions for 2-stage search.
316    /// When set to Some(dim) and the `matryoshka` feature is enabled, the query
317    /// embedding is truncated to `dim` dimensions for candidate retrieval, then
318    /// reranked with the full embedding. Disabled by default because it requires
319    /// a compatible truncated-vector index; callers opt in explicitly.
320    #[serde(default = "default_candidate_dims")]
321    pub candidate_dims: Option<usize>,
322
323    /// When true, compress search result content using SimpleMem-style semantic
324    /// compression (first sentence + key terms, capped at 150 chars).
325    /// Defaults to false.
326    #[serde(default)]
327    pub compress_results: bool,
328
329    /// When true, use q8-quantized embeddings for initial candidate selection
330    /// in vector search, then rerank top candidates with exact f32 cosine.
331    /// Reduces f32 computations by ~20x on large stores with zero recall loss.
332    /// Defaults to false (existing brute-force behavior).
333    #[serde(default)]
334    pub use_compressed_candidates: bool,
335}
336
337/// Candidate backend policy for rebuildable derived vector artifacts.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
339#[serde(rename_all = "snake_case")]
340pub enum DerivedVectorBackendPolicy {
341    /// Use authoritative raw f32 embeddings for vector candidate generation.
342    #[default]
343    Disabled,
344    /// Use TurboQuant only to generate candidates, then exact rerank by default.
345    TurboQuantCandidateOnly,
346    /// Use a generation-level proveKV/poly-kv shared pool only to generate candidates,
347    /// then exact-rerank against authoritative f32 embeddings.
348    ///
349    /// This is deliberately not a replacement for SQLite f32 storage or for prompt/KV
350    /// prefix reuse. It is a rebuildable derived artifact over an embedding snapshot.
351    ProveKvPoolCandidateOnly,
352}
353
354const fn default_turbo_quant_bits() -> u8 {
355    8
356}
357
358const fn default_turbo_quant_projections() -> usize {
359    64
360}
361
362const fn default_true() -> bool {
363    true
364}
365
366const fn default_zero() -> f64 {
367    0.0
368}
369
370const fn default_sparse_top_k() -> usize {
371    50
372}
373
374const fn default_sparse_derive_top_k() -> usize {
375    128
376}
377
378const fn default_sparse_derive_min_weight() -> f32 {
379    0.01
380}
381
382const fn default_candidate_dims() -> Option<usize> {
383    None
384}
385
386impl Default for SearchConfig {
387    fn default() -> Self {
388        Self {
389            bm25_weight: 1.0,
390            vector_weight: 1.0,
391            sparse_weight: 0.0,
392            sparse_top_k: default_sparse_top_k(),
393            sparse_min_score: 0.0,
394            derive_sparse_from_dense: false,
395            sparse_derive_top_k: default_sparse_derive_top_k(),
396            sparse_derive_min_weight: default_sparse_derive_min_weight(),
397            late_interaction_weight: 0.15,
398            bm25_k1: 1.2,
399            bm25_b: 0.75,
400            namespace_weights: std::collections::HashMap::new(),
401            rrf_k: 60.0,
402            candidate_pool_size: 50,
403            default_top_k: 5,
404            min_similarity: 0.3,
405            recency_half_life_days: None,
406            recency_weight: 0.5,
407            rerank_from_f32: true,
408            derived_vector_backend: DerivedVectorBackendPolicy::Disabled,
409            turbo_quant_bits: default_turbo_quant_bits(),
410            turbo_quant_projections: default_turbo_quant_projections(),
411            turbo_quant_seed: 0,
412            turbo_quant_require_exact_rerank: true,
413            candidate_dims: default_candidate_dims(),
414            compress_results: false,
415            use_compressed_candidates: false,
416        }
417    }
418}
419
420impl SearchConfig {
421    pub(crate) fn uses_turbo_quant_backend(&self) -> bool {
422        self.derived_vector_backend == DerivedVectorBackendPolicy::TurboQuantCandidateOnly
423    }
424
425    pub(crate) fn uses_provekv_pool_backend(&self) -> bool {
426        self.derived_vector_backend == DerivedVectorBackendPolicy::ProveKvPoolCandidateOnly
427    }
428
429    pub(crate) fn uses_derived_vector_backend(&self) -> bool {
430        self.uses_turbo_quant_backend() || self.uses_provekv_pool_backend()
431    }
432
433    fn normalize_and_validate(&mut self, embedding_dimensions: usize) -> Result<(), MemoryError> {
434        const MAX_WEIGHT: f64 = 1_000_000.0;
435        const MAX_CANDIDATE_POOL: usize = 1_000_000;
436        const MAX_TOP_K: usize = 10_000;
437        const MAX_SPARSE_DIMENSIONS: usize = 1_000_000;
438        const MAX_RRF_K: f64 = 1_000_000.0;
439        const MAX_RECENCY_DAYS: f64 = 365_000.0;
440        #[cfg(not(feature = "turbo-quant-codec"))]
441        let _ = embedding_dimensions;
442        if self.candidate_pool_size == 0 {
443            self.candidate_pool_size = 1;
444        }
445        if self.default_top_k == 0 {
446            self.default_top_k = 1;
447        }
448        self.candidate_pool_size = self.candidate_pool_size.max(self.default_top_k);
449        if self.sparse_top_k == 0 {
450            self.sparse_top_k = 1;
451        }
452        if self.sparse_derive_top_k == 0 {
453            self.sparse_derive_top_k = 1;
454        }
455        if self.candidate_pool_size > MAX_CANDIDATE_POOL {
456            return Err(MemoryError::InvalidConfig {
457                field: "search.candidate_pool_size",
458                reason: format!("candidate_pool_size must be <= {MAX_CANDIDATE_POOL}"),
459            });
460        }
461        if self.default_top_k > MAX_TOP_K {
462            return Err(MemoryError::InvalidConfig {
463                field: "search.default_top_k",
464                reason: format!("default_top_k must be <= {MAX_TOP_K}"),
465            });
466        }
467        if self.sparse_top_k > MAX_CANDIDATE_POOL {
468            return Err(MemoryError::InvalidConfig {
469                field: "search.sparse_top_k",
470                reason: format!("sparse_top_k must be <= {MAX_CANDIDATE_POOL}"),
471            });
472        }
473        if self.sparse_derive_top_k > MAX_SPARSE_DIMENSIONS {
474            return Err(MemoryError::InvalidConfig {
475                field: "search.sparse_derive_top_k",
476                reason: format!("sparse_derive_top_k must be <= {MAX_SPARSE_DIMENSIONS}"),
477            });
478        }
479        if !self.rrf_k.is_finite() || self.rrf_k <= 0.0 {
480            return Err(MemoryError::InvalidConfig {
481                field: "search.rrf_k",
482                reason: "rrf_k must be finite and > 0".to_string(),
483            });
484        }
485        if self.rrf_k > MAX_RRF_K {
486            return Err(MemoryError::InvalidConfig {
487                field: "search.rrf_k",
488                reason: format!("rrf_k must be <= {MAX_RRF_K}"),
489            });
490        }
491        if !self.bm25_weight.is_finite() || !(0.0..=MAX_WEIGHT).contains(&self.bm25_weight) {
492            return Err(MemoryError::InvalidConfig {
493                field: "search.bm25_weight",
494                reason: format!("bm25_weight must be finite and within [0, {MAX_WEIGHT}]"),
495            });
496        }
497        if !self.vector_weight.is_finite() || !(0.0..=MAX_WEIGHT).contains(&self.vector_weight) {
498            return Err(MemoryError::InvalidConfig {
499                field: "search.vector_weight",
500                reason: format!("vector_weight must be finite and within [0, {MAX_WEIGHT}]"),
501            });
502        }
503        if !self.sparse_weight.is_finite() || !(0.0..=MAX_WEIGHT).contains(&self.sparse_weight) {
504            return Err(MemoryError::InvalidConfig {
505                field: "search.sparse_weight",
506                reason: format!("sparse_weight must be finite and within [0, {MAX_WEIGHT}]"),
507            });
508        }
509        if !self.sparse_min_score.is_finite() || self.sparse_min_score.abs() > MAX_WEIGHT {
510            return Err(MemoryError::InvalidConfig {
511                field: "search.sparse_min_score",
512                reason: format!("sparse_min_score must be finite and within ±{MAX_WEIGHT}"),
513            });
514        }
515        if !self.sparse_derive_min_weight.is_finite()
516            || !(0.0..=MAX_WEIGHT as f32).contains(&self.sparse_derive_min_weight)
517        {
518            return Err(MemoryError::InvalidConfig {
519                field: "search.sparse_derive_min_weight",
520                reason: format!(
521                    "sparse_derive_min_weight must be finite and within [0, {MAX_WEIGHT}]"
522                ),
523            });
524        }
525        if !self.late_interaction_weight.is_finite()
526            || !(0.0..=MAX_WEIGHT).contains(&self.late_interaction_weight)
527        {
528            return Err(MemoryError::InvalidConfig {
529                field: "search.late_interaction_weight",
530                reason: format!(
531                    "late_interaction_weight must be finite and within [0, {MAX_WEIGHT}]"
532                ),
533            });
534        }
535        if !self.bm25_k1.is_finite() || !(0.0..=100.0).contains(&self.bm25_k1) {
536            return Err(MemoryError::InvalidConfig {
537                field: "search.bm25_k1",
538                reason: "bm25_k1 must be finite and within [0, 100]".to_string(),
539            });
540        }
541        if !self.bm25_b.is_finite() || !(0.0..=1.0).contains(&self.bm25_b) {
542            return Err(MemoryError::InvalidConfig {
543                field: "search.bm25_b",
544                reason: "bm25_b must be finite and within [0, 1]".to_string(),
545            });
546        }
547        if let Some((_, weight)) = self
548            .namespace_weights
549            .iter()
550            .find(|(_, weight)| !weight.is_finite() || !(0.0..=MAX_WEIGHT).contains(weight))
551        {
552            return Err(MemoryError::InvalidConfig {
553                field: "search.namespace_weights",
554                reason: format!(
555                    "namespace weights must be finite and within [0, {MAX_WEIGHT}]; found {weight}"
556                ),
557            });
558        }
559        if !self.recency_weight.is_finite() || !(0.0..=MAX_WEIGHT).contains(&self.recency_weight) {
560            return Err(MemoryError::InvalidConfig {
561                field: "search.recency_weight",
562                reason: format!("recency_weight must be finite and within [0, {MAX_WEIGHT}]"),
563            });
564        }
565        if !self.min_similarity.is_finite() || !(-1.0..=1.0).contains(&self.min_similarity) {
566            return Err(MemoryError::InvalidConfig {
567                field: "search.min_similarity",
568                reason: "min_similarity must be finite and within [-1.0, 1.0]".to_string(),
569            });
570        }
571        if matches!(self.recency_half_life_days, Some(v) if !v.is_finite()) {
572            return Err(MemoryError::InvalidConfig {
573                field: "search.recency_half_life_days",
574                reason: "recency_half_life_days must be finite".to_string(),
575            });
576        }
577        if matches!(self.recency_half_life_days, Some(v) if v <= 0.0) {
578            return Err(MemoryError::InvalidConfig {
579                field: "search.recency_half_life_days",
580                reason: "recency_half_life_days must be > 0 when enabled".to_string(),
581            });
582        }
583        if matches!(self.recency_half_life_days, Some(v) if v > MAX_RECENCY_DAYS) {
584            return Err(MemoryError::InvalidConfig {
585                field: "search.recency_half_life_days",
586                reason: format!("recency_half_life_days must be <= {MAX_RECENCY_DAYS}"),
587            });
588        }
589        if !(2..=16).contains(&self.turbo_quant_bits) {
590            return Err(MemoryError::InvalidConfig {
591                field: "search.turbo_quant_bits",
592                reason: "TurboQuant bits must be within 2..=16".to_string(),
593            });
594        }
595        if self.turbo_quant_projections == 0 || self.turbo_quant_projections > MAX_CANDIDATE_POOL {
596            return Err(MemoryError::InvalidConfig {
597                field: "search.turbo_quant_projections",
598                reason: format!("TurboQuant projections must be within 1..={MAX_CANDIDATE_POOL}"),
599            });
600        }
601        if matches!(self.candidate_dims, Some(0))
602            || matches!(self.candidate_dims, Some(dim) if dim > embedding_dimensions)
603        {
604            return Err(MemoryError::InvalidConfig {
605                field: "search.candidate_dims",
606                reason: format!(
607                    "candidate_dims must be within 1..={embedding_dimensions} when enabled"
608                ),
609            });
610        }
611        if self.uses_turbo_quant_backend() {
612            #[cfg(not(feature = "turbo-quant-codec"))]
613            {
614                return Err(MemoryError::InvalidConfig {
615                    field: "search.derived_vector_backend",
616                    reason: "turbo_quant_candidate_only requires the turbo-quant-codec feature"
617                        .to_string(),
618                });
619            }
620            #[cfg(feature = "turbo-quant-codec")]
621            {
622                if embedding_dimensions % 2 != 0 {
623                    return Err(MemoryError::InvalidConfig {
624                        field: "embedding.dimensions",
625                        reason: "TurboQuant requires even embedding dimensions".to_string(),
626                    });
627                }
628                if self.turbo_quant_projections == 0 {
629                    return Err(MemoryError::InvalidConfig {
630                        field: "search.turbo_quant_projections",
631                        reason: "TurboQuant projections must be at least 1".to_string(),
632                    });
633                }
634                if !(2..=16).contains(&self.turbo_quant_bits) {
635                    return Err(MemoryError::InvalidConfig {
636                        field: "search.turbo_quant_bits",
637                        reason: "TurboQuant bits must be within 2..=16".to_string(),
638                    });
639                }
640            }
641        }
642        if self.uses_derived_vector_backend() && !self.turbo_quant_require_exact_rerank {
643            return Err(MemoryError::InvalidConfig {
644                field: "search.turbo_quant_require_exact_rerank",
645                reason: "derived vector candidate backends require exact f32 rerank".to_string(),
646            });
647        }
648        Ok(())
649    }
650}
651
652/// Chunking strategy to use when splitting text.
653#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
654#[serde(rename_all = "snake_case")]
655pub enum ChunkingStrategy {
656    /// Plain recursive splitting (current/default behavior).
657    #[default]
658    Plain,
659    /// Sentence-boundary-aware chunking with configurable overlap.
660    Sentence,
661    /// Code-aware chunking that avoids splitting inside function bodies.
662    /// Detects Rust, Python, and TypeScript blocks.
663    Code,
664    /// Markdown-header-based chunking that splits on header boundaries.
665    Markdown,
666}
667
668/// Text chunking parameters.
669#[derive(Debug, Clone, Serialize, Deserialize)]
670pub struct ChunkingConfig {
671    /// Target chunk size in characters.
672    pub target_size: usize,
673
674    /// Minimum chunk size. Chunks smaller than this are merged with neighbors.
675    pub min_size: usize,
676
677    /// Maximum chunk size. Chunks larger than this are force-split.
678    pub max_size: usize,
679
680    /// Overlap between adjacent chunks in characters.
681    pub overlap: usize,
682
683    /// Chunking strategy to use. Defaults to [`ChunkingStrategy::Plain`]
684    /// for backward compatibility.
685    #[serde(default)]
686    pub strategy: ChunkingStrategy,
687}
688
689impl Default for ChunkingConfig {
690    fn default() -> Self {
691        Self {
692            target_size: 1000,
693            min_size: 100,
694            max_size: 2000,
695            overlap: 200,
696            strategy: ChunkingStrategy::default(),
697        }
698    }
699}
700
701impl ChunkingConfig {
702    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
703        if self.min_size == 0 {
704            self.min_size = 1;
705        }
706        if self.max_size == 0 {
707            return Err(MemoryError::InvalidConfig {
708                field: "chunking.max_size",
709                reason: "max_size must be at least 1".to_string(),
710            });
711        }
712        if self.max_size < self.min_size {
713            return Err(MemoryError::InvalidConfig {
714                field: "chunking.max_size",
715                reason: "max_size must be >= min_size".to_string(),
716            });
717        }
718        if self.target_size < self.min_size {
719            self.target_size = self.min_size;
720        }
721        if self.target_size > self.max_size {
722            self.target_size = self.max_size;
723        }
724        if self.overlap >= self.min_size {
725            self.overlap = self.min_size.saturating_sub(1);
726        }
727        Ok(())
728    }
729}
730
731/// Connection pool configuration for SQLite.
732///
733/// Controls busy timeout and WAL checkpoint behavior. These defaults
734/// are tuned for a single-process server on local SSD storage.
735#[derive(Debug, Clone, Serialize, Deserialize)]
736pub struct PoolConfig {
737    /// SQLite busy timeout in milliseconds.
738    /// Default: 5000 (5 seconds).
739    pub busy_timeout_ms: u32,
740
741    /// WAL auto-checkpoint threshold in pages.
742    /// Default: 1000 (~4 MB with 4KB pages).
743    pub wal_autocheckpoint: u32,
744
745    /// Enable WAL mode. Should almost always be true.
746    /// Default: true.
747    pub enable_wal: bool,
748
749    /// Number of reader connections kept in the pool.
750    /// Writes still flow through a single writer connection because SQLite
751    /// allows only one concurrent writer, but readers can proceed concurrently
752    /// under WAL semantics.
753    pub max_read_connections: usize,
754
755    /// Timeout in seconds for acquiring a reader connection from the pool.
756    /// Default: 30 seconds.
757    pub reader_timeout_secs: u64,
758}
759
760impl Default for PoolConfig {
761    fn default() -> Self {
762        Self {
763            busy_timeout_ms: 5000,
764            wal_autocheckpoint: 1000,
765            enable_wal: true,
766            max_read_connections: 4,
767            reader_timeout_secs: 30,
768        }
769    }
770}
771
772impl PoolConfig {
773    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
774        if self.busy_timeout_ms == 0 {
775            self.busy_timeout_ms = 1;
776        }
777        if self.wal_autocheckpoint == 0 {
778            self.wal_autocheckpoint = 1;
779        }
780        if self.max_read_connections == 0 {
781            return Err(MemoryError::InvalidConfig {
782                field: "pool.max_read_connections",
783                reason: "set pool.max_read_connections to at least 1".to_string(),
784            });
785        }
786        if self.reader_timeout_secs == 0 {
787            self.reader_timeout_secs = 1;
788        }
789        self.reader_timeout_secs = self.reader_timeout_secs.min(300);
790        Ok(())
791    }
792}
793
794/// Resource limits for the memory system.
795///
796/// Prevents runaway resource usage. All limits have defaults tuned for
797/// a laptop-class server (8GB RAM, SSD storage).
798#[derive(Debug, Clone, Serialize, Deserialize)]
799pub struct MemoryLimits {
800    /// Maximum number of facts per namespace.
801    /// Default: 100_000.
802    pub max_facts_per_namespace: usize,
803
804    /// Maximum number of chunks per document.
805    /// Default: 1_000.
806    pub max_chunks_per_document: usize,
807
808    /// Maximum content size in bytes for a single fact or message.
809    /// Default: 1 MB (1_048_576 bytes).
810    pub max_content_bytes: usize,
811
812    /// Maximum number of concurrent embedding requests.
813    /// Hard-capped at 32 regardless of config.
814    /// Default: 8.
815    pub max_embedding_concurrency: usize,
816
817    /// Maximum total database size in bytes. 0 = unlimited.
818    /// Default: 0 (unlimited).
819    pub max_db_size_bytes: u64,
820
821    /// Embedding request timeout.
822    /// Default: 30 seconds.
823    #[serde(with = "duration_secs")]
824    pub embedding_timeout: Duration,
825}
826
827impl Default for MemoryLimits {
828    fn default() -> Self {
829        Self {
830            max_facts_per_namespace: 100_000,
831            max_chunks_per_document: 1_000,
832            max_content_bytes: 1_048_576,
833            max_embedding_concurrency: 8,
834            max_db_size_bytes: 0,
835            embedding_timeout: Duration::from_secs(30),
836        }
837    }
838}
839
840impl MemoryLimits {
841    /// Normalize every resource field independently and return a correction report.
842    /// Valid fields are never replaced because an unrelated field is invalid.
843    pub fn normalize_with_report(mut self) -> (Self, ConfigCorrectionReport) {
844        const MAX_FACTS_PER_NAMESPACE: usize = 10_000_000;
845        const MAX_CHUNKS_PER_DOCUMENT: usize = 1_000_000;
846        const MAX_CONTENT_BYTES: usize = 64 * 1024 * 1024;
847        const MAX_EMBEDDING_TIMEOUT_SECS: u64 = 300;
848        const MAX_DB_SIZE_BYTES: u64 = 1 << 50;
849
850        let defaults = Self::default();
851        let mut report = ConfigCorrectionReport::default();
852        if self.max_facts_per_namespace == 0 {
853            self.max_facts_per_namespace = defaults.max_facts_per_namespace;
854            report.corrected(
855                "limits.max_facts_per_namespace",
856                "must be at least 1",
857                format!("replaced with default {}", self.max_facts_per_namespace),
858            );
859        } else if self.max_facts_per_namespace > MAX_FACTS_PER_NAMESPACE {
860            self.max_facts_per_namespace = MAX_FACTS_PER_NAMESPACE;
861            report.corrected(
862                "limits.max_facts_per_namespace",
863                "exceeds hard upper bound",
864                format!("clamped to {MAX_FACTS_PER_NAMESPACE}"),
865            );
866        }
867        if self.max_chunks_per_document == 0 {
868            self.max_chunks_per_document = defaults.max_chunks_per_document;
869            report.corrected(
870                "limits.max_chunks_per_document",
871                "must be at least 1",
872                format!("replaced with default {}", self.max_chunks_per_document),
873            );
874        } else if self.max_chunks_per_document > MAX_CHUNKS_PER_DOCUMENT {
875            self.max_chunks_per_document = MAX_CHUNKS_PER_DOCUMENT;
876            report.corrected(
877                "limits.max_chunks_per_document",
878                "exceeds hard upper bound",
879                format!("clamped to {MAX_CHUNKS_PER_DOCUMENT}"),
880            );
881        }
882        if self.max_content_bytes == 0 {
883            self.max_content_bytes = defaults.max_content_bytes;
884            report.corrected(
885                "limits.max_content_bytes",
886                "must be at least 1",
887                format!("replaced with default {}", self.max_content_bytes),
888            );
889        } else if self.max_content_bytes > MAX_CONTENT_BYTES {
890            self.max_content_bytes = MAX_CONTENT_BYTES;
891            report.corrected(
892                "limits.max_content_bytes",
893                "exceeds hard upper bound",
894                format!("clamped to {MAX_CONTENT_BYTES}"),
895            );
896        }
897        if self.max_embedding_concurrency == 0 {
898            self.max_embedding_concurrency = 1;
899            report.corrected(
900                "limits.max_embedding_concurrency",
901                "must be at least 1",
902                "clamped to 1".to_string(),
903            );
904        } else if self.max_embedding_concurrency > 32 {
905            self.max_embedding_concurrency = 32;
906            report.corrected(
907                "limits.max_embedding_concurrency",
908                "exceeds hard upper bound",
909                "clamped to 32".to_string(),
910            );
911        }
912        if self.max_db_size_bytes > MAX_DB_SIZE_BYTES {
913            self.max_db_size_bytes = MAX_DB_SIZE_BYTES;
914            report.corrected(
915                "limits.max_db_size_bytes",
916                "exceeds hard upper bound",
917                format!("clamped to {MAX_DB_SIZE_BYTES}"),
918            );
919        }
920        if self.embedding_timeout.is_zero() {
921            self.embedding_timeout = Duration::from_secs(1);
922            report.corrected(
923                "limits.embedding_timeout",
924                "must be at least one second",
925                "clamped to 1 second".to_string(),
926            );
927        } else if self.embedding_timeout.as_secs() > MAX_EMBEDDING_TIMEOUT_SECS {
928            self.embedding_timeout = Duration::from_secs(MAX_EMBEDDING_TIMEOUT_SECS);
929            report.corrected(
930                "limits.embedding_timeout",
931                "exceeds hard upper bound",
932                format!("clamped to {MAX_EMBEDDING_TIMEOUT_SECS} seconds"),
933            );
934        }
935        (self, report)
936    }
937
938    /// Normalize and validate limits to hard caps.
939    pub fn normalize_and_validate(self) -> Result<Self, MemoryError> {
940        Ok(self.normalize_with_report().0)
941    }
942
943    /// Backward-compatible alias for callers that only need clamped limits.
944    ///
945    /// Falls back to defaults if the caller-provided limits are invalid.
946    /// Default limits are infallible so the fallback path cannot fail.
947    pub fn validated(self) -> Self {
948        let (limits, report) = self.normalize_with_report();
949        for correction in report.corrections {
950            tracing::warn!(
951                field = %correction.field,
952                reason = %correction.reason,
953                action = %correction.action,
954                "corrected invalid memory limit"
955            );
956        }
957        limits
958    }
959}
960
961mod duration_secs {
962    use serde::{Deserialize, Deserializer, Serializer};
963    use std::time::Duration;
964
965    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
966        s.serialize_u64(d.as_secs())
967    }
968
969    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
970        let secs = u64::deserialize(d)?;
971        Ok(Duration::from_secs(secs))
972    }
973}
974
975#[cfg(test)]
976mod hardening_tests {
977    use super::*;
978    use proptest::prelude::*;
979
980    #[test]
981    fn one_invalid_limit_preserves_every_other_valid_limit() {
982        let original = MemoryLimits {
983            max_facts_per_namespace: 0,
984            max_chunks_per_document: 321,
985            max_content_bytes: 654_321,
986            max_embedding_concurrency: 7,
987            max_db_size_bytes: 987_654_321,
988            embedding_timeout: Duration::from_secs(19),
989        };
990
991        let corrected = original.validated();
992        assert_eq!(
993            corrected.max_facts_per_namespace,
994            MemoryLimits::default().max_facts_per_namespace
995        );
996        assert_eq!(corrected.max_chunks_per_document, 321);
997        assert_eq!(corrected.max_content_bytes, 654_321);
998        assert_eq!(corrected.max_embedding_concurrency, 7);
999        assert_eq!(corrected.max_db_size_bytes, 987_654_321);
1000        assert_eq!(corrected.embedding_timeout, Duration::from_secs(19));
1001    }
1002
1003    #[test]
1004    fn memory_config_returns_structured_correction_report() {
1005        let mut config = MemoryConfig::default();
1006        config.limits.max_facts_per_namespace = 0;
1007        let (normalized, report) = config.normalize_with_report().unwrap();
1008        assert_eq!(
1009            normalized.limits.max_facts_per_namespace,
1010            MemoryLimits::default().max_facts_per_namespace
1011        );
1012        assert_eq!(report.corrections.len(), 1);
1013        assert_eq!(
1014            report.corrections[0].field,
1015            "limits.max_facts_per_namespace"
1016        );
1017    }
1018
1019    proptest! {
1020        #[test]
1021        fn arbitrary_single_bad_limit_preserves_other_valid_fields(
1022            chunks in 1usize..10_000,
1023            content in 1usize..10_000_000,
1024            concurrency in 1usize..=32,
1025            db_size in 0u64..=(1u64 << 50),
1026            timeout in 1u64..=300,
1027        ) {
1028            let original = MemoryLimits {
1029                max_facts_per_namespace: 0,
1030                max_chunks_per_document: chunks,
1031                max_content_bytes: content,
1032                max_embedding_concurrency: concurrency,
1033                max_db_size_bytes: db_size,
1034                embedding_timeout: Duration::from_secs(timeout),
1035            };
1036            let (corrected, report) = original.normalize_with_report();
1037            prop_assert_eq!(corrected.max_chunks_per_document, chunks);
1038            prop_assert_eq!(corrected.max_content_bytes, content);
1039            prop_assert_eq!(corrected.max_embedding_concurrency, concurrency);
1040            prop_assert_eq!(corrected.max_db_size_bytes, db_size);
1041            prop_assert_eq!(corrected.embedding_timeout, Duration::from_secs(timeout));
1042            prop_assert_eq!(report.corrections.len(), 1);
1043            prop_assert_eq!(report.corrections[0].field.as_str(), "limits.max_facts_per_namespace");
1044        }
1045    }
1046
1047    #[test]
1048    fn search_rejects_every_unbounded_or_non_finite_numeric_family() {
1049        let mut cases = Vec::new();
1050
1051        let mut config = SearchConfig::default();
1052        config.late_interaction_weight = f64::NAN;
1053        cases.push(config);
1054
1055        let mut config = SearchConfig::default();
1056        config.bm25_k1 = f64::INFINITY;
1057        cases.push(config);
1058
1059        let mut config = SearchConfig::default();
1060        config.bm25_b = 1.1;
1061        cases.push(config);
1062
1063        let mut config = SearchConfig::default();
1064        config.namespace_weights.insert("bad".into(), f64::INFINITY);
1065        cases.push(config);
1066
1067        let mut config = SearchConfig::default();
1068        config.candidate_pool_size = usize::MAX;
1069        cases.push(config);
1070
1071        let mut config = SearchConfig::default();
1072        config.sparse_top_k = usize::MAX;
1073        cases.push(config);
1074
1075        let mut config = SearchConfig::default();
1076        config.default_top_k = usize::MAX;
1077        cases.push(config);
1078
1079        for mut config in cases {
1080            assert!(
1081                config.normalize_and_validate(768).is_err(),
1082                "invalid numeric config was accepted: {config:?}"
1083            );
1084        }
1085    }
1086
1087    #[test]
1088    fn search_config_nan_weight_preserves_other_numeric_fields() {
1089        let mut config = SearchConfig::default();
1090        let expected_vector_weight = config.vector_weight;
1091        let expected_sparse_weight = config.sparse_weight;
1092        let expected_candidate_pool_size = config.candidate_pool_size;
1093        let expected_rerank_flag = config.rerank_from_f32;
1094        let expected_derive_sparse = config.derive_sparse_from_dense;
1095
1096        config.bm25_weight = f64::NAN;
1097
1098        let err = config.normalize_and_validate(768).unwrap_err();
1099        match err {
1100            MemoryError::InvalidConfig { field, reason: _ } => {
1101                assert_eq!(field, "search.bm25_weight")
1102            }
1103            _ => panic!("expected InvalidConfig for non-finite numeric field"),
1104        }
1105
1106        assert_eq!(config.vector_weight, expected_vector_weight);
1107        assert_eq!(config.sparse_weight, expected_sparse_weight);
1108        assert_eq!(config.candidate_pool_size, expected_candidate_pool_size);
1109        assert_eq!(config.rerank_from_f32, expected_rerank_flag);
1110        assert_eq!(config.derive_sparse_from_dense, expected_derive_sparse);
1111    }
1112}