Skip to main content

vela_protocol/
tensions.rs

1//! Unresolved contradiction analysis — "Where does science disagree with itself?"
2//!
3//! Finds all `contradicts` link pairs, scores them by tension (high confidence
4//! on both sides = maximum tension), and checks whether a superseding finding
5//! has resolved the disagreement.
6
7use std::collections::HashSet;
8
9use colored::Colorize;
10
11use crate::cli_style as style;
12
13use crate::bundle::FindingBundle;
14use crate::project::Project;
15
16/// A pair of contradicting findings with a tension score.
17#[derive(Debug, Clone)]
18pub struct Tension {
19    pub finding_a: TensionSide,
20    pub finding_b: TensionSide,
21    pub score: f64,
22    pub resolved: bool,
23    pub superseding_id: Option<String>,
24}
25
26/// One side of a contradiction.
27#[derive(Debug, Clone)]
28pub struct TensionSide {
29    pub id: String,
30    pub assertion: String,
31    pub confidence: f64,
32    pub assertion_type: String,
33    pub citation_count: u64,
34    pub contradicts_count: usize,
35}
36
37/// Run the tensions analysis.
38pub fn analyze(
39    frontier: &Project,
40    both_high: bool,
41    cross_domain: bool,
42    top: usize,
43) -> Vec<Tension> {
44    // Build a set of all `contradicts` pairs (deduplicated by sorted ID pair).
45    let mut seen_pairs: HashSet<(String, String)> = HashSet::new();
46    let mut tensions: Vec<Tension> = Vec::new();
47
48    // Pre-compute contradiction counts per finding.
49    let mut contradict_counts: std::collections::HashMap<&str, usize> =
50        std::collections::HashMap::new();
51    for f in &frontier.findings {
52        for l in &f.links {
53            if l.link_type == "contradicts" {
54                *contradict_counts.entry(f.id.as_str()).or_default() += 1;
55            }
56        }
57    }
58
59    // Build ID -> index map.
60    let id_map: std::collections::HashMap<&str, usize> = frontier
61        .findings
62        .iter()
63        .enumerate()
64        .map(|(i, f)| (f.id.as_str(), i))
65        .collect();
66
67    for f in &frontier.findings {
68        for l in &f.links {
69            if l.link_type != "contradicts" {
70                continue;
71            }
72
73            // Get the target finding.
74            let target_idx = match id_map.get(l.target.as_str()) {
75                Some(&i) => i,
76                None => continue,
77            };
78            let target = &frontier.findings[target_idx];
79
80            // Deduplicate: use sorted pair.
81            let pair = if f.id < target.id {
82                (f.id.clone(), target.id.clone())
83            } else {
84                (target.id.clone(), f.id.clone())
85            };
86
87            if seen_pairs.contains(&pair) {
88                continue;
89            }
90            seen_pairs.insert(pair);
91
92            // Apply filters.
93            if both_high && (f.confidence.score < 0.8 || target.confidence.score < 0.8) {
94                continue;
95            }
96
97            if cross_domain && f.assertion.assertion_type == target.assertion.assertion_type {
98                continue;
99            }
100
101            let side_a = make_side(f, &contradict_counts);
102            let side_b = make_side(target, &contradict_counts);
103
104            // Tension score = min(conf_a, conf_b) * (citations_a + citations_b)
105            let min_conf = f.confidence.score.min(target.confidence.score);
106            let total_cites = side_a.citation_count + side_b.citation_count;
107            // Use at least 1 for citations to avoid zero scores when no citations available.
108            let score = min_conf * (total_cites.max(1) as f64);
109
110            // Check if resolved: is there a finding that supersedes either side?
111            let (resolved, superseding_id) = check_resolved(&f.id, &target.id, frontier, &id_map);
112
113            tensions.push(Tension {
114                finding_a: side_a,
115                finding_b: side_b,
116                score,
117                resolved,
118                superseding_id,
119            });
120        }
121    }
122
123    tensions.sort_by(|a, b| {
124        b.score
125            .partial_cmp(&a.score)
126            .unwrap_or(std::cmp::Ordering::Equal)
127    });
128    tensions.truncate(top);
129    tensions
130}
131
132fn make_side(
133    f: &FindingBundle,
134    contradict_counts: &std::collections::HashMap<&str, usize>,
135) -> TensionSide {
136    TensionSide {
137        id: f.id.clone(),
138        assertion: f.assertion.text.clone(),
139        confidence: f.confidence.score,
140        assertion_type: f.assertion.assertion_type.clone(),
141        citation_count: f.provenance.citation_count.unwrap_or(0),
142        contradicts_count: contradict_counts.get(f.id.as_str()).copied().unwrap_or(0),
143    }
144}
145
146/// Check if either finding in a contradiction has been superseded by a third finding.
147/// A finding is "superseded" if another finding links to it with a "supersedes" link type,
148/// or if a newer finding with higher confidence contradicts it.
149fn check_resolved(
150    id_a: &str,
151    id_b: &str,
152    frontier: &Project,
153    _id_map: &std::collections::HashMap<&str, usize>,
154) -> (bool, Option<String>) {
155    for f in &frontier.findings {
156        for l in &f.links {
157            // A finding that explicitly supersedes either side resolves the tension.
158            if l.link_type == "supersedes" && (l.target == id_a || l.target == id_b) {
159                return (true, Some(f.id.clone()));
160            }
161        }
162    }
163    (false, None)
164}
165
166/// Print the tensions report to stdout with colored formatting.
167pub fn print_tensions(tensions: &[Tension]) {
168    println!();
169    println!("  {}", "VELA · TENSIONS".dimmed());
170    println!("  {}", style::tick_row(60));
171
172    if tensions.is_empty() {
173        println!("  no tensions found in this frontier.");
174        println!();
175        return;
176    }
177
178    for (i, t) in tensions.iter().enumerate() {
179        let status = if t.resolved {
180            style::ok(&format!(
181                "resolved by {}",
182                t.superseding_id.as_deref().unwrap_or("unknown")
183            ))
184        } else {
185            style::warn("contested")
186        };
187
188        println!(
189            "{} {}  (tension score: {:.1})",
190            format!("{}.", i + 1).bold(),
191            status,
192            t.score
193        );
194        println!(
195            "  a: \"{}\" ({:.2})",
196            truncate(&t.finding_a.assertion, 60),
197            t.finding_a.confidence
198        );
199        println!(
200            "     {} [{} contradictions]",
201            t.finding_a.id, t.finding_a.contradicts_count
202        );
203        println!(
204            "  b: \"{}\" ({:.2})",
205            truncate(&t.finding_b.assertion, 60),
206            t.finding_b.confidence
207        );
208        println!(
209            "     {} [{} contradictions]",
210            t.finding_b.id, t.finding_b.contradicts_count
211        );
212
213        if t.finding_a.assertion_type != t.finding_b.assertion_type {
214            println!(
215                "  {} cross-domain: {} vs {}",
216                style::brass("·"),
217                t.finding_a.assertion_type,
218                t.finding_b.assertion_type
219            );
220        }
221
222        println!();
223    }
224}
225
226fn truncate(s: &str, max: usize) -> String {
227    if s.len() <= max {
228        return s.to_string();
229    }
230    let mut end = max;
231    while end > 0 && !s.is_char_boundary(end) {
232        end -= 1;
233    }
234    format!("{}...", &s[..end])
235}
236
237// ── Tests ────────────────────────────────────────────────────────────
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::bundle::*;
243    use crate::project;
244
245    fn make_finding(id: &str, score: f64, assertion_type: &str) -> FindingBundle {
246        FindingBundle {
247            id: id.into(),
248            version: 1,
249            previous_version: None,
250            assertion: Assertion {
251                text: format!("Finding {id}"),
252                assertion_type: assertion_type.into(),
253                entities: vec![],
254                relation: None,
255                direction: None,
256                causal_claim: None,
257                causal_evidence_grade: None,
258            },
259            evidence: Evidence {
260                evidence_type: "experimental".into(),
261                model_system: String::new(),
262                species: None,
263                method: String::new(),
264                sample_size: None,
265                effect_size: None,
266                p_value: None,
267                replicated: false,
268                replication_count: None,
269                evidence_spans: vec![],
270            },
271            conditions: Conditions {
272                text: String::new(),
273                species_verified: vec![],
274                species_unverified: vec![],
275                in_vitro: false,
276                in_vivo: false,
277                human_data: false,
278                clinical_trial: false,
279                concentration_range: None,
280                duration: None,
281                age_group: None,
282                cell_type: None,
283            },
284            confidence: Confidence::raw(score, "test", 0.85),
285            provenance: Provenance {
286                source_type: "published_paper".into(),
287                doi: None,
288                pmid: None,
289                pmc: None,
290                openalex_id: None,
291                url: None,
292                title: "Test".into(),
293                authors: vec![],
294                year: Some(2025),
295                journal: None,
296                license: None,
297                publisher: None,
298                funders: vec![],
299                extraction: Extraction::default(),
300                review: None,
301                citation_count: Some(50),
302            },
303            flags: Flags {
304                gap: false,
305                negative_space: false,
306                contested: false,
307                retracted: false,
308                declining: false,
309                gravity_well: false,
310                review_state: None,
311                superseded: false,
312                signature_threshold: None,
313                jointly_accepted: false,
314            },
315            links: vec![],
316            annotations: vec![],
317            attachments: vec![],
318            created: String::new(),
319            updated: None,
320
321            access_tier: crate::access_tier::AccessTier::Public,
322        }
323    }
324
325    fn make_frontier_from(findings: Vec<FindingBundle>) -> Project {
326        project::assemble("test", findings, 1, 0, "test frontier")
327    }
328
329    #[test]
330    fn basic_contradiction_detected() {
331        let mut a = make_finding("a", 0.9, "mechanism");
332        let b = make_finding("b", 0.85, "mechanism");
333        a.add_link("b", "contradicts", "opposite findings");
334
335        let c = make_frontier_from(vec![a, b]);
336        let results = analyze(&c, false, false, 20);
337
338        assert_eq!(results.len(), 1);
339        assert!(!results[0].resolved);
340        assert!(results[0].score > 0.0);
341    }
342
343    #[test]
344    fn both_high_filter() {
345        let mut a = make_finding("a", 0.9, "mechanism");
346        let b = make_finding("b", 0.5, "mechanism"); // low confidence
347        a.add_link("b", "contradicts", "");
348
349        let c = make_frontier_from(vec![a, b]);
350
351        // Without filter: found
352        let results = analyze(&c, false, false, 20);
353        assert_eq!(results.len(), 1);
354
355        // With both_high filter: excluded (b < 0.8)
356        let results_filtered = analyze(&c, true, false, 20);
357        assert_eq!(results_filtered.len(), 0);
358    }
359
360    #[test]
361    fn cross_domain_filter() {
362        let mut a = make_finding("a", 0.9, "mechanism");
363        let b = make_finding("b", 0.85, "mechanism"); // same type
364        a.add_link("b", "contradicts", "");
365
366        let mut c_finding = make_finding("c", 0.88, "therapeutic"); // different type
367        let d = make_finding("d", 0.82, "mechanism");
368        c_finding.add_link("d", "contradicts", "");
369
370        let frontier = make_frontier_from(vec![a, b, c_finding, d]);
371
372        // Without filter: both found
373        let results = analyze(&frontier, false, false, 20);
374        assert_eq!(results.len(), 2);
375
376        // With cross_domain: only c vs d (different types)
377        let results_filtered = analyze(&frontier, false, true, 20);
378        assert_eq!(results_filtered.len(), 1);
379    }
380
381    #[test]
382    fn resolved_by_supersedes() {
383        let mut a = make_finding("a", 0.9, "mechanism");
384        let b = make_finding("b", 0.85, "mechanism");
385        a.add_link("b", "contradicts", "");
386        let mut resolver = make_finding("resolver", 0.95, "mechanism");
387        resolver.add_link("a", "supersedes", "newer finding");
388
389        let c = make_frontier_from(vec![a, b, resolver]);
390        let results = analyze(&c, false, false, 20);
391
392        assert_eq!(results.len(), 1);
393        assert!(results[0].resolved);
394        assert_eq!(results[0].superseding_id.as_deref(), Some("resolver"));
395    }
396
397    #[test]
398    fn tension_score_uses_min_confidence() {
399        let mut a = make_finding("a", 0.9, "mechanism");
400        let b = make_finding("b", 0.7, "mechanism");
401        a.add_link("b", "contradicts", "");
402
403        let c = make_frontier_from(vec![a, b]);
404        let results = analyze(&c, false, false, 20);
405
406        // score = min(0.9, 0.7) * (50 + 50) = 0.7 * 100 = 70.0
407        assert_eq!(results.len(), 1);
408        assert!((results[0].score - 70.0).abs() < 0.1);
409    }
410
411    #[test]
412    fn deduplicated_pairs() {
413        // Both a->b and b->a contradicts links should produce only one tension.
414        let mut a = make_finding("a", 0.9, "mechanism");
415        let mut b = make_finding("b", 0.85, "mechanism");
416        a.add_link("b", "contradicts", "");
417        b.add_link("a", "contradicts", "");
418
419        let c = make_frontier_from(vec![a, b]);
420        let results = analyze(&c, false, false, 20);
421        assert_eq!(results.len(), 1);
422    }
423
424    #[test]
425    fn no_contradictions_empty() {
426        let a = make_finding("a", 0.9, "mechanism");
427        let b = make_finding("b", 0.85, "mechanism");
428        let c = make_frontier_from(vec![a, b]);
429        let results = analyze(&c, false, false, 20);
430        assert!(results.is_empty());
431    }
432}