Skip to main content

totalreclaw_core/
decision_log.rs

1//! Decision log types and loser-recovery utilities (Phase 2 Steps B/C).
2//!
3//! The decision log (`decisions.jsonl`) records every contradiction-resolution
4//! decision the system makes. It serves two purposes:
5//!   1. Audit trail for operator visibility.
6//!   2. Recovery path for the pin tool — when a superseded fact's on-chain blob
7//!      is tombstoned (`0x00`), the pin tool recovers the original plaintext from
8//!      the `loser_claim_json` field in this log.
9
10use crate::contradiction::ScoreComponents;
11use crate::feedback_log::{FeedbackEntry, FormulaWinner, UserDecision};
12use serde::{Deserialize, Serialize};
13
14/// Cap on the decisions.jsonl log — oldest lines are dropped above this.
15pub const DECISION_LOG_MAX_LINES: usize = 10_000;
16
17/// Soft cap on candidates fetched per entity during contradiction detection.
18pub const CONTRADICTION_CANDIDATE_CAP: usize = 20;
19
20/// A single row in `decisions.jsonl`.
21///
22/// Field names use `snake_case` to match the TypeScript output byte-for-byte.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct DecisionLogEntry {
25    pub ts: i64,
26    pub entity_id: String,
27    pub new_claim_id: String,
28    pub existing_claim_id: String,
29    pub similarity: f64,
30    /// One of: "supersede_existing", "skip_new", "shadow", "tie_leave_both".
31    pub action: String,
32    #[serde(skip_serializing_if = "Option::is_none", default)]
33    pub reason: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none", default)]
35    pub winner_score: Option<f64>,
36    #[serde(skip_serializing_if = "Option::is_none", default)]
37    pub loser_score: Option<f64>,
38    /// Per-component score breakdown for the formula winner (Slice 2f).
39    #[serde(skip_serializing_if = "Option::is_none", default)]
40    pub winner_components: Option<ScoreComponents>,
41    /// Per-component score breakdown for the formula loser (Slice 2f).
42    #[serde(skip_serializing_if = "Option::is_none", default)]
43    pub loser_components: Option<ScoreComponents>,
44    /// Full canonical Claim JSON for the loser (raw string, NOT parsed).
45    /// Only populated on `supersede_existing` rows.
46    #[serde(skip_serializing_if = "Option::is_none", default)]
47    pub loser_claim_json: Option<String>,
48    /// "active" or "shadow".
49    pub mode: String,
50}
51
52/// Walk `decisions.jsonl` content in reverse and return the `loser_claim_json`
53/// for the most recent `supersede_existing` decision where
54/// `existing_claim_id == fact_id`.
55///
56/// Returns `None` if no matching row is found or if `loser_claim_json` is absent.
57pub fn find_loser_claim_in_decision_log(fact_id: &str, log_content: &str) -> Option<String> {
58    if log_content.is_empty() {
59        return None;
60    }
61    let lines: Vec<&str> = log_content.split('\n').filter(|l| !l.is_empty()).collect();
62    for i in (0..lines.len()).rev() {
63        let entry: DecisionLogEntry = match serde_json::from_str(lines[i]) {
64            Ok(e) => e,
65            Err(_) => continue,
66        };
67        if entry.action != "supersede_existing" {
68            continue;
69        }
70        if entry.existing_claim_id != fact_id {
71            continue;
72        }
73        match &entry.loser_claim_json {
74            Some(json) if !json.is_empty() => return Some(json.clone()),
75            _ => continue,
76        }
77    }
78    None
79}
80
81/// Walk `decisions.jsonl` content in reverse and return the first
82/// `supersede_existing` decision where the fact appears as winner or loser.
83///
84/// - `role == "loser"`: matches `existing_claim_id == fact_id`
85/// - `role == "winner"`: matches `new_claim_id == fact_id`
86///
87/// Only matches rows that have both `winner_components` and `loser_components`
88/// populated (Slice 2f requirement for feedback reconstruction).
89///
90/// Returns the JSON-serialized `DecisionLogEntry`, or `None`.
91pub fn find_decision_for_pin(fact_id: &str, role: &str, log_content: &str) -> Option<String> {
92    if log_content.is_empty() {
93        return None;
94    }
95    let lines: Vec<&str> = log_content.split('\n').filter(|l| !l.is_empty()).collect();
96    for i in (0..lines.len()).rev() {
97        let entry: DecisionLogEntry = match serde_json::from_str(lines[i]) {
98            Ok(e) => e,
99            Err(_) => continue,
100        };
101        if entry.action != "supersede_existing" {
102            continue;
103        }
104        if entry.winner_components.is_none() || entry.loser_components.is_none() {
105            continue;
106        }
107        let matches = match role {
108            "loser" => entry.existing_claim_id == fact_id,
109            "winner" => entry.new_claim_id == fact_id,
110            _ => false,
111        };
112        if matches {
113            return serde_json::to_string(&entry).ok();
114        }
115    }
116    None
117}
118
119/// Build a `FeedbackEntry` JSON from a decision-log entry JSON and a pin action.
120///
121/// `action` is either `"pin_loser"` or `"unpin_winner"`.
122///
123/// For `supersede_existing`, the formula's winner is always the new claim
124/// (`new_claim_id`) and the loser is the existing claim.
125///
126/// Returns `None` if the decision is missing component scores or the action is
127/// unrecognized.
128pub fn build_feedback_from_decision(
129    decision_json: &str,
130    action: &str,
131    now_unix: i64,
132) -> Option<String> {
133    let decision: DecisionLogEntry = serde_json::from_str(decision_json).ok()?;
134    let winner_components = decision.winner_components?;
135    let loser_components = decision.loser_components?;
136
137    let user_decision = match action {
138        "pin_loser" => UserDecision::PinA,
139        "unpin_winner" => UserDecision::PinB,
140        _ => return None,
141    };
142
143    let entry = FeedbackEntry {
144        ts: now_unix,
145        claim_a_id: decision.existing_claim_id,
146        claim_b_id: decision.new_claim_id,
147        formula_winner: FormulaWinner::B,
148        user_decision,
149        winner_components,
150        loser_components,
151    };
152
153    serde_json::to_string(&entry).ok()
154}
155
156/// Append one decision-log entry (as JSON string) to existing JSONL content.
157///
158/// Handles empty content, content with trailing newline, and content without.
159pub fn append_decision_entry(existing_content: &str, entry_json: &str) -> String {
160    let mut out = String::with_capacity(existing_content.len() + entry_json.len() + 2);
161    if existing_content.is_empty() {
162        out.push_str(entry_json);
163        out.push('\n');
164    } else if existing_content.ends_with('\n') {
165        out.push_str(existing_content);
166        out.push_str(entry_json);
167        out.push('\n');
168    } else {
169        out.push_str(existing_content);
170        out.push('\n');
171        out.push_str(entry_json);
172        out.push('\n');
173    }
174    out
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::contradiction::ScoreComponents;
181
182    fn sample_components() -> ScoreComponents {
183        ScoreComponents {
184            confidence: 0.8,
185            corroboration: 1.732,
186            recency: 0.333,
187            validation: 0.7,
188            weighted_total: 0.7331,
189        }
190    }
191
192    fn sample_loser_components() -> ScoreComponents {
193        ScoreComponents {
194            confidence: 0.6,
195            corroboration: 1.0,
196            recency: 0.125,
197            validation: 0.5,
198            weighted_total: 0.4025,
199        }
200    }
201
202    fn sample_entry() -> DecisionLogEntry {
203        DecisionLogEntry {
204            ts: 1_776_384_000,
205            entity_id: "ent123".to_string(),
206            new_claim_id: "0xnew".to_string(),
207            existing_claim_id: "0xold".to_string(),
208            similarity: 0.72,
209            action: "supersede_existing".to_string(),
210            reason: Some("new_wins".to_string()),
211            winner_score: Some(0.7331),
212            loser_score: Some(0.4025),
213            winner_components: Some(sample_components()),
214            loser_components: Some(sample_loser_components()),
215            loser_claim_json: Some(r#"{"t":"old claim","c":"fact","cf":0.9,"i":5,"sa":"oc"}"#.to_string()),
216            mode: "active".to_string(),
217        }
218    }
219
220    fn sample_entry_no_components() -> DecisionLogEntry {
221        DecisionLogEntry {
222            ts: 1_776_384_000,
223            entity_id: "ent123".to_string(),
224            new_claim_id: "0xnew".to_string(),
225            existing_claim_id: "0xold2".to_string(),
226            similarity: 0.65,
227            action: "supersede_existing".to_string(),
228            reason: Some("new_wins".to_string()),
229            winner_score: None,
230            loser_score: None,
231            winner_components: None,
232            loser_components: None,
233            loser_claim_json: None,
234            mode: "active".to_string(),
235        }
236    }
237
238    // === DecisionLogEntry serde ===
239
240    #[test]
241    fn test_decision_log_entry_round_trip() {
242        let entry = sample_entry();
243        let json = serde_json::to_string(&entry).unwrap();
244        let back: DecisionLogEntry = serde_json::from_str(&json).unwrap();
245        assert_eq!(entry, back);
246    }
247
248    #[test]
249    fn test_decision_log_entry_omits_none_fields() {
250        let entry = sample_entry_no_components();
251        let json = serde_json::to_string(&entry).unwrap();
252        assert!(!json.contains("winner_components"));
253        assert!(!json.contains("loser_components"));
254        assert!(!json.contains("loser_claim_json"));
255    }
256
257    #[test]
258    fn test_decision_log_entry_snake_case_keys() {
259        let entry = sample_entry();
260        let json = serde_json::to_string(&entry).unwrap();
261        assert!(json.contains("\"entity_id\""));
262        assert!(json.contains("\"new_claim_id\""));
263        assert!(json.contains("\"existing_claim_id\""));
264        assert!(json.contains("\"winner_score\""));
265        assert!(json.contains("\"loser_score\""));
266        assert!(json.contains("\"loser_claim_json\""));
267    }
268
269    // === find_loser_claim_in_decision_log ===
270
271    #[test]
272    fn test_find_loser_empty_log() {
273        assert!(find_loser_claim_in_decision_log("0xold", "").is_none());
274    }
275
276    #[test]
277    fn test_find_loser_no_match() {
278        let entry = sample_entry();
279        let line = serde_json::to_string(&entry).unwrap();
280        let content = format!("{}\n", line);
281        assert!(find_loser_claim_in_decision_log("0xnonexistent", &content).is_none());
282    }
283
284    #[test]
285    fn test_find_loser_matches_correct_entry() {
286        let entry = sample_entry();
287        let line = serde_json::to_string(&entry).unwrap();
288        let content = format!("{}\n", line);
289        let result = find_loser_claim_in_decision_log("0xold", &content);
290        assert!(result.is_some());
291        assert!(result.unwrap().contains("old claim"));
292    }
293
294    #[test]
295    fn test_find_loser_walks_backward_returns_most_recent() {
296        let mut entry1 = sample_entry();
297        entry1.loser_claim_json = Some(r#"{"t":"first version"}"#.to_string());
298        entry1.ts = 1_000;
299        let mut entry2 = sample_entry();
300        entry2.loser_claim_json = Some(r#"{"t":"second version"}"#.to_string());
301        entry2.ts = 2_000;
302        let content = format!(
303            "{}\n{}\n",
304            serde_json::to_string(&entry1).unwrap(),
305            serde_json::to_string(&entry2).unwrap()
306        );
307        let result = find_loser_claim_in_decision_log("0xold", &content).unwrap();
308        assert!(result.contains("second version"));
309    }
310
311    #[test]
312    fn test_find_loser_skips_non_supersede_actions() {
313        let mut entry = sample_entry();
314        entry.action = "tie_leave_both".to_string();
315        let content = format!("{}\n", serde_json::to_string(&entry).unwrap());
316        assert!(find_loser_claim_in_decision_log("0xold", &content).is_none());
317    }
318
319    #[test]
320    fn test_find_loser_skips_empty_loser_json() {
321        let mut entry = sample_entry();
322        entry.loser_claim_json = Some("".to_string());
323        let content = format!("{}\n", serde_json::to_string(&entry).unwrap());
324        assert!(find_loser_claim_in_decision_log("0xold", &content).is_none());
325    }
326
327    #[test]
328    fn test_find_loser_skips_malformed_lines() {
329        let entry = sample_entry();
330        let content = format!(
331            "not valid json\n{}\n",
332            serde_json::to_string(&entry).unwrap()
333        );
334        let result = find_loser_claim_in_decision_log("0xold", &content);
335        assert!(result.is_some());
336    }
337
338    // === find_decision_for_pin ===
339
340    #[test]
341    fn test_find_decision_for_pin_loser_role() {
342        let entry = sample_entry();
343        let content = format!("{}\n", serde_json::to_string(&entry).unwrap());
344        let result = find_decision_for_pin("0xold", "loser", &content);
345        assert!(result.is_some());
346        let parsed: DecisionLogEntry = serde_json::from_str(&result.unwrap()).unwrap();
347        assert_eq!(parsed.existing_claim_id, "0xold");
348    }
349
350    #[test]
351    fn test_find_decision_for_pin_winner_role() {
352        let entry = sample_entry();
353        let content = format!("{}\n", serde_json::to_string(&entry).unwrap());
354        let result = find_decision_for_pin("0xnew", "winner", &content);
355        assert!(result.is_some());
356        let parsed: DecisionLogEntry = serde_json::from_str(&result.unwrap()).unwrap();
357        assert_eq!(parsed.new_claim_id, "0xnew");
358    }
359
360    #[test]
361    fn test_find_decision_for_pin_no_match() {
362        let entry = sample_entry();
363        let content = format!("{}\n", serde_json::to_string(&entry).unwrap());
364        assert!(find_decision_for_pin("0xunknown", "loser", &content).is_none());
365    }
366
367    #[test]
368    fn test_find_decision_for_pin_skips_no_components() {
369        let entry = sample_entry_no_components();
370        let content = format!("{}\n", serde_json::to_string(&entry).unwrap());
371        assert!(find_decision_for_pin("0xold2", "loser", &content).is_none());
372    }
373
374    #[test]
375    fn test_find_decision_for_pin_empty_log() {
376        assert!(find_decision_for_pin("0xold", "loser", "").is_none());
377    }
378
379    #[test]
380    fn test_find_decision_for_pin_invalid_role() {
381        let entry = sample_entry();
382        let content = format!("{}\n", serde_json::to_string(&entry).unwrap());
383        assert!(find_decision_for_pin("0xold", "invalid_role", &content).is_none());
384    }
385
386    // === build_feedback_from_decision ===
387
388    #[test]
389    fn test_build_feedback_pin_loser() {
390        let entry = sample_entry();
391        let decision_json = serde_json::to_string(&entry).unwrap();
392        let result = build_feedback_from_decision(&decision_json, "pin_loser", 1_776_500_000);
393        assert!(result.is_some());
394        let feedback: FeedbackEntry = serde_json::from_str(&result.unwrap()).unwrap();
395        assert_eq!(feedback.ts, 1_776_500_000);
396        assert_eq!(feedback.claim_a_id, "0xold"); // existing = loser
397        assert_eq!(feedback.claim_b_id, "0xnew"); // new = winner
398        assert_eq!(feedback.formula_winner, FormulaWinner::B);
399        assert_eq!(feedback.user_decision, UserDecision::PinA);
400    }
401
402    #[test]
403    fn test_build_feedback_unpin_winner() {
404        let entry = sample_entry();
405        let decision_json = serde_json::to_string(&entry).unwrap();
406        let result = build_feedback_from_decision(&decision_json, "unpin_winner", 1_776_500_000);
407        assert!(result.is_some());
408        let feedback: FeedbackEntry = serde_json::from_str(&result.unwrap()).unwrap();
409        assert_eq!(feedback.user_decision, UserDecision::PinB);
410    }
411
412    #[test]
413    fn test_build_feedback_missing_components_returns_none() {
414        let entry = sample_entry_no_components();
415        let decision_json = serde_json::to_string(&entry).unwrap();
416        assert!(build_feedback_from_decision(&decision_json, "pin_loser", 1_776_500_000).is_none());
417    }
418
419    #[test]
420    fn test_build_feedback_invalid_action_returns_none() {
421        let entry = sample_entry();
422        let decision_json = serde_json::to_string(&entry).unwrap();
423        assert!(build_feedback_from_decision(&decision_json, "bad_action", 1_776_500_000).is_none());
424    }
425
426    #[test]
427    fn test_build_feedback_invalid_json_returns_none() {
428        assert!(build_feedback_from_decision("not json", "pin_loser", 1_776_500_000).is_none());
429    }
430
431    #[test]
432    fn test_build_feedback_round_trip() {
433        let entry = sample_entry();
434        let decision_json = serde_json::to_string(&entry).unwrap();
435        let feedback_json =
436            build_feedback_from_decision(&decision_json, "pin_loser", 1_776_500_000).unwrap();
437        let feedback: FeedbackEntry = serde_json::from_str(&feedback_json).unwrap();
438        // Verify components pass through
439        assert_eq!(feedback.winner_components, sample_components());
440        assert_eq!(feedback.loser_components, sample_loser_components());
441    }
442
443    // === append_decision_entry ===
444
445    #[test]
446    fn test_append_to_empty() {
447        let entry = sample_entry();
448        let json = serde_json::to_string(&entry).unwrap();
449        let out = append_decision_entry("", &json);
450        assert!(out.ends_with('\n'));
451        assert_eq!(out.matches('\n').count(), 1);
452    }
453
454    #[test]
455    fn test_append_after_existing_with_newline() {
456        let entry = sample_entry();
457        let json = serde_json::to_string(&entry).unwrap();
458        let first = append_decision_entry("", &json);
459        let second = append_decision_entry(&first, &json);
460        assert!(second.ends_with('\n'));
461        assert_eq!(second.matches('\n').count(), 2);
462    }
463
464    #[test]
465    fn test_append_after_existing_without_newline() {
466        let entry = sample_entry();
467        let json = serde_json::to_string(&entry).unwrap();
468        let out = append_decision_entry(&json, &json);
469        assert!(out.ends_with('\n'));
470        assert_eq!(out.matches('\n').count(), 2);
471    }
472
473    // === Constants ===
474
475    #[test]
476    fn test_constants() {
477        assert_eq!(DECISION_LOG_MAX_LINES, 10_000);
478        assert_eq!(CONTRADICTION_CANDIDATE_CAP, 20);
479    }
480}