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";
15pub const DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS: &[&str] = &[
16    "BEGIN SYSTEM PROMPT",
17    "ignore previous instructions",
18    "api_key",
19    "secret_key",
20    "secret:",
21    "secret=",
22    "token:",
23    "token=",
24    "password:",
25    "password=",
26    "credential:",
27    "credential=",
28    "private_key",
29    "aws_secret_access_key",
30];
31
32/// Reasoning effort (`C`). Highest is `Xhigh`.
33#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, clap::ValueEnum)]
34#[serde(rename_all = "lowercase")]
35#[value(rename_all = "lowercase")]
36pub enum Effort {
37    Minimal,
38    Low,
39    Medium,
40    High,
41    #[default]
42    Xhigh,
43}
44
45impl Effort {
46    pub fn as_str(self) -> &'static str {
47        match self {
48            Effort::Minimal => "minimal",
49            Effort::Low => "low",
50            Effort::Medium => "medium",
51            Effort::High => "high",
52            Effort::Xhigh => "xhigh",
53        }
54    }
55
56    /// The highest supported effort — the default reviewer aggressiveness.
57    pub fn highest() -> Self {
58        Effort::Xhigh
59    }
60
61    /// Value accepted by Claude's `--effort`. Verified: claude takes
62    /// `low|medium|high|xhigh|max` and has no `minimal`, so `Minimal` clamps to
63    /// `low`. (Codex and Pi both accept `minimal`.)
64    pub fn claude_value(self) -> &'static str {
65        match self {
66            Effort::Minimal => "low",
67            other => other.as_str(),
68        }
69    }
70}
71
72/// A concrete reviewer/arbiter selection: harness + model + reasoning effort.
73#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
74pub struct HarnessSelection {
75    pub harness: String,
76    pub model: String,
77    #[serde(default)]
78    pub effort: Effort,
79}
80
81impl HarnessSelection {
82    pub fn new(harness: impl Into<String>, model: impl Into<String>, effort: Effort) -> Self {
83        Self {
84            harness: harness.into(),
85            model: model.into(),
86            effort,
87        }
88    }
89}
90
91/// The opposed reviewer (and optional second-pass arbiter) for one writer harness.
92#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
93pub struct AdversarialPair {
94    pub reviewer: HarnessSelection,
95    #[serde(default)]
96    pub arbiter: Option<HarnessSelection>,
97}
98
99#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
100pub struct TruthMirrorConfig {
101    #[serde(default = "default_ledger_dir")]
102    pub ledger_dir: String,
103    #[serde(default)]
104    pub allow_same_model: bool,
105    /// Writer harness assumed when none is provided on the CLI or commit trailer.
106    #[serde(default = "default_writer")]
107    pub default_writer: String,
108    /// Adversarial pairs keyed by writer harness (lowercase). Empty in the parsed
109    /// form when `[pairs]` is absent; `normalize` fills defaults / folds legacy.
110    #[serde(default)]
111    pub pairs: BTreeMap<String, AdversarialPair>,
112    #[serde(default)]
113    pub strict: StrictConfig,
114    #[serde(default)]
115    pub gates: GatesConfig,
116    #[serde(default)]
117    pub ground_truth: GroundTruthConfig,
118    #[serde(default)]
119    pub history: HistoryConfig,
120    #[serde(default)]
121    pub enforcement: EnforcementConfig,
122    #[serde(default)]
123    pub skills: SkillsConfig,
124    #[serde(default)]
125    pub memory_skill: MemorySkillConfig,
126    /// Legacy `[review]` block, folded into `pairs` on load for back-compat.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub review: Option<LegacyReview>,
129}
130
131impl Default for TruthMirrorConfig {
132    fn default() -> Self {
133        Self {
134            ledger_dir: default_ledger_dir(),
135            allow_same_model: false,
136            default_writer: default_writer(),
137            pairs: default_pairs(),
138            strict: StrictConfig::default(),
139            gates: GatesConfig::default(),
140            ground_truth: GroundTruthConfig::default(),
141            history: HistoryConfig::default(),
142            enforcement: EnforcementConfig::default(),
143            skills: SkillsConfig::default(),
144            memory_skill: MemorySkillConfig::default(),
145            review: None,
146        }
147    }
148}
149
150impl TruthMirrorConfig {
151    pub fn load_for_cli(
152        explicit_path: Option<&Path>,
153        state_dir: &Path,
154    ) -> Result<Self, ConfigError> {
155        let path = explicit_path.map_or_else(|| Self::default_path(state_dir), PathBuf::from);
156        Self::load_or_default(path)
157    }
158
159    pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
160        let path = path.into();
161        match fs::read_to_string(&path) {
162            Ok(contents) => Self::from_toml_str(&path, &contents),
163            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
164            Err(source) => Err(ConfigError::Read { path, source }),
165        }
166    }
167
168    pub fn from_toml_str(path: &Path, contents: &str) -> Result<Self, ConfigError> {
169        let mut config: Self = toml::from_str(contents).map_err(|source| ConfigError::Parse {
170            path: path.to_path_buf(),
171            source,
172        })?;
173        config.normalize();
174        config.validate()?;
175        Ok(config)
176    }
177
178    /// Resolve the effective `pairs`: explicit `[pairs]` win; a legacy `[review]`
179    /// block folds in as an OVERRIDE for its writer (on top of defaults so other
180    /// writers still resolve); with neither, all writers get default pairs.
181    fn normalize(&mut self) {
182        // Lowercase explicit pair keys FIRST so every comparison below is
183        // case-insensitive (matching `pair_for`). Otherwise a `[pairs.CODEX]` could
184        // be clobbered by a legacy `[review]` folded under `codex`.
185        let lowered: BTreeMap<String, AdversarialPair> = std::mem::take(&mut self.pairs)
186            .into_iter()
187            .map(|(key, value)| (key.trim().to_ascii_lowercase(), value))
188            .collect();
189        self.pairs = lowered;
190
191        let had_explicit_pairs = !self.pairs.is_empty();
192        let review = self.review.take();
193
194        if !had_explicit_pairs && review.is_some() {
195            // Legacy-only config: seed defaults so non-legacy writers still resolve.
196            self.pairs = default_pairs();
197        }
198
199        if let Some(review) = review {
200            let writer = review.watched.harness.trim().to_ascii_lowercase();
201            let pair = AdversarialPair {
202                reviewer: HarnessSelection::new(
203                    review.reviewer.harness,
204                    review.reviewer.model,
205                    Effort::highest(),
206                ),
207                arbiter: None,
208            };
209            if had_explicit_pairs {
210                // Explicit `[pairs]` win: legacy only fills a writer they omit.
211                self.pairs.entry(writer).or_insert(pair);
212            } else {
213                // Legacy-only config: override the seeded default for this writer.
214                self.pairs.insert(writer, pair);
215            }
216        }
217
218        if self.pairs.is_empty() {
219            self.pairs = default_pairs();
220        }
221    }
222
223    fn validate(&self) -> Result<(), ConfigError> {
224        for (writer, pair) in &self.pairs {
225            if let Some(arbiter) = &pair.arbiter
226                && normalized_model(&arbiter.model) == normalized_model(&pair.reviewer.model)
227            {
228                return Err(ConfigError::ArbiterNotDistinct {
229                    writer: writer.clone(),
230                });
231            }
232        }
233        if !self.memory_skill.scan.entropy_threshold.is_finite()
234            || self.memory_skill.scan.entropy_threshold <= 0.0
235        {
236            return Err(ConfigError::InvalidMemorySkillEntropyThreshold {
237                value: self.memory_skill.scan.entropy_threshold,
238            });
239        }
240        if self.memory_skill.max_skill_bytes == 0 {
241            return Err(ConfigError::InvalidMemorySkillMaxSkillBytes);
242        }
243        if self.memory_skill.scan.secret_detector_timeout_seconds == 0 {
244            return Err(ConfigError::InvalidMemorySkillSecretDetectorTimeout);
245        }
246        Ok(())
247    }
248
249    /// The adversarial pair for a writer harness (case-insensitive).
250    pub fn pair_for(&self, writer_harness: &str) -> Option<&AdversarialPair> {
251        self.pairs.get(&writer_harness.trim().to_ascii_lowercase())
252    }
253
254    pub fn default_path(state_dir: &Path) -> PathBuf {
255        state_dir.join("config.toml")
256    }
257}
258
259/// Legacy single watched/reviewer pair (pre-adversarial-pairs config).
260#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
261pub struct LegacyReview {
262    pub watched: LegacyModel,
263    pub reviewer: LegacyModel,
264}
265
266#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
267pub struct LegacyModel {
268    pub harness: String,
269    pub model: String,
270}
271
272/// Strict goal-loop thresholds. `N == 0` disables that stop condition.
273#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
274#[serde(default)]
275pub struct StrictConfig {
276    pub stop_after_lies: u32,
277    pub stop_after_fuckups: u32,
278    pub max_passes: u32,
279}
280
281impl Default for StrictConfig {
282    fn default() -> Self {
283        Self {
284            stop_after_lies: 1,
285            stop_after_fuckups: 3,
286            max_passes: 3,
287        }
288    }
289}
290
291impl StrictConfig {
292    pub fn goal_policy(
293        &self,
294        lies_override: Option<u32>,
295        fuckups_override: Option<u32>,
296    ) -> crate::reviewer::StrictGoalPolicy {
297        crate::reviewer::StrictGoalPolicy {
298            stop_after_lies: lies_override.unwrap_or(self.stop_after_lies),
299            stop_after_fuckups: fuckups_override.unwrap_or(self.stop_after_fuckups),
300        }
301    }
302}
303
304/// Deterministic-gate configuration: banned diff sentinels, evidence patterns,
305/// and diff paths excluded from marker scanning.
306#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
307#[serde(default)]
308pub struct GatesConfig {
309    pub fake_markers: Vec<String>,
310    pub evidence_patterns: Vec<String>,
311    pub marker_ignore_paths: Vec<String>,
312}
313
314impl Default for GatesConfig {
315    fn default() -> Self {
316        Self {
317            fake_markers: strs(crate::claim::DEFAULT_FAKE_MARKERS),
318            evidence_patterns: strs(crate::claim::DEFAULT_EVIDENCE_PATTERNS),
319            marker_ignore_paths: strs(crate::claim::DEFAULT_MARKER_IGNORE_PATHS),
320        }
321    }
322}
323
324impl GatesConfig {
325    /// The resolved gate policy: config values plus built-in defaults for any
326    /// list left empty, so an empty config never silently disables a gate.
327    pub fn to_policy(&self) -> crate::claim::GatePolicy {
328        crate::claim::GatePolicy {
329            fake_markers: union_defaults(&self.fake_markers, crate::claim::DEFAULT_FAKE_MARKERS),
330            evidence_patterns: union_defaults(
331                &self.evidence_patterns,
332                crate::claim::DEFAULT_EVIDENCE_PATTERNS,
333            ),
334            marker_ignore_paths: union_defaults(
335                &self.marker_ignore_paths,
336                crate::claim::DEFAULT_MARKER_IGNORE_PATHS,
337            ),
338        }
339    }
340}
341
342fn strs(values: &[&str]) -> Vec<String> {
343    values.iter().map(|value| (*value).to_owned()).collect()
344}
345
346/// Built-in defaults ALWAYS apply; configured values are additive. A repo can add
347/// its own banned tokens or evidence patterns but cannot silently disable the
348/// deterministic anti-lie defaults by setting a shorter list.
349fn union_defaults(values: &[String], defaults: &[&str]) -> Vec<String> {
350    let mut out: Vec<String> = strs(defaults);
351    for value in values {
352        if !out.iter().any(|existing| existing == value) {
353            out.push(value.clone());
354        }
355    }
356    out
357}
358
359/// Ground-truth constraint loading.
360#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
361#[serde(default)]
362pub struct GroundTruthConfig {
363    pub enabled: bool,
364    pub file_names: Vec<String>,
365    pub include_openspec_specs: bool,
366    pub max_bytes: usize,
367}
368
369impl Default for GroundTruthConfig {
370    fn default() -> Self {
371        Self {
372            enabled: true,
373            file_names: ["TRUTH.md", "AGENTS.md", "CLAUDE.md"]
374                .iter()
375                .map(|name| (*name).to_owned())
376                .collect(),
377            include_openspec_specs: true,
378            max_bytes: 20_000,
379        }
380    }
381}
382
383/// Conversation-trajectory window.
384#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
385#[serde(default)]
386pub struct HistoryConfig {
387    pub window_user: usize,
388    pub window_agent: usize,
389    pub max_bytes: usize,
390    /// Optional repo-relative JSONL transcript (`{role,text}` per line). When
391    /// unset or missing, recent commits are used as the trajectory proxy.
392    pub transcript_path: Option<String>,
393}
394
395impl Default for HistoryConfig {
396    fn default() -> Self {
397        Self {
398            window_user: 3,
399            window_agent: 10,
400            max_bytes: 12_000,
401            transcript_path: None,
402        }
403    }
404}
405
406/// Enforcement escalation thresholds. `0` disables a condition.
407#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
408#[serde(default)]
409pub struct EnforcementConfig {
410    pub block_tools_after_unresolved: u32,
411    pub block_tools_after_secs: u64,
412}
413
414impl EnforcementConfig {
415    pub fn is_enabled(&self) -> bool {
416        self.block_tools_after_unresolved > 0 || self.block_tools_after_secs > 0
417    }
418}
419
420#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
421#[serde(default)]
422pub struct SkillsConfig {
423    pub enabled: bool,
424}
425
426#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
427#[serde(rename_all = "lowercase")]
428pub enum MemorySkillMode {
429    Off,
430    Suggest,
431    #[default]
432    Stage,
433}
434
435#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
436#[serde(rename_all = "snake_case")]
437pub enum MemorySkillCandidateKind {
438    HowToSkill,
439    AntiPatternSkill,
440    RemediationSkill,
441}
442
443impl MemorySkillCandidateKind {
444    pub fn as_str(self) -> &'static str {
445        match self {
446            Self::HowToSkill => "how_to_skill",
447            Self::AntiPatternSkill => "anti_pattern_skill",
448            Self::RemediationSkill => "remediation_skill",
449        }
450    }
451}
452
453impl std::fmt::Display for MemorySkillCandidateKind {
454    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455        formatter.write_str(self.as_str())
456    }
457}
458
459#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
460#[serde(default)]
461pub struct MemorySkillConfig {
462    pub enabled: bool,
463    pub mode: MemorySkillMode,
464    pub candidate_dir: String,
465    pub approved_dir: String,
466    pub approved_slug_prefix: String,
467    pub toolbox_delivery_registry: String,
468    pub allow_global_writes: bool,
469    pub require_claim_evidence: bool,
470    pub max_candidates_per_commit: usize,
471    pub max_skill_bytes: usize,
472    pub pre_push_blocks_pending: bool,
473    /// Historical config key: when true, `memory_skill::scan_text` rejects the
474    /// whole candidate on secret matches; it does not redact matched text in
475    /// place. Scanning is fully disabled only when this is false and
476    /// `memory_skill.scan.secret_detection = "off"`.
477    pub redact_secrets: bool,
478    pub signals: MemorySkillSignalsConfig,
479    pub review: MemorySkillReviewConfig,
480    pub scan: MemorySkillScanConfig,
481}
482
483impl Default for MemorySkillConfig {
484    fn default() -> Self {
485        Self {
486            enabled: false,
487            mode: MemorySkillMode::Stage,
488            candidate_dir: format!("{DEFAULT_STATE_DIR}/skills/candidates"),
489            approved_dir: ".agents/skills".to_owned(),
490            approved_slug_prefix: "generated-".to_owned(),
491            toolbox_delivery_registry: String::new(),
492            allow_global_writes: false,
493            require_claim_evidence: true,
494            max_candidates_per_commit: 1,
495            max_skill_bytes: 12_000,
496            pre_push_blocks_pending: false,
497            redact_secrets: true,
498            signals: MemorySkillSignalsConfig::default(),
499            review: MemorySkillReviewConfig::default(),
500            scan: MemorySkillScanConfig::default(),
501        }
502    }
503}
504
505impl MemorySkillConfig {
506    pub fn effective_enabled(&self, skills: &SkillsConfig) -> bool {
507        skills.enabled && self.enabled && self.mode != MemorySkillMode::Off
508    }
509}
510
511#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
512#[serde(default)]
513pub struct MemorySkillSignalsConfig {
514    pub min_occurrences: usize,
515    pub require_reusable_procedure: bool,
516    pub capture_passes_as_how_to: bool,
517    pub capture_rejections_as_remediation: bool,
518    pub capture_rejections_as_antipattern: bool,
519    pub rejection_precedence: Vec<MemorySkillCandidateKind>,
520}
521
522impl Default for MemorySkillSignalsConfig {
523    fn default() -> Self {
524        Self {
525            min_occurrences: 2,
526            require_reusable_procedure: true,
527            capture_passes_as_how_to: true,
528            capture_rejections_as_remediation: true,
529            capture_rejections_as_antipattern: true,
530            rejection_precedence: vec![
531                MemorySkillCandidateKind::AntiPatternSkill,
532                MemorySkillCandidateKind::RemediationSkill,
533            ],
534        }
535    }
536}
537
538#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
539#[serde(default)]
540pub struct MemorySkillReviewConfig {
541    pub require_adversarial_review: bool,
542    pub require_reviewed_ledger_entry: bool,
543    pub require_pass_for_how_to: bool,
544    pub require_structured_findings_checked: bool,
545    pub reject_without_ledger_entry: bool,
546}
547
548impl Default for MemorySkillReviewConfig {
549    fn default() -> Self {
550        Self {
551            require_adversarial_review: true,
552            require_reviewed_ledger_entry: true,
553            require_pass_for_how_to: true,
554            require_structured_findings_checked: true,
555            reject_without_ledger_entry: true,
556        }
557    }
558}
559
560#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
561#[serde(default)]
562pub struct MemorySkillScanConfig {
563    pub secret_detection: SecretDetectionMode,
564    /// Optional scanner command. A non-zero exit is treated as a detected
565    /// finding; best-effort mode only downgrades operational failures.
566    pub secret_detector: String,
567    pub allow_secret_detector_command: bool,
568    pub secret_detector_timeout_seconds: u64,
569    pub entropy_threshold: f64,
570    pub blocked_patterns: Vec<String>,
571    pub blocked_patterns_case_insensitive: bool,
572}
573
574impl Default for MemorySkillScanConfig {
575    fn default() -> Self {
576        Self {
577            secret_detection: SecretDetectionMode::Required,
578            secret_detector: String::new(),
579            allow_secret_detector_command: false,
580            secret_detector_timeout_seconds: 10,
581            entropy_threshold: 4.5,
582            blocked_patterns: Vec::new(),
583            blocked_patterns_case_insensitive: true,
584        }
585    }
586}
587
588impl MemorySkillScanConfig {
589    pub fn effective_blocked_patterns(&self) -> Vec<String> {
590        let mut patterns = Vec::new();
591        for pattern in DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS {
592            push_unique_pattern(
593                &mut patterns,
594                pattern,
595                self.blocked_patterns_case_insensitive,
596            );
597        }
598        for pattern in &self.blocked_patterns {
599            push_unique_pattern(
600                &mut patterns,
601                pattern,
602                self.blocked_patterns_case_insensitive,
603            );
604        }
605        patterns
606    }
607}
608
609fn push_unique_pattern(patterns: &mut Vec<String>, pattern: &str, case_insensitive: bool) {
610    let pattern = pattern.trim();
611    if pattern.is_empty()
612        || patterns.iter().any(|existing| {
613            if case_insensitive {
614                existing.eq_ignore_ascii_case(pattern)
615            } else {
616                existing == pattern
617            }
618        })
619    {
620        return;
621    }
622    patterns.push(pattern.to_owned());
623}
624
625#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
626#[serde(rename_all = "kebab-case")]
627pub enum SecretDetectionMode {
628    #[default]
629    Required,
630    BestEffort,
631    Off,
632}
633
634#[derive(Debug, Error)]
635pub enum ConfigError {
636    #[error("failed to read config {path}: {source}")]
637    Read {
638        path: PathBuf,
639        #[source]
640        source: io::Error,
641    },
642    #[error("failed to parse config {path}: {source}")]
643    Parse {
644        path: PathBuf,
645        #[source]
646        source: toml::de::Error,
647    },
648    #[error("pair for writer {writer:?} has an arbiter model equal to the reviewer model")]
649    ArbiterNotDistinct { writer: String },
650    #[error("memory_skill.scan.entropy_threshold must be finite and greater than 0.0, got {value}")]
651    InvalidMemorySkillEntropyThreshold { value: f64 },
652    #[error("memory_skill.max_skill_bytes must be greater than 0")]
653    InvalidMemorySkillMaxSkillBytes,
654    #[error("memory_skill.scan.secret_detector_timeout_seconds must be greater than 0")]
655    InvalidMemorySkillSecretDetectorTimeout,
656}
657
658fn default_ledger_dir() -> String {
659    DEFAULT_STATE_DIR.to_owned()
660}
661
662fn default_writer() -> String {
663    "codex".to_owned()
664}
665
666/// Sensible opposed pairs so a repo with no `[pairs]` still reviews adversarially.
667fn default_pairs() -> BTreeMap<String, AdversarialPair> {
668    let mut pairs = BTreeMap::new();
669    pairs.insert(
670        "codex".to_owned(),
671        AdversarialPair {
672            reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
673            arbiter: Some(HarnessSelection::new(
674                "pi",
675                "openai-codex/gpt-5.5",
676                Effort::highest(),
677            )),
678        },
679    );
680    pairs.insert(
681        "claude".to_owned(),
682        AdversarialPair {
683            reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
684            // Arbiter must be a third distinct model. The reviewer is codex/gpt-5.5;
685            // using pi/openai-codex/gpt-5.5 as arbiter would be the SAME underlying
686            // model after provider-prefix stripping, violating the distinct-model rule.
687            // Gemini (gemini-2.5-pro) is a genuinely different family and provider,
688            // and InvocationPlan::for_harness supports ReviewerHarness::Gemini.
689            arbiter: Some(HarnessSelection::new(
690                "gemini",
691                "gemini-2.5-pro",
692                Effort::highest(),
693            )),
694        },
695    );
696    pairs.insert(
697        "pi".to_owned(),
698        AdversarialPair {
699            reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
700            arbiter: Some(HarnessSelection::new(
701                "claude",
702                "claude-opus-4-8",
703                Effort::highest(),
704            )),
705        },
706    );
707    pairs
708}
709
710/// Normalise a model identifier for opposition comparisons.
711///
712/// Strips the provider prefix (everything up to and including the last `/`)
713/// so `openai-codex/gpt-5.5` compares equal to `gpt-5.5`. Keeps config
714/// validation consistent with the runtime check in `reviewer::normalized_model`.
715/// The prefix is only stripped when it leaves a non-empty suffix.
716pub(crate) fn normalized_model(model: &str) -> String {
717    let model = model.trim();
718    let short = model
719        .rsplit_once('/')
720        .and_then(|(_, suffix)| {
721            let s = suffix.trim();
722            if s.is_empty() { None } else { Some(s) }
723        })
724        .unwrap_or(model);
725    short.to_ascii_lowercase()
726}
727
728#[cfg(test)]
729mod tests {
730    use std::path::Path;
731
732    use super::{Effort, TruthMirrorConfig};
733
734    #[test]
735    fn default_config_has_three_opposed_pairs() {
736        let config = TruthMirrorConfig::default();
737
738        assert_eq!(config.pairs.len(), 3);
739        assert_eq!(config.ledger_dir, ".truth");
740        assert!(!config.skills.enabled);
741        assert!(!config.memory_skill.enabled);
742        assert_eq!(config.memory_skill.mode, super::MemorySkillMode::Stage);
743        let codex = config.pair_for("codex").unwrap();
744        assert_eq!(codex.reviewer.harness, "claude");
745        assert_eq!(codex.reviewer.model, "claude-opus-4-8");
746        assert_eq!(codex.reviewer.effort, Effort::Xhigh);
747    }
748
749    #[test]
750    fn effort_serializes_lowercase() {
751        assert_eq!(Effort::Xhigh.as_str(), "xhigh");
752        assert_eq!(Effort::highest(), Effort::Xhigh);
753    }
754
755    #[test]
756    fn pairs_config_parses_and_resolves_by_writer() {
757        // Arbiter must be distinct from reviewer after provider-prefix stripping:
758        // gemini/gemini-2.5-pro normalizes to "gemini-2.5-pro", which != "gpt-5.5".
759        let contents = r#"
760default_writer = "claude"
761
762[pairs.claude]
763reviewer = { harness = "codex", model = "gpt-5.5", effort = "xhigh" }
764arbiter  = { harness = "gemini", model = "gemini-2.5-pro", effort = "high" }
765
766[pairs.codex]
767reviewer = { harness = "claude", model = "claude-opus-4-8" }
768"#;
769        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
770
771        let claude_pair = config.pair_for("claude").unwrap();
772        assert_eq!(claude_pair.reviewer.harness, "codex");
773        assert_eq!(claude_pair.reviewer.effort, Effort::Xhigh);
774        assert_eq!(claude_pair.arbiter.as_ref().unwrap().effort, Effort::High);
775
776        // Omitted effort defaults to highest.
777        let codex_pair = config.pair_for("codex").unwrap();
778        assert_eq!(codex_pair.reviewer.effort, Effort::Xhigh);
779    }
780
781    #[test]
782    fn arbiter_same_as_reviewer_after_prefix_strip_is_rejected() {
783        // openai-codex/gpt-5.5 normalizes to gpt-5.5, same as the reviewer model.
784        // This was the bug in the original claude default pair (B1/B2 remediation).
785        let contents = r#"
786[pairs.claude]
787reviewer = { harness = "codex", model = "gpt-5.5" }
788arbiter  = { harness = "pi", model = "openai-codex/gpt-5.5" }
789"#;
790        let result = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents);
791        assert!(result.is_err());
792        let err = result.unwrap_err().to_string();
793        assert!(
794            err.contains("arbiter"),
795            "expected arbiter error, got: {err}"
796        );
797    }
798
799    #[test]
800    fn default_claude_pair_arbiter_is_distinct_from_reviewer() {
801        // B1 fix: the claude default pair used to have arbiter=pi/openai-codex/gpt-5.5
802        // and reviewer=codex/gpt-5.5. After provider-prefix stripping both normalize
803        // to "gpt-5.5", which is the same model. The fix changed the arbiter to
804        // gemini/gemini-2.5-pro. This test ensures the default config loads without
805        // ArbiterNotDistinct and has a genuinely distinct arbiter.
806        let config = TruthMirrorConfig::default();
807        let pair = config.pair_for("claude").expect("claude pair must exist");
808        let arbiter = pair.arbiter.as_ref().expect("claude arbiter must be set");
809        assert_eq!(
810            arbiter.harness, "gemini",
811            "claude arbiter harness must be gemini"
812        );
813        assert_ne!(
814            super::normalized_model(&arbiter.model),
815            super::normalized_model(&pair.reviewer.model),
816            "claude arbiter must be distinct from reviewer after normalization"
817        );
818    }
819
820    #[test]
821    fn normalized_strips_provider_prefix_for_comparison() {
822        // B2: openai-codex/gpt-5.5 and gpt-5.5 should normalize to the same token.
823        assert_eq!(
824            super::normalized_model("openai-codex/gpt-5.5"),
825            super::normalized_model("gpt-5.5")
826        );
827        // Prefix-only values (bare "/") keep their identity to avoid false collisions.
828        assert_ne!(super::normalized_model("/"), super::normalized_model(""));
829    }
830
831    #[test]
832    fn legacy_review_block_overrides_default_pair() {
833        // Reviewer distinct from the built-in codex default (claude/opus-4-8) so
834        // the test proves the legacy block actually overrides the default rather
835        // than coincidentally matching it.
836        let contents = r#"
837ledger_dir = ".truth-mirror"
838
839[review.watched]
840harness = "codex"
841model = "gpt-5.5"
842
843[review.reviewer]
844harness = "gemini"
845model = "gemini-3-pro"
846"#;
847        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
848
849        let pair = config.pair_for("codex").unwrap();
850        assert_eq!(pair.reviewer.harness, "gemini");
851        assert_eq!(pair.reviewer.model, "gemini-3-pro");
852        // Other writers still resolve from defaults.
853        assert!(config.pair_for("claude").is_some());
854        assert!(config.pair_for("pi").is_some());
855    }
856
857    #[test]
858    fn explicit_pairs_win_over_legacy_review() {
859        // A mixed migration config must not let legacy [review] clobber a modern
860        // explicit [pairs.<writer>] entry.
861        let contents = r#"
862[pairs.codex]
863reviewer = { harness = "claude", model = "explicit-model" }
864
865[review.watched]
866harness = "codex"
867model = "gpt-5.5"
868
869[review.reviewer]
870harness = "gemini"
871model = "legacy-model"
872"#;
873        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
874
875        let pair = config.pair_for("codex").unwrap();
876        assert_eq!(pair.reviewer.harness, "claude");
877        assert_eq!(pair.reviewer.model, "explicit-model");
878    }
879
880    #[test]
881    fn explicit_pairs_win_case_insensitively_over_legacy() {
882        // `[pairs.CODEX]` (uppercase) must not be clobbered by a legacy [review]
883        // folded under lowercase `codex`.
884        let contents = r#"
885[pairs.CODEX]
886reviewer = { harness = "claude", model = "explicit-model" }
887
888[review.watched]
889harness = "codex"
890model = "gpt-5.5"
891
892[review.reviewer]
893harness = "gemini"
894model = "legacy-model"
895"#;
896        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
897
898        let pair = config.pair_for("codex").unwrap();
899        assert_eq!(pair.reviewer.model, "explicit-model");
900        // No duplicate CODEX/codex entries.
901        assert_eq!(config.pairs.len(), 1);
902    }
903
904    #[test]
905    fn arbiter_equal_to_reviewer_model_is_rejected() {
906        let contents = r#"
907[pairs.codex]
908reviewer = { harness = "claude", model = "same-model" }
909arbiter  = { harness = "pi", model = "same-model" }
910"#;
911        let error =
912            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
913
914        assert!(matches!(
915            error,
916            super::ConfigError::ArbiterNotDistinct { .. }
917        ));
918    }
919
920    #[test]
921    fn missing_config_loads_default() {
922        let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
923
924        assert_eq!(config.pairs.len(), 3);
925        assert!(config.ground_truth.enabled);
926        assert_eq!(config.history.window_user, 3);
927        assert!(!config.enforcement.is_enabled());
928    }
929
930    #[test]
931    fn gates_config_parses_and_builds_policy() {
932        let contents = r#"
933[pairs.codex]
934reviewer = { harness = "claude", model = "claude-opus-4-8" }
935
936[gates]
937fake_markers = ["pretend-pass"]
938evidence_patterns = ["jira:"]
939marker_ignore_paths = [".md", "vendor/"]
940"#;
941        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
942
943        // Config values are ADDITIVE: built-in defaults always apply, plus the
944        // repo's custom entries. A repo can add but not silently disable defaults.
945        // The default marker literal is built at runtime so it never appears as an
946        // added line in this file (truth-mirror's own gate would flag it).
947        let default_marker = ["mock", "as", "real"].join("-");
948        let policy = config.gates.to_policy();
949        assert!(policy.fake_markers.iter().any(|m| m == "pretend-pass"));
950        assert!(policy.fake_markers.contains(&default_marker));
951        assert!(policy.evidence_patterns.iter().any(|p| p == "jira:"));
952        assert!(policy.evidence_patterns.iter().any(|p| p == "tests:"));
953        assert!(policy.marker_ignore_paths.iter().any(|p| p == "vendor/"));
954        assert!(policy.marker_ignore_paths.iter().any(|p| p == "openspec/"));
955    }
956
957    #[test]
958    fn memory_skill_effective_enabled_requires_parent_self_and_active_mode() {
959        let skills_enabled = super::SkillsConfig { enabled: true };
960        let skills_disabled = super::SkillsConfig { enabled: false };
961        let mut memory = super::MemorySkillConfig {
962            enabled: true,
963            mode: super::MemorySkillMode::Stage,
964            ..super::MemorySkillConfig::default()
965        };
966
967        assert!(memory.effective_enabled(&skills_enabled));
968        assert!(!memory.effective_enabled(&skills_disabled));
969
970        memory.enabled = false;
971        assert!(!memory.effective_enabled(&skills_enabled));
972
973        memory.enabled = true;
974        memory.mode = super::MemorySkillMode::Off;
975        assert!(!memory.effective_enabled(&skills_enabled));
976    }
977
978    #[test]
979    fn memory_skill_rejection_precedence_is_legacy_compatible() {
980        let contents = r#"
981[memory_skill.signals]
982rejection_precedence = ["how_to_skill"]
983"#;
984
985        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
986
987        assert_eq!(
988            config.memory_skill.signals.rejection_precedence,
989            vec![super::MemorySkillCandidateKind::HowToSkill]
990        );
991    }
992
993    #[test]
994    fn memory_skill_entropy_threshold_must_be_finite() {
995        let contents = r#"
996[memory_skill.scan]
997entropy_threshold = inf
998"#;
999
1000        let error =
1001            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1002
1003        assert!(matches!(
1004            error,
1005            super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1006        ));
1007    }
1008
1009    #[test]
1010    fn memory_skill_entropy_threshold_must_be_positive() {
1011        let contents = r#"
1012[memory_skill.scan]
1013entropy_threshold = 0.0
1014"#;
1015
1016        let error =
1017            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1018
1019        assert!(matches!(
1020            error,
1021            super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1022        ));
1023    }
1024
1025    #[test]
1026    fn memory_skill_max_skill_bytes_must_be_positive() {
1027        let contents = r#"
1028[memory_skill]
1029max_skill_bytes = 0
1030"#;
1031
1032        let error =
1033            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1034
1035        assert!(matches!(
1036            error,
1037            super::ConfigError::InvalidMemorySkillMaxSkillBytes
1038        ));
1039    }
1040
1041    #[test]
1042    fn memory_skill_secret_detector_timeout_must_be_positive() {
1043        let contents = r#"
1044[memory_skill.scan]
1045secret_detector_timeout_seconds = 0
1046"#;
1047
1048        let error =
1049            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1050
1051        assert!(matches!(
1052            error,
1053            super::ConfigError::InvalidMemorySkillSecretDetectorTimeout
1054        ));
1055    }
1056
1057    #[test]
1058    fn empty_gate_lists_fall_back_to_defaults() {
1059        // An explicitly empty list must not silently disable a gate.
1060        let policy = super::GatesConfig {
1061            fake_markers: Vec::new(),
1062            evidence_patterns: Vec::new(),
1063            marker_ignore_paths: Vec::new(),
1064        }
1065        .to_policy();
1066
1067        assert!(!policy.fake_markers.is_empty());
1068        assert!(!policy.evidence_patterns.is_empty());
1069        assert!(!policy.marker_ignore_paths.is_empty());
1070    }
1071
1072    #[test]
1073    fn memory_skill_blocked_patterns_trim_and_dedupe() {
1074        let config = super::MemorySkillScanConfig {
1075            blocked_patterns: vec![
1076                " API_KEY ".to_owned(),
1077                " custom ".to_owned(),
1078                " ".to_owned(),
1079                "custom".to_owned(),
1080            ],
1081            ..super::MemorySkillScanConfig::default()
1082        };
1083
1084        let patterns = config.effective_blocked_patterns();
1085
1086        assert_eq!(
1087            patterns
1088                .iter()
1089                .filter(|pattern| pattern.eq_ignore_ascii_case("api_key"))
1090                .count(),
1091            1
1092        );
1093        assert_eq!(
1094            patterns
1095                .iter()
1096                .filter(|pattern| *pattern == "custom")
1097                .count(),
1098            1
1099        );
1100        assert!(patterns.iter().all(|pattern| pattern.trim() == pattern));
1101    }
1102
1103    #[test]
1104    fn pair_keys_are_lowercased() {
1105        let contents = r#"
1106[pairs.CODEX]
1107reviewer = { harness = "claude", model = "claude-opus-4-8" }
1108"#;
1109        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1110
1111        assert!(config.pair_for("codex").is_some());
1112        assert!(config.pair_for("CoDeX").is_some());
1113    }
1114}