1use crate::contradiction::ScoreComponents;
11use crate::feedback_log::{FeedbackEntry, FormulaWinner, UserDecision};
12use serde::{Deserialize, Serialize};
13
14pub const DECISION_LOG_MAX_LINES: usize = 10_000;
16
17pub const CONTRADICTION_CANDIDATE_CAP: usize = 20;
19
20#[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 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 #[serde(skip_serializing_if = "Option::is_none", default)]
40 pub winner_components: Option<ScoreComponents>,
41 #[serde(skip_serializing_if = "Option::is_none", default)]
43 pub loser_components: Option<ScoreComponents>,
44 #[serde(skip_serializing_if = "Option::is_none", default)]
47 pub loser_claim_json: Option<String>,
48 pub mode: String,
50}
51
52pub 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
81pub 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
119pub 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
156pub 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 #[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 #[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 #[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 #[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"); assert_eq!(feedback.claim_b_id, "0xnew"); 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 assert_eq!(feedback.winner_components, sample_components());
440 assert_eq!(feedback.loser_components, sample_loser_components());
441 }
442
443 #[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 #[test]
476 fn test_constants() {
477 assert_eq!(DECISION_LOG_MAX_LINES, 10_000);
478 assert_eq!(CONTRADICTION_CANDIDATE_CAP, 20);
479 }
480}