truth-mirror 0.1.0

Truthfulness gate and adversarial reviewer harness for AI coding agents.
Documentation
use std::{
    fs, io,
    path::{Path, PathBuf},
};

use serde::{Deserialize, Serialize};
use thiserror::Error;

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TruthMirrorConfig {
    pub ledger_dir: String,
    pub review: ReviewPair,
    #[serde(default)]
    pub strict: StrictConfig,
}

impl Default for TruthMirrorConfig {
    fn default() -> Self {
        Self {
            ledger_dir: ".truth-mirror".to_owned(),
            review: ReviewPair::default(),
            strict: StrictConfig::default(),
        }
    }
}

/// Strict goal-loop thresholds. `N == 0` disables that stop condition, matching
/// `StrictGoalPolicy` semantics. `max_passes` bounds the loop so an honest agent
/// still terminates.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct StrictConfig {
    pub stop_after_lies: u32,
    pub stop_after_fuckups: u32,
    pub max_passes: u32,
}

impl Default for StrictConfig {
    fn default() -> Self {
        Self {
            stop_after_lies: 1,
            stop_after_fuckups: 3,
            max_passes: 3,
        }
    }
}

impl StrictConfig {
    pub fn goal_policy(
        &self,
        lies_override: Option<u32>,
        fuckups_override: Option<u32>,
    ) -> crate::reviewer::StrictGoalPolicy {
        crate::reviewer::StrictGoalPolicy {
            stop_after_lies: lies_override.unwrap_or(self.stop_after_lies),
            stop_after_fuckups: fuckups_override.unwrap_or(self.stop_after_fuckups),
        }
    }
}

impl TruthMirrorConfig {
    pub fn load_for_cli(
        explicit_path: Option<&Path>,
        state_dir: &Path,
    ) -> Result<Self, ConfigError> {
        let path = explicit_path.map_or_else(|| Self::default_path(state_dir), PathBuf::from);
        Self::load_or_default(path)
    }

    pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
        let path = path.into();
        match fs::read_to_string(&path) {
            Ok(contents) => Self::from_toml_str(&path, &contents),
            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
            Err(source) => Err(ConfigError::Read { path, source }),
        }
    }

    pub fn from_toml_str(path: &Path, contents: &str) -> Result<Self, ConfigError> {
        let config: Self = toml::from_str(contents).map_err(|source| ConfigError::Parse {
            path: path.to_path_buf(),
            source,
        })?;
        config.review.validate_model_opposition()?;
        Ok(config)
    }

    pub fn default_path(state_dir: &Path) -> PathBuf {
        state_dir.join("config.toml")
    }
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewPair {
    pub watched: ModelSelection,
    pub reviewer: ModelSelection,
    pub allow_same_model: bool,
}

impl ReviewPair {
    pub fn validate_model_opposition(&self) -> Result<(), ConfigError> {
        if !self.allow_same_model
            && normalized(&self.watched.model) == normalized(&self.reviewer.model)
        {
            return Err(ConfigError::SameModelWithoutWaiver {
                watched_model: self.watched.model.clone(),
                reviewer_model: self.reviewer.model.clone(),
            });
        }

        Ok(())
    }
}

impl Default for ReviewPair {
    fn default() -> Self {
        Self {
            watched: ModelSelection {
                harness: "codex".to_owned(),
                model: "gpt-5.5".to_owned(),
            },
            reviewer: ModelSelection {
                harness: "claude".to_owned(),
                model: "claude-opus-4-1".to_owned(),
            },
            allow_same_model: false,
        }
    }
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ModelSelection {
    pub harness: String,
    pub model: String,
}

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("failed to read config {path}: {source}")]
    Read {
        path: PathBuf,
        #[source]
        source: io::Error,
    },
    #[error("failed to parse config {path}: {source}")]
    Parse {
        path: PathBuf,
        #[source]
        source: toml::de::Error,
    },
    #[error(
        "same reviewer model is disallowed without --allow-same-model: watched={watched_model}, reviewer={reviewer_model}"
    )]
    SameModelWithoutWaiver {
        watched_model: String,
        reviewer_model: String,
    },
}

fn normalized(model: &str) -> String {
    model.trim().to_ascii_lowercase()
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use super::{ConfigError, ModelSelection, ReviewPair, TruthMirrorConfig};

    #[test]
    fn default_config_uses_different_models() {
        let pair = ReviewPair::default();

        pair.validate_model_opposition().unwrap();
    }

    #[test]
    fn same_model_is_rejected_without_explicit_waiver() {
        let pair = ReviewPair {
            watched: ModelSelection {
                harness: "codex".to_owned(),
                model: "gpt-5.5".to_owned(),
            },
            reviewer: ModelSelection {
                harness: "codex".to_owned(),
                model: " GPT-5.5 ".to_owned(),
            },
            allow_same_model: false,
        };

        assert!(pair.validate_model_opposition().is_err());
    }

    #[test]
    fn same_model_can_be_allowed_explicitly() {
        let pair = ReviewPair {
            watched: ModelSelection {
                harness: "codex".to_owned(),
                model: "gpt-5.5".to_owned(),
            },
            reviewer: ModelSelection {
                harness: "codex".to_owned(),
                model: "gpt-5.5".to_owned(),
            },
            allow_same_model: true,
        };

        pair.validate_model_opposition().unwrap();
    }

    #[test]
    fn missing_config_loads_default() {
        let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();

        assert_eq!(config, TruthMirrorConfig::default());
    }

    #[test]
    fn config_load_validates_model_opposition() {
        let contents = r#"
ledger_dir = ".truth-mirror"

[review]
allow_same_model = false

[review.watched]
harness = "codex"
model = "gpt-5.5"

[review.reviewer]
harness = "claude"
model = "gpt-5.5"
"#;

        let error =
            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();

        assert!(matches!(error, ConfigError::SameModelWithoutWaiver { .. }));
    }
}