Skip to main content

truth_mirror/
ledger.rs

1//! Machine-readable ledger and markdown mirror.
2
3use std::{
4    collections::BTreeMap,
5    fmt, fs,
6    io::{self, Write},
7    path::{Path, PathBuf},
8    process::ExitCode,
9};
10
11use anyhow::Result;
12use serde::{Deserialize, Deserializer, Serialize, de};
13use thiserror::Error;
14
15use crate::{cli, config::MemorySkillCandidateKind, time::unix_now};
16
17pub const MACHINE_LEDGER_FILE: &str = "ledger.jsonl";
18pub const MARKDOWN_LEDGER_FILE: &str = "ledger.md";
19
20#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
21pub struct LedgerEntry {
22    pub commit_sha: String,
23    pub verdict: Verdict,
24    pub disposition: Disposition,
25    pub claim: String,
26    pub evidence: Vec<String>,
27    pub reviewer: ReviewerConfig,
28    #[serde(default)]
29    pub summary: String,
30    pub findings: Vec<String>,
31    #[serde(default)]
32    pub structured_findings: Vec<StructuredFinding>,
33    #[serde(default)]
34    pub next_steps: Vec<String>,
35    #[serde(default)]
36    pub memory_skill_classification: MemorySkillClassification,
37    #[serde(default)]
38    pub raw_reviewer_output: String,
39    pub resolution: Option<Resolution>,
40    pub created_at_unix: u64,
41    pub updated_at_unix: u64,
42}
43
44impl LedgerEntry {
45    pub fn new(
46        commit_sha: impl Into<String>,
47        verdict: Verdict,
48        claim: impl Into<String>,
49        evidence: Vec<String>,
50        reviewer: ReviewerConfig,
51        findings: Vec<String>,
52    ) -> Self {
53        Self::new_at(
54            commit_sha,
55            verdict,
56            claim,
57            evidence,
58            reviewer,
59            findings,
60            unix_now(),
61        )
62    }
63
64    pub fn new_at(
65        commit_sha: impl Into<String>,
66        verdict: Verdict,
67        claim: impl Into<String>,
68        evidence: Vec<String>,
69        reviewer: ReviewerConfig,
70        findings: Vec<String>,
71        timestamp: u64,
72    ) -> Self {
73        let disposition = match verdict {
74            Verdict::Pass => Disposition::Resolved,
75            Verdict::Reject => Disposition::Open,
76        };
77
78        Self {
79            commit_sha: commit_sha.into(),
80            verdict,
81            disposition,
82            claim: claim.into(),
83            evidence,
84            reviewer,
85            summary: String::new(),
86            findings,
87            structured_findings: Vec::new(),
88            next_steps: Vec::new(),
89            memory_skill_classification: MemorySkillClassification::default(),
90            raw_reviewer_output: String::new(),
91            resolution: None,
92            created_at_unix: timestamp,
93            updated_at_unix: timestamp,
94        }
95    }
96
97    pub fn with_structured_review(
98        mut self,
99        summary: impl Into<String>,
100        structured_findings: Vec<StructuredFinding>,
101        next_steps: Vec<String>,
102        raw_reviewer_output: impl Into<String>,
103    ) -> Self {
104        self.summary = summary.into();
105        self.structured_findings = structured_findings;
106        self.next_steps = next_steps;
107        self.raw_reviewer_output = raw_reviewer_output.into();
108        self
109    }
110
111    pub fn with_memory_skill_classification(
112        mut self,
113        classification: MemorySkillClassification,
114    ) -> Self {
115        self.memory_skill_classification = classification;
116        self
117    }
118
119    pub fn is_unresolved_rejection(&self) -> bool {
120        self.verdict == Verdict::Reject && self.disposition == Disposition::Open
121    }
122}
123
124#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
125#[serde(rename_all = "UPPERCASE")]
126pub enum Verdict {
127    Pass,
128    Reject,
129}
130
131impl fmt::Display for Verdict {
132    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            Self::Pass => formatter.write_str("PASS"),
135            Self::Reject => formatter.write_str("REJECT"),
136        }
137    }
138}
139
140#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
141#[serde(rename_all = "kebab-case")]
142pub enum FindingSeverity {
143    Critical,
144    High,
145    Medium,
146    Low,
147}
148
149impl fmt::Display for FindingSeverity {
150    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151        match self {
152            Self::Critical => formatter.write_str("critical"),
153            Self::High => formatter.write_str("high"),
154            Self::Medium => formatter.write_str("medium"),
155            Self::Low => formatter.write_str("low"),
156        }
157    }
158}
159
160#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
161pub struct StructuredFinding {
162    pub severity: FindingSeverity,
163    pub title: String,
164    pub body: String,
165    pub file: String,
166    pub line_start: u32,
167    pub line_end: u32,
168    #[serde(deserialize_with = "deserialize_confidence")]
169    pub confidence: u8,
170    pub recommendation: String,
171}
172
173impl StructuredFinding {
174    pub fn display_line(&self) -> String {
175        format!(
176            "{} [{}:{}-{}] {}: {} Recommendation: {} Confidence: {}%",
177            self.severity,
178            self.file,
179            self.line_start,
180            self.line_end,
181            self.title,
182            self.body,
183            self.recommendation,
184            self.confidence
185        )
186    }
187}
188
189#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
190pub struct MemorySkillClassification {
191    pub kind: MemorySkillClassificationKind,
192    pub learning_source: String,
193    pub reasoning: String,
194}
195
196impl Default for MemorySkillClassification {
197    fn default() -> Self {
198        Self {
199            kind: MemorySkillClassificationKind::None,
200            learning_source: String::new(),
201            reasoning: String::new(),
202        }
203    }
204}
205
206impl MemorySkillClassification {
207    pub fn candidate_kind(&self) -> Option<MemorySkillCandidateKind> {
208        match self.kind {
209            MemorySkillClassificationKind::None => None,
210            MemorySkillClassificationKind::HowToSkill => Some(MemorySkillCandidateKind::HowToSkill),
211            MemorySkillClassificationKind::AntiPatternSkill => {
212                Some(MemorySkillCandidateKind::AntiPatternSkill)
213            }
214            MemorySkillClassificationKind::RemediationSkill => {
215                Some(MemorySkillCandidateKind::RemediationSkill)
216            }
217        }
218    }
219
220    pub fn validate_for_verdict(&self, verdict: Verdict) -> Result<(), String> {
221        if self.reasoning.trim().is_empty() {
222            return Err("memory_skill.reasoning must not be empty".to_owned());
223        }
224        if self.kind != MemorySkillClassificationKind::None
225            && self.learning_source.trim().is_empty()
226        {
227            return Err(
228                "memory_skill.learning_source must not be empty when kind is not none".to_owned(),
229            );
230        }
231        match (verdict, self.kind) {
232            (Verdict::Pass, MemorySkillClassificationKind::AntiPatternSkill)
233            | (Verdict::Pass, MemorySkillClassificationKind::RemediationSkill) => Err(
234                "PASS verdict cannot propose anti_pattern_skill or remediation_skill".to_owned(),
235            ),
236            (Verdict::Reject, MemorySkillClassificationKind::HowToSkill) => {
237                Err("REJECT verdict cannot propose how_to_skill".to_owned())
238            }
239            _ => Ok(()),
240        }
241    }
242}
243
244#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
245#[serde(rename_all = "snake_case")]
246pub enum MemorySkillClassificationKind {
247    None,
248    HowToSkill,
249    AntiPatternSkill,
250    RemediationSkill,
251}
252
253impl std::fmt::Display for MemorySkillClassificationKind {
254    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
255        let value = match self {
256            Self::None => "none",
257            Self::HowToSkill => "how_to_skill",
258            Self::AntiPatternSkill => "anti_pattern_skill",
259            Self::RemediationSkill => "remediation_skill",
260        };
261        formatter.write_str(value)
262    }
263}
264
265/// Deserialize a reviewer finding's `confidence`, tolerating every numeric shape a
266/// reviewer harness emits and normalizing to an integer percent in `0..=100`.
267///
268/// Codex (gpt-5.5) emits a normalized float such as `0.95`; other harnesses emit an
269/// integer percent (`86`) or a decimal percent (`95.5`). A malformed or out-of-range
270/// value is **clamped**, never rejected: dropping the whole verdict because the
271/// confidence was formatted unusually is exactly how six REJECT verdicts were silently
272/// swallowed. A verdict must never be lost to confidence formatting.
273fn deserialize_confidence<'de, D>(deserializer: D) -> Result<u8, D::Error>
274where
275    D: Deserializer<'de>,
276{
277    struct ConfidenceVisitor;
278
279    impl<'de> de::Visitor<'de> for ConfidenceVisitor {
280        type Value = u8;
281
282        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
283            formatter.write_str("an integer percent 0..=100 or a normalized float 0.0..=1.0")
284        }
285
286        fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
287        where
288            E: de::Error,
289        {
290            Ok(clamp_percent(value))
291        }
292
293        fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
294        where
295            E: de::Error,
296        {
297            // A negative confidence is meaningless; floor it at zero rather than error.
298            Ok(u64::try_from(value).map_or(0, clamp_percent))
299        }
300
301        fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
302        where
303            E: de::Error,
304        {
305            Ok(confidence_from_f64(value))
306        }
307
308        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
309        where
310            E: de::Error,
311        {
312            // Some harnesses quote the number (`"0.95"` / `"86"`). Parse it on the
313            // float path; an unparseable string floors to zero rather than dropping
314            // the whole verdict over a stray confidence format.
315            Ok(value.trim().parse::<f64>().map_or(0, confidence_from_f64))
316        }
317    }
318
319    deserializer.deserialize_any(ConfidenceVisitor)
320}
321
322/// Clamp an integer confidence to the `0..=100` percent range.
323fn clamp_percent(value: u64) -> u8 {
324    value.min(100) as u8
325}
326
327/// Normalize a floating-point confidence to an integer percent in `0..=100`.
328///
329/// A value in `0.0..=1.0` is read as a normalized fraction and scaled to a percent;
330/// anything larger is treated as already being on the percent scale. Non-finite or
331/// out-of-range inputs are clamped so a stray value never drops the verdict.
332fn confidence_from_f64(value: f64) -> u8 {
333    if value.is_nan() {
334        return 0;
335    }
336
337    let percent = if (0.0..=1.0).contains(&value) {
338        value * 100.0
339    } else {
340        value
341    };
342
343    if percent <= 0.0 {
344        0
345    } else if percent >= 100.0 {
346        100
347    } else {
348        percent.round() as u8
349    }
350}
351
352#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
353#[serde(rename_all = "kebab-case")]
354pub enum Disposition {
355    Open,
356    Resolved,
357    Waived,
358}
359
360impl fmt::Display for Disposition {
361    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
362        match self {
363            Self::Open => formatter.write_str("open"),
364            Self::Resolved => formatter.write_str("resolved"),
365            Self::Waived => formatter.write_str("waived"),
366        }
367    }
368}
369
370#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
371pub struct ReviewerConfig {
372    pub harness: String,
373    pub model: String,
374    pub allow_same_model: bool,
375}
376
377impl ReviewerConfig {
378    pub fn new(
379        harness: impl Into<String>,
380        model: impl Into<String>,
381        allow_same_model: bool,
382    ) -> Self {
383        Self {
384            harness: harness.into(),
385            model: model.into(),
386            allow_same_model,
387        }
388    }
389}
390
391#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
392pub struct Resolution {
393    pub kind: ResolutionKind,
394    pub reason: String,
395    pub resolved_at_unix: u64,
396}
397
398#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
399#[serde(rename_all = "kebab-case")]
400pub enum ResolutionKind {
401    Resolved,
402    Waived,
403}
404
405#[derive(Clone, Debug, Default, Eq, PartialEq)]
406pub struct LedgerStats {
407    pub total: usize,
408    pub pass: usize,
409    pub reject: usize,
410    pub unresolved: usize,
411    pub resolved: usize,
412    pub waived: usize,
413}
414
415#[derive(Clone, Debug)]
416pub struct LedgerStore {
417    root: PathBuf,
418}
419
420impl LedgerStore {
421    pub fn new(root: impl Into<PathBuf>) -> Self {
422        Self { root: root.into() }
423    }
424
425    pub fn machine_path(&self) -> PathBuf {
426        self.root.join(MACHINE_LEDGER_FILE)
427    }
428
429    pub fn markdown_path(&self) -> PathBuf {
430        self.root.join(MARKDOWN_LEDGER_FILE)
431    }
432
433    pub fn append_entry(&self, entry: &LedgerEntry) -> Result<(), LedgerError> {
434        fs::create_dir_all(&self.root)?;
435        let mut file = fs::OpenOptions::new()
436            .create(true)
437            .append(true)
438            .open(self.machine_path())?;
439        serde_json::to_writer(&mut file, entry)?;
440        writeln!(file)?;
441        self.render_markdown_mirror()?;
442        Ok(())
443    }
444
445    pub fn read_history(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
446        let path = self.machine_path();
447        let contents = match fs::read_to_string(&path) {
448            Ok(contents) => contents,
449            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
450            Err(error) => return Err(error.into()),
451        };
452
453        contents
454            .lines()
455            .filter(|line| !line.trim().is_empty())
456            .map(|line| serde_json::from_str(line).map_err(LedgerError::from))
457            .collect()
458    }
459
460    pub fn latest_entries(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
461        let mut by_sha = BTreeMap::new();
462        for entry in self.read_history()? {
463            by_sha.insert(entry.commit_sha.clone(), entry);
464        }
465        Ok(by_sha.into_values().collect())
466    }
467
468    pub fn show(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
469        self.latest_entries()?
470            .into_iter()
471            .find(|entry| entry.commit_sha == sha)
472            .ok_or_else(|| LedgerError::NotFound {
473                sha: sha.to_owned(),
474            })
475    }
476
477    pub fn unresolved_rejections(&self) -> Result<Vec<LedgerEntry>, LedgerError> {
478        Ok(self
479            .latest_entries()?
480            .into_iter()
481            .filter(LedgerEntry::is_unresolved_rejection)
482            .collect())
483    }
484
485    pub fn resolve(&self, sha: &str) -> Result<LedgerEntry, LedgerError> {
486        self.transition_rejection(
487            sha,
488            Disposition::Resolved,
489            ResolutionKind::Resolved,
490            "resolved",
491        )
492    }
493
494    pub fn waive(&self, sha: &str, reason: &str) -> Result<LedgerEntry, LedgerError> {
495        if reason.trim().is_empty() {
496            return Err(LedgerError::EmptyWaiverReason);
497        }
498
499        self.transition_rejection(sha, Disposition::Waived, ResolutionKind::Waived, reason)
500    }
501
502    pub fn stats(&self) -> Result<LedgerStats, LedgerError> {
503        let mut stats = LedgerStats::default();
504        for entry in self.latest_entries()? {
505            stats.total += 1;
506            match entry.verdict {
507                Verdict::Pass => stats.pass += 1,
508                Verdict::Reject => stats.reject += 1,
509            }
510            match entry.disposition {
511                Disposition::Open => {
512                    if entry.verdict == Verdict::Reject {
513                        stats.unresolved += 1;
514                    }
515                }
516                Disposition::Resolved => stats.resolved += 1,
517                Disposition::Waived => stats.waived += 1,
518            }
519        }
520        Ok(stats)
521    }
522
523    pub fn render_markdown_mirror(&self) -> Result<(), LedgerError> {
524        fs::create_dir_all(&self.root)?;
525        let markdown = render_markdown(&self.latest_entries()?, &self.stats()?);
526        fs::write(self.markdown_path(), markdown)?;
527        Ok(())
528    }
529
530    fn transition_rejection(
531        &self,
532        sha: &str,
533        disposition: Disposition,
534        kind: ResolutionKind,
535        reason: &str,
536    ) -> Result<LedgerEntry, LedgerError> {
537        let mut entry = self.show(sha)?;
538        if !entry.is_unresolved_rejection() {
539            return Err(LedgerError::NoOpenRejection {
540                sha: sha.to_owned(),
541            });
542        }
543
544        let timestamp = unix_now();
545        entry.disposition = disposition;
546        entry.resolution = Some(Resolution {
547            kind,
548            reason: reason.trim().to_owned(),
549            resolved_at_unix: timestamp,
550        });
551        entry.updated_at_unix = timestamp;
552        self.append_entry(&entry)?;
553        Ok(entry)
554    }
555}
556
557#[derive(Debug, Error)]
558pub enum LedgerError {
559    #[error("ledger IO failed: {0}")]
560    Io(#[from] io::Error),
561    #[error("ledger JSON failed: {0}")]
562    Json(#[from] serde_json::Error),
563    #[error("ledger entry not found for commit {sha}")]
564    NotFound { sha: String },
565    #[error("commit {sha} has no open rejection")]
566    NoOpenRejection { sha: String },
567    #[error("waive requires a non-empty reason")]
568    EmptyWaiverReason,
569}
570
571pub fn run(args: cli::LedgerArgs, state_dir: &Path) -> Result<ExitCode> {
572    let store = LedgerStore::new(state_dir);
573    match args.command {
574        cli::LedgerCommand::List => {
575            print_unresolved(&store.unresolved_rejections()?);
576        }
577        cli::LedgerCommand::Show { sha } => {
578            let entry = store.show(&sha)?;
579            print_entry(&entry);
580        }
581        cli::LedgerCommand::Resolve { sha } => {
582            let entry = store.resolve(&sha)?;
583            println!("resolved {}", entry.commit_sha);
584        }
585        cli::LedgerCommand::Waive { sha, reason } => {
586            let entry = store.waive(&sha, &reason)?;
587            println!("waived {}", entry.commit_sha);
588        }
589        cli::LedgerCommand::Stats => print_stats(&store.stats()?),
590    }
591
592    Ok(ExitCode::SUCCESS)
593}
594
595fn render_markdown(entries: &[LedgerEntry], stats: &LedgerStats) -> String {
596    let mut output = String::new();
597    output.push_str("# Truth Mirror Ledger\n\n");
598    output.push_str("## Summary\n\n");
599    output.push_str(&format!("- Total: {}\n", stats.total));
600    output.push_str(&format!("- PASS: {}\n", stats.pass));
601    output.push_str(&format!("- REJECT: {}\n", stats.reject));
602    output.push_str(&format!("- Unresolved rejections: {}\n", stats.unresolved));
603    output.push_str(&format!("- Resolved: {}\n", stats.resolved));
604    output.push_str(&format!("- Waived: {}\n\n", stats.waived));
605    output.push_str("## Entries\n");
606
607    if entries.is_empty() {
608        output.push_str("\nNo ledger entries.\n");
609        return output;
610    }
611
612    for entry in entries {
613        output.push_str(&format!(
614            "\n### {} - {} - {}\n\n",
615            entry.commit_sha, entry.verdict, entry.disposition
616        ));
617        output.push_str(&format!("- Claim: {}\n", entry.claim));
618        output.push_str(&format!(
619            "- Evidence: {}\n",
620            if entry.evidence.is_empty() {
621                "none".to_owned()
622            } else {
623                entry.evidence.join(", ")
624            }
625        ));
626        output.push_str(&format!(
627            "- Reviewer: {}/{} (allow_same_model={})\n",
628            entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
629        ));
630        if !entry.summary.trim().is_empty() {
631            output.push_str(&format!("- Summary: {}\n", entry.summary));
632        }
633
634        if entry.findings.is_empty() {
635            output.push_str("- Findings: none\n");
636        } else if !entry.structured_findings.is_empty() {
637            output.push_str("- Findings:\n");
638            for finding in &entry.structured_findings {
639                output.push_str(&format!("  - {}\n", finding.display_line()));
640            }
641        } else {
642            output.push_str("- Findings:\n");
643            for finding in &entry.findings {
644                output.push_str(&format!("  - {}\n", finding));
645            }
646        }
647
648        if !entry.next_steps.is_empty() {
649            output.push_str("- Next steps:\n");
650            for step in &entry.next_steps {
651                output.push_str(&format!("  - {}\n", step));
652            }
653        }
654
655        if !entry
656            .memory_skill_classification
657            .reasoning
658            .trim()
659            .is_empty()
660        {
661            let classification = &entry.memory_skill_classification;
662            output.push_str(&format!("- Memory skill: {}", classification.kind));
663            if !classification.learning_source.trim().is_empty() {
664                output.push_str(&format!(" - {}", classification.learning_source));
665            }
666            output.push_str(&format!(" - {}\n", classification.reasoning));
667        }
668
669        if !entry.raw_reviewer_output.trim().is_empty() {
670            output.push_str("- Raw reviewer output:\n\n```json\n");
671            output.push_str(entry.raw_reviewer_output.trim());
672            output.push_str("\n```\n");
673        }
674
675        if let Some(resolution) = &entry.resolution {
676            output.push_str(&format!(
677                "- Resolution: {:?} - {}\n",
678                resolution.kind, resolution.reason
679            ));
680        }
681    }
682
683    output
684}
685
686fn print_unresolved(entries: &[LedgerEntry]) {
687    if entries.is_empty() {
688        println!("No unresolved rejected commits.");
689        return;
690    }
691
692    for entry in entries {
693        println!(
694            "{} {} {} {}",
695            entry.commit_sha, entry.verdict, entry.disposition, entry.claim
696        );
697    }
698}
699
700fn print_entry(entry: &LedgerEntry) {
701    println!("commit: {}", entry.commit_sha);
702    println!("verdict: {}", entry.verdict);
703    println!("disposition: {}", entry.disposition);
704    println!("claim: {}", entry.claim);
705    println!("evidence: {}", entry.evidence.join(", "));
706    println!(
707        "reviewer: {}/{} allow_same_model={}",
708        entry.reviewer.harness, entry.reviewer.model, entry.reviewer.allow_same_model
709    );
710    if !entry.summary.trim().is_empty() {
711        println!("summary: {}", entry.summary);
712    }
713    if entry.findings.is_empty() {
714        println!("findings: none");
715    } else if !entry.structured_findings.is_empty() {
716        println!("findings:");
717        for finding in &entry.structured_findings {
718            println!("- {}", finding.display_line());
719        }
720    } else {
721        println!("findings:");
722        for finding in &entry.findings {
723            println!("- {finding}");
724        }
725    }
726    if !entry.next_steps.is_empty() {
727        println!("next_steps:");
728        for step in &entry.next_steps {
729            println!("- {step}");
730        }
731    }
732    if !entry
733        .memory_skill_classification
734        .reasoning
735        .trim()
736        .is_empty()
737    {
738        let classification = &entry.memory_skill_classification;
739        println!("memory_skill_kind: {}", classification.kind);
740        if !classification.learning_source.trim().is_empty() {
741            println!(
742                "memory_skill_learning_source: {}",
743                classification.learning_source
744            );
745        }
746        println!("memory_skill_reasoning: {}", classification.reasoning);
747    }
748    if !entry.raw_reviewer_output.trim().is_empty() {
749        println!("raw_reviewer_output:");
750        println!("{}", entry.raw_reviewer_output.trim());
751    }
752}
753
754fn print_stats(stats: &LedgerStats) {
755    println!("total={}", stats.total);
756    println!("pass={}", stats.pass);
757    println!("reject={}", stats.reject);
758    println!("unresolved={}", stats.unresolved);
759    println!("resolved={}", stats.resolved);
760    println!("waived={}", stats.waived);
761}
762
763#[cfg(test)]
764mod tests {
765    use std::fs;
766
767    use proptest::prelude::*;
768
769    use super::{
770        Disposition, FindingSeverity, LedgerEntry, LedgerStore, MemorySkillClassification,
771        MemorySkillClassificationKind, ResolutionKind, ReviewerConfig, StructuredFinding, Verdict,
772    };
773
774    fn reviewer() -> ReviewerConfig {
775        ReviewerConfig::new("claude", "claude-opus-4-1", false)
776    }
777
778    fn rejected_entry(sha: &str) -> LedgerEntry {
779        LedgerEntry::new_at(
780            sha,
781            Verdict::Reject,
782            "CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
783            vec!["tests:cargo-test".to_owned()],
784            reviewer(),
785            vec!["claim was unsupported".to_owned()],
786            100,
787        )
788    }
789
790    fn structured_finding() -> StructuredFinding {
791        StructuredFinding {
792            severity: FindingSeverity::High,
793            title: "missing proof".to_owned(),
794            body: "The evidence pointer does not prove the claim.".to_owned(),
795            file: "src/lib.rs".to_owned(),
796            line_start: 7,
797            line_end: 9,
798            confidence: 95,
799            recommendation: "Add executable evidence before claiming completion.".to_owned(),
800        }
801    }
802
803    #[test]
804    fn append_and_read_latest_entries() {
805        let temp = tempfile::tempdir().unwrap();
806        let store = LedgerStore::new(temp.path());
807        store.append_entry(&rejected_entry("abc123")).unwrap();
808
809        let entries = store.latest_entries().unwrap();
810
811        assert_eq!(entries.len(), 1);
812        assert_eq!(entries[0].commit_sha, "abc123");
813        assert!(entries[0].is_unresolved_rejection());
814    }
815
816    #[test]
817    fn markdown_mirror_renders_summary_and_findings() {
818        let temp = tempfile::tempdir().unwrap();
819        let store = LedgerStore::new(temp.path());
820        store.append_entry(&rejected_entry("abc123")).unwrap();
821
822        let markdown = fs::read_to_string(store.markdown_path()).unwrap();
823
824        assert!(markdown.contains("# Truth Mirror Ledger"));
825        assert!(markdown.contains("Unresolved rejections: 1"));
826        assert!(markdown.contains("claim was unsupported"));
827    }
828
829    #[test]
830    fn markdown_mirror_renders_structured_review_provenance() {
831        let temp = tempfile::tempdir().unwrap();
832        let store = LedgerStore::new(temp.path());
833        let entry = rejected_entry("abc123")
834            .with_structured_review(
835                "The claim is not proven.",
836                vec![structured_finding()],
837                vec!["Run cargo test.".to_owned()],
838                r#"{"verdict":"REJECT"}"#,
839            )
840            .with_memory_skill_classification(MemorySkillClassification {
841                kind: MemorySkillClassificationKind::AntiPatternSkill,
842                learning_source: "missing proof".to_owned(),
843                reasoning: "The reviewer proposed an anti-pattern candidate.".to_owned(),
844            });
845        store.append_entry(&entry).unwrap();
846
847        let markdown = fs::read_to_string(store.markdown_path()).unwrap();
848
849        assert!(markdown.contains("The claim is not proven."));
850        assert!(markdown.contains("high [src/lib.rs:7-9] missing proof"));
851        assert!(markdown.contains("Run cargo test."));
852        assert!(markdown.contains("Memory skill: anti_pattern_skill - missing proof"));
853        assert!(markdown.contains(r#"{"verdict":"REJECT"}"#));
854    }
855
856    #[test]
857    fn old_ledger_entry_without_memory_skill_classification_reads_as_none() {
858        let entry: LedgerEntry = serde_json::from_value(serde_json::json!({
859            "commit_sha": "abc123",
860            "verdict": "REJECT",
861            "disposition": "open",
862            "claim": "CLAIM: thing | verified: cargo test | evidence: tests:cargo-test",
863            "evidence": ["tests:cargo-test"],
864            "reviewer": {
865                "harness": "claude",
866                "model": "claude-opus-4-1",
867                "allow_same_model": false
868            },
869            "summary": "The claim is not proven.",
870            "findings": ["missing proof"],
871            "structured_findings": [],
872            "next_steps": [],
873            "raw_reviewer_output": "{}",
874            "resolution": null,
875            "created_at_unix": 100,
876            "updated_at_unix": 100
877        }))
878        .unwrap();
879
880        assert_eq!(
881            entry.memory_skill_classification.kind,
882            MemorySkillClassificationKind::None
883        );
884    }
885
886    #[test]
887    fn resolve_clears_unresolved_rejection() {
888        let temp = tempfile::tempdir().unwrap();
889        let store = LedgerStore::new(temp.path());
890        store.append_entry(&rejected_entry("abc123")).unwrap();
891
892        let resolved = store.resolve("abc123").unwrap();
893
894        assert_eq!(resolved.disposition, Disposition::Resolved);
895        assert_eq!(
896            resolved.resolution.as_ref().unwrap().kind,
897            ResolutionKind::Resolved
898        );
899        assert!(store.unresolved_rejections().unwrap().is_empty());
900        assert_eq!(store.read_history().unwrap().len(), 2);
901    }
902
903    #[test]
904    fn waive_records_reason_and_clears_unresolved_rejection() {
905        let temp = tempfile::tempdir().unwrap();
906        let store = LedgerStore::new(temp.path());
907        store.append_entry(&rejected_entry("abc123")).unwrap();
908
909        let waived = store.waive("abc123", "Ramiro approved exception").unwrap();
910
911        assert_eq!(waived.disposition, Disposition::Waived);
912        assert_eq!(
913            waived.resolution.as_ref().unwrap().reason,
914            "Ramiro approved exception"
915        );
916        assert!(store.unresolved_rejections().unwrap().is_empty());
917    }
918
919    #[test]
920    fn stats_counts_latest_dispositions() {
921        let temp = tempfile::tempdir().unwrap();
922        let store = LedgerStore::new(temp.path());
923        store.append_entry(&rejected_entry("abc123")).unwrap();
924        store
925            .append_entry(&LedgerEntry::new_at(
926                "def456",
927                Verdict::Pass,
928                "CLAIM: pass | verified: cargo test | evidence: tests:cargo-test",
929                vec!["tests:cargo-test".to_owned()],
930                reviewer(),
931                Vec::new(),
932                100,
933            ))
934            .unwrap();
935        store.waive("abc123", "accepted risk").unwrap();
936
937        let stats = store.stats().unwrap();
938
939        assert_eq!(stats.total, 2);
940        assert_eq!(stats.pass, 1);
941        assert_eq!(stats.reject, 1);
942        assert_eq!(stats.unresolved, 0);
943        assert_eq!(stats.waived, 1);
944    }
945
946    /// Deserialize a finding whose only varying field is `confidence`, exercising the
947    /// real `#[serde(deserialize_with = "deserialize_confidence")]` wiring end to end.
948    fn confidence_of(confidence: serde_json::Value) -> u8 {
949        let value = serde_json::json!({
950            "severity": "high",
951            "title": "missing proof",
952            "body": "The cited evidence does not prove the claim.",
953            "file": "src/lib.rs",
954            "line_start": 1,
955            "line_end": 1,
956            "confidence": confidence,
957            "recommendation": "Provide executable evidence.",
958        });
959        serde_json::from_value::<StructuredFinding>(value)
960            .expect("a finding must never fail to deserialize over confidence formatting")
961            .confidence
962    }
963
964    #[test]
965    fn confidence_accepts_codex_normalized_floats() {
966        // The exact payload shapes that silently swallowed six REJECT verdicts.
967        assert_eq!(confidence_of(serde_json::json!(0.95)), 95);
968        assert_eq!(confidence_of(serde_json::json!(0.9)), 90);
969        assert_eq!(confidence_of(serde_json::json!(0.86)), 86);
970    }
971
972    #[test]
973    fn confidence_accepts_integer_percent() {
974        assert_eq!(confidence_of(serde_json::json!(86)), 86);
975        assert_eq!(confidence_of(serde_json::json!(0)), 0);
976        assert_eq!(confidence_of(serde_json::json!(100)), 100);
977    }
978
979    #[test]
980    fn confidence_normalizes_or_clamps_but_never_drops_the_verdict() {
981        assert_eq!(confidence_of(serde_json::json!(95.5)), 96); // decimal percent rounds
982        assert_eq!(confidence_of(serde_json::json!(140)), 100); // percent over 100 clamps
983        assert_eq!(confidence_of(serde_json::json!(-3)), 0); // negative int floors
984        assert_eq!(confidence_of(serde_json::json!(-0.2)), 0); // negative float floors
985        assert_eq!(confidence_of(serde_json::json!("0.86")), 86); // quoted number
986        assert_eq!(confidence_of(serde_json::json!("nonsense")), 0); // junk floors, no drop
987    }
988
989    proptest! {
990        #[test]
991        fn confidence_deserialization_is_total_over_all_finite_floats(value in -1000.0f64..1000.0) {
992            // No finite confidence float may ever error out of deserialization.
993            let confidence = confidence_of(serde_json::json!(value));
994            prop_assert!(confidence <= 100);
995        }
996
997        #[test]
998        fn append_read_preserves_unresolved_rejection_semantics(sha in "[a-f0-9]{7,40}") {
999            let temp = tempfile::tempdir().unwrap();
1000            let store = LedgerStore::new(temp.path());
1001            store.append_entry(&rejected_entry(&sha)).unwrap();
1002
1003            let entries = store.latest_entries().unwrap();
1004            prop_assert_eq!(entries.len(), 1);
1005            prop_assert_eq!(entries[0].commit_sha.as_str(), sha.as_str());
1006            prop_assert!(entries[0].is_unresolved_rejection());
1007        }
1008    }
1009}