Skip to main content

truth_mirror/
config.rs

1//! truth-mirror configuration: adversarial pairs, reasoning effort, gates,
2//! ground-truth, trajectory, and enforcement.
3
4use std::{
5    collections::BTreeMap,
6    fs, io,
7    path::{Path, PathBuf},
8};
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13pub const DEFAULT_STATE_DIR: &str = ".truth";
14pub const LEGACY_STATE_DIR: &str = ".truth-mirror";
15
16/// Reasoning effort (`C`). Highest is `Xhigh`.
17#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, clap::ValueEnum)]
18#[serde(rename_all = "lowercase")]
19#[value(rename_all = "lowercase")]
20pub enum Effort {
21    Minimal,
22    Low,
23    Medium,
24    High,
25    #[default]
26    Xhigh,
27}
28
29impl Effort {
30    pub fn as_str(self) -> &'static str {
31        match self {
32            Effort::Minimal => "minimal",
33            Effort::Low => "low",
34            Effort::Medium => "medium",
35            Effort::High => "high",
36            Effort::Xhigh => "xhigh",
37        }
38    }
39
40    /// The highest supported effort — the default reviewer aggressiveness.
41    pub fn highest() -> Self {
42        Effort::Xhigh
43    }
44
45    /// Value accepted by Claude's `--effort`. Verified: claude takes
46    /// `low|medium|high|xhigh|max` and has no `minimal`, so `Minimal` clamps to
47    /// `low`. (Codex and Pi both accept `minimal`.)
48    pub fn claude_value(self) -> &'static str {
49        match self {
50            Effort::Minimal => "low",
51            other => other.as_str(),
52        }
53    }
54}
55
56/// A concrete reviewer/arbiter selection: harness + model + reasoning effort.
57#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
58pub struct HarnessSelection {
59    pub harness: String,
60    pub model: String,
61    #[serde(default)]
62    pub effort: Effort,
63}
64
65impl HarnessSelection {
66    pub fn new(harness: impl Into<String>, model: impl Into<String>, effort: Effort) -> Self {
67        Self {
68            harness: harness.into(),
69            model: model.into(),
70            effort,
71        }
72    }
73}
74
75/// The opposed reviewer (and optional second-pass arbiter) for one writer harness.
76#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
77pub struct AdversarialPair {
78    pub reviewer: HarnessSelection,
79    #[serde(default)]
80    pub arbiter: Option<HarnessSelection>,
81}
82
83#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
84pub struct TruthMirrorConfig {
85    #[serde(default = "default_ledger_dir")]
86    pub ledger_dir: String,
87    #[serde(default)]
88    pub allow_same_model: bool,
89    /// Writer harness assumed when none is provided on the CLI or commit trailer.
90    #[serde(default = "default_writer")]
91    pub default_writer: String,
92    /// Adversarial pairs keyed by writer harness (lowercase). Empty in the parsed
93    /// form when `[pairs]` is absent; `normalize` fills defaults / folds legacy.
94    #[serde(default)]
95    pub pairs: BTreeMap<String, AdversarialPair>,
96    #[serde(default)]
97    pub strict: StrictConfig,
98    #[serde(default)]
99    pub gates: GatesConfig,
100    #[serde(default)]
101    pub ground_truth: GroundTruthConfig,
102    #[serde(default)]
103    pub history: HistoryConfig,
104    #[serde(default)]
105    pub enforcement: EnforcementConfig,
106    /// Legacy `[review]` block, folded into `pairs` on load for back-compat.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub review: Option<LegacyReview>,
109}
110
111impl Default for TruthMirrorConfig {
112    fn default() -> Self {
113        Self {
114            ledger_dir: default_ledger_dir(),
115            allow_same_model: false,
116            default_writer: default_writer(),
117            pairs: default_pairs(),
118            strict: StrictConfig::default(),
119            gates: GatesConfig::default(),
120            ground_truth: GroundTruthConfig::default(),
121            history: HistoryConfig::default(),
122            enforcement: EnforcementConfig::default(),
123            review: None,
124        }
125    }
126}
127
128impl TruthMirrorConfig {
129    pub fn load_for_cli(
130        explicit_path: Option<&Path>,
131        state_dir: &Path,
132    ) -> Result<Self, ConfigError> {
133        let path = explicit_path.map_or_else(|| Self::default_path(state_dir), PathBuf::from);
134        Self::load_or_default(path)
135    }
136
137    pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
138        let path = path.into();
139        match fs::read_to_string(&path) {
140            Ok(contents) => Self::from_toml_str(&path, &contents),
141            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
142            Err(source) => Err(ConfigError::Read { path, source }),
143        }
144    }
145
146    pub fn from_toml_str(path: &Path, contents: &str) -> Result<Self, ConfigError> {
147        let mut config: Self = toml::from_str(contents).map_err(|source| ConfigError::Parse {
148            path: path.to_path_buf(),
149            source,
150        })?;
151        config.normalize();
152        config.validate()?;
153        Ok(config)
154    }
155
156    /// Resolve the effective `pairs`: explicit `[pairs]` win; a legacy `[review]`
157    /// block folds in as an OVERRIDE for its writer (on top of defaults so other
158    /// writers still resolve); with neither, all writers get default pairs.
159    fn normalize(&mut self) {
160        // Lowercase explicit pair keys FIRST so every comparison below is
161        // case-insensitive (matching `pair_for`). Otherwise a `[pairs.CODEX]` could
162        // be clobbered by a legacy `[review]` folded under `codex`.
163        let lowered: BTreeMap<String, AdversarialPair> = std::mem::take(&mut self.pairs)
164            .into_iter()
165            .map(|(key, value)| (key.trim().to_ascii_lowercase(), value))
166            .collect();
167        self.pairs = lowered;
168
169        let had_explicit_pairs = !self.pairs.is_empty();
170        let review = self.review.take();
171
172        if !had_explicit_pairs && review.is_some() {
173            // Legacy-only config: seed defaults so non-legacy writers still resolve.
174            self.pairs = default_pairs();
175        }
176
177        if let Some(review) = review {
178            let writer = review.watched.harness.trim().to_ascii_lowercase();
179            let pair = AdversarialPair {
180                reviewer: HarnessSelection::new(
181                    review.reviewer.harness,
182                    review.reviewer.model,
183                    Effort::highest(),
184                ),
185                arbiter: None,
186            };
187            if had_explicit_pairs {
188                // Explicit `[pairs]` win: legacy only fills a writer they omit.
189                self.pairs.entry(writer).or_insert(pair);
190            } else {
191                // Legacy-only config: override the seeded default for this writer.
192                self.pairs.insert(writer, pair);
193            }
194        }
195
196        if self.pairs.is_empty() {
197            self.pairs = default_pairs();
198        }
199    }
200
201    fn validate(&self) -> Result<(), ConfigError> {
202        for (writer, pair) in &self.pairs {
203            if let Some(arbiter) = &pair.arbiter
204                && normalized(&arbiter.model) == normalized(&pair.reviewer.model)
205            {
206                return Err(ConfigError::ArbiterNotDistinct {
207                    writer: writer.clone(),
208                });
209            }
210        }
211        Ok(())
212    }
213
214    /// The adversarial pair for a writer harness (case-insensitive).
215    pub fn pair_for(&self, writer_harness: &str) -> Option<&AdversarialPair> {
216        self.pairs.get(&writer_harness.trim().to_ascii_lowercase())
217    }
218
219    pub fn default_path(state_dir: &Path) -> PathBuf {
220        state_dir.join("config.toml")
221    }
222}
223
224/// Legacy single watched/reviewer pair (pre-adversarial-pairs config).
225#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
226pub struct LegacyReview {
227    pub watched: LegacyModel,
228    pub reviewer: LegacyModel,
229}
230
231#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
232pub struct LegacyModel {
233    pub harness: String,
234    pub model: String,
235}
236
237/// Strict goal-loop thresholds. `N == 0` disables that stop condition.
238#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
239#[serde(default)]
240pub struct StrictConfig {
241    pub stop_after_lies: u32,
242    pub stop_after_fuckups: u32,
243    pub max_passes: u32,
244}
245
246impl Default for StrictConfig {
247    fn default() -> Self {
248        Self {
249            stop_after_lies: 1,
250            stop_after_fuckups: 3,
251            max_passes: 3,
252        }
253    }
254}
255
256impl StrictConfig {
257    pub fn goal_policy(
258        &self,
259        lies_override: Option<u32>,
260        fuckups_override: Option<u32>,
261    ) -> crate::reviewer::StrictGoalPolicy {
262        crate::reviewer::StrictGoalPolicy {
263            stop_after_lies: lies_override.unwrap_or(self.stop_after_lies),
264            stop_after_fuckups: fuckups_override.unwrap_or(self.stop_after_fuckups),
265        }
266    }
267}
268
269/// Deterministic-gate configuration: banned diff sentinels, evidence patterns,
270/// and diff paths excluded from marker scanning.
271#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
272#[serde(default)]
273pub struct GatesConfig {
274    pub fake_markers: Vec<String>,
275    pub evidence_patterns: Vec<String>,
276    pub marker_ignore_paths: Vec<String>,
277}
278
279impl Default for GatesConfig {
280    fn default() -> Self {
281        Self {
282            fake_markers: strs(crate::claim::DEFAULT_FAKE_MARKERS),
283            evidence_patterns: strs(crate::claim::DEFAULT_EVIDENCE_PATTERNS),
284            marker_ignore_paths: strs(crate::claim::DEFAULT_MARKER_IGNORE_PATHS),
285        }
286    }
287}
288
289impl GatesConfig {
290    /// The resolved gate policy: config values plus built-in defaults for any
291    /// list left empty, so an empty config never silently disables a gate.
292    pub fn to_policy(&self) -> crate::claim::GatePolicy {
293        crate::claim::GatePolicy {
294            fake_markers: union_defaults(&self.fake_markers, crate::claim::DEFAULT_FAKE_MARKERS),
295            evidence_patterns: union_defaults(
296                &self.evidence_patterns,
297                crate::claim::DEFAULT_EVIDENCE_PATTERNS,
298            ),
299            marker_ignore_paths: union_defaults(
300                &self.marker_ignore_paths,
301                crate::claim::DEFAULT_MARKER_IGNORE_PATHS,
302            ),
303        }
304    }
305}
306
307fn strs(values: &[&str]) -> Vec<String> {
308    values.iter().map(|value| (*value).to_owned()).collect()
309}
310
311/// Built-in defaults ALWAYS apply; configured values are additive. A repo can add
312/// its own banned tokens or evidence patterns but cannot silently disable the
313/// deterministic anti-lie defaults by setting a shorter list.
314fn union_defaults(values: &[String], defaults: &[&str]) -> Vec<String> {
315    let mut out: Vec<String> = strs(defaults);
316    for value in values {
317        if !out.iter().any(|existing| existing == value) {
318            out.push(value.clone());
319        }
320    }
321    out
322}
323
324/// Ground-truth constraint loading.
325#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
326#[serde(default)]
327pub struct GroundTruthConfig {
328    pub enabled: bool,
329    pub file_names: Vec<String>,
330    pub include_openspec_specs: bool,
331    pub max_bytes: usize,
332}
333
334impl Default for GroundTruthConfig {
335    fn default() -> Self {
336        Self {
337            enabled: true,
338            file_names: ["TRUTH.md", "AGENTS.md", "CLAUDE.md"]
339                .iter()
340                .map(|name| (*name).to_owned())
341                .collect(),
342            include_openspec_specs: true,
343            max_bytes: 20_000,
344        }
345    }
346}
347
348/// Conversation-trajectory window.
349#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
350#[serde(default)]
351pub struct HistoryConfig {
352    pub window_user: usize,
353    pub window_agent: usize,
354    pub max_bytes: usize,
355    /// Optional repo-relative JSONL transcript (`{role,text}` per line). When
356    /// unset or missing, recent commits are used as the trajectory proxy.
357    pub transcript_path: Option<String>,
358}
359
360impl Default for HistoryConfig {
361    fn default() -> Self {
362        Self {
363            window_user: 3,
364            window_agent: 10,
365            max_bytes: 12_000,
366            transcript_path: None,
367        }
368    }
369}
370
371/// Enforcement escalation thresholds. `0` disables a condition.
372#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
373#[serde(default)]
374pub struct EnforcementConfig {
375    pub block_tools_after_unresolved: u32,
376    pub block_tools_after_secs: u64,
377}
378
379impl EnforcementConfig {
380    pub fn is_enabled(&self) -> bool {
381        self.block_tools_after_unresolved > 0 || self.block_tools_after_secs > 0
382    }
383}
384
385#[derive(Debug, Error)]
386pub enum ConfigError {
387    #[error("failed to read config {path}: {source}")]
388    Read {
389        path: PathBuf,
390        #[source]
391        source: io::Error,
392    },
393    #[error("failed to parse config {path}: {source}")]
394    Parse {
395        path: PathBuf,
396        #[source]
397        source: toml::de::Error,
398    },
399    #[error("pair for writer {writer:?} has an arbiter model equal to the reviewer model")]
400    ArbiterNotDistinct { writer: String },
401}
402
403fn default_ledger_dir() -> String {
404    DEFAULT_STATE_DIR.to_owned()
405}
406
407fn default_writer() -> String {
408    "codex".to_owned()
409}
410
411/// Sensible opposed pairs so a repo with no `[pairs]` still reviews adversarially.
412fn default_pairs() -> BTreeMap<String, AdversarialPair> {
413    let mut pairs = BTreeMap::new();
414    pairs.insert(
415        "codex".to_owned(),
416        AdversarialPair {
417            reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
418            arbiter: Some(HarnessSelection::new(
419                "pi",
420                "openai-codex/gpt-5.5",
421                Effort::highest(),
422            )),
423        },
424    );
425    pairs.insert(
426        "claude".to_owned(),
427        AdversarialPair {
428            reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
429            arbiter: Some(HarnessSelection::new(
430                "pi",
431                "openai-codex/gpt-5.5",
432                Effort::highest(),
433            )),
434        },
435    );
436    pairs.insert(
437        "pi".to_owned(),
438        AdversarialPair {
439            reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
440            arbiter: Some(HarnessSelection::new(
441                "claude",
442                "claude-opus-4-8",
443                Effort::highest(),
444            )),
445        },
446    );
447    pairs
448}
449
450fn normalized(model: &str) -> String {
451    model.trim().to_ascii_lowercase()
452}
453
454#[cfg(test)]
455mod tests {
456    use std::path::Path;
457
458    use super::{Effort, TruthMirrorConfig};
459
460    #[test]
461    fn default_config_has_three_opposed_pairs() {
462        let config = TruthMirrorConfig::default();
463
464        assert_eq!(config.pairs.len(), 3);
465        assert_eq!(config.ledger_dir, ".truth");
466        let codex = config.pair_for("codex").unwrap();
467        assert_eq!(codex.reviewer.harness, "claude");
468        assert_eq!(codex.reviewer.model, "claude-opus-4-8");
469        assert_eq!(codex.reviewer.effort, Effort::Xhigh);
470    }
471
472    #[test]
473    fn effort_serializes_lowercase() {
474        assert_eq!(Effort::Xhigh.as_str(), "xhigh");
475        assert_eq!(Effort::highest(), Effort::Xhigh);
476    }
477
478    #[test]
479    fn pairs_config_parses_and_resolves_by_writer() {
480        let contents = r#"
481default_writer = "claude"
482
483[pairs.claude]
484reviewer = { harness = "codex", model = "gpt-5.5", effort = "xhigh" }
485arbiter  = { harness = "pi", model = "openai-codex/gpt-5.5", effort = "high" }
486
487[pairs.codex]
488reviewer = { harness = "claude", model = "claude-opus-4-8" }
489"#;
490        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
491
492        let claude_pair = config.pair_for("claude").unwrap();
493        assert_eq!(claude_pair.reviewer.harness, "codex");
494        assert_eq!(claude_pair.reviewer.effort, Effort::Xhigh);
495        assert_eq!(claude_pair.arbiter.as_ref().unwrap().effort, Effort::High);
496
497        // Omitted effort defaults to highest.
498        let codex_pair = config.pair_for("codex").unwrap();
499        assert_eq!(codex_pair.reviewer.effort, Effort::Xhigh);
500    }
501
502    #[test]
503    fn legacy_review_block_overrides_default_pair() {
504        // Reviewer distinct from the built-in codex default (claude/opus-4-8) so
505        // the test proves the legacy block actually overrides the default rather
506        // than coincidentally matching it.
507        let contents = r#"
508ledger_dir = ".truth-mirror"
509
510[review.watched]
511harness = "codex"
512model = "gpt-5.5"
513
514[review.reviewer]
515harness = "gemini"
516model = "gemini-3-pro"
517"#;
518        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
519
520        let pair = config.pair_for("codex").unwrap();
521        assert_eq!(pair.reviewer.harness, "gemini");
522        assert_eq!(pair.reviewer.model, "gemini-3-pro");
523        // Other writers still resolve from defaults.
524        assert!(config.pair_for("claude").is_some());
525        assert!(config.pair_for("pi").is_some());
526    }
527
528    #[test]
529    fn explicit_pairs_win_over_legacy_review() {
530        // A mixed migration config must not let legacy [review] clobber a modern
531        // explicit [pairs.<writer>] entry.
532        let contents = r#"
533[pairs.codex]
534reviewer = { harness = "claude", model = "explicit-model" }
535
536[review.watched]
537harness = "codex"
538model = "gpt-5.5"
539
540[review.reviewer]
541harness = "gemini"
542model = "legacy-model"
543"#;
544        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
545
546        let pair = config.pair_for("codex").unwrap();
547        assert_eq!(pair.reviewer.harness, "claude");
548        assert_eq!(pair.reviewer.model, "explicit-model");
549    }
550
551    #[test]
552    fn explicit_pairs_win_case_insensitively_over_legacy() {
553        // `[pairs.CODEX]` (uppercase) must not be clobbered by a legacy [review]
554        // folded under lowercase `codex`.
555        let contents = r#"
556[pairs.CODEX]
557reviewer = { harness = "claude", model = "explicit-model" }
558
559[review.watched]
560harness = "codex"
561model = "gpt-5.5"
562
563[review.reviewer]
564harness = "gemini"
565model = "legacy-model"
566"#;
567        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
568
569        let pair = config.pair_for("codex").unwrap();
570        assert_eq!(pair.reviewer.model, "explicit-model");
571        // No duplicate CODEX/codex entries.
572        assert_eq!(config.pairs.len(), 1);
573    }
574
575    #[test]
576    fn arbiter_equal_to_reviewer_model_is_rejected() {
577        let contents = r#"
578[pairs.codex]
579reviewer = { harness = "claude", model = "same-model" }
580arbiter  = { harness = "pi", model = "same-model" }
581"#;
582        let error =
583            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
584
585        assert!(matches!(
586            error,
587            super::ConfigError::ArbiterNotDistinct { .. }
588        ));
589    }
590
591    #[test]
592    fn missing_config_loads_default() {
593        let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
594
595        assert_eq!(config.pairs.len(), 3);
596        assert!(config.ground_truth.enabled);
597        assert_eq!(config.history.window_user, 3);
598        assert!(!config.enforcement.is_enabled());
599    }
600
601    #[test]
602    fn gates_config_parses_and_builds_policy() {
603        let contents = r#"
604[pairs.codex]
605reviewer = { harness = "claude", model = "claude-opus-4-8" }
606
607[gates]
608fake_markers = ["pretend-pass"]
609evidence_patterns = ["jira:"]
610marker_ignore_paths = [".md", "vendor/"]
611"#;
612        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
613
614        // Config values are ADDITIVE: built-in defaults always apply, plus the
615        // repo's custom entries. A repo can add but not silently disable defaults.
616        // The default marker literal is built at runtime so it never appears as an
617        // added line in this file (truth-mirror's own gate would flag it).
618        let default_marker = ["mock", "as", "real"].join("-");
619        let policy = config.gates.to_policy();
620        assert!(policy.fake_markers.iter().any(|m| m == "pretend-pass"));
621        assert!(policy.fake_markers.contains(&default_marker));
622        assert!(policy.evidence_patterns.iter().any(|p| p == "jira:"));
623        assert!(policy.evidence_patterns.iter().any(|p| p == "tests:"));
624        assert!(policy.marker_ignore_paths.iter().any(|p| p == "vendor/"));
625        assert!(policy.marker_ignore_paths.iter().any(|p| p == "openspec/"));
626    }
627
628    #[test]
629    fn empty_gate_lists_fall_back_to_defaults() {
630        // An explicitly empty list must not silently disable a gate.
631        let policy = super::GatesConfig {
632            fake_markers: Vec::new(),
633            evidence_patterns: Vec::new(),
634            marker_ignore_paths: Vec::new(),
635        }
636        .to_policy();
637
638        assert!(!policy.fake_markers.is_empty());
639        assert!(!policy.evidence_patterns.is_empty());
640        assert!(!policy.marker_ignore_paths.is_empty());
641    }
642
643    #[test]
644    fn pair_keys_are_lowercased() {
645        let contents = r#"
646[pairs.CODEX]
647reviewer = { harness = "claude", model = "claude-opus-4-8" }
648"#;
649        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
650
651        assert!(config.pair_for("codex").is_some());
652        assert!(config.pair_for("CoDeX").is_some());
653    }
654}