//! Aphoria Authority Lens - formalizes the authority-based conflict scoring. //! //! Wraps the existing scoring formula from `conflict.rs` into a proper //! `stemedb_lens::Lens` implementation. This allows the authority resolution //! logic to be used as a first-class Lens in Episteme queries. use stemedb_core::types::{Assertion, SourceClass}; use stemedb_lens::{Lens, Resolution}; use crate::types::TierBreakdown; /// Authority-based lens that resolves conflicts by source class tier. /// /// Higher-authority sources (lower tier numbers) win. Uses the same formula /// as `compute_conflict_score()` in `conflict.rs`: /// /// ```text /// normalized = 0.4 + (3.0 - min_tier) / 3.0 * 0.55 /// ``` /// /// Tier 0 (Regulatory) produces score ~0.95, Tier 3 (Expert) produces ~0.40. pub struct AphoriaAuthorityLens; impl Lens for AphoriaAuthorityLens { fn resolve(&self, candidates: &[Assertion]) -> Resolution { if candidates.is_empty() { return Resolution::empty(); } if candidates.len() == 1 { return Resolution::with_winner(candidates[0].clone(), 1, 1.0, 0.0); } // Group by tier, pick the winner from the highest-authority (lowest tier) group let mut best_tier = u8::MAX; let mut best_assertion: Option<&Assertion> = None; let mut best_confidence: f32 = 0.0; for assertion in candidates { let tier = assertion.source_class.tier(); if tier < best_tier || (tier == best_tier && assertion.confidence > best_confidence) { best_tier = tier; best_assertion = Some(assertion); best_confidence = assertion.confidence; } } let winner = match best_assertion { Some(a) => a.clone(), None => return Resolution::empty(), }; // Compute conflict score using the same formula as conflict.rs let conflict_score = authority_conflict_score(candidates); // Resolution confidence is based on how dominant the winning tier is let min_tier = best_tier as f32; let resolution_confidence = 0.4 + (3.0 - min_tier.min(3.0)) / 3.0 * 0.55; Resolution::with_winner( winner, candidates.len(), resolution_confidence.min(1.0), conflict_score, ) } fn name(&self) -> &'static str { "AphoriaAuthority" } } /// Compute cross-tier conflict score for a set of assertions. /// /// Uses the same normalized formula as `conflict.rs:compute_conflict_score()`: /// `normalized = 0.4 + (3.0 - min_tier) / 3.0 * 0.55` /// /// Returns 0.0 if all assertions are the same tier, higher values when /// high-authority sources (Tier 0) conflict with low-authority (Tier 3+). fn authority_conflict_score(candidates: &[Assertion]) -> f32 { if candidates.len() <= 1 { return 0.0; } let min_tier = candidates.iter().map(|a| a.source_class.tier()).min().unwrap_or(3); let max_tier = candidates.iter().map(|a| a.source_class.tier()).max().unwrap_or(3); if min_tier == max_tier { return 0.0; // Same tier, no authority conflict } // Tier distance maps to conflict intensity let tier_distance = (max_tier - min_tier) as f32; (tier_distance / 5.0).min(1.0) // Max 5 tiers apart (0-5) } /// Compute tier breakdown from a set of assertions. /// /// Returns a sorted (by tier) list of tier breakdowns. pub fn compute_tier_breakdown(assertions: &[Assertion]) -> Vec { use std::collections::BTreeMap; let mut by_tier: BTreeMap = BTreeMap::new(); for assertion in assertions { let tier = assertion.source_class.tier(); let entry = by_tier.entry(tier).or_insert((assertion.source_class, 0, 0.0)); entry.1 += 1; if assertion.confidence > entry.2 { entry.2 = assertion.confidence; } } by_tier .into_iter() .map(|(tier, (source_class, count, max_conf))| TierBreakdown { tier, source_class, assertion_count: count, max_confidence: max_conf, }) .collect() } #[cfg(test)] mod tests { use super::*; use stemedb_core::testing::AssertionBuilder; #[test] fn test_empty_candidates() { let lens = AphoriaAuthorityLens; let result = lens.resolve(&[]); assert!(result.winner.is_none()); assert_eq!(result.candidates_count, 0); } #[test] fn test_single_candidate() { let lens = AphoriaAuthorityLens; let assertion = AssertionBuilder::new().source_class(SourceClass::Regulatory).confidence(0.95).build(); let result = lens.resolve(&[assertion]); assert!(result.winner.is_some()); assert_eq!(result.candidates_count, 1); } #[test] fn test_authority_wins_over_lower_tier() { let lens = AphoriaAuthorityLens; let regulatory = AssertionBuilder::new() .subject("rfc://test") .source_class(SourceClass::Regulatory) .confidence(0.9) .build(); let community = AssertionBuilder::new() .subject("code://test") .source_class(SourceClass::Community) .confidence(1.0) .build(); let result = lens.resolve(&[community, regulatory]); let winner = result.winner.as_ref().expect("should have winner"); assert_eq!(winner.source_class, SourceClass::Regulatory); assert_eq!(result.candidates_count, 2); } #[test] fn test_lens_scores_match_existing() { // Verify the normalized formula matches conflict.rs expectations // Tier 0 vs code → ~0.95 let regulatory = AssertionBuilder::new().source_class(SourceClass::Regulatory).confidence(1.0).build(); let community = AssertionBuilder::new().source_class(SourceClass::Community).confidence(1.0).build(); let lens = AphoriaAuthorityLens; let result = lens.resolve(&[regulatory, community]); // Resolution confidence for Tier 0 winner should be ~0.95 assert!( result.resolution_confidence > 0.9, "Expected >0.9, got {}", result.resolution_confidence ); } #[test] fn test_tier_breakdown() { let assertions = vec![ AssertionBuilder::new().source_class(SourceClass::Regulatory).confidence(0.95).build(), AssertionBuilder::new().source_class(SourceClass::Regulatory).confidence(0.9).build(), AssertionBuilder::new().source_class(SourceClass::Community).confidence(0.7).build(), ]; let breakdown = compute_tier_breakdown(&assertions); assert_eq!(breakdown.len(), 2); assert_eq!(breakdown[0].tier, 0); // Regulatory assert_eq!(breakdown[0].assertion_count, 2); assert!((breakdown[0].max_confidence - 0.95).abs() < f32::EPSILON); assert_eq!(breakdown[1].assertion_count, 1); } }