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