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_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    /// Legacy fallback used only when no project `.engram/` marker is found and
71    /// `ENGRAM_DB_PATH` is not set. Runtime resolution always prefers the
72    /// per-project layout (`<project>/.engram/engram.db`), so for normal use
73    /// this can be `None`. Kept for backward-compat TOML parsing.
74    #[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    /// Voyage-4 output dimension (256/512/1024/2048 via Matryoshka). Omit to
85    /// let the API choose its default (1024 for voyage-4). Ignored by
86    /// non-Voyage providers. Must match `[hnsw].dimension` when set —
87    /// mismatch fails fast at HNSW insert with `[3002] DimensionMismatch`.
88    #[serde(default)]
89    pub output_dimension: Option<usize>,
90    /// Base URL of the Ollama daemon for the `ollama` provider (e.g.
91    /// `http://localhost:11434`). Ignored by non-Ollama providers. Omit to use
92    /// `DEFAULT_EMBEDDING_HOST`; override at runtime with `ENGRAM_OLLAMA_HOST`.
93    #[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    /// Base URL of the Ollama daemon for the `ollama` provider (e.g.
105    /// `http://localhost:11434`). Ignored by non-Ollama providers. Omit to use
106    /// `DEFAULT_OLLAMA_HOST`; override at runtime with `ENGRAM_OLLAMA_HOST`.
107    #[serde(default)]
108    pub host: Option<String>,
109}
110
111#[derive(Serialize, Deserialize, Clone)]
112pub struct ServerConfig {
113    /// Legacy fallback socket path. Used only when no project `.engram/`
114    /// marker is found and `ENGRAM_SOCKET_PATH` is not set. Runtime prefers
115    /// the per-project socket at `<project>/.engram/engram.sock`.
116    #[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    /// Minimum mean |bm25| rank an FTS duplicate group must reach to appear
134    /// in consolidation preview. Same unit as the `similarity` reported for
135    /// `match_type == "fts"` groups (NOT cosine; non-negative, unbounded
136    /// above). `0.0` disables the floor; exact groups are never filtered.
137    #[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/// Hybrid-search blend parameters. `rrf_k` is the Reciprocal Rank Fusion
147/// smoothing constant (larger values flatten the rank-position weighting);
148/// `vector_weight` and `sparse_weight` scale the dense-vector and full-text
149/// contributions before fusion.
150#[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
202/// Validates a configured deduplication threshold lies in the half-open
203/// cosine-similarity range `(0.0, 1.0]`. A threshold of `0.0` or below would
204/// treat unrelated memories as duplicates; above `1.0` is unreachable for
205/// normalized embeddings and signals a misconfiguration.
206pub 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
215/// Validates the consolidation FTS similarity floor. The floor is compared
216/// against a mean |bm25| rank (non-negative, unbounded above), so any finite
217/// non-negative value is meaningful. NaN/Inf are rejected explicitly — TOML
218/// 1.0 parses `nan` and `inf`, and a NaN floor would silently disable every
219/// comparison.
220pub 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
229/// Validates the hybrid-search blend parameters. `rrf_k` must be non-zero
230/// (it appears in the Reciprocal Rank Fusion denominator `k + rank`), both
231/// weights must be finite and non-negative, and at least one weight must be
232/// positive — two zero weights would cancel every contribution and yield an
233/// empty ranking regardless of how many results each index returns. NaN/Inf
234/// weights are rejected outright (NaN passes a naive `< 0.0` range check).
235pub 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    /// Loads the effective config by layering project-local `engram.toml`
262    /// over the global `~/.engram/engram.toml`, then applying `ENGRAM_*` env
263    /// overrides. Final priority is `env > project-local > global`.
264    ///
265    /// When neither config file exists the built-in defaults are used. The
266    /// `api_key` of each provider is always taken from the global layer — a
267    /// project-local config can never set or change a secret.
268    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        // ENGRAM_DB_PATH / ENGRAM_SOCKET_PATH are honored directly by server.rs
408        // (resolve_database_path / resolve_socket_path). Copying them onto
409        // `self.database.path` / `self.server.socket_path` here would be redundant
410        // and could mask the per-project layout derived from `project_dir`.
411        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        // `PathBuf::pop()` returns false at filesystem root, which terminates this loop safely.
511        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
535// Test instrumentation for the deterministic provider. Gated behind
536// `DETERMINISTIC_PROVIDER_INSTRUMENTATION`, which defaults to OFF so
537// production users (TUI demo mode, local setups without API keys) pay
538// zero overhead. Tests call `enable_deterministic_provider_instrumentation`
539// before parallel assertions and `disable_deterministic_provider_instrumentation`
540// in teardown (typically via a drop-guard).
541static 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
545/// Enable test-only instrumentation (counter + 20ms sleep) on the deterministic
546/// embedding provider. Off in production by default; tests enable it before
547/// reading concurrency counters.
548pub fn enable_deterministic_provider_instrumentation() {
549    DETERMINISTIC_PROVIDER_INSTRUMENTATION.store(true, Ordering::Relaxed);
550}
551
552/// Disable instrumentation. Tests should call this in teardown to avoid leaking
553/// state across serially-run suites.
554pub 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        // Test-only instrumentation: when enabled via
574        // `enable_deterministic_provider_instrumentation`, each embed call
575        // records concurrent entries and sleeps 20ms to widen the overlap
576        // window for parallelism assertions. Production users never enable
577        // this — a relaxed atomic load is ~1ns, so overhead is effectively
578        // zero otherwise.
579        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        // Deterministic provider must yield byte-identical vectors regardless
629        // of input_type — test fixtures rely on this for assertion stability.
630        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        // The [search] table is serde-defaulted, so a TOML predating Phase 11
769        // must still deserialize and fall back to the documented defaults.
770        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}