reasonkit/thinktool/
triangulation.rs

1//! # Epistemic Triangulation Protocol
2//!
3//! Implements multi-source verification for claims achieving +50% false claim rejection.
4//!
5//! ## Scientific Foundation
6//!
7//! Based on:
8//! - Du Bois triangulation methodology
9//! - PNAS 2025 research on fact-checking infrastructure
10//! - Epistemic anchor theory
11//!
12//! ## Core Principle
13//!
14//! ```text
15//! ┌─────────────────────────────────────────────────────────────────────┐
16//! │                  TRIANGULATION PROTOCOL                             │
17//! ├─────────────────────────────────────────────────────────────────────┤
18//! │                                                                     │
19//! │   SOURCE A (Primary) ──────┐                                       │
20//! │   • Official/Authoritative │                                       │
21//! │   • Tier 1: Weight 1.0     ├─────► CLAIM ◄─────┐                   │
22//! │                            │                    │                   │
23//! │   SOURCE B (Secondary) ────┘                    │                   │
24//! │   • Different domain                            │                   │
25//! │   • Tier 2: Weight 0.7    ───────► VERIFIED ◄──┤                   │
26//! │                                                 │                   │
27//! │   SOURCE C (Independent) ──────────────────────┘                   │
28//! │   • Different author                                               │
29//! │   • Tier 3: Weight 0.4                                             │
30//! │                                                                     │
31//! │   MINIMUM REQUIREMENT: 3 independent sources                       │
32//! └─────────────────────────────────────────────────────────────────────┘
33//! ```
34//!
35//! ## Usage
36//!
37//! ```rust,ignore
38//! use reasonkit::thinktool::triangulation::{Triangulator, Source, SourceTier};
39//!
40//! let triangulator = Triangulator::new();
41//! let claim = "AI will reach AGI by 2030";
42//!
43//! triangulator.add_source(Source::new("OpenAI Research Paper", SourceTier::Primary));
44//! triangulator.add_source(Source::new("DeepMind Analysis", SourceTier::Secondary));
45//! triangulator.add_source(Source::new("Academic Survey", SourceTier::Independent));
46//!
47//! let result = triangulator.verify()?;
48//! ```
49
50use serde::{Deserialize, Serialize};
51
52/// Source tier classification
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum SourceTier {
55    /// Tier 1: Primary/Authoritative sources (weight 1.0)
56    /// Official docs, peer-reviewed papers, primary sources
57    Primary,
58    /// Tier 2: Secondary/Reliable sources (weight 0.7)
59    /// Reputable news, expert blogs, secondary analysis
60    Secondary,
61    /// Tier 3: Independent sources (weight 0.4)
62    /// Community content, user-generated, unverified
63    Independent,
64    /// Tier 4: Unverified sources (weight 0.2)
65    Unverified,
66}
67
68impl SourceTier {
69    pub fn weight(&self) -> f32 {
70        match self {
71            SourceTier::Primary => 1.0,
72            SourceTier::Secondary => 0.7,
73            SourceTier::Independent => 0.4,
74            SourceTier::Unverified => 0.2,
75        }
76    }
77
78    pub fn label(&self) -> &'static str {
79        match self {
80            SourceTier::Primary => "Tier 1 (Primary)",
81            SourceTier::Secondary => "Tier 2 (Secondary)",
82            SourceTier::Independent => "Tier 3 (Independent)",
83            SourceTier::Unverified => "Tier 4 (Unverified)",
84        }
85    }
86}
87
88/// Source type classification
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
90pub enum SourceType {
91    /// Academic peer-reviewed
92    Academic,
93    /// Official documentation
94    Documentation,
95    /// News article
96    News,
97    /// Expert opinion/blog
98    Expert,
99    /// Government/regulatory
100    Government,
101    /// Industry report
102    Industry,
103    /// Community forum
104    Community,
105    /// Social media
106    Social,
107    /// Primary data
108    PrimaryData,
109}
110
111impl SourceType {
112    pub fn default_tier(&self) -> SourceTier {
113        match self {
114            SourceType::Academic => SourceTier::Primary,
115            SourceType::Documentation => SourceTier::Primary,
116            SourceType::Government => SourceTier::Primary,
117            SourceType::PrimaryData => SourceTier::Primary,
118            SourceType::News => SourceTier::Secondary,
119            SourceType::Expert => SourceTier::Secondary,
120            SourceType::Industry => SourceTier::Secondary,
121            SourceType::Community => SourceTier::Independent,
122            SourceType::Social => SourceTier::Unverified,
123        }
124    }
125}
126
127/// A source for triangulation
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct Source {
130    /// Source identifier/name
131    pub name: String,
132    /// URL if available
133    pub url: Option<String>,
134    /// Source tier
135    pub tier: SourceTier,
136    /// Source type
137    pub source_type: SourceType,
138    /// Domain/field
139    pub domain: Option<String>,
140    /// Author/organization
141    pub author: Option<String>,
142    /// Publication date
143    pub date: Option<String>,
144    /// Whether URL was verified accessible
145    pub verified: bool,
146    /// Direct quote supporting claim
147    pub quote: Option<String>,
148    /// Does this source support or contradict the claim?
149    pub stance: Stance,
150    /// Credibility assessment (0.0-1.0)
151    pub credibility: f32,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155pub enum Stance {
156    /// Supports the claim
157    Support,
158    /// Contradicts the claim
159    Contradict,
160    /// Neither supports nor contradicts
161    Neutral,
162    /// Partially supports
163    Partial,
164}
165
166impl Source {
167    pub fn new(name: impl Into<String>, tier: SourceTier) -> Self {
168        Self {
169            name: name.into(),
170            url: None,
171            tier,
172            source_type: SourceType::Documentation,
173            domain: None,
174            author: None,
175            date: None,
176            verified: false,
177            quote: None,
178            stance: Stance::Neutral,
179            credibility: tier.weight(),
180        }
181    }
182
183    pub fn with_url(mut self, url: impl Into<String>) -> Self {
184        self.url = Some(url.into());
185        self
186    }
187
188    pub fn with_type(mut self, source_type: SourceType) -> Self {
189        self.source_type = source_type;
190        self
191    }
192
193    pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
194        self.domain = Some(domain.into());
195        self
196    }
197
198    pub fn with_author(mut self, author: impl Into<String>) -> Self {
199        self.author = Some(author.into());
200        self
201    }
202
203    pub fn with_quote(mut self, quote: impl Into<String>) -> Self {
204        self.quote = Some(quote.into());
205        self
206    }
207
208    pub fn with_stance(mut self, stance: Stance) -> Self {
209        self.stance = stance;
210        self
211    }
212
213    pub fn verified(mut self) -> Self {
214        self.verified = true;
215        self
216    }
217
218    /// Calculate effective weight
219    pub fn effective_weight(&self) -> f32 {
220        let base = self.tier.weight();
221        let stance_modifier: f32 = match self.stance {
222            Stance::Support => 1.0,
223            Stance::Contradict => -1.0,
224            Stance::Neutral => 0.3,
225            Stance::Partial => 0.6,
226        };
227        let verified_bonus = if self.verified { 1.2 } else { 1.0 };
228
229        base * stance_modifier.abs() * verified_bonus * self.credibility
230    }
231}
232
233/// Result of triangulation
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct TriangulationResult {
236    /// The claim being verified
237    pub claim: String,
238    /// All sources considered
239    pub sources: Vec<Source>,
240    /// Verification score (0.0-1.0)
241    pub verification_score: f32,
242    /// Is the claim verified (score >= threshold)?
243    pub is_verified: bool,
244    /// Number of supporting sources
245    pub support_count: usize,
246    /// Number of contradicting sources
247    pub contradict_count: usize,
248    /// Effective triangulation weight
249    pub triangulation_weight: f32,
250    /// Diversity of sources
251    pub source_diversity: f32,
252    /// Issues found
253    pub issues: Vec<TriangulationIssue>,
254    /// Confidence level
255    pub confidence: VerificationConfidence,
256    /// Recommendation
257    pub recommendation: VerificationRecommendation,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct TriangulationIssue {
262    pub issue_type: TriangulationIssueType,
263    pub description: String,
264    pub severity: IssueSeverity,
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
268pub enum TriangulationIssueType {
269    InsufficientSources,
270    NoTier1Source,
271    AllSourcesSameDomain,
272    AllSourcesSameAuthor,
273    UnverifiedUrls,
274    ContradictoryEvidence,
275    StaleData,
276    CircularSources,
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
280pub enum IssueSeverity {
281    Warning,
282    Error,
283    Critical,
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
287pub enum VerificationConfidence {
288    /// High confidence (>= 3 Tier 1 sources)
289    High,
290    /// Medium confidence (3+ sources, mixed tiers)
291    Medium,
292    /// Low confidence (< 3 sources or all low tier)
293    Low,
294    /// Unable to verify
295    Unverifiable,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub enum VerificationRecommendation {
300    /// Claim can be stated as fact
301    AcceptAsFact,
302    /// Claim can be stated with qualifier
303    AcceptWithQualifier(String),
304    /// More sources needed
305    NeedsMoreSources,
306    /// Conflicting evidence - present both sides
307    PresentBothSides,
308    /// Claim should not be made
309    Reject,
310    /// Unable to determine
311    Inconclusive,
312}
313
314/// Configuration for triangulation
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct TriangulationConfig {
317    /// Minimum sources required
318    pub min_sources: usize,
319    /// Minimum Tier 1 sources
320    pub min_tier1_sources: usize,
321    /// Verification threshold
322    pub verification_threshold: f32,
323    /// Require URL verification
324    pub require_verified_urls: bool,
325    /// Maximum age of sources (days)
326    pub max_source_age_days: Option<u32>,
327    /// Require domain diversity
328    pub require_domain_diversity: bool,
329}
330
331impl Default for TriangulationConfig {
332    fn default() -> Self {
333        Self {
334            min_sources: 3,
335            min_tier1_sources: 1,
336            verification_threshold: 0.6,
337            require_verified_urls: false,
338            max_source_age_days: None,
339            require_domain_diversity: true,
340        }
341    }
342}
343
344/// The triangulation engine
345pub struct Triangulator {
346    pub config: TriangulationConfig,
347    sources: Vec<Source>,
348    claim: Option<String>,
349}
350
351impl Triangulator {
352    pub fn new() -> Self {
353        Self {
354            config: TriangulationConfig::default(),
355            sources: Vec::new(),
356            claim: None,
357        }
358    }
359
360    pub fn with_config(config: TriangulationConfig) -> Self {
361        Self {
362            config,
363            sources: Vec::new(),
364            claim: None,
365        }
366    }
367
368    pub fn set_claim(&mut self, claim: impl Into<String>) {
369        self.claim = Some(claim.into());
370    }
371
372    pub fn add_source(&mut self, source: Source) {
373        self.sources.push(source);
374    }
375
376    pub fn clear_sources(&mut self) {
377        self.sources.clear();
378    }
379
380    /// Verify the claim
381    pub fn verify(&self) -> TriangulationResult {
382        let claim = self.claim.clone().unwrap_or_default();
383        let mut issues = Vec::new();
384
385        // Check minimum sources
386        if self.sources.len() < self.config.min_sources {
387            issues.push(TriangulationIssue {
388                issue_type: TriangulationIssueType::InsufficientSources,
389                description: format!(
390                    "Only {} sources, minimum {} required",
391                    self.sources.len(),
392                    self.config.min_sources
393                ),
394                severity: IssueSeverity::Critical,
395            });
396        }
397
398        // Check Tier 1 sources
399        let tier1_count = self
400            .sources
401            .iter()
402            .filter(|s| s.tier == SourceTier::Primary)
403            .count();
404
405        if tier1_count < self.config.min_tier1_sources {
406            issues.push(TriangulationIssue {
407                issue_type: TriangulationIssueType::NoTier1Source,
408                description: format!(
409                    "Only {} Tier 1 sources, minimum {} required",
410                    tier1_count, self.config.min_tier1_sources
411                ),
412                severity: IssueSeverity::Error,
413            });
414        }
415
416        // Check domain diversity
417        if self.config.require_domain_diversity {
418            let domains: std::collections::HashSet<_> = self
419                .sources
420                .iter()
421                .filter_map(|s| s.domain.as_ref())
422                .collect();
423
424            if domains.len() < 2 && self.sources.len() >= 2 {
425                issues.push(TriangulationIssue {
426                    issue_type: TriangulationIssueType::AllSourcesSameDomain,
427                    description: "All sources from same domain - need diversity".into(),
428                    severity: IssueSeverity::Warning,
429                });
430            }
431        }
432
433        // Check for contradictions
434        let support_count = self
435            .sources
436            .iter()
437            .filter(|s| s.stance == Stance::Support || s.stance == Stance::Partial)
438            .count();
439
440        let contradict_count = self
441            .sources
442            .iter()
443            .filter(|s| s.stance == Stance::Contradict)
444            .count();
445
446        if support_count > 0 && contradict_count > 0 {
447            issues.push(TriangulationIssue {
448                issue_type: TriangulationIssueType::ContradictoryEvidence,
449                description: format!(
450                    "{} supporting, {} contradicting - conflicting evidence",
451                    support_count, contradict_count
452                ),
453                severity: IssueSeverity::Warning,
454            });
455        }
456
457        // Calculate triangulation weight
458        let total_support_weight: f32 = self
459            .sources
460            .iter()
461            .filter(|s| s.stance == Stance::Support || s.stance == Stance::Partial)
462            .map(|s| s.effective_weight())
463            .sum();
464
465        let total_contradict_weight: f32 = self
466            .sources
467            .iter()
468            .filter(|s| s.stance == Stance::Contradict)
469            .map(|s| s.effective_weight())
470            .sum();
471
472        let triangulation_weight = total_support_weight - total_contradict_weight;
473
474        // Calculate verification score
475        let max_possible_weight = self.sources.len() as f32 * 1.0; // Max if all Tier 1 supporting
476        let verification_score = if max_possible_weight > 0.0 {
477            (triangulation_weight / max_possible_weight).clamp(0.0, 1.0)
478        } else {
479            0.0
480        };
481
482        // Calculate source diversity (different domains/authors/types)
483        let source_diversity = self.calculate_diversity();
484
485        // Determine confidence level
486        let confidence = if tier1_count >= 2 && support_count >= 3 && contradict_count == 0 {
487            VerificationConfidence::High
488        } else if self.sources.len() >= 3 && support_count >= 2 {
489            VerificationConfidence::Medium
490        } else if self.sources.is_empty() {
491            VerificationConfidence::Unverifiable
492        } else {
493            VerificationConfidence::Low
494        };
495
496        // Determine recommendation
497        let recommendation = if issues.iter().any(|i| i.severity == IssueSeverity::Critical) {
498            VerificationRecommendation::NeedsMoreSources
499        } else if contradict_count > support_count {
500            VerificationRecommendation::Reject
501        } else if support_count > 0 && contradict_count > 0 {
502            VerificationRecommendation::PresentBothSides
503        } else if verification_score >= self.config.verification_threshold {
504            if confidence == VerificationConfidence::High {
505                VerificationRecommendation::AcceptAsFact
506            } else {
507                VerificationRecommendation::AcceptWithQualifier(format!(
508                    "Based on {} sources",
509                    support_count
510                ))
511            }
512        } else if verification_score > 0.0 {
513            VerificationRecommendation::AcceptWithQualifier("Limited evidence suggests".into())
514        } else if self.sources.is_empty() {
515            VerificationRecommendation::Inconclusive
516        } else {
517            VerificationRecommendation::NeedsMoreSources
518        };
519
520        let is_verified = verification_score >= self.config.verification_threshold
521            && !issues.iter().any(|i| i.severity == IssueSeverity::Critical);
522
523        TriangulationResult {
524            claim,
525            sources: self.sources.clone(),
526            verification_score,
527            is_verified,
528            support_count,
529            contradict_count,
530            triangulation_weight,
531            source_diversity,
532            issues,
533            confidence,
534            recommendation,
535        }
536    }
537
538    fn calculate_diversity(&self) -> f32 {
539        if self.sources.is_empty() {
540            return 0.0;
541        }
542
543        let domains: std::collections::HashSet<_> = self
544            .sources
545            .iter()
546            .filter_map(|s| s.domain.as_ref())
547            .collect();
548
549        let authors: std::collections::HashSet<_> = self
550            .sources
551            .iter()
552            .filter_map(|s| s.author.as_ref())
553            .collect();
554
555        let types: std::collections::HashSet<_> =
556            self.sources.iter().map(|s| s.source_type).collect();
557
558        let tiers: std::collections::HashSet<_> = self.sources.iter().map(|s| s.tier).collect();
559
560        let n = self.sources.len() as f32;
561        let domain_diversity = domains.len() as f32 / n.min(5.0);
562        let author_diversity = authors.len() as f32 / n.min(5.0);
563        let type_diversity = types.len() as f32 / 4.0; // 4 main types
564        let tier_diversity = tiers.len() as f32 / 4.0; // 4 tiers
565
566        (domain_diversity * 0.3
567            + author_diversity * 0.3
568            + type_diversity * 0.2
569            + tier_diversity * 0.2)
570            .min(1.0)
571    }
572}
573
574impl Default for Triangulator {
575    fn default() -> Self {
576        Self::new()
577    }
578}
579
580impl TriangulationResult {
581    /// Format as structured text
582    pub fn format(&self) -> String {
583        let mut output = String::new();
584
585        output
586            .push_str("┌─────────────────────────────────────────────────────────────────────┐\n");
587        output
588            .push_str("│                  TRIANGULATION REPORT                               │\n");
589        output
590            .push_str("├─────────────────────────────────────────────────────────────────────┤\n");
591
592        output.push_str(&format!("│ CLAIM: {}\n", self.claim));
593        output
594            .push_str("├─────────────────────────────────────────────────────────────────────┤\n");
595
596        // Summary
597        output.push_str(&format!(
598            "│ STATUS: {} (Score: {:.0}%)\n",
599            if self.is_verified {
600                "✓ VERIFIED"
601            } else {
602                "✗ UNVERIFIED"
603            },
604            self.verification_score * 100.0
605        ));
606        output.push_str(&format!("│ CONFIDENCE: {:?}\n", self.confidence));
607        output.push_str(&format!("│ RECOMMENDATION: {:?}\n", self.recommendation));
608
609        output
610            .push_str("├─────────────────────────────────────────────────────────────────────┤\n");
611        output.push_str("│ SOURCES:\n");
612
613        for source in &self.sources {
614            let stance_icon = match source.stance {
615                Stance::Support => "✓",
616                Stance::Contradict => "✗",
617                Stance::Neutral => "○",
618                Stance::Partial => "◐",
619            };
620            output.push_str(&format!(
621                "│   {} [{}] {} ({})\n",
622                stance_icon,
623                source.tier.label(),
624                source.name,
625                if source.verified {
626                    "verified"
627                } else {
628                    "unverified"
629                }
630            ));
631        }
632
633        // Stats
634        output
635            .push_str("├─────────────────────────────────────────────────────────────────────┤\n");
636        output.push_str(&format!(
637            "│ STATS: {} supporting, {} contradicting, diversity: {:.0}%\n",
638            self.support_count,
639            self.contradict_count,
640            self.source_diversity * 100.0
641        ));
642
643        // Issues
644        if !self.issues.is_empty() {
645            output.push_str(
646                "├─────────────────────────────────────────────────────────────────────┤\n",
647            );
648            output.push_str("│ ISSUES:\n");
649            for issue in &self.issues {
650                let severity_icon = match issue.severity {
651                    IssueSeverity::Warning => "⚠",
652                    IssueSeverity::Error => "⛔",
653                    IssueSeverity::Critical => "🚫",
654                };
655                output.push_str(&format!("│   {} {}\n", severity_icon, issue.description));
656            }
657        }
658
659        output
660            .push_str("└─────────────────────────────────────────────────────────────────────┘\n");
661
662        output
663    }
664}
665
666/// Prompt templates for triangulation
667pub struct TriangulationPrompts;
668
669impl TriangulationPrompts {
670    /// Find sources for a claim
671    pub fn find_sources(claim: &str) -> String {
672        format!(
673            r#"Find 3+ independent sources to verify or refute this claim:
674
675CLAIM: {claim}
676
677For each source, provide:
6781. Name/Title
6792. URL (if available)
6803. Tier:
681   - Tier 1 (Primary): Official docs, peer-reviewed papers, primary sources
682   - Tier 2 (Secondary): Reputable news, expert blogs, industry reports
683   - Tier 3 (Independent): Community content, forums
684   - Tier 4 (Unverified): Social media, unknown sources
685
6864. Type: Academic/Documentation/News/Expert/Government/Industry/Community/Social
6875. Domain: What field is this from?
6886. Author: Who wrote/published this?
6897. Stance: Support/Contradict/Neutral/Partial
6908. Direct quote: Key quote supporting/refuting the claim
691
692CRITICAL REQUIREMENTS:
693- Minimum 3 sources from different domains
694- At least 1 Tier 1 source
695- Look for contradicting evidence too
696- Verify URLs are accessible
697
698Respond in JSON format."#,
699            claim = claim
700        )
701    }
702
703    /// Evaluate triangulation result
704    pub fn evaluate_triangulation(sources: &str, claim: &str) -> String {
705        format!(
706            r#"Evaluate whether this claim is sufficiently triangulated:
707
708CLAIM: {claim}
709
710SOURCES PROVIDED:
711{sources}
712
713Evaluate:
7141. Are there at least 3 independent sources?
7152. Is there at least 1 Tier 1 (primary) source?
7163. Do sources come from different domains?
7174. Are there any contradictions?
7185. What is the overall verification score (0-100%)?
7196. What is your confidence level (High/Medium/Low/Unverifiable)?
7207. What is your recommendation?
721   - Accept as fact
722   - Accept with qualifier
723   - Needs more sources
724   - Present both sides
725   - Reject
726   - Inconclusive
727
728Respond in JSON format."#,
729            claim = claim,
730            sources = sources
731        )
732    }
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738
739    #[test]
740    fn test_source_creation() {
741        let source = Source::new("Test Paper", SourceTier::Primary)
742            .with_url("https://example.com")
743            .with_domain("AI")
744            .with_stance(Stance::Support)
745            .verified();
746
747        assert_eq!(source.tier, SourceTier::Primary);
748        assert!(source.verified);
749        assert_eq!(source.stance, Stance::Support);
750    }
751
752    #[test]
753    fn test_basic_triangulation() {
754        let mut triangulator = Triangulator::new();
755        triangulator.set_claim("AI can pass the Turing test");
756
757        triangulator.add_source(
758            Source::new("Research Paper", SourceTier::Primary)
759                .with_domain("AI")
760                .with_stance(Stance::Support)
761                .verified(),
762        );
763        triangulator.add_source(
764            Source::new("Industry Report", SourceTier::Secondary)
765                .with_domain("Tech")
766                .with_stance(Stance::Support)
767                .verified(),
768        );
769        triangulator.add_source(
770            Source::new("Expert Blog", SourceTier::Independent)
771                .with_domain("ML")
772                .with_stance(Stance::Support),
773        );
774
775        let result = triangulator.verify();
776
777        assert!(result.support_count >= 3);
778        assert_eq!(result.contradict_count, 0);
779        assert!(result.verification_score > 0.5);
780    }
781
782    #[test]
783    fn test_insufficient_sources() {
784        let mut triangulator = Triangulator::new();
785        triangulator.set_claim("Some claim");
786        triangulator.add_source(Source::new("Single Source", SourceTier::Primary));
787
788        let result = triangulator.verify();
789
790        assert!(!result.is_verified);
791        assert!(result
792            .issues
793            .iter()
794            .any(|i| i.issue_type == TriangulationIssueType::InsufficientSources));
795    }
796
797    #[test]
798    fn test_contradictory_evidence() {
799        let mut triangulator = Triangulator::new();
800        triangulator.set_claim("Contested claim");
801
802        triangulator
803            .add_source(Source::new("Source A", SourceTier::Primary).with_stance(Stance::Support));
804        triangulator.add_source(
805            Source::new("Source B", SourceTier::Primary).with_stance(Stance::Contradict),
806        );
807        triangulator.add_source(
808            Source::new("Source C", SourceTier::Secondary).with_stance(Stance::Support),
809        );
810
811        let result = triangulator.verify();
812
813        assert!(result.contradict_count > 0);
814        assert!(result
815            .issues
816            .iter()
817            .any(|i| i.issue_type == TriangulationIssueType::ContradictoryEvidence));
818    }
819
820    #[test]
821    fn test_tier_weights() {
822        assert!(SourceTier::Primary.weight() > SourceTier::Secondary.weight());
823        assert!(SourceTier::Secondary.weight() > SourceTier::Independent.weight());
824        assert!(SourceTier::Independent.weight() > SourceTier::Unverified.weight());
825    }
826
827    #[test]
828    fn test_source_type_default_tier() {
829        assert_eq!(SourceType::Academic.default_tier(), SourceTier::Primary);
830        assert_eq!(SourceType::News.default_tier(), SourceTier::Secondary);
831        assert_eq!(
832            SourceType::Community.default_tier(),
833            SourceTier::Independent
834        );
835        assert_eq!(SourceType::Social.default_tier(), SourceTier::Unverified);
836    }
837}