//! Core Lens trait and types. //! //! The Lens trait is the fundamental abstraction for resolving conflicting //! assertions into a deterministic answer. //! //! # Two Kinds of Lenses //! //! | Trait | Purpose | Returns | //! |-------|---------|---------| //! | `Lens` / `AsyncLens` | Pick a winner | `Resolution` (single assertion) | //! | `AnalysisLens` | Map conflict landscape | `ConflictAnalysis` (all claims) | //! //! Use `Lens` when you need "the answer". Use `AnalysisLens` when you need //! "Trust but Verify" - showing users what's contested. use async_trait::async_trait; use stemedb_core::types::{Assertion, ConflictAnalysis, SourceClass}; /// The result of a Lens resolution. #[derive(Debug, Clone)] pub struct Resolution { /// The winning assertion, if any candidates were provided. pub winner: Option, /// All candidates that were considered. pub candidates_count: usize, /// Confidence in the resolution (0.0 to 1.0). /// Higher values indicate stronger consensus or more decisive selection. pub resolution_confidence: f32, /// Degree of disagreement among candidates (0.0 = full agreement, 1.0 = max conflict). /// Computed as normalized variance of candidate confidence values. /// This is the numeric basis for the "disagreement is the information" thesis. pub conflict_score: f32, } impl Resolution { /// Create an empty resolution (no candidates). pub fn empty() -> Self { Self { winner: None, candidates_count: 0, resolution_confidence: 0.0, conflict_score: 0.0 } } /// Create a resolution with a single winner. pub fn with_winner( winner: Assertion, candidates_count: usize, confidence: f32, conflict_score: f32, ) -> Self { Self { winner: Some(winner), candidates_count, resolution_confidence: confidence, conflict_score, } } } /// Compute conflict score from candidate assertion confidences. /// /// This is the **canonical definition** of conflict score in Episteme. /// Other modules should reference this documentation. /// /// # Algorithm /// /// Uses normalized variance of confidence values: /// - 0 or 1 candidates: 0.0 (no conflict possible) /// - All same confidence: 0.0 (unanimous agreement) /// - Max variance (e.g., 0.0 vs 1.0): 1.0 (maximum disagreement) /// /// # Normalization /// /// The formula normalizes variance to [0.0, 1.0] range: /// variance of [0,1] values has max 0.25 (when values are 0 and 1), /// so we multiply by 4 to normalize. /// /// # Edge Cases /// /// - NaN confidences: Treated defensively as 0.0 conflict (fail-safe) /// - Empty candidates: Returns 0.0 /// - Single candidate: Returns 0.0 (no disagreement possible) /// /// # Vision Alignment /// /// This score enables the "disagreement is the information" thesis: /// high conflict scores surface uncertainty for user review rather than /// hiding it behind a confident-looking single answer. pub fn compute_conflict_score(candidates: &[Assertion]) -> f32 { if candidates.len() <= 1 { return 0.0; } let n = candidates.len() as f32; let sum: f32 = candidates.iter().map(|a| a.confidence).sum(); let mean = sum / n; let variance: f32 = candidates.iter().map(|a| (a.confidence - mean).powi(2)).sum::() / n; // Normalize: max variance of [0,1] values is 0.25, so 4x normalizes to [0,1] let score = 4.0 * variance; // Defensive: NaN from malformed confidences treated as no meaningful conflict if score.is_nan() { return 0.0; } score.min(1.0) } // ============================================================================ // Layered Resolution Types // ============================================================================ /// Per-tier resolution result from `LayeredLens`. /// /// Represents the consensus within a single source class tier. /// Tiers range from 0 (Regulatory, highest authority) to 5 (Anecdotal, lowest). #[derive(Debug, Clone)] pub struct TierResolution { /// The tier number (0-5). Lower = higher authority. pub tier: u8, /// The source class for this tier. pub source_class: SourceClass, /// The winning assertion from within-tier consensus, if any candidates. pub winner: Option, /// Number of candidates in this tier. pub candidates_count: usize, /// Within-tier conflict score (0.0 = unanimous, 1.0 = max conflict). pub conflict_score: f32, /// Within-tier resolution confidence (0.0 to 1.0). pub resolution_confidence: f32, } impl TierResolution { /// Create a tier resolution with no candidates. pub fn empty(tier: u8, source_class: SourceClass) -> Self { Self { tier, source_class, winner: None, candidates_count: 0, conflict_score: 0.0, resolution_confidence: 0.0, } } } /// Multi-tier resolution result from `LayeredLens`. /// /// Contains per-tier consensus results plus an overall winner. /// Enables "What does Tier 0 say? What does Tier 5 say?" queries. /// /// # Cross-Tier Conflict /// /// The `overall_conflict_score` measures disagreement between tiers: /// - 0.0: All tiers with winners agree on the same object value /// - 1.0: Tiers disagree on what the answer should be /// /// This is different from within-tier conflict (measured in each `TierResolution`). #[derive(Debug, Clone)] pub struct LayeredResolution { /// Per-tier consensus results, ordered by tier (0 = highest authority first). /// Only tiers with at least one candidate are included. pub tiers: Vec, /// Overall winner: winner from the highest-authority tier that has candidates. /// This is the answer from the most authoritative source class present. pub overall_winner: Option, /// Cross-tier disagreement score (0.0 = tiers agree, 1.0 = tiers disagree). /// Measures whether tier winners agree on the same object value. pub overall_conflict_score: f32, /// Total candidates considered across all tiers. pub total_candidates: usize, } impl LayeredResolution { /// Create an empty layered resolution (no candidates in any tier). pub fn empty() -> Self { Self { tiers: Vec::new(), overall_winner: None, overall_conflict_score: 0.0, total_candidates: 0, } } } /// A LayeredLens resolves conflicts with per-tier consensus. /// /// Unlike a standard `Lens` which returns a single winner, a `LayeredLens` /// provides visibility into what each source class tier says. /// /// # Use Case: Consumer Health /// /// Query "semaglutide muscle_loss" and see: /// - Tier 0 (FDA): [no data] /// - Tier 1 (Clinical): "Significant loss" (12 sources, 0.85 confidence) /// - Tier 5 (Anecdotal): "Minimal loss" (200 sources, 0.45 confidence) /// - Overall winner: "Significant loss" (from Tier 1) /// - Cross-tier conflict: 0.8 (clinical and anecdotal disagree) /// /// # Contract /// /// - **Stateless:** LayeredLenses must not maintain internal state. /// - **Deterministic:** Same input must produce same output. /// - **Tier-Ordered:** Results are always ordered by tier (0 first). pub trait LayeredLens: Send + Sync { /// Resolve candidates with per-tier consensus. /// /// # Arguments /// * `candidates` - All assertions matching the query filters /// /// # Returns /// A `LayeredResolution` with per-tier results and overall winner. fn resolve_layered(&self, candidates: &[Assertion]) -> LayeredResolution; /// Human-readable name of this lens for logging/debugging. fn name(&self) -> &'static str; } // ============================================================================ // Standard Resolution Types // ============================================================================ /// A Lens resolves conflicting assertions into a deterministic answer. /// /// # Contract /// /// - **Stateless:** Lenses must not maintain internal state. /// - **Deterministic:** Same input must produce same output. /// - **Fast:** Runs on every read, avoid allocations where possible. /// /// # Implementation Notes /// /// Lenses should handle edge cases gracefully: /// - Empty input: Return `Resolution::empty()` /// - Single candidate: Return that candidate (trivial resolution) /// - Ties: Define a consistent tiebreaker (e.g., lowest hash) pub trait Lens: Send + Sync { /// Resolve a set of candidate assertions into a single answer. /// /// # Arguments /// * `candidates` - All assertions matching the query filters /// /// # Returns /// A resolution containing the winning assertion (if any) and metadata. fn resolve(&self, candidates: &[Assertion]) -> Resolution; /// Human-readable name of this lens for logging/debugging. fn name(&self) -> &'static str; } /// An AnalysisLens maps the conflict landscape instead of picking a winner. /// /// Unlike `Lens` which collapses uncertainty into a single answer, `AnalysisLens` /// surfaces all competing claims with their relative support. This enables /// "Trust but Verify" UX where users see disagreement explicitly. /// /// # Contract /// /// - **Stateless:** AnalysisLenses must not maintain internal state. /// - **Deterministic:** Same input and storage state produces same output. /// - **Complete:** Returns ALL distinct claims, not just the top one. /// /// # When to Use /// /// Use `AnalysisLens` when: /// - User needs to see "who disagrees and why" /// - Fact is contested and hiding conflict would be misleading /// - Building a "Living Review" dashboard for research /// /// Use regular `Lens` when: /// - You need a definitive answer for action /// - Conflict is resolved elsewhere /// - Performance is critical (Analysis is more expensive) #[async_trait] pub trait AnalysisLens: Send + Sync { /// Analyze a set of candidate assertions and return the conflict landscape. /// /// # Arguments /// * `candidates` - All assertions matching the query filters /// /// # Returns /// A `ConflictAnalysis` containing: /// - `status`: Unanimous, Agreed, or Contested /// - `conflict_score`: 0.0 (unanimous) to 1.0 (chaos) /// - `claims`: All distinct claims ranked by support async fn analyze(&self, candidates: &[Assertion]) -> ConflictAnalysis; /// Human-readable name of this lens for logging/debugging. fn name(&self) -> &'static str; } #[cfg(test)] mod tests { use super::*; use stemedb_core::testing::AssertionBuilder; #[test] fn test_empty_resolution() { let resolution = Resolution::empty(); assert!(resolution.winner.is_none()); assert_eq!(resolution.candidates_count, 0); assert!((resolution.resolution_confidence - 0.0).abs() < f32::EPSILON); assert!((resolution.conflict_score - 0.0).abs() < f32::EPSILON); } #[test] fn test_conflict_score_zero_for_empty() { let score = compute_conflict_score(&[]); assert!((score - 0.0).abs() < f32::EPSILON); } #[test] fn test_conflict_score_zero_for_single() { let assertion = AssertionBuilder::new().confidence(0.9).build(); let score = compute_conflict_score(&[assertion]); assert!((score - 0.0).abs() < f32::EPSILON); } #[test] fn test_conflict_score_zero_for_agreement() { // All same confidence = no conflict let assertions = vec![ AssertionBuilder::new().confidence(0.9).build(), AssertionBuilder::new().confidence(0.9).build(), AssertionBuilder::new().confidence(0.9).build(), ]; let score = compute_conflict_score(&assertions); assert!(score < 0.01, "Expected near-zero, got {}", score); } #[test] fn test_conflict_score_high_for_disagreement() { // Candidates at 0.1, 0.5, 0.9 = high variance let assertions = vec![ AssertionBuilder::new().confidence(0.1).build(), AssertionBuilder::new().confidence(0.5).build(), AssertionBuilder::new().confidence(0.9).build(), ]; let score = compute_conflict_score(&assertions); assert!(score > 0.3, "Expected high conflict, got {}", score); } #[test] fn test_conflict_score_max_for_extremes() { // 0.0 vs 1.0 = maximum disagreement let assertions = vec![ AssertionBuilder::new().confidence(0.0).build(), AssertionBuilder::new().confidence(1.0).build(), ]; let score = compute_conflict_score(&assertions); assert!((score - 1.0).abs() < 0.01, "Expected ~1.0, got {}", score); } #[test] fn test_conflict_score_handles_nan_defensively() { // NaN confidences should result in 0.0 (fail-safe) let mut assertions = vec![ AssertionBuilder::new().confidence(0.5).build(), AssertionBuilder::new().confidence(0.5).build(), ]; assertions[0].confidence = f32::NAN; assertions[1].confidence = f32::NAN; let score = compute_conflict_score(&assertions); assert!((score - 0.0).abs() < f32::EPSILON, "Expected 0.0 for NaN, got {}", score); } }