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