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