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