kimetsu_core/config.rs
1use serde::{Deserialize, Serialize};
2
3use crate::{KIMETSU_CONFIG_VERSION, KimetsuResult};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ProjectConfig {
7 pub kimetsu: KimetsuSection,
8 pub model: ModelSection,
9 pub broker: BrokerSection,
10 pub shell: ShellSection,
11 pub ingestion: IngestionSection,
12 pub run: RunSection,
13 /// v0.8: which built-in embedding model the brain uses. The
14 /// `#[serde(default)]` keeps every pre-v0.8 project.toml loading
15 /// cleanly (they get the lean English default). Resolution
16 /// precedence is `KIMETSU_BRAIN_EMBEDDER` env > this field >
17 /// default; see `kimetsu_brain::embeddings::resolve_embedder_id`.
18 #[serde(default)]
19 pub embedder: EmbedderSection,
20 /// v0.8.5: automatic memory harvesting. `#[serde(default)]` keeps
21 /// pre-v0.8.5 project.toml files loading cleanly (they get
22 /// auto-harvest on).
23 #[serde(default)]
24 pub learning: LearningSection,
25 /// S1.2: top-level cheap-model override. When present, takes
26 /// precedence over `[learning.distiller]` as the resolved cheap
27 /// model for all consumers (distiller, consolidation, future
28 /// digest/ask). Entirely optional — when absent the resolver falls
29 /// back to `[learning.distiller]` for back-compat. `#[serde(default)]`
30 /// keeps all existing project.toml files loading unchanged.
31 #[serde(default)]
32 pub cheap_model: Option<CheapModelSection>,
33 /// S5.1: storage / retrieval backend selection. `#[serde(default)]`
34 /// keeps every existing project.toml loading cleanly (they get
35 /// `backend = "flat"`, the current FTS + usearch-ANN path).
36 #[serde(default)]
37 pub storage: StorageSection,
38 /// Epic S3: personal brain sync via event-log replication.
39 /// `#[serde(default)]` keeps every pre-S3 project.toml loading cleanly
40 /// (they get no sync dir and a freshly-generated machine_id).
41 #[serde(default)]
42 pub sync: SyncSection,
43}
44
45impl ProjectConfig {
46 pub fn default_for_project(project_id: impl Into<String>) -> Self {
47 Self {
48 kimetsu: KimetsuSection {
49 project_id: project_id.into(),
50 schema_version: KIMETSU_CONFIG_VERSION,
51 use_user_brain: default_true(),
52 mcp_write_tools: default_true(),
53 },
54 model: ModelSection::default(),
55 broker: BrokerSection::default(),
56 shell: ShellSection::default(),
57 ingestion: IngestionSection::default(),
58 run: RunSection::default(),
59 embedder: EmbedderSection::default(),
60 learning: LearningSection::default(),
61 cheap_model: None,
62 storage: StorageSection::default(),
63 sync: SyncSection::default(),
64 }
65 }
66
67 /// S1.2: resolve the effective cheap-model config.
68 ///
69 /// Resolution order (first wins):
70 /// 1. `[cheap_model]` if present AND `enabled = true` — explicit top-level
71 /// section introduced in S1.2.
72 /// 2. `[learning.distiller]` if `enabled = true` — back-compat alias so
73 /// any existing config with `[learning.distiller]` keeps working with
74 /// zero changes.
75 /// 3. `None` — no cheap model configured; consumers degrade gracefully
76 /// (no panic, feature just does not run — same as distiller-absent
77 /// behaviour before S1.2).
78 ///
79 /// FUTURE consumers (digest/resume/skill/ask) must call this resolver so
80 /// resolution stays in ONE place.
81 pub fn cheap_model(&self) -> Option<CheapModelSection> {
82 if let Some(ref cm) = self.cheap_model {
83 if cm.enabled {
84 return Some(cm.clone());
85 }
86 }
87 // Back-compat: treat an enabled [learning.distiller] as the cheap model.
88 if self.learning.distiller.enabled {
89 return Some(CheapModelSection::from_distiller(&self.learning.distiller));
90 }
91 None
92 }
93
94 pub fn from_toml(value: &str) -> KimetsuResult<Self> {
95 Ok(toml::from_str(value)?)
96 }
97
98 pub fn to_toml(&self) -> KimetsuResult<String> {
99 Ok(toml::to_string_pretty(self)?)
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct KimetsuSection {
105 pub project_id: String,
106 pub schema_version: i64,
107 /// W3.3: per-project opt-out of the global cross-project user brain
108 /// (`~/.kimetsu/brain.db`). When false, GlobalUser writes fall back to
109 /// the project DB and retrieval skips the user-brain merge — identical
110 /// to `KIMETSU_USER_BRAIN=0` but durable and scoped to this project.
111 ///
112 /// Precedence: `KIMETSU_USER_BRAIN` env > this field > default (true).
113 /// `#[serde(default)]` keeps all pre-W3 project.toml files loading
114 /// unchanged (they get `use_user_brain = true`).
115 #[serde(default = "default_true")]
116 pub use_user_brain: bool,
117 /// v1.0.0: allow the LOCAL stdio MCP server to expose privileged write
118 /// tools (`kimetsu_brain_record`, `memory_add/accept/reject`, …).
119 /// Default true: the local plugin install on your own machine is the
120 /// "trusted session" the gate exists for, and the brain's own workflow
121 /// (CLAUDE.md guidance, the Stop-hook harvest cue) instructs the agent
122 /// to record lessons — a default-deny gate contradicted that at every
123 /// session end. Personalize via `kimetsu config set
124 /// kimetsu.mcp_write_tools false`. Precedence:
125 /// `KIMETSU_MCP_ENABLE_WRITE_TOOLS` env (set = wins, truthy/falsy) >
126 /// this field > default (true). The REMOTE server ignores this field
127 /// entirely (a cloned repo's project.toml is untrusted there) and
128 /// stays env-only, default-deny.
129 #[serde(default = "default_true")]
130 pub mcp_write_tools: bool,
131}
132
133/// v0.8: embedding-model selection. `model` is one of the curated
134/// built-in ids exposed by `kimetsu brain model list`
135/// (`bge-small-en-v1.5`, `bge-m3`, `jina-v2-base-code`). Switching
136/// changes the vector dimension, so a `kimetsu brain reindex` is
137/// required for cosine retrieval to use the new model.
138///
139/// W3.1: `enabled` is a persistent off-switch for the embedding engine.
140/// When false, the embedder resolves to NoopEmbedder (FTS-only; no
141/// vectors written or queried). Precedence: `KIMETSU_BRAIN_EMBEDDER`
142/// env override > this field > default (true). A disable env value
143/// (`noop`/`off`/`0`/…) always wins; a real model-id env value means
144/// "enabled" regardless of this field.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct EmbedderSection {
147 #[serde(default = "default_embedder_id")]
148 pub model: String,
149 /// W3.1: persistent embeddings off-switch. Default true (enabled).
150 /// `#[serde(default = "default_true")]` keeps pre-W3 project.toml
151 /// files loading unchanged.
152 #[serde(default = "default_true")]
153 pub enabled: bool,
154 /// v1.0.0: use the warm embedder daemon for the `UserPromptSubmit`
155 /// hook. `false` ⇒ the hook never spawns/contacts a daemon and stays
156 /// on floored-FTS even on `embeddings` builds. Config equivalent of
157 /// `KIMETSU_EMBED_DAEMON=0`. `#[serde(default = "default_true")]`
158 /// keeps older configs loading with the daemon on.
159 #[serde(default = "default_true")]
160 pub daemon: bool,
161 /// v1.0.0: pre-warm the daemon at harness startup (via `kimetsu brain
162 /// warm`, wired to SessionStart). `false` ⇒ no startup spawn; the
163 /// daemon (if `daemon=true`) warms lazily on the first prompt instead.
164 #[serde(default = "default_true")]
165 pub warm_on_start: bool,
166 /// v1.0.0: cross-encoder reranker the warm daemon applies as the final
167 /// ranking stage. `"off"` (default), a curated fastembed reranker id
168 /// (`jina-reranker-v1-turbo-en`, `bge-reranker-base`,
169 /// `bge-reranker-v2-m3`, `jina-reranker-v2-base-multilingual`), a
170 /// benchmarked alias (`jina-reranker-v1-tiny-en`,
171 /// `ms-marco-tinybert-l-2-v2`, `ms-marco-minilm-l-4-v2`), or any
172 /// HuggingFace `org/repo` with an ONNX export.
173 ///
174 /// Default `ms-marco-tinybert-l-2-v2`, chosen with `kimetsu brain
175 /// bench` on the 100-case real-memory dataset: paired with the
176 /// `jina-v2-base-code` embedder it lands within noise of the best
177 /// quality (MRR 0.938 vs 0.953 top) at ~43ms per rerank — far inside
178 /// the hook's 300ms budget. On slower machines a miss degrades
179 /// gracefully to floored-FTS for that turn. `"off"` disables
180 /// reranking. Validate changes on your own corpus with
181 /// `kimetsu brain bench` / `kimetsu brain eval`.
182 #[serde(default = "default_reranker_id")]
183 pub reranker: String,
184}
185
186fn default_embedder_id() -> String {
187 "jina-v2-base-code".to_string()
188}
189
190fn default_reranker_id() -> String {
191 "ms-marco-tinybert-l-2-v2".to_string()
192}
193
194fn default_true() -> bool {
195 true
196}
197
198impl Default for EmbedderSection {
199 fn default() -> Self {
200 Self {
201 model: default_embedder_id(),
202 enabled: default_true(),
203 daemon: default_true(),
204 warm_on_start: default_true(),
205 reranker: default_reranker_id(),
206 }
207 }
208}
209
210/// v0.8.5: automatic memory harvesting. When `auto_harvest` is on, the
211/// proactive PostToolUse hook and the Stop hook emit a `[kimetsu-harvest]`
212/// cue at high-signal moments (a failed-then-fixed command, or a
213/// non-trivial session that recorded nothing) telling the agent to
214/// dispatch the `kimetsu-memory-harvester` subagent. Set it false to
215/// silence those cues.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct LearningSection {
218 #[serde(default = "default_auto_harvest")]
219 pub auto_harvest: bool,
220 /// v1.5: store the raw retrieval query in local `context.served`
221 /// telemetry so the self-tuning loop can build a personal eval set.
222 /// Data never leaves the machine and is never exported. Set false to
223 /// keep only the query hash (the pre-v1.5 behavior). Default true so
224 /// new installs gain the eval-set signal immediately on upgrade;
225 /// `#[serde(default = "default_true")]` keeps pre-v1.5 project.toml
226 /// files loading cleanly (they get store_queries = true).
227 #[serde(default = "default_true")]
228 pub store_queries: bool,
229 /// Opt-in credentialed SessionEnd distiller (configured by the install
230 /// wizard). Disabled by default; `#[serde(default)]` keeps older
231 /// project.toml files loading.
232 #[serde(default)]
233 pub distiller: DistillerSection,
234}
235
236fn default_auto_harvest() -> bool {
237 true
238}
239
240impl Default for LearningSection {
241 fn default() -> Self {
242 Self {
243 auto_harvest: default_auto_harvest(),
244 store_queries: default_true(),
245 distiller: DistillerSection::default(),
246 }
247 }
248}
249
250/// Credentialed SessionEnd distiller config. Secret values (the API key,
251/// optional base URL) live in `.env` under the env-var names below; only
252/// non-secret selection lives here. `provider` is `anthropic`, `openai`, or
253/// `bedrock`.
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct DistillerSection {
256 #[serde(default)]
257 pub enabled: bool,
258 #[serde(default = "default_distiller_provider")]
259 pub provider: String,
260 #[serde(default = "default_distiller_model")]
261 pub model: String,
262 #[serde(default = "default_distiller_api_key_env")]
263 pub api_key_env: String,
264 #[serde(default = "default_distiller_base_url_env")]
265 pub base_url_env: String,
266 /// AWS Bedrock distiller: literal region. Takes precedence over
267 /// `region_env`. `#[serde(default)]` keeps existing config loading.
268 #[serde(default)]
269 pub region: Option<String>,
270 /// AWS Bedrock distiller: env-var name that holds the region.
271 /// Defaults to `"AWS_REGION"`. `#[serde(default)]` keeps existing
272 /// config loading cleanly.
273 #[serde(default = "default_distiller_region_env")]
274 pub region_env: String,
275}
276
277fn default_distiller_provider() -> String {
278 "anthropic".to_string()
279}
280fn default_distiller_model() -> String {
281 "claude-haiku-4-5".to_string()
282}
283fn default_distiller_api_key_env() -> String {
284 "ANTHROPIC_API_KEY".to_string()
285}
286fn default_distiller_base_url_env() -> String {
287 "ANTHROPIC_BASE_URL".to_string()
288}
289
290fn default_distiller_region_env() -> String {
291 "AWS_REGION".to_string()
292}
293
294impl Default for DistillerSection {
295 fn default() -> Self {
296 Self {
297 enabled: false,
298 provider: default_distiller_provider(),
299 model: default_distiller_model(),
300 api_key_env: default_distiller_api_key_env(),
301 base_url_env: default_distiller_base_url_env(),
302 region: None,
303 region_env: default_distiller_region_env(),
304 }
305 }
306}
307
308/// S1.2: top-level cheap-model config. Same shape as `DistillerSection`
309/// (provider / model / api_key_env / base_url_env / region / region_env) but
310/// with `provider` now including `"ollama"` (S1.1).
311///
312/// Providers:
313/// - `"anthropic"` / `"claude"` — Anthropic API.
314/// - `"openai"` / `"gpt"` / `"oai"` — OpenAI-compatible API.
315/// - `"ollama"` — local Ollama server (OpenAI-compatible at
316/// `http://localhost:11434/v1`). No API key required. Recommended
317/// small instruct models: `qwen2.5:3b`, `llama3.2:3b`.
318/// Override the endpoint with `base_url_env` (default env var
319/// `OLLAMA_BASE_URL`).
320/// - `"bedrock"` / `"aws"` — AWS Bedrock.
321///
322/// `#[serde(default)]` on the `ProjectConfig` field keeps all existing
323/// project.toml files loading unchanged (`cheap_model = None`).
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct CheapModelSection {
326 #[serde(default)]
327 pub enabled: bool,
328 #[serde(default = "default_cheap_model_provider")]
329 pub provider: String,
330 #[serde(default = "default_cheap_model_model")]
331 pub model: String,
332 /// Env-var name that holds the API key (not required for `ollama`).
333 #[serde(default = "default_cheap_model_api_key_env")]
334 pub api_key_env: String,
335 /// Env-var name that holds the base URL override. For `ollama` the
336 /// default resolved URL is `http://localhost:11434/v1` when this env
337 /// var is absent or empty.
338 #[serde(default = "default_cheap_model_base_url_env")]
339 pub base_url_env: String,
340 /// AWS Bedrock: literal region. Takes precedence over `region_env`.
341 #[serde(default)]
342 pub region: Option<String>,
343 /// AWS Bedrock: env-var name that holds the region (default `"AWS_REGION"`).
344 #[serde(default = "default_cheap_model_region_env")]
345 pub region_env: String,
346}
347
348fn default_cheap_model_provider() -> String {
349 "anthropic".to_string()
350}
351fn default_cheap_model_model() -> String {
352 "claude-haiku-4-5".to_string()
353}
354fn default_cheap_model_api_key_env() -> String {
355 "ANTHROPIC_API_KEY".to_string()
356}
357fn default_cheap_model_base_url_env() -> String {
358 "ANTHROPIC_BASE_URL".to_string()
359}
360fn default_cheap_model_region_env() -> String {
361 "AWS_REGION".to_string()
362}
363
364impl Default for CheapModelSection {
365 fn default() -> Self {
366 Self {
367 enabled: false,
368 provider: default_cheap_model_provider(),
369 model: default_cheap_model_model(),
370 api_key_env: default_cheap_model_api_key_env(),
371 base_url_env: default_cheap_model_base_url_env(),
372 region: None,
373 region_env: default_cheap_model_region_env(),
374 }
375 }
376}
377
378impl CheapModelSection {
379 /// S1.1: for `provider = "ollama"`, return the default base URL
380 /// (`http://localhost:11434/v1`) when no override is configured.
381 pub const OLLAMA_DEFAULT_BASE_URL: &'static str = "http://localhost:11434/v1";
382
383 /// Construct from a `DistillerSection` for back-compat resolution.
384 pub fn from_distiller(d: &DistillerSection) -> Self {
385 Self {
386 enabled: d.enabled,
387 provider: d.provider.clone(),
388 model: d.model.clone(),
389 api_key_env: d.api_key_env.clone(),
390 base_url_env: d.base_url_env.clone(),
391 region: d.region.clone(),
392 region_env: d.region_env.clone(),
393 }
394 }
395}
396
397/// S5.1: storage / retrieval backend configuration.
398///
399/// Controls which `RetrievalBackend` implementation is used for memory
400/// candidate generation. The broker (scoring, floors, rerank, compression)
401/// is backend-agnostic and is NOT affected by this setting.
402///
403/// `#[serde(default)]` keeps every pre-S5 project.toml loading cleanly
404/// (they get `backend = "flat"`, which is exactly today's FTS + usearch-ANN
405/// behaviour).
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct StorageSection {
408 /// Which retrieval backend to use.
409 ///
410 /// | Value | Behaviour |
411 /// |---------------|-------------------------------------------------------|
412 /// | `"flat"` | FTS + usearch HNSW ANN (current, default) |
413 /// | `"graph-lite"`| TODO (S5.2): graph-augmented FTS + ANN |
414 /// | `"graph"` | TODO (future): full graph traversal |
415 ///
416 /// Unknown values fall back to `"flat"` with a warning.
417 #[serde(default = "default_storage_backend")]
418 pub backend: String,
419}
420
421fn default_storage_backend() -> String {
422 "flat".to_string()
423}
424
425impl Default for StorageSection {
426 fn default() -> Self {
427 Self {
428 backend: default_storage_backend(),
429 }
430 }
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct ModelSection {
435 pub provider: String,
436 pub model: String,
437 pub api_key_env: String,
438 pub max_output_tokens: u32,
439 pub temperature: f32,
440 pub request_timeout_secs: u64,
441 /// AWS Bedrock: literal region (e.g. `us-east-1`). Takes precedence over
442 /// `region_env`. `#[serde(default)]` keeps existing project.toml loading.
443 #[serde(default)]
444 pub region: Option<String>,
445 /// AWS Bedrock: env-var name that holds the region. Defaults to
446 /// `"AWS_REGION"` via `default_region_env()`. Consulted only when
447 /// `region` is `None`. `#[serde(default)]` keeps existing project.toml
448 /// loading cleanly.
449 #[serde(default = "default_region_env")]
450 pub region_env: String,
451 /// v1.5: override the built-in $/MTok price table for the ROI ledger.
452 /// When set, this value is used for USD conversion instead of the
453 /// approximate built-in table. Useful for private-endpoint pricing or
454 /// non-standard model deployments. `#[serde(default)]` keeps all
455 /// pre-v1.5 project.toml files loading cleanly (they get `None`).
456 #[serde(default)]
457 pub price_per_mtok: Option<f64>,
458}
459
460fn default_region_env() -> String {
461 "AWS_REGION".to_string()
462}
463
464impl Default for ModelSection {
465 fn default() -> Self {
466 Self {
467 provider: "anthropic".to_string(),
468 model: "claude-opus-4-7".to_string(),
469 api_key_env: "ANTHROPIC_API_KEY".to_string(),
470 max_output_tokens: 8192,
471 temperature: 0.2,
472 request_timeout_secs: 120,
473 region: None,
474 region_env: default_region_env(),
475 price_per_mtok: None,
476 }
477 }
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct BrokerSection {
482 /// Flat per-stage budget (tokens). Used as a fallback when
483 /// `task_size == 0` (broker disabled or task-size signal unavailable)
484 /// and as the compat default for pre-F3 project.toml files.
485 /// For live runs the adaptive budget (`adaptive_budget`) supersedes this.
486 pub default_budget_tokens: u32,
487 pub weights: BrokerWeights,
488 /// D1f: hard cap on capsules rendered into a model prompt. The
489 /// broker may surface more capsules than this (up to the token
490 /// budget), but the pipeline render step truncates to this cap so
491 /// a tighter, higher-precision capsule set isn't silently padded
492 /// back to a larger number. 0 = disabled (budget-only limit).
493 ///
494 /// Default 8: lower than the old hard-coded 12 so precision from
495 /// D1e wins; operators can raise it per-project in project.toml.
496 /// `#[serde(default)]` keeps pre-D1f project.toml files loading.
497 #[serde(default = "default_max_capsules")]
498 pub max_capsules: usize,
499 /// D1e: absolute minimum cosine similarity between the query
500 /// embedding and a candidate embedding required for the candidate
501 /// to survive budgeting. When > 0.0, candidates whose cosine is
502 /// strictly below this threshold are dropped BEFORE the MMR pass
503 /// so a genuinely-irrelevant corpus hits the zero-capsule skipped
504 /// path more often. Inert on lean (NoopEmbedder) builds because
505 /// there is no query embedding to compare against.
506 ///
507 /// Default -1.0 = AUTO (v1.0.0): the right floor is MODEL-DEPENDENT,
508 /// because cosine scales differ per embedder. bge-family cosines for
509 /// related pairs sit well above ~0.5 with noise below ~0.4, so auto
510 /// resolves to 0.35 there. jina-v2 cosines run lower — the remote
511 /// benchmark showed a 0.35 floor KILLING relevant results outright
512 /// (MRR 0.90 → 0.77, recall@2 == recall@4) — and that model's own
513 /// precision already keeps noise low (~1.2 vs bge's ~4.0 capsules on
514 /// no-answer queries, floors off), so auto resolves to 0.0 (disabled)
515 /// for non-bge models. Set an explicit value to override auto in
516 /// either direction; 0.0 disables. `#[serde(default = …)]` keeps older
517 /// configs loading with auto.
518 #[serde(default = "default_min_semantic_score")]
519 pub min_semantic_score: f32,
520 /// v1.0.0: absolute *lexical* relevance floor for memory candidates,
521 /// expressed as the fraction of the query's IDF-weighted discriminating
522 /// power a memory must lexically cover to survive. Unlike
523 /// `min_semantic_score` (which needs a query embedding and is therefore
524 /// inert on the FTS-only `UserPromptSubmit` hook path), this floor works
525 /// on lexical retrieval — closing the gap where a broad conceptual query
526 /// ("what's the idea of the repo") surfaces unrelated memories that only
527 /// share a corpus-ubiquitous token like the project name.
528 ///
529 /// Mechanics: query tokens are stripped of stopwords; each remaining
530 /// token is IDF-weighted over the memory corpus (so the project name,
531 /// present in nearly every memory, contributes ~0). A memory is dropped
532 /// when the IDF-weighted share of the query it covers is below this floor
533 /// AND it has no semantic support. Repo-file/manifest capsules pass
534 /// through untouched (their FTS match on file content is itself the
535 /// relevance signal, and overview queries *want* the README).
536 ///
537 /// Default 0.5 = "must cover the more-discriminating half of the query."
538 /// 0.0 disables the floor. `#[serde(default = …)]` keeps older configs
539 /// loading with the floor active.
540 #[serde(default = "default_min_lexical_coverage")]
541 pub min_lexical_coverage: f32,
542 /// F3: floor for the adaptive per-stage brain budget. Small tasks
543 /// receive at least this many tokens so the brain is never starved.
544 /// `#[serde(default)]` keeps pre-F3 project.toml files loading cleanly.
545 #[serde(default = "default_budget_floor_tokens")]
546 pub budget_floor_tokens: u32,
547 /// F3: per-run global ceiling on brain-injected tokens across ALL
548 /// stages combined. Later stages receive only the remaining capacity
549 /// once earlier stages have been charged via the `RunRecallLedger`.
550 /// `#[serde(default)]` keeps pre-F3 project.toml files loading cleanly.
551 #[serde(default = "default_budget_run_cap_tokens")]
552 pub budget_run_cap_tokens: u32,
553 /// W3.2: persistent ambient-context off-switch. When false, the
554 /// workspace fingerprint (branch, recent files, dirty status) is not
555 /// collected or appended to the retrieval query. Precedence:
556 /// `KIMETSU_BRAIN_AMBIENT` env override > this field > default (true).
557 /// `#[serde(default = "default_true")]` keeps pre-W3 project.toml
558 /// files loading unchanged.
559 #[serde(default = "default_true")]
560 pub ambient: bool,
561 /// v1.5 (Story 2.1): render-time capsule compression. When true (default),
562 /// capsule summaries are compressed with [`compress_for_render`] before
563 /// being injected into hook stdout or MCP tool responses. Compression
564 /// strips `[tags: ...]` / `(context: ...)` annotations and caps at 3
565 /// sentences. Ranking is NEVER affected — compression runs only after
566 /// retrieval and reranking. Set false to inject full memory text (useful
567 /// for debugging or when summaries are already concise).
568 ///
569 /// `#[serde(default = "default_true")]` keeps pre-v1.5 project.toml files
570 /// loading cleanly (they get compression ON).
571 #[serde(default = "default_true")]
572 pub compress_capsules: bool,
573 /// v1.5 (Story 2.3): session-scoped cross-turn capsule dedupe. When true
574 /// (default), the `UserPromptSubmit` context hook skips capsules whose
575 /// `expansion_handle` was already injected earlier in the same session
576 /// (tracked via the proactive-state sidecar). A soft policy: skipping only
577 /// happens when at least one NEW capsule remains — if dedupe would empty
578 /// the injection entirely, all capsules are injected anyway (a repeated
579 /// top memory may still be the right context). Set false to disable
580 /// session dedupe and always inject the full ranked set.
581 ///
582 /// `#[serde(default = "default_true")]` keeps pre-v1.5 project.toml files
583 /// loading cleanly (they get session dedupe ON).
584 #[serde(default = "default_true")]
585 pub session_dedupe: bool,
586 /// Flagship 1 / Pass B: inject the repo digest + work-resume context at
587 /// SessionStart. When true (default), `kimetsu brain session-start-hook`
588 /// prints `additionalContext` JSON combining the ~400-token repo digest
589 /// (1.1) and the episodic resume (Pass A). Set false to suppress the
590 /// warm-start injection entirely — useful when the host already provides
591 /// repo context or the digest is not yet built.
592 ///
593 /// `#[serde(default = "default_true")]` keeps pre-Flagship-1 project.toml
594 /// files loading cleanly (they get warm_start ON — the feature is additive
595 /// and defaults to enabled so fresh installs get it immediately).
596 #[serde(default = "default_true")]
597 pub warm_start: bool,
598 /// Flagship 3 / Pass B (3.3): minimum composite broker score for a capsule
599 /// to receive the "Verified answer from project memory:" prefix at render
600 /// time. This prefix signals to the model that it can act in one turn
601 /// rather than re-verifying the information.
602 ///
603 /// STRICTLY ADDITIVE: only changes the rendered prefix of an already-top
604 /// capsule. Ranking, floors, and capsule selection are NEVER affected.
605 ///
606 /// The threshold is deliberately conservative (0.92 default) so the marker
607 /// is rare and only fires on genuinely unambiguous matches. Tune with
608 /// `kimetsu brain bench` data (Epic S2) before lowering. Set to 1.1 (above
609 /// the maximum achievable score) to disable entirely, or 0.0 to always
610 /// mark any top capsule (not recommended — wait for regret data first).
611 ///
612 /// Regret guard: if the capsule's memory was recently dropped by floors in
613 /// another retrieval context (appears in the dropped sidecar), the prefix
614 /// is suppressed regardless of this threshold, preventing overconfident
615 /// labelling of inconsistently-scored memories.
616 ///
617 /// `#[serde(default = …)]` keeps all pre-F3 project.toml files loading
618 /// unchanged (they get the conservative default).
619 #[serde(default = "default_answer_grade_min_score")]
620 pub answer_grade_min_score: f32,
621 /// Flagship 3 / Pass B (3.5): opt-in proactive pre-fetch at PreToolUse.
622 ///
623 /// When true, the PreToolUse hook does a LIGHTWEIGHT relevance warm based
624 /// on the current tool's file path (in addition to the command text),
625 /// surfacing a relevant memory before the agent edits or reads a file.
626 /// The existing floors (min_score, max_capsules, session dedupe, refractory
627 /// throttle) all apply — this is additive only.
628 ///
629 /// Default false (OFF): the PreToolUse hook behaviour is identical to
630 /// before this flag existed. Graduating to default-on waits for regret
631 /// data (Epic S2) to confirm that file-path-augmented queries don't
632 /// increase noise. Enable per-project in project.toml once comfortable.
633 ///
634 /// `#[serde(default)]` keeps all pre-F3 project.toml files loading with
635 /// the feature OFF (zero behaviour change for existing users).
636 #[serde(default)]
637 pub proactive_prefetch: bool,
638}
639
640fn default_max_capsules() -> usize {
641 8
642}
643
644fn default_min_semantic_score() -> f32 {
645 -1.0
646}
647
648fn default_min_lexical_coverage() -> f32 {
649 0.5
650}
651
652fn default_budget_floor_tokens() -> u32 {
653 1500
654}
655
656fn default_budget_run_cap_tokens() -> u32 {
657 8000
658}
659
660/// F3 / Pass B (3.3): conservative answer-grade threshold. At 0.92 the marker
661/// fires only when the retrieval pipeline (embedder + reranker) places the top
662/// capsule in the very top of its score range — roughly 1-in-10 retrievals on
663/// a well-populated brain. Lowering requires regret data from Epic S2 to
664/// confirm precision stays high.
665fn default_answer_grade_min_score() -> f32 {
666 0.92
667}
668
669impl Default for BrokerSection {
670 fn default() -> Self {
671 Self {
672 default_budget_tokens: 6000,
673 weights: BrokerWeights::default(),
674 max_capsules: default_max_capsules(),
675 min_semantic_score: default_min_semantic_score(),
676 min_lexical_coverage: default_min_lexical_coverage(),
677 budget_floor_tokens: default_budget_floor_tokens(),
678 budget_run_cap_tokens: default_budget_run_cap_tokens(),
679 ambient: default_true(),
680 compress_capsules: default_true(),
681 session_dedupe: default_true(),
682 warm_start: default_true(),
683 answer_grade_min_score: default_answer_grade_min_score(),
684 proactive_prefetch: false,
685 }
686 }
687}
688
689/// F3: compute the adaptive per-stage brain budget given a task-size signal.
690///
691/// **Task-size signal** (defined): `task_size = estimate_tokens(task_text) +
692/// estimate_tokens(localized_file_context)`, where `estimate_tokens` uses the
693/// same heuristic as the rest of the pipeline: `(whitespace_words * 1.33).ceil()`.
694/// Localized-file context is the rendered list of paths surfaced before the
695/// first implementation attempt.
696///
697/// **Scaling**: `floor + k * sqrt(task_size)` clamped to `[floor, run_cap]`.
698/// sqrt is chosen because it grows slower than linear — doubling task_size
699/// grows the budget by only ~41%, and a 5× task grows it by only ~124%
700/// (well under 2×).
701///
702/// **Constant k**: chosen so a "typical" task (task_size ≈ 200 tokens, e.g.
703/// a concise one-paragraph task + a handful of file paths) lands near
704/// today's default 6000 tokens, avoiding a behavior cliff on upgrade.
705/// k = (6000 - 1500) / sqrt(200) ≈ 318.2
706///
707/// **Fallback**: when `task_size == 0` (broker disabled, size signal
708/// unavailable, or called pre-retrieval) returns `floor` — callers should
709/// use `default_budget_tokens` instead in those paths.
710///
711/// **Per-run cap**: the caller is responsible for computing
712/// `remaining = run_cap.saturating_sub(ledger.injected_tokens())` and passing
713/// `min(adaptive_budget(...), remaining)` as the stage's `budget_tokens`.
714pub fn adaptive_budget(task_size: u32, floor: u32, run_cap: u32) -> u32 {
715 if task_size == 0 {
716 return floor;
717 }
718 // k ≈ 318.2 so that adaptive_budget(200, 1500, 8000) ≈ 6000.
719 // We scale k by 10 and work in integer arithmetic to avoid f64 in hot path.
720 const K_SCALED: u32 = 3182; // k * 10
721 let sqrt_part = (task_size as f64).sqrt();
722 let budget_f = floor as f64 + (K_SCALED as f64 / 10.0) * sqrt_part;
723 let budget = budget_f.round() as u32;
724 budget.clamp(floor, run_cap)
725}
726
727#[derive(Debug, Clone, Serialize, Deserialize)]
728pub struct BrokerWeights {
729 pub relevance: f32,
730 pub confidence: f32,
731 pub freshness: f32,
732 pub scope: f32,
733 pub localization: Option<StageWeights>,
734 pub patch_plan: Option<StageWeights>,
735 pub verification: Option<StageWeights>,
736 pub review: Option<StageWeights>,
737 /// v0.5.1: half-life (in days) for the usefulness-decay
738 /// multiplier. A memory's effective usefulness contribution
739 /// decays as `exp(-ln(2) * age_days / half_life)` where age is
740 /// measured from `last_useful_at` if present, else
741 /// `created_at`. 30 days = a 6-month-old useful memory ends
742 /// up at ~1.5% of its original weight; tune lower for faster-
743 /// changing repos, higher for slow-evolving ones.
744 ///
745 /// `#[serde(default)]` keeps pre-v0.5.1 project.toml files
746 /// loading cleanly — they get the 30-day default.
747 #[serde(default = "default_decay_half_life_days")]
748 pub decay_half_life_days: f32,
749}
750
751fn default_decay_half_life_days() -> f32 {
752 30.0
753}
754
755impl Default for BrokerWeights {
756 fn default() -> Self {
757 Self {
758 relevance: 0.50,
759 confidence: 0.20,
760 freshness: 0.20,
761 scope: 0.10,
762 localization: Some(StageWeights {
763 relevance: 0.70,
764 confidence: 0.10,
765 freshness: 0.10,
766 scope: 0.10,
767 }),
768 patch_plan: Some(StageWeights {
769 relevance: 0.40,
770 confidence: 0.30,
771 freshness: 0.10,
772 scope: 0.20,
773 }),
774 verification: Some(StageWeights {
775 relevance: 0.40,
776 confidence: 0.10,
777 freshness: 0.40,
778 scope: 0.10,
779 }),
780 review: Some(StageWeights {
781 relevance: 0.50,
782 confidence: 0.20,
783 freshness: 0.20,
784 scope: 0.10,
785 }),
786 decay_half_life_days: default_decay_half_life_days(),
787 }
788 }
789}
790
791#[derive(Debug, Clone, Serialize, Deserialize)]
792pub struct StageWeights {
793 pub relevance: f32,
794 pub confidence: f32,
795 pub freshness: f32,
796 pub scope: f32,
797}
798
799#[derive(Debug, Clone, Serialize, Deserialize)]
800pub struct ShellSection {
801 pub default_timeout_secs: u64,
802 pub max_timeout_secs: u64,
803 pub env_allowlist_extra: Vec<String>,
804 pub redact_secrets: bool,
805}
806
807impl Default for ShellSection {
808 fn default() -> Self {
809 Self {
810 default_timeout_secs: 60,
811 max_timeout_secs: 600,
812 env_allowlist_extra: vec!["RUSTFLAGS".to_string(), "CARGO_HOME".to_string()],
813 redact_secrets: true,
814 }
815 }
816}
817
818#[derive(Debug, Clone, Serialize, Deserialize)]
819pub struct IngestionSection {
820 pub max_file_bytes: u64,
821 pub extra_skip_dirs: Vec<String>,
822 pub max_total_files: u64,
823 /// v1.0: enable/disable the per-add conflict-detection scan.
824 ///
825 /// Default true. Set to false (or set env `KIMETSU_DETECT_CONFLICTS=0`)
826 /// to skip the cosine-similarity conflict scan at add time — useful when
827 /// bulk-seeding a brain where the O(N²) scan would be prohibitively slow.
828 /// Review `kimetsu brain memory conflicts` afterwards to catch any
829 /// contradictions.
830 ///
831 /// Precedence: `KIMETSU_DETECT_CONFLICTS` env > this field > default.
832 #[serde(default = "default_true")]
833 pub detect_conflicts: bool,
834}
835
836impl Default for IngestionSection {
837 fn default() -> Self {
838 Self {
839 max_file_bytes: 524_288,
840 extra_skip_dirs: Vec::new(),
841 max_total_files: 50_000,
842 detect_conflicts: true,
843 }
844 }
845}
846
847#[derive(Debug, Clone, Serialize, Deserialize)]
848pub struct RunSection {
849 pub max_total_tool_calls: u32,
850 pub max_total_model_turns: u32,
851 pub max_total_cost_usd: f32,
852}
853
854impl Default for RunSection {
855 fn default() -> Self {
856 // `max_total_cost_usd` is treated as advisory under subscription-based
857 // providers (e.g. Claude Code OAuth). The agent loop still enforces it
858 // when it does fire, but the default is set high enough that it
859 // functions as a runaway-prevention safety net rather than a per-run
860 // budget. Tighten in `project.toml` when running against a metered
861 // provider.
862 Self {
863 max_total_tool_calls: 60,
864 max_total_model_turns: 30,
865 max_total_cost_usd: 250.0,
866 }
867 }
868}
869
870/// Epic S3: personal brain sync configuration.
871///
872/// Controls the event-log replication directory protocol. When `dir` is
873/// absent, the sync subcommand is unconfigured and prints a setup hint.
874///
875/// `machine_id` is a stable opaque identifier for this machine. It defaults
876/// to a freshly-generated ULID that is persisted in project.toml on first
877/// use (written by `kimetsu brain sync --setup`). Operators can set it
878/// manually to a meaningful name (hostname, username, etc.) — just keep it
879/// unique within the sync directory.
880///
881/// `#[serde(default)]` keeps every pre-S3 project.toml loading cleanly.
882#[derive(Debug, Clone, Default, Serialize, Deserialize)]
883pub struct SyncSection {
884 /// Absolute (or home-relative) path to the shared sync directory.
885 /// Each machine writes its batches under `<dir>/<machine_id>/`.
886 /// When `None`, syncing is unconfigured.
887 #[serde(default)]
888 pub dir: Option<String>,
889 /// Stable machine identifier. Defaults to an empty string (= not yet
890 /// set; the CLI generates one on first use).
891 #[serde(default)]
892 pub machine_id: String,
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898
899 /// A pre-v0.8 project.toml has no `[embedder]` table. The
900 /// `#[serde(default)]` on the field must keep it loading cleanly,
901 /// defaulting to the lean English model.
902 #[test]
903 fn pre_v0_8_config_without_embedder_loads_with_default() {
904 let toml = r#"
905[kimetsu]
906project_id = "demo"
907schema_version = 7
908
909[model]
910provider = "anthropic"
911model = "claude-opus-4-7"
912api_key_env = "ANTHROPIC_API_KEY"
913max_output_tokens = 8192
914temperature = 0.2
915request_timeout_secs = 120
916
917[broker]
918default_budget_tokens = 6000
919
920[broker.weights]
921relevance = 0.5
922confidence = 0.2
923freshness = 0.2
924scope = 0.1
925
926[shell]
927default_timeout_secs = 60
928max_timeout_secs = 600
929env_allowlist_extra = []
930redact_secrets = true
931
932[ingestion]
933max_file_bytes = 524288
934extra_skip_dirs = []
935max_total_files = 50000
936
937[run]
938max_total_tool_calls = 60
939max_total_model_turns = 30
940max_total_cost_usd = 250.0
941"#;
942 let config = ProjectConfig::from_toml(toml).expect("pre-v0.8 toml must load");
943 assert_eq!(config.embedder.model, "jina-v2-base-code");
944 // A pre-v0.8.5 toml has no [learning] section — auto-harvest
945 // defaults on so existing installs gain the behavior on upgrade.
946 assert!(config.learning.auto_harvest);
947 // A pre-distiller toml has no [learning.distiller] — defaults to off,
948 // anthropic, claude-haiku-4-5.
949 assert!(!config.learning.distiller.enabled);
950 assert_eq!(config.learning.distiller.provider, "anthropic");
951 assert_eq!(config.learning.distiller.model, "claude-haiku-4-5");
952 assert_eq!(config.learning.distiller.api_key_env, "ANTHROPIC_API_KEY");
953 assert_eq!(config.learning.distiller.base_url_env, "ANTHROPIC_BASE_URL");
954 // D1e/D1f: pre-D1 configs without max_capsules / min_semantic_score
955 // must load cleanly and receive the safe defaults.
956 assert_eq!(config.broker.max_capsules, 8);
957 // v1.0.0: the semantic floor is ON by default (was 0.0/disabled) now
958 // that the warm daemon serves semantic retrieval to every prompt.
959 assert_eq!(config.broker.min_semantic_score, -1.0, "auto sentinel");
960 // v1.0.0: a config without min_lexical_coverage loads with the floor
961 // active at its default (0.5), so existing installs gain the relevance
962 // gate on upgrade.
963 assert_eq!(config.broker.min_lexical_coverage, 0.5);
964 // F3: pre-F3 configs without budget_floor_tokens / budget_run_cap_tokens
965 // must load cleanly and receive the safe defaults.
966 assert_eq!(config.broker.budget_floor_tokens, 1500);
967 assert_eq!(config.broker.budget_run_cap_tokens, 8000);
968 // W3: pre-W3 configs without the new off-switch fields must load
969 // cleanly and default to enabled (true) for all three features.
970 assert!(
971 config.embedder.enabled,
972 "W3.1: embedder.enabled must default to true"
973 );
974 assert!(
975 config.broker.ambient,
976 "W3.2: broker.ambient must default to true"
977 );
978 assert!(
979 config.kimetsu.use_user_brain,
980 "W3.3: kimetsu.use_user_brain must default to true"
981 );
982 // v1.0.0: daemon + warm_on_start default ON so existing installs get
983 // the warm-daemon path on upgrade.
984 assert!(
985 config.embedder.daemon,
986 "embedder.daemon must default to true"
987 );
988 assert!(
989 config.embedder.warm_on_start,
990 "embedder.warm_on_start must default to true"
991 );
992 // v1.0.0: reranker defaults to jina-reranker-v1-turbo-en so existing
993 // v1.0.0: a config without mcp_write_tools loads with local write
994 // tools ENABLED, so the record-a-lesson workflow the brain itself
995 // prescribes works out of the box on upgrade.
996 assert!(
997 config.kimetsu.mcp_write_tools,
998 "kimetsu.mcp_write_tools must default to true"
999 );
1000 // v1.0.0: jina-tiny + pool 6 measured as fitting the hook's 300ms
1001 // budget on real memories with the best benchmark quality, so the
1002 // reranker is ON by default.
1003 assert_eq!(
1004 config.embedder.reranker, "ms-marco-tinybert-l-2-v2",
1005 "embedder.reranker must default to ms-marco-tinybert-l-2-v2"
1006 );
1007 // v1.5: a pre-v1.5 project.toml has no learning.store_queries —
1008 // defaults to true so existing installs gain the eval-set signal
1009 // on upgrade without any config change.
1010 assert!(
1011 config.learning.store_queries,
1012 "learning.store_queries must default to true"
1013 );
1014 // v1.5 (Story 2.1): a pre-v1.5 project.toml without broker.compress_capsules
1015 // must load cleanly and default to true (compression ON).
1016 assert!(
1017 config.broker.compress_capsules,
1018 "broker.compress_capsules must default to true"
1019 );
1020 // v1.5 (Story 2.3): a pre-v1.5 project.toml without broker.session_dedupe
1021 // must load cleanly and default to true (dedupe ON).
1022 assert!(
1023 config.broker.session_dedupe,
1024 "broker.session_dedupe must default to true"
1025 );
1026 // Flagship 1 Pass B: a pre-Flagship-1 project.toml without
1027 // broker.warm_start must load cleanly and default to true (warm-start ON).
1028 assert!(
1029 config.broker.warm_start,
1030 "broker.warm_start must default to true"
1031 );
1032 // S5.1: a pre-S5 project.toml without [storage] must load cleanly
1033 // and default to backend = "flat" (the existing FTS + ANN path).
1034 assert_eq!(
1035 config.storage.backend, "flat",
1036 "storage.backend must default to \"flat\" when absent"
1037 );
1038 // F3 Pass B (3.3): a pre-F3 project.toml without broker.answer_grade_min_score
1039 // must load cleanly and receive the conservative default (0.92).
1040 assert!(
1041 (config.broker.answer_grade_min_score - 0.92).abs() < f32::EPSILON,
1042 "broker.answer_grade_min_score must default to 0.92"
1043 );
1044 // F3 Pass B (3.5): a pre-F3 project.toml without broker.proactive_prefetch
1045 // must load cleanly and default to false (opt-in, OFF by default).
1046 assert!(
1047 !config.broker.proactive_prefetch,
1048 "broker.proactive_prefetch must default to false (opt-in)"
1049 );
1050 // S3: a pre-S3 project.toml without [sync] must load cleanly and
1051 // default to no sync dir and empty machine_id.
1052 assert!(
1053 config.sync.dir.is_none(),
1054 "sync.dir must default to None when absent"
1055 );
1056 assert!(
1057 config.sync.machine_id.is_empty(),
1058 "sync.machine_id must default to empty string when absent"
1059 );
1060 }
1061
1062 /// A1: default_for_project must use KIMETSU_CONFIG_VERSION (the
1063 /// project.toml format version), NOT KIMETSU_SCHEMA_VERSION (the brain.db
1064 /// schema). The two constants are intentionally decoupled so a DB-schema
1065 /// bump does not force every project.toml to be rewritten.
1066 #[test]
1067 fn default_config_uses_config_version_not_schema_version() {
1068 let cfg = ProjectConfig::default_for_project("p1");
1069 assert_eq!(cfg.kimetsu.schema_version, crate::KIMETSU_CONFIG_VERSION);
1070 }
1071
1072 /// `model set` writes the whole config back via `to_toml`; a
1073 /// round-trip must preserve the chosen embedder (and other sections).
1074 #[test]
1075 fn embedder_survives_toml_round_trip() {
1076 let mut config = ProjectConfig::default_for_project("demo");
1077 config.embedder.model = "bge-m3".to_string();
1078 let serialized = config.to_toml().expect("serialize");
1079 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
1080 assert_eq!(reloaded.embedder.model, "bge-m3");
1081 assert_eq!(reloaded.broker.default_budget_tokens, 6000);
1082 assert_eq!(reloaded.kimetsu.project_id, "demo");
1083 // F3 fields survive round-trip.
1084 assert_eq!(reloaded.broker.budget_floor_tokens, 1500);
1085 assert_eq!(reloaded.broker.budget_run_cap_tokens, 8000);
1086 // W3 off-switch fields survive round-trip.
1087 assert!(reloaded.embedder.enabled);
1088 assert!(reloaded.broker.ambient);
1089 assert!(reloaded.kimetsu.use_user_brain);
1090 }
1091
1092 /// W3: the three new off-switch fields can be set to `false` in
1093 /// project.toml and round-trip cleanly.
1094 #[test]
1095 fn w3_off_switch_fields_round_trip_as_false() {
1096 let mut config = ProjectConfig::default_for_project("demo");
1097 config.embedder.enabled = false;
1098 config.broker.ambient = false;
1099 config.kimetsu.use_user_brain = false;
1100 let serialized = config.to_toml().expect("serialize");
1101 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
1102 assert!(
1103 !reloaded.embedder.enabled,
1104 "embedder.enabled must survive as false"
1105 );
1106 assert!(
1107 !reloaded.broker.ambient,
1108 "broker.ambient must survive as false"
1109 );
1110 assert!(
1111 !reloaded.kimetsu.use_user_brain,
1112 "kimetsu.use_user_brain must survive as false"
1113 );
1114 // Unrelated fields unaffected.
1115 assert_eq!(reloaded.kimetsu.project_id, "demo");
1116 }
1117
1118 // ── F3: adaptive_budget unit tests ────────────────────────────────────
1119
1120 /// F3-budget-1: floor is returned when task_size == 0.
1121 #[test]
1122 fn f3_adaptive_budget_zero_size_returns_floor() {
1123 assert_eq!(
1124 super::adaptive_budget(0, 1500, 8000),
1125 1500,
1126 "task_size=0 must return floor"
1127 );
1128 }
1129
1130 /// F3-budget-2: run_cap is returned when task_size is enormous (very large).
1131 #[test]
1132 fn f3_adaptive_budget_huge_size_clamped_to_run_cap() {
1133 let result = super::adaptive_budget(1_000_000, 1500, 8000);
1134 assert_eq!(result, 8000, "huge task_size must be clamped to run_cap");
1135 }
1136
1137 /// F3-budget-3: budget grows SUBLINEARLY — adaptive_budget(5*T) < 2 * adaptive_budget(T).
1138 ///
1139 /// With sqrt scaling: budget(5*T) / budget(T) = (floor + k*sqrt(5T)) / (floor + k*sqrt(T))
1140 /// < sqrt(5) ≈ 2.236 for large T, but also < 2 for T in the practical range
1141 /// because the floor term dominates at small sizes and sqrt(5) dominates at large
1142 /// sizes. Specifically for T=200: budget(200)≈6000, budget(1000)≈8000 (capped) → ratio < 2.
1143 /// For T=50 (below cap): budget(50)≈3751, budget(250)≈6534 → ratio ≈ 1.74 < 2. ✓
1144 #[test]
1145 fn f3_adaptive_budget_is_sublinear() {
1146 let floor = 1500u32;
1147 let run_cap = 16_000u32; // raised cap for this test so neither hits the ceiling
1148
1149 // T0 = 200 tokens (concise task), 5*T0 = 1000 tokens (verbose task)
1150 let t0 = 200u32;
1151 let b_t0 = super::adaptive_budget(t0, floor, run_cap);
1152 let b_5t0 = super::adaptive_budget(5 * t0, floor, run_cap);
1153
1154 assert!(
1155 b_5t0 < 2 * b_t0,
1156 "sublinear guarantee: adaptive_budget(5*T)={b_5t0} must be < 2*adaptive_budget(T)={} (T={t0})",
1157 2 * b_t0
1158 );
1159 assert!(
1160 b_5t0 > b_t0,
1161 "budget must still grow: adaptive_budget(5*T)={b_5t0} > adaptive_budget(T)={b_t0}"
1162 );
1163 }
1164
1165 /// F3-budget-4: a typical task (task_size ≈ 200) lands near the historical
1166 /// default of 6000 tokens, avoiding a behavior cliff on upgrade.
1167 #[test]
1168 fn f3_adaptive_budget_typical_task_near_historical_default() {
1169 let budget = super::adaptive_budget(200, 1500, 8000);
1170 // k = 318.2 → floor + k*sqrt(200) = 1500 + 318.2*14.14 ≈ 5999
1171 // Allow ±300 to tolerate rounding.
1172 assert!(
1173 (5700..=8000).contains(&budget),
1174 "typical task budget expected near 6000, got {budget}"
1175 );
1176 }
1177
1178 /// F3-budget-5: floor is always respected — even small tasks get at least floor.
1179 #[test]
1180 fn f3_adaptive_budget_respects_floor() {
1181 for size in [1u32, 5, 10, 50] {
1182 let b = super::adaptive_budget(size, 1500, 8000);
1183 assert!(
1184 b >= 1500,
1185 "task_size={size}: budget={b} must be >= floor=1500"
1186 );
1187 }
1188 }
1189
1190 /// F3-budget-6: run_cap is always respected — large tasks never exceed cap.
1191 #[test]
1192 fn f3_adaptive_budget_respects_run_cap() {
1193 for size in [500u32, 1000, 5000, 100_000] {
1194 let b = super::adaptive_budget(size, 1500, 8000);
1195 assert!(
1196 b <= 8000,
1197 "task_size={size}: budget={b} must be <= run_cap=8000"
1198 );
1199 }
1200 }
1201
1202 /// v1.5: a pre-v1.5 project.toml without `model.price_per_mtok` must
1203 /// load cleanly and default to `None` (backward compatibility).
1204 #[test]
1205 fn pre_v1_5_config_without_price_per_mtok_loads_with_none() {
1206 let toml = r#"
1207[kimetsu]
1208project_id = "demo"
1209schema_version = 7
1210
1211[model]
1212provider = "anthropic"
1213model = "claude-sonnet-4-7"
1214api_key_env = "ANTHROPIC_API_KEY"
1215max_output_tokens = 8192
1216temperature = 0.2
1217request_timeout_secs = 120
1218
1219[broker]
1220default_budget_tokens = 6000
1221
1222[broker.weights]
1223relevance = 0.5
1224confidence = 0.2
1225freshness = 0.2
1226scope = 0.1
1227
1228[shell]
1229default_timeout_secs = 60
1230max_timeout_secs = 600
1231env_allowlist_extra = []
1232redact_secrets = true
1233
1234[ingestion]
1235max_file_bytes = 524288
1236extra_skip_dirs = []
1237max_total_files = 50000
1238
1239[run]
1240max_total_tool_calls = 60
1241max_total_model_turns = 30
1242max_total_cost_usd = 250.0
1243"#;
1244 let config = ProjectConfig::from_toml(toml).expect("pre-v1.5 toml must load");
1245 assert!(
1246 config.model.price_per_mtok.is_none(),
1247 "price_per_mtok must default to None when absent from project.toml"
1248 );
1249 }
1250
1251 /// v1.5 (Story 2.1+2.3): compress_capsules and session_dedupe survive a
1252 /// round-trip through serialize → deserialize when set to false.
1253 #[test]
1254 fn broker_v1_5_fields_round_trip_as_false() {
1255 let mut config = ProjectConfig::default_for_project("demo");
1256 config.broker.compress_capsules = false;
1257 config.broker.session_dedupe = false;
1258 let serialized = config.to_toml().expect("serialize");
1259 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
1260 assert!(
1261 !reloaded.broker.compress_capsules,
1262 "compress_capsules must survive as false"
1263 );
1264 assert!(
1265 !reloaded.broker.session_dedupe,
1266 "session_dedupe must survive as false"
1267 );
1268 }
1269
1270 /// v1.5: when `model.price_per_mtok` is set in project.toml it must
1271 /// round-trip cleanly through serialize → deserialize.
1272 #[test]
1273 fn price_per_mtok_round_trips() {
1274 let mut config = ProjectConfig::default_for_project("demo");
1275 config.model.price_per_mtok = Some(7.5);
1276 let serialized = config.to_toml().expect("serialize");
1277 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
1278 assert_eq!(
1279 reloaded.model.price_per_mtok,
1280 Some(7.5),
1281 "price_per_mtok must round-trip"
1282 );
1283 }
1284
1285 // ── S1.2: cheap_model() resolver tests ───────────────────────────────
1286
1287 /// S1.2-a: a toml with only `[learning.distiller]` (no `[cheap_model]`)
1288 /// resolves via back-compat and returns the distiller's settings.
1289 #[test]
1290 fn s1_2_a_learning_distiller_back_compat() {
1291 let mut config = ProjectConfig::default_for_project("demo");
1292 config.learning.distiller.enabled = true;
1293 config.learning.distiller.provider = "openai".to_string();
1294 config.learning.distiller.model = "gpt-5.4-mini".to_string();
1295 config.cheap_model = None;
1296
1297 let resolved = config.cheap_model().expect("back-compat must resolve");
1298 assert_eq!(resolved.provider, "openai");
1299 assert_eq!(resolved.model, "gpt-5.4-mini");
1300 assert!(resolved.enabled);
1301 }
1302
1303 /// S1.2-b: `[cheap_model]` takes precedence over `[learning.distiller]`
1304 /// when both are present and enabled.
1305 #[test]
1306 fn s1_2_b_cheap_model_takes_precedence() {
1307 let mut config = ProjectConfig::default_for_project("demo");
1308 config.learning.distiller.enabled = true;
1309 config.learning.distiller.provider = "anthropic".to_string();
1310 config.learning.distiller.model = "claude-haiku-4-5".to_string();
1311 config.cheap_model = Some(super::CheapModelSection {
1312 enabled: true,
1313 provider: "ollama".to_string(),
1314 model: "qwen2.5:3b".to_string(),
1315 api_key_env: "OLLAMA_API_KEY".to_string(),
1316 base_url_env: "OLLAMA_BASE_URL".to_string(),
1317 region: None,
1318 region_env: "AWS_REGION".to_string(),
1319 });
1320
1321 let resolved = config.cheap_model().expect("cheap_model must resolve");
1322 assert_eq!(
1323 resolved.provider, "ollama",
1324 "[cheap_model] must win over [learning.distiller]"
1325 );
1326 assert_eq!(resolved.model, "qwen2.5:3b");
1327 }
1328
1329 /// S1.2-c: provider=ollama round-trips and the OLLAMA_DEFAULT_BASE_URL
1330 /// constant has the expected value.
1331 #[test]
1332 fn s1_2_c_ollama_default_base_url() {
1333 assert_eq!(
1334 super::CheapModelSection::OLLAMA_DEFAULT_BASE_URL,
1335 "http://localhost:11434/v1",
1336 "ollama default base URL must point to localhost:11434/v1"
1337 );
1338
1339 let mut config = ProjectConfig::default_for_project("demo");
1340 config.cheap_model = Some(super::CheapModelSection {
1341 enabled: true,
1342 provider: "ollama".to_string(),
1343 model: "llama3.2:3b".to_string(),
1344 api_key_env: "OLLAMA_API_KEY".to_string(),
1345 base_url_env: "OLLAMA_BASE_URL".to_string(),
1346 region: None,
1347 region_env: "AWS_REGION".to_string(),
1348 });
1349
1350 let serialized = config.to_toml().expect("serialize");
1351 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
1352 let cm = reloaded.cheap_model().expect("ollama section must resolve");
1353 assert_eq!(cm.provider, "ollama");
1354 assert_eq!(cm.model, "llama3.2:3b");
1355 }
1356
1357 /// S1.2-d: absent/disabled cheap model → resolver returns None;
1358 /// consumers that call `.cheap_model()` degrade gracefully (no panic).
1359 #[test]
1360 fn s1_2_d_absent_disabled_returns_none() {
1361 // (i) Neither section present/enabled.
1362 let config = ProjectConfig::default_for_project("demo");
1363 assert!(
1364 config.cheap_model().is_none(),
1365 "no cheap model configured: must return None"
1366 );
1367
1368 // (ii) [cheap_model] present but disabled.
1369 let mut config2 = ProjectConfig::default_for_project("demo");
1370 config2.cheap_model = Some(super::CheapModelSection {
1371 enabled: false,
1372 ..super::CheapModelSection::default()
1373 });
1374 assert!(
1375 config2.cheap_model().is_none(),
1376 "disabled cheap_model must return None"
1377 );
1378
1379 // (iii) [learning.distiller] present but disabled → back-compat returns None.
1380 let mut config3 = ProjectConfig::default_for_project("demo");
1381 config3.learning.distiller.enabled = false;
1382 assert!(
1383 config3.cheap_model().is_none(),
1384 "disabled learning.distiller must return None via back-compat"
1385 );
1386 }
1387
1388 /// S1.2: a pre-S1.2 project.toml (no `[cheap_model]` section) must load
1389 /// cleanly with `cheap_model = None`.
1390 #[test]
1391 fn pre_s1_2_config_without_cheap_model_loads_cleanly() {
1392 let toml = r#"
1393[kimetsu]
1394project_id = "demo"
1395schema_version = 7
1396
1397[model]
1398provider = "anthropic"
1399model = "claude-opus-4-7"
1400api_key_env = "ANTHROPIC_API_KEY"
1401max_output_tokens = 8192
1402temperature = 0.2
1403request_timeout_secs = 120
1404
1405[broker]
1406default_budget_tokens = 6000
1407
1408[broker.weights]
1409relevance = 0.5
1410confidence = 0.2
1411freshness = 0.2
1412scope = 0.1
1413
1414[shell]
1415default_timeout_secs = 60
1416max_timeout_secs = 600
1417env_allowlist_extra = []
1418redact_secrets = true
1419
1420[ingestion]
1421max_file_bytes = 524288
1422extra_skip_dirs = []
1423max_total_files = 50000
1424
1425[run]
1426max_total_tool_calls = 60
1427max_total_model_turns = 30
1428max_total_cost_usd = 250.0
1429"#;
1430 let config = ProjectConfig::from_toml(toml).expect("pre-S1.2 toml must load");
1431 assert!(
1432 config.cheap_model.is_none(),
1433 "cheap_model field must be None when absent from project.toml"
1434 );
1435 // And the resolver must return None (no distiller enabled either).
1436 assert!(
1437 config.cheap_model().is_none(),
1438 "cheap_model() must return None when no cheap model is configured"
1439 );
1440 }
1441
1442 // ── S5.1: StorageSection tests ────────────────────────────────────────
1443
1444 /// S5.1-a: `storage.backend` survives a round-trip through
1445 /// serialize → deserialize for each known variant string.
1446 #[test]
1447 fn s5_1_storage_backend_round_trips() {
1448 for variant in &["flat", "graph-lite", "graph"] {
1449 let mut config = ProjectConfig::default_for_project("demo");
1450 config.storage.backend = (*variant).to_string();
1451 let serialized = config.to_toml().expect("serialize");
1452 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
1453 assert_eq!(
1454 reloaded.storage.backend, *variant,
1455 "storage.backend=\"{}\" must round-trip",
1456 variant
1457 );
1458 }
1459 }
1460
1461 /// S5.1-b: `default_for_project` uses `backend = "flat"`.
1462 #[test]
1463 fn s5_1_default_for_project_uses_flat_backend() {
1464 let config = ProjectConfig::default_for_project("demo");
1465 assert_eq!(
1466 config.storage.backend, "flat",
1467 "default project config must use flat backend"
1468 );
1469 }
1470
1471 // ── F3 Pass B: answer_grade_min_score + proactive_prefetch tests ─────────
1472
1473 /// F3-B-1: new fields survive a round-trip through serialize → deserialize
1474 /// with non-default values.
1475 #[test]
1476 fn f3b_new_broker_fields_round_trip() {
1477 let mut config = ProjectConfig::default_for_project("demo");
1478 config.broker.answer_grade_min_score = 0.85;
1479 config.broker.proactive_prefetch = true;
1480 let serialized = config.to_toml().expect("serialize");
1481 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
1482 assert!(
1483 (reloaded.broker.answer_grade_min_score - 0.85).abs() < f32::EPSILON,
1484 "answer_grade_min_score must round-trip"
1485 );
1486 assert!(
1487 reloaded.broker.proactive_prefetch,
1488 "proactive_prefetch must round-trip as true"
1489 );
1490 }
1491
1492 /// F3-B-2: proactive_prefetch = false (default) survives round-trip.
1493 #[test]
1494 fn f3b_proactive_prefetch_default_false_round_trips() {
1495 let config = ProjectConfig::default_for_project("demo");
1496 assert!(!config.broker.proactive_prefetch, "default must be false");
1497 let serialized = config.to_toml().expect("serialize");
1498 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
1499 assert!(
1500 !reloaded.broker.proactive_prefetch,
1501 "default false must survive round-trip"
1502 );
1503 }
1504
1505 /// F3-B-3: default_for_project uses conservative defaults for both fields.
1506 #[test]
1507 fn f3b_default_for_project_uses_conservative_defaults() {
1508 let config = ProjectConfig::default_for_project("demo");
1509 // answer_grade_min_score: 0.92 (rare, only very high-confidence capsules)
1510 assert!(
1511 (config.broker.answer_grade_min_score - 0.92).abs() < f32::EPSILON,
1512 "default answer_grade_min_score must be 0.92"
1513 );
1514 // proactive_prefetch: false (opt-in — never changes default behaviour)
1515 assert!(
1516 !config.broker.proactive_prefetch,
1517 "default proactive_prefetch must be false"
1518 );
1519 }
1520
1521 // ── S3: SyncSection tests ──────────────────────────────────────────────
1522
1523 /// S3-cfg-1: a pre-S3 project.toml (no `[sync]` section) loads cleanly
1524 /// and defaults to no sync dir and empty machine_id.
1525 #[test]
1526 fn s3_pre_s3_config_without_sync_loads_cleanly() {
1527 let toml = r#"
1528[kimetsu]
1529project_id = "demo"
1530schema_version = 7
1531
1532[model]
1533provider = "anthropic"
1534model = "claude-opus-4-7"
1535api_key_env = "ANTHROPIC_API_KEY"
1536max_output_tokens = 8192
1537temperature = 0.2
1538request_timeout_secs = 120
1539
1540[broker]
1541default_budget_tokens = 6000
1542
1543[broker.weights]
1544relevance = 0.5
1545confidence = 0.2
1546freshness = 0.2
1547scope = 0.1
1548
1549[shell]
1550default_timeout_secs = 60
1551max_timeout_secs = 600
1552env_allowlist_extra = []
1553redact_secrets = true
1554
1555[ingestion]
1556max_file_bytes = 524288
1557extra_skip_dirs = []
1558max_total_files = 50000
1559
1560[run]
1561max_total_tool_calls = 60
1562max_total_model_turns = 30
1563max_total_cost_usd = 250.0
1564"#;
1565 let config = ProjectConfig::from_toml(toml).expect("pre-S3 toml must load");
1566 assert!(
1567 config.sync.dir.is_none(),
1568 "sync.dir must default to None when absent"
1569 );
1570 assert!(
1571 config.sync.machine_id.is_empty(),
1572 "sync.machine_id must default to empty string when absent"
1573 );
1574 }
1575
1576 /// S3-cfg-2: a `[sync]` section with dir + machine_id round-trips cleanly.
1577 #[test]
1578 fn s3_sync_section_round_trips() {
1579 let mut config = ProjectConfig::default_for_project("demo");
1580 config.sync.dir = Some("/tmp/kimetsu-sync".to_string());
1581 config.sync.machine_id = "my-laptop-01".to_string();
1582 let serialized = config.to_toml().expect("serialize");
1583 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
1584 assert_eq!(
1585 reloaded.sync.dir,
1586 Some("/tmp/kimetsu-sync".to_string()),
1587 "sync.dir must round-trip"
1588 );
1589 assert_eq!(
1590 reloaded.sync.machine_id, "my-laptop-01",
1591 "sync.machine_id must round-trip"
1592 );
1593 }
1594
1595 /// S3-cfg-3: default_for_project gives an unconfigured sync section.
1596 #[test]
1597 fn s3_default_for_project_sync_unconfigured() {
1598 let config = ProjectConfig::default_for_project("demo");
1599 assert!(config.sync.dir.is_none(), "default sync.dir must be None");
1600 assert!(
1601 config.sync.machine_id.is_empty(),
1602 "default sync.machine_id must be empty"
1603 );
1604 }
1605}