1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4
5use serde::{Deserialize, Serialize};
6
7use engram_hnsw::HnswParams;
8use engram_llm_client::{
9 EmbeddingProvider, LocalTextGenerator, OllamaEmbeddingProvider, OllamaTextGenerator,
10 OpenAITextGenerator, TextGenerator, VoyageEmbeddingProvider,
11};
12
13use crate::config_loader::{
14 deep_merge, load_global_config_tree, load_project_config_tree, restore_secret, secret_at,
15};
16use crate::error::CoreError;
17
18pub(crate) const ENGRAM_CONFIG_SUBPATH: &str = ".engram/engram.toml";
22const PROJECT_DIR_MARKER: &str = ".engram";
23
24const EMBEDDING_SECTION: &str = "embedding";
25const LLM_SECTION: &str = "llm";
26
27const DEFAULT_EMBEDDING_PROVIDER: &str = "voyage";
28pub const DEFAULT_EMBEDDING_MODEL: &str = "voyage-4";
29const DEFAULT_EMBEDDING_DIMENSION: usize = 1024;
30pub const DEFAULT_OLLAMA_HOST: &str = "http://localhost:11434";
31pub const DEFAULT_EMBEDDING_HOST: &str = DEFAULT_OLLAMA_HOST;
32const DEFAULT_HYDE_THRESHOLD: usize = 0;
33const DEFAULT_LLM_PROVIDER: &str = "openai";
34const DEFAULT_LLM_MODEL: &str = "gpt-4o-mini";
35const DEFAULT_REINDEX_INTERVAL_SECS: u64 = 3600;
36const DEFAULT_HNSW_MAX_CONNECTIONS: usize = 16;
37const DEFAULT_HNSW_EF_CONSTRUCTION: usize = 200;
38const DEFAULT_HNSW_EF_SEARCH: usize = 40;
39const DEFAULT_CONSOLIDATION_STALE_DAYS: u32 = 90;
40const DEFAULT_CONSOLIDATION_MIN_SCORE: f64 = 0.3;
41const DEFAULT_CONSOLIDATION_FTS_SIMILARITY_FLOOR: f32 = 0.0;
42const DEFAULT_DEDUP_THRESHOLD: f32 = 0.95;
43const DEFAULT_TRAINER_BINARY: &str = "engram-trainer";
44const DEFAULT_TRAINER_TIMEOUT_SECS: u64 = 300;
45const DEFAULT_MODELS_PATH: &str = "~/.engram/models";
46const DEFAULT_RRF_K: usize = 60;
47const DEFAULT_VECTOR_WEIGHT: f64 = 0.7;
48const DEFAULT_SPARSE_WEIGHT: f64 = 0.3;
49
50#[derive(Serialize, Deserialize, Clone)]
51pub struct Config {
52 #[serde(default)]
53 pub database: DatabaseConfig,
54 pub embedding: EmbeddingConfig,
55 pub llm: LlmConfig,
56 pub server: ServerConfig,
57 pub hnsw: HnswConfig,
58 #[serde(default)]
59 pub consolidation: ConsolidationConfig,
60 #[serde(default)]
61 pub deduplication: DeduplicationConfig,
62 #[serde(default)]
63 pub trainer: TrainerConfig,
64 #[serde(default)]
65 pub search: SearchConfig,
66}
67
68#[derive(Serialize, Deserialize, Clone, Default)]
69pub struct DatabaseConfig {
70 #[serde(default)]
75 pub path: Option<String>,
76}
77
78#[derive(Serialize, Deserialize, Clone)]
79pub struct EmbeddingConfig {
80 pub provider: String,
81 pub api_key: Option<String>,
82 pub model: Option<String>,
83 pub dimension: Option<usize>,
84 #[serde(default)]
89 pub output_dimension: Option<usize>,
90 #[serde(default)]
94 pub host: Option<String>,
95 #[serde(default)]
96 pub hyde_threshold: usize,
97}
98
99#[derive(Serialize, Deserialize, Clone)]
100pub struct LlmConfig {
101 pub provider: String,
102 pub api_key: Option<String>,
103 pub model: Option<String>,
104 #[serde(default)]
108 pub host: Option<String>,
109}
110
111#[derive(Serialize, Deserialize, Clone)]
112pub struct ServerConfig {
113 #[serde(default)]
117 pub socket_path: Option<String>,
118 pub reindex_interval_secs: u64,
119}
120
121#[derive(Serialize, Deserialize, Clone)]
122pub struct HnswConfig {
123 pub max_connections: usize,
124 pub ef_construction: usize,
125 pub ef_search: usize,
126 pub dimension: usize,
127}
128
129#[derive(Serialize, Deserialize, Clone)]
130pub struct ConsolidationConfig {
131 pub stale_days: u32,
132 pub min_score: f64,
133 #[serde(default)]
138 pub fts_similarity_floor: f32,
139}
140
141#[derive(Serialize, Deserialize, Clone)]
142pub struct DeduplicationConfig {
143 pub threshold: f32,
144}
145
146#[derive(Serialize, Deserialize, Clone)]
151pub struct SearchConfig {
152 pub rrf_k: usize,
153 pub vector_weight: f64,
154 pub sparse_weight: f64,
155}
156
157#[derive(Serialize, Deserialize, Clone)]
158pub struct TrainerConfig {
159 pub trainer_binary: String,
160 pub trainer_timeout_secs: u64,
161 pub models_path: String,
162}
163
164impl Default for TrainerConfig {
165 fn default() -> Self {
166 Self {
167 trainer_binary: DEFAULT_TRAINER_BINARY.into(),
168 trainer_timeout_secs: DEFAULT_TRAINER_TIMEOUT_SECS,
169 models_path: DEFAULT_MODELS_PATH.into(),
170 }
171 }
172}
173
174impl Default for ConsolidationConfig {
175 fn default() -> Self {
176 Self {
177 stale_days: DEFAULT_CONSOLIDATION_STALE_DAYS,
178 min_score: DEFAULT_CONSOLIDATION_MIN_SCORE,
179 fts_similarity_floor: DEFAULT_CONSOLIDATION_FTS_SIMILARITY_FLOOR,
180 }
181 }
182}
183
184impl Default for DeduplicationConfig {
185 fn default() -> Self {
186 Self {
187 threshold: DEFAULT_DEDUP_THRESHOLD,
188 }
189 }
190}
191
192impl Default for SearchConfig {
193 fn default() -> Self {
194 Self {
195 rrf_k: DEFAULT_RRF_K,
196 vector_weight: DEFAULT_VECTOR_WEIGHT,
197 sparse_weight: DEFAULT_SPARSE_WEIGHT,
198 }
199 }
200}
201
202pub fn validate_dedup_threshold(threshold: f32) -> Result<(), CoreError> {
207 if threshold > 0.0 && threshold <= 1.0 {
208 return Ok(());
209 }
210 Err(CoreError::ConfigValidation(format!(
211 "deduplication.threshold must be in (0.0, 1.0], got {threshold}"
212 )))
213}
214
215pub fn validate_fts_similarity_floor(floor: f32) -> Result<(), CoreError> {
221 if floor.is_finite() && floor >= 0.0 {
222 return Ok(());
223 }
224 Err(CoreError::ConfigValidation(format!(
225 "consolidation.fts_similarity_floor must be finite and >= 0.0, got {floor}"
226 )))
227}
228
229pub fn validate_search_config(config: &SearchConfig) -> Result<(), CoreError> {
236 if config.rrf_k == 0 {
237 return Err(CoreError::ConfigValidation(
238 "search.rrf_k must be greater than 0".to_string(),
239 ));
240 }
241 if !config.vector_weight.is_finite() || !config.sparse_weight.is_finite() {
242 return Err(CoreError::ConfigValidation(
243 "search weights must be finite".to_string(),
244 ));
245 }
246 if config.vector_weight < 0.0 || config.sparse_weight < 0.0 {
247 return Err(CoreError::ConfigValidation(format!(
248 "search weights must be non-negative, got vector_weight={}, sparse_weight={}",
249 config.vector_weight, config.sparse_weight
250 )));
251 }
252 if config.vector_weight == 0.0 && config.sparse_weight == 0.0 {
253 return Err(CoreError::ConfigValidation(
254 "search.vector_weight and search.sparse_weight cannot both be 0.0".to_string(),
255 ));
256 }
257 Ok(())
258}
259
260impl Config {
261 pub fn load() -> Result<Self, CoreError> {
269 let global_tree = load_global_config_tree()?;
270 let project_tree = load_project_config_tree()?;
271
272 if global_tree.is_none() && project_tree.is_none() {
273 let mut config = Self::default();
274 config.apply_env_overrides();
275 return Ok(config);
276 }
277
278 let mut merged_tree = match global_tree {
279 Some(tree) => tree,
280 None => toml::Value::try_from(Self::default())
281 .map_err(|error| CoreError::ConfigParseError(error.to_string()))?,
282 };
283
284 let global_embedding_secret = secret_at(&merged_tree, EMBEDDING_SECTION);
285 let global_llm_secret = secret_at(&merged_tree, LLM_SECTION);
286
287 if let Some(project_tree) = project_tree {
288 deep_merge(&mut merged_tree, project_tree);
289 }
290
291 restore_secret(&mut merged_tree, EMBEDDING_SECTION, global_embedding_secret);
292 restore_secret(&mut merged_tree, LLM_SECTION, global_llm_secret);
293
294 let mut config: Config = merged_tree
295 .try_into()
296 .map_err(|error: toml::de::Error| CoreError::ConfigParseError(error.to_string()))?;
297 config.apply_env_overrides();
298 Ok(config)
299 }
300
301 pub fn load_from_path(path: &str) -> Result<Self, CoreError> {
302 let content = fs::read_to_string(path).map_err(|_| CoreError::ConfigNotFound)?;
303 let mut config: Config = toml::from_str(&content)
304 .map_err(|error| CoreError::ConfigParseError(error.to_string()))?;
305 config.apply_env_overrides();
306 Ok(config)
307 }
308
309 pub fn build_embedding_provider(
310 &self,
311 ) -> Result<Box<dyn EmbeddingProvider + Send + Sync>, CoreError> {
312 let dimension = self
313 .embedding
314 .dimension
315 .unwrap_or(DEFAULT_EMBEDDING_DIMENSION);
316 match self.embedding.provider.as_str() {
317 "voyage" => {
318 let api_key = self
319 .embedding
320 .api_key
321 .as_deref()
322 .filter(|key| !key.is_empty())
323 .ok_or_else(|| CoreError::InvalidProvider("voyage requires api_key".into()))?;
324 let model = self
325 .embedding
326 .model
327 .clone()
328 .unwrap_or_else(|| DEFAULT_EMBEDDING_MODEL.into());
329 let provider = VoyageEmbeddingProvider::with_config(
330 api_key.to_owned(),
331 model,
332 dimension,
333 self.embedding.output_dimension,
334 engram_llm_client::RetryConfig::default(),
335 "https://api.voyageai.com".into(),
336 )?;
337 Ok(Box::new(provider))
338 }
339 "ollama" => {
340 let host = self
341 .embedding
342 .host
343 .clone()
344 .unwrap_or_else(|| DEFAULT_EMBEDDING_HOST.into());
345 let model = self
346 .embedding
347 .model
348 .clone()
349 .unwrap_or_else(|| engram_llm_client::ollama::DEFAULT_OLLAMA_MODEL.into());
350 let provider = OllamaEmbeddingProvider::new(host, model, dimension)?;
351 Ok(Box::new(provider))
352 }
353 "deterministic" => Ok(Box::new(DeterministicEmbeddingProvider { dimension })),
354 other => Err(CoreError::InvalidProvider(format!(
355 "{other} embedding not supported"
356 ))),
357 }
358 }
359
360 pub fn build_text_generator(&self) -> Result<Box<dyn TextGenerator + Send + Sync>, CoreError> {
361 match self.llm.provider.as_str() {
362 "openai" => {
363 let api_key = self
364 .llm
365 .api_key
366 .as_deref()
367 .filter(|key| !key.is_empty())
368 .ok_or_else(|| CoreError::InvalidProvider("openai requires api_key".into()))?;
369 let generator = OpenAITextGenerator::new(api_key.to_owned())?;
370 Ok(Box::new(generator))
371 }
372 "local" => {
373 let models_path = expand_tilde(&self.trainer.models_path);
374 let model_path = format!("{models_path}/text_generator.onnx");
375 let tokenizer_path = format!("{models_path}/tokenizer.json");
376 let generator = LocalTextGenerator::new(&model_path, &tokenizer_path)
377 .map_err(|error| CoreError::InvalidProvider(error.to_string()))?;
378 Ok(Box::new(generator))
379 }
380 "ollama" => {
381 let host = self
382 .llm
383 .host
384 .clone()
385 .unwrap_or_else(|| DEFAULT_OLLAMA_HOST.into());
386 let model = self.llm.model.clone().unwrap_or_else(|| {
387 engram_llm_client::ollama_text::DEFAULT_OLLAMA_LLM_MODEL.into()
388 });
389 let generator = OllamaTextGenerator::new(host, model)?;
390 Ok(Box::new(generator))
391 }
392 other => Err(CoreError::InvalidProvider(format!(
393 "{other} llm not supported"
394 ))),
395 }
396 }
397
398 pub fn build_hnsw_params(&self) -> Result<HnswParams, CoreError> {
399 HnswParams::new(self.hnsw.dimension)?
400 .with_max_connections(self.hnsw.max_connections)?
401 .with_ef_construction(self.hnsw.ef_construction)?
402 .with_ef_search(self.hnsw.ef_search)
403 .map_err(CoreError::Hnsw)
404 }
405
406 fn apply_env_overrides(&mut self) {
407 if let Ok(value) = std::env::var("ENGRAM_EMBEDDING_MODEL") {
412 self.embedding.model = Some(value);
413 }
414 if let Ok(value) = std::env::var("ENGRAM_LLM_MODEL") {
415 self.llm.model = Some(value);
416 }
417 if let Ok(value) = std::env::var("ENGRAM_TRAINER_BINARY") {
418 self.trainer.trainer_binary = value;
419 }
420 if let Ok(value) = std::env::var("ENGRAM_TRAINER_TIMEOUT")
421 && let Ok(secs) = value.parse::<u64>()
422 {
423 self.trainer.trainer_timeout_secs = secs;
424 }
425 if let Ok(value) = std::env::var("ENGRAM_MODELS_PATH") {
426 self.trainer.models_path = value;
427 }
428 self.apply_provider_api_key_overrides();
429 }
430
431 fn apply_provider_api_key_overrides(&mut self) {
432 if self.embedding.provider == "voyage"
433 && let Ok(value) = std::env::var("ENGRAM_VOYAGE_API_KEY")
434 {
435 self.embedding.api_key = Some(value);
436 }
437 if self.embedding.provider == "ollama"
438 && let Ok(value) = std::env::var("ENGRAM_OLLAMA_HOST")
439 {
440 self.embedding.host = Some(value);
441 }
442 if self.llm.provider == "ollama"
443 && let Ok(value) = std::env::var("ENGRAM_OLLAMA_HOST")
444 {
445 self.llm.host = Some(value);
446 }
447 if self.llm.provider == "openai"
448 && let Ok(value) = std::env::var("ENGRAM_OPENAI_API_KEY")
449 {
450 self.llm.api_key = Some(value);
451 }
452 }
453}
454
455impl Default for Config {
456 fn default() -> Self {
457 Self {
458 database: DatabaseConfig { path: None },
459 embedding: EmbeddingConfig {
460 provider: DEFAULT_EMBEDDING_PROVIDER.into(),
461 api_key: None,
462 model: Some(DEFAULT_EMBEDDING_MODEL.into()),
463 dimension: Some(DEFAULT_EMBEDDING_DIMENSION),
464 output_dimension: None,
465 host: None,
466 hyde_threshold: DEFAULT_HYDE_THRESHOLD,
467 },
468 llm: LlmConfig {
469 provider: DEFAULT_LLM_PROVIDER.into(),
470 api_key: None,
471 model: Some(DEFAULT_LLM_MODEL.into()),
472 host: None,
473 },
474 server: ServerConfig {
475 socket_path: None,
476 reindex_interval_secs: DEFAULT_REINDEX_INTERVAL_SECS,
477 },
478 hnsw: HnswConfig {
479 max_connections: DEFAULT_HNSW_MAX_CONNECTIONS,
480 ef_construction: DEFAULT_HNSW_EF_CONSTRUCTION,
481 ef_search: DEFAULT_HNSW_EF_SEARCH,
482 dimension: DEFAULT_EMBEDDING_DIMENSION,
483 },
484 consolidation: ConsolidationConfig::default(),
485 deduplication: DeduplicationConfig::default(),
486 trainer: TrainerConfig::default(),
487 search: SearchConfig::default(),
488 }
489 }
490}
491
492pub fn resolve_project_dir(
493 start: &Path,
494 explicit_override: Option<&Path>,
495) -> Result<PathBuf, CoreError> {
496 if let Some(path) = explicit_override {
497 return Ok(path.to_path_buf());
498 }
499 if let Ok(env_path) = std::env::var("ENGRAM_PROJECT_DIR") {
500 let candidate = PathBuf::from(env_path);
501 if candidate.is_absolute() {
502 return Ok(candidate);
503 }
504 }
505 let mut current = start.to_path_buf();
506 loop {
507 if current.join(PROJECT_DIR_MARKER).is_dir() {
508 return Ok(current);
509 }
510 if !current.pop() {
512 break;
513 }
514 }
515 Err(CoreError::ProjectDirNotFound)
516}
517
518pub fn expand_tilde(path: &str) -> String {
519 if let Some(rest) = path.strip_prefix("~/")
520 && let Some(home) = home_directory()
521 {
522 return format!("{home}/{rest}");
523 }
524 path.to_string()
525}
526
527pub fn home_directory() -> Option<String> {
528 std::env::var("HOME").ok()
529}
530
531struct DeterministicEmbeddingProvider {
532 dimension: usize,
533}
534
535static DETERMINISTIC_PROVIDER_INSTRUMENTATION: AtomicBool = AtomicBool::new(false);
542static DETERMINISTIC_PROVIDER_ENTRIES: AtomicUsize = AtomicUsize::new(0);
543static DETERMINISTIC_PROVIDER_MAX_CONCURRENT: AtomicUsize = AtomicUsize::new(0);
544
545pub fn enable_deterministic_provider_instrumentation() {
549 DETERMINISTIC_PROVIDER_INSTRUMENTATION.store(true, Ordering::Relaxed);
550}
551
552pub fn disable_deterministic_provider_instrumentation() {
555 DETERMINISTIC_PROVIDER_INSTRUMENTATION.store(false, Ordering::Relaxed);
556}
557
558pub fn deterministic_provider_max_concurrent() -> usize {
559 DETERMINISTIC_PROVIDER_MAX_CONCURRENT.load(Ordering::Relaxed)
560}
561
562pub fn reset_deterministic_provider_counters() {
563 DETERMINISTIC_PROVIDER_ENTRIES.store(0, Ordering::Relaxed);
564 DETERMINISTIC_PROVIDER_MAX_CONCURRENT.store(0, Ordering::Relaxed);
565}
566
567impl EmbeddingProvider for DeterministicEmbeddingProvider {
568 fn embed(
569 &self,
570 text: &str,
571 _input_type: Option<&str>,
572 ) -> Result<Vec<f32>, engram_llm_client::ApiError> {
573 let instrumented = DETERMINISTIC_PROVIDER_INSTRUMENTATION.load(Ordering::Relaxed);
580 if instrumented {
581 let entries = DETERMINISTIC_PROVIDER_ENTRIES.fetch_add(1, Ordering::SeqCst) + 1;
582 DETERMINISTIC_PROVIDER_MAX_CONCURRENT.fetch_max(entries, Ordering::SeqCst);
583 std::thread::sleep(std::time::Duration::from_millis(20));
584 }
585
586 let mut embedding = vec![0.0_f32; self.dimension];
587 for (index, byte) in text.bytes().enumerate() {
588 embedding[index % self.dimension] += byte as f32 / 255.0;
589 }
590 let norm: f32 = embedding
591 .iter()
592 .map(|v| v * v)
593 .sum::<f32>()
594 .sqrt()
595 .max(1e-10);
596 for value in &mut embedding {
597 *value /= norm;
598 }
599
600 if instrumented {
601 DETERMINISTIC_PROVIDER_ENTRIES.fetch_sub(1, Ordering::SeqCst);
602 }
603 Ok(embedding)
604 }
605
606 fn dimension(&self) -> usize {
607 self.dimension
608 }
609
610 fn model_name(&self) -> &str {
611 "deterministic"
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618
619 #[test]
620 fn deterministic_provider_ignores_input_type() {
621 let provider = DeterministicEmbeddingProvider { dimension: 8 };
622 let text = "rust ownership rules govern compile-time memory access";
623
624 let document = provider.embed(text, Some("document")).unwrap();
625 let query = provider.embed(text, Some("query")).unwrap();
626 let none = provider.embed(text, None).unwrap();
627
628 assert_eq!(document, query);
631 assert_eq!(document, none);
632 }
633
634 #[test]
635 fn embedding_config_deserializes_without_output_dimension() {
636 let toml_input = r#"
637 provider = "voyage"
638 model = "voyage-code-3"
639 dimension = 1024
640 "#;
641 let config: EmbeddingConfig = toml::from_str(toml_input).unwrap();
642 assert_eq!(config.provider, "voyage");
643 assert_eq!(config.output_dimension, None);
644 }
645
646 #[test]
647 fn embedding_config_deserializes_with_output_dimension() {
648 let toml_input = r#"
649 provider = "voyage"
650 model = "voyage-4"
651 dimension = 1024
652 output_dimension = 1024
653 "#;
654 let config: EmbeddingConfig = toml::from_str(toml_input).unwrap();
655 assert_eq!(config.output_dimension, Some(1024));
656 }
657
658 #[test]
659 fn embedding_config_deserializes_without_host() {
660 let toml_input = r#"
661 provider = "ollama"
662 model = "qwen3-embedding:0.6b"
663 dimension = 1024
664 "#;
665 let config: EmbeddingConfig = toml::from_str(toml_input).unwrap();
666 assert_eq!(config.host, None);
667 }
668
669 #[test]
670 fn embedding_config_deserializes_with_host() {
671 let toml_input = r#"
672 provider = "ollama"
673 model = "qwen3-embedding:0.6b"
674 dimension = 1024
675 host = "http://ollama.internal:11434"
676 "#;
677 let config: EmbeddingConfig = toml::from_str(toml_input).unwrap();
678 assert_eq!(config.host.as_deref(), Some("http://ollama.internal:11434"));
679 }
680
681 #[test]
682 fn build_embedding_provider_ollama_uses_prefixed_model_name() {
683 let mut config = Config::default();
684 config.embedding.provider = "ollama".into();
685 config.embedding.model = Some("qwen3-embedding:0.6b".into());
686 config.embedding.dimension = Some(1024);
687 config.embedding.host = Some("http://127.0.0.1:1".into());
688
689 let provider = config
690 .build_embedding_provider()
691 .expect("ollama provider builds without a live daemon");
692 assert_eq!(provider.model_name(), "ollama:qwen3-embedding:0.6b");
693 assert_eq!(provider.dimension(), 1024);
694 }
695
696 #[test]
697 fn default_dedup_threshold_is_documented_default() {
698 assert_eq!(Config::default().deduplication.threshold, 0.95);
699 }
700
701 #[test]
702 fn validate_dedup_threshold_rejects_zero() {
703 let error = validate_dedup_threshold(0.0).expect_err("zero is out of range");
704 assert!(matches!(error, CoreError::ConfigValidation(_)));
705 assert!(error.to_string().contains("[6022]"));
706 assert!(error.to_string().contains("0"));
707 }
708
709 #[test]
710 fn validate_dedup_threshold_rejects_negative() {
711 let error = validate_dedup_threshold(-0.5).expect_err("negative is out of range");
712 assert!(matches!(error, CoreError::ConfigValidation(_)));
713 assert!(error.to_string().contains("-0.5"));
714 }
715
716 #[test]
717 fn validate_dedup_threshold_rejects_above_one() {
718 let error = validate_dedup_threshold(1.0001).expect_err("above 1.0 is out of range");
719 assert!(matches!(error, CoreError::ConfigValidation(_)));
720 }
721
722 #[test]
723 fn validate_dedup_threshold_accepts_one() {
724 validate_dedup_threshold(1.0).expect("1.0 is the inclusive upper bound");
725 }
726
727 #[test]
728 fn validate_dedup_threshold_accepts_tiny_positive() {
729 validate_dedup_threshold(0.0001).expect("any positive within range is accepted");
730 }
731
732 #[test]
733 fn validate_fts_similarity_floor_accepts_zero_and_positive() {
734 validate_fts_similarity_floor(0.0).expect("0.0 disables the floor and is valid");
735 validate_fts_similarity_floor(4.5).expect("any finite non-negative floor is valid");
736 }
737
738 #[test]
739 fn validate_fts_similarity_floor_rejects_negative() {
740 let error = validate_fts_similarity_floor(-0.1).expect_err("negative is out of range");
741 assert!(matches!(error, CoreError::ConfigValidation(_)));
742 assert!(error.to_string().contains("-0.1"));
743 }
744
745 #[test]
746 fn validate_fts_similarity_floor_rejects_nan() {
747 let error = validate_fts_similarity_floor(f32::NAN).expect_err("NaN is not finite");
748 assert!(matches!(error, CoreError::ConfigValidation(_)));
749 }
750
751 #[test]
752 fn validate_fts_similarity_floor_rejects_infinity() {
753 let error =
754 validate_fts_similarity_floor(f32::INFINITY).expect_err("infinity is not finite");
755 assert!(matches!(error, CoreError::ConfigValidation(_)));
756 }
757
758 #[test]
759 fn default_search_config_matches_documented_values() {
760 let search = SearchConfig::default();
761 assert_eq!(search.rrf_k, 60);
762 assert_eq!(search.vector_weight, 0.7);
763 assert_eq!(search.sparse_weight, 0.3);
764 }
765
766 #[test]
767 fn config_loads_without_search_section() {
768 let toml_input = r#"
771 [embedding]
772 provider = "deterministic"
773
774 [llm]
775 provider = "openai"
776
777 [server]
778 reindex_interval_secs = 3600
779
780 [hnsw]
781 max_connections = 16
782 ef_construction = 200
783 ef_search = 40
784 dimension = 1024
785 "#;
786 let config: Config = toml::from_str(toml_input).unwrap();
787 assert_eq!(config.search.rrf_k, 60);
788 assert_eq!(config.search.vector_weight, 0.7);
789 assert_eq!(config.search.sparse_weight, 0.3);
790 }
791
792 #[test]
793 fn search_config_round_trips_through_toml() {
794 let toml_input = r#"
795 rrf_k = 30
796 vector_weight = 0.6
797 sparse_weight = 0.4
798 "#;
799 let search: SearchConfig = toml::from_str(toml_input).unwrap();
800 assert_eq!(search.rrf_k, 30);
801 assert_eq!(search.vector_weight, 0.6);
802 assert_eq!(search.sparse_weight, 0.4);
803 }
804
805 #[test]
806 fn validate_search_config_accepts_defaults() {
807 validate_search_config(&SearchConfig::default()).expect("documented defaults are valid");
808 }
809
810 #[test]
811 fn validate_search_config_accepts_one_zero_weight() {
812 let search = SearchConfig {
813 rrf_k: 60,
814 vector_weight: 0.0,
815 sparse_weight: 0.3,
816 };
817 validate_search_config(&search).expect("a single zero weight still leaves one live term");
818 }
819
820 #[test]
821 fn validate_search_config_rejects_zero_rrf_k() {
822 let search = SearchConfig {
823 rrf_k: 0,
824 ..SearchConfig::default()
825 };
826 let error = validate_search_config(&search).expect_err("rrf_k of 0 divides by k+rank base");
827 assert!(matches!(error, CoreError::ConfigValidation(_)));
828 assert!(error.to_string().contains("rrf_k"));
829 }
830
831 #[test]
832 fn validate_search_config_rejects_negative_weight() {
833 let search = SearchConfig {
834 rrf_k: 60,
835 vector_weight: -0.1,
836 sparse_weight: 0.3,
837 };
838 let error = validate_search_config(&search).expect_err("negative weight is invalid");
839 assert!(matches!(error, CoreError::ConfigValidation(_)));
840 assert!(error.to_string().contains("non-negative"));
841 }
842
843 #[test]
844 fn validate_search_config_rejects_non_finite_weight() {
845 let search = SearchConfig {
846 rrf_k: 60,
847 vector_weight: f64::NAN,
848 sparse_weight: 0.3,
849 };
850 let error = validate_search_config(&search).expect_err("NaN weight is invalid");
851 assert!(matches!(error, CoreError::ConfigValidation(_)));
852 assert!(error.to_string().contains("finite"));
853 }
854
855 #[test]
856 fn validate_search_config_rejects_all_zero_weights() {
857 let search = SearchConfig {
858 rrf_k: 60,
859 vector_weight: 0.0,
860 sparse_weight: 0.0,
861 };
862 let error = validate_search_config(&search).expect_err("both weights zero cancels output");
863 assert!(matches!(error, CoreError::ConfigValidation(_)));
864 }
865
866 #[test]
867 fn build_text_generator_ollama_uses_prefixed_model_name() {
868 let mut config = Config::default();
869 config.llm.provider = "ollama".into();
870 config.llm.model = Some("qwen3:4b".into());
871 config.llm.host = Some("http://127.0.0.1:1".into());
872 config.llm.api_key = None;
873
874 let generator = config
875 .build_text_generator()
876 .expect("ollama text generator builds without a live daemon or api key");
877 assert_eq!(generator.model_name(), "ollama:qwen3:4b");
878 }
879
880 #[test]
881 fn build_text_generator_ollama_builds_without_host() {
882 let mut config = Config::default();
883 config.llm.provider = "ollama".into();
884 config.llm.model = Some("qwen3:4b".into());
885 config.llm.host = None;
886 config.llm.api_key = None;
887
888 let generator = config
889 .build_text_generator()
890 .expect("ollama text generator falls back to the default host");
891 assert_eq!(generator.model_name(), "ollama:qwen3:4b");
892 }
893
894 #[test]
895 fn llm_config_deserializes_without_host() {
896 let toml_input = r#"
897 provider = "ollama"
898 model = "qwen3:4b"
899 "#;
900 let config: LlmConfig = toml::from_str(toml_input).unwrap();
901 assert_eq!(config.host, None);
902 }
903
904 #[test]
905 fn llm_config_deserializes_with_host() {
906 let toml_input = r#"
907 provider = "ollama"
908 model = "qwen3:4b"
909 host = "http://ollama.internal:11434"
910 "#;
911 let config: LlmConfig = toml::from_str(toml_input).unwrap();
912 assert_eq!(config.host.as_deref(), Some("http://ollama.internal:11434"));
913 }
914}