1use serde::{Deserialize, Serialize};
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum SourceTier {
55 Primary,
58 Secondary,
61 Independent,
64 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
90pub enum SourceType {
91 Academic,
93 Documentation,
95 News,
97 Expert,
99 Government,
101 Industry,
103 Community,
105 Social,
107 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#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct Source {
130 pub name: String,
132 pub url: Option<String>,
134 pub tier: SourceTier,
136 pub source_type: SourceType,
138 pub domain: Option<String>,
140 pub author: Option<String>,
142 pub date: Option<String>,
144 pub verified: bool,
146 pub quote: Option<String>,
148 pub stance: Stance,
150 pub credibility: f32,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155pub enum Stance {
156 Support,
158 Contradict,
160 Neutral,
162 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct TriangulationResult {
236 pub claim: String,
238 pub sources: Vec<Source>,
240 pub verification_score: f32,
242 pub is_verified: bool,
244 pub support_count: usize,
246 pub contradict_count: usize,
248 pub triangulation_weight: f32,
250 pub source_diversity: f32,
252 pub issues: Vec<TriangulationIssue>,
254 pub confidence: VerificationConfidence,
256 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,
290 Medium,
292 Low,
294 Unverifiable,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
299pub enum VerificationRecommendation {
300 AcceptAsFact,
302 AcceptWithQualifier(String),
304 NeedsMoreSources,
306 PresentBothSides,
308 Reject,
310 Inconclusive,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct TriangulationConfig {
317 pub min_sources: usize,
319 pub min_tier1_sources: usize,
321 pub verification_threshold: f32,
323 pub require_verified_urls: bool,
325 pub max_source_age_days: Option<u32>,
327 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
344pub 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 pub fn verify(&self) -> TriangulationResult {
382 let claim = self.claim.clone().unwrap_or_default();
383 let mut issues = Vec::new();
384
385 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 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 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 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 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 let max_possible_weight = self.sources.len() as f32 * 1.0; 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 let source_diversity = self.calculate_diversity();
484
485 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 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; let tier_diversity = tiers.len() as f32 / 4.0; (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 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 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 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 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
666pub struct TriangulationPrompts;
668
669impl TriangulationPrompts {
670 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 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}