use serde::{Deserialize, Serialize};
use crate::{KIMETSU_CONFIG_VERSION, KimetsuResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectConfig {
pub kimetsu: KimetsuSection,
pub model: ModelSection,
pub broker: BrokerSection,
pub shell: ShellSection,
pub ingestion: IngestionSection,
pub run: RunSection,
#[serde(default)]
pub embedder: EmbedderSection,
#[serde(default)]
pub learning: LearningSection,
#[serde(default)]
pub cheap_model: Option<CheapModelSection>,
#[serde(default)]
pub storage: StorageSection,
#[serde(default)]
pub sync: SyncSection,
#[serde(default)]
pub lifecycle: LifecycleSection,
#[serde(default)]
pub retrieval: RetrievalSection,
}
impl ProjectConfig {
pub fn default_for_project(project_id: impl Into<String>) -> Self {
Self {
kimetsu: KimetsuSection {
project_id: project_id.into(),
schema_version: KIMETSU_CONFIG_VERSION,
use_user_brain: default_true(),
mcp_write_tools: default_true(),
tier: None,
},
model: ModelSection::default(),
broker: BrokerSection {
abstain_min_score: -1.0,
..BrokerSection::default()
},
shell: ShellSection::default(),
ingestion: IngestionSection::default(),
run: RunSection::default(),
embedder: EmbedderSection::default(),
learning: LearningSection::default(),
cheap_model: None,
storage: StorageSection::default(),
sync: SyncSection::default(),
lifecycle: LifecycleSection::default(),
retrieval: RetrievalSection {
level: "deep".to_string(),
},
}
}
pub fn apply_retrieval_level(&mut self) {
if !self.embedder.enabled {
return;
}
let reranker_off = matches!(
self.embedder.reranker.trim().to_ascii_lowercase().as_str(),
"" | "off" | "none" | "noop"
);
match self.retrieval.level.as_str() {
"basic" => {
self.embedder.enabled = false;
self.embedder.reranker = "off".to_string();
}
"flexible" => {
self.embedder.enabled = true;
self.embedder.reranker = "off".to_string();
}
"deep" => {
self.embedder.enabled = true;
self.embedder.reranker = "ms-marco-tinybert-l-2-v2".to_string();
}
"advanced" => {
self.embedder.enabled = true;
self.embedder.reranker = "ms-marco-tinybert-l-2-v2".to_string();
}
_ => {} }
if reranker_off {
self.embedder.reranker = "off".into();
}
}
pub fn hyde_from_level(&self) -> bool {
self.retrieval.level == "advanced"
}
pub fn cheap_model(&self) -> Option<CheapModelSection> {
if let Some(ref cm) = self.cheap_model {
if cm.enabled {
return Some(cm.clone());
}
}
if self.learning.distiller.enabled {
return Some(CheapModelSection::from_distiller(&self.learning.distiller));
}
None
}
pub fn tier(&self) -> Tier {
match self.tier_requested() {
Some(Tier::Deep) if self.cheap_model().is_some() => Tier::Deep,
Some(Tier::Deep) => Tier::Free,
Some(Tier::Free) => Tier::Free,
None if self.cheap_model().is_some() => Tier::Deep,
None => Tier::Free,
}
}
pub fn tier_requested(&self) -> Option<Tier> {
match std::env::var("KIMETSU_TIER") {
Ok(raw) => raw.parse::<Tier>().ok().or(self.kimetsu.tier),
Err(_) => self.kimetsu.tier,
}
}
pub fn tier_downgraded(&self) -> bool {
self.tier_requested() == Some(Tier::Deep) && self.cheap_model().is_none()
}
pub fn allows_model_in_pipeline(&self) -> bool {
self.tier().allows_model()
}
pub fn allows_automatic_harvest(&self) -> bool {
self.learning.auto_harvest && self.allows_model_in_pipeline()
}
pub fn from_toml(value: &str) -> KimetsuResult<Self> {
Ok(toml::from_str(value)?)
}
pub fn to_toml(&self) -> KimetsuResult<String> {
Ok(toml::to_string_pretty(self)?)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KimetsuSection {
pub project_id: String,
pub schema_version: i64,
#[serde(default = "default_true")]
pub use_user_brain: bool,
#[serde(default = "default_true")]
pub mcp_write_tools: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tier: Option<Tier>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Tier {
#[default]
Free,
Deep,
}
impl Tier {
pub fn allows_model(self) -> bool {
matches!(self, Tier::Deep)
}
pub fn as_str(self) -> &'static str {
match self {
Tier::Free => "free",
Tier::Deep => "deep",
}
}
}
impl std::str::FromStr for Tier {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"free" => Ok(Tier::Free),
"deep" => Ok(Tier::Deep),
other => Err(format!("unknown tier `{other}` (expected free or deep)")),
}
}
}
impl std::fmt::Display for Tier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbedderSection {
#[serde(default = "default_embedder_id")]
pub model: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub daemon: bool,
#[serde(default = "default_true")]
pub warm_on_start: bool,
#[serde(default = "default_reranker_id")]
pub reranker: String,
}
fn default_embedder_id() -> String {
"jina-v2-base-code".to_string()
}
fn default_reranker_id() -> String {
"ms-marco-tinybert-l-2-v2".to_string()
}
fn default_true() -> bool {
true
}
impl Default for EmbedderSection {
fn default() -> Self {
Self {
model: default_embedder_id(),
enabled: default_true(),
daemon: default_true(),
warm_on_start: default_true(),
reranker: default_reranker_id(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrievalSection {
#[serde(default = "default_retrieval_level")]
pub level: String,
}
impl Default for RetrievalSection {
fn default() -> Self {
Self {
level: default_retrieval_level(),
}
}
}
fn default_retrieval_level() -> String {
"custom".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LearningSection {
#[serde(default = "default_auto_harvest")]
pub auto_harvest: bool,
#[serde(default = "default_true")]
pub store_queries: bool,
#[serde(default)]
pub distiller: DistillerSection,
}
fn default_auto_harvest() -> bool {
true
}
impl Default for LearningSection {
fn default() -> Self {
Self {
auto_harvest: default_auto_harvest(),
store_queries: default_true(),
distiller: DistillerSection::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DistillerSection {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_distiller_provider")]
pub provider: String,
#[serde(default = "default_distiller_model")]
pub model: String,
#[serde(default = "default_distiller_api_key_env")]
pub api_key_env: String,
#[serde(default = "default_distiller_base_url_env")]
pub base_url_env: String,
#[serde(default)]
pub region: Option<String>,
#[serde(default = "default_distiller_region_env")]
pub region_env: String,
}
fn default_distiller_provider() -> String {
"anthropic".to_string()
}
fn default_distiller_model() -> String {
"claude-haiku-4-5".to_string()
}
fn default_distiller_api_key_env() -> String {
"ANTHROPIC_API_KEY".to_string()
}
fn default_distiller_base_url_env() -> String {
"ANTHROPIC_BASE_URL".to_string()
}
fn default_distiller_region_env() -> String {
"AWS_REGION".to_string()
}
impl Default for DistillerSection {
fn default() -> Self {
Self {
enabled: false,
provider: default_distiller_provider(),
model: default_distiller_model(),
api_key_env: default_distiller_api_key_env(),
base_url_env: default_distiller_base_url_env(),
region: None,
region_env: default_distiller_region_env(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheapModelSection {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_cheap_model_provider")]
pub provider: String,
#[serde(default = "default_cheap_model_model")]
pub model: String,
#[serde(default = "default_cheap_model_api_key_env")]
pub api_key_env: String,
#[serde(default = "default_cheap_model_base_url_env")]
pub base_url_env: String,
#[serde(default)]
pub region: Option<String>,
#[serde(default = "default_cheap_model_region_env")]
pub region_env: String,
}
fn default_cheap_model_provider() -> String {
"anthropic".to_string()
}
fn default_cheap_model_model() -> String {
"claude-haiku-4-5".to_string()
}
fn default_cheap_model_api_key_env() -> String {
"ANTHROPIC_API_KEY".to_string()
}
fn default_cheap_model_base_url_env() -> String {
"ANTHROPIC_BASE_URL".to_string()
}
fn default_cheap_model_region_env() -> String {
"AWS_REGION".to_string()
}
impl Default for CheapModelSection {
fn default() -> Self {
Self {
enabled: false,
provider: default_cheap_model_provider(),
model: default_cheap_model_model(),
api_key_env: default_cheap_model_api_key_env(),
base_url_env: default_cheap_model_base_url_env(),
region: None,
region_env: default_cheap_model_region_env(),
}
}
}
impl CheapModelSection {
pub const OLLAMA_DEFAULT_BASE_URL: &'static str = "http://localhost:11434/v1";
pub fn from_distiller(d: &DistillerSection) -> Self {
Self {
enabled: d.enabled,
provider: d.provider.clone(),
model: d.model.clone(),
api_key_env: d.api_key_env.clone(),
base_url_env: d.base_url_env.clone(),
region: d.region.clone(),
region_env: d.region_env.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageSection {
#[serde(default = "default_storage_backend")]
pub backend: String,
}
fn default_storage_backend() -> String {
"graph-lite".to_string()
}
impl Default for StorageSection {
fn default() -> Self {
Self {
backend: default_storage_backend(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelSection {
pub provider: String,
pub model: String,
pub api_key_env: String,
pub max_output_tokens: u32,
pub temperature: f32,
pub request_timeout_secs: u64,
#[serde(default)]
pub region: Option<String>,
#[serde(default = "default_region_env")]
pub region_env: String,
#[serde(default)]
pub price_per_mtok: Option<f64>,
}
fn default_region_env() -> String {
"AWS_REGION".to_string()
}
impl Default for ModelSection {
fn default() -> Self {
Self {
provider: "anthropic".to_string(),
model: "claude-opus-4-7".to_string(),
api_key_env: "ANTHROPIC_API_KEY".to_string(),
max_output_tokens: 8192,
temperature: 0.2,
request_timeout_secs: 120,
region: None,
region_env: default_region_env(),
price_per_mtok: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrokerSection {
pub default_budget_tokens: u32,
pub weights: BrokerWeights,
#[serde(default = "default_max_capsules")]
pub max_capsules: usize,
#[serde(default = "default_min_semantic_score")]
pub min_semantic_score: f32,
#[serde(default = "default_min_lexical_coverage")]
pub min_lexical_coverage: f32,
#[serde(default = "default_fusion")]
pub fusion: String,
#[serde(default = "default_normalization")]
pub normalization: String,
#[serde(default = "default_abstain_min_score")]
pub abstain_min_score: f32,
#[serde(
default = "default_rerank_min_score",
deserialize_with = "deserialize_rerank_min_score"
)]
pub rerank_min_score: f32,
#[serde(default)]
pub explicit_fact_guard: bool,
#[serde(default = "default_budget_floor_tokens")]
pub budget_floor_tokens: u32,
#[serde(default = "default_budget_run_cap_tokens")]
pub budget_run_cap_tokens: u32,
#[serde(default = "default_true")]
pub ambient: bool,
#[serde(default = "default_true")]
pub compress_capsules: bool,
#[serde(default = "default_true")]
pub session_dedupe: bool,
#[serde(default = "default_true")]
pub warm_start: bool,
#[serde(default = "default_answer_grade_min_score")]
pub answer_grade_min_score: f32,
#[serde(default)]
pub proactive_prefetch: bool,
}
fn default_max_capsules() -> usize {
8
}
fn default_min_semantic_score() -> f32 {
-1.0
}
fn default_fusion() -> String {
"linear".to_string()
}
fn default_normalization() -> String {
"per_kind".to_string()
}
fn default_min_lexical_coverage() -> f32 {
0.5
}
fn default_abstain_min_score() -> f32 {
0.0
}
fn default_budget_floor_tokens() -> u32 {
1500
}
fn default_budget_run_cap_tokens() -> u32 {
8000
}
fn default_answer_grade_min_score() -> f32 {
0.92
}
fn default_rerank_min_score() -> f32 {
0.30
}
fn deserialize_rerank_min_score<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<f32, D::Error> {
let value = f32::deserialize(deserializer)?;
if value.is_finite() && (0.0..=1.0).contains(&value) {
Ok(value)
} else {
Err(serde::de::Error::custom(
"rerank_min_score must be finite and between 0 and 1",
))
}
}
impl Default for BrokerSection {
fn default() -> Self {
Self {
default_budget_tokens: 6000,
weights: BrokerWeights::default(),
max_capsules: default_max_capsules(),
min_semantic_score: default_min_semantic_score(),
min_lexical_coverage: default_min_lexical_coverage(),
fusion: default_fusion(),
normalization: default_normalization(),
abstain_min_score: default_abstain_min_score(),
rerank_min_score: default_rerank_min_score(),
explicit_fact_guard: false,
budget_floor_tokens: default_budget_floor_tokens(),
budget_run_cap_tokens: default_budget_run_cap_tokens(),
ambient: default_true(),
compress_capsules: default_true(),
session_dedupe: default_true(),
warm_start: default_true(),
answer_grade_min_score: default_answer_grade_min_score(),
proactive_prefetch: false,
}
}
}
pub fn adaptive_budget(task_size: u32, floor: u32, run_cap: u32) -> u32 {
if task_size == 0 {
return floor;
}
const K_SCALED: u32 = 3182; let sqrt_part = (task_size as f64).sqrt();
let budget_f = floor as f64 + (K_SCALED as f64 / 10.0) * sqrt_part;
let budget = budget_f.round() as u32;
budget.clamp(floor, run_cap)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrokerWeights {
pub relevance: f32,
pub confidence: f32,
pub freshness: f32,
pub scope: f32,
pub localization: Option<StageWeights>,
pub patch_plan: Option<StageWeights>,
pub verification: Option<StageWeights>,
pub review: Option<StageWeights>,
#[serde(default = "default_decay_half_life_days")]
pub decay_half_life_days: f32,
}
fn default_decay_half_life_days() -> f32 {
30.0
}
impl Default for BrokerWeights {
fn default() -> Self {
Self {
relevance: 0.50,
confidence: 0.20,
freshness: 0.20,
scope: 0.10,
localization: Some(StageWeights {
relevance: 0.70,
confidence: 0.10,
freshness: 0.10,
scope: 0.10,
}),
patch_plan: Some(StageWeights {
relevance: 0.40,
confidence: 0.30,
freshness: 0.10,
scope: 0.20,
}),
verification: Some(StageWeights {
relevance: 0.40,
confidence: 0.10,
freshness: 0.40,
scope: 0.10,
}),
review: Some(StageWeights {
relevance: 0.50,
confidence: 0.20,
freshness: 0.20,
scope: 0.10,
}),
decay_half_life_days: default_decay_half_life_days(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageWeights {
pub relevance: f32,
pub confidence: f32,
pub freshness: f32,
pub scope: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShellSection {
pub default_timeout_secs: u64,
pub max_timeout_secs: u64,
pub env_allowlist_extra: Vec<String>,
pub redact_secrets: bool,
}
impl Default for ShellSection {
fn default() -> Self {
Self {
default_timeout_secs: 60,
max_timeout_secs: 600,
env_allowlist_extra: vec!["RUSTFLAGS".to_string(), "CARGO_HOME".to_string()],
redact_secrets: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IngestionSection {
pub max_file_bytes: u64,
pub extra_skip_dirs: Vec<String>,
pub max_total_files: u64,
#[serde(default = "default_true")]
pub detect_conflicts: bool,
#[serde(default)]
pub resolve_conflicts: bool,
#[serde(default = "default_true")]
pub initial_importance_scoring: bool,
#[serde(default = "default_true")]
pub quality_filter_enabled: bool,
#[serde(default = "default_quality_filter_novelty_threshold")]
pub quality_filter_novelty_threshold: f32,
#[serde(default = "default_quality_filter_min_len")]
pub quality_filter_min_len: usize,
#[serde(default = "default_quality_filter_max_len")]
pub quality_filter_max_len: usize,
#[serde(default = "default_transient_ttl_days")]
pub transient_ttl_days: u32,
}
fn default_transient_ttl_days() -> u32 {
7
}
fn default_quality_filter_novelty_threshold() -> f32 {
0.9
}
fn default_quality_filter_min_len() -> usize {
10
}
fn default_quality_filter_max_len() -> usize {
500
}
impl Default for IngestionSection {
fn default() -> Self {
Self {
max_file_bytes: 524_288,
extra_skip_dirs: Vec::new(),
max_total_files: 50_000,
detect_conflicts: true,
resolve_conflicts: false,
initial_importance_scoring: true,
quality_filter_enabled: true,
quality_filter_novelty_threshold: default_quality_filter_novelty_threshold(),
quality_filter_min_len: default_quality_filter_min_len(),
quality_filter_max_len: default_quality_filter_max_len(),
transient_ttl_days: default_transient_ttl_days(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunSection {
pub max_total_tool_calls: u32,
pub max_total_model_turns: u32,
pub max_total_cost_usd: f32,
}
impl Default for RunSection {
fn default() -> Self {
Self {
max_total_tool_calls: 60,
max_total_model_turns: 30,
max_total_cost_usd: 250.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncSection {
#[serde(default)]
pub dir: Option<String>,
#[serde(default)]
pub machine_id: String,
#[serde(default = "default_sync_auto")]
pub auto: bool,
}
fn default_sync_auto() -> bool {
true
}
impl Default for SyncSection {
fn default() -> Self {
Self {
dir: None,
machine_id: String::new(),
auto: default_sync_auto(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LifecycleSection {
#[serde(default)]
pub forget_enabled: bool,
#[serde(default = "default_forget_min_age_days")]
pub forget_min_age_days: u32,
#[serde(default = "default_forget_usefulness_floor")]
pub forget_usefulness_floor: f32,
#[serde(default = "default_forget_protect_use_count")]
pub forget_protect_use_count: u32,
#[serde(default = "default_regret_flag_threshold")]
pub regret_flag_threshold: u64,
#[serde(default = "default_proposal_expiry_days")]
pub proposal_expiry_days: u32,
#[serde(default = "default_proposal_auto_accept_confidence")]
pub proposal_auto_accept_confidence: f32,
}
fn default_forget_min_age_days() -> u32 {
90
}
fn default_forget_usefulness_floor() -> f32 {
-0.1
}
fn default_forget_protect_use_count() -> u32 {
10
}
fn default_regret_flag_threshold() -> u64 {
5
}
fn default_proposal_expiry_days() -> u32 {
30
}
fn default_proposal_auto_accept_confidence() -> f32 {
1.1 }
impl Default for LifecycleSection {
fn default() -> Self {
Self {
forget_enabled: false,
forget_min_age_days: default_forget_min_age_days(),
forget_usefulness_floor: default_forget_usefulness_floor(),
forget_protect_use_count: default_forget_protect_use_count(),
regret_flag_threshold: default_regret_flag_threshold(),
proposal_expiry_days: default_proposal_expiry_days(),
proposal_auto_accept_confidence: default_proposal_auto_accept_confidence(),
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn explicit_fact_guard_is_opt_in_and_round_trips() {
let default = ProjectConfig::default_for_project("guard");
assert!(!default.broker.explicit_fact_guard);
for enabled in [false, true] {
let mut value = serde_json::to_value(&default).unwrap();
value["broker"]["explicit_fact_guard"] = serde_json::json!(enabled);
let config: ProjectConfig = serde_json::from_value(value).unwrap();
assert_eq!(
serde_json::to_value(config).unwrap()["broker"]["explicit_fact_guard"],
enabled
);
}
}
#[test]
fn rerank_cutoff_survives_configuration_roundtrip_and_rejects_invalid_values() {
let mut value = serde_json::to_value(ProjectConfig::default_for_project("cutoff")).unwrap();
value["broker"]["rerank_min_score"] = serde_json::json!(0.75);
let config: ProjectConfig = serde_json::from_value(value.clone()).unwrap();
assert_eq!(
serde_json::to_value(config).unwrap()["broker"]["rerank_min_score"],
0.75
);
for invalid in [-0.1, 1.1] {
value["broker"]["rerank_min_score"] = serde_json::json!(invalid);
assert!(serde_json::from_value::<ProjectConfig>(value.clone()).is_err());
}
}
use super::*;
fn enabled_cheap_model() -> CheapModelSection {
CheapModelSection {
enabled: true,
..CheapModelSection::default()
}
}
#[test]
fn hardening_automatic_harvest_policy_matrix() {
for tier in [None, Some(Tier::Free), Some(Tier::Deep)] {
for model in [false, true] {
for automatic in [false, true] {
let mut config = ProjectConfig::default_for_project("harvest-matrix");
config.kimetsu.tier = tier;
config.cheap_model = model.then(enabled_cheap_model);
config.learning.auto_harvest = automatic;
assert_eq!(
config.allows_automatic_harvest(),
automatic && model && tier != Some(Tier::Free),
"tier={tier:?} model={model} automatic={automatic}"
);
}
}
}
}
#[test]
fn tier_defaults_to_free_without_a_model() {
let config = ProjectConfig::default_for_project("test");
assert_eq!(config.tier(), Tier::Free);
assert!(!config.tier_downgraded());
}
#[test]
fn tier_auto_resolves_to_deep_when_a_model_is_configured() {
let mut config = ProjectConfig::default_for_project("test");
config.cheap_model = Some(enabled_cheap_model());
assert_eq!(config.tier_requested(), None, "field left at auto");
assert_eq!(config.tier(), Tier::Deep);
}
#[test]
fn tier_auto_follows_the_legacy_distiller_alias() {
let mut config = ProjectConfig::default_for_project("test");
config.learning.distiller.enabled = true;
assert_eq!(config.tier(), Tier::Deep);
}
#[test]
fn explicit_free_overrides_a_configured_model() {
let mut config = ProjectConfig::default_for_project("test");
config.cheap_model = Some(enabled_cheap_model());
config.kimetsu.tier = Some(Tier::Free);
assert_eq!(config.tier(), Tier::Free);
assert!(!config.allows_model_in_pipeline());
}
#[test]
fn deep_without_a_model_downgrades_and_is_flagged() {
let mut config = ProjectConfig::default_for_project("test");
config.kimetsu.tier = Some(Tier::Deep);
assert_eq!(config.tier(), Tier::Free, "no model — nothing to run");
assert!(
config.tier_downgraded(),
"the discrepancy must be reportable, not silent"
);
}
#[test]
fn tier_round_trips_and_auto_stays_unwritten() {
let config = ProjectConfig::default_for_project("test");
let toml = config.to_toml().expect("to_toml");
assert!(
!toml.contains("tier"),
"auto must not serialize a tier field; got:\n{toml}"
);
let mut deep = ProjectConfig::default_for_project("test");
deep.kimetsu.tier = Some(Tier::Deep);
let toml = deep.to_toml().expect("to_toml");
assert!(toml.contains("tier = \"deep\""), "got:\n{toml}");
let parsed = ProjectConfig::from_toml(&toml).expect("from_toml");
assert_eq!(parsed.kimetsu.tier, Some(Tier::Deep));
}
#[test]
fn missing_tier_field_loads_cleanly() {
let mut written = ProjectConfig::default_for_project("legacy");
written.kimetsu.tier = Some(Tier::Deep);
let toml = written.to_toml().expect("to_toml");
let legacy: String = toml
.lines()
.filter(|line| !line.trim_start().starts_with("tier ="))
.collect::<Vec<_>>()
.join("\n");
assert!(
!legacy.contains("tier ="),
"fixture must have no tier field"
);
let config = ProjectConfig::from_toml(&legacy).expect("legacy config must load");
assert_eq!(config.kimetsu.tier, None);
assert_eq!(config.tier(), Tier::Free);
}
#[test]
fn pre_v0_8_config_without_embedder_loads_with_default() {
let toml = r#"
[kimetsu]
project_id = "demo"
schema_version = 7
[model]
provider = "anthropic"
model = "claude-opus-4-7"
api_key_env = "ANTHROPIC_API_KEY"
max_output_tokens = 8192
temperature = 0.2
request_timeout_secs = 120
[broker]
default_budget_tokens = 6000
[broker.weights]
relevance = 0.5
confidence = 0.2
freshness = 0.2
scope = 0.1
[shell]
default_timeout_secs = 60
max_timeout_secs = 600
env_allowlist_extra = []
redact_secrets = true
[ingestion]
max_file_bytes = 524288
extra_skip_dirs = []
max_total_files = 50000
[run]
max_total_tool_calls = 60
max_total_model_turns = 30
max_total_cost_usd = 250.0
"#;
let config = ProjectConfig::from_toml(toml).expect("pre-v0.8 toml must load");
assert_eq!(config.embedder.model, "jina-v2-base-code");
assert!(config.learning.auto_harvest);
assert!(!config.learning.distiller.enabled);
assert_eq!(config.learning.distiller.provider, "anthropic");
assert_eq!(config.learning.distiller.model, "claude-haiku-4-5");
assert_eq!(config.learning.distiller.api_key_env, "ANTHROPIC_API_KEY");
assert_eq!(config.learning.distiller.base_url_env, "ANTHROPIC_BASE_URL");
assert_eq!(config.broker.max_capsules, 8);
assert_eq!(config.broker.min_semantic_score, -1.0, "auto sentinel");
assert_eq!(config.broker.min_lexical_coverage, 0.5);
assert_eq!(config.broker.budget_floor_tokens, 1500);
assert_eq!(config.broker.budget_run_cap_tokens, 8000);
assert!(
config.embedder.enabled,
"W3.1: embedder.enabled must default to true"
);
assert!(
config.broker.ambient,
"W3.2: broker.ambient must default to true"
);
assert!(
config.kimetsu.use_user_brain,
"W3.3: kimetsu.use_user_brain must default to true"
);
assert!(
config.embedder.daemon,
"embedder.daemon must default to true"
);
assert!(
config.embedder.warm_on_start,
"embedder.warm_on_start must default to true"
);
assert!(
config.kimetsu.mcp_write_tools,
"kimetsu.mcp_write_tools must default to true"
);
assert_eq!(
config.embedder.reranker, "ms-marco-tinybert-l-2-v2",
"embedder.reranker must default to ms-marco-tinybert-l-2-v2"
);
assert!(
config.learning.store_queries,
"learning.store_queries must default to true"
);
assert!(
config.broker.compress_capsules,
"broker.compress_capsules must default to true"
);
assert!(
config.broker.session_dedupe,
"broker.session_dedupe must default to true"
);
assert!(
config.broker.warm_start,
"broker.warm_start must default to true"
);
assert_eq!(
config.storage.backend, "graph-lite",
"storage.backend must default to \"graph-lite\" when absent"
);
assert!(
(config.broker.answer_grade_min_score - 0.92).abs() < f32::EPSILON,
"broker.answer_grade_min_score must default to 0.92"
);
assert!(
!config.broker.proactive_prefetch,
"broker.proactive_prefetch must default to false (opt-in)"
);
assert!(
config.sync.dir.is_none(),
"sync.dir must default to None when absent"
);
assert!(
config.sync.machine_id.is_empty(),
"sync.machine_id must default to empty string when absent"
);
assert_eq!(
config.retrieval.level, "custom",
"retrieval.level must default to \"custom\" when absent"
);
}
#[test]
fn retrieval_level_resolves_embedder_and_reranker() {
let mut basic = ProjectConfig::default_for_project("p");
basic.retrieval.level = "basic".to_string();
basic.apply_retrieval_level();
assert!(!basic.embedder.enabled);
assert_eq!(basic.embedder.reranker, "off");
assert!(!basic.hyde_from_level());
let mut flexible = ProjectConfig::default_for_project("p");
flexible.retrieval.level = "flexible".to_string();
flexible.apply_retrieval_level();
assert!(flexible.embedder.enabled);
assert_eq!(flexible.embedder.reranker, "off");
assert!(!flexible.hyde_from_level());
let mut deep = ProjectConfig::default_for_project("p");
deep.retrieval.level = "deep".to_string();
deep.apply_retrieval_level();
assert!(deep.embedder.enabled);
assert_eq!(deep.embedder.reranker, "ms-marco-tinybert-l-2-v2");
assert!(!deep.hyde_from_level());
let mut advanced = ProjectConfig::default_for_project("p");
advanced.retrieval.level = "advanced".to_string();
advanced.apply_retrieval_level();
assert!(advanced.embedder.enabled);
assert_eq!(advanced.embedder.reranker, "ms-marco-tinybert-l-2-v2");
assert!(
advanced.hyde_from_level(),
"advanced level must enable HyDE"
);
let mut custom = ProjectConfig::default_for_project("p");
custom.retrieval.level = "custom".to_string();
custom.embedder.enabled = false;
custom.embedder.reranker = "bge-reranker-base".to_string();
custom.apply_retrieval_level();
assert!(
!custom.embedder.enabled,
"custom must not touch embedder.enabled"
);
assert_eq!(
custom.embedder.reranker, "bge-reranker-base",
"custom must not touch embedder.reranker"
);
assert!(!custom.hyde_from_level());
let mut unknown = ProjectConfig::default_for_project("p");
unknown.retrieval.level = "bogus".to_string();
unknown.embedder.enabled = false;
unknown.apply_retrieval_level();
assert!(!unknown.embedder.enabled, "unknown level must be a no-op");
}
#[test]
fn retrieval_level_never_reenables_explicit_reranker_off() {
for level in ["deep", "advanced"] {
let mut config = ProjectConfig::default_for_project("off");
config.retrieval.level = level.into();
config.embedder.reranker = "off".into();
config.apply_retrieval_level();
assert_eq!(config.embedder.reranker, "off");
assert!(config.embedder.enabled);
}
}
#[test]
fn retrieval_level_never_overrides_embedder_off_switch() {
for level in &["basic", "flexible", "deep", "advanced"] {
let mut cfg = ProjectConfig::default_for_project("p");
cfg.retrieval.level = level.to_string();
cfg.embedder.enabled = false;
let reranker_before = cfg.embedder.reranker.clone();
cfg.apply_retrieval_level();
assert!(
!cfg.embedder.enabled,
"level {level} must not re-enable a disabled embedder"
);
assert_eq!(
cfg.embedder.reranker, reranker_before,
"level {level} must not touch the reranker when the embedder is off"
);
}
}
#[test]
fn default_config_uses_config_version_not_schema_version() {
let cfg = ProjectConfig::default_for_project("p1");
assert_eq!(cfg.kimetsu.schema_version, crate::KIMETSU_CONFIG_VERSION);
}
#[test]
fn embedder_survives_toml_round_trip() {
let mut config = ProjectConfig::default_for_project("demo");
config.embedder.model = "bge-m3".to_string();
let serialized = config.to_toml().expect("serialize");
let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
assert_eq!(reloaded.embedder.model, "bge-m3");
assert_eq!(reloaded.broker.default_budget_tokens, 6000);
assert_eq!(reloaded.kimetsu.project_id, "demo");
assert_eq!(reloaded.broker.budget_floor_tokens, 1500);
assert_eq!(reloaded.broker.budget_run_cap_tokens, 8000);
assert!(reloaded.embedder.enabled);
assert!(reloaded.broker.ambient);
assert!(reloaded.kimetsu.use_user_brain);
}
#[test]
fn w3_off_switch_fields_round_trip_as_false() {
let mut config = ProjectConfig::default_for_project("demo");
config.embedder.enabled = false;
config.broker.ambient = false;
config.kimetsu.use_user_brain = false;
let serialized = config.to_toml().expect("serialize");
let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
assert!(
!reloaded.embedder.enabled,
"embedder.enabled must survive as false"
);
assert!(
!reloaded.broker.ambient,
"broker.ambient must survive as false"
);
assert!(
!reloaded.kimetsu.use_user_brain,
"kimetsu.use_user_brain must survive as false"
);
assert_eq!(reloaded.kimetsu.project_id, "demo");
}
#[test]
fn f3_adaptive_budget_zero_size_returns_floor() {
assert_eq!(
super::adaptive_budget(0, 1500, 8000),
1500,
"task_size=0 must return floor"
);
}
#[test]
fn f3_adaptive_budget_huge_size_clamped_to_run_cap() {
let result = super::adaptive_budget(1_000_000, 1500, 8000);
assert_eq!(result, 8000, "huge task_size must be clamped to run_cap");
}
#[test]
fn f3_adaptive_budget_is_sublinear() {
let floor = 1500u32;
let run_cap = 16_000u32;
let t0 = 200u32;
let b_t0 = super::adaptive_budget(t0, floor, run_cap);
let b_5t0 = super::adaptive_budget(5 * t0, floor, run_cap);
assert!(
b_5t0 < 2 * b_t0,
"sublinear guarantee: adaptive_budget(5*T)={b_5t0} must be < 2*adaptive_budget(T)={} (T={t0})",
2 * b_t0
);
assert!(
b_5t0 > b_t0,
"budget must still grow: adaptive_budget(5*T)={b_5t0} > adaptive_budget(T)={b_t0}"
);
}
#[test]
fn f3_adaptive_budget_typical_task_near_historical_default() {
let budget = super::adaptive_budget(200, 1500, 8000);
assert!(
(5700..=8000).contains(&budget),
"typical task budget expected near 6000, got {budget}"
);
}
#[test]
fn f3_adaptive_budget_respects_floor() {
for size in [1u32, 5, 10, 50] {
let b = super::adaptive_budget(size, 1500, 8000);
assert!(
b >= 1500,
"task_size={size}: budget={b} must be >= floor=1500"
);
}
}
#[test]
fn f3_adaptive_budget_respects_run_cap() {
for size in [500u32, 1000, 5000, 100_000] {
let b = super::adaptive_budget(size, 1500, 8000);
assert!(
b <= 8000,
"task_size={size}: budget={b} must be <= run_cap=8000"
);
}
}
#[test]
fn pre_v1_5_config_without_price_per_mtok_loads_with_none() {
let toml = r#"
[kimetsu]
project_id = "demo"
schema_version = 7
[model]
provider = "anthropic"
model = "claude-sonnet-4-7"
api_key_env = "ANTHROPIC_API_KEY"
max_output_tokens = 8192
temperature = 0.2
request_timeout_secs = 120
[broker]
default_budget_tokens = 6000
[broker.weights]
relevance = 0.5
confidence = 0.2
freshness = 0.2
scope = 0.1
[shell]
default_timeout_secs = 60
max_timeout_secs = 600
env_allowlist_extra = []
redact_secrets = true
[ingestion]
max_file_bytes = 524288
extra_skip_dirs = []
max_total_files = 50000
[run]
max_total_tool_calls = 60
max_total_model_turns = 30
max_total_cost_usd = 250.0
"#;
let config = ProjectConfig::from_toml(toml).expect("pre-v1.5 toml must load");
assert!(
config.model.price_per_mtok.is_none(),
"price_per_mtok must default to None when absent from project.toml"
);
}
#[test]
fn broker_v1_5_fields_round_trip_as_false() {
let mut config = ProjectConfig::default_for_project("demo");
config.broker.compress_capsules = false;
config.broker.session_dedupe = false;
let serialized = config.to_toml().expect("serialize");
let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
assert!(
!reloaded.broker.compress_capsules,
"compress_capsules must survive as false"
);
assert!(
!reloaded.broker.session_dedupe,
"session_dedupe must survive as false"
);
}
#[test]
fn price_per_mtok_round_trips() {
let mut config = ProjectConfig::default_for_project("demo");
config.model.price_per_mtok = Some(7.5);
let serialized = config.to_toml().expect("serialize");
let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
assert_eq!(
reloaded.model.price_per_mtok,
Some(7.5),
"price_per_mtok must round-trip"
);
}
#[test]
fn s1_2_a_learning_distiller_back_compat() {
let mut config = ProjectConfig::default_for_project("demo");
config.learning.distiller.enabled = true;
config.learning.distiller.provider = "openai".to_string();
config.learning.distiller.model = "gpt-5.4-mini".to_string();
config.cheap_model = None;
let resolved = config.cheap_model().expect("back-compat must resolve");
assert_eq!(resolved.provider, "openai");
assert_eq!(resolved.model, "gpt-5.4-mini");
assert!(resolved.enabled);
}
#[test]
fn s1_2_b_cheap_model_takes_precedence() {
let mut config = ProjectConfig::default_for_project("demo");
config.learning.distiller.enabled = true;
config.learning.distiller.provider = "anthropic".to_string();
config.learning.distiller.model = "claude-haiku-4-5".to_string();
config.cheap_model = Some(super::CheapModelSection {
enabled: true,
provider: "ollama".to_string(),
model: "qwen2.5:3b".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url_env: "OLLAMA_BASE_URL".to_string(),
region: None,
region_env: "AWS_REGION".to_string(),
});
let resolved = config.cheap_model().expect("cheap_model must resolve");
assert_eq!(
resolved.provider, "ollama",
"[cheap_model] must win over [learning.distiller]"
);
assert_eq!(resolved.model, "qwen2.5:3b");
}
#[test]
fn s1_2_c_ollama_default_base_url() {
assert_eq!(
super::CheapModelSection::OLLAMA_DEFAULT_BASE_URL,
"http://localhost:11434/v1",
"ollama default base URL must point to localhost:11434/v1"
);
let mut config = ProjectConfig::default_for_project("demo");
config.cheap_model = Some(super::CheapModelSection {
enabled: true,
provider: "ollama".to_string(),
model: "llama3.2:3b".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url_env: "OLLAMA_BASE_URL".to_string(),
region: None,
region_env: "AWS_REGION".to_string(),
});
let serialized = config.to_toml().expect("serialize");
let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
let cm = reloaded.cheap_model().expect("ollama section must resolve");
assert_eq!(cm.provider, "ollama");
assert_eq!(cm.model, "llama3.2:3b");
}
#[test]
fn s1_2_d_absent_disabled_returns_none() {
let config = ProjectConfig::default_for_project("demo");
assert!(
config.cheap_model().is_none(),
"no cheap model configured: must return None"
);
let mut config2 = ProjectConfig::default_for_project("demo");
config2.cheap_model = Some(super::CheapModelSection {
enabled: false,
..super::CheapModelSection::default()
});
assert!(
config2.cheap_model().is_none(),
"disabled cheap_model must return None"
);
let mut config3 = ProjectConfig::default_for_project("demo");
config3.learning.distiller.enabled = false;
assert!(
config3.cheap_model().is_none(),
"disabled learning.distiller must return None via back-compat"
);
}
#[test]
fn pre_s1_2_config_without_cheap_model_loads_cleanly() {
let toml = r#"
[kimetsu]
project_id = "demo"
schema_version = 7
[model]
provider = "anthropic"
model = "claude-opus-4-7"
api_key_env = "ANTHROPIC_API_KEY"
max_output_tokens = 8192
temperature = 0.2
request_timeout_secs = 120
[broker]
default_budget_tokens = 6000
[broker.weights]
relevance = 0.5
confidence = 0.2
freshness = 0.2
scope = 0.1
[shell]
default_timeout_secs = 60
max_timeout_secs = 600
env_allowlist_extra = []
redact_secrets = true
[ingestion]
max_file_bytes = 524288
extra_skip_dirs = []
max_total_files = 50000
[run]
max_total_tool_calls = 60
max_total_model_turns = 30
max_total_cost_usd = 250.0
"#;
let config = ProjectConfig::from_toml(toml).expect("pre-S1.2 toml must load");
assert!(
config.cheap_model.is_none(),
"cheap_model field must be None when absent from project.toml"
);
assert!(
config.cheap_model().is_none(),
"cheap_model() must return None when no cheap model is configured"
);
}
#[test]
fn s5_1_storage_backend_round_trips() {
for variant in &["flat", "graph-lite", "graph"] {
let mut config = ProjectConfig::default_for_project("demo");
config.storage.backend = (*variant).to_string();
let serialized = config.to_toml().expect("serialize");
let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
assert_eq!(
reloaded.storage.backend, *variant,
"storage.backend=\"{}\" must round-trip",
variant
);
}
}
#[test]
fn default_for_project_uses_the_benchmarked_backend() {
let config = ProjectConfig::default_for_project("demo");
assert_eq!(
config.storage.backend, "graph-lite",
"default project config must use the backend the benchmarks measure"
);
}
#[test]
fn f3b_new_broker_fields_round_trip() {
let mut config = ProjectConfig::default_for_project("demo");
config.broker.answer_grade_min_score = 0.85;
config.broker.proactive_prefetch = true;
let serialized = config.to_toml().expect("serialize");
let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
assert!(
(reloaded.broker.answer_grade_min_score - 0.85).abs() < f32::EPSILON,
"answer_grade_min_score must round-trip"
);
assert!(
reloaded.broker.proactive_prefetch,
"proactive_prefetch must round-trip as true"
);
}
#[test]
fn f3b_proactive_prefetch_default_false_round_trips() {
let config = ProjectConfig::default_for_project("demo");
assert!(!config.broker.proactive_prefetch, "default must be false");
let serialized = config.to_toml().expect("serialize");
let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
assert!(
!reloaded.broker.proactive_prefetch,
"default false must survive round-trip"
);
}
#[test]
fn f3b_default_for_project_uses_conservative_defaults() {
let config = ProjectConfig::default_for_project("demo");
assert!(
(config.broker.answer_grade_min_score - 0.92).abs() < f32::EPSILON,
"default answer_grade_min_score must be 0.92"
);
assert!(
!config.broker.proactive_prefetch,
"default proactive_prefetch must be false"
);
}
#[test]
fn s3_pre_s3_config_without_sync_loads_cleanly() {
let toml = r#"
[kimetsu]
project_id = "demo"
schema_version = 7
[model]
provider = "anthropic"
model = "claude-opus-4-7"
api_key_env = "ANTHROPIC_API_KEY"
max_output_tokens = 8192
temperature = 0.2
request_timeout_secs = 120
[broker]
default_budget_tokens = 6000
[broker.weights]
relevance = 0.5
confidence = 0.2
freshness = 0.2
scope = 0.1
[shell]
default_timeout_secs = 60
max_timeout_secs = 600
env_allowlist_extra = []
redact_secrets = true
[ingestion]
max_file_bytes = 524288
extra_skip_dirs = []
max_total_files = 50000
[run]
max_total_tool_calls = 60
max_total_model_turns = 30
max_total_cost_usd = 250.0
"#;
let config = ProjectConfig::from_toml(toml).expect("pre-S3 toml must load");
assert!(
config.sync.dir.is_none(),
"sync.dir must default to None when absent"
);
assert!(
config.sync.machine_id.is_empty(),
"sync.machine_id must default to empty string when absent"
);
}
#[test]
fn s3_sync_section_round_trips() {
let mut config = ProjectConfig::default_for_project("demo");
config.sync.dir = Some("/tmp/kimetsu-sync".to_string());
config.sync.machine_id = "my-laptop-01".to_string();
let serialized = config.to_toml().expect("serialize");
let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
assert_eq!(
reloaded.sync.dir,
Some("/tmp/kimetsu-sync".to_string()),
"sync.dir must round-trip"
);
assert_eq!(
reloaded.sync.machine_id, "my-laptop-01",
"sync.machine_id must round-trip"
);
}
#[test]
fn s3_default_for_project_sync_unconfigured() {
let config = ProjectConfig::default_for_project("demo");
assert!(config.sync.dir.is_none(), "default sync.dir must be None");
assert!(
config.sync.machine_id.is_empty(),
"default sync.machine_id must be empty"
);
}
}