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/// Replication scope. Only fact-create replication is currently supported.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
10#[serde(rename_all = "snake_case")]
11pub enum ReplicationMode {
12    #[default]
13    Disabled,
14    FactCreateRequired,
15}
16
17/// Configuration for the memory system.
18#[derive(Clone, Serialize, Deserialize)]
19pub struct MemoryConfig {
20    /// Base directory for all storage files (SQLite + HNSW sidecar files).
21    /// Replaces the v0.1.0 `database_path` field.
22    pub base_dir: PathBuf,
23
24    /// Embedding provider configuration.
25    pub embedding: EmbeddingConfig,
26
27    /// Search tuning parameters.
28    pub search: SearchConfig,
29
30    /// Chunking parameters.
31    pub chunking: ChunkingConfig,
32
33    /// Connection pool configuration.
34    pub pool: PoolConfig,
35
36    /// Resource limits.
37    pub limits: MemoryLimits,
38
39    /// Optional device identity for mutation journaling.
40    #[serde(default)]
41    pub journal_device_id: Option<String>,
42
43    /// Optional store identity for mutation journaling.
44    #[serde(default)]
45    pub journal_store_id: Option<String>,
46
47    /// Explicit replication mode. Defaults to disabled for compatibility.
48    #[serde(default)]
49    pub replication_mode: ReplicationMode,
50
51    /// Positive stream epoch required by FactCreateRequired.
52    #[serde(default)]
53    pub replication_stream_epoch: u64,
54
55    /// Custom token counter. None = use EstimateTokenCounter (chars / 4).
56    #[serde(skip)]
57    pub token_counter: Option<Arc<dyn TokenCounter>>,
58
59    /// HNSW index configuration.
60    #[cfg(feature = "hnsw")]
61    #[serde(skip)]
62    pub hnsw: crate::hnsw::HnswConfig,
63}
64
65impl std::fmt::Debug for MemoryConfig {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        let mut s = f.debug_struct("MemoryConfig");
68        s.field("base_dir", &self.base_dir)
69            .field("embedding", &self.embedding)
70            .field("search", &self.search)
71            .field("chunking", &self.chunking)
72            .field("pool", &self.pool)
73            .field("limits", &self.limits)
74            .field("journal_device_id", &self.journal_device_id)
75            .field("journal_store_id", &self.journal_store_id)
76            .field("replication_mode", &self.replication_mode)
77            .field("replication_stream_epoch", &self.replication_stream_epoch)
78            .field(
79                "token_counter",
80                &self.token_counter.as_ref().map(|_| "custom"),
81            );
82        #[cfg(feature = "hnsw")]
83        s.field("hnsw", &self.hnsw);
84        s.finish()
85    }
86}
87
88impl Default for MemoryConfig {
89    fn default() -> Self {
90        Self {
91            base_dir: PathBuf::from("memory"),
92            embedding: EmbeddingConfig::default(),
93            search: SearchConfig::default(),
94            chunking: ChunkingConfig::default(),
95            pool: PoolConfig::default(),
96            limits: MemoryLimits::default(),
97            journal_device_id: None,
98            journal_store_id: None,
99            replication_mode: ReplicationMode::Disabled,
100            replication_stream_epoch: 0,
101            token_counter: None,
102            #[cfg(feature = "hnsw")]
103            hnsw: crate::hnsw::HnswConfig::default(),
104        }
105    }
106}
107
108impl MemoryConfig {
109    /// Normalize and validate configuration into a concrete runtime shape.
110    ///
111    /// This is the single canonical config entry point used by store creation.
112    pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
113        self.embedding.normalize_and_validate()?;
114        self.limits = self.limits.normalize_and_validate()?;
115        let timeout_cap_secs = self.limits.embedding_timeout.as_secs().max(1);
116        self.embedding.timeout_secs = self.embedding.timeout_secs.min(timeout_cap_secs);
117        self.search
118            .normalize_and_validate(self.embedding.dimensions)?;
119        self.chunking.normalize_and_validate()?;
120        self.pool.normalize_and_validate()?;
121        self.validate_replication()?;
122        #[cfg(feature = "hnsw")]
123        {
124            self.hnsw.dimensions = self.embedding.dimensions;
125        }
126        Ok(self)
127    }
128
129    fn validate_replication(&self) -> Result<(), MemoryError> {
130        let identity = match (
131            self.journal_device_id.as_deref(),
132            self.journal_store_id.as_deref(),
133        ) {
134            (None, None) => None,
135            (Some(device_id), Some(store_id)) => Some((device_id, store_id)),
136            _ => {
137                return Err(MemoryError::InvalidConfig {
138                    field: "journal_device_id/journal_store_id",
139                    reason: "both identity fields must be set together".to_string(),
140                });
141            }
142        };
143
144        if let Some((device_id, store_id)) = identity {
145            for (field, value) in [
146                ("journal_device_id", device_id),
147                ("journal_store_id", store_id),
148            ] {
149                if value.is_empty()
150                    || value.trim() != value
151                    || value.chars().any(char::is_whitespace)
152                {
153                    return Err(MemoryError::InvalidConfig {
154                        field,
155                        reason: "must be non-empty, trimmed, and contain no whitespace".to_string(),
156                    });
157                }
158            }
159        }
160
161        if self.replication_mode == ReplicationMode::FactCreateRequired {
162            if identity.is_none() {
163                return Err(MemoryError::InvalidConfig {
164                    field: "replication_mode",
165                    reason: "FactCreateRequired requires device and store identity".to_string(),
166                });
167            }
168            if self.replication_stream_epoch == 0 {
169                return Err(MemoryError::InvalidConfig {
170                    field: "replication_stream_epoch",
171                    reason: "FactCreateRequired requires a positive epoch".to_string(),
172                });
173            }
174        }
175        Ok(())
176    }
177}
178
179/// Embedding provider configuration.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct EmbeddingConfig {
182    /// Ollama base URL. Only required when using OllamaEmbedder.
183    /// When using CandleEmbedder (default with `candle-embedder` feature),
184    /// this field is ignored. Defaults to `http://localhost:11434`.
185    pub ollama_url: String,
186
187    /// Embedding model name.
188    pub model: String,
189
190    /// Expected embedding dimensions.
191    pub dimensions: usize,
192
193    /// Maximum texts to embed in a single API call.
194    pub batch_size: usize,
195
196    /// Timeout for embedding requests in seconds.
197    pub timeout_secs: u64,
198}
199
200impl Default for EmbeddingConfig {
201    fn default() -> Self {
202        Self {
203            ollama_url: "http://localhost:11434".to_string(),
204            model: "nomic-embed-text".to_string(),
205            dimensions: 768,
206            batch_size: 32,
207            timeout_secs: 30,
208        }
209    }
210}
211
212impl EmbeddingConfig {
213    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
214        if self.dimensions == 0 {
215            return Err(MemoryError::InvalidConfig {
216                field: "embedding.dimensions",
217                reason: "dimensions must be at least 1".to_string(),
218            });
219        }
220        if self.batch_size == 0 {
221            self.batch_size = 1;
222        }
223        if self.timeout_secs == 0 {
224            self.timeout_secs = 1;
225        }
226        // Validate ollama_url only when it will be used. With the
227        // candle-embedder feature, the default embedder is CandleEmbedder
228        // which does not use Ollama, so a placeholder URL is fine.
229        #[cfg(not(feature = "candle-embedder"))]
230        {
231            let parsed =
232                reqwest::Url::parse(&self.ollama_url).map_err(|_| MemoryError::InvalidConfig {
233                    field: "embedding.ollama_url",
234                    reason: "must be an absolute http:// or https:// URL".to_string(),
235                })?;
236            match parsed.scheme() {
237                "http" | "https" if parsed.host_str().is_some() => {}
238                _ => {
239                    return Err(MemoryError::InvalidConfig {
240                        field: "embedding.ollama_url",
241                        reason: "must be an absolute http:// or https:// URL".to_string(),
242                    })
243                }
244            }
245        }
246        // With candle-embedder, skip URL validation — the field is ignored
247        // by CandleEmbedder. If OllamaEmbedder is used explicitly via
248        // open_with_embedder, it does its own URL handling.
249        #[cfg(feature = "candle-embedder")]
250        {
251            let _ = &self.ollama_url; // suppress unused field warning
252        }
253        Ok(())
254    }
255}
256
257/// Search tuning parameters.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct SearchConfig {
260    /// Weight for BM25 score in RRF fusion.
261    pub bm25_weight: f64,
262
263    /// Weight for vector similarity in RRF fusion.
264    pub vector_weight: f64,
265
266    /// Weight for sparse dot-product ranking in RRF fusion.
267    /// Defaults to 0.0 so existing BM25+dense behavior is unchanged.
268    #[serde(default = "default_zero")]
269    pub sparse_weight: f64,
270
271    /// Maximum sparse candidates admitted to fusion.
272    #[serde(default = "default_sparse_top_k")]
273    pub sparse_top_k: usize,
274
275    /// Minimum sparse dot-product score admitted to fusion.
276    #[serde(default = "default_zero")]
277    pub sparse_min_score: f64,
278
279    /// Explicitly allow dense-only embedders to derive generic sparse weights.
280    /// This is disabled by default and the result must not be described as SPLADE.
281    #[serde(default)]
282    pub derive_sparse_from_dense: bool,
283
284    /// Maximum dense dimensions retained by explicit generic sparse derivation.
285    #[serde(default = "default_sparse_derive_top_k")]
286    pub sparse_derive_top_k: usize,
287
288    /// Minimum absolute dense value retained by generic sparse derivation.
289    #[serde(default = "default_sparse_derive_min_weight")]
290    pub sparse_derive_min_weight: f32,
291
292    /// Weight for late interaction (ColBERT MaxSim) in RRF fusion.
293    /// Defaults to 0.0 (disabled). Set to 1.0 to enable as 3rd RRF signal.
294    #[serde(default = "default_zero")]
295    pub late_interaction_weight: f64,
296
297    /// BM25 k1 parameter. Controls term frequency saturation.
298    /// Default: 1.2 (FTS5 standard). Lower (0.8-1.0) helps with technical content.
299    pub bm25_k1: f64,
300
301    /// BM25 b parameter. Controls document length normalization.
302    /// Default: 0.75 (FTS5 standard).
303    pub bm25_b: f64,
304
305    /// Optional per-namespace weight multipliers.
306    /// Empty = no weighting (all namespaces scored equally).
307    pub namespace_weights: std::collections::HashMap<String, f64>,
308
309    /// RRF constant (k). Controls rank importance decay.
310    pub rrf_k: f64,
311
312    /// Number of candidates from each search method before fusion.
313    pub candidate_pool_size: usize,
314
315    /// Default number of results to return.
316    pub default_top_k: usize,
317
318    /// Minimum cosine similarity threshold for vector candidates.
319    pub min_similarity: f64,
320
321    /// Optional recency boost. If enabled, results are boosted based on how
322    /// recently they were created/updated. The value is the half-life in days —
323    /// a fact that is `recency_half_life_days` old gets 50% of the recency boost.
324    /// None = no recency weighting (current behavior, default).
325    pub recency_half_life_days: Option<f64>,
326
327    /// Weight of the recency boost relative to BM25 and vector scores in RRF.
328    /// Only used when recency_half_life_days is Some.
329    /// Default: 0.5
330    pub recency_weight: f64,
331
332    /// When true, rerank top HNSW candidates using exact f32 cosine similarity
333    /// from SQLite. Improves recall at the cost of one batched SQL query.
334    /// Only applies when HNSW feature is enabled.
335    /// Default: true
336    pub rerank_from_f32: bool,
337
338    /// Optional derived-vector candidate backend. Disabled by default because
339    /// raw f32 embeddings remain authoritative.
340    #[serde(default)]
341    pub derived_vector_backend: DerivedVectorBackendPolicy,
342
343    /// TurboQuant polar angle bits when the TurboQuant candidate backend is enabled.
344    #[serde(default = "default_turbo_quant_bits")]
345    pub turbo_quant_bits: u8,
346
347    /// TurboQuant QJL projection count when the TurboQuant candidate backend is enabled.
348    #[serde(default = "default_turbo_quant_projections")]
349    pub turbo_quant_projections: usize,
350
351    /// TurboQuant profile seed when the TurboQuant candidate backend is enabled.
352    #[serde(default)]
353    pub turbo_quant_seed: u64,
354
355    /// Require exact f32 rerank for TurboQuant candidates. Defaults to true.
356    #[serde(default = "default_true")]
357    pub turbo_quant_require_exact_rerank: bool,
358
359    /// Matryoshka candidate-stage embedding dimensions for 2-stage search.
360    /// When set to Some(dim) and the `matryoshka` feature is enabled, the query
361    /// embedding is truncated to `dim` dimensions for candidate retrieval, then
362    /// reranked with the full embedding. Disabled by default because it requires
363    /// a compatible truncated-vector index; callers opt in explicitly.
364    #[serde(default = "default_candidate_dims")]
365    pub candidate_dims: Option<usize>,
366
367    /// When true, compress search result content using SimpleMem-style semantic
368    /// compression (first sentence + key terms, capped at 150 chars).
369    /// Defaults to false.
370    #[serde(default)]
371    pub compress_results: bool,
372}
373
374/// Candidate backend policy for rebuildable derived vector artifacts.
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
376#[serde(rename_all = "snake_case")]
377pub enum DerivedVectorBackendPolicy {
378    /// Use authoritative raw f32 embeddings for vector candidate generation.
379    #[default]
380    Disabled,
381    /// Use TurboQuant only to generate candidates, then exact rerank by default.
382    TurboQuantCandidateOnly,
383    /// Use a generation-level proveKV/poly-kv shared pool only to generate candidates,
384    /// then exact-rerank against authoritative f32 embeddings.
385    ///
386    /// This is deliberately not a replacement for SQLite f32 storage or for prompt/KV
387    /// prefix reuse. It is a rebuildable derived artifact over an embedding snapshot.
388    ProveKvPoolCandidateOnly,
389    /// Request FibQuant candidate generation.
390    ///
391    /// The policy is part of the public configuration contract, but this source
392    /// currently rejects it explicitly until a truthful FibQuant artifact adapter
393    /// exists; it never silently substitutes raw-vector retrieval.
394    FibQuantCandidateOnly,
395    /// Request per-dimension compressed candidate generation.
396    ///
397    /// The policy is part of the public configuration contract, but this source
398    /// currently rejects it explicitly until a truthful per-dimension artifact
399    /// adapter exists; it never silently substitutes raw-vector retrieval.
400    PerDimCandidateOnly,
401}
402
403const fn default_turbo_quant_bits() -> u8 {
404    8
405}
406
407const fn default_turbo_quant_projections() -> usize {
408    64
409}
410
411const fn default_true() -> bool {
412    true
413}
414
415const fn default_zero() -> f64 {
416    0.0
417}
418
419const fn default_sparse_top_k() -> usize {
420    50
421}
422
423const fn default_sparse_derive_top_k() -> usize {
424    128
425}
426
427const MAX_SEARCH_CANDIDATE_POOL_SIZE: usize = 2_000;
428const MAX_SEARCH_DEFAULT_TOP_K: usize = 200;
429const MAX_SPARSE_TOP_K: usize = 1_000;
430const MAX_SPARSE_DERIVE_TOP_K: usize = 1_000;
431
432const fn default_sparse_derive_min_weight() -> f32 {
433    0.01
434}
435
436const fn default_candidate_dims() -> Option<usize> {
437    None
438}
439
440impl Default for SearchConfig {
441    fn default() -> Self {
442        Self {
443            bm25_weight: 1.0,
444            vector_weight: 1.0,
445            sparse_weight: 0.0,
446            sparse_top_k: default_sparse_top_k(),
447            sparse_min_score: 0.0,
448            derive_sparse_from_dense: false,
449            sparse_derive_top_k: default_sparse_derive_top_k(),
450            sparse_derive_min_weight: default_sparse_derive_min_weight(),
451            late_interaction_weight: 0.15,
452            bm25_k1: 1.2,
453            bm25_b: 0.75,
454            namespace_weights: std::collections::HashMap::new(),
455            rrf_k: 60.0,
456            candidate_pool_size: 50,
457            default_top_k: 5,
458            min_similarity: 0.3,
459            recency_half_life_days: None,
460            recency_weight: 0.5,
461            rerank_from_f32: true,
462            derived_vector_backend: DerivedVectorBackendPolicy::Disabled,
463            turbo_quant_bits: default_turbo_quant_bits(),
464            turbo_quant_projections: default_turbo_quant_projections(),
465            turbo_quant_seed: 0,
466            turbo_quant_require_exact_rerank: true,
467            candidate_dims: default_candidate_dims(),
468            compress_results: false,
469        }
470    }
471}
472
473impl SearchConfig {
474    pub(crate) fn uses_turbo_quant_backend(&self) -> bool {
475        self.derived_vector_backend == DerivedVectorBackendPolicy::TurboQuantCandidateOnly
476    }
477
478    pub(crate) fn uses_provekv_pool_backend(&self) -> bool {
479        self.derived_vector_backend == DerivedVectorBackendPolicy::ProveKvPoolCandidateOnly
480    }
481
482    pub(crate) fn uses_derived_vector_backend(&self) -> bool {
483        self.uses_turbo_quant_backend()
484            || self.uses_provekv_pool_backend()
485            || matches!(
486                self.derived_vector_backend,
487                DerivedVectorBackendPolicy::FibQuantCandidateOnly
488                    | DerivedVectorBackendPolicy::PerDimCandidateOnly
489            )
490    }
491
492    fn normalize_and_validate(&mut self, embedding_dimensions: usize) -> Result<(), MemoryError> {
493        #[cfg(not(feature = "turbo-quant-codec"))]
494        let _ = embedding_dimensions;
495
496        match self.derived_vector_backend {
497            DerivedVectorBackendPolicy::FibQuantCandidateOnly => {
498                return Err(MemoryError::NotImplemented(
499                    "FibQuant candidate generation is not implemented in this build".to_string(),
500                ));
501            }
502            DerivedVectorBackendPolicy::PerDimCandidateOnly => {
503                return Err(MemoryError::NotImplemented(
504                    "per-dimension candidate generation is not implemented in this build"
505                        .to_string(),
506                ));
507            }
508            _ => {}
509        }
510
511        self.candidate_pool_size = self
512            .candidate_pool_size
513            .clamp(1, MAX_SEARCH_CANDIDATE_POOL_SIZE);
514        self.default_top_k = self.default_top_k.clamp(1, MAX_SEARCH_DEFAULT_TOP_K);
515        self.candidate_pool_size = self.candidate_pool_size.max(self.default_top_k);
516        self.sparse_top_k = self.sparse_top_k.clamp(1, MAX_SPARSE_TOP_K);
517        self.sparse_derive_top_k = self.sparse_derive_top_k.clamp(1, MAX_SPARSE_DERIVE_TOP_K);
518        if !self.rrf_k.is_finite() || self.rrf_k <= 0.0 {
519            return Err(MemoryError::InvalidConfig {
520                field: "search.rrf_k",
521                reason: "rrf_k must be finite and > 0".to_string(),
522            });
523        }
524        if !self.bm25_weight.is_finite() || self.bm25_weight < 0.0 {
525            return Err(MemoryError::InvalidConfig {
526                field: "search.bm25_weight",
527                reason: "bm25_weight must be finite and >= 0".to_string(),
528            });
529        }
530        if !self.vector_weight.is_finite() || self.vector_weight < 0.0 {
531            return Err(MemoryError::InvalidConfig {
532                field: "search.vector_weight",
533                reason: "vector_weight must be finite and >= 0".to_string(),
534            });
535        }
536        if !self.sparse_weight.is_finite() || self.sparse_weight < 0.0 {
537            return Err(MemoryError::InvalidConfig {
538                field: "search.sparse_weight",
539                reason: "sparse_weight must be finite and >= 0".to_string(),
540            });
541        }
542        if !self.sparse_min_score.is_finite() {
543            return Err(MemoryError::InvalidConfig {
544                field: "search.sparse_min_score",
545                reason: "sparse_min_score must be finite".to_string(),
546            });
547        }
548        if !self.sparse_derive_min_weight.is_finite() || self.sparse_derive_min_weight < 0.0 {
549            return Err(MemoryError::InvalidConfig {
550                field: "search.sparse_derive_min_weight",
551                reason: "sparse_derive_min_weight must be finite and >= 0".to_string(),
552            });
553        }
554        if !self.recency_weight.is_finite() || self.recency_weight < 0.0 {
555            return Err(MemoryError::InvalidConfig {
556                field: "search.recency_weight",
557                reason: "recency_weight must be finite and >= 0".to_string(),
558            });
559        }
560        if !self.min_similarity.is_finite() || !(-1.0..=1.0).contains(&self.min_similarity) {
561            return Err(MemoryError::InvalidConfig {
562                field: "search.min_similarity",
563                reason: "min_similarity must be finite and within [-1.0, 1.0]".to_string(),
564            });
565        }
566        if matches!(self.recency_half_life_days, Some(v) if !v.is_finite()) {
567            return Err(MemoryError::InvalidConfig {
568                field: "search.recency_half_life_days",
569                reason: "recency_half_life_days must be finite".to_string(),
570            });
571        }
572        if matches!(self.recency_half_life_days, Some(v) if v <= 0.0) {
573            return Err(MemoryError::InvalidConfig {
574                field: "search.recency_half_life_days",
575                reason: "recency_half_life_days must be > 0 when enabled".to_string(),
576            });
577        }
578        if self.uses_turbo_quant_backend() {
579            #[cfg(not(feature = "turbo-quant-codec"))]
580            {
581                return Err(MemoryError::InvalidConfig {
582                    field: "search.derived_vector_backend",
583                    reason: "turbo_quant_candidate_only requires the turbo-quant-codec feature"
584                        .to_string(),
585                });
586            }
587            #[cfg(feature = "turbo-quant-codec")]
588            {
589                if embedding_dimensions % 2 != 0 {
590                    return Err(MemoryError::InvalidConfig {
591                        field: "embedding.dimensions",
592                        reason: "TurboQuant requires even embedding dimensions".to_string(),
593                    });
594                }
595                if self.turbo_quant_projections == 0 {
596                    return Err(MemoryError::InvalidConfig {
597                        field: "search.turbo_quant_projections",
598                        reason: "TurboQuant projections must be at least 1".to_string(),
599                    });
600                }
601                if !(2..=16).contains(&self.turbo_quant_bits) {
602                    return Err(MemoryError::InvalidConfig {
603                        field: "search.turbo_quant_bits",
604                        reason: "TurboQuant bits must be within 2..=16".to_string(),
605                    });
606                }
607            }
608        }
609        if self.uses_derived_vector_backend() && !self.turbo_quant_require_exact_rerank {
610            return Err(MemoryError::InvalidConfig {
611                field: "search.turbo_quant_require_exact_rerank",
612                reason: "derived vector candidate backends require exact f32 rerank".to_string(),
613            });
614        }
615        Ok(())
616    }
617}
618
619/// Chunking strategy to use when splitting text.
620#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
621#[serde(rename_all = "snake_case")]
622pub enum ChunkingStrategy {
623    /// Plain recursive splitting (current/default behavior).
624    #[default]
625    Plain,
626    /// Sentence-boundary-aware chunking with configurable overlap.
627    Sentence,
628    /// Code-aware chunking that avoids splitting inside function bodies.
629    /// Detects Rust, Python, and TypeScript blocks.
630    Code,
631    /// Markdown-header-based chunking that splits on header boundaries.
632    Markdown,
633}
634
635/// Text chunking parameters.
636#[derive(Debug, Clone, Serialize, Deserialize)]
637pub struct ChunkingConfig {
638    /// Target chunk size in characters.
639    pub target_size: usize,
640
641    /// Minimum chunk size. Chunks smaller than this are merged with neighbors.
642    pub min_size: usize,
643
644    /// Maximum chunk size. Chunks larger than this are force-split.
645    pub max_size: usize,
646
647    /// Overlap between adjacent chunks in characters.
648    pub overlap: usize,
649
650    /// Chunking strategy to use. Defaults to [`ChunkingStrategy::Plain`]
651    /// for backward compatibility.
652    #[serde(default)]
653    pub strategy: ChunkingStrategy,
654}
655
656impl Default for ChunkingConfig {
657    fn default() -> Self {
658        Self {
659            target_size: 1000,
660            min_size: 100,
661            max_size: 2000,
662            overlap: 200,
663            strategy: ChunkingStrategy::default(),
664        }
665    }
666}
667
668impl ChunkingConfig {
669    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
670        if self.min_size == 0 {
671            self.min_size = 1;
672        }
673        if self.max_size == 0 {
674            return Err(MemoryError::InvalidConfig {
675                field: "chunking.max_size",
676                reason: "max_size must be at least 1".to_string(),
677            });
678        }
679        if self.max_size < self.min_size {
680            return Err(MemoryError::InvalidConfig {
681                field: "chunking.max_size",
682                reason: "max_size must be >= min_size".to_string(),
683            });
684        }
685        if self.target_size < self.min_size {
686            self.target_size = self.min_size;
687        }
688        if self.target_size > self.max_size {
689            self.target_size = self.max_size;
690        }
691        if self.overlap >= self.min_size {
692            self.overlap = self.min_size.saturating_sub(1);
693        }
694        Ok(())
695    }
696}
697
698/// Connection pool configuration for SQLite.
699///
700/// Controls busy timeout and WAL checkpoint behavior. These defaults
701/// are tuned for a single-process server on local SSD storage.
702#[derive(Debug, Clone, Serialize, Deserialize)]
703pub struct PoolConfig {
704    /// SQLite busy timeout in milliseconds.
705    /// Default: 5000 (5 seconds).
706    pub busy_timeout_ms: u32,
707
708    /// WAL auto-checkpoint threshold in pages.
709    /// Default: 1000 (~4 MB with 4KB pages).
710    pub wal_autocheckpoint: u32,
711
712    /// Enable WAL mode. Should almost always be true.
713    /// Default: true.
714    pub enable_wal: bool,
715
716    /// Number of reader connections kept in the pool.
717    /// Writes still flow through a single writer connection because SQLite
718    /// allows only one concurrent writer, but readers can proceed concurrently
719    /// under WAL semantics.
720    pub max_read_connections: usize,
721
722    /// Timeout in seconds for acquiring a reader connection from the pool.
723    /// Default: 30 seconds.
724    pub reader_timeout_secs: u64,
725}
726
727impl Default for PoolConfig {
728    fn default() -> Self {
729        Self {
730            busy_timeout_ms: 5000,
731            wal_autocheckpoint: 1000,
732            enable_wal: true,
733            max_read_connections: 4,
734            reader_timeout_secs: 30,
735        }
736    }
737}
738
739impl PoolConfig {
740    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
741        if self.busy_timeout_ms == 0 {
742            self.busy_timeout_ms = 1;
743        }
744        if self.wal_autocheckpoint == 0 {
745            self.wal_autocheckpoint = 1;
746        }
747        if self.max_read_connections == 0 {
748            return Err(MemoryError::InvalidConfig {
749                field: "pool.max_read_connections",
750                reason: "set pool.max_read_connections to at least 1".to_string(),
751            });
752        }
753        if self.reader_timeout_secs == 0 {
754            self.reader_timeout_secs = 1;
755        }
756        self.reader_timeout_secs = self.reader_timeout_secs.min(300);
757        Ok(())
758    }
759}
760
761/// Resource limits for the memory system.
762///
763/// Prevents runaway resource usage. All limits have defaults tuned for
764/// a laptop-class server (8GB RAM, SSD storage).
765#[derive(Debug, Clone, Serialize, Deserialize)]
766pub struct MemoryLimits {
767    /// Maximum number of facts per namespace.
768    /// Default: 100_000.
769    pub max_facts_per_namespace: usize,
770
771    /// Maximum number of chunks per document.
772    /// Default: 1_000.
773    pub max_chunks_per_document: usize,
774
775    /// Maximum content size in bytes for a single fact or message.
776    /// Default: 1 MB (1_048_576 bytes).
777    pub max_content_bytes: usize,
778
779    /// Maximum number of concurrent embedding requests.
780    /// Hard-capped at 32 regardless of config.
781    /// Default: 8.
782    pub max_embedding_concurrency: usize,
783
784    /// Maximum total database size in bytes. 0 = unlimited.
785    /// Default: 0 (unlimited).
786    pub max_db_size_bytes: u64,
787
788    /// Embedding request timeout.
789    /// Default: 30 seconds.
790    #[serde(with = "duration_secs")]
791    pub embedding_timeout: Duration,
792}
793
794impl Default for MemoryLimits {
795    fn default() -> Self {
796        Self {
797            max_facts_per_namespace: 100_000,
798            max_chunks_per_document: 1_000,
799            max_content_bytes: 1_048_576,
800            max_embedding_concurrency: 8,
801            max_db_size_bytes: 0,
802            embedding_timeout: Duration::from_secs(30),
803        }
804    }
805}
806
807impl MemoryLimits {
808    /// Normalize and validate limits to hard caps.
809    pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
810        if self.max_facts_per_namespace == 0 {
811            return Err(MemoryError::InvalidConfig {
812                field: "limits.max_facts_per_namespace",
813                reason: "must be at least 1".to_string(),
814            });
815        }
816        if self.max_chunks_per_document == 0 {
817            return Err(MemoryError::InvalidConfig {
818                field: "limits.max_chunks_per_document",
819                reason: "must be at least 1".to_string(),
820            });
821        }
822        if self.max_content_bytes == 0 {
823            return Err(MemoryError::InvalidConfig {
824                field: "limits.max_content_bytes",
825                reason: "must be at least 1".to_string(),
826            });
827        }
828        // Hard cap: concurrency at 32
829        if self.max_embedding_concurrency > 32 {
830            self.max_embedding_concurrency = 32;
831        }
832        if self.max_embedding_concurrency == 0 {
833            self.max_embedding_concurrency = 1;
834        }
835        if self.embedding_timeout.is_zero() {
836            self.embedding_timeout = Duration::from_secs(1);
837        }
838        Ok(self)
839    }
840
841    /// Backward-compatible alias for callers that only need clamped limits.
842    ///
843    /// Falls back to defaults if the caller-provided limits are invalid.
844    /// Default limits are infallible so the fallback path cannot fail.
845    pub fn validated(self) -> Self {
846        self.normalize_and_validate().unwrap_or_else(|err| {
847            tracing::warn!(
848                error = %err,
849                "invalid MemoryLimits supplied to validated(); using defaults"
850            );
851            // Default limits are always valid — this path is infallible.
852            let defaults = Self::default();
853            Self {
854                max_facts_per_namespace: defaults.max_facts_per_namespace,
855                max_chunks_per_document: defaults.max_chunks_per_document,
856                max_content_bytes: defaults.max_content_bytes,
857                max_embedding_concurrency: defaults.max_embedding_concurrency.clamp(1, 32),
858                max_db_size_bytes: defaults.max_db_size_bytes,
859                embedding_timeout: if defaults.embedding_timeout.is_zero() {
860                    std::time::Duration::from_secs(1)
861                } else {
862                    defaults.embedding_timeout
863                },
864            }
865        })
866    }
867}
868
869mod duration_secs {
870    use serde::{Deserialize, Deserializer, Serializer};
871    use std::time::Duration;
872
873    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
874        s.serialize_u64(d.as_secs())
875    }
876
877    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
878        let secs = u64::deserialize(d)?;
879        Ok(Duration::from_secs(secs))
880    }
881}