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