Skip to main content

engram_core/
config.rs

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
18/// Path of an `engram.toml` relative to its `.engram/` parent. Used for both
19/// the global config under `$HOME` and project-local configs under a
20/// discovered project root.
21pub(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    /// Legacy fallback used only when no project `.engram/` marker is found and
70    /// `ENGRAM_DB_PATH` is not set. Runtime resolution always prefers the
71    /// per-project layout (`<project>/.engram/engram.db`), so for normal use
72    /// this can be `None`. Kept for backward-compat TOML parsing.
73    #[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    /// Voyage-4 output dimension (256/512/1024/2048 via Matryoshka). Omit to
84    /// let the API choose its default (1024 for voyage-4). Ignored by
85    /// non-Voyage providers. Must match `[hnsw].dimension` when set —
86    /// mismatch fails fast at HNSW insert with `[3002] DimensionMismatch`.
87    #[serde(default)]
88    pub output_dimension: Option<usize>,
89    /// Base URL of the Ollama daemon for the `ollama` provider (e.g.
90    /// `http://localhost:11434`). Ignored by non-Ollama providers. Omit to use
91    /// `DEFAULT_EMBEDDING_HOST`; override at runtime with `ENGRAM_OLLAMA_HOST`.
92    #[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    /// Base URL of the Ollama daemon for the `ollama` provider (e.g.
104    /// `http://localhost:11434`). Ignored by non-Ollama providers. Omit to use
105    /// `DEFAULT_OLLAMA_HOST`; override at runtime with `ENGRAM_OLLAMA_HOST`.
106    #[serde(default)]
107    pub host: Option<String>,
108}
109
110#[derive(Serialize, Deserialize, Clone)]
111pub struct ServerConfig {
112    /// Legacy fallback socket path. Used only when no project `.engram/`
113    /// marker is found and `ENGRAM_SOCKET_PATH` is not set. Runtime prefers
114    /// the per-project socket at `<project>/.engram/engram.sock`.
115    #[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/// Hybrid-search blend parameters. `rrf_k` is the Reciprocal Rank Fusion
140/// smoothing constant (larger values flatten the rank-position weighting);
141/// `vector_weight` and `sparse_weight` scale the dense-vector and full-text
142/// contributions before fusion.
143#[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
194/// Validates a configured deduplication threshold lies in the half-open
195/// cosine-similarity range `(0.0, 1.0]`. A threshold of `0.0` or below would
196/// treat unrelated memories as duplicates; above `1.0` is unreachable for
197/// normalized embeddings and signals a misconfiguration.
198pub 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
207/// Validates the hybrid-search blend parameters. `rrf_k` must be non-zero
208/// (it appears in the Reciprocal Rank Fusion denominator `k + rank`), both
209/// weights must be finite and non-negative, and at least one weight must be
210/// positive — two zero weights would cancel every contribution and yield an
211/// empty ranking regardless of how many results each index returns. NaN/Inf
212/// weights are rejected outright (NaN passes a naive `< 0.0` range check).
213pub 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    /// Loads the effective config by layering project-local `engram.toml`
240    /// over the global `~/.engram/engram.toml`, then applying `ENGRAM_*` env
241    /// overrides. Final priority is `env > project-local > global`.
242    ///
243    /// When neither config file exists the built-in defaults are used. The
244    /// `api_key` of each provider is always taken from the global layer — a
245    /// project-local config can never set or change a secret.
246    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        // ENGRAM_DB_PATH / ENGRAM_SOCKET_PATH are honored directly by server.rs
386        // (resolve_database_path / resolve_socket_path). Copying them onto
387        // `self.database.path` / `self.server.socket_path` here would be redundant
388        // and could mask the per-project layout derived from `project_dir`.
389        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        // `PathBuf::pop()` returns false at filesystem root, which terminates this loop safely.
489        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
513// Test instrumentation for the deterministic provider. Gated behind
514// `DETERMINISTIC_PROVIDER_INSTRUMENTATION`, which defaults to OFF so
515// production users (TUI demo mode, local setups without API keys) pay
516// zero overhead. Tests call `enable_deterministic_provider_instrumentation`
517// before parallel assertions and `disable_deterministic_provider_instrumentation`
518// in teardown (typically via a drop-guard).
519static 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
523/// Enable test-only instrumentation (counter + 20ms sleep) on the deterministic
524/// embedding provider. Off in production by default; tests enable it before
525/// reading concurrency counters.
526pub fn enable_deterministic_provider_instrumentation() {
527    DETERMINISTIC_PROVIDER_INSTRUMENTATION.store(true, Ordering::Relaxed);
528}
529
530/// Disable instrumentation. Tests should call this in teardown to avoid leaking
531/// state across serially-run suites.
532pub 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        // Test-only instrumentation: when enabled via
552        // `enable_deterministic_provider_instrumentation`, each embed call
553        // records concurrent entries and sleeps 20ms to widen the overlap
554        // window for parallelism assertions. Production users never enable
555        // this — a relaxed atomic load is ~1ns, so overhead is effectively
556        // zero otherwise.
557        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        // Deterministic provider must yield byte-identical vectors regardless
607        // of input_type — test fixtures rely on this for assertion stability.
608        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        // The [search] table is serde-defaulted, so a TOML predating Phase 11
721        // must still deserialize and fall back to the documented defaults.
722        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}