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