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