1use 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#[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#[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
37pub fn analyze(
39 frontier: &Project,
40 both_high: bool,
41 cross_domain: bool,
42 top: usize,
43) -> Vec<Tension> {
44 let mut seen_pairs: HashSet<(String, String)> = HashSet::new();
46 let mut tensions: Vec<Tension> = Vec::new();
47
48 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 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 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 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 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 let min_conf = f.confidence.score.min(target.confidence.score);
106 let total_cites = side_a.citation_count + side_b.citation_count;
107 let score = min_conf * (total_cites.max(1) as f64);
109
110 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
146fn 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 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
166pub 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#[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"); a.add_link("b", "contradicts", "");
348
349 let c = make_frontier_from(vec![a, b]);
350
351 let results = analyze(&c, false, false, 20);
353 assert_eq!(results.len(), 1);
354
355 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"); a.add_link("b", "contradicts", "");
365
366 let mut c_finding = make_finding("c", 0.88, "therapeutic"); 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 let results = analyze(&frontier, false, false, 20);
374 assert_eq!(results.len(), 2);
375
376 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 assert_eq!(results.len(), 1);
408 assert!((results[0].score - 70.0).abs() < 0.1);
409 }
410
411 #[test]
412 fn deduplicated_pairs() {
413 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}