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 sparse dot-product ranking in RRF fusion.
184    /// Defaults to 0.0 so existing BM25+dense behavior is unchanged.
185    #[serde(default = "default_zero")]
186    pub sparse_weight: f64,
187
188    /// Maximum sparse candidates admitted to fusion.
189    #[serde(default = "default_sparse_top_k")]
190    pub sparse_top_k: usize,
191
192    /// Minimum sparse dot-product score admitted to fusion.
193    #[serde(default = "default_zero")]
194    pub sparse_min_score: f64,
195
196    /// Explicitly allow dense-only embedders to derive generic sparse weights.
197    /// This is disabled by default and the result must not be described as SPLADE.
198    #[serde(default)]
199    pub derive_sparse_from_dense: bool,
200
201    /// Maximum dense dimensions retained by explicit generic sparse derivation.
202    #[serde(default = "default_sparse_derive_top_k")]
203    pub sparse_derive_top_k: usize,
204
205    /// Minimum absolute dense value retained by generic sparse derivation.
206    #[serde(default = "default_sparse_derive_min_weight")]
207    pub sparse_derive_min_weight: f32,
208
209    /// Weight for late interaction (ColBERT MaxSim) in RRF fusion.
210    /// Defaults to 0.0 (disabled). Set to 1.0 to enable as 3rd RRF signal.
211    #[serde(default = "default_zero")]
212    pub late_interaction_weight: f64,
213
214    /// BM25 k1 parameter. Controls term frequency saturation.
215    /// Default: 1.2 (FTS5 standard). Lower (0.8-1.0) helps with technical content.
216    pub bm25_k1: f64,
217
218    /// BM25 b parameter. Controls document length normalization.
219    /// Default: 0.75 (FTS5 standard).
220    pub bm25_b: f64,
221
222    /// Optional per-namespace weight multipliers.
223    /// Empty = no weighting (all namespaces scored equally).
224    pub namespace_weights: std::collections::HashMap<String, f64>,
225
226    /// RRF constant (k). Controls rank importance decay.
227    pub rrf_k: f64,
228
229    /// Number of candidates from each search method before fusion.
230    pub candidate_pool_size: usize,
231
232    /// Default number of results to return.
233    pub default_top_k: usize,
234
235    /// Minimum cosine similarity threshold for vector candidates.
236    pub min_similarity: f64,
237
238    /// Optional recency boost. If enabled, results are boosted based on how
239    /// recently they were created/updated. The value is the half-life in days —
240    /// a fact that is `recency_half_life_days` old gets 50% of the recency boost.
241    /// None = no recency weighting (current behavior, default).
242    pub recency_half_life_days: Option<f64>,
243
244    /// Weight of the recency boost relative to BM25 and vector scores in RRF.
245    /// Only used when recency_half_life_days is Some.
246    /// Default: 0.5
247    pub recency_weight: f64,
248
249    /// When true, rerank top HNSW candidates using exact f32 cosine similarity
250    /// from SQLite. Improves recall at the cost of one batched SQL query.
251    /// Only applies when HNSW feature is enabled.
252    /// Default: true
253    pub rerank_from_f32: bool,
254
255    /// Optional derived-vector candidate backend. Disabled by default because
256    /// raw f32 embeddings remain authoritative.
257    #[serde(default)]
258    pub derived_vector_backend: DerivedVectorBackendPolicy,
259
260    /// TurboQuant polar angle bits when the TurboQuant candidate backend is enabled.
261    #[serde(default = "default_turbo_quant_bits")]
262    pub turbo_quant_bits: u8,
263
264    /// TurboQuant QJL projection count when the TurboQuant candidate backend is enabled.
265    #[serde(default = "default_turbo_quant_projections")]
266    pub turbo_quant_projections: usize,
267
268    /// TurboQuant profile seed when the TurboQuant candidate backend is enabled.
269    #[serde(default)]
270    pub turbo_quant_seed: u64,
271
272    /// Require exact f32 rerank for TurboQuant candidates. Defaults to true.
273    #[serde(default = "default_true")]
274    pub turbo_quant_require_exact_rerank: bool,
275
276    /// Matryoshka candidate-stage embedding dimensions for 2-stage search.
277    /// When set to Some(dim) and the `matryoshka` feature is enabled, the query
278    /// embedding is truncated to `dim` dimensions for candidate retrieval, then
279    /// reranked with the full embedding. Defaults to Some(64).
280    /// Set to None to disable 2-stage matryoshka search.
281    #[serde(default = "default_candidate_dims")]
282    pub candidate_dims: Option<usize>,
283
284    /// When true, compress search result content using SimpleMem-style semantic
285    /// compression (first sentence + key terms, capped at 150 chars).
286    /// Defaults to false.
287    #[serde(default)]
288    pub compress_results: bool,
289}
290
291/// Candidate backend policy for rebuildable derived vector artifacts.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
293#[serde(rename_all = "snake_case")]
294pub enum DerivedVectorBackendPolicy {
295    /// Use authoritative raw f32 embeddings for vector candidate generation.
296    #[default]
297    Disabled,
298    /// Use TurboQuant only to generate candidates, then exact rerank by default.
299    TurboQuantCandidateOnly,
300    /// Use a generation-level proveKV/poly-kv shared pool only to generate candidates,
301    /// then exact-rerank against authoritative f32 embeddings.
302    ///
303    /// This is deliberately not a replacement for SQLite f32 storage or for prompt/KV
304    /// prefix reuse. It is a rebuildable derived artifact over an embedding snapshot.
305    ProveKvPoolCandidateOnly,
306}
307
308const fn default_turbo_quant_bits() -> u8 {
309    8
310}
311
312const fn default_turbo_quant_projections() -> usize {
313    64
314}
315
316const fn default_true() -> bool {
317    true
318}
319
320const fn default_zero() -> f64 {
321    0.0
322}
323
324const fn default_sparse_top_k() -> usize {
325    50
326}
327
328const fn default_sparse_derive_top_k() -> usize {
329    128
330}
331
332const fn default_sparse_derive_min_weight() -> f32 {
333    0.01
334}
335
336const fn default_candidate_dims() -> Option<usize> {
337    Some(64)
338}
339
340impl Default for SearchConfig {
341    fn default() -> Self {
342        Self {
343            bm25_weight: 1.0,
344            vector_weight: 1.0,
345            sparse_weight: 0.0,
346            sparse_top_k: default_sparse_top_k(),
347            sparse_min_score: 0.0,
348            derive_sparse_from_dense: false,
349            sparse_derive_top_k: default_sparse_derive_top_k(),
350            sparse_derive_min_weight: default_sparse_derive_min_weight(),
351            late_interaction_weight: 0.15,
352            bm25_k1: 1.2,
353            bm25_b: 0.75,
354            namespace_weights: std::collections::HashMap::new(),
355            rrf_k: 60.0,
356            candidate_pool_size: 50,
357            default_top_k: 5,
358            min_similarity: 0.3,
359            recency_half_life_days: None,
360            recency_weight: 0.5,
361            rerank_from_f32: true,
362            derived_vector_backend: DerivedVectorBackendPolicy::Disabled,
363            turbo_quant_bits: default_turbo_quant_bits(),
364            turbo_quant_projections: default_turbo_quant_projections(),
365            turbo_quant_seed: 0,
366            turbo_quant_require_exact_rerank: true,
367            candidate_dims: default_candidate_dims(),
368            compress_results: false,
369        }
370    }
371}
372
373impl SearchConfig {
374    pub(crate) fn uses_turbo_quant_backend(&self) -> bool {
375        self.derived_vector_backend == DerivedVectorBackendPolicy::TurboQuantCandidateOnly
376    }
377
378    pub(crate) fn uses_provekv_pool_backend(&self) -> bool {
379        self.derived_vector_backend == DerivedVectorBackendPolicy::ProveKvPoolCandidateOnly
380    }
381
382    pub(crate) fn uses_derived_vector_backend(&self) -> bool {
383        self.uses_turbo_quant_backend() || self.uses_provekv_pool_backend()
384    }
385
386    fn normalize_and_validate(&mut self, embedding_dimensions: usize) -> Result<(), MemoryError> {
387        #[cfg(not(feature = "turbo-quant-codec"))]
388        let _ = embedding_dimensions;
389        if self.candidate_pool_size == 0 {
390            self.candidate_pool_size = 1;
391        }
392        if self.default_top_k == 0 {
393            self.default_top_k = 1;
394        }
395        self.candidate_pool_size = self.candidate_pool_size.max(self.default_top_k);
396        if self.sparse_top_k == 0 {
397            self.sparse_top_k = 1;
398        }
399        if self.sparse_derive_top_k == 0 {
400            self.sparse_derive_top_k = 1;
401        }
402        if !self.rrf_k.is_finite() || self.rrf_k <= 0.0 {
403            return Err(MemoryError::InvalidConfig {
404                field: "search.rrf_k",
405                reason: "rrf_k must be finite and > 0".to_string(),
406            });
407        }
408        if !self.bm25_weight.is_finite() || self.bm25_weight < 0.0 {
409            return Err(MemoryError::InvalidConfig {
410                field: "search.bm25_weight",
411                reason: "bm25_weight must be finite and >= 0".to_string(),
412            });
413        }
414        if !self.vector_weight.is_finite() || self.vector_weight < 0.0 {
415            return Err(MemoryError::InvalidConfig {
416                field: "search.vector_weight",
417                reason: "vector_weight must be finite and >= 0".to_string(),
418            });
419        }
420        if !self.sparse_weight.is_finite() || self.sparse_weight < 0.0 {
421            return Err(MemoryError::InvalidConfig {
422                field: "search.sparse_weight",
423                reason: "sparse_weight must be finite and >= 0".to_string(),
424            });
425        }
426        if !self.sparse_min_score.is_finite() {
427            return Err(MemoryError::InvalidConfig {
428                field: "search.sparse_min_score",
429                reason: "sparse_min_score must be finite".to_string(),
430            });
431        }
432        if !self.sparse_derive_min_weight.is_finite() || self.sparse_derive_min_weight < 0.0 {
433            return Err(MemoryError::InvalidConfig {
434                field: "search.sparse_derive_min_weight",
435                reason: "sparse_derive_min_weight must be finite and >= 0".to_string(),
436            });
437        }
438        if !self.recency_weight.is_finite() || self.recency_weight < 0.0 {
439            return Err(MemoryError::InvalidConfig {
440                field: "search.recency_weight",
441                reason: "recency_weight must be finite and >= 0".to_string(),
442            });
443        }
444        if !self.min_similarity.is_finite() || !(-1.0..=1.0).contains(&self.min_similarity) {
445            return Err(MemoryError::InvalidConfig {
446                field: "search.min_similarity",
447                reason: "min_similarity must be finite and within [-1.0, 1.0]".to_string(),
448            });
449        }
450        if matches!(self.recency_half_life_days, Some(v) if !v.is_finite()) {
451            return Err(MemoryError::InvalidConfig {
452                field: "search.recency_half_life_days",
453                reason: "recency_half_life_days must be finite".to_string(),
454            });
455        }
456        if matches!(self.recency_half_life_days, Some(v) if v <= 0.0) {
457            return Err(MemoryError::InvalidConfig {
458                field: "search.recency_half_life_days",
459                reason: "recency_half_life_days must be > 0 when enabled".to_string(),
460            });
461        }
462        if self.uses_turbo_quant_backend() {
463            #[cfg(not(feature = "turbo-quant-codec"))]
464            {
465                return Err(MemoryError::InvalidConfig {
466                    field: "search.derived_vector_backend",
467                    reason: "turbo_quant_candidate_only requires the turbo-quant-codec feature"
468                        .to_string(),
469                });
470            }
471            #[cfg(feature = "turbo-quant-codec")]
472            {
473                if embedding_dimensions % 2 != 0 {
474                    return Err(MemoryError::InvalidConfig {
475                        field: "embedding.dimensions",
476                        reason: "TurboQuant requires even embedding dimensions".to_string(),
477                    });
478                }
479                if self.turbo_quant_projections == 0 {
480                    return Err(MemoryError::InvalidConfig {
481                        field: "search.turbo_quant_projections",
482                        reason: "TurboQuant projections must be at least 1".to_string(),
483                    });
484                }
485                if !(2..=16).contains(&self.turbo_quant_bits) {
486                    return Err(MemoryError::InvalidConfig {
487                        field: "search.turbo_quant_bits",
488                        reason: "TurboQuant bits must be within 2..=16".to_string(),
489                    });
490                }
491            }
492        }
493        if self.uses_derived_vector_backend() && !self.turbo_quant_require_exact_rerank {
494            return Err(MemoryError::InvalidConfig {
495                field: "search.turbo_quant_require_exact_rerank",
496                reason: "derived vector candidate backends require exact f32 rerank".to_string(),
497            });
498        }
499        Ok(())
500    }
501}
502
503/// Chunking strategy to use when splitting text.
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
505#[serde(rename_all = "snake_case")]
506pub enum ChunkingStrategy {
507    /// Plain recursive splitting (current/default behavior).
508    #[default]
509    Plain,
510    /// Sentence-boundary-aware chunking with configurable overlap.
511    Sentence,
512    /// Code-aware chunking that avoids splitting inside function bodies.
513    /// Detects Rust, Python, and TypeScript blocks.
514    Code,
515    /// Markdown-header-based chunking that splits on header boundaries.
516    Markdown,
517}
518
519/// Text chunking parameters.
520#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct ChunkingConfig {
522    /// Target chunk size in characters.
523    pub target_size: usize,
524
525    /// Minimum chunk size. Chunks smaller than this are merged with neighbors.
526    pub min_size: usize,
527
528    /// Maximum chunk size. Chunks larger than this are force-split.
529    pub max_size: usize,
530
531    /// Overlap between adjacent chunks in characters.
532    pub overlap: usize,
533
534    /// Chunking strategy to use. Defaults to [`ChunkingStrategy::Plain`]
535    /// for backward compatibility.
536    #[serde(default)]
537    pub strategy: ChunkingStrategy,
538}
539
540impl Default for ChunkingConfig {
541    fn default() -> Self {
542        Self {
543            target_size: 1000,
544            min_size: 100,
545            max_size: 2000,
546            overlap: 200,
547            strategy: ChunkingStrategy::default(),
548        }
549    }
550}
551
552impl ChunkingConfig {
553    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
554        if self.min_size == 0 {
555            self.min_size = 1;
556        }
557        if self.max_size == 0 {
558            return Err(MemoryError::InvalidConfig {
559                field: "chunking.max_size",
560                reason: "max_size must be at least 1".to_string(),
561            });
562        }
563        if self.max_size < self.min_size {
564            return Err(MemoryError::InvalidConfig {
565                field: "chunking.max_size",
566                reason: "max_size must be >= min_size".to_string(),
567            });
568        }
569        if self.target_size < self.min_size {
570            self.target_size = self.min_size;
571        }
572        if self.target_size > self.max_size {
573            self.target_size = self.max_size;
574        }
575        if self.overlap >= self.min_size {
576            self.overlap = self.min_size.saturating_sub(1);
577        }
578        Ok(())
579    }
580}
581
582/// Connection pool configuration for SQLite.
583///
584/// Controls busy timeout and WAL checkpoint behavior. These defaults
585/// are tuned for a single-process server on local SSD storage.
586#[derive(Debug, Clone, Serialize, Deserialize)]
587pub struct PoolConfig {
588    /// SQLite busy timeout in milliseconds.
589    /// Default: 5000 (5 seconds).
590    pub busy_timeout_ms: u32,
591
592    /// WAL auto-checkpoint threshold in pages.
593    /// Default: 1000 (~4 MB with 4KB pages).
594    pub wal_autocheckpoint: u32,
595
596    /// Enable WAL mode. Should almost always be true.
597    /// Default: true.
598    pub enable_wal: bool,
599
600    /// Number of reader connections kept in the pool.
601    /// Writes still flow through a single writer connection because SQLite
602    /// allows only one concurrent writer, but readers can proceed concurrently
603    /// under WAL semantics.
604    pub max_read_connections: usize,
605
606    /// Timeout in seconds for acquiring a reader connection from the pool.
607    /// Default: 30 seconds.
608    pub reader_timeout_secs: u64,
609}
610
611impl Default for PoolConfig {
612    fn default() -> Self {
613        Self {
614            busy_timeout_ms: 5000,
615            wal_autocheckpoint: 1000,
616            enable_wal: true,
617            max_read_connections: 4,
618            reader_timeout_secs: 30,
619        }
620    }
621}
622
623impl PoolConfig {
624    fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
625        if self.busy_timeout_ms == 0 {
626            self.busy_timeout_ms = 1;
627        }
628        if self.wal_autocheckpoint == 0 {
629            self.wal_autocheckpoint = 1;
630        }
631        if self.max_read_connections == 0 {
632            return Err(MemoryError::InvalidConfig {
633                field: "pool.max_read_connections",
634                reason: "set pool.max_read_connections to at least 1".to_string(),
635            });
636        }
637        if self.reader_timeout_secs == 0 {
638            self.reader_timeout_secs = 1;
639        }
640        self.reader_timeout_secs = self.reader_timeout_secs.min(300);
641        Ok(())
642    }
643}
644
645/// Resource limits for the memory system.
646///
647/// Prevents runaway resource usage. All limits have defaults tuned for
648/// a laptop-class server (8GB RAM, SSD storage).
649#[derive(Debug, Clone, Serialize, Deserialize)]
650pub struct MemoryLimits {
651    /// Maximum number of facts per namespace.
652    /// Default: 100_000.
653    pub max_facts_per_namespace: usize,
654
655    /// Maximum number of chunks per document.
656    /// Default: 1_000.
657    pub max_chunks_per_document: usize,
658
659    /// Maximum content size in bytes for a single fact or message.
660    /// Default: 1 MB (1_048_576 bytes).
661    pub max_content_bytes: usize,
662
663    /// Maximum number of concurrent embedding requests.
664    /// Hard-capped at 32 regardless of config.
665    /// Default: 8.
666    pub max_embedding_concurrency: usize,
667
668    /// Maximum total database size in bytes. 0 = unlimited.
669    /// Default: 0 (unlimited).
670    pub max_db_size_bytes: u64,
671
672    /// Embedding request timeout.
673    /// Default: 30 seconds.
674    #[serde(with = "duration_secs")]
675    pub embedding_timeout: Duration,
676}
677
678impl Default for MemoryLimits {
679    fn default() -> Self {
680        Self {
681            max_facts_per_namespace: 100_000,
682            max_chunks_per_document: 1_000,
683            max_content_bytes: 1_048_576,
684            max_embedding_concurrency: 8,
685            max_db_size_bytes: 0,
686            embedding_timeout: Duration::from_secs(30),
687        }
688    }
689}
690
691impl MemoryLimits {
692    /// Normalize and validate limits to hard caps.
693    pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
694        if self.max_facts_per_namespace == 0 {
695            return Err(MemoryError::InvalidConfig {
696                field: "limits.max_facts_per_namespace",
697                reason: "must be at least 1".to_string(),
698            });
699        }
700        if self.max_chunks_per_document == 0 {
701            return Err(MemoryError::InvalidConfig {
702                field: "limits.max_chunks_per_document",
703                reason: "must be at least 1".to_string(),
704            });
705        }
706        if self.max_content_bytes == 0 {
707            return Err(MemoryError::InvalidConfig {
708                field: "limits.max_content_bytes",
709                reason: "must be at least 1".to_string(),
710            });
711        }
712        // Hard cap: concurrency at 32
713        if self.max_embedding_concurrency > 32 {
714            self.max_embedding_concurrency = 32;
715        }
716        if self.max_embedding_concurrency == 0 {
717            self.max_embedding_concurrency = 1;
718        }
719        if self.embedding_timeout.is_zero() {
720            self.embedding_timeout = Duration::from_secs(1);
721        }
722        Ok(self)
723    }
724
725    /// Backward-compatible alias for callers that only need clamped limits.
726    ///
727    /// Falls back to defaults if the caller-provided limits are invalid.
728    /// Default limits are infallible so the fallback path cannot fail.
729    pub fn validated(self) -> Self {
730        self.normalize_and_validate().unwrap_or_else(|err| {
731            tracing::warn!(
732                error = %err,
733                "invalid MemoryLimits supplied to validated(); using defaults"
734            );
735            // Default limits are always valid — this path is infallible.
736            let defaults = Self::default();
737            Self {
738                max_facts_per_namespace: defaults.max_facts_per_namespace,
739                max_chunks_per_document: defaults.max_chunks_per_document,
740                max_content_bytes: defaults.max_content_bytes,
741                max_embedding_concurrency: defaults.max_embedding_concurrency.clamp(1, 32),
742                max_db_size_bytes: defaults.max_db_size_bytes,
743                embedding_timeout: if defaults.embedding_timeout.is_zero() {
744                    std::time::Duration::from_secs(1)
745                } else {
746                    defaults.embedding_timeout
747                },
748            }
749        })
750    }
751}
752
753mod duration_secs {
754    use serde::{Deserialize, Deserializer, Serializer};
755    use std::time::Duration;
756
757    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
758        s.serialize_u64(d.as_secs())
759    }
760
761    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
762        let secs = u64::deserialize(d)?;
763        Ok(Duration::from_secs(secs))
764    }
765}