use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use serde::{Deserialize, Serialize};
use engram_hnsw::HnswParams;
use engram_llm_client::{
EmbeddingProvider, LocalTextGenerator, OllamaEmbeddingProvider, OllamaTextGenerator,
OpenAITextGenerator, TextGenerator, VoyageEmbeddingProvider,
};
use crate::config_loader::{
deep_merge, load_global_config_tree, load_project_config_tree, restore_secret, secret_at,
};
use crate::error::CoreError;
pub(crate) const ENGRAM_CONFIG_SUBPATH: &str = ".engram/engram.toml";
const PROJECT_DIR_MARKER: &str = ".engram";
const EMBEDDING_SECTION: &str = "embedding";
const LLM_SECTION: &str = "llm";
const DEFAULT_EMBEDDING_PROVIDER: &str = "voyage";
pub const DEFAULT_EMBEDDING_MODEL: &str = "voyage-4";
const DEFAULT_EMBEDDING_DIMENSION: usize = 1024;
pub const DEFAULT_OLLAMA_HOST: &str = "http://localhost:11434";
pub const DEFAULT_EMBEDDING_HOST: &str = DEFAULT_OLLAMA_HOST;
const DEFAULT_HYDE_THRESHOLD: usize = 0;
const DEFAULT_LLM_PROVIDER: &str = "openai";
const DEFAULT_LLM_MODEL: &str = "gpt-4o-mini";
const DEFAULT_REINDEX_INTERVAL_SECS: u64 = 3600;
const DEFAULT_HNSW_MAX_CONNECTIONS: usize = 16;
const DEFAULT_HNSW_EF_CONSTRUCTION: usize = 200;
const DEFAULT_HNSW_EF_SEARCH: usize = 40;
const DEFAULT_CONSOLIDATION_STALE_DAYS: u32 = 90;
const DEFAULT_CONSOLIDATION_MIN_SCORE: f64 = 0.3;
const DEFAULT_CONSOLIDATION_FTS_SIMILARITY_FLOOR: f32 = 0.0;
const DEFAULT_DEDUP_THRESHOLD: f32 = 0.95;
const DEFAULT_TRAINER_BINARY: &str = "engram-trainer";
const DEFAULT_TRAINER_TIMEOUT_SECS: u64 = 300;
const DEFAULT_MODELS_PATH: &str = "~/.engram/models";
const DEFAULT_RRF_K: usize = 60;
const DEFAULT_VECTOR_WEIGHT: f64 = 0.7;
const DEFAULT_SPARSE_WEIGHT: f64 = 0.3;
#[derive(Serialize, Deserialize, Clone)]
pub struct Config {
#[serde(default)]
pub database: DatabaseConfig,
pub embedding: EmbeddingConfig,
pub llm: LlmConfig,
pub server: ServerConfig,
pub hnsw: HnswConfig,
#[serde(default)]
pub consolidation: ConsolidationConfig,
#[serde(default)]
pub deduplication: DeduplicationConfig,
#[serde(default)]
pub trainer: TrainerConfig,
#[serde(default)]
pub search: SearchConfig,
}
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct DatabaseConfig {
#[serde(default)]
pub path: Option<String>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct EmbeddingConfig {
pub provider: String,
pub api_key: Option<String>,
pub model: Option<String>,
pub dimension: Option<usize>,
#[serde(default)]
pub output_dimension: Option<usize>,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub hyde_threshold: usize,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct LlmConfig {
pub provider: String,
pub api_key: Option<String>,
pub model: Option<String>,
#[serde(default)]
pub host: Option<String>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ServerConfig {
#[serde(default)]
pub socket_path: Option<String>,
pub reindex_interval_secs: u64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct HnswConfig {
pub max_connections: usize,
pub ef_construction: usize,
pub ef_search: usize,
pub dimension: usize,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ConsolidationConfig {
pub stale_days: u32,
pub min_score: f64,
#[serde(default)]
pub fts_similarity_floor: f32,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct DeduplicationConfig {
pub threshold: f32,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct SearchConfig {
pub rrf_k: usize,
pub vector_weight: f64,
pub sparse_weight: f64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct TrainerConfig {
pub trainer_binary: String,
pub trainer_timeout_secs: u64,
pub models_path: String,
}
impl Default for TrainerConfig {
fn default() -> Self {
Self {
trainer_binary: DEFAULT_TRAINER_BINARY.into(),
trainer_timeout_secs: DEFAULT_TRAINER_TIMEOUT_SECS,
models_path: DEFAULT_MODELS_PATH.into(),
}
}
}
impl Default for ConsolidationConfig {
fn default() -> Self {
Self {
stale_days: DEFAULT_CONSOLIDATION_STALE_DAYS,
min_score: DEFAULT_CONSOLIDATION_MIN_SCORE,
fts_similarity_floor: DEFAULT_CONSOLIDATION_FTS_SIMILARITY_FLOOR,
}
}
}
impl Default for DeduplicationConfig {
fn default() -> Self {
Self {
threshold: DEFAULT_DEDUP_THRESHOLD,
}
}
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
rrf_k: DEFAULT_RRF_K,
vector_weight: DEFAULT_VECTOR_WEIGHT,
sparse_weight: DEFAULT_SPARSE_WEIGHT,
}
}
}
pub fn validate_dedup_threshold(threshold: f32) -> Result<(), CoreError> {
if threshold > 0.0 && threshold <= 1.0 {
return Ok(());
}
Err(CoreError::ConfigValidation(format!(
"deduplication.threshold must be in (0.0, 1.0], got {threshold}"
)))
}
pub fn validate_fts_similarity_floor(floor: f32) -> Result<(), CoreError> {
if floor.is_finite() && floor >= 0.0 {
return Ok(());
}
Err(CoreError::ConfigValidation(format!(
"consolidation.fts_similarity_floor must be finite and >= 0.0, got {floor}"
)))
}
pub fn validate_search_config(config: &SearchConfig) -> Result<(), CoreError> {
if config.rrf_k == 0 {
return Err(CoreError::ConfigValidation(
"search.rrf_k must be greater than 0".to_string(),
));
}
if !config.vector_weight.is_finite() || !config.sparse_weight.is_finite() {
return Err(CoreError::ConfigValidation(
"search weights must be finite".to_string(),
));
}
if config.vector_weight < 0.0 || config.sparse_weight < 0.0 {
return Err(CoreError::ConfigValidation(format!(
"search weights must be non-negative, got vector_weight={}, sparse_weight={}",
config.vector_weight, config.sparse_weight
)));
}
if config.vector_weight == 0.0 && config.sparse_weight == 0.0 {
return Err(CoreError::ConfigValidation(
"search.vector_weight and search.sparse_weight cannot both be 0.0".to_string(),
));
}
Ok(())
}
impl Config {
pub fn load() -> Result<Self, CoreError> {
let global_tree = load_global_config_tree()?;
let project_tree = load_project_config_tree()?;
if global_tree.is_none() && project_tree.is_none() {
let mut config = Self::default();
config.apply_env_overrides();
return Ok(config);
}
let mut merged_tree = match global_tree {
Some(tree) => tree,
None => toml::Value::try_from(Self::default())
.map_err(|error| CoreError::ConfigParseError(error.to_string()))?,
};
let global_embedding_secret = secret_at(&merged_tree, EMBEDDING_SECTION);
let global_llm_secret = secret_at(&merged_tree, LLM_SECTION);
if let Some(project_tree) = project_tree {
deep_merge(&mut merged_tree, project_tree);
}
restore_secret(&mut merged_tree, EMBEDDING_SECTION, global_embedding_secret);
restore_secret(&mut merged_tree, LLM_SECTION, global_llm_secret);
let mut config: Config = merged_tree
.try_into()
.map_err(|error: toml::de::Error| CoreError::ConfigParseError(error.to_string()))?;
config.apply_env_overrides();
Ok(config)
}
pub fn load_from_path(path: &str) -> Result<Self, CoreError> {
let content = fs::read_to_string(path).map_err(|_| CoreError::ConfigNotFound)?;
let mut config: Config = toml::from_str(&content)
.map_err(|error| CoreError::ConfigParseError(error.to_string()))?;
config.apply_env_overrides();
Ok(config)
}
pub fn build_embedding_provider(
&self,
) -> Result<Box<dyn EmbeddingProvider + Send + Sync>, CoreError> {
let dimension = self
.embedding
.dimension
.unwrap_or(DEFAULT_EMBEDDING_DIMENSION);
match self.embedding.provider.as_str() {
"voyage" => {
let api_key = self
.embedding
.api_key
.as_deref()
.filter(|key| !key.is_empty())
.ok_or_else(|| CoreError::InvalidProvider("voyage requires api_key".into()))?;
let model = self
.embedding
.model
.clone()
.unwrap_or_else(|| DEFAULT_EMBEDDING_MODEL.into());
let provider = VoyageEmbeddingProvider::with_config(
api_key.to_owned(),
model,
dimension,
self.embedding.output_dimension,
engram_llm_client::RetryConfig::default(),
"https://api.voyageai.com".into(),
)?;
Ok(Box::new(provider))
}
"ollama" => {
let host = self
.embedding
.host
.clone()
.unwrap_or_else(|| DEFAULT_EMBEDDING_HOST.into());
let model = self
.embedding
.model
.clone()
.unwrap_or_else(|| engram_llm_client::ollama::DEFAULT_OLLAMA_MODEL.into());
let provider = OllamaEmbeddingProvider::new(host, model, dimension)?;
Ok(Box::new(provider))
}
"deterministic" => Ok(Box::new(DeterministicEmbeddingProvider { dimension })),
other => Err(CoreError::InvalidProvider(format!(
"{other} embedding not supported"
))),
}
}
pub fn build_text_generator(&self) -> Result<Box<dyn TextGenerator + Send + Sync>, CoreError> {
match self.llm.provider.as_str() {
"openai" => {
let api_key = self
.llm
.api_key
.as_deref()
.filter(|key| !key.is_empty())
.ok_or_else(|| CoreError::InvalidProvider("openai requires api_key".into()))?;
let generator = OpenAITextGenerator::new(api_key.to_owned())?;
Ok(Box::new(generator))
}
"local" => {
let models_path = expand_tilde(&self.trainer.models_path);
let model_path = format!("{models_path}/text_generator.onnx");
let tokenizer_path = format!("{models_path}/tokenizer.json");
let generator = LocalTextGenerator::new(&model_path, &tokenizer_path)
.map_err(|error| CoreError::InvalidProvider(error.to_string()))?;
Ok(Box::new(generator))
}
"ollama" => {
let host = self
.llm
.host
.clone()
.unwrap_or_else(|| DEFAULT_OLLAMA_HOST.into());
let model = self.llm.model.clone().unwrap_or_else(|| {
engram_llm_client::ollama_text::DEFAULT_OLLAMA_LLM_MODEL.into()
});
let generator = OllamaTextGenerator::new(host, model)?;
Ok(Box::new(generator))
}
other => Err(CoreError::InvalidProvider(format!(
"{other} llm not supported"
))),
}
}
pub fn build_hnsw_params(&self) -> Result<HnswParams, CoreError> {
HnswParams::new(self.hnsw.dimension)?
.with_max_connections(self.hnsw.max_connections)?
.with_ef_construction(self.hnsw.ef_construction)?
.with_ef_search(self.hnsw.ef_search)
.map_err(CoreError::Hnsw)
}
fn apply_env_overrides(&mut self) {
if let Ok(value) = std::env::var("ENGRAM_EMBEDDING_MODEL") {
self.embedding.model = Some(value);
}
if let Ok(value) = std::env::var("ENGRAM_LLM_MODEL") {
self.llm.model = Some(value);
}
if let Ok(value) = std::env::var("ENGRAM_TRAINER_BINARY") {
self.trainer.trainer_binary = value;
}
if let Ok(value) = std::env::var("ENGRAM_TRAINER_TIMEOUT")
&& let Ok(secs) = value.parse::<u64>()
{
self.trainer.trainer_timeout_secs = secs;
}
if let Ok(value) = std::env::var("ENGRAM_MODELS_PATH") {
self.trainer.models_path = value;
}
self.apply_provider_api_key_overrides();
}
fn apply_provider_api_key_overrides(&mut self) {
if self.embedding.provider == "voyage"
&& let Ok(value) = std::env::var("ENGRAM_VOYAGE_API_KEY")
{
self.embedding.api_key = Some(value);
}
if self.embedding.provider == "ollama"
&& let Ok(value) = std::env::var("ENGRAM_OLLAMA_HOST")
{
self.embedding.host = Some(value);
}
if self.llm.provider == "ollama"
&& let Ok(value) = std::env::var("ENGRAM_OLLAMA_HOST")
{
self.llm.host = Some(value);
}
if self.llm.provider == "openai"
&& let Ok(value) = std::env::var("ENGRAM_OPENAI_API_KEY")
{
self.llm.api_key = Some(value);
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
database: DatabaseConfig { path: None },
embedding: EmbeddingConfig {
provider: DEFAULT_EMBEDDING_PROVIDER.into(),
api_key: None,
model: Some(DEFAULT_EMBEDDING_MODEL.into()),
dimension: Some(DEFAULT_EMBEDDING_DIMENSION),
output_dimension: None,
host: None,
hyde_threshold: DEFAULT_HYDE_THRESHOLD,
},
llm: LlmConfig {
provider: DEFAULT_LLM_PROVIDER.into(),
api_key: None,
model: Some(DEFAULT_LLM_MODEL.into()),
host: None,
},
server: ServerConfig {
socket_path: None,
reindex_interval_secs: DEFAULT_REINDEX_INTERVAL_SECS,
},
hnsw: HnswConfig {
max_connections: DEFAULT_HNSW_MAX_CONNECTIONS,
ef_construction: DEFAULT_HNSW_EF_CONSTRUCTION,
ef_search: DEFAULT_HNSW_EF_SEARCH,
dimension: DEFAULT_EMBEDDING_DIMENSION,
},
consolidation: ConsolidationConfig::default(),
deduplication: DeduplicationConfig::default(),
trainer: TrainerConfig::default(),
search: SearchConfig::default(),
}
}
}
pub fn resolve_project_dir(
start: &Path,
explicit_override: Option<&Path>,
) -> Result<PathBuf, CoreError> {
if let Some(path) = explicit_override {
return Ok(path.to_path_buf());
}
if let Ok(env_path) = std::env::var("ENGRAM_PROJECT_DIR") {
let candidate = PathBuf::from(env_path);
if candidate.is_absolute() {
return Ok(candidate);
}
}
let mut current = start.to_path_buf();
loop {
if current.join(PROJECT_DIR_MARKER).is_dir() {
return Ok(current);
}
if !current.pop() {
break;
}
}
Err(CoreError::ProjectDirNotFound)
}
pub fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/")
&& let Some(home) = home_directory()
{
return format!("{home}/{rest}");
}
path.to_string()
}
pub fn home_directory() -> Option<String> {
std::env::var("HOME").ok()
}
struct DeterministicEmbeddingProvider {
dimension: usize,
}
static DETERMINISTIC_PROVIDER_INSTRUMENTATION: AtomicBool = AtomicBool::new(false);
static DETERMINISTIC_PROVIDER_ENTRIES: AtomicUsize = AtomicUsize::new(0);
static DETERMINISTIC_PROVIDER_MAX_CONCURRENT: AtomicUsize = AtomicUsize::new(0);
pub fn enable_deterministic_provider_instrumentation() {
DETERMINISTIC_PROVIDER_INSTRUMENTATION.store(true, Ordering::Relaxed);
}
pub fn disable_deterministic_provider_instrumentation() {
DETERMINISTIC_PROVIDER_INSTRUMENTATION.store(false, Ordering::Relaxed);
}
pub fn deterministic_provider_max_concurrent() -> usize {
DETERMINISTIC_PROVIDER_MAX_CONCURRENT.load(Ordering::Relaxed)
}
pub fn reset_deterministic_provider_counters() {
DETERMINISTIC_PROVIDER_ENTRIES.store(0, Ordering::Relaxed);
DETERMINISTIC_PROVIDER_MAX_CONCURRENT.store(0, Ordering::Relaxed);
}
impl EmbeddingProvider for DeterministicEmbeddingProvider {
fn embed(
&self,
text: &str,
_input_type: Option<&str>,
) -> Result<Vec<f32>, engram_llm_client::ApiError> {
let instrumented = DETERMINISTIC_PROVIDER_INSTRUMENTATION.load(Ordering::Relaxed);
if instrumented {
let entries = DETERMINISTIC_PROVIDER_ENTRIES.fetch_add(1, Ordering::SeqCst) + 1;
DETERMINISTIC_PROVIDER_MAX_CONCURRENT.fetch_max(entries, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(20));
}
let mut embedding = vec![0.0_f32; self.dimension];
for (index, byte) in text.bytes().enumerate() {
embedding[index % self.dimension] += byte as f32 / 255.0;
}
let norm: f32 = embedding
.iter()
.map(|v| v * v)
.sum::<f32>()
.sqrt()
.max(1e-10);
for value in &mut embedding {
*value /= norm;
}
if instrumented {
DETERMINISTIC_PROVIDER_ENTRIES.fetch_sub(1, Ordering::SeqCst);
}
Ok(embedding)
}
fn dimension(&self) -> usize {
self.dimension
}
fn model_name(&self) -> &str {
"deterministic"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic_provider_ignores_input_type() {
let provider = DeterministicEmbeddingProvider { dimension: 8 };
let text = "rust ownership rules govern compile-time memory access";
let document = provider.embed(text, Some("document")).unwrap();
let query = provider.embed(text, Some("query")).unwrap();
let none = provider.embed(text, None).unwrap();
assert_eq!(document, query);
assert_eq!(document, none);
}
#[test]
fn embedding_config_deserializes_without_output_dimension() {
let toml_input = r#"
provider = "voyage"
model = "voyage-code-3"
dimension = 1024
"#;
let config: EmbeddingConfig = toml::from_str(toml_input).unwrap();
assert_eq!(config.provider, "voyage");
assert_eq!(config.output_dimension, None);
}
#[test]
fn embedding_config_deserializes_with_output_dimension() {
let toml_input = r#"
provider = "voyage"
model = "voyage-4"
dimension = 1024
output_dimension = 1024
"#;
let config: EmbeddingConfig = toml::from_str(toml_input).unwrap();
assert_eq!(config.output_dimension, Some(1024));
}
#[test]
fn embedding_config_deserializes_without_host() {
let toml_input = r#"
provider = "ollama"
model = "qwen3-embedding:0.6b"
dimension = 1024
"#;
let config: EmbeddingConfig = toml::from_str(toml_input).unwrap();
assert_eq!(config.host, None);
}
#[test]
fn embedding_config_deserializes_with_host() {
let toml_input = r#"
provider = "ollama"
model = "qwen3-embedding:0.6b"
dimension = 1024
host = "http://ollama.internal:11434"
"#;
let config: EmbeddingConfig = toml::from_str(toml_input).unwrap();
assert_eq!(config.host.as_deref(), Some("http://ollama.internal:11434"));
}
#[test]
fn build_embedding_provider_ollama_uses_prefixed_model_name() {
let mut config = Config::default();
config.embedding.provider = "ollama".into();
config.embedding.model = Some("qwen3-embedding:0.6b".into());
config.embedding.dimension = Some(1024);
config.embedding.host = Some("http://127.0.0.1:1".into());
let provider = config
.build_embedding_provider()
.expect("ollama provider builds without a live daemon");
assert_eq!(provider.model_name(), "ollama:qwen3-embedding:0.6b");
assert_eq!(provider.dimension(), 1024);
}
#[test]
fn default_dedup_threshold_is_documented_default() {
assert_eq!(Config::default().deduplication.threshold, 0.95);
}
#[test]
fn validate_dedup_threshold_rejects_zero() {
let error = validate_dedup_threshold(0.0).expect_err("zero is out of range");
assert!(matches!(error, CoreError::ConfigValidation(_)));
assert!(error.to_string().contains("[6022]"));
assert!(error.to_string().contains("0"));
}
#[test]
fn validate_dedup_threshold_rejects_negative() {
let error = validate_dedup_threshold(-0.5).expect_err("negative is out of range");
assert!(matches!(error, CoreError::ConfigValidation(_)));
assert!(error.to_string().contains("-0.5"));
}
#[test]
fn validate_dedup_threshold_rejects_above_one() {
let error = validate_dedup_threshold(1.0001).expect_err("above 1.0 is out of range");
assert!(matches!(error, CoreError::ConfigValidation(_)));
}
#[test]
fn validate_dedup_threshold_accepts_one() {
validate_dedup_threshold(1.0).expect("1.0 is the inclusive upper bound");
}
#[test]
fn validate_dedup_threshold_accepts_tiny_positive() {
validate_dedup_threshold(0.0001).expect("any positive within range is accepted");
}
#[test]
fn validate_fts_similarity_floor_accepts_zero_and_positive() {
validate_fts_similarity_floor(0.0).expect("0.0 disables the floor and is valid");
validate_fts_similarity_floor(4.5).expect("any finite non-negative floor is valid");
}
#[test]
fn validate_fts_similarity_floor_rejects_negative() {
let error = validate_fts_similarity_floor(-0.1).expect_err("negative is out of range");
assert!(matches!(error, CoreError::ConfigValidation(_)));
assert!(error.to_string().contains("-0.1"));
}
#[test]
fn validate_fts_similarity_floor_rejects_nan() {
let error = validate_fts_similarity_floor(f32::NAN).expect_err("NaN is not finite");
assert!(matches!(error, CoreError::ConfigValidation(_)));
}
#[test]
fn validate_fts_similarity_floor_rejects_infinity() {
let error =
validate_fts_similarity_floor(f32::INFINITY).expect_err("infinity is not finite");
assert!(matches!(error, CoreError::ConfigValidation(_)));
}
#[test]
fn default_search_config_matches_documented_values() {
let search = SearchConfig::default();
assert_eq!(search.rrf_k, 60);
assert_eq!(search.vector_weight, 0.7);
assert_eq!(search.sparse_weight, 0.3);
}
#[test]
fn config_loads_without_search_section() {
let toml_input = r#"
[embedding]
provider = "deterministic"
[llm]
provider = "openai"
[server]
reindex_interval_secs = 3600
[hnsw]
max_connections = 16
ef_construction = 200
ef_search = 40
dimension = 1024
"#;
let config: Config = toml::from_str(toml_input).unwrap();
assert_eq!(config.search.rrf_k, 60);
assert_eq!(config.search.vector_weight, 0.7);
assert_eq!(config.search.sparse_weight, 0.3);
}
#[test]
fn search_config_round_trips_through_toml() {
let toml_input = r#"
rrf_k = 30
vector_weight = 0.6
sparse_weight = 0.4
"#;
let search: SearchConfig = toml::from_str(toml_input).unwrap();
assert_eq!(search.rrf_k, 30);
assert_eq!(search.vector_weight, 0.6);
assert_eq!(search.sparse_weight, 0.4);
}
#[test]
fn validate_search_config_accepts_defaults() {
validate_search_config(&SearchConfig::default()).expect("documented defaults are valid");
}
#[test]
fn validate_search_config_accepts_one_zero_weight() {
let search = SearchConfig {
rrf_k: 60,
vector_weight: 0.0,
sparse_weight: 0.3,
};
validate_search_config(&search).expect("a single zero weight still leaves one live term");
}
#[test]
fn validate_search_config_rejects_zero_rrf_k() {
let search = SearchConfig {
rrf_k: 0,
..SearchConfig::default()
};
let error = validate_search_config(&search).expect_err("rrf_k of 0 divides by k+rank base");
assert!(matches!(error, CoreError::ConfigValidation(_)));
assert!(error.to_string().contains("rrf_k"));
}
#[test]
fn validate_search_config_rejects_negative_weight() {
let search = SearchConfig {
rrf_k: 60,
vector_weight: -0.1,
sparse_weight: 0.3,
};
let error = validate_search_config(&search).expect_err("negative weight is invalid");
assert!(matches!(error, CoreError::ConfigValidation(_)));
assert!(error.to_string().contains("non-negative"));
}
#[test]
fn validate_search_config_rejects_non_finite_weight() {
let search = SearchConfig {
rrf_k: 60,
vector_weight: f64::NAN,
sparse_weight: 0.3,
};
let error = validate_search_config(&search).expect_err("NaN weight is invalid");
assert!(matches!(error, CoreError::ConfigValidation(_)));
assert!(error.to_string().contains("finite"));
}
#[test]
fn validate_search_config_rejects_all_zero_weights() {
let search = SearchConfig {
rrf_k: 60,
vector_weight: 0.0,
sparse_weight: 0.0,
};
let error = validate_search_config(&search).expect_err("both weights zero cancels output");
assert!(matches!(error, CoreError::ConfigValidation(_)));
}
#[test]
fn build_text_generator_ollama_uses_prefixed_model_name() {
let mut config = Config::default();
config.llm.provider = "ollama".into();
config.llm.model = Some("qwen3:4b".into());
config.llm.host = Some("http://127.0.0.1:1".into());
config.llm.api_key = None;
let generator = config
.build_text_generator()
.expect("ollama text generator builds without a live daemon or api key");
assert_eq!(generator.model_name(), "ollama:qwen3:4b");
}
#[test]
fn build_text_generator_ollama_builds_without_host() {
let mut config = Config::default();
config.llm.provider = "ollama".into();
config.llm.model = Some("qwen3:4b".into());
config.llm.host = None;
config.llm.api_key = None;
let generator = config
.build_text_generator()
.expect("ollama text generator falls back to the default host");
assert_eq!(generator.model_name(), "ollama:qwen3:4b");
}
#[test]
fn llm_config_deserializes_without_host() {
let toml_input = r#"
provider = "ollama"
model = "qwen3:4b"
"#;
let config: LlmConfig = toml::from_str(toml_input).unwrap();
assert_eq!(config.host, None);
}
#[test]
fn llm_config_deserializes_with_host() {
let toml_input = r#"
provider = "ollama"
model = "qwen3:4b"
host = "http://ollama.internal:11434"
"#;
let config: LlmConfig = toml::from_str(toml_input).unwrap();
assert_eq!(config.host.as_deref(), Some("http://ollama.internal:11434"));
}
}