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;