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 /// F3 Flagship 3 / Lifecycle & forgetting policy.
44 /// `#[serde(default)]` keeps all existing project.toml files loading
45 /// cleanly (they get forgetting disabled, sane defaults for all thresholds,
46 /// regret threshold 5, proposal expiry 30d, auto-accept disabled).
47 #[serde(default)]
48 pub lifecycle: LifecycleSection,
49 /// Retrieval pipeline preset. A single knob that bundles the retrieval
50 /// stack (embedder.enabled + embedder.reranker + HyDE) so users pick one
51 /// `level` instead of tuning each piece by hand. `#[serde(default)]`
52 /// keeps every existing project.toml loading cleanly: absent ⇒
53 /// `level = "custom"`, which is a no-op and leaves `[embedder]` exactly
54 /// as configured (byte-identical behaviour to before this field existed).
55 /// Resolved into `[embedder]` at config-load time by
56 /// [`ProjectConfig::apply_retrieval_level`].
57 #[serde(default)]
58 pub retrieval: RetrievalSection,
59}
60
61impl ProjectConfig {
62 pub fn default_for_project(project_id: impl Into<String>) -> Self {
63 Self {
64 kimetsu: KimetsuSection {
65 project_id: project_id.into(),
66 schema_version: KIMETSU_CONFIG_VERSION,
67 use_user_brain: default_true(),
68 mcp_write_tools: default_true(),
69 tier: None,
70 },
71 model: ModelSection::default(),
72 broker: BrokerSection::default(),
73 shell: ShellSection::default(),
74 ingestion: IngestionSection::default(),
75 run: RunSection::default(),
76 embedder: EmbedderSection::default(),
77 learning: LearningSection::default(),
78 cheap_model: None,
79 storage: StorageSection::default(),
80 sync: SyncSection::default(),
81 lifecycle: LifecycleSection::default(),
82 // NEW projects ship on "deep" (semantic + rerank), the
83 // recommended default. Existing project.toml files that omit
84 // [retrieval] get "custom" via #[serde(default)] and so behave
85 // exactly as before.
86 retrieval: RetrievalSection {
87 level: "deep".to_string(),
88 },
89 }
90 }
91
92 /// Apply the retrieval-level preset, mutating `embedder.enabled` +
93 /// `embedder.reranker` to match the configured `[retrieval] level`.
94 ///
95 /// Resolution:
96 /// - `basic` ⇒ embedder off, reranker off (FTS lexical only).
97 /// - `flexible` ⇒ embedder on, reranker off (semantic, no rerank).
98 /// - `deep` ⇒ embedder on, reranker `ms-marco-tinybert-l-2-v2`.
99 /// - `advanced` ⇒ same as `deep`, plus HyDE (see [`Self::hyde_from_level`]).
100 /// - `custom`/unknown ⇒ no-op: use the configured `[embedder]` values
101 /// as-is (the escape hatch for manual tuning).
102 ///
103 /// Called once at the load chokepoint (`load_config`) so every retrieval
104 /// consumer sees the resolved `[embedder]` values automatically.
105 pub fn apply_retrieval_level(&mut self) {
106 // The `[embedder] enabled = false` off-switch outranks every level
107 // preset: levels tune the retrieval stack, they must never override an
108 // explicit opt-out (the bidirectional-config rule). Without this guard,
109 // `level = "deep"` silently re-enabled a disabled embedder on every
110 // config load, and vectors were written against the operator's wishes.
111 if !self.embedder.enabled {
112 return;
113 }
114 match self.retrieval.level.as_str() {
115 "basic" => {
116 self.embedder.enabled = false;
117 self.embedder.reranker = "off".to_string();
118 }
119 "flexible" => {
120 self.embedder.enabled = true;
121 self.embedder.reranker = "off".to_string();
122 }
123 "deep" => {
124 self.embedder.enabled = true;
125 self.embedder.reranker = "ms-marco-tinybert-l-2-v2".to_string();
126 }
127 "advanced" => {
128 self.embedder.enabled = true;
129 self.embedder.reranker = "ms-marco-tinybert-l-2-v2".to_string();
130 }
131 _ => {} // "custom" or unknown: leave as configured
132 }
133 }
134
135 /// True when the configured level enables HyDE query expansion.
136 pub fn hyde_from_level(&self) -> bool {
137 self.retrieval.level == "advanced"
138 }
139
140 /// S1.2: resolve the effective cheap-model config.
141 ///
142 /// Resolution order (first wins):
143 /// 1. `[cheap_model]` if present AND `enabled = true` — explicit top-level
144 /// section introduced in S1.2.
145 /// 2. `[learning.distiller]` if `enabled = true` — back-compat alias so
146 /// any existing config with `[learning.distiller]` keeps working with
147 /// zero changes.
148 /// 3. `None` — no cheap model configured; consumers degrade gracefully
149 /// (no panic, feature just does not run — same as distiller-absent
150 /// behaviour before S1.2).
151 ///
152 /// FUTURE consumers (digest/resume/skill/ask) must call this resolver so
153 /// resolution stays in ONE place.
154 pub fn cheap_model(&self) -> Option<CheapModelSection> {
155 if let Some(ref cm) = self.cheap_model {
156 if cm.enabled {
157 return Some(cm.clone());
158 }
159 }
160 // Back-compat: treat an enabled [learning.distiller] as the cheap model.
161 if self.learning.distiller.enabled {
162 return Some(CheapModelSection::from_distiller(&self.learning.distiller));
163 }
164 None
165 }
166
167 /// v2.6: resolve the effective product tier.
168 ///
169 /// Resolution order (first wins):
170 /// 1. `KIMETSU_TIER` env var (`free` / `deep`; an unparseable value is
171 /// ignored rather than fatal — a typo in a shell profile must not
172 /// break retrieval).
173 /// 2. `[kimetsu] tier`, when set explicitly.
174 /// 3. Auto: Deep when a cheap model is configured, Free otherwise.
175 ///
176 /// **`deep` downgrades to `free` when [`Self::cheap_model`] resolves to
177 /// `None`.** Deep with no reachable model is not a third state: it is Free
178 /// with a misleading label, and every consumer would have to re-check.
179 /// Resolving it here means a caller can branch on the tier alone; use
180 /// [`Self::tier_downgraded`] when you want to *report* the discrepancy.
181 pub fn tier(&self) -> Tier {
182 match self.tier_requested() {
183 Some(Tier::Deep) if self.cheap_model().is_some() => Tier::Deep,
184 Some(Tier::Deep) => Tier::Free,
185 Some(Tier::Free) => Tier::Free,
186 // Auto: a brain that already has a cheap model configured is
187 // already making model calls. Calling that "free" would be a lie.
188 None if self.cheap_model().is_some() => Tier::Deep,
189 None => Tier::Free,
190 }
191 }
192
193 /// The tier the user explicitly asked for, if any. `None` means auto.
194 pub fn tier_requested(&self) -> Option<Tier> {
195 match std::env::var("KIMETSU_TIER") {
196 Ok(raw) => raw.parse::<Tier>().ok().or(self.kimetsu.tier),
197 Err(_) => self.kimetsu.tier,
198 }
199 }
200
201 /// True when Deep was asked for but no cheap model is reachable, so the
202 /// brain is silently running Free. `kimetsu doctor` surfaces this: the
203 /// failure mode it guards against is paying attention to a `deep` label
204 /// while none of the Deep features can actually run.
205 pub fn tier_downgraded(&self) -> bool {
206 self.tier_requested() == Some(Tier::Deep) && self.cheap_model().is_none()
207 }
208
209 /// The single gate every Deep-only code path must consult before making a
210 /// model call in the memory pipeline.
211 ///
212 /// Equivalent to `self.tier().allows_model()`, named for the invariant it
213 /// enforces: on Free this returns false, and the "zero LLM calls" claim is
214 /// exactly the statement that no memory-pipeline call site proceeds past it.
215 pub fn allows_model_in_pipeline(&self) -> bool {
216 self.tier().allows_model()
217 }
218
219 pub fn from_toml(value: &str) -> KimetsuResult<Self> {
220 Ok(toml::from_str(value)?)
221 }
222
223 pub fn to_toml(&self) -> KimetsuResult<String> {
224 Ok(toml::to_string_pretty(self)?)
225 }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct KimetsuSection {
230 pub project_id: String,
231 pub schema_version: i64,
232 /// W3.3: per-project opt-out of the global cross-project user brain
233 /// (`~/.kimetsu/brain.db`). When false, GlobalUser writes fall back to
234 /// the project DB and retrieval skips the user-brain merge — identical
235 /// to `KIMETSU_USER_BRAIN=0` but durable and scoped to this project.
236 ///
237 /// Precedence: `KIMETSU_USER_BRAIN` env > this field > default (true).
238 /// `#[serde(default)]` keeps all pre-W3 project.toml files loading
239 /// unchanged (they get `use_user_brain = true`).
240 #[serde(default = "default_true")]
241 pub use_user_brain: bool,
242 /// v1.0.0: allow the LOCAL stdio MCP server to expose privileged write
243 /// tools (`kimetsu_brain_record`, `memory_add/accept/reject`, …).
244 /// Default true: the local plugin install on your own machine is the
245 /// "trusted session" the gate exists for, and the brain's own workflow
246 /// (CLAUDE.md guidance, the Stop-hook harvest cue) instructs the agent
247 /// to record lessons — a default-deny gate contradicted that at every
248 /// session end. Personalize via `kimetsu config set
249 /// kimetsu.mcp_write_tools false`. Precedence:
250 /// `KIMETSU_MCP_ENABLE_WRITE_TOOLS` env (set = wins, truthy/falsy) >
251 /// this field > default (true). The REMOTE server ignores this field
252 /// entirely (a cloned repo's project.toml is untrusted there) and
253 /// stays env-only, default-deny.
254 #[serde(default = "default_true")]
255 pub mcp_write_tools: bool,
256 /// v2.6: which product tier this brain runs as.
257 ///
258 /// `"free"` (default) is the headline claim — **zero LLM calls anywhere in
259 /// the memory pipeline**. Ingest, store, retrieve and rerank are FTS5 +
260 /// local embeddings + a local cross-encoder, and every capability has a
261 /// deterministic or statistical implementation.
262 ///
263 /// `"deep"` opts into a local small model in the loop for the handful of
264 /// features that are genuinely better with one (see [`Tier`]). Every Deep
265 /// feature has a Free fallback that *is* the Free behaviour, so flipping
266 /// the tier can add quality but can never remove a capability.
267 ///
268 /// Absent (the default) means **auto**: a brain with a cheap model
269 /// configured is already making model calls, so it reads as Deep; a brain
270 /// without one reads as Free. That keeps every pre-v2.6 `project.toml`
271 /// behaving exactly as it did while making the label honest. Set it
272 /// explicitly to force the tier — `"free"` is a durable opt-out of model
273 /// calls even when credentials are present.
274 ///
275 /// Precedence: `KIMETSU_TIER` env > this field > auto.
276 /// Resolve it with [`ProjectConfig::tier`], never by reading this field —
277 /// the resolver also downgrades `deep` to `free` when no model is actually
278 /// reachable, which would otherwise be a label over a no-op.
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub tier: Option<Tier>,
281}
282
283/// v2.6: the product tier. See [`KimetsuSection::tier`].
284///
285/// | Feature | Free | Deep |
286/// |---|---|---|
287/// | write-time lesson distillation | rule-based capture | model-distilled lessons |
288/// | repo digest | rule-based assembly | model-distilled summary |
289/// | reflection over memory clusters | not run | synthesized general principles |
290/// | contradiction detection | cosine proximity | entailment adjudication |
291/// | proactive inject-or-stay-silent | locally-fit statistical policy | model adjudication |
292/// | idle-time work | consolidation, pruning, tuning | the above plus query anticipation |
293///
294/// The benchmark tables in `docs/memory-benchmark/` report both columns
295/// separately: Free is what the "model-free" claim is measured on.
296#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(rename_all = "lowercase")]
298pub enum Tier {
299 /// Model-free. The default, and what the published claims measure.
300 #[default]
301 Free,
302 /// A local small model in the loop for the features listed on [`Tier`].
303 Deep,
304}
305
306impl Tier {
307 /// True when this tier permits a model call in the memory pipeline.
308 pub fn allows_model(self) -> bool {
309 matches!(self, Tier::Deep)
310 }
311
312 pub fn as_str(self) -> &'static str {
313 match self {
314 Tier::Free => "free",
315 Tier::Deep => "deep",
316 }
317 }
318}
319
320impl std::str::FromStr for Tier {
321 type Err = String;
322
323 fn from_str(value: &str) -> Result<Self, Self::Err> {
324 match value.trim().to_ascii_lowercase().as_str() {
325 "free" => Ok(Tier::Free),
326 "deep" => Ok(Tier::Deep),
327 other => Err(format!("unknown tier `{other}` (expected free or deep)")),
328 }
329 }
330}
331
332impl std::fmt::Display for Tier {
333 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334 f.write_str(self.as_str())
335 }
336}
337
338/// v0.8: embedding-model selection. `model` is one of the curated
339/// built-in ids exposed by `kimetsu brain model list`
340/// (`bge-small-en-v1.5`, `bge-m3`, `jina-v2-base-code`). Switching
341/// changes the vector dimension, so a `kimetsu brain reindex` is
342/// required for cosine retrieval to use the new model.
343///
344/// W3.1: `enabled` is a persistent off-switch for the embedding engine.
345/// When false, the embedder resolves to NoopEmbedder (FTS-only; no
346/// vectors written or queried). Precedence: `KIMETSU_BRAIN_EMBEDDER`
347/// env override > this field > default (true). A disable env value
348/// (`noop`/`off`/`0`/…) always wins; a real model-id env value means
349/// "enabled" regardless of this field.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct EmbedderSection {
352 #[serde(default = "default_embedder_id")]
353 pub model: String,
354 /// W3.1: persistent embeddings off-switch. Default true (enabled).
355 /// `#[serde(default = "default_true")]` keeps pre-W3 project.toml
356 /// files loading unchanged.
357 #[serde(default = "default_true")]
358 pub enabled: bool,
359 /// v1.0.0: use the warm embedder daemon for the `UserPromptSubmit`
360 /// hook. `false` ⇒ the hook never spawns/contacts a daemon and stays
361 /// on floored-FTS even on `embeddings` builds. Config equivalent of
362 /// `KIMETSU_EMBED_DAEMON=0`. `#[serde(default = "default_true")]`
363 /// keeps older configs loading with the daemon on.
364 #[serde(default = "default_true")]
365 pub daemon: bool,
366 /// v1.0.0: pre-warm the daemon at harness startup (via `kimetsu brain
367 /// warm`, wired to SessionStart). `false` ⇒ no startup spawn; the
368 /// daemon (if `daemon=true`) warms lazily on the first prompt instead.
369 #[serde(default = "default_true")]
370 pub warm_on_start: bool,
371 /// v1.0.0: cross-encoder reranker the warm daemon applies as the final
372 /// ranking stage. `"off"` (default), a curated fastembed reranker id
373 /// (`jina-reranker-v1-turbo-en`, `bge-reranker-base`,
374 /// `bge-reranker-v2-m3`, `jina-reranker-v2-base-multilingual`), a
375 /// benchmarked alias (`jina-reranker-v1-tiny-en`,
376 /// `ms-marco-tinybert-l-2-v2`, `ms-marco-minilm-l-4-v2`), or any
377 /// HuggingFace `org/repo` with an ONNX export.
378 ///
379 /// Default `ms-marco-tinybert-l-2-v2`, chosen with `kimetsu brain
380 /// bench` on the 100-case real-memory dataset: paired with the
381 /// `jina-v2-base-code` embedder it lands within noise of the best
382 /// quality (MRR 0.938 vs 0.953 top) at ~43ms per rerank — far inside
383 /// the hook's 300ms budget. On slower machines a miss degrades
384 /// gracefully to floored-FTS for that turn. `"off"` disables
385 /// reranking. Validate changes on your own corpus with
386 /// `kimetsu brain bench` / `kimetsu brain eval`.
387 #[serde(default = "default_reranker_id")]
388 pub reranker: String,
389}
390
391fn default_embedder_id() -> String {
392 "jina-v2-base-code".to_string()
393}
394
395fn default_reranker_id() -> String {
396 "ms-marco-tinybert-l-2-v2".to_string()
397}
398
399fn default_true() -> bool {
400 true
401}
402
403impl Default for EmbedderSection {
404 fn default() -> Self {
405 Self {
406 model: default_embedder_id(),
407 enabled: default_true(),
408 daemon: default_true(),
409 warm_on_start: default_true(),
410 reranker: default_reranker_id(),
411 }
412 }
413}
414
415/// Retrieval pipeline preset. A single `level` knob bundles the retrieval
416/// stack so users do not have to tune the embedder, reranker, and HyDE
417/// individually. Resolved into `[embedder]` (+ a HyDE flag) at config-load
418/// time by [`ProjectConfig::apply_retrieval_level`] /
419/// [`ProjectConfig::hyde_from_level`].
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct RetrievalSection {
422 /// Retrieval pipeline preset: "basic" | "flexible" | "deep" | "advanced" | "custom".
423 #[serde(default = "default_retrieval_level")]
424 pub level: String,
425}
426
427impl Default for RetrievalSection {
428 fn default() -> Self {
429 Self {
430 level: default_retrieval_level(),
431 }
432 }
433}
434
435fn default_retrieval_level() -> String {
436 "custom".to_string()
437}
438
439/// v0.8.5: automatic memory harvesting. When `auto_harvest` is on, the
440/// proactive PostToolUse hook and the Stop hook emit a `[kimetsu-harvest]`
441/// cue at high-signal moments (a failed-then-fixed command, or a
442/// non-trivial session that recorded nothing) telling the agent to
443/// dispatch the `kimetsu-memory-harvester` subagent. Set it false to
444/// silence those cues.
445#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct LearningSection {
447 #[serde(default = "default_auto_harvest")]
448 pub auto_harvest: bool,
449 /// v1.5: store the raw retrieval query in local `context.served`
450 /// telemetry so the self-tuning loop can build a personal eval set.
451 /// Data never leaves the machine and is never exported. Set false to
452 /// keep only the query hash (the pre-v1.5 behavior). Default true so
453 /// new installs gain the eval-set signal immediately on upgrade;
454 /// `#[serde(default = "default_true")]` keeps pre-v1.5 project.toml
455 /// files loading cleanly (they get store_queries = true).
456 #[serde(default = "default_true")]
457 pub store_queries: bool,
458 /// Opt-in credentialed SessionEnd distiller (configured by the install
459 /// wizard). Disabled by default; `#[serde(default)]` keeps older
460 /// project.toml files loading.
461 #[serde(default)]
462 pub distiller: DistillerSection,
463}
464
465fn default_auto_harvest() -> bool {
466 true
467}
468
469impl Default for LearningSection {
470 fn default() -> Self {
471 Self {
472 auto_harvest: default_auto_harvest(),
473 store_queries: default_true(),
474 distiller: DistillerSection::default(),
475 }
476 }
477}
478
479/// Credentialed SessionEnd distiller config. Secret values (the API key,
480/// optional base URL) live in `.env` under the env-var names below; only
481/// non-secret selection lives here. `provider` is `anthropic`, `openai`, or
482/// `bedrock`.
483#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct DistillerSection {
485 #[serde(default)]
486 pub enabled: bool,
487 #[serde(default = "default_distiller_provider")]
488 pub provider: String,
489 #[serde(default = "default_distiller_model")]
490 pub model: String,
491 #[serde(default = "default_distiller_api_key_env")]
492 pub api_key_env: String,
493 #[serde(default = "default_distiller_base_url_env")]
494 pub base_url_env: String,
495 /// AWS Bedrock distiller: literal region. Takes precedence over
496 /// `region_env`. `#[serde(default)]` keeps existing config loading.
497 #[serde(default)]
498 pub region: Option<String>,
499 /// AWS Bedrock distiller: env-var name that holds the region.
500 /// Defaults to `"AWS_REGION"`. `#[serde(default)]` keeps existing
501 /// config loading cleanly.
502 #[serde(default = "default_distiller_region_env")]
503 pub region_env: String,
504}
505
506fn default_distiller_provider() -> String {
507 "anthropic".to_string()
508}
509fn default_distiller_model() -> String {
510 "claude-haiku-4-5".to_string()
511}
512fn default_distiller_api_key_env() -> String {
513 "ANTHROPIC_API_KEY".to_string()
514}
515fn default_distiller_base_url_env() -> String {
516 "ANTHROPIC_BASE_URL".to_string()
517}
518
519fn default_distiller_region_env() -> String {
520 "AWS_REGION".to_string()
521}
522
523impl Default for DistillerSection {
524 fn default() -> Self {
525 Self {
526 enabled: false,
527 provider: default_distiller_provider(),
528 model: default_distiller_model(),
529 api_key_env: default_distiller_api_key_env(),
530 base_url_env: default_distiller_base_url_env(),
531 region: None,
532 region_env: default_distiller_region_env(),
533 }
534 }
535}
536
537/// S1.2: top-level cheap-model config. Same shape as `DistillerSection`
538/// (provider / model / api_key_env / base_url_env / region / region_env) but
539/// with `provider` now including `"ollama"` (S1.1).
540///
541/// Providers:
542/// - `"anthropic"` / `"claude"` — Anthropic API.
543/// - `"openai"` / `"gpt"` / `"oai"` — OpenAI-compatible API.
544/// - `"ollama"` — local Ollama server (OpenAI-compatible at
545/// `http://localhost:11434/v1`). No API key required. Recommended
546/// small instruct models: `qwen2.5:3b`, `llama3.2:3b`.
547/// Override the endpoint with `base_url_env` (default env var
548/// `OLLAMA_BASE_URL`).
549/// - `"bedrock"` / `"aws"` — AWS Bedrock.
550///
551/// `#[serde(default)]` on the `ProjectConfig` field keeps all existing
552/// project.toml files loading unchanged (`cheap_model = None`).
553#[derive(Debug, Clone, Serialize, Deserialize)]
554pub struct CheapModelSection {
555 #[serde(default)]
556 pub enabled: bool,
557 #[serde(default = "default_cheap_model_provider")]
558 pub provider: String,
559 #[serde(default = "default_cheap_model_model")]
560 pub model: String,
561 /// Env-var name that holds the API key (not required for `ollama`).
562 #[serde(default = "default_cheap_model_api_key_env")]
563 pub api_key_env: String,
564 /// Env-var name that holds the base URL override. For `ollama` the
565 /// default resolved URL is `http://localhost:11434/v1` when this env
566 /// var is absent or empty.
567 #[serde(default = "default_cheap_model_base_url_env")]
568 pub base_url_env: String,
569 /// AWS Bedrock: literal region. Takes precedence over `region_env`.
570 #[serde(default)]
571 pub region: Option<String>,
572 /// AWS Bedrock: env-var name that holds the region (default `"AWS_REGION"`).
573 #[serde(default = "default_cheap_model_region_env")]
574 pub region_env: String,
575}
576
577fn default_cheap_model_provider() -> String {
578 "anthropic".to_string()
579}
580fn default_cheap_model_model() -> String {
581 "claude-haiku-4-5".to_string()
582}
583fn default_cheap_model_api_key_env() -> String {
584 "ANTHROPIC_API_KEY".to_string()
585}
586fn default_cheap_model_base_url_env() -> String {
587 "ANTHROPIC_BASE_URL".to_string()
588}
589fn default_cheap_model_region_env() -> String {
590 "AWS_REGION".to_string()
591}
592
593impl Default for CheapModelSection {
594 fn default() -> Self {
595 Self {
596 enabled: false,
597 provider: default_cheap_model_provider(),
598 model: default_cheap_model_model(),
599 api_key_env: default_cheap_model_api_key_env(),
600 base_url_env: default_cheap_model_base_url_env(),
601 region: None,
602 region_env: default_cheap_model_region_env(),
603 }
604 }
605}
606
607impl CheapModelSection {
608 /// S1.1: for `provider = "ollama"`, return the default base URL
609 /// (`http://localhost:11434/v1`) when no override is configured.
610 pub const OLLAMA_DEFAULT_BASE_URL: &'static str = "http://localhost:11434/v1";
611
612 /// Construct from a `DistillerSection` for back-compat resolution.
613 pub fn from_distiller(d: &DistillerSection) -> Self {
614 Self {
615 enabled: d.enabled,
616 provider: d.provider.clone(),
617 model: d.model.clone(),
618 api_key_env: d.api_key_env.clone(),
619 base_url_env: d.base_url_env.clone(),
620 region: d.region.clone(),
621 region_env: d.region_env.clone(),
622 }
623 }
624}
625
626/// S5.1: storage / retrieval backend configuration.
627///
628/// Controls which `RetrievalBackend` implementation is used for memory
629/// candidate generation. The broker (scoring, floors, rerank, compression)
630/// is backend-agnostic and is NOT affected by this setting.
631///
632/// `#[serde(default)]` keeps every pre-S5 project.toml loading cleanly
633/// (they get `backend = "flat"`, which is exactly today's FTS + usearch-ANN
634/// behaviour).
635#[derive(Debug, Clone, Serialize, Deserialize)]
636pub struct StorageSection {
637 /// Which retrieval backend to use.
638 ///
639 /// | Value | Behaviour |
640 /// |---------------|--------------------------------------------------------|
641 /// | `"flat"` | FTS + usearch HNSW ANN (default) |
642 /// | `"graph-lite"`| flat, plus 2-hop BFS over `memory_edges` with hop decay |
643 /// | `"graph"` | petgraph backend (feature `graph`; remote server only) |
644 ///
645 /// Unknown values fall back to `"flat"` with a warning.
646 ///
647 /// All three are implemented in `kimetsu_brain::backend`. Note that
648 /// `graph-lite` only differs from `flat` once the edge table has
649 /// non-`supersedes` edges in it — run `kimetsu brain graph build` first,
650 /// or it degenerates to flat retrieval. The published BEAM 100K figure
651 /// (73.3%, vs 62.3% flat) was measured on `graph-lite` with edges built.
652 #[serde(default = "default_storage_backend")]
653 pub backend: String,
654}
655
656/// v2.6: `graph-lite`, not `flat`.
657///
658/// The published BEAM 100K figure (73.3%) was measured on graph-lite; `flat`
659/// scored 62.3% on the same set. Shipping `flat` as the default meant the
660/// advertised number described a configuration almost nobody was running.
661///
662/// It was the right default until now only because the graph was empty in
663/// practice: `relates_to` edges existed solely if a user remembered to run
664/// `kimetsu brain graph build`, so graph-lite paid for a traversal that found
665/// nothing. Now that the write path links each memory as it lands (see
666/// `crate::graph::incremental_edges_for_memory`), the traversal has something
667/// to traverse.
668///
669/// Safe by construction: graph-lite's candidate set is a superset of flat's,
670/// and graph-reached candidates enter with `raw_relevance = 0.0`, so they rank
671/// below every direct hit and can only fill slots flat would have left empty.
672fn default_storage_backend() -> String {
673 "graph-lite".to_string()
674}
675
676impl Default for StorageSection {
677 fn default() -> Self {
678 Self {
679 backend: default_storage_backend(),
680 }
681 }
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize)]
685pub struct ModelSection {
686 pub provider: String,
687 pub model: String,
688 pub api_key_env: String,
689 pub max_output_tokens: u32,
690 pub temperature: f32,
691 pub request_timeout_secs: u64,
692 /// AWS Bedrock: literal region (e.g. `us-east-1`). Takes precedence over
693 /// `region_env`. `#[serde(default)]` keeps existing project.toml loading.
694 #[serde(default)]
695 pub region: Option<String>,
696 /// AWS Bedrock: env-var name that holds the region. Defaults to
697 /// `"AWS_REGION"` via `default_region_env()`. Consulted only when
698 /// `region` is `None`. `#[serde(default)]` keeps existing project.toml
699 /// loading cleanly.
700 #[serde(default = "default_region_env")]
701 pub region_env: String,
702 /// v1.5: override the built-in $/MTok price table for the ROI ledger.
703 /// When set, this value is used for USD conversion instead of the
704 /// approximate built-in table. Useful for private-endpoint pricing or
705 /// non-standard model deployments. `#[serde(default)]` keeps all
706 /// pre-v1.5 project.toml files loading cleanly (they get `None`).
707 #[serde(default)]
708 pub price_per_mtok: Option<f64>,
709}
710
711fn default_region_env() -> String {
712 "AWS_REGION".to_string()
713}
714
715impl Default for ModelSection {
716 fn default() -> Self {
717 Self {
718 provider: "anthropic".to_string(),
719 model: "claude-opus-4-7".to_string(),
720 api_key_env: "ANTHROPIC_API_KEY".to_string(),
721 max_output_tokens: 8192,
722 temperature: 0.2,
723 request_timeout_secs: 120,
724 region: None,
725 region_env: default_region_env(),
726 price_per_mtok: None,
727 }
728 }
729}
730
731#[derive(Debug, Clone, Serialize, Deserialize)]
732pub struct BrokerSection {
733 /// Flat per-stage budget (tokens). Used as a fallback when
734 /// `task_size == 0` (broker disabled or task-size signal unavailable)
735 /// and as the compat default for pre-F3 project.toml files.
736 /// For live runs the adaptive budget (`adaptive_budget`) supersedes this.
737 pub default_budget_tokens: u32,
738 pub weights: BrokerWeights,
739 /// D1f: hard cap on capsules rendered into a model prompt. The
740 /// broker may surface more capsules than this (up to the token
741 /// budget), but the pipeline render step truncates to this cap so
742 /// a tighter, higher-precision capsule set isn't silently padded
743 /// back to a larger number. 0 = disabled (budget-only limit).
744 ///
745 /// Default 8: lower than the old hard-coded 12 so precision from
746 /// D1e wins; operators can raise it per-project in project.toml.
747 /// `#[serde(default)]` keeps pre-D1f project.toml files loading.
748 #[serde(default = "default_max_capsules")]
749 pub max_capsules: usize,
750 /// D1e: absolute minimum cosine similarity between the query
751 /// embedding and a candidate embedding required for the candidate
752 /// to survive budgeting. When > 0.0, candidates whose cosine is
753 /// strictly below this threshold are dropped BEFORE the MMR pass
754 /// so a genuinely-irrelevant corpus hits the zero-capsule skipped
755 /// path more often. Inert on lean (NoopEmbedder) builds because
756 /// there is no query embedding to compare against.
757 ///
758 /// Default -1.0 = AUTO (v1.0.0): the right floor is MODEL-DEPENDENT,
759 /// because cosine scales differ per embedder. bge-family cosines for
760 /// related pairs sit well above ~0.5 with noise below ~0.4, so auto
761 /// resolves to 0.35 there. jina-v2 cosines run lower — the remote
762 /// benchmark showed a 0.35 floor KILLING relevant results outright
763 /// (MRR 0.90 → 0.77, recall@2 == recall@4) — and that model's own
764 /// precision already keeps noise low (~1.2 vs bge's ~4.0 capsules on
765 /// no-answer queries, floors off), so auto resolves to 0.0 (disabled)
766 /// for non-bge models. Set an explicit value to override auto in
767 /// either direction; 0.0 disables. `#[serde(default = …)]` keeps older
768 /// configs loading with auto.
769 #[serde(default = "default_min_semantic_score")]
770 pub min_semantic_score: f32,
771 /// v1.0.0: absolute *lexical* relevance floor for memory candidates,
772 /// expressed as the fraction of the query's IDF-weighted discriminating
773 /// power a memory must lexically cover to survive. Unlike
774 /// `min_semantic_score` (which needs a query embedding and is therefore
775 /// inert on the FTS-only `UserPromptSubmit` hook path), this floor works
776 /// on lexical retrieval — closing the gap where a broad conceptual query
777 /// ("what's the idea of the repo") surfaces unrelated memories that only
778 /// share a corpus-ubiquitous token like the project name.
779 ///
780 /// Mechanics: query tokens are stripped of stopwords; each remaining
781 /// token is IDF-weighted over the memory corpus (so the project name,
782 /// present in nearly every memory, contributes ~0). A memory is dropped
783 /// when the IDF-weighted share of the query it covers is below this floor
784 /// AND it has no semantic support. Repo-file/manifest capsules pass
785 /// through untouched (their FTS match on file content is itself the
786 /// relevance signal, and overview queries *want* the README).
787 ///
788 /// Default 0.5 = "must cover the more-discriminating half of the query."
789 /// 0.0 disables the floor. `#[serde(default = …)]` keeps older configs
790 /// loading with the floor active.
791 #[serde(default = "default_min_lexical_coverage")]
792 pub min_lexical_coverage: f32,
793 /// v2.6: how the broker merges candidate lists from different retrieval
794 /// strategies (lexical FTS, semantic ANN, graph traversal).
795 ///
796 /// * `"linear"` (default) — union the lists, keeping each memory's best
797 /// `raw_relevance`, itself a linear blend of BM25 and cosine at α = 0.5.
798 /// Kimetsu's behaviour through v2.5.
799 /// * `"rrf"` — reciprocal rank fusion. Uses only each candidate's rank in
800 /// each list, so the fact that BM25 is unbounded and corpus-dependent
801 /// while cosine is bounded and tightly clustered stops mattering, and a
802 /// memory both lists rank highly beats one a single list loves.
803 ///
804 /// Defaults to `linear` because the house rule is that every claim ships
805 /// with a measurement, and "RRF is the 2026 default" is not one for *this*
806 /// corpus. `kimetsu brain tune` sweeps both against your own query history.
807 /// Unknown values fall back to `linear`.
808 #[serde(default = "default_fusion")]
809 pub fusion: String,
810 /// v2.6: how a candidate's raw relevance is normalized into the
811 /// `relevance` term of the composite score.
812 ///
813 /// * `"per_kind"` (default) — normalize within each capsule kind, so the
814 /// best memory and the best repo_file each land at `relevance = 1.0`
815 /// however good either is. Kimetsu's behaviour through v2.5.
816 /// * `"global"` — one max across every candidate, so relevance means the
817 /// same thing across kinds and the best of an irrelevant kind stays low.
818 ///
819 /// Per-kind normalization is the reason the lexical and semantic floors
820 /// have to exist: they prune weak candidates before normalization can
821 /// flatter them to 1.0. Global is the more principled rule, and it is
822 /// still not the default, because a ranking change ships with a
823 /// measurement on a corpus and not with an argument. Unknown values fall
824 /// back to `per_kind`.
825 #[serde(default = "default_normalization")]
826 pub normalization: String,
827 /// v2.5 (graph-ranking): composite-score floor for the whole retrieval. When
828 /// set above zero and the TOP direct candidate's final score is below it, the
829 /// context bundle comes back empty (`skipped`) so the reader abstains instead
830 /// of answering from weak matches. Keyed off the strongest DIRECT hit (graph
831 /// candidates rank below their seed), so multi-hop expansion never masks a
832 /// genuine "nothing matched". 0.0 disables. `#[serde(default = …)]` keeps
833 /// older project.toml files loading unchanged.
834 #[serde(default = "default_abstain_min_score")]
835 pub abstain_min_score: f32,
836 /// F3: floor for the adaptive per-stage brain budget. Small tasks
837 /// receive at least this many tokens so the brain is never starved.
838 /// `#[serde(default)]` keeps pre-F3 project.toml files loading cleanly.
839 #[serde(default = "default_budget_floor_tokens")]
840 pub budget_floor_tokens: u32,
841 /// F3: per-run global ceiling on brain-injected tokens across ALL
842 /// stages combined. Later stages receive only the remaining capacity
843 /// once earlier stages have been charged via the `RunRecallLedger`.
844 /// `#[serde(default)]` keeps pre-F3 project.toml files loading cleanly.
845 #[serde(default = "default_budget_run_cap_tokens")]
846 pub budget_run_cap_tokens: u32,
847 /// W3.2: persistent ambient-context off-switch. When false, the
848 /// workspace fingerprint (branch, recent files, dirty status) is not
849 /// collected or appended to the retrieval query. Precedence:
850 /// `KIMETSU_BRAIN_AMBIENT` env override > this field > default (true).
851 /// `#[serde(default = "default_true")]` keeps pre-W3 project.toml
852 /// files loading unchanged.
853 #[serde(default = "default_true")]
854 pub ambient: bool,
855 /// v1.5 (Story 2.1): render-time capsule compression. When true (default),
856 /// capsule summaries are compressed with [`compress_for_render`] before
857 /// being injected into hook stdout or MCP tool responses. Compression
858 /// strips `[tags: ...]` / `(context: ...)` annotations and caps at 3
859 /// sentences. Ranking is NEVER affected — compression runs only after
860 /// retrieval and reranking. Set false to inject full memory text (useful
861 /// for debugging or when summaries are already concise).
862 ///
863 /// `#[serde(default = "default_true")]` keeps pre-v1.5 project.toml files
864 /// loading cleanly (they get compression ON).
865 #[serde(default = "default_true")]
866 pub compress_capsules: bool,
867 /// v1.5 (Story 2.3): session-scoped cross-turn capsule dedupe. When true
868 /// (default), the `UserPromptSubmit` context hook skips capsules whose
869 /// `expansion_handle` was already injected earlier in the same session
870 /// (tracked via the proactive-state sidecar). A soft policy: skipping only
871 /// happens when at least one NEW capsule remains — if dedupe would empty
872 /// the injection entirely, all capsules are injected anyway (a repeated
873 /// top memory may still be the right context). Set false to disable
874 /// session dedupe and always inject the full ranked set.
875 ///
876 /// `#[serde(default = "default_true")]` keeps pre-v1.5 project.toml files
877 /// loading cleanly (they get session dedupe ON).
878 #[serde(default = "default_true")]
879 pub session_dedupe: bool,
880 /// Flagship 1 / Pass B: inject the repo digest + work-resume context at
881 /// SessionStart. When true (default), `kimetsu brain session-start-hook`
882 /// prints `additionalContext` JSON combining the ~400-token repo digest
883 /// (1.1) and the episodic resume (Pass A). Set false to suppress the
884 /// warm-start injection entirely — useful when the host already provides
885 /// repo context or the digest is not yet built.
886 ///
887 /// `#[serde(default = "default_true")]` keeps pre-Flagship-1 project.toml
888 /// files loading cleanly (they get warm_start ON — the feature is additive
889 /// and defaults to enabled so fresh installs get it immediately).
890 #[serde(default = "default_true")]
891 pub warm_start: bool,
892 /// Flagship 3 / Pass B (3.3): minimum composite broker score for a capsule
893 /// to receive the "Verified answer from project memory:" prefix at render
894 /// time. This prefix signals to the model that it can act in one turn
895 /// rather than re-verifying the information.
896 ///
897 /// STRICTLY ADDITIVE: only changes the rendered prefix of an already-top
898 /// capsule. Ranking, floors, and capsule selection are NEVER affected.
899 ///
900 /// The threshold is deliberately conservative (0.92 default) so the marker
901 /// is rare and only fires on genuinely unambiguous matches. Tune with
902 /// `kimetsu brain bench` data (Epic S2) before lowering. Set to 1.1 (above
903 /// the maximum achievable score) to disable entirely, or 0.0 to always
904 /// mark any top capsule (not recommended — wait for regret data first).
905 ///
906 /// Regret guard: if the capsule's memory was recently dropped by floors in
907 /// another retrieval context (appears in the dropped sidecar), the prefix
908 /// is suppressed regardless of this threshold, preventing overconfident
909 /// labelling of inconsistently-scored memories.
910 ///
911 /// `#[serde(default = …)]` keeps all pre-F3 project.toml files loading
912 /// unchanged (they get the conservative default).
913 #[serde(default = "default_answer_grade_min_score")]
914 pub answer_grade_min_score: f32,
915 /// Flagship 3 / Pass B (3.5): opt-in proactive pre-fetch at PreToolUse.
916 ///
917 /// When true, the PreToolUse hook does a LIGHTWEIGHT relevance warm based
918 /// on the current tool's file path (in addition to the command text),
919 /// surfacing a relevant memory before the agent edits or reads a file.
920 /// The existing floors (min_score, max_capsules, session dedupe, refractory
921 /// throttle) all apply — this is additive only.
922 ///
923 /// Default false (OFF): the PreToolUse hook behaviour is identical to
924 /// before this flag existed.
925 ///
926 /// v2.6: graduating to default-on has always been stated to depend on
927 /// evidence that file-path-augmented queries do not increase noise — and
928 /// nothing was recording which hook surface an injection came from, so
929 /// that evidence could not accumulate and the flag could not graduate on
930 /// any timescale. Injections now carry their surface
931 /// (`inject_policy::Surface`), and `kimetsu brain policy` reports
932 /// acceptance per surface, so the prefetch surface can be compared against
933 /// the ones that react to something observed rather than predicted. The
934 /// default stays off until that comparison is made on a real brain; the
935 /// point of this change is that it is now makeable. Enable per-project in
936 /// project.toml meanwhile.
937 ///
938 /// `#[serde(default)]` keeps all pre-F3 project.toml files loading with
939 /// the feature OFF (zero behaviour change for existing users).
940 #[serde(default)]
941 pub proactive_prefetch: bool,
942}
943
944fn default_max_capsules() -> usize {
945 8
946}
947
948fn default_min_semantic_score() -> f32 {
949 -1.0
950}
951
952fn default_fusion() -> String {
953 "linear".to_string()
954}
955
956fn default_normalization() -> String {
957 "per_kind".to_string()
958}
959
960fn default_min_lexical_coverage() -> f32 {
961 0.5
962}
963
964/// v2.5: whole-retrieval abstention floor on the top direct candidate's composite
965/// score. 0.0 = disabled (unchanged behaviour); set per-project to make weak
966/// retrievals return an empty bundle so the reader abstains.
967fn default_abstain_min_score() -> f32 {
968 0.0
969}
970
971fn default_budget_floor_tokens() -> u32 {
972 1500
973}
974
975fn default_budget_run_cap_tokens() -> u32 {
976 8000
977}
978
979/// F3 / Pass B (3.3): conservative answer-grade threshold. At 0.92 the marker
980/// fires only when the retrieval pipeline (embedder + reranker) places the top
981/// capsule in the very top of its score range — roughly 1-in-10 retrievals on
982/// a well-populated brain. Lowering requires regret data from Epic S2 to
983/// confirm precision stays high.
984fn default_answer_grade_min_score() -> f32 {
985 0.92
986}
987
988impl Default for BrokerSection {
989 fn default() -> Self {
990 Self {
991 default_budget_tokens: 6000,
992 weights: BrokerWeights::default(),
993 max_capsules: default_max_capsules(),
994 min_semantic_score: default_min_semantic_score(),
995 min_lexical_coverage: default_min_lexical_coverage(),
996 fusion: default_fusion(),
997 normalization: default_normalization(),
998 abstain_min_score: default_abstain_min_score(),
999 budget_floor_tokens: default_budget_floor_tokens(),
1000 budget_run_cap_tokens: default_budget_run_cap_tokens(),
1001 ambient: default_true(),
1002 compress_capsules: default_true(),
1003 session_dedupe: default_true(),
1004 warm_start: default_true(),
1005 answer_grade_min_score: default_answer_grade_min_score(),
1006 proactive_prefetch: false,
1007 }
1008 }
1009}
1010
1011/// F3: compute the adaptive per-stage brain budget given a task-size signal.
1012///
1013/// **Task-size signal** (defined): `task_size = estimate_tokens(task_text) +
1014/// estimate_tokens(localized_file_context)`, where `estimate_tokens` uses the
1015/// same heuristic as the rest of the pipeline: `(whitespace_words * 1.33).ceil()`.
1016/// Localized-file context is the rendered list of paths surfaced before the
1017/// first implementation attempt.
1018///
1019/// **Scaling**: `floor + k * sqrt(task_size)` clamped to `[floor, run_cap]`.
1020/// sqrt is chosen because it grows slower than linear — doubling task_size
1021/// grows the budget by only ~41%, and a 5× task grows it by only ~124%
1022/// (well under 2×).
1023///
1024/// **Constant k**: chosen so a "typical" task (task_size ≈ 200 tokens, e.g.
1025/// a concise one-paragraph task + a handful of file paths) lands near
1026/// today's default 6000 tokens, avoiding a behavior cliff on upgrade.
1027/// k = (6000 - 1500) / sqrt(200) ≈ 318.2
1028///
1029/// **Fallback**: when `task_size == 0` (broker disabled, size signal
1030/// unavailable, or called pre-retrieval) returns `floor` — callers should
1031/// use `default_budget_tokens` instead in those paths.
1032///
1033/// **Per-run cap**: the caller is responsible for computing
1034/// `remaining = run_cap.saturating_sub(ledger.injected_tokens())` and passing
1035/// `min(adaptive_budget(...), remaining)` as the stage's `budget_tokens`.
1036pub fn adaptive_budget(task_size: u32, floor: u32, run_cap: u32) -> u32 {
1037 if task_size == 0 {
1038 return floor;
1039 }
1040 // k ≈ 318.2 so that adaptive_budget(200, 1500, 8000) ≈ 6000.
1041 // We scale k by 10 and work in integer arithmetic to avoid f64 in hot path.
1042 const K_SCALED: u32 = 3182; // k * 10
1043 let sqrt_part = (task_size as f64).sqrt();
1044 let budget_f = floor as f64 + (K_SCALED as f64 / 10.0) * sqrt_part;
1045 let budget = budget_f.round() as u32;
1046 budget.clamp(floor, run_cap)
1047}
1048
1049#[derive(Debug, Clone, Serialize, Deserialize)]
1050pub struct BrokerWeights {
1051 pub relevance: f32,
1052 pub confidence: f32,
1053 pub freshness: f32,
1054 pub scope: f32,
1055 pub localization: Option<StageWeights>,
1056 pub patch_plan: Option<StageWeights>,
1057 pub verification: Option<StageWeights>,
1058 pub review: Option<StageWeights>,
1059 /// v0.5.1: half-life (in days) for the usefulness-decay
1060 /// multiplier. A memory's effective usefulness contribution
1061 /// decays as `exp(-ln(2) * age_days / half_life)` where age is
1062 /// measured from `last_useful_at` if present, else
1063 /// `created_at`. 30 days = a 6-month-old useful memory ends
1064 /// up at ~1.5% of its original weight; tune lower for faster-
1065 /// changing repos, higher for slow-evolving ones.
1066 ///
1067 /// `#[serde(default)]` keeps pre-v0.5.1 project.toml files
1068 /// loading cleanly — they get the 30-day default.
1069 #[serde(default = "default_decay_half_life_days")]
1070 pub decay_half_life_days: f32,
1071}
1072
1073fn default_decay_half_life_days() -> f32 {
1074 30.0
1075}
1076
1077impl Default for BrokerWeights {
1078 fn default() -> Self {
1079 Self {
1080 relevance: 0.50,
1081 confidence: 0.20,
1082 freshness: 0.20,
1083 scope: 0.10,
1084 localization: Some(StageWeights {
1085 relevance: 0.70,
1086 confidence: 0.10,
1087 freshness: 0.10,
1088 scope: 0.10,
1089 }),
1090 patch_plan: Some(StageWeights {
1091 relevance: 0.40,
1092 confidence: 0.30,
1093 freshness: 0.10,
1094 scope: 0.20,
1095 }),
1096 verification: Some(StageWeights {
1097 relevance: 0.40,
1098 confidence: 0.10,
1099 freshness: 0.40,
1100 scope: 0.10,
1101 }),
1102 review: Some(StageWeights {
1103 relevance: 0.50,
1104 confidence: 0.20,
1105 freshness: 0.20,
1106 scope: 0.10,
1107 }),
1108 decay_half_life_days: default_decay_half_life_days(),
1109 }
1110 }
1111}
1112
1113#[derive(Debug, Clone, Serialize, Deserialize)]
1114pub struct StageWeights {
1115 pub relevance: f32,
1116 pub confidence: f32,
1117 pub freshness: f32,
1118 pub scope: f32,
1119}
1120
1121#[derive(Debug, Clone, Serialize, Deserialize)]
1122pub struct ShellSection {
1123 pub default_timeout_secs: u64,
1124 pub max_timeout_secs: u64,
1125 pub env_allowlist_extra: Vec<String>,
1126 pub redact_secrets: bool,
1127}
1128
1129impl Default for ShellSection {
1130 fn default() -> Self {
1131 Self {
1132 default_timeout_secs: 60,
1133 max_timeout_secs: 600,
1134 env_allowlist_extra: vec!["RUSTFLAGS".to_string(), "CARGO_HOME".to_string()],
1135 redact_secrets: true,
1136 }
1137 }
1138}
1139
1140#[derive(Debug, Clone, Serialize, Deserialize)]
1141pub struct IngestionSection {
1142 pub max_file_bytes: u64,
1143 pub extra_skip_dirs: Vec<String>,
1144 pub max_total_files: u64,
1145 /// v1.0: enable/disable the per-add conflict-detection scan.
1146 ///
1147 /// Default true. Set to false (or set env `KIMETSU_DETECT_CONFLICTS=0`)
1148 /// to skip the cosine-similarity conflict scan at add time — useful when
1149 /// bulk-seeding a brain where the O(N²) scan would be prohibitively slow.
1150 /// Review `kimetsu brain memory conflicts` afterwards to catch any
1151 /// contradictions.
1152 ///
1153 /// Precedence: `KIMETSU_DETECT_CONFLICTS` env > this field > default.
1154 #[serde(default = "default_true")]
1155 pub detect_conflicts: bool,
1156 /// v2.5 Pass B (Story 1.3): enable automatic contradiction resolution.
1157 ///
1158 /// When true (default), conflicting memory pairs are scored by
1159 /// `confidence × recency`. Clear winners (score gap ≥ 0.15) have the
1160 /// loser's `valid_to` stamped to now via `mark_memory_temporal`
1161 /// (event-sourced, rebuild-safe). Near-ties are queued in
1162 /// `memory_conflicts` for operator review, same as the v0.5.2 behavior.
1163 ///
1164 /// Set to false (or set env `KIMETSU_RESOLVE_CONFLICTS=0`) to revert to
1165 /// detect-only mode: all conflicts are queued for the operator.
1166 ///
1167 /// Precedence: `KIMETSU_RESOLVE_CONFLICTS` env > this field > default.
1168 /// Resolution only runs when `detect_conflicts` is also enabled.
1169 #[serde(default = "default_true")]
1170 pub resolve_conflicts: bool,
1171
1172 /// Flagship 2 / Story 2.1: seed a non-zero initial usefulness_score for
1173 /// new memories at write time.
1174 ///
1175 /// Uses a rule-based estimator (kind weight + rarity bonus) — no model
1176 /// required. The value is stored in the `memory.accepted` event payload
1177 /// (`initial_usefulness`) and applied by the projector (rebuild-safe).
1178 /// Set false to keep the v0 default of 0.0 for all new memories.
1179 /// `#[serde(default = "default_true")]` keeps pre-Flagship-2 project.toml
1180 /// files loading cleanly (they get the feature ON).
1181 #[serde(default = "default_true")]
1182 pub initial_importance_scoring: bool,
1183
1184 /// Flagship 2 / Story 2.2: quality-control filter in the distiller.
1185 /// Drop lessons that are near-duplicates (cosine ≥ threshold), too long,
1186 /// too short, or contain transience markers. Default true.
1187 /// `#[serde(default = "default_true")]` keeps older configs loading cleanly.
1188 #[serde(default = "default_true")]
1189 pub quality_filter_enabled: bool,
1190
1191 /// Flagship 2 / Story 2.2: novelty threshold — cosine ≥ this value → DROP.
1192 /// Default 0.9. `#[serde(default)]` keeps older configs loading cleanly
1193 /// (they get the default via the `Default` impl).
1194 #[serde(default = "default_quality_filter_novelty_threshold")]
1195 pub quality_filter_novelty_threshold: f32,
1196
1197 /// Flagship 2 / Story 2.2: minimum lesson length in chars (after trim).
1198 /// Lessons shorter than this are dropped. Default 10.
1199 #[serde(default = "default_quality_filter_min_len")]
1200 pub quality_filter_min_len: usize,
1201
1202 /// Flagship 2 / Story 2.2: maximum lesson length in chars (after trim).
1203 /// Lessons longer than this are dropped. Default 500.
1204 #[serde(default = "default_quality_filter_max_len")]
1205 pub quality_filter_max_len: usize,
1206}
1207
1208fn default_quality_filter_novelty_threshold() -> f32 {
1209 0.9
1210}
1211fn default_quality_filter_min_len() -> usize {
1212 10
1213}
1214fn default_quality_filter_max_len() -> usize {
1215 500
1216}
1217
1218impl Default for IngestionSection {
1219 fn default() -> Self {
1220 Self {
1221 max_file_bytes: 524_288,
1222 extra_skip_dirs: Vec::new(),
1223 max_total_files: 50_000,
1224 detect_conflicts: true,
1225 resolve_conflicts: true,
1226 initial_importance_scoring: true,
1227 quality_filter_enabled: true,
1228 quality_filter_novelty_threshold: default_quality_filter_novelty_threshold(),
1229 quality_filter_min_len: default_quality_filter_min_len(),
1230 quality_filter_max_len: default_quality_filter_max_len(),
1231 }
1232 }
1233}
1234
1235#[derive(Debug, Clone, Serialize, Deserialize)]
1236pub struct RunSection {
1237 pub max_total_tool_calls: u32,
1238 pub max_total_model_turns: u32,
1239 pub max_total_cost_usd: f32,
1240}
1241
1242impl Default for RunSection {
1243 fn default() -> Self {
1244 // `max_total_cost_usd` is treated as advisory under subscription-based
1245 // providers (e.g. Claude Code OAuth). The agent loop still enforces it
1246 // when it does fire, but the default is set high enough that it
1247 // functions as a runaway-prevention safety net rather than a per-run
1248 // budget. Tighten in `project.toml` when running against a metered
1249 // provider.
1250 Self {
1251 max_total_tool_calls: 60,
1252 max_total_model_turns: 30,
1253 max_total_cost_usd: 250.0,
1254 }
1255 }
1256}
1257
1258/// Epic S3: personal brain sync configuration.
1259///
1260/// Controls the event-log replication directory protocol. When `dir` is
1261/// absent, the sync subcommand is unconfigured and prints a setup hint.
1262///
1263/// `machine_id` is a stable opaque identifier for this machine. It defaults
1264/// to a freshly-generated ULID that is persisted in project.toml on first
1265/// use (written by `kimetsu brain sync --setup`). Operators can set it
1266/// manually to a meaningful name (hostname, username, etc.) — just keep it
1267/// unique within the sync directory.
1268///
1269/// `#[serde(default)]` keeps every pre-S3 project.toml loading cleanly.
1270#[derive(Debug, Clone, Serialize, Deserialize)]
1271pub struct SyncSection {
1272 /// Absolute (or home-relative) path to the shared sync directory.
1273 /// Each machine writes its batches under `<dir>/<machine_id>/`.
1274 /// When `None`, syncing is unconfigured.
1275 #[serde(default)]
1276 pub dir: Option<String>,
1277 /// Stable machine identifier. Defaults to an empty string (= not yet
1278 /// set; the CLI generates one on first use).
1279 #[serde(default)]
1280 pub machine_id: String,
1281 /// v2.6 #3 Slice B: when `dir` is configured, automatically run a full sync
1282 /// (push + pull + converge) at session end. Defaults to `true` — set
1283 /// `auto = false` to keep sync manual (`kimetsu brain sync`).
1284 #[serde(default = "default_sync_auto")]
1285 pub auto: bool,
1286}
1287
1288fn default_sync_auto() -> bool {
1289 true
1290}
1291
1292impl Default for SyncSection {
1293 fn default() -> Self {
1294 Self {
1295 dir: None,
1296 machine_id: String::new(),
1297 auto: default_sync_auto(),
1298 }
1299 }
1300}
1301
1302// ---------------------------------------------------------------------------
1303// F3 Flagship 3 / Lifecycle & forgetting configuration
1304// ---------------------------------------------------------------------------
1305
1306/// F3 lifecycle / forgetting policy configuration.
1307///
1308/// All settings are gated behind `forget_enabled = false` by default so
1309/// existing installs are completely unaffected until an operator opts in.
1310///
1311/// `#[serde(default)]` keeps all existing project.toml files loading cleanly.
1312#[derive(Debug, Clone, Serialize, Deserialize)]
1313pub struct LifecycleSection {
1314 // ---- Story 3.1: Active forgetting ----
1315 /// Master opt-in switch. Default false — forgetting is NEVER triggered
1316 /// without the operator explicitly enabling it.
1317 #[serde(default)]
1318 pub forget_enabled: bool,
1319
1320 /// Minimum age (days) before a memory is eligible for archival.
1321 /// Only memories whose `last_useful_at` (or `created_at` when never cited)
1322 /// is older than this many days are considered. Default 90.
1323 #[serde(default = "default_forget_min_age_days")]
1324 pub forget_min_age_days: u32,
1325
1326 /// Usefulness floor: memories with `usefulness_score / max(use_count, 1)
1327 /// <= this` value are candidates. Default -0.1 (net-negative).
1328 #[serde(default = "default_forget_usefulness_floor")]
1329 pub forget_usefulness_floor: f32,
1330
1331 /// Evergreen protection threshold. Memories with
1332 /// `use_count >= forget_protect_use_count` are NEVER archived regardless
1333 /// of their usefulness ratio. Default 10.
1334 #[serde(default = "default_forget_protect_use_count")]
1335 pub forget_protect_use_count: u32,
1336
1337 // ---- Story 3.2: Regret-driven review ----
1338 /// Number of `retrieval.regret` events a memory must accumulate before
1339 /// it appears in the review list. Default 5.
1340 #[serde(default = "default_regret_flag_threshold")]
1341 pub regret_flag_threshold: u64,
1342
1343 // ---- Story 3.3: Proposal-queue hygiene ----
1344 /// Number of days before a pending proposal is auto-expired (rejected with
1345 /// reason "expired"). Default 30. 0 disables expiry.
1346 #[serde(default = "default_proposal_expiry_days")]
1347 pub proposal_expiry_days: u32,
1348
1349 /// Proposals with `proposed_confidence >= this` value are auto-accepted
1350 /// during the hygiene pass. Default 1.1 (disabled — threshold above the
1351 /// maximum possible confidence of 1.0).
1352 #[serde(default = "default_proposal_auto_accept_confidence")]
1353 pub proposal_auto_accept_confidence: f32,
1354}
1355
1356fn default_forget_min_age_days() -> u32 {
1357 90
1358}
1359fn default_forget_usefulness_floor() -> f32 {
1360 -0.1
1361}
1362fn default_forget_protect_use_count() -> u32 {
1363 10
1364}
1365fn default_regret_flag_threshold() -> u64 {
1366 5
1367}
1368fn default_proposal_expiry_days() -> u32 {
1369 30
1370}
1371fn default_proposal_auto_accept_confidence() -> f32 {
1372 1.1 // disabled: above max confidence
1373}
1374
1375impl Default for LifecycleSection {
1376 fn default() -> Self {
1377 Self {
1378 forget_enabled: false,
1379 forget_min_age_days: default_forget_min_age_days(),
1380 forget_usefulness_floor: default_forget_usefulness_floor(),
1381 forget_protect_use_count: default_forget_protect_use_count(),
1382 regret_flag_threshold: default_regret_flag_threshold(),
1383 proposal_expiry_days: default_proposal_expiry_days(),
1384 proposal_auto_accept_confidence: default_proposal_auto_accept_confidence(),
1385 }
1386 }
1387}
1388
1389#[cfg(test)]
1390mod tests {
1391 use super::*;
1392
1393 // ── v2.6: Free/Deep tier resolution ──────────────────────────────────
1394 //
1395 // These deliberately do not touch KIMETSU_TIER: env is process-global and
1396 // the suite runs in parallel by default. The env branch is a one-line
1397 // `parse().ok().or(field)`; the interesting logic is the field × model
1398 // matrix below.
1399
1400 fn enabled_cheap_model() -> CheapModelSection {
1401 CheapModelSection {
1402 enabled: true,
1403 ..CheapModelSection::default()
1404 }
1405 }
1406
1407 /// A brand-new project with no model configured is Free, which is what the
1408 /// "zero LLM calls in the memory pipeline" claim is measured on.
1409 #[test]
1410 fn tier_defaults_to_free_without_a_model() {
1411 let config = ProjectConfig::default_for_project("test");
1412 assert_eq!(config.tier(), Tier::Free);
1413 assert!(!config.tier_downgraded());
1414 }
1415
1416 /// Auto: a brain with a cheap model configured is already making model
1417 /// calls. Reporting that as Free would be a lie, so it resolves to Deep
1418 /// without anyone having to edit project.toml — which is also what keeps
1419 /// every pre-v2.6 config behaving exactly as it did.
1420 #[test]
1421 fn tier_auto_resolves_to_deep_when_a_model_is_configured() {
1422 let mut config = ProjectConfig::default_for_project("test");
1423 config.cheap_model = Some(enabled_cheap_model());
1424 assert_eq!(config.tier_requested(), None, "field left at auto");
1425 assert_eq!(config.tier(), Tier::Deep);
1426 }
1427
1428 /// Back-compat: an enabled `[learning.distiller]` is a cheap model by the
1429 /// existing resolver, so it lights up Deep the same way.
1430 #[test]
1431 fn tier_auto_follows_the_legacy_distiller_alias() {
1432 let mut config = ProjectConfig::default_for_project("test");
1433 config.learning.distiller.enabled = true;
1434 assert_eq!(config.tier(), Tier::Deep);
1435 }
1436
1437 /// `tier = "free"` is a durable opt-out: credentials present, model calls
1438 /// off. Without this the only way to stop the distiller would be to remove
1439 /// the credentials.
1440 #[test]
1441 fn explicit_free_overrides_a_configured_model() {
1442 let mut config = ProjectConfig::default_for_project("test");
1443 config.cheap_model = Some(enabled_cheap_model());
1444 config.kimetsu.tier = Some(Tier::Free);
1445 assert_eq!(config.tier(), Tier::Free);
1446 assert!(!config.allows_model_in_pipeline());
1447 }
1448
1449 /// Deep with nothing to run on is Free with a misleading label. Resolve it
1450 /// down, and flag it so `doctor` can say so out loud.
1451 #[test]
1452 fn deep_without_a_model_downgrades_and_is_flagged() {
1453 let mut config = ProjectConfig::default_for_project("test");
1454 config.kimetsu.tier = Some(Tier::Deep);
1455 assert_eq!(config.tier(), Tier::Free, "no model — nothing to run");
1456 assert!(
1457 config.tier_downgraded(),
1458 "the discrepancy must be reportable, not silent"
1459 );
1460 }
1461
1462 /// The tier round-trips through TOML, and an absent field stays absent
1463 /// (auto) rather than being written back as an explicit choice.
1464 #[test]
1465 fn tier_round_trips_and_auto_stays_unwritten() {
1466 let config = ProjectConfig::default_for_project("test");
1467 let toml = config.to_toml().expect("to_toml");
1468 assert!(
1469 !toml.contains("tier"),
1470 "auto must not serialize a tier field; got:\n{toml}"
1471 );
1472
1473 let mut deep = ProjectConfig::default_for_project("test");
1474 deep.kimetsu.tier = Some(Tier::Deep);
1475 let toml = deep.to_toml().expect("to_toml");
1476 assert!(toml.contains("tier = \"deep\""), "got:\n{toml}");
1477 let parsed = ProjectConfig::from_toml(&toml).expect("from_toml");
1478 assert_eq!(parsed.kimetsu.tier, Some(Tier::Deep));
1479 }
1480
1481 /// A pre-v2.6 project.toml has no `tier` field at all: it must load, and
1482 /// it must land on Free rather than on a serde error.
1483 #[test]
1484 fn missing_tier_field_loads_cleanly() {
1485 let mut written = ProjectConfig::default_for_project("legacy");
1486 written.kimetsu.tier = Some(Tier::Deep);
1487 let toml = written.to_toml().expect("to_toml");
1488 // Strip the tier line to simulate a config written before the field existed.
1489 let legacy: String = toml
1490 .lines()
1491 .filter(|line| !line.trim_start().starts_with("tier ="))
1492 .collect::<Vec<_>>()
1493 .join("\n");
1494 assert!(
1495 !legacy.contains("tier ="),
1496 "fixture must have no tier field"
1497 );
1498
1499 let config = ProjectConfig::from_toml(&legacy).expect("legacy config must load");
1500 assert_eq!(config.kimetsu.tier, None);
1501 assert_eq!(config.tier(), Tier::Free);
1502 }
1503
1504 /// A pre-v0.8 project.toml has no `[embedder]` table. The
1505 /// `#[serde(default)]` on the field must keep it loading cleanly,
1506 /// defaulting to the lean English model.
1507 #[test]
1508 fn pre_v0_8_config_without_embedder_loads_with_default() {
1509 let toml = r#"
1510[kimetsu]
1511project_id = "demo"
1512schema_version = 7
1513
1514[model]
1515provider = "anthropic"
1516model = "claude-opus-4-7"
1517api_key_env = "ANTHROPIC_API_KEY"
1518max_output_tokens = 8192
1519temperature = 0.2
1520request_timeout_secs = 120
1521
1522[broker]
1523default_budget_tokens = 6000
1524
1525[broker.weights]
1526relevance = 0.5
1527confidence = 0.2
1528freshness = 0.2
1529scope = 0.1
1530
1531[shell]
1532default_timeout_secs = 60
1533max_timeout_secs = 600
1534env_allowlist_extra = []
1535redact_secrets = true
1536
1537[ingestion]
1538max_file_bytes = 524288
1539extra_skip_dirs = []
1540max_total_files = 50000
1541
1542[run]
1543max_total_tool_calls = 60
1544max_total_model_turns = 30
1545max_total_cost_usd = 250.0
1546"#;
1547 let config = ProjectConfig::from_toml(toml).expect("pre-v0.8 toml must load");
1548 assert_eq!(config.embedder.model, "jina-v2-base-code");
1549 // A pre-v0.8.5 toml has no [learning] section — auto-harvest
1550 // defaults on so existing installs gain the behavior on upgrade.
1551 assert!(config.learning.auto_harvest);
1552 // A pre-distiller toml has no [learning.distiller] — defaults to off,
1553 // anthropic, claude-haiku-4-5.
1554 assert!(!config.learning.distiller.enabled);
1555 assert_eq!(config.learning.distiller.provider, "anthropic");
1556 assert_eq!(config.learning.distiller.model, "claude-haiku-4-5");
1557 assert_eq!(config.learning.distiller.api_key_env, "ANTHROPIC_API_KEY");
1558 assert_eq!(config.learning.distiller.base_url_env, "ANTHROPIC_BASE_URL");
1559 // D1e/D1f: pre-D1 configs without max_capsules / min_semantic_score
1560 // must load cleanly and receive the safe defaults.
1561 assert_eq!(config.broker.max_capsules, 8);
1562 // v1.0.0: the semantic floor is ON by default (was 0.0/disabled) now
1563 // that the warm daemon serves semantic retrieval to every prompt.
1564 assert_eq!(config.broker.min_semantic_score, -1.0, "auto sentinel");
1565 // v1.0.0: a config without min_lexical_coverage loads with the floor
1566 // active at its default (0.5), so existing installs gain the relevance
1567 // gate on upgrade.
1568 assert_eq!(config.broker.min_lexical_coverage, 0.5);
1569 // F3: pre-F3 configs without budget_floor_tokens / budget_run_cap_tokens
1570 // must load cleanly and receive the safe defaults.
1571 assert_eq!(config.broker.budget_floor_tokens, 1500);
1572 assert_eq!(config.broker.budget_run_cap_tokens, 8000);
1573 // W3: pre-W3 configs without the new off-switch fields must load
1574 // cleanly and default to enabled (true) for all three features.
1575 assert!(
1576 config.embedder.enabled,
1577 "W3.1: embedder.enabled must default to true"
1578 );
1579 assert!(
1580 config.broker.ambient,
1581 "W3.2: broker.ambient must default to true"
1582 );
1583 assert!(
1584 config.kimetsu.use_user_brain,
1585 "W3.3: kimetsu.use_user_brain must default to true"
1586 );
1587 // v1.0.0: daemon + warm_on_start default ON so existing installs get
1588 // the warm-daemon path on upgrade.
1589 assert!(
1590 config.embedder.daemon,
1591 "embedder.daemon must default to true"
1592 );
1593 assert!(
1594 config.embedder.warm_on_start,
1595 "embedder.warm_on_start must default to true"
1596 );
1597 // v1.0.0: reranker defaults to jina-reranker-v1-turbo-en so existing
1598 // v1.0.0: a config without mcp_write_tools loads with local write
1599 // tools ENABLED, so the record-a-lesson workflow the brain itself
1600 // prescribes works out of the box on upgrade.
1601 assert!(
1602 config.kimetsu.mcp_write_tools,
1603 "kimetsu.mcp_write_tools must default to true"
1604 );
1605 // v1.0.0: jina-tiny + pool 6 measured as fitting the hook's 300ms
1606 // budget on real memories with the best benchmark quality, so the
1607 // reranker is ON by default.
1608 assert_eq!(
1609 config.embedder.reranker, "ms-marco-tinybert-l-2-v2",
1610 "embedder.reranker must default to ms-marco-tinybert-l-2-v2"
1611 );
1612 // v1.5: a pre-v1.5 project.toml has no learning.store_queries —
1613 // defaults to true so existing installs gain the eval-set signal
1614 // on upgrade without any config change.
1615 assert!(
1616 config.learning.store_queries,
1617 "learning.store_queries must default to true"
1618 );
1619 // v1.5 (Story 2.1): a pre-v1.5 project.toml without broker.compress_capsules
1620 // must load cleanly and default to true (compression ON).
1621 assert!(
1622 config.broker.compress_capsules,
1623 "broker.compress_capsules must default to true"
1624 );
1625 // v1.5 (Story 2.3): a pre-v1.5 project.toml without broker.session_dedupe
1626 // must load cleanly and default to true (dedupe ON).
1627 assert!(
1628 config.broker.session_dedupe,
1629 "broker.session_dedupe must default to true"
1630 );
1631 // Flagship 1 Pass B: a pre-Flagship-1 project.toml without
1632 // broker.warm_start must load cleanly and default to true (warm-start ON).
1633 assert!(
1634 config.broker.warm_start,
1635 "broker.warm_start must default to true"
1636 );
1637 // S5.1: a pre-S5 project.toml without [storage] must load cleanly.
1638 // v2.6 flipped the default from "flat" to "graph-lite" — the config
1639 // the published benchmark numbers were measured on, and safe because
1640 // graph-lite's candidate set is a superset of flat's.
1641 assert_eq!(
1642 config.storage.backend, "graph-lite",
1643 "storage.backend must default to \"graph-lite\" when absent"
1644 );
1645 // F3 Pass B (3.3): a pre-F3 project.toml without broker.answer_grade_min_score
1646 // must load cleanly and receive the conservative default (0.92).
1647 assert!(
1648 (config.broker.answer_grade_min_score - 0.92).abs() < f32::EPSILON,
1649 "broker.answer_grade_min_score must default to 0.92"
1650 );
1651 // F3 Pass B (3.5): a pre-F3 project.toml without broker.proactive_prefetch
1652 // must load cleanly and default to false (opt-in, OFF by default).
1653 assert!(
1654 !config.broker.proactive_prefetch,
1655 "broker.proactive_prefetch must default to false (opt-in)"
1656 );
1657 // S3: a pre-S3 project.toml without [sync] must load cleanly and
1658 // default to no sync dir and empty machine_id.
1659 assert!(
1660 config.sync.dir.is_none(),
1661 "sync.dir must default to None when absent"
1662 );
1663 assert!(
1664 config.sync.machine_id.is_empty(),
1665 "sync.machine_id must default to empty string when absent"
1666 );
1667 // Retrieval levels: a project.toml without [retrieval] must load
1668 // cleanly and default to level = "custom", which is a no-op so the
1669 // [embedder] values above are used exactly as configured.
1670 assert_eq!(
1671 config.retrieval.level, "custom",
1672 "retrieval.level must default to \"custom\" when absent"
1673 );
1674 }
1675
1676 /// Each retrieval level must resolve into the documented
1677 /// `embedder.enabled` + `embedder.reranker` (+ HyDE) preset.
1678 #[test]
1679 fn retrieval_level_resolves_embedder_and_reranker() {
1680 // basic: lexical only — embedder off, reranker off.
1681 let mut basic = ProjectConfig::default_for_project("p");
1682 basic.retrieval.level = "basic".to_string();
1683 basic.apply_retrieval_level();
1684 assert!(!basic.embedder.enabled);
1685 assert_eq!(basic.embedder.reranker, "off");
1686 assert!(!basic.hyde_from_level());
1687
1688 // flexible: semantic, no rerank — embedder on, reranker off.
1689 let mut flexible = ProjectConfig::default_for_project("p");
1690 flexible.retrieval.level = "flexible".to_string();
1691 flexible.apply_retrieval_level();
1692 assert!(flexible.embedder.enabled);
1693 assert_eq!(flexible.embedder.reranker, "off");
1694 assert!(!flexible.hyde_from_level());
1695
1696 // deep: semantic + rerank — embedder on, reranker tinybert.
1697 let mut deep = ProjectConfig::default_for_project("p");
1698 deep.retrieval.level = "deep".to_string();
1699 deep.apply_retrieval_level();
1700 assert!(deep.embedder.enabled);
1701 assert_eq!(deep.embedder.reranker, "ms-marco-tinybert-l-2-v2");
1702 assert!(!deep.hyde_from_level());
1703
1704 // advanced: semantic + rerank + HyDE.
1705 let mut advanced = ProjectConfig::default_for_project("p");
1706 advanced.retrieval.level = "advanced".to_string();
1707 advanced.apply_retrieval_level();
1708 assert!(advanced.embedder.enabled);
1709 assert_eq!(advanced.embedder.reranker, "ms-marco-tinybert-l-2-v2");
1710 assert!(
1711 advanced.hyde_from_level(),
1712 "advanced level must enable HyDE"
1713 );
1714
1715 // custom: no-op — hand-set [embedder] values are left untouched.
1716 let mut custom = ProjectConfig::default_for_project("p");
1717 custom.retrieval.level = "custom".to_string();
1718 custom.embedder.enabled = false;
1719 custom.embedder.reranker = "bge-reranker-base".to_string();
1720 custom.apply_retrieval_level();
1721 assert!(
1722 !custom.embedder.enabled,
1723 "custom must not touch embedder.enabled"
1724 );
1725 assert_eq!(
1726 custom.embedder.reranker, "bge-reranker-base",
1727 "custom must not touch embedder.reranker"
1728 );
1729 assert!(!custom.hyde_from_level());
1730
1731 // unknown level behaves like custom (no-op).
1732 let mut unknown = ProjectConfig::default_for_project("p");
1733 unknown.retrieval.level = "bogus".to_string();
1734 unknown.embedder.enabled = false;
1735 unknown.apply_retrieval_level();
1736 assert!(!unknown.embedder.enabled, "unknown level must be a no-op");
1737 }
1738
1739 /// The `[embedder] enabled = false` off-switch outranks every level
1740 /// preset: `level = "deep"` (or any other) must never re-enable a
1741 /// disabled embedder on config load. Regression test for the W3.1
1742 /// CI failure where vectors were written despite `enabled = false`.
1743 #[test]
1744 fn retrieval_level_never_overrides_embedder_off_switch() {
1745 for level in &["basic", "flexible", "deep", "advanced"] {
1746 let mut cfg = ProjectConfig::default_for_project("p");
1747 cfg.retrieval.level = level.to_string();
1748 cfg.embedder.enabled = false;
1749 let reranker_before = cfg.embedder.reranker.clone();
1750 cfg.apply_retrieval_level();
1751 assert!(
1752 !cfg.embedder.enabled,
1753 "level {level} must not re-enable a disabled embedder"
1754 );
1755 assert_eq!(
1756 cfg.embedder.reranker, reranker_before,
1757 "level {level} must not touch the reranker when the embedder is off"
1758 );
1759 }
1760 }
1761
1762 /// A1: default_for_project must use KIMETSU_CONFIG_VERSION (the
1763 /// project.toml format version), NOT KIMETSU_SCHEMA_VERSION (the brain.db
1764 /// schema). The two constants are intentionally decoupled so a DB-schema
1765 /// bump does not force every project.toml to be rewritten.
1766 #[test]
1767 fn default_config_uses_config_version_not_schema_version() {
1768 let cfg = ProjectConfig::default_for_project("p1");
1769 assert_eq!(cfg.kimetsu.schema_version, crate::KIMETSU_CONFIG_VERSION);
1770 }
1771
1772 /// `model set` writes the whole config back via `to_toml`; a
1773 /// round-trip must preserve the chosen embedder (and other sections).
1774 #[test]
1775 fn embedder_survives_toml_round_trip() {
1776 let mut config = ProjectConfig::default_for_project("demo");
1777 config.embedder.model = "bge-m3".to_string();
1778 let serialized = config.to_toml().expect("serialize");
1779 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
1780 assert_eq!(reloaded.embedder.model, "bge-m3");
1781 assert_eq!(reloaded.broker.default_budget_tokens, 6000);
1782 assert_eq!(reloaded.kimetsu.project_id, "demo");
1783 // F3 fields survive round-trip.
1784 assert_eq!(reloaded.broker.budget_floor_tokens, 1500);
1785 assert_eq!(reloaded.broker.budget_run_cap_tokens, 8000);
1786 // W3 off-switch fields survive round-trip.
1787 assert!(reloaded.embedder.enabled);
1788 assert!(reloaded.broker.ambient);
1789 assert!(reloaded.kimetsu.use_user_brain);
1790 }
1791
1792 /// W3: the three new off-switch fields can be set to `false` in
1793 /// project.toml and round-trip cleanly.
1794 #[test]
1795 fn w3_off_switch_fields_round_trip_as_false() {
1796 let mut config = ProjectConfig::default_for_project("demo");
1797 config.embedder.enabled = false;
1798 config.broker.ambient = false;
1799 config.kimetsu.use_user_brain = false;
1800 let serialized = config.to_toml().expect("serialize");
1801 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
1802 assert!(
1803 !reloaded.embedder.enabled,
1804 "embedder.enabled must survive as false"
1805 );
1806 assert!(
1807 !reloaded.broker.ambient,
1808 "broker.ambient must survive as false"
1809 );
1810 assert!(
1811 !reloaded.kimetsu.use_user_brain,
1812 "kimetsu.use_user_brain must survive as false"
1813 );
1814 // Unrelated fields unaffected.
1815 assert_eq!(reloaded.kimetsu.project_id, "demo");
1816 }
1817
1818 // ── F3: adaptive_budget unit tests ────────────────────────────────────
1819
1820 /// F3-budget-1: floor is returned when task_size == 0.
1821 #[test]
1822 fn f3_adaptive_budget_zero_size_returns_floor() {
1823 assert_eq!(
1824 super::adaptive_budget(0, 1500, 8000),
1825 1500,
1826 "task_size=0 must return floor"
1827 );
1828 }
1829
1830 /// F3-budget-2: run_cap is returned when task_size is enormous (very large).
1831 #[test]
1832 fn f3_adaptive_budget_huge_size_clamped_to_run_cap() {
1833 let result = super::adaptive_budget(1_000_000, 1500, 8000);
1834 assert_eq!(result, 8000, "huge task_size must be clamped to run_cap");
1835 }
1836
1837 /// F3-budget-3: budget grows SUBLINEARLY — adaptive_budget(5*T) < 2 * adaptive_budget(T).
1838 ///
1839 /// With sqrt scaling: budget(5*T) / budget(T) = (floor + k*sqrt(5T)) / (floor + k*sqrt(T))
1840 /// < sqrt(5) ≈ 2.236 for large T, but also < 2 for T in the practical range
1841 /// because the floor term dominates at small sizes and sqrt(5) dominates at large
1842 /// sizes. Specifically for T=200: budget(200)≈6000, budget(1000)≈8000 (capped) → ratio < 2.
1843 /// For T=50 (below cap): budget(50)≈3751, budget(250)≈6534 → ratio ≈ 1.74 < 2. ✓
1844 #[test]
1845 fn f3_adaptive_budget_is_sublinear() {
1846 let floor = 1500u32;
1847 let run_cap = 16_000u32; // raised cap for this test so neither hits the ceiling
1848
1849 // T0 = 200 tokens (concise task), 5*T0 = 1000 tokens (verbose task)
1850 let t0 = 200u32;
1851 let b_t0 = super::adaptive_budget(t0, floor, run_cap);
1852 let b_5t0 = super::adaptive_budget(5 * t0, floor, run_cap);
1853
1854 assert!(
1855 b_5t0 < 2 * b_t0,
1856 "sublinear guarantee: adaptive_budget(5*T)={b_5t0} must be < 2*adaptive_budget(T)={} (T={t0})",
1857 2 * b_t0
1858 );
1859 assert!(
1860 b_5t0 > b_t0,
1861 "budget must still grow: adaptive_budget(5*T)={b_5t0} > adaptive_budget(T)={b_t0}"
1862 );
1863 }
1864
1865 /// F3-budget-4: a typical task (task_size ≈ 200) lands near the historical
1866 /// default of 6000 tokens, avoiding a behavior cliff on upgrade.
1867 #[test]
1868 fn f3_adaptive_budget_typical_task_near_historical_default() {
1869 let budget = super::adaptive_budget(200, 1500, 8000);
1870 // k = 318.2 → floor + k*sqrt(200) = 1500 + 318.2*14.14 ≈ 5999
1871 // Allow ±300 to tolerate rounding.
1872 assert!(
1873 (5700..=8000).contains(&budget),
1874 "typical task budget expected near 6000, got {budget}"
1875 );
1876 }
1877
1878 /// F3-budget-5: floor is always respected — even small tasks get at least floor.
1879 #[test]
1880 fn f3_adaptive_budget_respects_floor() {
1881 for size in [1u32, 5, 10, 50] {
1882 let b = super::adaptive_budget(size, 1500, 8000);
1883 assert!(
1884 b >= 1500,
1885 "task_size={size}: budget={b} must be >= floor=1500"
1886 );
1887 }
1888 }
1889
1890 /// F3-budget-6: run_cap is always respected — large tasks never exceed cap.
1891 #[test]
1892 fn f3_adaptive_budget_respects_run_cap() {
1893 for size in [500u32, 1000, 5000, 100_000] {
1894 let b = super::adaptive_budget(size, 1500, 8000);
1895 assert!(
1896 b <= 8000,
1897 "task_size={size}: budget={b} must be <= run_cap=8000"
1898 );
1899 }
1900 }
1901
1902 /// v1.5: a pre-v1.5 project.toml without `model.price_per_mtok` must
1903 /// load cleanly and default to `None` (backward compatibility).
1904 #[test]
1905 fn pre_v1_5_config_without_price_per_mtok_loads_with_none() {
1906 let toml = r#"
1907[kimetsu]
1908project_id = "demo"
1909schema_version = 7
1910
1911[model]
1912provider = "anthropic"
1913model = "claude-sonnet-4-7"
1914api_key_env = "ANTHROPIC_API_KEY"
1915max_output_tokens = 8192
1916temperature = 0.2
1917request_timeout_secs = 120
1918
1919[broker]
1920default_budget_tokens = 6000
1921
1922[broker.weights]
1923relevance = 0.5
1924confidence = 0.2
1925freshness = 0.2
1926scope = 0.1
1927
1928[shell]
1929default_timeout_secs = 60
1930max_timeout_secs = 600
1931env_allowlist_extra = []
1932redact_secrets = true
1933
1934[ingestion]
1935max_file_bytes = 524288
1936extra_skip_dirs = []
1937max_total_files = 50000
1938
1939[run]
1940max_total_tool_calls = 60
1941max_total_model_turns = 30
1942max_total_cost_usd = 250.0
1943"#;
1944 let config = ProjectConfig::from_toml(toml).expect("pre-v1.5 toml must load");
1945 assert!(
1946 config.model.price_per_mtok.is_none(),
1947 "price_per_mtok must default to None when absent from project.toml"
1948 );
1949 }
1950
1951 /// v1.5 (Story 2.1+2.3): compress_capsules and session_dedupe survive a
1952 /// round-trip through serialize → deserialize when set to false.
1953 #[test]
1954 fn broker_v1_5_fields_round_trip_as_false() {
1955 let mut config = ProjectConfig::default_for_project("demo");
1956 config.broker.compress_capsules = false;
1957 config.broker.session_dedupe = false;
1958 let serialized = config.to_toml().expect("serialize");
1959 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
1960 assert!(
1961 !reloaded.broker.compress_capsules,
1962 "compress_capsules must survive as false"
1963 );
1964 assert!(
1965 !reloaded.broker.session_dedupe,
1966 "session_dedupe must survive as false"
1967 );
1968 }
1969
1970 /// v1.5: when `model.price_per_mtok` is set in project.toml it must
1971 /// round-trip cleanly through serialize → deserialize.
1972 #[test]
1973 fn price_per_mtok_round_trips() {
1974 let mut config = ProjectConfig::default_for_project("demo");
1975 config.model.price_per_mtok = Some(7.5);
1976 let serialized = config.to_toml().expect("serialize");
1977 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
1978 assert_eq!(
1979 reloaded.model.price_per_mtok,
1980 Some(7.5),
1981 "price_per_mtok must round-trip"
1982 );
1983 }
1984
1985 // ── S1.2: cheap_model() resolver tests ───────────────────────────────
1986
1987 /// S1.2-a: a toml with only `[learning.distiller]` (no `[cheap_model]`)
1988 /// resolves via back-compat and returns the distiller's settings.
1989 #[test]
1990 fn s1_2_a_learning_distiller_back_compat() {
1991 let mut config = ProjectConfig::default_for_project("demo");
1992 config.learning.distiller.enabled = true;
1993 config.learning.distiller.provider = "openai".to_string();
1994 config.learning.distiller.model = "gpt-5.4-mini".to_string();
1995 config.cheap_model = None;
1996
1997 let resolved = config.cheap_model().expect("back-compat must resolve");
1998 assert_eq!(resolved.provider, "openai");
1999 assert_eq!(resolved.model, "gpt-5.4-mini");
2000 assert!(resolved.enabled);
2001 }
2002
2003 /// S1.2-b: `[cheap_model]` takes precedence over `[learning.distiller]`
2004 /// when both are present and enabled.
2005 #[test]
2006 fn s1_2_b_cheap_model_takes_precedence() {
2007 let mut config = ProjectConfig::default_for_project("demo");
2008 config.learning.distiller.enabled = true;
2009 config.learning.distiller.provider = "anthropic".to_string();
2010 config.learning.distiller.model = "claude-haiku-4-5".to_string();
2011 config.cheap_model = Some(super::CheapModelSection {
2012 enabled: true,
2013 provider: "ollama".to_string(),
2014 model: "qwen2.5:3b".to_string(),
2015 api_key_env: "OLLAMA_API_KEY".to_string(),
2016 base_url_env: "OLLAMA_BASE_URL".to_string(),
2017 region: None,
2018 region_env: "AWS_REGION".to_string(),
2019 });
2020
2021 let resolved = config.cheap_model().expect("cheap_model must resolve");
2022 assert_eq!(
2023 resolved.provider, "ollama",
2024 "[cheap_model] must win over [learning.distiller]"
2025 );
2026 assert_eq!(resolved.model, "qwen2.5:3b");
2027 }
2028
2029 /// S1.2-c: provider=ollama round-trips and the OLLAMA_DEFAULT_BASE_URL
2030 /// constant has the expected value.
2031 #[test]
2032 fn s1_2_c_ollama_default_base_url() {
2033 assert_eq!(
2034 super::CheapModelSection::OLLAMA_DEFAULT_BASE_URL,
2035 "http://localhost:11434/v1",
2036 "ollama default base URL must point to localhost:11434/v1"
2037 );
2038
2039 let mut config = ProjectConfig::default_for_project("demo");
2040 config.cheap_model = Some(super::CheapModelSection {
2041 enabled: true,
2042 provider: "ollama".to_string(),
2043 model: "llama3.2:3b".to_string(),
2044 api_key_env: "OLLAMA_API_KEY".to_string(),
2045 base_url_env: "OLLAMA_BASE_URL".to_string(),
2046 region: None,
2047 region_env: "AWS_REGION".to_string(),
2048 });
2049
2050 let serialized = config.to_toml().expect("serialize");
2051 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
2052 let cm = reloaded.cheap_model().expect("ollama section must resolve");
2053 assert_eq!(cm.provider, "ollama");
2054 assert_eq!(cm.model, "llama3.2:3b");
2055 }
2056
2057 /// S1.2-d: absent/disabled cheap model → resolver returns None;
2058 /// consumers that call `.cheap_model()` degrade gracefully (no panic).
2059 #[test]
2060 fn s1_2_d_absent_disabled_returns_none() {
2061 // (i) Neither section present/enabled.
2062 let config = ProjectConfig::default_for_project("demo");
2063 assert!(
2064 config.cheap_model().is_none(),
2065 "no cheap model configured: must return None"
2066 );
2067
2068 // (ii) [cheap_model] present but disabled.
2069 let mut config2 = ProjectConfig::default_for_project("demo");
2070 config2.cheap_model = Some(super::CheapModelSection {
2071 enabled: false,
2072 ..super::CheapModelSection::default()
2073 });
2074 assert!(
2075 config2.cheap_model().is_none(),
2076 "disabled cheap_model must return None"
2077 );
2078
2079 // (iii) [learning.distiller] present but disabled → back-compat returns None.
2080 let mut config3 = ProjectConfig::default_for_project("demo");
2081 config3.learning.distiller.enabled = false;
2082 assert!(
2083 config3.cheap_model().is_none(),
2084 "disabled learning.distiller must return None via back-compat"
2085 );
2086 }
2087
2088 /// S1.2: a pre-S1.2 project.toml (no `[cheap_model]` section) must load
2089 /// cleanly with `cheap_model = None`.
2090 #[test]
2091 fn pre_s1_2_config_without_cheap_model_loads_cleanly() {
2092 let toml = r#"
2093[kimetsu]
2094project_id = "demo"
2095schema_version = 7
2096
2097[model]
2098provider = "anthropic"
2099model = "claude-opus-4-7"
2100api_key_env = "ANTHROPIC_API_KEY"
2101max_output_tokens = 8192
2102temperature = 0.2
2103request_timeout_secs = 120
2104
2105[broker]
2106default_budget_tokens = 6000
2107
2108[broker.weights]
2109relevance = 0.5
2110confidence = 0.2
2111freshness = 0.2
2112scope = 0.1
2113
2114[shell]
2115default_timeout_secs = 60
2116max_timeout_secs = 600
2117env_allowlist_extra = []
2118redact_secrets = true
2119
2120[ingestion]
2121max_file_bytes = 524288
2122extra_skip_dirs = []
2123max_total_files = 50000
2124
2125[run]
2126max_total_tool_calls = 60
2127max_total_model_turns = 30
2128max_total_cost_usd = 250.0
2129"#;
2130 let config = ProjectConfig::from_toml(toml).expect("pre-S1.2 toml must load");
2131 assert!(
2132 config.cheap_model.is_none(),
2133 "cheap_model field must be None when absent from project.toml"
2134 );
2135 // And the resolver must return None (no distiller enabled either).
2136 assert!(
2137 config.cheap_model().is_none(),
2138 "cheap_model() must return None when no cheap model is configured"
2139 );
2140 }
2141
2142 // ── S5.1: StorageSection tests ────────────────────────────────────────
2143
2144 /// S5.1-a: `storage.backend` survives a round-trip through
2145 /// serialize → deserialize for each known variant string.
2146 #[test]
2147 fn s5_1_storage_backend_round_trips() {
2148 for variant in &["flat", "graph-lite", "graph"] {
2149 let mut config = ProjectConfig::default_for_project("demo");
2150 config.storage.backend = (*variant).to_string();
2151 let serialized = config.to_toml().expect("serialize");
2152 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
2153 assert_eq!(
2154 reloaded.storage.backend, *variant,
2155 "storage.backend=\"{}\" must round-trip",
2156 variant
2157 );
2158 }
2159 }
2160
2161 /// v2.6: `default_for_project` uses `backend = "graph-lite"`.
2162 ///
2163 /// Guards the thing that made the old default wrong: the published BEAM
2164 /// 100K figure was measured on graph-lite (73.3%) while `flat` — what
2165 /// users actually got — scored 62.3% on the same set.
2166 #[test]
2167 fn default_for_project_uses_the_benchmarked_backend() {
2168 let config = ProjectConfig::default_for_project("demo");
2169 assert_eq!(
2170 config.storage.backend, "graph-lite",
2171 "default project config must use the backend the benchmarks measure"
2172 );
2173 }
2174
2175 // ── F3 Pass B: answer_grade_min_score + proactive_prefetch tests ─────────
2176
2177 /// F3-B-1: new fields survive a round-trip through serialize → deserialize
2178 /// with non-default values.
2179 #[test]
2180 fn f3b_new_broker_fields_round_trip() {
2181 let mut config = ProjectConfig::default_for_project("demo");
2182 config.broker.answer_grade_min_score = 0.85;
2183 config.broker.proactive_prefetch = true;
2184 let serialized = config.to_toml().expect("serialize");
2185 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
2186 assert!(
2187 (reloaded.broker.answer_grade_min_score - 0.85).abs() < f32::EPSILON,
2188 "answer_grade_min_score must round-trip"
2189 );
2190 assert!(
2191 reloaded.broker.proactive_prefetch,
2192 "proactive_prefetch must round-trip as true"
2193 );
2194 }
2195
2196 /// F3-B-2: proactive_prefetch = false (default) survives round-trip.
2197 #[test]
2198 fn f3b_proactive_prefetch_default_false_round_trips() {
2199 let config = ProjectConfig::default_for_project("demo");
2200 assert!(!config.broker.proactive_prefetch, "default must be false");
2201 let serialized = config.to_toml().expect("serialize");
2202 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
2203 assert!(
2204 !reloaded.broker.proactive_prefetch,
2205 "default false must survive round-trip"
2206 );
2207 }
2208
2209 /// F3-B-3: default_for_project uses conservative defaults for both fields.
2210 #[test]
2211 fn f3b_default_for_project_uses_conservative_defaults() {
2212 let config = ProjectConfig::default_for_project("demo");
2213 // answer_grade_min_score: 0.92 (rare, only very high-confidence capsules)
2214 assert!(
2215 (config.broker.answer_grade_min_score - 0.92).abs() < f32::EPSILON,
2216 "default answer_grade_min_score must be 0.92"
2217 );
2218 // proactive_prefetch: false (opt-in — never changes default behaviour)
2219 assert!(
2220 !config.broker.proactive_prefetch,
2221 "default proactive_prefetch must be false"
2222 );
2223 }
2224
2225 // ── S3: SyncSection tests ──────────────────────────────────────────────
2226
2227 /// S3-cfg-1: a pre-S3 project.toml (no `[sync]` section) loads cleanly
2228 /// and defaults to no sync dir and empty machine_id.
2229 #[test]
2230 fn s3_pre_s3_config_without_sync_loads_cleanly() {
2231 let toml = r#"
2232[kimetsu]
2233project_id = "demo"
2234schema_version = 7
2235
2236[model]
2237provider = "anthropic"
2238model = "claude-opus-4-7"
2239api_key_env = "ANTHROPIC_API_KEY"
2240max_output_tokens = 8192
2241temperature = 0.2
2242request_timeout_secs = 120
2243
2244[broker]
2245default_budget_tokens = 6000
2246
2247[broker.weights]
2248relevance = 0.5
2249confidence = 0.2
2250freshness = 0.2
2251scope = 0.1
2252
2253[shell]
2254default_timeout_secs = 60
2255max_timeout_secs = 600
2256env_allowlist_extra = []
2257redact_secrets = true
2258
2259[ingestion]
2260max_file_bytes = 524288
2261extra_skip_dirs = []
2262max_total_files = 50000
2263
2264[run]
2265max_total_tool_calls = 60
2266max_total_model_turns = 30
2267max_total_cost_usd = 250.0
2268"#;
2269 let config = ProjectConfig::from_toml(toml).expect("pre-S3 toml must load");
2270 assert!(
2271 config.sync.dir.is_none(),
2272 "sync.dir must default to None when absent"
2273 );
2274 assert!(
2275 config.sync.machine_id.is_empty(),
2276 "sync.machine_id must default to empty string when absent"
2277 );
2278 }
2279
2280 /// S3-cfg-2: a `[sync]` section with dir + machine_id round-trips cleanly.
2281 #[test]
2282 fn s3_sync_section_round_trips() {
2283 let mut config = ProjectConfig::default_for_project("demo");
2284 config.sync.dir = Some("/tmp/kimetsu-sync".to_string());
2285 config.sync.machine_id = "my-laptop-01".to_string();
2286 let serialized = config.to_toml().expect("serialize");
2287 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
2288 assert_eq!(
2289 reloaded.sync.dir,
2290 Some("/tmp/kimetsu-sync".to_string()),
2291 "sync.dir must round-trip"
2292 );
2293 assert_eq!(
2294 reloaded.sync.machine_id, "my-laptop-01",
2295 "sync.machine_id must round-trip"
2296 );
2297 }
2298
2299 /// S3-cfg-3: default_for_project gives an unconfigured sync section.
2300 #[test]
2301 fn s3_default_for_project_sync_unconfigured() {
2302 let config = ProjectConfig::default_for_project("demo");
2303 assert!(config.sync.dir.is_none(), "default sync.dir must be None");
2304 assert!(
2305 config.sync.machine_id.is_empty(),
2306 "default sync.machine_id must be empty"
2307 );
2308 }
2309}