Skip to main content

totalreclaw_core/
contradiction.rs

1//! Contradiction detection and resolution primitives (Phase 2 Slice 2a).
2
3use crate::claims::{deterministic_entity_id, Claim};
4use crate::reranker::cosine_similarity_f32;
5use chrono::DateTime;
6use serde::{Deserialize, Serialize};
7
8/// Weights for the resolution formula. Defaults come from P2-3.
9#[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/// A detected contradiction between two claims that share an entity.
18#[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/// Per-component breakdown of a claim's score.
27#[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/// Output of running the resolution formula on a contradiction.
37#[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/// How the user resolved a contradiction relative to the formula's choice.
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum UserPinned {
52    /// User pinned the loser (formula chose the wrong claim).
53    Loser,
54    /// User pinned both (they are not actually a contradiction).
55    Both,
56}
57
58/// A user-override event used by the feedback-tuning loop.
59#[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
66/// Default contradiction detection band lower bound (below = unrelated).
67pub const DEFAULT_LOWER_THRESHOLD: f64 = 0.3;
68
69/// Default contradiction detection band upper bound (at/above = duplicate).
70pub const DEFAULT_UPPER_THRESHOLD: f64 = 0.85;
71
72/// Gradient step size for weight feedback adjustment.
73pub const FEEDBACK_STEP_SIZE: f64 = 0.02;
74
75/// Per-weight lower clamp.
76pub const WEIGHT_MIN: f64 = 0.05;
77
78/// Per-weight upper clamp.
79pub const WEIGHT_MAX: f64 = 0.60;
80
81/// Weight sum lower bound (after clamping + rescaling).
82pub const WEIGHT_SUM_MIN: f64 = 0.9;
83
84/// Weight sum upper bound (after clamping + rescaling).
85pub const WEIGHT_SUM_MAX: f64 = 1.1;
86
87/// Structurally defensible default weights from P2-3 of the Phase 2 design doc.
88pub 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
131/// Compute a claim's score components for contradiction resolution.
132pub 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
154/// Run the resolution formula on two contradicting claims. Returns winner and loser.
155/// Ties (equal weighted totals) favour `claim_a` deterministically.
156pub 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
191/// Detect contradictions between a new claim and existing claims that share at least one entity.
192///
193/// Returns contradictions where cosine similarity is in `[lower_threshold, upper_threshold)`.
194/// Pairs with multiple shared entities are reported once, using the first shared entity
195/// (by insertion order of the new claim's entity list) as the representative.
196///
197/// Existing claims with an empty embedding vector are skipped (cannot be evaluated).
198/// If the new claim has no entities, returns an empty vec.
199pub 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
254// ---------------------------------------------------------------------------
255// Step D: Contradiction Detection Orchestration
256// ---------------------------------------------------------------------------
257
258/// Core orchestration loop for contradiction resolution.
259///
260/// Given a new claim and a set of candidates (existing claims with embeddings),
261/// detect contradictions and resolve each one by checking pin status, running the
262/// resolution formula, and applying the tie-zone guard.
263///
264/// All I/O (subgraph queries, decryption, file reads) stays in client adapters.
265/// This function operates on pre-fetched, pre-decrypted data only.
266///
267/// Returns an empty vec when:
268/// - candidates is empty
269/// - new_embedding is empty
270/// - no contradictions are detected
271pub 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    // Index candidates by id for fast lookup.
302    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        // Pinned existing claims are untouchable.
317        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        // Run the resolution formula.
333        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            // New claim wins — check tie zone.
344            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            // Existing claim wins.
371            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
388/// Convert resolution actions + metadata into decision log entries.
389///
390/// For each action, builds a `DecisionLogEntry` with scores, entity, and mode.
391/// For `SupersedeExisting`, populates `loser_claim_json` from the provided map
392/// (enables pin-on-tombstone recovery).
393pub 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                // No log entry for no-op actions.
502            }
503        }
504    }
505
506    entries
507}
508
509/// Filter resolution actions based on the auto-resolve mode.
510///
511/// - `"active"`: return actions as-is (but filter out ties, which are informational only)
512/// - `"shadow"`: return empty vec (log only, no side effects)
513/// - `"off"` or anything else: return empty vec
514pub 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
528/// Apply a single counterexample to the weights via a small gradient step.
529/// See `FEEDBACK_STEP_SIZE`, `WEIGHT_MIN`, `WEIGHT_MAX`, and `WEIGHT_SUM_MIN`/`MAX`.
530///
531/// For `UserPinned::Both`, the detection (not the weights) was wrong, so weights are unchanged.
532pub 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    // Per-component deltas, clamped to [-1, 1] so each step stays bounded by ±step_size.
544    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    // Clamp each weight individually.
557    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    // If the sum drifted outside [0.9, 1.1], rescale proportionally toward 1.0.
563    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    /// 2026-04-12T00:00:00Z — fixed "now" for deterministic recency tests.
613    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    // ----- default_weights -----
623
624    #[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    // ----- compute_score_components: validation -----
641
642    #[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    // ----- compute_score_components: corroboration -----
678
679    #[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        // `corroboration_count` is u32 and defaults to 1, but defensively handle 0.
710        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    // ----- compute_score_components: recency -----
716
717    #[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        // 1 / (1 + 2/30) = 30/32 = 0.9375
722        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        // 1 / (1 + 30/30) = 0.5
730        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        // days = 10000 -> 1 / (1 + 10000/30) ~= 0.002994
738        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        // Future -> days = 0 -> recency = 1.0
760        assert!((s.recency - 1.0).abs() < 1e-9);
761    }
762
763    // ----- compute_score_components: confidence clamping -----
764
765    #[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    // ----- compute_score_components: weighted total -----
787
788    #[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        // 0.9*0.25 + 1*0.15 + 1*0.40 + 1*0.20 = 0.225 + 0.15 + 0.40 + 0.20 = 0.975
793        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        // 0.5*0.1 + 1*0.1 + 1*0.5 + 0.7*0.3 = 0.05 + 0.1 + 0.5 + 0.21 = 0.86
807        assert!((s.weighted_total - 0.86).abs() < 1e-9);
808    }
809
810    // ----- resolve_pair -----
811
812    #[test]
813    fn test_resolve_pair_vim_vs_vscode_defaults() {
814        // Pedro's known-answer scenario from the plan.
815        // Vim: confidence 0.8, 60 days old, corroboration 3, auto-extracted
816        // VS Code: confidence 0.9, 7 days old, corroboration 1, auto-extracted
817        // With default weights, VS Code wins because recency dominates.
818        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        // With default (recency-heavy) weights, a fresh auto-extracted claim beats
847        // an older explicit user-remembered claim.
848        // With validation-heavy weights, the explicit user-remembered claim wins.
849        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        // Identical claims except IDs -> weighted totals equal -> a wins by tie-break.
878        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    // ----- detect_contradictions -----
905
906    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    /// Build an embedding at a controlled angle from a reference axis-aligned vector.
913    /// Returns a unit vector whose cosine similarity with `emb_along_axis(axis, dim)` equals `cos_target`.
914    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        // Upper threshold is exclusive: sim == 0.85 should NOT be a contradiction.
988        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        // Lower threshold is inclusive: sim == 0.3 IS a contradiction.
1006        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        // New claim has entities [editor, language]. Existing shares both.
1124        // First shared entity is "editor" (new claim's order).
1125        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    // ----- apply_feedback -----
1142
1143    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        // If winner == loser components, no shift.
1173        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        // Formula picked the low-recency winner, user pinned the high-recency loser.
1190        // -> recency weight should increase.
1191        let w = default_weights();
1192        let winner = components(0.9, 1.0, 0.2, 0.7); // winner had high confidence
1193        let loser = components(0.7, 1.0, 0.9, 0.7); // loser had high recency
1194        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        // Apply an extreme counterexample repeatedly and ensure weights stay in [0.05, 0.60].
1207        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        // A single step shouldn't move any weight by more than step_size.
1248        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    // ----- cosine_similarity (reusing reranker::cosine_similarity_f32) -----
1262
1263    #[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        // Reranker's cosine_similarity_f32 returns 0.0 for mismatched lengths (by contract).
1296        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    // ----- Serde round-trip for new types -----
1302
1303    #[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        // f64 fields may drift by ~1 ulp through JSON; compare with tight tolerance.
1314        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    // =========================================================================
1369    // Step D: resolve_with_candidates
1370    // =========================================================================
1371
1372    /// Helper: create a normalized embedding vector of the given dimension.
1373    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    /// Similar embedding (slightly perturbed).
1380    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        // New claim and existing share no entities → no contradictions.
1391        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        // New claim: recent, high confidence. Existing: old, lower confidence.
1433        // Both share entity "editor". Embeddings are similar but in the contradiction band.
1434        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        // Existing claim: recent, explicit remember, high confidence.
1463        // New claim: old, auto-extracted, lower confidence.
1464        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        // Two claims with near-identical scores → tie zone.
1520        // Both have same confidence, corroboration, source, and very close recency.
1521        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        // Use a very large tie_zone_tolerance to force a tie.
1529        let actions = resolve_with_candidates(
1530            &new, "new_id", &new_emb, &candidates, &default_weights(),
1531            0.0, 1.0, NOW, 10.0, // huge tolerance → everything is a tie
1532        );
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    // =========================================================================
1548    // Step D: build_decision_log_entries
1549    // =========================================================================
1550
1551    #[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    // =========================================================================
1645    // Step D: filter_shadow_mode
1646    // =========================================================================
1647
1648    #[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    // =========================================================================
1742    // Step D: Integration test — full pipeline
1743    // =========================================================================
1744
1745    #[test]
1746    fn test_full_pipeline_resolve_to_decision_log() {
1747        // Full integration: resolve_with_candidates → build_decision_log_entries → filter
1748        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        // Step 1: Resolve
1757        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        // Step 2: Build decision log entries
1764        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        // Verify the entry has the right shape.
1769        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        // Step 3: Filter for active mode
1776        let filtered = filter_shadow_mode(actions.clone(), "active");
1777        // Should have at least 1 actionable result (supersede or skip, no ties)
1778        for a in &filtered {
1779            assert!(!matches!(a, crate::claims::ResolutionAction::TieLeaveBoth { .. }));
1780        }
1781
1782        // Step 4: Shadow mode returns empty
1783        let shadow_filtered = filter_shadow_mode(actions, "shadow");
1784        assert!(shadow_filtered.is_empty());
1785
1786        // Step 5: Decision log entry is serializable
1787        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        // Verify the reason string format for ExistingPinned.
1795        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}