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