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