//! Layered Consensus Lens: Per-source-class consensus resolution. //! //! This lens provides visibility into what each authority tier says, //! rather than collapsing everything into a single winner. //! //! # Use Case: Consumer Health //! //! Query "semaglutide muscle_loss" and see: //! - Tier 0 (Regulatory): [no data] //! - Tier 1 (Clinical): "Significant loss" (12 sources) //! - Tier 5 (Anecdotal): "Minimal loss" (200 sources) //! //! The overall winner comes from the highest-authority tier present (Tier 1), //! but the consumer can see that anecdotal sources disagree. //! //! # Algorithm //! //! 1. Group candidates by `source_class.tier()` //! 2. For each tier, run `ConsensusLens::resolve()` to get within-tier winner //! 3. Compute per-tier conflict score //! 4. Overall winner = winner from lowest tier number (highest authority) //! 5. Cross-tier conflict = do tier winners agree on the same object value? use crate::consensus::ConsensusLens; use crate::traits::{LayeredLens, LayeredResolution, Lens, Resolution, TierResolution}; use std::collections::HashMap; use stemedb_core::types::{Assertion, SourceClass}; use tracing::instrument; /// Layered Consensus Lens: Provides per-tier resolution results. /// /// # Example /// /// ```rust,ignore /// use stemedb_lens::{LayeredConsensusLens, LayeredLens}; /// use stemedb_core::types::Assertion; /// /// let lens = LayeredConsensusLens::new(); /// let assertions: Vec = vec![/* ... */]; /// let result = lens.resolve_layered(&assertions); /// /// for tier in &result.tiers { /// println!("Tier {}: {:?} candidates", tier.tier, tier.candidates_count); /// } /// ``` #[derive(Debug, Clone, Copy, Default)] pub struct LayeredConsensusLens; impl LayeredConsensusLens { /// Create a new LayeredConsensusLens. pub fn new() -> Self { Self } /// Group assertions by their source class tier. fn group_by_tier(candidates: &[Assertion]) -> HashMap> { let mut groups: HashMap> = HashMap::new(); for assertion in candidates { let tier = assertion.source_class.tier(); groups.entry(tier).or_default().push(assertion); } groups } /// Get the SourceClass for a given tier number. fn tier_to_source_class(tier: u8) -> SourceClass { match tier { 0 => SourceClass::Regulatory, 1 => SourceClass::Clinical, 2 => SourceClass::Observational, 3 => SourceClass::Expert, 4 => SourceClass::Community, _ => SourceClass::Anecdotal, } } /// Compute cross-tier conflict score. /// /// Measures disagreement between tier winners: /// - 0.0: All tier winners have the same object value /// - 1.0: Tier winners have completely different object values /// /// # Algorithm /// /// Groups tier winners by their object value and computes normalized entropy. /// If only one tier has a winner, conflict is 0.0 (no disagreement possible). fn compute_cross_tier_conflict(tier_winners: &[&Assertion]) -> f32 { if tier_winners.len() <= 1 { return 0.0; } // Group winners by their object value (using Debug format as equality proxy) let mut value_counts: HashMap = HashMap::new(); for winner in tier_winners { let key = format!("{:?}", winner.object); *value_counts.entry(key).or_default() += 1; } let num_unique_values = value_counts.len(); if num_unique_values == 1 { // All winners agree return 0.0; } // Compute normalized entropy let total = tier_winners.len() as f32; let mut entropy = 0.0f32; for &count in value_counts.values() { if count > 0 { let p = count as f32 / total; entropy -= p * p.ln(); } } // Normalize by max entropy (uniform distribution) let max_entropy = (num_unique_values as f32).ln(); if max_entropy > 0.0 { (entropy / max_entropy).min(1.0) } else { 0.0 } } } impl LayeredLens for LayeredConsensusLens { #[instrument(skip(self, candidates), fields(candidates_count = candidates.len(), lens = "LayeredConsensus"))] fn resolve_layered(&self, candidates: &[Assertion]) -> LayeredResolution { if candidates.is_empty() { return LayeredResolution::empty(); } // Group by tier let tier_groups = Self::group_by_tier(candidates); // Resolve each tier let consensus_lens = ConsensusLens; let mut tier_resolutions: Vec = Vec::new(); // Process tiers in order (0 to 5) for tier in 0..=5u8 { if let Some(tier_candidates) = tier_groups.get(&tier) { // Convert refs to owned for ConsensusLens let owned: Vec = tier_candidates.iter().map(|a| (*a).clone()).collect(); let resolution = consensus_lens.resolve(&owned); let tier_resolution = TierResolution { tier, source_class: Self::tier_to_source_class(tier), winner: resolution.winner, candidates_count: resolution.candidates_count, conflict_score: resolution.conflict_score, resolution_confidence: resolution.resolution_confidence, }; tier_resolutions.push(tier_resolution); } } // Overall winner = winner from highest-authority tier (lowest tier number) let overall_winner = tier_resolutions.iter().find_map(|tr| tr.winner.clone()); // Collect tier winners for cross-tier conflict calculation let tier_winners: Vec<&Assertion> = tier_resolutions.iter().filter_map(|tr| tr.winner.as_ref()).collect(); let overall_conflict_score = Self::compute_cross_tier_conflict(&tier_winners); LayeredResolution { tiers: tier_resolutions, overall_winner, overall_conflict_score, total_candidates: candidates.len(), } } fn name(&self) -> &'static str { "LayeredConsensus" } } /// Implement standard Lens trait for compatibility. /// /// Returns the overall winner as a regular Resolution. /// Use `resolve_layered()` for the richer per-tier results. impl Lens for LayeredConsensusLens { #[instrument(skip(self, candidates), fields(candidates_count = candidates.len(), lens = "LayeredConsensus"))] fn resolve(&self, candidates: &[Assertion]) -> Resolution { let layered = self.resolve_layered(candidates); match layered.overall_winner { Some(winner) => { // Aggregate confidence from the winning tier let winning_tier = layered.tiers.first(); let confidence = winning_tier.map(|t| t.resolution_confidence).unwrap_or(1.0); Resolution::with_winner( winner, layered.total_candidates, confidence, layered.overall_conflict_score, ) } None => Resolution::empty(), } } fn name(&self) -> &'static str { "LayeredConsensus" } } #[cfg(test)] mod tests { use super::*; use stemedb_core::testing::AssertionBuilder; use stemedb_core::types::ObjectValue; /// Create an assertion with the specified source class and object value. fn create_assertion(source_class: SourceClass, value: &str, timestamp: u64) -> Assertion { AssertionBuilder::new() .source_class(source_class) .object_text(value) .timestamp(timestamp) .build() } #[test] fn test_layered_empty_candidates() { let lens = LayeredConsensusLens::new(); let result = lens.resolve_layered(&[]); assert!(result.overall_winner.is_none()); assert!(result.tiers.is_empty()); assert_eq!(result.total_candidates, 0); assert!((result.overall_conflict_score - 0.0).abs() < f32::EPSILON); } #[test] fn test_layered_single_tier() { // All candidates are from the same source class (Expert, Tier 3) let lens = LayeredConsensusLens::new(); let assertions = vec![ create_assertion(SourceClass::Expert, "safe", 1000), create_assertion(SourceClass::Expert, "safe", 1100), create_assertion(SourceClass::Expert, "risky", 1200), ]; let result = lens.resolve_layered(&assertions); // Should have exactly one tier assert_eq!(result.tiers.len(), 1); assert_eq!(result.tiers[0].tier, 3); // Expert = Tier 3 assert_eq!(result.tiers[0].candidates_count, 3); // Winner should be "safe" (2 vs 1) assert!(result.overall_winner.is_some()); if let Some(winner) = &result.overall_winner { assert_eq!(winner.object, ObjectValue::Text("safe".to_string())); } // Cross-tier conflict should be 0 (only one tier) assert!((result.overall_conflict_score - 0.0).abs() < f32::EPSILON); } #[test] fn test_layered_multi_tier_agreement() { // Tier 0 (Regulatory) and Tier 5 (Anecdotal) both say "safe" let lens = LayeredConsensusLens::new(); let assertions = vec![ create_assertion(SourceClass::Regulatory, "safe", 1000), create_assertion(SourceClass::Anecdotal, "safe", 1100), create_assertion(SourceClass::Anecdotal, "safe", 1200), ]; let result = lens.resolve_layered(&assertions); // Should have two tiers (0 and 5) assert_eq!(result.tiers.len(), 2); assert_eq!(result.tiers[0].tier, 0); // Regulatory first assert_eq!(result.tiers[1].tier, 5); // Anecdotal second // Overall winner from Tier 0 assert!(result.overall_winner.is_some()); if let Some(winner) = &result.overall_winner { assert_eq!(winner.object, ObjectValue::Text("safe".to_string())); assert_eq!(winner.source_class, SourceClass::Regulatory); } // Cross-tier conflict should be low (both agree on "safe") assert!( result.overall_conflict_score < 0.1, "Expected low conflict, got {}", result.overall_conflict_score ); } #[test] fn test_layered_multi_tier_disagreement() { // Tier 1 (Clinical) says "safe", Tier 5 (Anecdotal) says "dangerous" let lens = LayeredConsensusLens::new(); let assertions = vec![ create_assertion(SourceClass::Clinical, "safe", 1000), create_assertion(SourceClass::Clinical, "safe", 1100), create_assertion(SourceClass::Anecdotal, "dangerous", 1200), create_assertion(SourceClass::Anecdotal, "dangerous", 1300), create_assertion(SourceClass::Anecdotal, "dangerous", 1400), ]; let result = lens.resolve_layered(&assertions); // Should have two tiers assert_eq!(result.tiers.len(), 2); // Tier 1 (Clinical) is higher authority, so its winner is overall winner assert!(result.overall_winner.is_some()); if let Some(winner) = &result.overall_winner { assert_eq!(winner.object, ObjectValue::Text("safe".to_string())); assert_eq!(winner.source_class, SourceClass::Clinical); } // Cross-tier conflict should be high (tiers disagree) assert!( result.overall_conflict_score > 0.5, "Expected high conflict, got {}", result.overall_conflict_score ); } #[test] fn test_layered_overall_winner_from_highest_authority() { // Tier 0 has 1 assertion, Tier 5 has 1000 assertions // Tier 0 should still win (highest authority) let lens = LayeredConsensusLens::new(); let mut assertions = vec![create_assertion(SourceClass::Regulatory, "approved", 1000)]; // Add many anecdotal assertions for i in 0..100 { assertions.push(create_assertion(SourceClass::Anecdotal, "questionable", 2000 + i)); } let result = lens.resolve_layered(&assertions); // Overall winner should be from Tier 0 (Regulatory) despite being outnumbered assert!(result.overall_winner.is_some()); if let Some(winner) = &result.overall_winner { assert_eq!(winner.object, ObjectValue::Text("approved".to_string())); assert_eq!(winner.source_class, SourceClass::Regulatory); } // Verify tier counts let tier_0 = result.tiers.iter().find(|t| t.tier == 0); let tier_5 = result.tiers.iter().find(|t| t.tier == 5); assert!(tier_0.is_some()); assert_eq!(tier_0.map(|t| t.candidates_count).unwrap_or(0), 1); assert!(tier_5.is_some()); assert_eq!(tier_5.map(|t| t.candidates_count).unwrap_or(0), 100); } #[test] fn test_layered_lens_trait_compatibility() { // Test that the standard Lens trait works let lens = LayeredConsensusLens::new(); let assertions = vec![ create_assertion(SourceClass::Clinical, "effective", 1000), create_assertion(SourceClass::Clinical, "effective", 1100), ]; let resolution = lens.resolve(&assertions); assert!(resolution.winner.is_some()); assert_eq!(resolution.candidates_count, 2); } #[test] fn test_layered_within_tier_conflict() { // Tier 1 has high internal conflict (split opinions) let lens = LayeredConsensusLens::new(); let assertions = vec![ create_assertion(SourceClass::Clinical, "safe", 1000), create_assertion(SourceClass::Clinical, "dangerous", 1100), ]; let result = lens.resolve_layered(&assertions); assert_eq!(result.tiers.len(), 1); // Within-tier conflict should be non-zero (assertions have different confidences // by default, but same confidence here - conflict comes from object disagreement // which is reflected in the resolution confidence, not conflict_score) let tier = &result.tiers[0]; assert_eq!(tier.candidates_count, 2); // Resolution confidence should reflect the split (50/50) assert!(tier.resolution_confidence < 0.6, "Expected low confidence for 50/50 split"); } #[test] fn test_layered_all_tiers_present() { // One assertion from each tier let lens = LayeredConsensusLens::new(); let assertions = vec![ create_assertion(SourceClass::Regulatory, "tier0", 1000), create_assertion(SourceClass::Clinical, "tier1", 1100), create_assertion(SourceClass::Observational, "tier2", 1200), create_assertion(SourceClass::Expert, "tier3", 1300), create_assertion(SourceClass::Community, "tier4", 1400), create_assertion(SourceClass::Anecdotal, "tier5", 1500), ]; let result = lens.resolve_layered(&assertions); // All 6 tiers should be present assert_eq!(result.tiers.len(), 6); // Verify tiers are in order for (i, tier) in result.tiers.iter().enumerate() { assert_eq!(tier.tier, i as u8); } // Overall winner should be from Tier 0 assert!(result.overall_winner.is_some()); if let Some(winner) = &result.overall_winner { assert_eq!(winner.object, ObjectValue::Text("tier0".to_string())); } } #[test] fn test_layered_lens_name() { let lens = LayeredConsensusLens::new(); assert_eq!(::name(&lens), "LayeredConsensus"); assert_eq!(::name(&lens), "LayeredConsensus"); } #[test] fn test_layered_numeric_values() { // Test with numeric object values let lens = LayeredConsensusLens::new(); let assertions = vec![ AssertionBuilder::new() .source_class(SourceClass::Clinical) .object_number(100.0) .timestamp(1000) .build(), AssertionBuilder::new() .source_class(SourceClass::Clinical) .object_number(100.0) .timestamp(1100) .build(), AssertionBuilder::new() .source_class(SourceClass::Anecdotal) .object_number(200.0) .timestamp(1200) .build(), ]; let result = lens.resolve_layered(&assertions); assert!(result.overall_winner.is_some()); if let Some(winner) = &result.overall_winner { assert_eq!(winner.object, ObjectValue::Number(100.0)); } // Cross-tier conflict should be high (100.0 vs 200.0) assert!(result.overall_conflict_score > 0.5); } }