Skip to main content

chronicle/schema/
correction.rs

1use serde::{Deserialize, Serialize};
2
3/// The type of correction applied to an annotation.
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
5#[serde(rename_all = "snake_case")]
6pub enum CorrectionType {
7    /// General flag that the annotation may be inaccurate
8    Flag,
9    /// Specific removal of a value from a field
10    Remove,
11    /// Amendment of a field with new content
12    Amend,
13}
14
15/// A single correction entry on a region annotation.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Correction {
18    /// Which annotation field this correction targets
19    pub field: String,
20
21    /// The type of correction
22    pub correction_type: CorrectionType,
23
24    /// Human/agent-readable explanation of the correction
25    pub reason: String,
26
27    /// The specific value being removed or amended (for array fields)
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub target_value: Option<String>,
30
31    /// Replacement value (for amend corrections)
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub replacement: Option<String>,
34
35    /// When the correction was made
36    pub timestamp: String,
37
38    /// Who made the correction (git author, agent session, etc.)
39    pub author: String,
40}
41
42/// Confidence penalty per correction on a region.
43pub const CORRECTION_PENALTY: f64 = 0.15;
44
45/// Minimum confidence floor (corrections can't reduce below this).
46pub const CORRECTION_FLOOR: f64 = 0.1;
47
48/// Apply the confidence penalty for accumulated corrections.
49pub fn apply_correction_penalty(base_confidence: f64, correction_count: usize) -> f64 {
50    let penalty = correction_count as f64 * CORRECTION_PENALTY;
51    (base_confidence - penalty).max(CORRECTION_FLOOR)
52}
53
54/// Resolve the author for a correction from git config or environment.
55pub fn resolve_author(git: &dyn crate::git::GitOps) -> String {
56    // Check CHRONICLE_SESSION env var first
57    if let Ok(session) = std::env::var("CHRONICLE_SESSION") {
58        if !session.is_empty() {
59            return session;
60        }
61    }
62
63    // Fall back to git user.name + user.email
64    let name = git
65        .config_get("user.name")
66        .ok()
67        .flatten()
68        .unwrap_or_default();
69    let email = git
70        .config_get("user.email")
71        .ok()
72        .flatten()
73        .unwrap_or_default();
74
75    if !name.is_empty() && !email.is_empty() {
76        format!("{name} <{email}>")
77    } else if !name.is_empty() {
78        name
79    } else if !email.is_empty() {
80        email
81    } else {
82        "unknown".to_string()
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn test_correction_roundtrip() {
92        let correction = Correction {
93            field: "constraints".to_string(),
94            correction_type: CorrectionType::Remove,
95            reason: "No longer required since v2.3".to_string(),
96            target_value: Some("Must drain queue".to_string()),
97            replacement: None,
98            timestamp: "2025-12-20T14:30:00Z".to_string(),
99            author: "test-user".to_string(),
100        };
101
102        let json = serde_json::to_string(&correction).unwrap();
103        let parsed: Correction = serde_json::from_str(&json).unwrap();
104
105        assert_eq!(parsed.field, "constraints");
106        assert_eq!(parsed.correction_type, CorrectionType::Remove);
107        assert_eq!(parsed.reason, "No longer required since v2.3");
108        assert_eq!(parsed.target_value.as_deref(), Some("Must drain queue"));
109        assert!(parsed.replacement.is_none());
110        assert_eq!(parsed.author, "test-user");
111    }
112
113    #[test]
114    fn test_correction_type_serialization() {
115        assert_eq!(
116            serde_json::to_string(&CorrectionType::Flag).unwrap(),
117            "\"flag\""
118        );
119        assert_eq!(
120            serde_json::to_string(&CorrectionType::Remove).unwrap(),
121            "\"remove\""
122        );
123        assert_eq!(
124            serde_json::to_string(&CorrectionType::Amend).unwrap(),
125            "\"amend\""
126        );
127    }
128
129    #[test]
130    fn test_correction_type_deserialization() {
131        let flag: CorrectionType = serde_json::from_str("\"flag\"").unwrap();
132        assert_eq!(flag, CorrectionType::Flag);
133        let remove: CorrectionType = serde_json::from_str("\"remove\"").unwrap();
134        assert_eq!(remove, CorrectionType::Remove);
135        let amend: CorrectionType = serde_json::from_str("\"amend\"").unwrap();
136        assert_eq!(amend, CorrectionType::Amend);
137    }
138
139    #[test]
140    fn test_apply_correction_penalty() {
141        assert_eq!(apply_correction_penalty(0.85, 0), 0.85);
142        assert_eq!(apply_correction_penalty(0.85, 1), 0.7);
143        assert_eq!(apply_correction_penalty(0.85, 2), 0.55);
144        // Floor kicks in
145        assert_eq!(apply_correction_penalty(0.85, 10), CORRECTION_FLOOR);
146        assert_eq!(apply_correction_penalty(0.3, 2), CORRECTION_FLOOR);
147    }
148
149    #[test]
150    fn test_flag_correction_no_target_value() {
151        let correction = Correction {
152            field: "intent".to_string(),
153            correction_type: CorrectionType::Flag,
154            reason: "Annotation seems wrong".to_string(),
155            target_value: None,
156            replacement: None,
157            timestamp: "2025-12-20T14:30:00Z".to_string(),
158            author: "tester".to_string(),
159        };
160
161        let json = serde_json::to_string(&correction).unwrap();
162        // target_value and replacement should be absent due to skip_serializing_if
163        assert!(!json.contains("target_value"));
164        assert!(!json.contains("replacement"));
165    }
166
167    #[test]
168    fn test_amend_correction_with_replacement() {
169        let correction = Correction {
170            field: "reasoning".to_string(),
171            correction_type: CorrectionType::Amend,
172            reason: "Updated reasoning".to_string(),
173            target_value: None,
174            replacement: Some("New reasoning text".to_string()),
175            timestamp: "2025-12-20T14:30:00Z".to_string(),
176            author: "tester".to_string(),
177        };
178
179        let json = serde_json::to_string(&correction).unwrap();
180        let parsed: Correction = serde_json::from_str(&json).unwrap();
181        assert_eq!(parsed.correction_type, CorrectionType::Amend);
182        assert_eq!(parsed.replacement.as_deref(), Some("New reasoning text"));
183    }
184}