1use crate::claims::{deterministic_entity_id, Claim};
4use crate::reranker::cosine_similarity_f32;
5use chrono::DateTime;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10pub struct ResolutionWeights {
11 pub confidence: f64,
12 pub corroboration: f64,
13 pub recency: f64,
14 pub validation: f64,
15}
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct Contradiction {
20 pub claim_a_id: String,
21 pub claim_b_id: String,
22 pub entity_id: String,
23 pub similarity: f64,
24}
25
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct ScoreComponents {
29 pub confidence: f64,
30 pub corroboration: f64,
31 pub recency: f64,
32 pub validation: f64,
33 pub weighted_total: f64,
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct ResolutionOutcome {
39 pub winner_id: String,
40 pub loser_id: String,
41 pub winner_score: f64,
42 pub loser_score: f64,
43 pub score_delta: f64,
44 pub winner_components: ScoreComponents,
45 pub loser_components: ScoreComponents,
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum UserPinned {
52 Loser,
54 Both,
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct Counterexample {
61 pub formula_winner: ScoreComponents,
62 pub formula_loser: ScoreComponents,
63 pub user_pinned: UserPinned,
64}
65
66pub const DEFAULT_LOWER_THRESHOLD: f64 = 0.3;
68
69pub const DEFAULT_UPPER_THRESHOLD: f64 = 0.85;
71
72pub const FEEDBACK_STEP_SIZE: f64 = 0.02;
74
75pub const WEIGHT_MIN: f64 = 0.05;
77
78pub const WEIGHT_MAX: f64 = 0.60;
80
81pub const WEIGHT_SUM_MIN: f64 = 0.9;
83
84pub const WEIGHT_SUM_MAX: f64 = 1.1;
86
87pub fn default_weights() -> ResolutionWeights {
89 ResolutionWeights {
90 confidence: 0.25,
91 corroboration: 0.15,
92 recency: 0.40,
93 validation: 0.20,
94 }
95}
96
97fn parse_iso_to_unix(s: &str) -> Option<i64> {
98 DateTime::parse_from_rfc3339(s).ok().map(|dt| dt.timestamp())
99}
100
101fn days_since(extracted_at: Option<&str>, now_unix: i64) -> f64 {
102 match extracted_at.and_then(parse_iso_to_unix) {
103 Some(ts) => {
104 let delta = (now_unix - ts) as f64;
105 (delta / 86400.0).max(0.0)
106 }
107 None => 10000.0,
108 }
109}
110
111fn recency_weight(extracted_at: Option<&str>, now_unix: i64) -> f64 {
112 let days = days_since(extracted_at, now_unix);
113 1.0 / (1.0 + days / 30.0)
114}
115
116fn validation_component(source_agent: &str) -> f64 {
117 if source_agent == "totalreclaw_remember" {
118 1.0
119 } else if source_agent.starts_with("openclaw-wiki-compile") {
120 0.95
121 } else {
122 0.7
123 }
124}
125
126fn corroboration_component(corroboration_count: u32) -> f64 {
127 let n = corroboration_count.max(1) as f64;
128 n.sqrt().min(3.0)
129}
130
131pub fn compute_score_components(
133 claim: &Claim,
134 now_unix_seconds: i64,
135 weights: &ResolutionWeights,
136) -> ScoreComponents {
137 let confidence = claim.confidence.clamp(0.0, 1.0);
138 let corroboration = corroboration_component(claim.corroboration_count);
139 let recency = recency_weight(claim.extracted_at.as_deref(), now_unix_seconds);
140 let validation = validation_component(&claim.source_agent);
141 let weighted_total = confidence * weights.confidence
142 + corroboration * weights.corroboration
143 + recency * weights.recency
144 + validation * weights.validation;
145 ScoreComponents {
146 confidence,
147 corroboration,
148 recency,
149 validation,
150 weighted_total,
151 }
152}
153
154pub fn resolve_pair(
157 claim_a: &Claim,
158 claim_a_id: &str,
159 claim_b: &Claim,
160 claim_b_id: &str,
161 now_unix_seconds: i64,
162 weights: &ResolutionWeights,
163) -> ResolutionOutcome {
164 let a = compute_score_components(claim_a, now_unix_seconds, weights);
165 let b = compute_score_components(claim_b, now_unix_seconds, weights);
166 if a.weighted_total >= b.weighted_total {
167 let score_delta = a.weighted_total - b.weighted_total;
168 ResolutionOutcome {
169 winner_id: claim_a_id.to_string(),
170 loser_id: claim_b_id.to_string(),
171 winner_score: a.weighted_total,
172 loser_score: b.weighted_total,
173 score_delta,
174 winner_components: a,
175 loser_components: b,
176 }
177 } else {
178 let score_delta = b.weighted_total - a.weighted_total;
179 ResolutionOutcome {
180 winner_id: claim_b_id.to_string(),
181 loser_id: claim_a_id.to_string(),
182 winner_score: b.weighted_total,
183 loser_score: a.weighted_total,
184 score_delta,
185 winner_components: b,
186 loser_components: a,
187 }
188 }
189}
190
191pub fn detect_contradictions(
200 new_claim: &Claim,
201 new_claim_id: &str,
202 new_embedding: &[f32],
203 existing: &[(Claim, String, Vec<f32>)],
204 lower_threshold: f64,
205 upper_threshold: f64,
206) -> Vec<Contradiction> {
207 if new_claim.entities.is_empty() {
208 return Vec::new();
209 }
210
211 let new_entity_ids: Vec<String> = new_claim
212 .entities
213 .iter()
214 .map(|e| deterministic_entity_id(&e.name))
215 .collect();
216
217 let mut out: Vec<Contradiction> = Vec::new();
218
219 for (existing_claim, existing_id, existing_emb) in existing.iter() {
220 if existing_emb.is_empty() {
221 continue;
222 }
223 if existing_id == new_claim_id {
224 continue;
225 }
226 let existing_entity_ids: Vec<String> = existing_claim
227 .entities
228 .iter()
229 .map(|e| deterministic_entity_id(&e.name))
230 .collect();
231
232 let shared_entity = new_entity_ids
233 .iter()
234 .find(|id| existing_entity_ids.iter().any(|eid| eid == *id));
235
236 let Some(entity_id) = shared_entity else {
237 continue;
238 };
239
240 let sim = cosine_similarity_f32(new_embedding, existing_emb);
241 if sim >= lower_threshold && sim < upper_threshold {
242 out.push(Contradiction {
243 claim_a_id: new_claim_id.to_string(),
244 claim_b_id: existing_id.clone(),
245 entity_id: entity_id.clone(),
246 similarity: sim,
247 });
248 }
249 }
250
251 out
252}
253
254pub fn resolve_with_candidates(
272 new_claim: &Claim,
273 new_claim_id: &str,
274 new_embedding: &[f32],
275 candidates: &[(Claim, String, Vec<f32>)],
276 weights: &ResolutionWeights,
277 threshold_lower: f64,
278 threshold_upper: f64,
279 now_unix_seconds: i64,
280 tie_zone_tolerance: f64,
281) -> Vec<crate::claims::ResolutionAction> {
282 use crate::claims::{is_pinned_claim, ResolutionAction, SkipReason};
283
284 if candidates.is_empty() || new_embedding.is_empty() {
285 return Vec::new();
286 }
287
288 let contradictions = detect_contradictions(
289 new_claim,
290 new_claim_id,
291 new_embedding,
292 candidates,
293 threshold_lower,
294 threshold_upper,
295 );
296
297 if contradictions.is_empty() {
298 return Vec::new();
299 }
300
301 let by_id: std::collections::HashMap<&str, &(Claim, String, Vec<f32>)> = candidates
303 .iter()
304 .map(|c| (c.1.as_str(), c))
305 .collect();
306
307 let mut actions: Vec<ResolutionAction> = Vec::new();
308
309 for contradiction in &contradictions {
310 let Some(existing_tuple) = by_id.get(contradiction.claim_b_id.as_str()) else {
311 continue;
312 };
313 let existing_claim = &existing_tuple.0;
314 let existing_id = &existing_tuple.1;
315
316 if is_pinned_claim(existing_claim) {
318 actions.push(ResolutionAction::SkipNew {
319 reason: SkipReason::ExistingPinned,
320 existing_id: existing_id.clone(),
321 new_id: new_claim_id.to_string(),
322 entity_id: Some(contradiction.entity_id.clone()),
323 similarity: Some(contradiction.similarity),
324 winner_score: None,
325 loser_score: None,
326 winner_components: None,
327 loser_components: None,
328 });
329 continue;
330 }
331
332 let outcome = resolve_pair(
334 new_claim,
335 new_claim_id,
336 existing_claim,
337 existing_id,
338 now_unix_seconds,
339 weights,
340 );
341
342 if outcome.winner_id == new_claim_id {
343 if outcome.score_delta.abs() < tie_zone_tolerance {
345 actions.push(ResolutionAction::TieLeaveBoth {
346 existing_id: existing_id.clone(),
347 new_id: new_claim_id.to_string(),
348 similarity: contradiction.similarity,
349 score_gap: outcome.score_delta,
350 entity_id: Some(contradiction.entity_id.clone()),
351 winner_score: Some(outcome.winner_score),
352 loser_score: Some(outcome.loser_score),
353 winner_components: Some(outcome.winner_components),
354 loser_components: Some(outcome.loser_components),
355 });
356 } else {
357 actions.push(ResolutionAction::SupersedeExisting {
358 existing_id: existing_id.clone(),
359 new_id: new_claim_id.to_string(),
360 similarity: contradiction.similarity,
361 score_gap: outcome.score_delta,
362 entity_id: Some(contradiction.entity_id.clone()),
363 winner_score: Some(outcome.winner_score),
364 loser_score: Some(outcome.loser_score),
365 winner_components: Some(outcome.winner_components),
366 loser_components: Some(outcome.loser_components),
367 });
368 }
369 } else {
370 actions.push(ResolutionAction::SkipNew {
372 reason: SkipReason::ExistingWins,
373 existing_id: existing_id.clone(),
374 new_id: new_claim_id.to_string(),
375 entity_id: Some(contradiction.entity_id.clone()),
376 similarity: Some(contradiction.similarity),
377 winner_score: Some(outcome.winner_score),
378 loser_score: Some(outcome.loser_score),
379 winner_components: Some(outcome.winner_components),
380 loser_components: Some(outcome.loser_components),
381 });
382 }
383 }
384
385 actions
386}
387
388pub fn build_decision_log_entries(
394 actions: &[crate::claims::ResolutionAction],
395 _new_claim_json: &str,
396 existing_claims_json: &std::collections::HashMap<String, String>,
397 mode: &str,
398 now_unix: i64,
399) -> Vec<crate::decision_log::DecisionLogEntry> {
400 use crate::claims::ResolutionAction;
401 use crate::decision_log::DecisionLogEntry;
402
403 let mut entries = Vec::new();
404
405 for action in actions {
406 match action {
407 ResolutionAction::SupersedeExisting {
408 existing_id,
409 new_id,
410 similarity,
411 entity_id,
412 winner_score,
413 loser_score,
414 winner_components,
415 loser_components,
416 ..
417 } => {
418 let loser_json = existing_claims_json.get(existing_id).cloned();
419 entries.push(DecisionLogEntry {
420 ts: now_unix,
421 entity_id: entity_id.clone().unwrap_or_default(),
422 new_claim_id: new_id.clone(),
423 existing_claim_id: existing_id.clone(),
424 similarity: *similarity,
425 action: if mode == "shadow" {
426 "shadow".to_string()
427 } else {
428 "supersede_existing".to_string()
429 },
430 reason: Some("new_wins".to_string()),
431 winner_score: *winner_score,
432 loser_score: *loser_score,
433 winner_components: winner_components.clone(),
434 loser_components: loser_components.clone(),
435 loser_claim_json: loser_json,
436 mode: mode.to_string(),
437 });
438 }
439 ResolutionAction::SkipNew {
440 reason,
441 existing_id,
442 new_id,
443 entity_id,
444 similarity,
445 winner_score,
446 loser_score,
447 winner_components,
448 loser_components,
449 } => {
450 entries.push(DecisionLogEntry {
451 ts: now_unix,
452 entity_id: entity_id.clone().unwrap_or_default(),
453 new_claim_id: new_id.clone(),
454 existing_claim_id: existing_id.clone(),
455 similarity: similarity.unwrap_or(0.0),
456 action: if mode == "shadow" {
457 "shadow".to_string()
458 } else {
459 "skip_new".to_string()
460 },
461 reason: Some(serde_json::to_value(reason)
462 .ok()
463 .and_then(|v| v.as_str().map(|s| s.to_string()))
464 .unwrap_or_else(|| format!("{:?}", reason).to_lowercase())),
465 winner_score: *winner_score,
466 loser_score: *loser_score,
467 winner_components: winner_components.clone(),
468 loser_components: loser_components.clone(),
469 loser_claim_json: None,
470 mode: mode.to_string(),
471 });
472 }
473 ResolutionAction::TieLeaveBoth {
474 existing_id,
475 new_id,
476 similarity,
477 entity_id,
478 winner_score,
479 loser_score,
480 winner_components,
481 loser_components,
482 ..
483 } => {
484 entries.push(DecisionLogEntry {
485 ts: now_unix,
486 entity_id: entity_id.clone().unwrap_or_default(),
487 new_claim_id: new_id.clone(),
488 existing_claim_id: existing_id.clone(),
489 similarity: *similarity,
490 action: "tie_leave_both".to_string(),
491 reason: Some("tie_below_tolerance".to_string()),
492 winner_score: *winner_score,
493 loser_score: *loser_score,
494 winner_components: winner_components.clone(),
495 loser_components: loser_components.clone(),
496 loser_claim_json: None,
497 mode: mode.to_string(),
498 });
499 }
500 ResolutionAction::NoContradiction => {
501 }
503 }
504 }
505
506 entries
507}
508
509pub fn filter_shadow_mode(
515 actions: Vec<crate::claims::ResolutionAction>,
516 mode: &str,
517) -> Vec<crate::claims::ResolutionAction> {
518 use crate::claims::ResolutionAction;
519 match mode {
520 "active" => actions
521 .into_iter()
522 .filter(|a| !matches!(a, ResolutionAction::TieLeaveBoth { .. }))
523 .collect(),
524 _ => Vec::new(),
525 }
526}
527
528pub fn apply_feedback(
533 weights: &ResolutionWeights,
534 counterexample: &Counterexample,
535) -> ResolutionWeights {
536 if matches!(counterexample.user_pinned, UserPinned::Both) {
537 return weights.clone();
538 }
539
540 let winner = &counterexample.formula_winner;
541 let loser = &counterexample.formula_loser;
542
543 let d_conf = (loser.confidence - winner.confidence).clamp(-1.0, 1.0);
545 let d_corr = (loser.corroboration - winner.corroboration).clamp(-1.0, 1.0);
546 let d_rec = (loser.recency - winner.recency).clamp(-1.0, 1.0);
547 let d_val = (loser.validation - winner.validation).clamp(-1.0, 1.0);
548
549 let mut new = ResolutionWeights {
550 confidence: weights.confidence + FEEDBACK_STEP_SIZE * d_conf,
551 corroboration: weights.corroboration + FEEDBACK_STEP_SIZE * d_corr,
552 recency: weights.recency + FEEDBACK_STEP_SIZE * d_rec,
553 validation: weights.validation + FEEDBACK_STEP_SIZE * d_val,
554 };
555
556 new.confidence = new.confidence.clamp(WEIGHT_MIN, WEIGHT_MAX);
558 new.corroboration = new.corroboration.clamp(WEIGHT_MIN, WEIGHT_MAX);
559 new.recency = new.recency.clamp(WEIGHT_MIN, WEIGHT_MAX);
560 new.validation = new.validation.clamp(WEIGHT_MIN, WEIGHT_MAX);
561
562 let sum = new.confidence + new.corroboration + new.recency + new.validation;
564 if sum < WEIGHT_SUM_MIN || sum > WEIGHT_SUM_MAX {
565 let scale = 1.0 / sum;
566 new.confidence = (new.confidence * scale).clamp(WEIGHT_MIN, WEIGHT_MAX);
567 new.corroboration = (new.corroboration * scale).clamp(WEIGHT_MIN, WEIGHT_MAX);
568 new.recency = (new.recency * scale).clamp(WEIGHT_MIN, WEIGHT_MAX);
569 new.validation = (new.validation * scale).clamp(WEIGHT_MIN, WEIGHT_MAX);
570 }
571
572 new
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use crate::claims::{ClaimCategory, ClaimStatus, EntityRef, EntityType};
579
580 fn make_claim(
581 text: &str,
582 confidence: f64,
583 corroboration: u32,
584 source: &str,
585 extracted_at: Option<&str>,
586 entities: Vec<&str>,
587 ) -> Claim {
588 Claim {
589 text: text.to_string(),
590 category: ClaimCategory::Fact,
591 confidence,
592 importance: 5,
593 corroboration_count: corroboration,
594 source_agent: source.to_string(),
595 source_conversation: None,
596 extracted_at: extracted_at.map(|s| s.to_string()),
597 entities: entities
598 .iter()
599 .map(|n| EntityRef {
600 name: n.to_string(),
601 entity_type: EntityType::Tool,
602 role: None,
603 })
604 .collect(),
605 supersedes: None,
606 superseded_by: None,
607 valid_from: None,
608 status: ClaimStatus::Active,
609 }
610 }
611
612 const NOW: i64 = 1776211200;
614
615 fn iso_days_ago(days: i64) -> String {
616 let ts = NOW - days * 86400;
617 chrono::DateTime::<chrono::Utc>::from_timestamp(ts, 0)
618 .unwrap()
619 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
620 }
621
622 #[test]
625 fn test_default_weights_values() {
626 let w = default_weights();
627 assert_eq!(w.confidence, 0.25);
628 assert_eq!(w.corroboration, 0.15);
629 assert_eq!(w.recency, 0.40);
630 assert_eq!(w.validation, 0.20);
631 }
632
633 #[test]
634 fn test_default_weights_sum_to_one() {
635 let w = default_weights();
636 let sum = w.confidence + w.corroboration + w.recency + w.validation;
637 assert!((sum - 1.0).abs() < 1e-12);
638 }
639
640 #[test]
643 fn test_validation_explicit_remember() {
644 let c = make_claim("x", 0.8, 1, "totalreclaw_remember", None, vec![]);
645 let s = compute_score_components(&c, NOW, &default_weights());
646 assert_eq!(s.validation, 1.0);
647 }
648
649 #[test]
650 fn test_validation_wiki_compile_exact() {
651 let c = make_claim("x", 0.8, 1, "openclaw-wiki-compile", None, vec![]);
652 let s = compute_score_components(&c, NOW, &default_weights());
653 assert_eq!(s.validation, 0.95);
654 }
655
656 #[test]
657 fn test_validation_wiki_compile_prefix() {
658 let c = make_claim("x", 0.8, 1, "openclaw-wiki-compile-v2", None, vec![]);
659 let s = compute_score_components(&c, NOW, &default_weights());
660 assert_eq!(s.validation, 0.95);
661 }
662
663 #[test]
664 fn test_validation_other_source() {
665 let c = make_claim("x", 0.8, 1, "openclaw-plugin", None, vec![]);
666 let s = compute_score_components(&c, NOW, &default_weights());
667 assert_eq!(s.validation, 0.7);
668 }
669
670 #[test]
671 fn test_validation_unknown_source() {
672 let c = make_claim("x", 0.8, 1, "unknown", None, vec![]);
673 let s = compute_score_components(&c, NOW, &default_weights());
674 assert_eq!(s.validation, 0.7);
675 }
676
677 #[test]
680 fn test_corroboration_one() {
681 let c = make_claim("x", 0.8, 1, "oc", None, vec![]);
682 let s = compute_score_components(&c, NOW, &default_weights());
683 assert!((s.corroboration - 1.0).abs() < 1e-12);
684 }
685
686 #[test]
687 fn test_corroboration_nine() {
688 let c = make_claim("x", 0.8, 9, "oc", None, vec![]);
689 let s = compute_score_components(&c, NOW, &default_weights());
690 assert!((s.corroboration - 3.0).abs() < 1e-12);
691 }
692
693 #[test]
694 fn test_corroboration_capped_at_three() {
695 let c = make_claim("x", 0.8, 100, "oc", None, vec![]);
696 let s = compute_score_components(&c, NOW, &default_weights());
697 assert!((s.corroboration - 3.0).abs() < 1e-12);
698 }
699
700 #[test]
701 fn test_corroboration_four() {
702 let c = make_claim("x", 0.8, 4, "oc", None, vec![]);
703 let s = compute_score_components(&c, NOW, &default_weights());
704 assert!((s.corroboration - 2.0).abs() < 1e-12);
705 }
706
707 #[test]
708 fn test_corroboration_zero_treated_as_one() {
709 let c = make_claim("x", 0.8, 0, "oc", None, vec![]);
711 let s = compute_score_components(&c, NOW, &default_weights());
712 assert!((s.corroboration - 1.0).abs() < 1e-12);
713 }
714
715 #[test]
718 fn test_recency_two_days_ago() {
719 let c = make_claim("x", 0.8, 1, "oc", Some(&iso_days_ago(2)), vec![]);
720 let s = compute_score_components(&c, NOW, &default_weights());
721 assert!((s.recency - 0.9375).abs() < 1e-9);
723 }
724
725 #[test]
726 fn test_recency_thirty_days_ago() {
727 let c = make_claim("x", 0.8, 1, "oc", Some(&iso_days_ago(30)), vec![]);
728 let s = compute_score_components(&c, NOW, &default_weights());
729 assert!((s.recency - 0.5).abs() < 1e-9);
731 }
732
733 #[test]
734 fn test_recency_missing_timestamp() {
735 let c = make_claim("x", 0.8, 1, "oc", None, vec![]);
736 let s = compute_score_components(&c, NOW, &default_weights());
737 assert!((s.recency - 0.002994).abs() < 1e-5);
739 }
740
741 #[test]
742 fn test_recency_today() {
743 let c = make_claim("x", 0.8, 1, "oc", Some(&iso_days_ago(0)), vec![]);
744 let s = compute_score_components(&c, NOW, &default_weights());
745 assert!((s.recency - 1.0).abs() < 1e-9);
746 }
747
748 #[test]
749 fn test_recency_unparseable_string_treated_as_missing() {
750 let c = make_claim("x", 0.8, 1, "oc", Some("not-a-date"), vec![]);
751 let s = compute_score_components(&c, NOW, &default_weights());
752 assert!((s.recency - 0.002994).abs() < 1e-5);
753 }
754
755 #[test]
756 fn test_recency_future_timestamp_clamped_to_zero_days() {
757 let c = make_claim("x", 0.8, 1, "oc", Some(&iso_days_ago(-10)), vec![]);
758 let s = compute_score_components(&c, NOW, &default_weights());
759 assert!((s.recency - 1.0).abs() < 1e-9);
761 }
762
763 #[test]
766 fn test_confidence_clamped_high() {
767 let c = make_claim("x", 1.5, 1, "oc", None, vec![]);
768 let s = compute_score_components(&c, NOW, &default_weights());
769 assert_eq!(s.confidence, 1.0);
770 }
771
772 #[test]
773 fn test_confidence_clamped_low() {
774 let c = make_claim("x", -0.3, 1, "oc", None, vec![]);
775 let s = compute_score_components(&c, NOW, &default_weights());
776 assert_eq!(s.confidence, 0.0);
777 }
778
779 #[test]
780 fn test_confidence_passthrough() {
781 let c = make_claim("x", 0.82, 1, "oc", None, vec![]);
782 let s = compute_score_components(&c, NOW, &default_weights());
783 assert!((s.confidence - 0.82).abs() < 1e-12);
784 }
785
786 #[test]
789 fn test_weighted_total_formula() {
790 let c = make_claim("x", 0.9, 1, "totalreclaw_remember", Some(&iso_days_ago(0)), vec![]);
791 let s = compute_score_components(&c, NOW, &default_weights());
792 assert!((s.weighted_total - 0.975).abs() < 1e-9);
794 }
795
796 #[test]
797 fn test_weighted_total_custom_weights() {
798 let c = make_claim("x", 0.5, 1, "oc", Some(&iso_days_ago(0)), vec![]);
799 let w = ResolutionWeights {
800 confidence: 0.1,
801 corroboration: 0.1,
802 recency: 0.5,
803 validation: 0.3,
804 };
805 let s = compute_score_components(&c, NOW, &w);
806 assert!((s.weighted_total - 0.86).abs() < 1e-9);
808 }
809
810 #[test]
813 fn test_resolve_pair_vim_vs_vscode_defaults() {
814 let vim = make_claim("uses Vim", 0.8, 3, "oc", Some(&iso_days_ago(60)), vec!["editor"]);
819 let vscode = make_claim(
820 "uses VS Code",
821 0.9,
822 1,
823 "oc",
824 Some(&iso_days_ago(7)),
825 vec!["editor"],
826 );
827 let outcome = resolve_pair(&vim, "vim_id", &vscode, "vscode_id", NOW, &default_weights());
828 assert_eq!(outcome.winner_id, "vscode_id");
829 assert_eq!(outcome.loser_id, "vim_id");
830 assert!(outcome.winner_score > outcome.loser_score);
831 assert!(outcome.score_delta > 0.0);
832 }
833
834 #[test]
835 fn test_resolve_pair_components_populated() {
836 let a = make_claim("a", 0.9, 1, "oc", Some(&iso_days_ago(1)), vec![]);
837 let b = make_claim("b", 0.5, 1, "oc", Some(&iso_days_ago(100)), vec![]);
838 let outcome = resolve_pair(&a, "a", &b, "b", NOW, &default_weights());
839 assert!(outcome.winner_components.weighted_total > outcome.loser_components.weighted_total);
840 assert!(outcome.winner_components.weighted_total == outcome.winner_score);
841 assert!(outcome.loser_components.weighted_total == outcome.loser_score);
842 }
843
844 #[test]
845 fn test_resolve_pair_flipped_by_different_weights() {
846 let explicit_old = make_claim(
850 "old",
851 0.95,
852 1,
853 "totalreclaw_remember",
854 Some(&iso_days_ago(60)),
855 vec![],
856 );
857 let auto_new = make_claim("new", 0.7, 1, "oc", Some(&iso_days_ago(7)), vec![]);
858
859 let defaults = default_weights();
860 let outcome_default =
861 resolve_pair(&explicit_old, "old", &auto_new, "new", NOW, &defaults);
862 assert_eq!(outcome_default.winner_id, "new");
863
864 let validation_heavy = ResolutionWeights {
865 confidence: 0.10,
866 corroboration: 0.10,
867 recency: 0.20,
868 validation: 0.60,
869 };
870 let outcome_val =
871 resolve_pair(&explicit_old, "old", &auto_new, "new", NOW, &validation_heavy);
872 assert_eq!(outcome_val.winner_id, "old");
873 }
874
875 #[test]
876 fn test_resolve_pair_tie_favours_a() {
877 let a = make_claim("same", 0.8, 1, "oc", Some(&iso_days_ago(5)), vec![]);
879 let b = make_claim("same", 0.8, 1, "oc", Some(&iso_days_ago(5)), vec![]);
880 let outcome = resolve_pair(&a, "id_a", &b, "id_b", NOW, &default_weights());
881 assert_eq!(outcome.winner_id, "id_a");
882 assert_eq!(outcome.loser_id, "id_b");
883 assert!(outcome.score_delta.abs() < 1e-12);
884 }
885
886 #[test]
887 fn test_resolve_pair_ids_correct() {
888 let a = make_claim("a", 0.9, 1, "oc", Some(&iso_days_ago(1)), vec![]);
889 let b = make_claim("b", 0.5, 1, "oc", Some(&iso_days_ago(100)), vec![]);
890 let outcome = resolve_pair(&a, "alpha", &b, "beta", NOW, &default_weights());
891 assert_eq!(outcome.winner_id, "alpha");
892 assert_eq!(outcome.loser_id, "beta");
893 }
894
895 #[test]
896 fn test_resolve_pair_score_delta_nonnegative() {
897 let a = make_claim("a", 0.1, 1, "oc", Some(&iso_days_ago(365)), vec![]);
898 let b = make_claim("b", 0.9, 1, "totalreclaw_remember", Some(&iso_days_ago(1)), vec![]);
899 let outcome = resolve_pair(&a, "a", &b, "b", NOW, &default_weights());
900 assert!(outcome.score_delta >= 0.0);
901 assert_eq!(outcome.score_delta, outcome.winner_score - outcome.loser_score);
902 }
903
904 fn emb_along_axis(axis: usize, dim: usize) -> Vec<f32> {
907 let mut v = vec![0.0f32; dim];
908 v[axis] = 1.0;
909 v
910 }
911
912 fn emb_at_cosine(axis: usize, other_axis: usize, dim: usize, cos_target: f64) -> Vec<f32> {
915 let mut v = vec![0.0f32; dim];
916 let sin = (1.0 - cos_target * cos_target).sqrt();
917 v[axis] = cos_target as f32;
918 v[other_axis] = sin as f32;
919 v
920 }
921
922 #[test]
923 fn test_detect_empty_existing() {
924 let new_claim = make_claim("x", 0.8, 1, "oc", None, vec!["editor"]);
925 let emb = emb_along_axis(0, 8);
926 let out = detect_contradictions(&new_claim, "new_id", &emb, &[], 0.3, 0.85);
927 assert!(out.is_empty());
928 }
929
930 #[test]
931 fn test_detect_new_claim_no_entities() {
932 let new_claim = make_claim("x", 0.8, 1, "oc", None, vec![]);
933 let emb = emb_along_axis(0, 8);
934 let existing_claim = make_claim("y", 0.8, 1, "oc", None, vec!["editor"]);
935 let existing_emb = emb_at_cosine(0, 1, 8, 0.5);
936 let out = detect_contradictions(
937 &new_claim,
938 "new_id",
939 &emb,
940 &[(existing_claim, "exist".to_string(), existing_emb)],
941 0.3,
942 0.85,
943 );
944 assert!(out.is_empty());
945 }
946
947 #[test]
948 fn test_detect_single_contradiction_in_band() {
949 let new_claim = make_claim("x", 0.8, 1, "oc", None, vec!["editor"]);
950 let emb = emb_along_axis(0, 8);
951 let existing_claim = make_claim("y", 0.8, 1, "oc", None, vec!["editor"]);
952 let existing_emb = emb_at_cosine(0, 1, 8, 0.5);
953 let out = detect_contradictions(
954 &new_claim,
955 "new_id",
956 &emb,
957 &[(existing_claim, "exist".to_string(), existing_emb)],
958 0.3,
959 0.85,
960 );
961 assert_eq!(out.len(), 1);
962 assert_eq!(out[0].claim_a_id, "new_id");
963 assert_eq!(out[0].claim_b_id, "exist");
964 assert!((out[0].similarity - 0.5).abs() < 1e-6);
965 assert_eq!(out[0].entity_id, deterministic_entity_id("editor"));
966 }
967
968 #[test]
969 fn test_detect_above_upper_threshold_is_duplicate() {
970 let new_claim = make_claim("x", 0.8, 1, "oc", None, vec!["editor"]);
971 let emb = emb_along_axis(0, 8);
972 let existing_claim = make_claim("y", 0.8, 1, "oc", None, vec!["editor"]);
973 let existing_emb = emb_at_cosine(0, 1, 8, 0.9);
974 let out = detect_contradictions(
975 &new_claim,
976 "new_id",
977 &emb,
978 &[(existing_claim, "exist".to_string(), existing_emb)],
979 0.3,
980 0.85,
981 );
982 assert!(out.is_empty());
983 }
984
985 #[test]
986 fn test_detect_exactly_at_upper_threshold_is_duplicate() {
987 let new_claim = make_claim("x", 0.8, 1, "oc", None, vec!["editor"]);
989 let emb = emb_along_axis(0, 8);
990 let existing_claim = make_claim("y", 0.8, 1, "oc", None, vec!["editor"]);
991 let existing_emb = emb_at_cosine(0, 1, 8, 0.85);
992 let out = detect_contradictions(
993 &new_claim,
994 "new_id",
995 &emb,
996 &[(existing_claim, "exist".to_string(), existing_emb)],
997 0.3,
998 0.85,
999 );
1000 assert!(out.is_empty());
1001 }
1002
1003 #[test]
1004 fn test_detect_exactly_at_lower_threshold_is_contradiction() {
1005 let new_claim = make_claim("x", 0.8, 1, "oc", None, vec!["editor"]);
1007 let emb = emb_along_axis(0, 8);
1008 let existing_claim = make_claim("y", 0.8, 1, "oc", None, vec!["editor"]);
1009 let existing_emb = emb_at_cosine(0, 1, 8, 0.3);
1010 let out = detect_contradictions(
1011 &new_claim,
1012 "new_id",
1013 &emb,
1014 &[(existing_claim, "exist".to_string(), existing_emb)],
1015 0.3,
1016 0.85,
1017 );
1018 assert_eq!(out.len(), 1);
1019 }
1020
1021 #[test]
1022 fn test_detect_below_lower_threshold_unrelated() {
1023 let new_claim = make_claim("x", 0.8, 1, "oc", None, vec!["editor"]);
1024 let emb = emb_along_axis(0, 8);
1025 let existing_claim = make_claim("y", 0.8, 1, "oc", None, vec!["editor"]);
1026 let existing_emb = emb_at_cosine(0, 1, 8, 0.2);
1027 let out = detect_contradictions(
1028 &new_claim,
1029 "new_id",
1030 &emb,
1031 &[(existing_claim, "exist".to_string(), existing_emb)],
1032 0.3,
1033 0.85,
1034 );
1035 assert!(out.is_empty());
1036 }
1037
1038 #[test]
1039 fn test_detect_different_entities_no_contradiction() {
1040 let new_claim = make_claim("x", 0.8, 1, "oc", None, vec!["editor"]);
1041 let emb = emb_along_axis(0, 8);
1042 let existing_claim = make_claim("y", 0.8, 1, "oc", None, vec!["database"]);
1043 let existing_emb = emb_at_cosine(0, 1, 8, 0.5);
1044 let out = detect_contradictions(
1045 &new_claim,
1046 "new_id",
1047 &emb,
1048 &[(existing_claim, "exist".to_string(), existing_emb)],
1049 0.3,
1050 0.85,
1051 );
1052 assert!(out.is_empty());
1053 }
1054
1055 #[test]
1056 fn test_detect_skips_empty_embedding() {
1057 let new_claim = make_claim("x", 0.8, 1, "oc", None, vec!["editor"]);
1058 let emb = emb_along_axis(0, 8);
1059 let existing_claim = make_claim("y", 0.8, 1, "oc", None, vec!["editor"]);
1060 let out = detect_contradictions(
1061 &new_claim,
1062 "new_id",
1063 &emb,
1064 &[(existing_claim, "exist".to_string(), Vec::new())],
1065 0.3,
1066 0.85,
1067 );
1068 assert!(out.is_empty());
1069 }
1070
1071 #[test]
1072 fn test_detect_skips_self_by_id() {
1073 let new_claim = make_claim("x", 0.8, 1, "oc", None, vec!["editor"]);
1074 let emb = emb_along_axis(0, 8);
1075 let existing_claim = make_claim("x", 0.8, 1, "oc", None, vec!["editor"]);
1076 let existing_emb = emb_at_cosine(0, 1, 8, 0.5);
1077 let out = detect_contradictions(
1078 &new_claim,
1079 "same_id",
1080 &emb,
1081 &[(existing_claim, "same_id".to_string(), existing_emb)],
1082 0.3,
1083 0.85,
1084 );
1085 assert!(out.is_empty());
1086 }
1087
1088 #[test]
1089 fn test_detect_multiple_candidates_mixed() {
1090 let new_claim = make_claim("x", 0.8, 1, "oc", None, vec!["editor"]);
1091 let emb = emb_along_axis(0, 8);
1092
1093 let c_in_band = make_claim("a", 0.8, 1, "oc", None, vec!["editor"]);
1094 let e_in_band = emb_at_cosine(0, 1, 8, 0.5);
1095
1096 let c_duplicate = make_claim("b", 0.8, 1, "oc", None, vec!["editor"]);
1097 let e_duplicate = emb_at_cosine(0, 2, 8, 0.9);
1098
1099 let c_unrelated_entity = make_claim("c", 0.8, 1, "oc", None, vec!["database"]);
1100 let e_unrelated_entity = emb_at_cosine(0, 3, 8, 0.5);
1101
1102 let c_unrelated_low = make_claim("d", 0.8, 1, "oc", None, vec!["editor"]);
1103 let e_unrelated_low = emb_at_cosine(0, 4, 8, 0.1);
1104
1105 let c_in_band2 = make_claim("e", 0.8, 1, "oc", None, vec!["editor"]);
1106 let e_in_band2 = emb_at_cosine(0, 5, 8, 0.7);
1107
1108 let existing = vec![
1109 (c_in_band, "a".to_string(), e_in_band),
1110 (c_duplicate, "b".to_string(), e_duplicate),
1111 (c_unrelated_entity, "c".to_string(), e_unrelated_entity),
1112 (c_unrelated_low, "d".to_string(), e_unrelated_low),
1113 (c_in_band2, "e".to_string(), e_in_band2),
1114 ];
1115
1116 let out = detect_contradictions(&new_claim, "new_id", &emb, &existing, 0.3, 0.85);
1117 let hit_ids: Vec<String> = out.iter().map(|c| c.claim_b_id.clone()).collect();
1118 assert_eq!(hit_ids, vec!["a".to_string(), "e".to_string()]);
1119 }
1120
1121 #[test]
1122 fn test_detect_multi_shared_entity_reports_first() {
1123 let new_claim = make_claim("x", 0.8, 1, "oc", None, vec!["editor", "language"]);
1126 let emb = emb_along_axis(0, 8);
1127 let existing_claim = make_claim("y", 0.8, 1, "oc", None, vec!["language", "editor"]);
1128 let existing_emb = emb_at_cosine(0, 1, 8, 0.5);
1129 let out = detect_contradictions(
1130 &new_claim,
1131 "new_id",
1132 &emb,
1133 &[(existing_claim, "exist".to_string(), existing_emb)],
1134 0.3,
1135 0.85,
1136 );
1137 assert_eq!(out.len(), 1);
1138 assert_eq!(out[0].entity_id, deterministic_entity_id("editor"));
1139 }
1140
1141 fn components(
1144 confidence: f64,
1145 corroboration: f64,
1146 recency: f64,
1147 validation: f64,
1148 ) -> ScoreComponents {
1149 ScoreComponents {
1150 confidence,
1151 corroboration,
1152 recency,
1153 validation,
1154 weighted_total: 0.0,
1155 }
1156 }
1157
1158 #[test]
1159 fn test_feedback_both_pinned_unchanged() {
1160 let w = default_weights();
1161 let ce = Counterexample {
1162 formula_winner: components(0.9, 3.0, 1.0, 1.0),
1163 formula_loser: components(0.5, 1.0, 0.1, 0.7),
1164 user_pinned: UserPinned::Both,
1165 };
1166 let new_w = apply_feedback(&w, &ce);
1167 assert_eq!(new_w, w);
1168 }
1169
1170 #[test]
1171 fn test_feedback_identity_equal_components() {
1172 let w = default_weights();
1174 let c = components(0.8, 2.0, 0.5, 0.7);
1175 let ce = Counterexample {
1176 formula_winner: c.clone(),
1177 formula_loser: c,
1178 user_pinned: UserPinned::Loser,
1179 };
1180 let new_w = apply_feedback(&w, &ce);
1181 assert!((new_w.confidence - w.confidence).abs() < 1e-12);
1182 assert!((new_w.corroboration - w.corroboration).abs() < 1e-12);
1183 assert!((new_w.recency - w.recency).abs() < 1e-12);
1184 assert!((new_w.validation - w.validation).abs() < 1e-12);
1185 }
1186
1187 #[test]
1188 fn test_feedback_recency_increases_when_loser_had_more() {
1189 let w = default_weights();
1192 let winner = components(0.9, 1.0, 0.2, 0.7); let loser = components(0.7, 1.0, 0.9, 0.7); let ce = Counterexample {
1195 formula_winner: winner,
1196 formula_loser: loser,
1197 user_pinned: UserPinned::Loser,
1198 };
1199 let new_w = apply_feedback(&w, &ce);
1200 assert!(new_w.recency > w.recency, "recency should increase");
1201 assert!(new_w.confidence < w.confidence, "confidence should decrease");
1202 }
1203
1204 #[test]
1205 fn test_feedback_clamped_to_range_after_many_steps() {
1206 let mut w = default_weights();
1208 let ce = Counterexample {
1209 formula_winner: components(1.0, 3.0, 1.0, 1.0),
1210 formula_loser: components(0.0, 0.0, 0.0, 0.0),
1211 user_pinned: UserPinned::Loser,
1212 };
1213 for _ in 0..500 {
1214 w = apply_feedback(&w, &ce);
1215 assert!(w.confidence >= WEIGHT_MIN - 1e-12 && w.confidence <= WEIGHT_MAX + 1e-12);
1216 assert!(
1217 w.corroboration >= WEIGHT_MIN - 1e-12 && w.corroboration <= WEIGHT_MAX + 1e-12
1218 );
1219 assert!(w.recency >= WEIGHT_MIN - 1e-12 && w.recency <= WEIGHT_MAX + 1e-12);
1220 assert!(w.validation >= WEIGHT_MIN - 1e-12 && w.validation <= WEIGHT_MAX + 1e-12);
1221 let sum = w.confidence + w.corroboration + w.recency + w.validation;
1222 assert!(
1223 sum >= WEIGHT_SUM_MIN - 1e-9 && sum <= WEIGHT_SUM_MAX + 1e-9,
1224 "sum drifted to {}",
1225 sum
1226 );
1227 }
1228 }
1229
1230 #[test]
1231 fn test_feedback_sum_stays_in_band_typical_steps() {
1232 let mut w = default_weights();
1233 let ce = Counterexample {
1234 formula_winner: components(0.9, 2.0, 0.5, 0.7),
1235 formula_loser: components(0.6, 1.0, 0.9, 0.95),
1236 user_pinned: UserPinned::Loser,
1237 };
1238 for _ in 0..50 {
1239 w = apply_feedback(&w, &ce);
1240 let sum = w.confidence + w.corroboration + w.recency + w.validation;
1241 assert!(sum >= WEIGHT_SUM_MIN - 1e-9 && sum <= WEIGHT_SUM_MAX + 1e-9);
1242 }
1243 }
1244
1245 #[test]
1246 fn test_feedback_single_step_magnitude_bounded() {
1247 let w = default_weights();
1249 let ce = Counterexample {
1250 formula_winner: components(1.0, 3.0, 1.0, 1.0),
1251 formula_loser: components(0.0, 0.0, 0.0, 0.0),
1252 user_pinned: UserPinned::Loser,
1253 };
1254 let new_w = apply_feedback(&w, &ce);
1255 assert!((new_w.confidence - w.confidence).abs() <= FEEDBACK_STEP_SIZE + 1e-12);
1256 assert!((new_w.corroboration - w.corroboration).abs() <= FEEDBACK_STEP_SIZE + 1e-12);
1257 assert!((new_w.recency - w.recency).abs() <= FEEDBACK_STEP_SIZE + 1e-12);
1258 assert!((new_w.validation - w.validation).abs() <= FEEDBACK_STEP_SIZE + 1e-12);
1259 }
1260
1261 #[test]
1264 fn test_cosine_identical() {
1265 let a = vec![1.0f32, 2.0, 3.0];
1266 let b = vec![1.0f32, 2.0, 3.0];
1267 assert!((cosine_similarity_f32(&a, &b) - 1.0).abs() < 1e-9);
1268 }
1269
1270 #[test]
1271 fn test_cosine_orthogonal() {
1272 let a = vec![1.0f32, 0.0];
1273 let b = vec![0.0f32, 1.0];
1274 assert!(cosine_similarity_f32(&a, &b).abs() < 1e-9);
1275 }
1276
1277 #[test]
1278 fn test_cosine_opposite() {
1279 let a = vec![1.0f32, 0.0];
1280 let b = vec![-1.0f32, 0.0];
1281 assert!((cosine_similarity_f32(&a, &b) + 1.0).abs() < 1e-9);
1282 }
1283
1284 #[test]
1285 fn test_cosine_zero_vector_returns_zero_not_nan() {
1286 let a = vec![0.0f32, 0.0, 0.0];
1287 let b = vec![1.0f32, 2.0, 3.0];
1288 let sim = cosine_similarity_f32(&a, &b);
1289 assert!(!sim.is_nan());
1290 assert_eq!(sim, 0.0);
1291 }
1292
1293 #[test]
1294 fn test_cosine_mismatched_lengths_returns_zero() {
1295 let a = vec![1.0f32, 2.0];
1297 let b = vec![1.0f32, 2.0, 3.0];
1298 assert_eq!(cosine_similarity_f32(&a, &b), 0.0);
1299 }
1300
1301 #[test]
1304 fn test_weights_serde_round_trip() {
1305 let w = default_weights();
1306 let j = serde_json::to_string(&w).unwrap();
1307 let back: ResolutionWeights = serde_json::from_str(&j).unwrap();
1308 assert_eq!(w, back);
1309 }
1310
1311 #[test]
1312 fn test_outcome_serde_round_trip() {
1313 let a = make_claim("a", 0.9, 1, "oc", Some(&iso_days_ago(1)), vec![]);
1315 let b = make_claim("b", 0.5, 1, "oc", Some(&iso_days_ago(100)), vec![]);
1316 let outcome = resolve_pair(&a, "alpha", &b, "beta", NOW, &default_weights());
1317 let j = serde_json::to_string(&outcome).unwrap();
1318 let back: ResolutionOutcome = serde_json::from_str(&j).unwrap();
1319 assert_eq!(back.winner_id, outcome.winner_id);
1320 assert_eq!(back.loser_id, outcome.loser_id);
1321 assert!((back.winner_score - outcome.winner_score).abs() < 1e-12);
1322 assert!((back.loser_score - outcome.loser_score).abs() < 1e-12);
1323 assert!((back.score_delta - outcome.score_delta).abs() < 1e-12);
1324 assert!(
1325 (back.winner_components.weighted_total - outcome.winner_components.weighted_total)
1326 .abs()
1327 < 1e-12
1328 );
1329 }
1330
1331 #[test]
1332 fn test_contradiction_serde_round_trip() {
1333 let c = Contradiction {
1334 claim_a_id: "a".to_string(),
1335 claim_b_id: "b".to_string(),
1336 entity_id: "deadbeef".to_string(),
1337 similarity: 0.5,
1338 };
1339 let j = serde_json::to_string(&c).unwrap();
1340 let back: Contradiction = serde_json::from_str(&j).unwrap();
1341 assert_eq!(c, back);
1342 }
1343
1344 #[test]
1345 fn test_counterexample_serde_round_trip() {
1346 let ce = Counterexample {
1347 formula_winner: components(0.9, 2.0, 0.5, 1.0),
1348 formula_loser: components(0.5, 1.0, 0.9, 0.7),
1349 user_pinned: UserPinned::Loser,
1350 };
1351 let j = serde_json::to_string(&ce).unwrap();
1352 let back: Counterexample = serde_json::from_str(&j).unwrap();
1353 assert_eq!(ce, back);
1354 }
1355
1356 #[test]
1357 fn test_counterexample_both_variant_serde() {
1358 let ce = Counterexample {
1359 formula_winner: components(0.9, 2.0, 0.5, 1.0),
1360 formula_loser: components(0.5, 1.0, 0.9, 0.7),
1361 user_pinned: UserPinned::Both,
1362 };
1363 let j = serde_json::to_string(&ce).unwrap();
1364 let back: Counterexample = serde_json::from_str(&j).unwrap();
1365 assert_eq!(ce, back);
1366 }
1367
1368 fn make_embedding(seed: f32, dim: usize) -> Vec<f32> {
1374 let raw: Vec<f32> = (0..dim).map(|i| seed + i as f32 * 0.1).collect();
1375 let norm: f32 = raw.iter().map(|x| x * x).sum::<f32>().sqrt();
1376 raw.iter().map(|x| x / norm).collect()
1377 }
1378
1379 fn perturb_embedding(base: &[f32], delta: f32) -> Vec<f32> {
1381 let raw: Vec<f32> = base.iter().enumerate().map(|(i, &x)| {
1382 if i == 0 { x + delta } else { x }
1383 }).collect();
1384 let norm: f32 = raw.iter().map(|x| x * x).sum::<f32>().sqrt();
1385 raw.iter().map(|x| x / norm).collect()
1386 }
1387
1388 #[test]
1389 fn test_resolve_with_candidates_no_contradictions() {
1390 let new = make_claim("prefers Vim", 0.9, 1, "oc", Some(&iso_days_ago(1)), vec!["editor"]);
1392 let existing = make_claim("likes Rust", 0.8, 1, "oc", Some(&iso_days_ago(5)), vec!["programming"]);
1393 let emb = make_embedding(1.0, 10);
1394 let candidates = vec![(existing, "exist_id".to_string(), emb.clone())];
1395
1396 let actions = resolve_with_candidates(
1397 &new, "new_id", &emb, &candidates, &default_weights(),
1398 DEFAULT_LOWER_THRESHOLD, DEFAULT_UPPER_THRESHOLD, NOW, 0.01,
1399 );
1400 assert!(actions.is_empty());
1401 }
1402
1403 #[test]
1404 fn test_resolve_with_candidates_empty_candidates() {
1405 let new = make_claim("prefers Vim", 0.9, 1, "oc", Some(&iso_days_ago(1)), vec!["editor"]);
1406 let emb = make_embedding(1.0, 10);
1407 let candidates: Vec<(Claim, String, Vec<f32>)> = vec![];
1408
1409 let actions = resolve_with_candidates(
1410 &new, "new_id", &emb, &candidates, &default_weights(),
1411 DEFAULT_LOWER_THRESHOLD, DEFAULT_UPPER_THRESHOLD, NOW, 0.01,
1412 );
1413 assert!(actions.is_empty());
1414 }
1415
1416 #[test]
1417 fn test_resolve_with_candidates_empty_embedding() {
1418 let new = make_claim("prefers Vim", 0.9, 1, "oc", Some(&iso_days_ago(1)), vec!["editor"]);
1419 let existing = make_claim("uses VS Code", 0.8, 1, "oc", Some(&iso_days_ago(30)), vec!["editor"]);
1420 let emb = make_embedding(1.0, 10);
1421 let candidates = vec![(existing, "exist_id".to_string(), emb)];
1422
1423 let actions = resolve_with_candidates(
1424 &new, "new_id", &[], &candidates, &default_weights(),
1425 DEFAULT_LOWER_THRESHOLD, DEFAULT_UPPER_THRESHOLD, NOW, 0.01,
1426 );
1427 assert!(actions.is_empty());
1428 }
1429
1430 #[test]
1431 fn test_resolve_with_candidates_new_wins_supersede() {
1432 let new = make_claim("uses VS Code", 0.95, 1, "totalreclaw_remember", Some(&iso_days_ago(1)), vec!["editor"]);
1435 let existing = make_claim("prefers Vim", 0.6, 1, "oc", Some(&iso_days_ago(60)), vec!["editor"]);
1436
1437 let new_emb = make_embedding(1.0, 10);
1438 let existing_emb = perturb_embedding(&new_emb, 0.3);
1439 let candidates = vec![(existing, "exist_id".to_string(), existing_emb)];
1440
1441 let actions = resolve_with_candidates(
1442 &new, "new_id", &new_emb, &candidates, &default_weights(),
1443 0.0, 1.0, NOW, 0.01,
1444 );
1445
1446 assert_eq!(actions.len(), 1);
1447 match &actions[0] {
1448 crate::claims::ResolutionAction::SupersedeExisting {
1449 existing_id, new_id, winner_score, loser_score, entity_id, ..
1450 } => {
1451 assert_eq!(existing_id, "exist_id");
1452 assert_eq!(new_id, "new_id");
1453 assert!(winner_score.unwrap() > loser_score.unwrap());
1454 assert!(entity_id.is_some());
1455 }
1456 other => panic!("expected SupersedeExisting, got {:?}", other),
1457 }
1458 }
1459
1460 #[test]
1461 fn test_resolve_with_candidates_existing_wins_skip() {
1462 let new = make_claim("prefers Vim", 0.5, 1, "oc", Some(&iso_days_ago(60)), vec!["editor"]);
1465 let existing = make_claim("uses VS Code", 0.95, 1, "totalreclaw_remember", Some(&iso_days_ago(1)), vec!["editor"]);
1466
1467 let new_emb = make_embedding(1.0, 10);
1468 let existing_emb = perturb_embedding(&new_emb, 0.3);
1469 let candidates = vec![(existing, "exist_id".to_string(), existing_emb)];
1470
1471 let actions = resolve_with_candidates(
1472 &new, "new_id", &new_emb, &candidates, &default_weights(),
1473 0.0, 1.0, NOW, 0.01,
1474 );
1475
1476 assert_eq!(actions.len(), 1);
1477 match &actions[0] {
1478 crate::claims::ResolutionAction::SkipNew {
1479 reason, existing_id, winner_score, loser_score, ..
1480 } => {
1481 assert_eq!(*reason, crate::claims::SkipReason::ExistingWins);
1482 assert_eq!(existing_id, "exist_id");
1483 assert!(winner_score.is_some());
1484 assert!(loser_score.is_some());
1485 }
1486 other => panic!("expected SkipNew, got {:?}", other),
1487 }
1488 }
1489
1490 #[test]
1491 fn test_resolve_with_candidates_pinned_existing_skip() {
1492 let new = make_claim("uses VS Code", 0.95, 1, "totalreclaw_remember", Some(&iso_days_ago(1)), vec!["editor"]);
1493 let mut existing = make_claim("prefers Vim", 0.6, 1, "oc", Some(&iso_days_ago(60)), vec!["editor"]);
1494 existing.status = ClaimStatus::Pinned;
1495
1496 let new_emb = make_embedding(1.0, 10);
1497 let existing_emb = perturb_embedding(&new_emb, 0.3);
1498 let candidates = vec![(existing, "exist_id".to_string(), existing_emb)];
1499
1500 let actions = resolve_with_candidates(
1501 &new, "new_id", &new_emb, &candidates, &default_weights(),
1502 0.0, 1.0, NOW, 0.01,
1503 );
1504
1505 assert_eq!(actions.len(), 1);
1506 match &actions[0] {
1507 crate::claims::ResolutionAction::SkipNew {
1508 reason, existing_id, ..
1509 } => {
1510 assert_eq!(*reason, crate::claims::SkipReason::ExistingPinned);
1511 assert_eq!(existing_id, "exist_id");
1512 }
1513 other => panic!("expected SkipNew ExistingPinned, got {:?}", other),
1514 }
1515 }
1516
1517 #[test]
1518 fn test_resolve_with_candidates_tie_zone() {
1519 let new = make_claim("prefers Postgres for OLTP", 0.85, 1, "oc", Some(&iso_days_ago(2)), vec!["database"]);
1522 let existing = make_claim("prefers DuckDB for OLAP", 0.85, 1, "oc", Some(&iso_days_ago(2)), vec!["database"]);
1523
1524 let new_emb = make_embedding(1.0, 10);
1525 let existing_emb = perturb_embedding(&new_emb, 0.3);
1526 let candidates = vec![(existing, "exist_id".to_string(), existing_emb)];
1527
1528 let actions = resolve_with_candidates(
1530 &new, "new_id", &new_emb, &candidates, &default_weights(),
1531 0.0, 1.0, NOW, 10.0, );
1533
1534 assert_eq!(actions.len(), 1);
1535 match &actions[0] {
1536 crate::claims::ResolutionAction::TieLeaveBoth {
1537 existing_id, new_id, entity_id, ..
1538 } => {
1539 assert_eq!(existing_id, "exist_id");
1540 assert_eq!(new_id, "new_id");
1541 assert!(entity_id.is_some());
1542 }
1543 other => panic!("expected TieLeaveBoth, got {:?}", other),
1544 }
1545 }
1546
1547 #[test]
1552 fn test_build_decision_log_entries_supersede_populates_loser_json() {
1553 use crate::claims::ResolutionAction;
1554 let actions = vec![ResolutionAction::SupersedeExisting {
1555 existing_id: "0xold".to_string(),
1556 new_id: "0xnew".to_string(),
1557 similarity: 0.72,
1558 score_gap: 0.15,
1559 entity_id: Some("ent123".to_string()),
1560 winner_score: Some(0.8),
1561 loser_score: Some(0.65),
1562 winner_components: None,
1563 loser_components: None,
1564 }];
1565 let mut existing_map = std::collections::HashMap::new();
1566 existing_map.insert("0xold".to_string(), r#"{"t":"old claim"}"#.to_string());
1567
1568 let entries = build_decision_log_entries(&actions, "{}", &existing_map, "active", 1_776_384_000);
1569 assert_eq!(entries.len(), 1);
1570 assert_eq!(entries[0].action, "supersede_existing");
1571 assert_eq!(entries[0].entity_id, "ent123");
1572 assert_eq!(entries[0].loser_claim_json.as_deref(), Some(r#"{"t":"old claim"}"#));
1573 assert_eq!(entries[0].mode, "active");
1574 assert_eq!(entries[0].reason.as_deref(), Some("new_wins"));
1575 }
1576
1577 #[test]
1578 fn test_build_decision_log_entries_skip_no_loser_json() {
1579 use crate::claims::{ResolutionAction, SkipReason};
1580 let actions = vec![ResolutionAction::SkipNew {
1581 reason: SkipReason::ExistingWins,
1582 existing_id: "0xold".to_string(),
1583 new_id: "0xnew".to_string(),
1584 entity_id: Some("ent123".to_string()),
1585 similarity: Some(0.72),
1586 winner_score: Some(0.8),
1587 loser_score: Some(0.65),
1588 winner_components: None,
1589 loser_components: None,
1590 }];
1591 let existing_map = std::collections::HashMap::new();
1592
1593 let entries = build_decision_log_entries(&actions, "{}", &existing_map, "active", 1_776_384_000);
1594 assert_eq!(entries.len(), 1);
1595 assert_eq!(entries[0].action, "skip_new");
1596 assert!(entries[0].loser_claim_json.is_none());
1597 assert_eq!(entries[0].reason.as_deref(), Some("existing_wins"));
1598 }
1599
1600 #[test]
1601 fn test_build_decision_log_entries_tie() {
1602 use crate::claims::ResolutionAction;
1603 let actions = vec![ResolutionAction::TieLeaveBoth {
1604 existing_id: "0xold".to_string(),
1605 new_id: "0xnew".to_string(),
1606 similarity: 0.72,
1607 score_gap: 0.005,
1608 entity_id: Some("ent123".to_string()),
1609 winner_score: Some(0.7),
1610 loser_score: Some(0.695),
1611 winner_components: None,
1612 loser_components: None,
1613 }];
1614 let existing_map = std::collections::HashMap::new();
1615
1616 let entries = build_decision_log_entries(&actions, "{}", &existing_map, "active", 1_776_384_000);
1617 assert_eq!(entries.len(), 1);
1618 assert_eq!(entries[0].action, "tie_leave_both");
1619 assert_eq!(entries[0].reason.as_deref(), Some("tie_below_tolerance"));
1620 }
1621
1622 #[test]
1623 fn test_build_decision_log_entries_shadow_mode_overrides_action() {
1624 use crate::claims::ResolutionAction;
1625 let actions = vec![ResolutionAction::SupersedeExisting {
1626 existing_id: "0xold".to_string(),
1627 new_id: "0xnew".to_string(),
1628 similarity: 0.72,
1629 score_gap: 0.15,
1630 entity_id: Some("ent123".to_string()),
1631 winner_score: Some(0.8),
1632 loser_score: Some(0.65),
1633 winner_components: None,
1634 loser_components: None,
1635 }];
1636 let existing_map = std::collections::HashMap::new();
1637
1638 let entries = build_decision_log_entries(&actions, "{}", &existing_map, "shadow", 1_776_384_000);
1639 assert_eq!(entries.len(), 1);
1640 assert_eq!(entries[0].action, "shadow");
1641 assert_eq!(entries[0].mode, "shadow");
1642 }
1643
1644 #[test]
1649 fn test_filter_shadow_mode_active_passes_through() {
1650 use crate::claims::ResolutionAction;
1651 let actions = vec![
1652 ResolutionAction::SupersedeExisting {
1653 existing_id: "a".to_string(),
1654 new_id: "b".to_string(),
1655 similarity: 0.7,
1656 score_gap: 0.2,
1657 entity_id: None,
1658 winner_score: None,
1659 loser_score: None,
1660 winner_components: None,
1661 loser_components: None,
1662 },
1663 ];
1664 let filtered = filter_shadow_mode(actions, "active");
1665 assert_eq!(filtered.len(), 1);
1666 }
1667
1668 #[test]
1669 fn test_filter_shadow_mode_active_removes_ties() {
1670 use crate::claims::ResolutionAction;
1671 let actions = vec![
1672 ResolutionAction::TieLeaveBoth {
1673 existing_id: "a".to_string(),
1674 new_id: "b".to_string(),
1675 similarity: 0.7,
1676 score_gap: 0.005,
1677 entity_id: None,
1678 winner_score: None,
1679 loser_score: None,
1680 winner_components: None,
1681 loser_components: None,
1682 },
1683 ResolutionAction::SupersedeExisting {
1684 existing_id: "c".to_string(),
1685 new_id: "d".to_string(),
1686 similarity: 0.7,
1687 score_gap: 0.2,
1688 entity_id: None,
1689 winner_score: None,
1690 loser_score: None,
1691 winner_components: None,
1692 loser_components: None,
1693 },
1694 ];
1695 let filtered = filter_shadow_mode(actions, "active");
1696 assert_eq!(filtered.len(), 1);
1697 match &filtered[0] {
1698 ResolutionAction::SupersedeExisting { existing_id, .. } => {
1699 assert_eq!(existing_id, "c");
1700 }
1701 other => panic!("expected SupersedeExisting, got {:?}", other),
1702 }
1703 }
1704
1705 #[test]
1706 fn test_filter_shadow_mode_shadow_returns_empty() {
1707 use crate::claims::ResolutionAction;
1708 let actions = vec![ResolutionAction::SupersedeExisting {
1709 existing_id: "a".to_string(),
1710 new_id: "b".to_string(),
1711 similarity: 0.7,
1712 score_gap: 0.2,
1713 entity_id: None,
1714 winner_score: None,
1715 loser_score: None,
1716 winner_components: None,
1717 loser_components: None,
1718 }];
1719 let filtered = filter_shadow_mode(actions, "shadow");
1720 assert!(filtered.is_empty());
1721 }
1722
1723 #[test]
1724 fn test_filter_shadow_mode_off_returns_empty() {
1725 use crate::claims::ResolutionAction;
1726 let actions = vec![ResolutionAction::SupersedeExisting {
1727 existing_id: "a".to_string(),
1728 new_id: "b".to_string(),
1729 similarity: 0.7,
1730 score_gap: 0.2,
1731 entity_id: None,
1732 winner_score: None,
1733 loser_score: None,
1734 winner_components: None,
1735 loser_components: None,
1736 }];
1737 let filtered = filter_shadow_mode(actions, "off");
1738 assert!(filtered.is_empty());
1739 }
1740
1741 #[test]
1746 fn test_full_pipeline_resolve_to_decision_log() {
1747 let new = make_claim("uses VS Code", 0.95, 1, "totalreclaw_remember", Some(&iso_days_ago(1)), vec!["editor"]);
1749 let existing = make_claim("prefers Vim", 0.6, 1, "oc", Some(&iso_days_ago(60)), vec!["editor"]);
1750
1751 let new_emb = make_embedding(1.0, 10);
1752 let existing_emb = perturb_embedding(&new_emb, 0.3);
1753 let existing_json = serde_json::to_string(&existing).unwrap();
1754 let candidates = vec![(existing, "0xold".to_string(), existing_emb)];
1755
1756 let actions = resolve_with_candidates(
1758 &new, "0xnew", &new_emb, &candidates, &default_weights(),
1759 0.0, 1.0, NOW, 0.01,
1760 );
1761 assert!(!actions.is_empty());
1762
1763 let mut existing_map = std::collections::HashMap::new();
1765 existing_map.insert("0xold".to_string(), existing_json.clone());
1766 let entries = build_decision_log_entries(&actions, "{}", &existing_map, "active", NOW);
1767 assert_eq!(entries.len(), actions.len());
1768 let entry = &entries[0];
1770 assert!(entry.ts == NOW);
1771 assert!(!entry.entity_id.is_empty());
1772 assert_eq!(entry.new_claim_id, "0xnew");
1773 assert_eq!(entry.existing_claim_id, "0xold");
1774
1775 let filtered = filter_shadow_mode(actions.clone(), "active");
1777 for a in &filtered {
1779 assert!(!matches!(a, crate::claims::ResolutionAction::TieLeaveBoth { .. }));
1780 }
1781
1782 let shadow_filtered = filter_shadow_mode(actions, "shadow");
1784 assert!(shadow_filtered.is_empty());
1785
1786 let entry_json = serde_json::to_string(&entry).unwrap();
1788 let _: crate::decision_log::DecisionLogEntry =
1789 serde_json::from_str(&entry_json).unwrap();
1790 }
1791
1792 #[test]
1793 fn test_build_decision_log_skip_pinned_reason_format() {
1794 use crate::claims::{ResolutionAction, SkipReason};
1796 let actions = vec![ResolutionAction::SkipNew {
1797 reason: SkipReason::ExistingPinned,
1798 existing_id: "0xold".to_string(),
1799 new_id: "0xnew".to_string(),
1800 entity_id: Some("ent".to_string()),
1801 similarity: Some(0.7),
1802 winner_score: None,
1803 loser_score: None,
1804 winner_components: None,
1805 loser_components: None,
1806 }];
1807 let entries = build_decision_log_entries(&actions, "{}", &std::collections::HashMap::new(), "active", NOW);
1808 assert_eq!(entries[0].reason.as_deref(), Some("existing_pinned"));
1809 }
1810}