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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub struct ConfigCorrection {
11 pub field: String,
13 pub reason: String,
15 pub action: String,
17}
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
21pub struct ConfigCorrectionReport {
22 pub corrections: Vec<ConfigCorrection>,
24}
25
26impl ConfigCorrectionReport {
27 pub fn is_empty(&self) -> bool {
29 self.corrections.is_empty()
30 }
31
32 fn corrected(&mut self, field: &str, reason: &str, action: String) {
33 self.corrections.push(ConfigCorrection {
34 field: field.to_string(),
35 reason: reason.to_string(),
36 action,
37 });
38 }
39}
40
41#[derive(Clone, Serialize, Deserialize)]
43pub struct MemoryConfig {
44 pub base_dir: PathBuf,
47
48 pub embedding: EmbeddingConfig,
50
51 pub search: SearchConfig,
53
54 pub chunking: ChunkingConfig,
56
57 pub pool: PoolConfig,
59
60 pub limits: MemoryLimits,
62
63 #[serde(skip)]
65 pub token_counter: Option<Arc<dyn TokenCounter>>,
66
67 #[cfg(feature = "hnsw")]
69 #[serde(skip)]
70 pub hnsw: crate::hnsw::HnswConfig,
71}
72
73impl std::fmt::Debug for MemoryConfig {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 let mut s = f.debug_struct("MemoryConfig");
76 s.field("base_dir", &self.base_dir)
77 .field("embedding", &self.embedding)
78 .field("search", &self.search)
79 .field("chunking", &self.chunking)
80 .field("pool", &self.pool)
81 .field("limits", &self.limits)
82 .field(
83 "token_counter",
84 &self.token_counter.as_ref().map(|_| "custom"),
85 );
86 #[cfg(feature = "hnsw")]
87 s.field("hnsw", &self.hnsw);
88 s.finish()
89 }
90}
91
92impl Default for MemoryConfig {
93 fn default() -> Self {
94 Self {
95 base_dir: PathBuf::from("memory"),
96 embedding: EmbeddingConfig::default(),
97 search: SearchConfig::default(),
98 chunking: ChunkingConfig::default(),
99 pool: PoolConfig::default(),
100 limits: MemoryLimits::default(),
101 token_counter: None,
102 #[cfg(feature = "hnsw")]
103 hnsw: crate::hnsw::HnswConfig::default(),
104 }
105 }
106}
107
108impl MemoryConfig {
109 pub fn normalize_with_report(mut self) -> Result<(Self, ConfigCorrectionReport), MemoryError> {
111 self.embedding.normalize_and_validate()?;
112 let (limits, report) = self.limits.normalize_with_report();
113 self.limits = limits;
114 let timeout_cap_secs = self.limits.embedding_timeout.as_secs().max(1);
115 self.embedding.timeout_secs = self.embedding.timeout_secs.min(timeout_cap_secs);
116 self.search
117 .normalize_and_validate(self.embedding.dimensions)?;
118 self.chunking.normalize_and_validate()?;
119 self.pool.normalize_and_validate()?;
120 #[cfg(feature = "hnsw")]
121 {
122 self.hnsw.dimensions = self.embedding.dimensions;
123 }
124 Ok((self, report))
125 }
126
127 pub fn normalize_and_validate(self) -> Result<Self, MemoryError> {
131 Ok(self.normalize_with_report()?.0)
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct EmbeddingConfig {
138 pub ollama_url: String,
142
143 pub model: String,
145
146 pub dimensions: usize,
148
149 pub batch_size: usize,
151
152 pub timeout_secs: u64,
154}
155
156impl Default for EmbeddingConfig {
157 fn default() -> Self {
158 Self {
159 ollama_url: "http://localhost:11434".to_string(),
160 model: "nomic-embed-text".to_string(),
161 dimensions: 768,
162 batch_size: 32,
163 timeout_secs: 30,
164 }
165 }
166}
167
168impl EmbeddingConfig {
169 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
170 if self.dimensions == 0 {
171 return Err(MemoryError::InvalidConfig {
172 field: "embedding.dimensions",
173 reason: "dimensions must be at least 1".to_string(),
174 });
175 }
176 if self.batch_size == 0 {
177 self.batch_size = 1;
178 }
179 if self.timeout_secs == 0 {
180 self.timeout_secs = 1;
181 }
182 #[cfg(not(feature = "candle-embedder"))]
186 {
187 let parsed =
188 reqwest::Url::parse(&self.ollama_url).map_err(|_| MemoryError::InvalidConfig {
189 field: "embedding.ollama_url",
190 reason: "must be an absolute http:// or https:// URL".to_string(),
191 })?;
192 match parsed.scheme() {
193 "http" | "https" if parsed.host_str().is_some() => {}
194 _ => {
195 return Err(MemoryError::InvalidConfig {
196 field: "embedding.ollama_url",
197 reason: "must be an absolute http:// or https:// URL".to_string(),
198 })
199 }
200 }
201 }
202 #[cfg(feature = "candle-embedder")]
206 {
207 let _ = &self.ollama_url; }
209 Ok(())
210 }
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct SearchConfig {
216 pub bm25_weight: f64,
218
219 pub vector_weight: f64,
221
222 #[serde(default = "default_zero")]
225 pub sparse_weight: f64,
226
227 #[serde(default = "default_sparse_top_k")]
229 pub sparse_top_k: usize,
230
231 #[serde(default = "default_zero")]
233 pub sparse_min_score: f64,
234
235 #[serde(default)]
238 pub derive_sparse_from_dense: bool,
239
240 #[serde(default = "default_sparse_derive_top_k")]
242 pub sparse_derive_top_k: usize,
243
244 #[serde(default = "default_sparse_derive_min_weight")]
246 pub sparse_derive_min_weight: f32,
247
248 #[serde(default = "default_zero")]
251 pub late_interaction_weight: f64,
252
253 pub bm25_k1: f64,
256
257 pub bm25_b: f64,
260
261 pub namespace_weights: std::collections::HashMap<String, f64>,
264
265 pub rrf_k: f64,
267
268 pub candidate_pool_size: usize,
270
271 pub default_top_k: usize,
273
274 pub min_similarity: f64,
276
277 pub recency_half_life_days: Option<f64>,
282
283 pub recency_weight: f64,
287
288 pub rerank_from_f32: bool,
293
294 #[serde(default)]
297 pub derived_vector_backend: DerivedVectorBackendPolicy,
298
299 #[serde(default = "default_turbo_quant_bits")]
301 pub turbo_quant_bits: u8,
302
303 #[serde(default = "default_turbo_quant_projections")]
305 pub turbo_quant_projections: usize,
306
307 #[serde(default)]
309 pub turbo_quant_seed: u64,
310
311 #[serde(default = "default_true")]
313 pub turbo_quant_require_exact_rerank: bool,
314
315 #[serde(default = "default_candidate_dims")]
321 pub candidate_dims: Option<usize>,
322
323 #[serde(default)]
327 pub compress_results: bool,
328
329 #[serde(default)]
334 pub use_compressed_candidates: bool,
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
339#[serde(rename_all = "snake_case")]
340pub enum DerivedVectorBackendPolicy {
341 #[default]
343 Disabled,
344 TurboQuantCandidateOnly,
346 ProveKvPoolCandidateOnly,
352}
353
354const fn default_turbo_quant_bits() -> u8 {
355 8
356}
357
358const fn default_turbo_quant_projections() -> usize {
359 64
360}
361
362const fn default_true() -> bool {
363 true
364}
365
366const fn default_zero() -> f64 {
367 0.0
368}
369
370const fn default_sparse_top_k() -> usize {
371 50
372}
373
374const fn default_sparse_derive_top_k() -> usize {
375 128
376}
377
378const fn default_sparse_derive_min_weight() -> f32 {
379 0.01
380}
381
382const fn default_candidate_dims() -> Option<usize> {
383 None
384}
385
386impl Default for SearchConfig {
387 fn default() -> Self {
388 Self {
389 bm25_weight: 1.0,
390 vector_weight: 1.0,
391 sparse_weight: 0.0,
392 sparse_top_k: default_sparse_top_k(),
393 sparse_min_score: 0.0,
394 derive_sparse_from_dense: false,
395 sparse_derive_top_k: default_sparse_derive_top_k(),
396 sparse_derive_min_weight: default_sparse_derive_min_weight(),
397 late_interaction_weight: 0.15,
398 bm25_k1: 1.2,
399 bm25_b: 0.75,
400 namespace_weights: std::collections::HashMap::new(),
401 rrf_k: 60.0,
402 candidate_pool_size: 50,
403 default_top_k: 5,
404 min_similarity: 0.3,
405 recency_half_life_days: None,
406 recency_weight: 0.5,
407 rerank_from_f32: true,
408 derived_vector_backend: DerivedVectorBackendPolicy::Disabled,
409 turbo_quant_bits: default_turbo_quant_bits(),
410 turbo_quant_projections: default_turbo_quant_projections(),
411 turbo_quant_seed: 0,
412 turbo_quant_require_exact_rerank: true,
413 candidate_dims: default_candidate_dims(),
414 compress_results: false,
415 use_compressed_candidates: false,
416 }
417 }
418}
419
420impl SearchConfig {
421 pub(crate) fn uses_turbo_quant_backend(&self) -> bool {
422 self.derived_vector_backend == DerivedVectorBackendPolicy::TurboQuantCandidateOnly
423 }
424
425 pub(crate) fn uses_provekv_pool_backend(&self) -> bool {
426 self.derived_vector_backend == DerivedVectorBackendPolicy::ProveKvPoolCandidateOnly
427 }
428
429 pub(crate) fn uses_derived_vector_backend(&self) -> bool {
430 self.uses_turbo_quant_backend() || self.uses_provekv_pool_backend()
431 }
432
433 fn normalize_and_validate(&mut self, embedding_dimensions: usize) -> Result<(), MemoryError> {
434 const MAX_WEIGHT: f64 = 1_000_000.0;
435 const MAX_CANDIDATE_POOL: usize = 1_000_000;
436 const MAX_TOP_K: usize = 10_000;
437 const MAX_SPARSE_DIMENSIONS: usize = 1_000_000;
438 const MAX_RRF_K: f64 = 1_000_000.0;
439 const MAX_RECENCY_DAYS: f64 = 365_000.0;
440 #[cfg(not(feature = "turbo-quant-codec"))]
441 let _ = embedding_dimensions;
442 if self.candidate_pool_size == 0 {
443 self.candidate_pool_size = 1;
444 }
445 if self.default_top_k == 0 {
446 self.default_top_k = 1;
447 }
448 self.candidate_pool_size = self.candidate_pool_size.max(self.default_top_k);
449 if self.sparse_top_k == 0 {
450 self.sparse_top_k = 1;
451 }
452 if self.sparse_derive_top_k == 0 {
453 self.sparse_derive_top_k = 1;
454 }
455 if self.candidate_pool_size > MAX_CANDIDATE_POOL {
456 return Err(MemoryError::InvalidConfig {
457 field: "search.candidate_pool_size",
458 reason: format!("candidate_pool_size must be <= {MAX_CANDIDATE_POOL}"),
459 });
460 }
461 if self.default_top_k > MAX_TOP_K {
462 return Err(MemoryError::InvalidConfig {
463 field: "search.default_top_k",
464 reason: format!("default_top_k must be <= {MAX_TOP_K}"),
465 });
466 }
467 if self.sparse_top_k > MAX_CANDIDATE_POOL {
468 return Err(MemoryError::InvalidConfig {
469 field: "search.sparse_top_k",
470 reason: format!("sparse_top_k must be <= {MAX_CANDIDATE_POOL}"),
471 });
472 }
473 if self.sparse_derive_top_k > MAX_SPARSE_DIMENSIONS {
474 return Err(MemoryError::InvalidConfig {
475 field: "search.sparse_derive_top_k",
476 reason: format!("sparse_derive_top_k must be <= {MAX_SPARSE_DIMENSIONS}"),
477 });
478 }
479 if !self.rrf_k.is_finite() || self.rrf_k <= 0.0 {
480 return Err(MemoryError::InvalidConfig {
481 field: "search.rrf_k",
482 reason: "rrf_k must be finite and > 0".to_string(),
483 });
484 }
485 if self.rrf_k > MAX_RRF_K {
486 return Err(MemoryError::InvalidConfig {
487 field: "search.rrf_k",
488 reason: format!("rrf_k must be <= {MAX_RRF_K}"),
489 });
490 }
491 if !self.bm25_weight.is_finite() || !(0.0..=MAX_WEIGHT).contains(&self.bm25_weight) {
492 return Err(MemoryError::InvalidConfig {
493 field: "search.bm25_weight",
494 reason: format!("bm25_weight must be finite and within [0, {MAX_WEIGHT}]"),
495 });
496 }
497 if !self.vector_weight.is_finite() || !(0.0..=MAX_WEIGHT).contains(&self.vector_weight) {
498 return Err(MemoryError::InvalidConfig {
499 field: "search.vector_weight",
500 reason: format!("vector_weight must be finite and within [0, {MAX_WEIGHT}]"),
501 });
502 }
503 if !self.sparse_weight.is_finite() || !(0.0..=MAX_WEIGHT).contains(&self.sparse_weight) {
504 return Err(MemoryError::InvalidConfig {
505 field: "search.sparse_weight",
506 reason: format!("sparse_weight must be finite and within [0, {MAX_WEIGHT}]"),
507 });
508 }
509 if !self.sparse_min_score.is_finite() || self.sparse_min_score.abs() > MAX_WEIGHT {
510 return Err(MemoryError::InvalidConfig {
511 field: "search.sparse_min_score",
512 reason: format!("sparse_min_score must be finite and within ±{MAX_WEIGHT}"),
513 });
514 }
515 if !self.sparse_derive_min_weight.is_finite()
516 || !(0.0..=MAX_WEIGHT as f32).contains(&self.sparse_derive_min_weight)
517 {
518 return Err(MemoryError::InvalidConfig {
519 field: "search.sparse_derive_min_weight",
520 reason: format!(
521 "sparse_derive_min_weight must be finite and within [0, {MAX_WEIGHT}]"
522 ),
523 });
524 }
525 if !self.late_interaction_weight.is_finite()
526 || !(0.0..=MAX_WEIGHT).contains(&self.late_interaction_weight)
527 {
528 return Err(MemoryError::InvalidConfig {
529 field: "search.late_interaction_weight",
530 reason: format!(
531 "late_interaction_weight must be finite and within [0, {MAX_WEIGHT}]"
532 ),
533 });
534 }
535 if !self.bm25_k1.is_finite() || !(0.0..=100.0).contains(&self.bm25_k1) {
536 return Err(MemoryError::InvalidConfig {
537 field: "search.bm25_k1",
538 reason: "bm25_k1 must be finite and within [0, 100]".to_string(),
539 });
540 }
541 if !self.bm25_b.is_finite() || !(0.0..=1.0).contains(&self.bm25_b) {
542 return Err(MemoryError::InvalidConfig {
543 field: "search.bm25_b",
544 reason: "bm25_b must be finite and within [0, 1]".to_string(),
545 });
546 }
547 if let Some((_, weight)) = self
548 .namespace_weights
549 .iter()
550 .find(|(_, weight)| !weight.is_finite() || !(0.0..=MAX_WEIGHT).contains(weight))
551 {
552 return Err(MemoryError::InvalidConfig {
553 field: "search.namespace_weights",
554 reason: format!(
555 "namespace weights must be finite and within [0, {MAX_WEIGHT}]; found {weight}"
556 ),
557 });
558 }
559 if !self.recency_weight.is_finite() || !(0.0..=MAX_WEIGHT).contains(&self.recency_weight) {
560 return Err(MemoryError::InvalidConfig {
561 field: "search.recency_weight",
562 reason: format!("recency_weight must be finite and within [0, {MAX_WEIGHT}]"),
563 });
564 }
565 if !self.min_similarity.is_finite() || !(-1.0..=1.0).contains(&self.min_similarity) {
566 return Err(MemoryError::InvalidConfig {
567 field: "search.min_similarity",
568 reason: "min_similarity must be finite and within [-1.0, 1.0]".to_string(),
569 });
570 }
571 if matches!(self.recency_half_life_days, Some(v) if !v.is_finite()) {
572 return Err(MemoryError::InvalidConfig {
573 field: "search.recency_half_life_days",
574 reason: "recency_half_life_days must be finite".to_string(),
575 });
576 }
577 if matches!(self.recency_half_life_days, Some(v) if v <= 0.0) {
578 return Err(MemoryError::InvalidConfig {
579 field: "search.recency_half_life_days",
580 reason: "recency_half_life_days must be > 0 when enabled".to_string(),
581 });
582 }
583 if matches!(self.recency_half_life_days, Some(v) if v > MAX_RECENCY_DAYS) {
584 return Err(MemoryError::InvalidConfig {
585 field: "search.recency_half_life_days",
586 reason: format!("recency_half_life_days must be <= {MAX_RECENCY_DAYS}"),
587 });
588 }
589 if !(2..=16).contains(&self.turbo_quant_bits) {
590 return Err(MemoryError::InvalidConfig {
591 field: "search.turbo_quant_bits",
592 reason: "TurboQuant bits must be within 2..=16".to_string(),
593 });
594 }
595 if self.turbo_quant_projections == 0 || self.turbo_quant_projections > MAX_CANDIDATE_POOL {
596 return Err(MemoryError::InvalidConfig {
597 field: "search.turbo_quant_projections",
598 reason: format!("TurboQuant projections must be within 1..={MAX_CANDIDATE_POOL}"),
599 });
600 }
601 if matches!(self.candidate_dims, Some(0))
602 || matches!(self.candidate_dims, Some(dim) if dim > embedding_dimensions)
603 {
604 return Err(MemoryError::InvalidConfig {
605 field: "search.candidate_dims",
606 reason: format!(
607 "candidate_dims must be within 1..={embedding_dimensions} when enabled"
608 ),
609 });
610 }
611 if self.uses_turbo_quant_backend() {
612 #[cfg(not(feature = "turbo-quant-codec"))]
613 {
614 return Err(MemoryError::InvalidConfig {
615 field: "search.derived_vector_backend",
616 reason: "turbo_quant_candidate_only requires the turbo-quant-codec feature"
617 .to_string(),
618 });
619 }
620 #[cfg(feature = "turbo-quant-codec")]
621 {
622 if embedding_dimensions % 2 != 0 {
623 return Err(MemoryError::InvalidConfig {
624 field: "embedding.dimensions",
625 reason: "TurboQuant requires even embedding dimensions".to_string(),
626 });
627 }
628 if self.turbo_quant_projections == 0 {
629 return Err(MemoryError::InvalidConfig {
630 field: "search.turbo_quant_projections",
631 reason: "TurboQuant projections must be at least 1".to_string(),
632 });
633 }
634 if !(2..=16).contains(&self.turbo_quant_bits) {
635 return Err(MemoryError::InvalidConfig {
636 field: "search.turbo_quant_bits",
637 reason: "TurboQuant bits must be within 2..=16".to_string(),
638 });
639 }
640 }
641 }
642 if self.uses_derived_vector_backend() && !self.turbo_quant_require_exact_rerank {
643 return Err(MemoryError::InvalidConfig {
644 field: "search.turbo_quant_require_exact_rerank",
645 reason: "derived vector candidate backends require exact f32 rerank".to_string(),
646 });
647 }
648 Ok(())
649 }
650}
651
652#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
654#[serde(rename_all = "snake_case")]
655pub enum ChunkingStrategy {
656 #[default]
658 Plain,
659 Sentence,
661 Code,
664 Markdown,
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize)]
670pub struct ChunkingConfig {
671 pub target_size: usize,
673
674 pub min_size: usize,
676
677 pub max_size: usize,
679
680 pub overlap: usize,
682
683 #[serde(default)]
686 pub strategy: ChunkingStrategy,
687}
688
689impl Default for ChunkingConfig {
690 fn default() -> Self {
691 Self {
692 target_size: 1000,
693 min_size: 100,
694 max_size: 2000,
695 overlap: 200,
696 strategy: ChunkingStrategy::default(),
697 }
698 }
699}
700
701impl ChunkingConfig {
702 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
703 if self.min_size == 0 {
704 self.min_size = 1;
705 }
706 if self.max_size == 0 {
707 return Err(MemoryError::InvalidConfig {
708 field: "chunking.max_size",
709 reason: "max_size must be at least 1".to_string(),
710 });
711 }
712 if self.max_size < self.min_size {
713 return Err(MemoryError::InvalidConfig {
714 field: "chunking.max_size",
715 reason: "max_size must be >= min_size".to_string(),
716 });
717 }
718 if self.target_size < self.min_size {
719 self.target_size = self.min_size;
720 }
721 if self.target_size > self.max_size {
722 self.target_size = self.max_size;
723 }
724 if self.overlap >= self.min_size {
725 self.overlap = self.min_size.saturating_sub(1);
726 }
727 Ok(())
728 }
729}
730
731#[derive(Debug, Clone, Serialize, Deserialize)]
736pub struct PoolConfig {
737 pub busy_timeout_ms: u32,
740
741 pub wal_autocheckpoint: u32,
744
745 pub enable_wal: bool,
748
749 pub max_read_connections: usize,
754
755 pub reader_timeout_secs: u64,
758}
759
760impl Default for PoolConfig {
761 fn default() -> Self {
762 Self {
763 busy_timeout_ms: 5000,
764 wal_autocheckpoint: 1000,
765 enable_wal: true,
766 max_read_connections: 4,
767 reader_timeout_secs: 30,
768 }
769 }
770}
771
772impl PoolConfig {
773 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
774 if self.busy_timeout_ms == 0 {
775 self.busy_timeout_ms = 1;
776 }
777 if self.wal_autocheckpoint == 0 {
778 self.wal_autocheckpoint = 1;
779 }
780 if self.max_read_connections == 0 {
781 return Err(MemoryError::InvalidConfig {
782 field: "pool.max_read_connections",
783 reason: "set pool.max_read_connections to at least 1".to_string(),
784 });
785 }
786 if self.reader_timeout_secs == 0 {
787 self.reader_timeout_secs = 1;
788 }
789 self.reader_timeout_secs = self.reader_timeout_secs.min(300);
790 Ok(())
791 }
792}
793
794#[derive(Debug, Clone, Serialize, Deserialize)]
799pub struct MemoryLimits {
800 pub max_facts_per_namespace: usize,
803
804 pub max_chunks_per_document: usize,
807
808 pub max_content_bytes: usize,
811
812 pub max_embedding_concurrency: usize,
816
817 pub max_db_size_bytes: u64,
820
821 #[serde(with = "duration_secs")]
824 pub embedding_timeout: Duration,
825}
826
827impl Default for MemoryLimits {
828 fn default() -> Self {
829 Self {
830 max_facts_per_namespace: 100_000,
831 max_chunks_per_document: 1_000,
832 max_content_bytes: 1_048_576,
833 max_embedding_concurrency: 8,
834 max_db_size_bytes: 0,
835 embedding_timeout: Duration::from_secs(30),
836 }
837 }
838}
839
840impl MemoryLimits {
841 pub fn normalize_with_report(mut self) -> (Self, ConfigCorrectionReport) {
844 const MAX_FACTS_PER_NAMESPACE: usize = 10_000_000;
845 const MAX_CHUNKS_PER_DOCUMENT: usize = 1_000_000;
846 const MAX_CONTENT_BYTES: usize = 64 * 1024 * 1024;
847 const MAX_EMBEDDING_TIMEOUT_SECS: u64 = 300;
848 const MAX_DB_SIZE_BYTES: u64 = 1 << 50;
849
850 let defaults = Self::default();
851 let mut report = ConfigCorrectionReport::default();
852 if self.max_facts_per_namespace == 0 {
853 self.max_facts_per_namespace = defaults.max_facts_per_namespace;
854 report.corrected(
855 "limits.max_facts_per_namespace",
856 "must be at least 1",
857 format!("replaced with default {}", self.max_facts_per_namespace),
858 );
859 } else if self.max_facts_per_namespace > MAX_FACTS_PER_NAMESPACE {
860 self.max_facts_per_namespace = MAX_FACTS_PER_NAMESPACE;
861 report.corrected(
862 "limits.max_facts_per_namespace",
863 "exceeds hard upper bound",
864 format!("clamped to {MAX_FACTS_PER_NAMESPACE}"),
865 );
866 }
867 if self.max_chunks_per_document == 0 {
868 self.max_chunks_per_document = defaults.max_chunks_per_document;
869 report.corrected(
870 "limits.max_chunks_per_document",
871 "must be at least 1",
872 format!("replaced with default {}", self.max_chunks_per_document),
873 );
874 } else if self.max_chunks_per_document > MAX_CHUNKS_PER_DOCUMENT {
875 self.max_chunks_per_document = MAX_CHUNKS_PER_DOCUMENT;
876 report.corrected(
877 "limits.max_chunks_per_document",
878 "exceeds hard upper bound",
879 format!("clamped to {MAX_CHUNKS_PER_DOCUMENT}"),
880 );
881 }
882 if self.max_content_bytes == 0 {
883 self.max_content_bytes = defaults.max_content_bytes;
884 report.corrected(
885 "limits.max_content_bytes",
886 "must be at least 1",
887 format!("replaced with default {}", self.max_content_bytes),
888 );
889 } else if self.max_content_bytes > MAX_CONTENT_BYTES {
890 self.max_content_bytes = MAX_CONTENT_BYTES;
891 report.corrected(
892 "limits.max_content_bytes",
893 "exceeds hard upper bound",
894 format!("clamped to {MAX_CONTENT_BYTES}"),
895 );
896 }
897 if self.max_embedding_concurrency == 0 {
898 self.max_embedding_concurrency = 1;
899 report.corrected(
900 "limits.max_embedding_concurrency",
901 "must be at least 1",
902 "clamped to 1".to_string(),
903 );
904 } else if self.max_embedding_concurrency > 32 {
905 self.max_embedding_concurrency = 32;
906 report.corrected(
907 "limits.max_embedding_concurrency",
908 "exceeds hard upper bound",
909 "clamped to 32".to_string(),
910 );
911 }
912 if self.max_db_size_bytes > MAX_DB_SIZE_BYTES {
913 self.max_db_size_bytes = MAX_DB_SIZE_BYTES;
914 report.corrected(
915 "limits.max_db_size_bytes",
916 "exceeds hard upper bound",
917 format!("clamped to {MAX_DB_SIZE_BYTES}"),
918 );
919 }
920 if self.embedding_timeout.is_zero() {
921 self.embedding_timeout = Duration::from_secs(1);
922 report.corrected(
923 "limits.embedding_timeout",
924 "must be at least one second",
925 "clamped to 1 second".to_string(),
926 );
927 } else if self.embedding_timeout.as_secs() > MAX_EMBEDDING_TIMEOUT_SECS {
928 self.embedding_timeout = Duration::from_secs(MAX_EMBEDDING_TIMEOUT_SECS);
929 report.corrected(
930 "limits.embedding_timeout",
931 "exceeds hard upper bound",
932 format!("clamped to {MAX_EMBEDDING_TIMEOUT_SECS} seconds"),
933 );
934 }
935 (self, report)
936 }
937
938 pub fn normalize_and_validate(self) -> Result<Self, MemoryError> {
940 Ok(self.normalize_with_report().0)
941 }
942
943 pub fn validated(self) -> Self {
948 let (limits, report) = self.normalize_with_report();
949 for correction in report.corrections {
950 tracing::warn!(
951 field = %correction.field,
952 reason = %correction.reason,
953 action = %correction.action,
954 "corrected invalid memory limit"
955 );
956 }
957 limits
958 }
959}
960
961mod duration_secs {
962 use serde::{Deserialize, Deserializer, Serializer};
963 use std::time::Duration;
964
965 pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
966 s.serialize_u64(d.as_secs())
967 }
968
969 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
970 let secs = u64::deserialize(d)?;
971 Ok(Duration::from_secs(secs))
972 }
973}
974
975#[cfg(test)]
976mod hardening_tests {
977 use super::*;
978 use proptest::prelude::*;
979
980 #[test]
981 fn one_invalid_limit_preserves_every_other_valid_limit() {
982 let original = MemoryLimits {
983 max_facts_per_namespace: 0,
984 max_chunks_per_document: 321,
985 max_content_bytes: 654_321,
986 max_embedding_concurrency: 7,
987 max_db_size_bytes: 987_654_321,
988 embedding_timeout: Duration::from_secs(19),
989 };
990
991 let corrected = original.validated();
992 assert_eq!(
993 corrected.max_facts_per_namespace,
994 MemoryLimits::default().max_facts_per_namespace
995 );
996 assert_eq!(corrected.max_chunks_per_document, 321);
997 assert_eq!(corrected.max_content_bytes, 654_321);
998 assert_eq!(corrected.max_embedding_concurrency, 7);
999 assert_eq!(corrected.max_db_size_bytes, 987_654_321);
1000 assert_eq!(corrected.embedding_timeout, Duration::from_secs(19));
1001 }
1002
1003 #[test]
1004 fn memory_config_returns_structured_correction_report() {
1005 let mut config = MemoryConfig::default();
1006 config.limits.max_facts_per_namespace = 0;
1007 let (normalized, report) = config.normalize_with_report().unwrap();
1008 assert_eq!(
1009 normalized.limits.max_facts_per_namespace,
1010 MemoryLimits::default().max_facts_per_namespace
1011 );
1012 assert_eq!(report.corrections.len(), 1);
1013 assert_eq!(
1014 report.corrections[0].field,
1015 "limits.max_facts_per_namespace"
1016 );
1017 }
1018
1019 proptest! {
1020 #[test]
1021 fn arbitrary_single_bad_limit_preserves_other_valid_fields(
1022 chunks in 1usize..10_000,
1023 content in 1usize..10_000_000,
1024 concurrency in 1usize..=32,
1025 db_size in 0u64..=(1u64 << 50),
1026 timeout in 1u64..=300,
1027 ) {
1028 let original = MemoryLimits {
1029 max_facts_per_namespace: 0,
1030 max_chunks_per_document: chunks,
1031 max_content_bytes: content,
1032 max_embedding_concurrency: concurrency,
1033 max_db_size_bytes: db_size,
1034 embedding_timeout: Duration::from_secs(timeout),
1035 };
1036 let (corrected, report) = original.normalize_with_report();
1037 prop_assert_eq!(corrected.max_chunks_per_document, chunks);
1038 prop_assert_eq!(corrected.max_content_bytes, content);
1039 prop_assert_eq!(corrected.max_embedding_concurrency, concurrency);
1040 prop_assert_eq!(corrected.max_db_size_bytes, db_size);
1041 prop_assert_eq!(corrected.embedding_timeout, Duration::from_secs(timeout));
1042 prop_assert_eq!(report.corrections.len(), 1);
1043 prop_assert_eq!(report.corrections[0].field.as_str(), "limits.max_facts_per_namespace");
1044 }
1045 }
1046
1047 #[test]
1048 fn search_rejects_every_unbounded_or_non_finite_numeric_family() {
1049 let mut cases = Vec::new();
1050
1051 let mut config = SearchConfig::default();
1052 config.late_interaction_weight = f64::NAN;
1053 cases.push(config);
1054
1055 let mut config = SearchConfig::default();
1056 config.bm25_k1 = f64::INFINITY;
1057 cases.push(config);
1058
1059 let mut config = SearchConfig::default();
1060 config.bm25_b = 1.1;
1061 cases.push(config);
1062
1063 let mut config = SearchConfig::default();
1064 config.namespace_weights.insert("bad".into(), f64::INFINITY);
1065 cases.push(config);
1066
1067 let mut config = SearchConfig::default();
1068 config.candidate_pool_size = usize::MAX;
1069 cases.push(config);
1070
1071 let mut config = SearchConfig::default();
1072 config.sparse_top_k = usize::MAX;
1073 cases.push(config);
1074
1075 let mut config = SearchConfig::default();
1076 config.default_top_k = usize::MAX;
1077 cases.push(config);
1078
1079 for mut config in cases {
1080 assert!(
1081 config.normalize_and_validate(768).is_err(),
1082 "invalid numeric config was accepted: {config:?}"
1083 );
1084 }
1085 }
1086
1087 #[test]
1088 fn search_config_nan_weight_preserves_other_numeric_fields() {
1089 let mut config = SearchConfig::default();
1090 let expected_vector_weight = config.vector_weight;
1091 let expected_sparse_weight = config.sparse_weight;
1092 let expected_candidate_pool_size = config.candidate_pool_size;
1093 let expected_rerank_flag = config.rerank_from_f32;
1094 let expected_derive_sparse = config.derive_sparse_from_dense;
1095
1096 config.bm25_weight = f64::NAN;
1097
1098 let err = config.normalize_and_validate(768).unwrap_err();
1099 match err {
1100 MemoryError::InvalidConfig { field, reason: _ } => {
1101 assert_eq!(field, "search.bm25_weight")
1102 }
1103 _ => panic!("expected InvalidConfig for non-finite numeric field"),
1104 }
1105
1106 assert_eq!(config.vector_weight, expected_vector_weight);
1107 assert_eq!(config.sparse_weight, expected_sparse_weight);
1108 assert_eq!(config.candidate_pool_size, expected_candidate_pool_size);
1109 assert_eq!(config.rerank_from_f32, expected_rerank_flag);
1110 assert_eq!(config.derive_sparse_from_dense, expected_derive_sparse);
1111 }
1112}