Skip to main content

truth_mirror/
config.rs

1use std::{
2    fs, io,
3    path::{Path, PathBuf},
4};
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
10pub struct TruthMirrorConfig {
11    pub ledger_dir: String,
12    pub review: ReviewPair,
13    #[serde(default)]
14    pub strict: StrictConfig,
15}
16
17impl Default for TruthMirrorConfig {
18    fn default() -> Self {
19        Self {
20            ledger_dir: ".truth-mirror".to_owned(),
21            review: ReviewPair::default(),
22            strict: StrictConfig::default(),
23        }
24    }
25}
26
27/// Strict goal-loop thresholds. `N == 0` disables that stop condition, matching
28/// `StrictGoalPolicy` semantics. `max_passes` bounds the loop so an honest agent
29/// still terminates.
30#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
31#[serde(default)]
32pub struct StrictConfig {
33    pub stop_after_lies: u32,
34    pub stop_after_fuckups: u32,
35    pub max_passes: u32,
36}
37
38impl Default for StrictConfig {
39    fn default() -> Self {
40        Self {
41            stop_after_lies: 1,
42            stop_after_fuckups: 3,
43            max_passes: 3,
44        }
45    }
46}
47
48impl StrictConfig {
49    pub fn goal_policy(
50        &self,
51        lies_override: Option<u32>,
52        fuckups_override: Option<u32>,
53    ) -> crate::reviewer::StrictGoalPolicy {
54        crate::reviewer::StrictGoalPolicy {
55            stop_after_lies: lies_override.unwrap_or(self.stop_after_lies),
56            stop_after_fuckups: fuckups_override.unwrap_or(self.stop_after_fuckups),
57        }
58    }
59}
60
61impl TruthMirrorConfig {
62    pub fn load_for_cli(
63        explicit_path: Option<&Path>,
64        state_dir: &Path,
65    ) -> Result<Self, ConfigError> {
66        let path = explicit_path.map_or_else(|| Self::default_path(state_dir), PathBuf::from);
67        Self::load_or_default(path)
68    }
69
70    pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
71        let path = path.into();
72        match fs::read_to_string(&path) {
73            Ok(contents) => Self::from_toml_str(&path, &contents),
74            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
75            Err(source) => Err(ConfigError::Read { path, source }),
76        }
77    }
78
79    pub fn from_toml_str(path: &Path, contents: &str) -> Result<Self, ConfigError> {
80        let config: Self = toml::from_str(contents).map_err(|source| ConfigError::Parse {
81            path: path.to_path_buf(),
82            source,
83        })?;
84        config.review.validate_model_opposition()?;
85        Ok(config)
86    }
87
88    pub fn default_path(state_dir: &Path) -> PathBuf {
89        state_dir.join("config.toml")
90    }
91}
92
93#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
94pub struct ReviewPair {
95    pub watched: ModelSelection,
96    pub reviewer: ModelSelection,
97    pub allow_same_model: bool,
98}
99
100impl ReviewPair {
101    pub fn validate_model_opposition(&self) -> Result<(), ConfigError> {
102        if !self.allow_same_model
103            && normalized(&self.watched.model) == normalized(&self.reviewer.model)
104        {
105            return Err(ConfigError::SameModelWithoutWaiver {
106                watched_model: self.watched.model.clone(),
107                reviewer_model: self.reviewer.model.clone(),
108            });
109        }
110
111        Ok(())
112    }
113}
114
115impl Default for ReviewPair {
116    fn default() -> Self {
117        Self {
118            watched: ModelSelection {
119                harness: "codex".to_owned(),
120                model: "gpt-5.5".to_owned(),
121            },
122            reviewer: ModelSelection {
123                harness: "claude".to_owned(),
124                model: "claude-opus-4-1".to_owned(),
125            },
126            allow_same_model: false,
127        }
128    }
129}
130
131#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
132pub struct ModelSelection {
133    pub harness: String,
134    pub model: String,
135}
136
137#[derive(Debug, Error)]
138pub enum ConfigError {
139    #[error("failed to read config {path}: {source}")]
140    Read {
141        path: PathBuf,
142        #[source]
143        source: io::Error,
144    },
145    #[error("failed to parse config {path}: {source}")]
146    Parse {
147        path: PathBuf,
148        #[source]
149        source: toml::de::Error,
150    },
151    #[error(
152        "same reviewer model is disallowed without --allow-same-model: watched={watched_model}, reviewer={reviewer_model}"
153    )]
154    SameModelWithoutWaiver {
155        watched_model: String,
156        reviewer_model: String,
157    },
158}
159
160fn normalized(model: &str) -> String {
161    model.trim().to_ascii_lowercase()
162}
163
164#[cfg(test)]
165mod tests {
166    use std::path::Path;
167
168    use super::{ConfigError, ModelSelection, ReviewPair, TruthMirrorConfig};
169
170    #[test]
171    fn default_config_uses_different_models() {
172        let pair = ReviewPair::default();
173
174        pair.validate_model_opposition().unwrap();
175    }
176
177    #[test]
178    fn same_model_is_rejected_without_explicit_waiver() {
179        let pair = ReviewPair {
180            watched: ModelSelection {
181                harness: "codex".to_owned(),
182                model: "gpt-5.5".to_owned(),
183            },
184            reviewer: ModelSelection {
185                harness: "codex".to_owned(),
186                model: " GPT-5.5 ".to_owned(),
187            },
188            allow_same_model: false,
189        };
190
191        assert!(pair.validate_model_opposition().is_err());
192    }
193
194    #[test]
195    fn same_model_can_be_allowed_explicitly() {
196        let pair = ReviewPair {
197            watched: ModelSelection {
198                harness: "codex".to_owned(),
199                model: "gpt-5.5".to_owned(),
200            },
201            reviewer: ModelSelection {
202                harness: "codex".to_owned(),
203                model: "gpt-5.5".to_owned(),
204            },
205            allow_same_model: true,
206        };
207
208        pair.validate_model_opposition().unwrap();
209    }
210
211    #[test]
212    fn missing_config_loads_default() {
213        let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
214
215        assert_eq!(config, TruthMirrorConfig::default());
216    }
217
218    #[test]
219    fn config_load_validates_model_opposition() {
220        let contents = r#"
221ledger_dir = ".truth-mirror"
222
223[review]
224allow_same_model = false
225
226[review.watched]
227harness = "codex"
228model = "gpt-5.5"
229
230[review.reviewer]
231harness = "claude"
232model = "gpt-5.5"
233"#;
234
235        let error =
236            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
237
238        assert!(matches!(error, ConfigError::SameModelWithoutWaiver { .. }));
239    }
240}