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;