Skip to main content

kbolt_core/config/
mod.rs

1use std::collections::HashMap;
2use std::ffi::OsStr;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::error::Result;
7use kbolt_types::KboltError;
8use serde::{Deserialize, Serialize};
9
10const APP_NAME: &str = "kbolt";
11const CONFIG_FILENAME: &str = "index.toml";
12const DEFAULT_REAP_DAYS: u32 = 7;
13const DEFAULT_CHUNK_TARGET_TOKENS: usize = 800;
14const DEFAULT_CHUNK_SOFT_MAX_TOKENS: usize = 950;
15const DEFAULT_CHUNK_HARD_MAX_TOKENS: usize = 1200;
16const DEFAULT_CHUNK_BOUNDARY_OVERLAP_TOKENS: usize = 48;
17const DEFAULT_CHUNK_NEIGHBOR_WINDOW: usize = 1;
18const DEFAULT_CHUNK_CONTEXTUAL_PREFIX: bool = true;
19const DEFAULT_CODE_CHUNK_TARGET_TOKENS: usize = 320;
20const DEFAULT_CODE_CHUNK_SOFT_MAX_TOKENS: usize = 420;
21const DEFAULT_CODE_CHUNK_HARD_MAX_TOKENS: usize = 560;
22const DEFAULT_CODE_CHUNK_BOUNDARY_OVERLAP_TOKENS: usize = 24;
23const DEFAULT_EMBEDDING_BATCH_SIZE: usize = 32;
24const DEFAULT_INFERENCE_TIMEOUT_MS: u64 = 30_000;
25const DEFAULT_INFERENCE_MAX_RETRIES: u32 = 2;
26const DEFAULT_LOCAL_EXPANDER_MAX_TOKENS: usize = 600;
27const DEFAULT_EXPANDER_SEED: u32 = 0;
28const DEFAULT_EXPANDER_TEMPERATURE: f32 = 0.7;
29const DEFAULT_EXPANDER_TOP_K: i32 = 20;
30const DEFAULT_EXPANDER_TOP_P: f32 = 0.8;
31const DEFAULT_EXPANDER_MIN_P: f32 = 0.0;
32const DEFAULT_EXPANDER_REPEAT_LAST_N: i32 = 64;
33const DEFAULT_EXPANDER_REPEAT_PENALTY: f32 = 1.0;
34const DEFAULT_EXPANDER_FREQUENCY_PENALTY: f32 = 0.0;
35const DEFAULT_EXPANDER_PRESENCE_PENALTY: f32 = 0.5;
36const DEFAULT_RANKING_DEEP_VARIANT_RRF_K: usize = 60;
37const DEFAULT_RANKING_DEEP_VARIANTS_MAX: usize = 4;
38const DEFAULT_RANKING_INITIAL_CANDIDATE_LIMIT_MIN: usize = 40;
39const DEFAULT_RANKING_RERANK_CANDIDATES_MIN: usize = 20;
40const DEFAULT_RANKING_RERANK_CANDIDATES_MAX: usize = 30;
41const DEFAULT_RANKING_HYBRID_LINEAR_DENSE_WEIGHT: f32 = 0.7;
42const DEFAULT_RANKING_HYBRID_LINEAR_BM25_WEIGHT: f32 = 0.3;
43const DEFAULT_RANKING_HYBRID_DBSF_DENSE_WEIGHT: f32 = 1.0;
44const DEFAULT_RANKING_HYBRID_DBSF_BM25_WEIGHT: f32 = 0.4;
45const DEFAULT_RANKING_HYBRID_DBSF_STDDEVS: f32 = 3.0;
46const DEFAULT_RANKING_HYBRID_RRF_K: usize = 60;
47const DEFAULT_RANKING_BM25_TITLE_BOOST: f32 = 2.0;
48const DEFAULT_RANKING_BM25_HEADING_BOOST: f32 = 1.5;
49const DEFAULT_RANKING_BM25_BODY_BOOST: f32 = 1.0;
50const DEFAULT_RANKING_BM25_FILEPATH_BOOST: f32 = 0.5;
51
52#[derive(Debug, Clone, PartialEq)]
53pub struct Config {
54    pub config_dir: PathBuf,
55    pub cache_dir: PathBuf,
56    pub default_space: Option<String>,
57    pub providers: HashMap<String, ProviderProfileConfig>,
58    pub roles: RoleBindingsConfig,
59    pub reaping: ReapingConfig,
60    pub chunking: ChunkingConfig,
61    pub ranking: RankingConfig,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum ProviderOperation {
67    Embedding,
68    Reranking,
69    ChatCompletion,
70}
71
72impl ProviderOperation {
73    pub fn as_str(self) -> &'static str {
74        match self {
75            Self::Embedding => "embedding",
76            Self::Reranking => "reranking",
77            Self::ChatCompletion => "chat_completion",
78        }
79    }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(tag = "kind", rename_all = "snake_case")]
84pub enum ProviderProfileConfig {
85    LlamaCppServer {
86        operation: ProviderOperation,
87        base_url: String,
88        model: String,
89        #[serde(default = "default_inference_timeout_ms")]
90        timeout_ms: u64,
91        #[serde(default = "default_inference_max_retries")]
92        max_retries: u32,
93    },
94    #[serde(rename = "openai_compatible")]
95    OpenAiCompatible {
96        operation: ProviderOperation,
97        base_url: String,
98        model: String,
99        #[serde(default)]
100        api_key_env: Option<String>,
101        #[serde(default = "default_inference_timeout_ms")]
102        timeout_ms: u64,
103        #[serde(default = "default_inference_max_retries")]
104        max_retries: u32,
105    },
106}
107
108impl ProviderProfileConfig {
109    pub fn operation(&self) -> ProviderOperation {
110        match self {
111            Self::LlamaCppServer { operation, .. } | Self::OpenAiCompatible { operation, .. } => {
112                *operation
113            }
114        }
115    }
116
117    pub fn base_url(&self) -> &str {
118        match self {
119            Self::LlamaCppServer { base_url, .. } | Self::OpenAiCompatible { base_url, .. } => {
120                base_url
121            }
122        }
123    }
124
125    pub fn model(&self) -> &str {
126        match self {
127            Self::LlamaCppServer { model, .. } | Self::OpenAiCompatible { model, .. } => model,
128        }
129    }
130
131    pub fn api_key_env(&self) -> Option<&str> {
132        match self {
133            Self::LlamaCppServer { .. } => None,
134            Self::OpenAiCompatible { api_key_env, .. } => api_key_env.as_deref(),
135        }
136    }
137
138    pub fn timeout_ms(&self) -> u64 {
139        match self {
140            Self::LlamaCppServer { timeout_ms, .. } | Self::OpenAiCompatible { timeout_ms, .. } => {
141                *timeout_ms
142            }
143        }
144    }
145
146    pub fn max_retries(&self) -> u32 {
147        match self {
148            Self::LlamaCppServer { max_retries, .. }
149            | Self::OpenAiCompatible { max_retries, .. } => *max_retries,
150        }
151    }
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
155pub struct RoleBindingsConfig {
156    #[serde(default)]
157    pub embedder: Option<EmbedderRoleConfig>,
158    #[serde(default)]
159    pub reranker: Option<RerankerRoleConfig>,
160    #[serde(default)]
161    pub expander: Option<ExpanderRoleConfig>,
162}
163
164impl RoleBindingsConfig {
165    fn is_empty(&self) -> bool {
166        self.embedder.is_none() && self.reranker.is_none() && self.expander.is_none()
167    }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct EmbedderRoleConfig {
172    pub provider: String,
173    #[serde(default = "default_embedding_batch_size")]
174    pub batch_size: usize,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct RerankerRoleConfig {
179    pub provider: String,
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183pub struct ExpanderRoleConfig {
184    pub provider: String,
185    #[serde(default = "default_local_expander_max_tokens")]
186    pub max_tokens: usize,
187    #[serde(flatten)]
188    pub sampling: ExpanderSamplingConfig,
189}
190
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192pub struct ExpanderSamplingConfig {
193    #[serde(default = "default_expander_seed")]
194    pub seed: u32,
195    #[serde(default = "default_expander_temperature")]
196    pub temperature: f32,
197    #[serde(default = "default_expander_top_k")]
198    pub top_k: i32,
199    #[serde(default = "default_expander_top_p")]
200    pub top_p: f32,
201    #[serde(default = "default_expander_min_p")]
202    pub min_p: f32,
203    #[serde(default = "default_expander_repeat_last_n")]
204    pub repeat_last_n: i32,
205    #[serde(default = "default_expander_repeat_penalty")]
206    pub repeat_penalty: f32,
207    #[serde(default = "default_expander_frequency_penalty")]
208    pub frequency_penalty: f32,
209    #[serde(default = "default_expander_presence_penalty")]
210    pub presence_penalty: f32,
211}
212
213pub type ExpanderRoleSamplingConfig = ExpanderSamplingConfig;
214pub type ExpanderLocalLlamaSamplingConfig = ExpanderSamplingConfig;
215
216impl Default for ExpanderSamplingConfig {
217    fn default() -> Self {
218        Self {
219            seed: default_expander_seed(),
220            temperature: default_expander_temperature(),
221            top_k: default_expander_top_k(),
222            top_p: default_expander_top_p(),
223            min_p: default_expander_min_p(),
224            repeat_last_n: default_expander_repeat_last_n(),
225            repeat_penalty: default_expander_repeat_penalty(),
226            frequency_penalty: default_expander_frequency_penalty(),
227            presence_penalty: default_expander_presence_penalty(),
228        }
229    }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct ReapingConfig {
234    pub days: u32,
235}
236
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238pub struct RankingConfig {
239    #[serde(default = "default_ranking_deep_variant_rrf_k")]
240    pub deep_variant_rrf_k: usize,
241    #[serde(default = "default_ranking_deep_variants_max")]
242    pub deep_variants_max: usize,
243    #[serde(default = "default_ranking_initial_candidate_limit_min")]
244    pub initial_candidate_limit_min: usize,
245    #[serde(default = "default_ranking_rerank_candidates_min")]
246    pub rerank_candidates_min: usize,
247    #[serde(default = "default_ranking_rerank_candidates_max")]
248    pub rerank_candidates_max: usize,
249    #[serde(default)]
250    pub hybrid_fusion: HybridFusionConfig,
251    #[serde(default)]
252    pub bm25_boosts: Bm25BoostsConfig,
253}
254
255impl Default for RankingConfig {
256    fn default() -> Self {
257        Self {
258            deep_variant_rrf_k: default_ranking_deep_variant_rrf_k(),
259            deep_variants_max: default_ranking_deep_variants_max(),
260            initial_candidate_limit_min: default_ranking_initial_candidate_limit_min(),
261            rerank_candidates_min: default_ranking_rerank_candidates_min(),
262            rerank_candidates_max: default_ranking_rerank_candidates_max(),
263            hybrid_fusion: HybridFusionConfig::default(),
264            bm25_boosts: Bm25BoostsConfig::default(),
265        }
266    }
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
270#[serde(rename_all = "snake_case")]
271pub enum HybridFusionMode {
272    Rrf,
273    Linear,
274    #[default]
275    Dbsf,
276}
277
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
279pub struct HybridFusionConfig {
280    #[serde(default)]
281    pub mode: HybridFusionMode,
282    #[serde(default)]
283    pub linear: LinearHybridFusionConfig,
284    #[serde(default)]
285    pub dbsf: DbsfHybridFusionConfig,
286    #[serde(default)]
287    pub rrf: RrfHybridFusionConfig,
288}
289
290impl Default for HybridFusionConfig {
291    fn default() -> Self {
292        Self {
293            mode: HybridFusionMode::default(),
294            linear: LinearHybridFusionConfig::default(),
295            dbsf: DbsfHybridFusionConfig::default(),
296            rrf: RrfHybridFusionConfig::default(),
297        }
298    }
299}
300
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
302pub struct LinearHybridFusionConfig {
303    #[serde(default = "default_ranking_hybrid_linear_dense_weight")]
304    pub dense_weight: f32,
305    #[serde(default = "default_ranking_hybrid_linear_bm25_weight")]
306    pub bm25_weight: f32,
307}
308
309impl Default for LinearHybridFusionConfig {
310    fn default() -> Self {
311        Self {
312            dense_weight: default_ranking_hybrid_linear_dense_weight(),
313            bm25_weight: default_ranking_hybrid_linear_bm25_weight(),
314        }
315    }
316}
317
318#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
319pub struct DbsfHybridFusionConfig {
320    #[serde(default = "default_ranking_hybrid_dbsf_dense_weight")]
321    pub dense_weight: f32,
322    #[serde(default = "default_ranking_hybrid_dbsf_bm25_weight")]
323    pub bm25_weight: f32,
324    #[serde(default = "default_ranking_hybrid_dbsf_stddevs")]
325    pub stddevs: f32,
326}
327
328impl Default for DbsfHybridFusionConfig {
329    fn default() -> Self {
330        Self {
331            dense_weight: default_ranking_hybrid_dbsf_dense_weight(),
332            bm25_weight: default_ranking_hybrid_dbsf_bm25_weight(),
333            stddevs: default_ranking_hybrid_dbsf_stddevs(),
334        }
335    }
336}
337
338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
339pub struct RrfHybridFusionConfig {
340    #[serde(default = "default_ranking_hybrid_rrf_k")]
341    pub k: usize,
342}
343
344impl Default for RrfHybridFusionConfig {
345    fn default() -> Self {
346        Self {
347            k: default_ranking_hybrid_rrf_k(),
348        }
349    }
350}
351
352#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
353pub struct Bm25BoostsConfig {
354    #[serde(default = "default_ranking_bm25_title_boost")]
355    pub title: f32,
356    #[serde(default = "default_ranking_bm25_heading_boost")]
357    pub heading: f32,
358    #[serde(default = "default_ranking_bm25_body_boost")]
359    pub body: f32,
360    #[serde(default = "default_ranking_bm25_filepath_boost")]
361    pub filepath: f32,
362}
363
364impl Default for Bm25BoostsConfig {
365    fn default() -> Self {
366        Self {
367            title: default_ranking_bm25_title_boost(),
368            heading: default_ranking_bm25_heading_boost(),
369            body: default_ranking_bm25_body_boost(),
370            filepath: default_ranking_bm25_filepath_boost(),
371        }
372    }
373}
374
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376pub struct ChunkingConfig {
377    #[serde(default)]
378    pub defaults: ChunkPolicy,
379    #[serde(default = "default_chunk_profiles")]
380    pub profiles: HashMap<String, ChunkPolicy>,
381}
382
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384pub struct ChunkPolicy {
385    #[serde(default = "default_chunk_target_tokens")]
386    pub target_tokens: usize,
387    #[serde(default = "default_chunk_soft_max_tokens")]
388    pub soft_max_tokens: usize,
389    #[serde(default = "default_chunk_hard_max_tokens")]
390    pub hard_max_tokens: usize,
391    #[serde(default = "default_chunk_boundary_overlap_tokens")]
392    pub boundary_overlap_tokens: usize,
393    #[serde(default = "default_chunk_neighbor_window")]
394    pub neighbor_window: usize,
395    #[serde(default = "default_chunk_contextual_prefix")]
396    pub contextual_prefix: bool,
397}
398
399impl Default for ChunkingConfig {
400    fn default() -> Self {
401        Self {
402            defaults: ChunkPolicy::default(),
403            profiles: default_chunk_profiles(),
404        }
405    }
406}
407
408impl Default for ChunkPolicy {
409    fn default() -> Self {
410        Self {
411            target_tokens: default_chunk_target_tokens(),
412            soft_max_tokens: default_chunk_soft_max_tokens(),
413            hard_max_tokens: default_chunk_hard_max_tokens(),
414            boundary_overlap_tokens: default_chunk_boundary_overlap_tokens(),
415            neighbor_window: default_chunk_neighbor_window(),
416            contextual_prefix: default_chunk_contextual_prefix(),
417        }
418    }
419}
420
421pub fn load(config_path: Option<&Path>) -> Result<Config> {
422    let config_dir = resolve_config_dir(config_path)?;
423    let cache_dir = default_cache_dir()?;
424    let config_file = config_dir.join(CONFIG_FILENAME);
425
426    load_from_file(
427        &config_file,
428        &config_dir,
429        &cache_dir,
430        ConfigLoadMode::CreateDefault,
431    )
432}
433
434pub fn load_existing(config_path: Option<&Path>) -> Result<Config> {
435    let config_dir = resolve_config_dir(config_path)?;
436    let cache_dir = default_cache_dir()?;
437    let config_file = config_dir.join(CONFIG_FILENAME);
438
439    load_from_file(
440        &config_file,
441        &config_dir,
442        &cache_dir,
443        ConfigLoadMode::ExistingOnly,
444    )
445}
446
447pub fn default_config_file_path() -> Result<PathBuf> {
448    resolve_config_file_path(None)
449}
450
451pub fn resolve_config_file_path(config_path: Option<&Path>) -> Result<PathBuf> {
452    Ok(resolve_config_dir(config_path)?.join(CONFIG_FILENAME))
453}
454
455pub fn save(config: &Config) -> Result<()> {
456    fs::create_dir_all(&config.config_dir)?;
457    fs::create_dir_all(&config.cache_dir)?;
458    validate_chunking(&config.chunking)?;
459    validate_provider_profiles(&config.providers)?;
460    validate_role_bindings(&config.roles, &config.providers)?;
461    validate_ranking(&config.ranking)?;
462
463    let file_config = FileConfig::from(config);
464    let serialized = toml::to_string_pretty(&file_config)?;
465    let content = if serialized.ends_with('\n') {
466        serialized
467    } else {
468        format!("{serialized}\n")
469    };
470
471    let config_file = config.config_dir.join(CONFIG_FILENAME);
472    fs::write(config_file, content)?;
473    Ok(())
474}
475
476fn resolve_config_dir(config_path: Option<&Path>) -> Result<PathBuf> {
477    match config_path {
478        None => default_config_dir(),
479        Some(path) => {
480            if path.file_name() == Some(OsStr::new(CONFIG_FILENAME)) {
481                let parent = path.parent().ok_or_else(|| {
482                    KboltError::Config(format!("invalid config path: {}", path.display()))
483                })?;
484                return Ok(parent.to_path_buf());
485            }
486
487            if path.extension() == Some(OsStr::new("toml")) {
488                return Err(KboltError::Config(format!(
489                    "config file override must be named {CONFIG_FILENAME}: {}",
490                    path.display()
491                ))
492                .into());
493            }
494
495            Ok(path.to_path_buf())
496        }
497    }
498}
499
500fn default_config_dir() -> Result<PathBuf> {
501    let base = dirs::config_dir()
502        .ok_or_else(|| KboltError::Config("unable to determine user config directory".into()))?;
503    Ok(base.join(APP_NAME))
504}
505
506fn default_cache_dir() -> Result<PathBuf> {
507    let base = dirs::cache_dir()
508        .ok_or_else(|| KboltError::Config("unable to determine user cache directory".into()))?;
509    Ok(base.join(APP_NAME))
510}
511
512fn load_from_file(
513    config_file: &Path,
514    config_dir: &Path,
515    cache_dir: &Path,
516    mode: ConfigLoadMode,
517) -> Result<Config> {
518    match mode {
519        ConfigLoadMode::CreateDefault => {
520            fs::create_dir_all(config_dir)?;
521            fs::create_dir_all(cache_dir)?;
522
523            if !config_file.exists() {
524                let default_config = Config {
525                    config_dir: config_dir.to_path_buf(),
526                    cache_dir: cache_dir.to_path_buf(),
527                    default_space: None,
528                    providers: HashMap::new(),
529                    roles: RoleBindingsConfig::default(),
530                    reaping: ReapingConfig {
531                        days: DEFAULT_REAP_DAYS,
532                    },
533                    chunking: ChunkingConfig::default(),
534                    ranking: RankingConfig::default(),
535                };
536                save(&default_config)?;
537            }
538        }
539        ConfigLoadMode::ExistingOnly => {
540            if !config_file.exists() {
541                return Err(KboltError::Config(format!(
542                    "config file not found: {}",
543                    config_file.display()
544                ))
545                .into());
546            }
547        }
548    }
549
550    let raw = fs::read_to_string(config_file)?;
551    let file_config: FileConfig = toml::from_str(&raw).map_err(|err| {
552        KboltError::Config(format!(
553            "invalid config file {}: {err}",
554            config_file.display()
555        ))
556    })?;
557    validate_chunking(&file_config.chunking)?;
558    validate_provider_profiles(&file_config.providers)?;
559    validate_role_bindings(&file_config.roles, &file_config.providers)?;
560    validate_ranking(&file_config.ranking)?;
561
562    Ok(Config {
563        config_dir: config_dir.to_path_buf(),
564        cache_dir: cache_dir.to_path_buf(),
565        default_space: file_config.default_space,
566        providers: file_config.providers,
567        roles: file_config.roles,
568        reaping: ReapingConfig {
569            days: file_config.reaping.days,
570        },
571        chunking: file_config.chunking,
572        ranking: file_config.ranking,
573    })
574}
575
576#[derive(Debug, Clone, Copy, PartialEq, Eq)]
577enum ConfigLoadMode {
578    CreateDefault,
579    ExistingOnly,
580}
581
582fn validate_chunking(chunking: &ChunkingConfig) -> Result<()> {
583    validate_chunk_policy("chunking.defaults", &chunking.defaults)?;
584    for (profile, policy) in &chunking.profiles {
585        validate_chunk_policy(format!("chunking.profiles.{profile}").as_str(), policy)?;
586    }
587    Ok(())
588}
589
590fn validate_provider_profiles(providers: &HashMap<String, ProviderProfileConfig>) -> Result<()> {
591    for (name, profile) in providers {
592        if name.trim().is_empty() {
593            return Err(
594                KboltError::Config("providers table names must not be empty".to_string()).into(),
595            );
596        }
597
598        validate_provider_profile(format!("providers.{name}").as_str(), profile)?;
599    }
600
601    Ok(())
602}
603
604fn validate_provider_profile(scope: &str, profile: &ProviderProfileConfig) -> Result<()> {
605    validate_provider_profile_common(
606        scope,
607        profile.base_url(),
608        profile.model(),
609        profile.api_key_env(),
610        profile.timeout_ms(),
611    )
612}
613
614fn validate_provider_profile_common(
615    scope: &str,
616    base_url: &str,
617    model: &str,
618    api_key_env: Option<&str>,
619    timeout_ms: u64,
620) -> Result<()> {
621    if model.trim().is_empty() {
622        return Err(KboltError::Config(format!("{scope}.model must not be empty")).into());
623    }
624
625    if base_url.trim().is_empty() {
626        return Err(KboltError::Config(format!("{scope}.base_url must not be empty")).into());
627    }
628
629    if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
630        return Err(KboltError::Config(format!(
631            "{scope}.base_url must start with http:// or https://"
632        ))
633        .into());
634    }
635
636    if timeout_ms == 0 {
637        return Err(
638            KboltError::Config(format!("{scope}.timeout_ms must be greater than zero")).into(),
639        );
640    }
641
642    if let Some(api_key_env) = api_key_env {
643        if api_key_env.trim().is_empty() {
644            return Err(KboltError::Config(format!(
645                "{scope}.api_key_env must not be empty when set"
646            ))
647            .into());
648        }
649    }
650
651    Ok(())
652}
653
654fn validate_role_bindings(
655    roles: &RoleBindingsConfig,
656    providers: &HashMap<String, ProviderProfileConfig>,
657) -> Result<()> {
658    if let Some(embedder) = roles.embedder.as_ref() {
659        validate_role_provider_reference(
660            "roles.embedder",
661            &embedder.provider,
662            &[ProviderOperation::Embedding],
663            providers,
664        )?;
665        if embedder.batch_size == 0 {
666            return Err(KboltError::Config(
667                "roles.embedder.batch_size must be greater than zero".to_string(),
668            )
669            .into());
670        }
671    }
672
673    if let Some(reranker) = roles.reranker.as_ref() {
674        validate_role_provider_reference(
675            "roles.reranker",
676            &reranker.provider,
677            &[
678                ProviderOperation::Reranking,
679                ProviderOperation::ChatCompletion,
680            ],
681            providers,
682        )?;
683    }
684
685    if let Some(expander) = roles.expander.as_ref() {
686        validate_role_provider_reference(
687            "roles.expander",
688            &expander.provider,
689            &[ProviderOperation::ChatCompletion],
690            providers,
691        )?;
692        if expander.max_tokens == 0 {
693            return Err(KboltError::Config(
694                "roles.expander.max_tokens must be greater than zero".to_string(),
695            )
696            .into());
697        }
698        validate_expander_sampling(
699            "roles.expander",
700            expander.sampling.temperature,
701            expander.sampling.top_k,
702            expander.sampling.top_p,
703            expander.sampling.min_p,
704            expander.sampling.repeat_last_n,
705            expander.sampling.repeat_penalty,
706            expander.sampling.frequency_penalty,
707            expander.sampling.presence_penalty,
708        )?;
709    }
710
711    Ok(())
712}
713
714fn validate_role_provider_reference(
715    scope: &str,
716    provider_name: &str,
717    allowed_operations: &[ProviderOperation],
718    providers: &HashMap<String, ProviderProfileConfig>,
719) -> Result<()> {
720    if provider_name.trim().is_empty() {
721        return Err(KboltError::Config(format!("{scope}.provider must not be empty")).into());
722    }
723
724    let Some(profile) = providers.get(provider_name) else {
725        return Err(KboltError::Config(format!(
726            "{scope}.provider references undefined provider profile '{provider_name}'"
727        ))
728        .into());
729    };
730
731    let operation = profile.operation();
732
733    if !allowed_operations.contains(&operation) {
734        return Err(KboltError::Config(format!(
735            "{scope}.provider '{provider_name}' uses incompatible operation '{}'",
736            operation.as_str()
737        ))
738        .into());
739    }
740
741    Ok(())
742}
743
744fn validate_expander_sampling(
745    scope: &str,
746    temperature: f32,
747    top_k: i32,
748    top_p: f32,
749    min_p: f32,
750    repeat_last_n: i32,
751    repeat_penalty: f32,
752    frequency_penalty: f32,
753    presence_penalty: f32,
754) -> Result<()> {
755    if !temperature.is_finite() || temperature <= 0.0 {
756        return Err(KboltError::Config(format!(
757            "{scope}.temperature must be finite and greater than zero"
758        ))
759        .into());
760    }
761
762    if top_k <= 0 {
763        return Err(KboltError::Config(format!("{scope}.top_k must be greater than zero")).into());
764    }
765
766    if !top_p.is_finite() || top_p <= 0.0 || top_p > 1.0 {
767        return Err(KboltError::Config(format!(
768            "{scope}.top_p must be finite and in the range (0, 1]"
769        ))
770        .into());
771    }
772
773    if !min_p.is_finite() || min_p < 0.0 || min_p > 1.0 {
774        return Err(KboltError::Config(format!(
775            "{scope}.min_p must be finite and in the range [0, 1]"
776        ))
777        .into());
778    }
779
780    if repeat_last_n < -1 {
781        return Err(KboltError::Config(format!(
782            "{scope}.repeat_last_n must be greater than or equal to -1"
783        ))
784        .into());
785    }
786
787    if !repeat_penalty.is_finite() || repeat_penalty <= 0.0 {
788        return Err(KboltError::Config(format!(
789            "{scope}.repeat_penalty must be finite and greater than zero"
790        ))
791        .into());
792    }
793
794    if !frequency_penalty.is_finite() {
795        return Err(KboltError::Config(format!("{scope}.frequency_penalty must be finite")).into());
796    }
797
798    if !presence_penalty.is_finite() {
799        return Err(KboltError::Config(format!("{scope}.presence_penalty must be finite")).into());
800    }
801
802    Ok(())
803}
804
805fn validate_chunk_policy(scope: &str, policy: &ChunkPolicy) -> Result<()> {
806    if policy.target_tokens == 0 || policy.soft_max_tokens == 0 || policy.hard_max_tokens == 0 {
807        return Err(KboltError::Config(format!(
808            "{scope} token caps must be greater than zero (target={}, soft_max={}, hard_max={})",
809            policy.target_tokens, policy.soft_max_tokens, policy.hard_max_tokens
810        ))
811        .into());
812    }
813
814    if policy.target_tokens > policy.soft_max_tokens {
815        return Err(KboltError::Config(format!(
816            "{scope} is invalid: target_tokens ({}) cannot exceed soft_max_tokens ({})",
817            policy.target_tokens, policy.soft_max_tokens
818        ))
819        .into());
820    }
821
822    if policy.soft_max_tokens > policy.hard_max_tokens {
823        return Err(KboltError::Config(format!(
824            "{scope} is invalid: soft_max_tokens ({}) cannot exceed hard_max_tokens ({})",
825            policy.soft_max_tokens, policy.hard_max_tokens
826        ))
827        .into());
828    }
829
830    Ok(())
831}
832
833fn validate_ranking(ranking: &RankingConfig) -> Result<()> {
834    if ranking.deep_variant_rrf_k == 0 {
835        return Err(KboltError::Config(
836            "ranking.deep_variant_rrf_k must be greater than zero".to_string(),
837        )
838        .into());
839    }
840
841    if ranking.deep_variants_max == 0 {
842        return Err(KboltError::Config(
843            "ranking.deep_variants_max must be greater than zero".to_string(),
844        )
845        .into());
846    }
847
848    if ranking.initial_candidate_limit_min == 0 {
849        return Err(KboltError::Config(
850            "ranking.initial_candidate_limit_min must be greater than zero".to_string(),
851        )
852        .into());
853    }
854
855    if ranking.rerank_candidates_min == 0 {
856        return Err(KboltError::Config(
857            "ranking.rerank_candidates_min must be greater than zero".to_string(),
858        )
859        .into());
860    }
861
862    if ranking.rerank_candidates_max < ranking.rerank_candidates_min {
863        return Err(KboltError::Config(format!(
864            "ranking.rerank_candidates_max ({}) must be greater than or equal to ranking.rerank_candidates_min ({})",
865            ranking.rerank_candidates_max, ranking.rerank_candidates_min
866        ))
867        .into());
868    }
869
870    validate_hybrid_fusion_weights(
871        "ranking.hybrid_fusion.linear",
872        ranking.hybrid_fusion.linear.dense_weight,
873        ranking.hybrid_fusion.linear.bm25_weight,
874    )?;
875    validate_hybrid_fusion_weights(
876        "ranking.hybrid_fusion.dbsf",
877        ranking.hybrid_fusion.dbsf.dense_weight,
878        ranking.hybrid_fusion.dbsf.bm25_weight,
879    )?;
880    if !ranking.hybrid_fusion.dbsf.stddevs.is_finite() || ranking.hybrid_fusion.dbsf.stddevs <= 0.0
881    {
882        return Err(KboltError::Config(
883            "ranking.hybrid_fusion.dbsf.stddevs must be finite and greater than zero".to_string(),
884        )
885        .into());
886    }
887    if ranking.hybrid_fusion.rrf.k == 0 {
888        return Err(KboltError::Config(
889            "ranking.hybrid_fusion.rrf.k must be greater than zero".to_string(),
890        )
891        .into());
892    }
893
894    validate_positive_finite_boost("ranking.bm25_boosts.title", ranking.bm25_boosts.title)?;
895    validate_positive_finite_boost("ranking.bm25_boosts.heading", ranking.bm25_boosts.heading)?;
896    validate_positive_finite_boost("ranking.bm25_boosts.body", ranking.bm25_boosts.body)?;
897    validate_positive_finite_boost("ranking.bm25_boosts.filepath", ranking.bm25_boosts.filepath)?;
898
899    Ok(())
900}
901
902fn validate_positive_finite_boost(scope: &str, value: f32) -> Result<()> {
903    if !value.is_finite() || value <= 0.0 {
904        return Err(
905            KboltError::Config(format!("{scope} must be finite and greater than zero")).into(),
906        );
907    }
908
909    Ok(())
910}
911
912fn validate_nonnegative_finite_weight(scope: &str, value: f32) -> Result<()> {
913    if !value.is_finite() || value < 0.0 {
914        return Err(KboltError::Config(format!(
915            "{scope} must be finite and greater than or equal to zero"
916        ))
917        .into());
918    }
919
920    Ok(())
921}
922
923fn validate_hybrid_fusion_weights(scope: &str, dense_weight: f32, bm25_weight: f32) -> Result<()> {
924    validate_nonnegative_finite_weight(format!("{scope}.dense_weight").as_str(), dense_weight)?;
925    validate_nonnegative_finite_weight(format!("{scope}.bm25_weight").as_str(), bm25_weight)?;
926    if dense_weight + bm25_weight <= 0.0 {
927        return Err(
928            KboltError::Config(format!("{scope} weights must sum to greater than zero")).into(),
929        );
930    }
931
932    Ok(())
933}
934
935#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
936#[serde(deny_unknown_fields)]
937struct FileConfig {
938    #[serde(default)]
939    default_space: Option<String>,
940    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
941    providers: HashMap<String, ProviderProfileConfig>,
942    #[serde(default, skip_serializing_if = "RoleBindingsConfig::is_empty")]
943    roles: RoleBindingsConfig,
944    #[serde(default)]
945    reaping: FileReapingConfig,
946    #[serde(default)]
947    chunking: ChunkingConfig,
948    #[serde(default)]
949    ranking: RankingConfig,
950}
951
952#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
953struct FileReapingConfig {
954    #[serde(default = "default_reap_days")]
955    days: u32,
956}
957
958impl Default for FileReapingConfig {
959    fn default() -> Self {
960        Self {
961            days: default_reap_days(),
962        }
963    }
964}
965
966impl From<&Config> for FileConfig {
967    fn from(value: &Config) -> Self {
968        Self {
969            default_space: value.default_space.clone(),
970            providers: value.providers.clone(),
971            roles: value.roles.clone(),
972            reaping: FileReapingConfig {
973                days: value.reaping.days,
974            },
975            chunking: value.chunking.clone(),
976            ranking: value.ranking.clone(),
977        }
978    }
979}
980
981fn default_reap_days() -> u32 {
982    DEFAULT_REAP_DAYS
983}
984
985fn default_chunk_target_tokens() -> usize {
986    DEFAULT_CHUNK_TARGET_TOKENS
987}
988
989fn default_chunk_soft_max_tokens() -> usize {
990    DEFAULT_CHUNK_SOFT_MAX_TOKENS
991}
992
993fn default_chunk_hard_max_tokens() -> usize {
994    DEFAULT_CHUNK_HARD_MAX_TOKENS
995}
996
997fn default_chunk_boundary_overlap_tokens() -> usize {
998    DEFAULT_CHUNK_BOUNDARY_OVERLAP_TOKENS
999}
1000
1001fn default_chunk_neighbor_window() -> usize {
1002    DEFAULT_CHUNK_NEIGHBOR_WINDOW
1003}
1004
1005fn default_chunk_contextual_prefix() -> bool {
1006    DEFAULT_CHUNK_CONTEXTUAL_PREFIX
1007}
1008
1009fn default_chunk_profiles() -> HashMap<String, ChunkPolicy> {
1010    HashMap::from([(
1011        "code".to_string(),
1012        ChunkPolicy {
1013            target_tokens: DEFAULT_CODE_CHUNK_TARGET_TOKENS,
1014            soft_max_tokens: DEFAULT_CODE_CHUNK_SOFT_MAX_TOKENS,
1015            hard_max_tokens: DEFAULT_CODE_CHUNK_HARD_MAX_TOKENS,
1016            boundary_overlap_tokens: DEFAULT_CODE_CHUNK_BOUNDARY_OVERLAP_TOKENS,
1017            neighbor_window: default_chunk_neighbor_window(),
1018            contextual_prefix: default_chunk_contextual_prefix(),
1019        },
1020    )])
1021}
1022
1023fn default_embedding_batch_size() -> usize {
1024    DEFAULT_EMBEDDING_BATCH_SIZE
1025}
1026
1027fn default_inference_timeout_ms() -> u64 {
1028    DEFAULT_INFERENCE_TIMEOUT_MS
1029}
1030
1031fn default_inference_max_retries() -> u32 {
1032    DEFAULT_INFERENCE_MAX_RETRIES
1033}
1034
1035fn default_local_expander_max_tokens() -> usize {
1036    DEFAULT_LOCAL_EXPANDER_MAX_TOKENS
1037}
1038
1039fn default_expander_seed() -> u32 {
1040    DEFAULT_EXPANDER_SEED
1041}
1042
1043fn default_expander_temperature() -> f32 {
1044    DEFAULT_EXPANDER_TEMPERATURE
1045}
1046
1047fn default_expander_top_k() -> i32 {
1048    DEFAULT_EXPANDER_TOP_K
1049}
1050
1051fn default_expander_top_p() -> f32 {
1052    DEFAULT_EXPANDER_TOP_P
1053}
1054
1055fn default_expander_min_p() -> f32 {
1056    DEFAULT_EXPANDER_MIN_P
1057}
1058
1059fn default_expander_repeat_last_n() -> i32 {
1060    DEFAULT_EXPANDER_REPEAT_LAST_N
1061}
1062
1063fn default_expander_repeat_penalty() -> f32 {
1064    DEFAULT_EXPANDER_REPEAT_PENALTY
1065}
1066
1067fn default_expander_frequency_penalty() -> f32 {
1068    DEFAULT_EXPANDER_FREQUENCY_PENALTY
1069}
1070
1071fn default_expander_presence_penalty() -> f32 {
1072    DEFAULT_EXPANDER_PRESENCE_PENALTY
1073}
1074
1075fn default_ranking_deep_variant_rrf_k() -> usize {
1076    DEFAULT_RANKING_DEEP_VARIANT_RRF_K
1077}
1078
1079fn default_ranking_deep_variants_max() -> usize {
1080    DEFAULT_RANKING_DEEP_VARIANTS_MAX
1081}
1082
1083fn default_ranking_initial_candidate_limit_min() -> usize {
1084    DEFAULT_RANKING_INITIAL_CANDIDATE_LIMIT_MIN
1085}
1086
1087fn default_ranking_rerank_candidates_min() -> usize {
1088    DEFAULT_RANKING_RERANK_CANDIDATES_MIN
1089}
1090
1091fn default_ranking_rerank_candidates_max() -> usize {
1092    DEFAULT_RANKING_RERANK_CANDIDATES_MAX
1093}
1094
1095fn default_ranking_hybrid_linear_dense_weight() -> f32 {
1096    DEFAULT_RANKING_HYBRID_LINEAR_DENSE_WEIGHT
1097}
1098
1099fn default_ranking_hybrid_linear_bm25_weight() -> f32 {
1100    DEFAULT_RANKING_HYBRID_LINEAR_BM25_WEIGHT
1101}
1102
1103fn default_ranking_hybrid_dbsf_dense_weight() -> f32 {
1104    DEFAULT_RANKING_HYBRID_DBSF_DENSE_WEIGHT
1105}
1106
1107fn default_ranking_hybrid_dbsf_bm25_weight() -> f32 {
1108    DEFAULT_RANKING_HYBRID_DBSF_BM25_WEIGHT
1109}
1110
1111fn default_ranking_hybrid_dbsf_stddevs() -> f32 {
1112    DEFAULT_RANKING_HYBRID_DBSF_STDDEVS
1113}
1114
1115fn default_ranking_hybrid_rrf_k() -> usize {
1116    DEFAULT_RANKING_HYBRID_RRF_K
1117}
1118
1119fn default_ranking_bm25_title_boost() -> f32 {
1120    DEFAULT_RANKING_BM25_TITLE_BOOST
1121}
1122
1123fn default_ranking_bm25_heading_boost() -> f32 {
1124    DEFAULT_RANKING_BM25_HEADING_BOOST
1125}
1126
1127fn default_ranking_bm25_body_boost() -> f32 {
1128    DEFAULT_RANKING_BM25_BODY_BOOST
1129}
1130
1131fn default_ranking_bm25_filepath_boost() -> f32 {
1132    DEFAULT_RANKING_BM25_FILEPATH_BOOST
1133}
1134
1135#[cfg(test)]
1136mod tests;