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