Skip to main content

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}