//! Vote-Aware Consensus Lens: Resolves based on actual vote counts from VoteStore. //! //! This lens integrates with the Ballot Box pattern to use real vote counts //! instead of grouping by object value like the basic ConsensusLens. //! //! # Design Philosophy //! //! Follows the "Deep Module" principle: //! - Simple interface: `resolve_async(&[Assertion])` returns winner //! - Complex implementation: Queries VoteStore, ranks by votes, handles ties //! - O(1) vote lookups via VoteStore's cached counters use crate::traits::{compute_conflict_score, Resolution}; use async_trait::async_trait; use stemedb_core::types::{Assertion, Hash}; use stemedb_storage::vote_store::VoteStore; use tracing::{debug, instrument}; /// Async version of the Lens trait for operations requiring I/O. /// /// # Contract /// /// - **Stateless:** AsyncLenses must not maintain internal state. /// - **Deterministic:** Same input and storage state produces same output. /// - **Fast:** Uses cached vote counts from VoteStore for O(1) lookups. /// /// # Implementation Notes /// /// AsyncLenses should handle edge cases gracefully: /// - Empty input: Return `Resolution::empty()` /// - Single candidate: Return that candidate (trivial resolution) /// - Ties: Define a consistent tiebreaker (e.g., highest timestamp) /// - Assertions with no votes: Treat as 0 votes/weight #[async_trait] pub trait AsyncLens: 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. async fn resolve_async(&self, candidates: &[Assertion]) -> Resolution; /// Human-readable name of this lens for logging/debugging. fn name(&self) -> &'static str; } /// Vote-Aware Consensus Lens: Returns the assertion with the highest vote count. /// /// # Resolution Strategy /// /// 1. For each candidate assertion, lookup its vote count and aggregate weight /// 2. Rank assertions by aggregate weight (sum of all vote weights) /// 3. Return the assertion with highest aggregate weight /// 4. Tiebreaker: If weights are equal, prefer most recent timestamp /// 5. Assertions with no votes are treated as having 0 weight /// /// # Confidence Calculation /// /// Resolution confidence is based on the winning assertion's vote proportion: /// - `confidence = winner_weight / total_weight_across_all_candidates` /// - If no votes exist for any candidate, confidence is 0.0 /// - If only one candidate has votes, confidence is 1.0 /// /// # Example /// /// ```ignore /// use stemedb_lens::VoteAwareConsensusLens; /// use stemedb_storage::{HybridStore, GenericVoteStore}; /// use std::sync::Arc; /// /// let store = HybridStore::open("./data").await?; /// let vote_store = Arc::new(GenericVoteStore::new(store)); /// let lens = VoteAwareConsensusLens::new(vote_store); /// /// let resolution = lens.resolve_async(&candidates).await?; /// ``` pub struct VoteAwareConsensusLens { vote_store: std::sync::Arc, } impl VoteAwareConsensusLens { /// Create a new VoteAwareConsensusLens with the given VoteStore. /// /// The VoteStore is wrapped in an Arc for shared ownership, allowing /// the lens to be used in multiple contexts. pub fn new(vote_store: std::sync::Arc) -> Self { Self { vote_store } } /// Compute the content-addressed hash of an assertion. /// /// This matches the logic used by the ingestion pipeline to ensure /// we lookup votes for the correct assertion hash. /// /// Returns `None` if serialization fails, allowing the caller to skip /// the candidate rather than using a potentially colliding hash. fn compute_assertion_hash(assertion: &Assertion) -> Option { // Serialize using the canonical serde module, then hash. let bytes = match stemedb_core::serde::serialize(assertion) { Ok(b) => b, Err(e) => { tracing::warn!("Failed to serialize assertion for hashing: {}", e); return None; } }; let hash_bytes = blake3::hash(&bytes); Some(*hash_bytes.as_bytes()) } } /// Internal struct to track assertion ranking data. #[derive(Debug)] struct RankedAssertion<'a> { assertion: &'a Assertion, vote_count: u64, aggregate_weight: f32, } #[async_trait] impl AsyncLens for VoteAwareConsensusLens { #[instrument(skip(self, candidates), fields(candidates_count = candidates.len()))] async fn resolve_async(&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); } // Collect vote data for all candidates let mut ranked: Vec = Vec::with_capacity(candidates.len()); let mut total_weight = 0.0_f32; for assertion in candidates { let assertion_hash = match Self::compute_assertion_hash(assertion) { Some(hash) => hash, None => { // Serialization failed - skip this candidate debug!("Skipping candidate due to serialization failure"); continue; } }; // Lookup vote count and aggregate weight from VoteStore // These are O(1) operations thanks to VoteStore's cached counters let vote_count = match self.vote_store.get_vote_count(&assertion_hash, &assertion.subject).await { Ok(count) => count, Err(e) => { debug!( assertion_hash = %hex::encode(assertion_hash), error = %e, "Failed to get vote count, treating as 0" ); 0 } }; let aggregate_weight = match self .vote_store .get_aggregate_weight(&assertion_hash, &assertion.subject) .await { Ok(weight) => weight, Err(e) => { debug!( assertion_hash = %hex::encode(assertion_hash), error = %e, "Failed to get aggregate weight, treating as 0.0" ); 0.0 } }; total_weight += aggregate_weight; ranked.push(RankedAssertion { assertion, vote_count, aggregate_weight }); } // Sort by aggregate weight (descending), then by timestamp (descending) for ties ranked.sort_by(|a, b| { b.aggregate_weight .partial_cmp(&a.aggregate_weight) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| b.assertion.timestamp.cmp(&a.assertion.timestamp)) }); // Select the winner (highest ranked) if let Some(winner_ranked) = ranked.first() { let confidence = if total_weight > 0.0 { winner_ranked.aggregate_weight / total_weight } else { // No votes for any candidate 0.0 }; let conflict = compute_conflict_score(candidates); debug!( winner_subject = %winner_ranked.assertion.subject, vote_count = winner_ranked.vote_count, aggregate_weight = winner_ranked.aggregate_weight, confidence, conflict, "Resolved via vote-aware consensus" ); Resolution::with_winner( winner_ranked.assertion.clone(), candidates.len(), confidence, conflict, ) } else { // Should never happen since we checked for empty candidates above Resolution::empty() } } fn name(&self) -> &'static str { "VoteAwareConsensus" } } #[cfg(test)] mod tests { use super::*; use std::sync::Arc; use stemedb_core::testing::{self, AssertionBuilder}; use stemedb_core::types::Vote; use stemedb_storage::{GenericVoteStore, HybridStore}; fn create_assertion(subject: &str, value: f64, timestamp: u64) -> Assertion { AssertionBuilder::new().subject(subject).object_number(value).timestamp(timestamp).build() } fn create_vote(assertion_hash: Hash, agent_id: [u8; 32], weight: f32, timestamp: u64) -> Vote { testing::test_vote(assertion_hash, agent_id, weight, timestamp) } #[tokio::test] async fn test_empty_candidates() { let store = Arc::new(HybridStore::open_temp().expect("Failed to create store")); let vote_store = Arc::new(GenericVoteStore::new(store)); let lens = VoteAwareConsensusLens::new(vote_store); let resolution = lens.resolve_async(&[]).await; assert!(resolution.winner.is_none()); assert_eq!(resolution.candidates_count, 0); } #[tokio::test] async fn test_single_candidate() { let store = Arc::new(HybridStore::open_temp().expect("Failed to create store")); let vote_store = Arc::new(GenericVoteStore::new(store)); let lens = VoteAwareConsensusLens::new(vote_store); let assertion = create_assertion("Tesla", 100.0, 1000); let resolution = lens.resolve_async(std::slice::from_ref(&assertion)).await; assert!(resolution.winner.is_some()); assert_eq!(resolution.winner.as_ref().unwrap().subject, "Tesla"); assert!((resolution.resolution_confidence - 1.0).abs() < f32::EPSILON); } #[tokio::test] async fn test_selects_highest_vote_weight() { let store = Arc::new(HybridStore::open_temp().expect("Failed to create store")); let vote_store = Arc::new(GenericVoteStore::new(store)); let lens = VoteAwareConsensusLens::new(Arc::clone(&vote_store)); // Create three assertions let a1 = create_assertion("Agent1", 100.0, 1000); let a2 = create_assertion("Agent2", 200.0, 1100); let a3 = create_assertion("Agent3", 300.0, 1200); // Add votes: a1 gets 0.5 weight, a2 gets 1.5 weight (winner), a3 gets 0.3 weight let hash1 = VoteAwareConsensusLens::>::compute_assertion_hash(&a1) .unwrap(); let hash2 = VoteAwareConsensusLens::>::compute_assertion_hash(&a2) .unwrap(); let hash3 = VoteAwareConsensusLens::>::compute_assertion_hash(&a3) .unwrap(); vote_store .put_vote(&create_vote(hash1, [1u8; 32], 0.5, 2000), "Agent1") .await .expect("put"); vote_store .put_vote(&create_vote(hash2, [2u8; 32], 0.8, 2001), "Agent2") .await .expect("put"); vote_store .put_vote(&create_vote(hash2, [3u8; 32], 0.7, 2002), "Agent2") .await .expect("put"); vote_store .put_vote(&create_vote(hash3, [4u8; 32], 0.3, 2003), "Agent3") .await .expect("put"); let resolution = lens.resolve_async(&[a1, a2.clone(), a3]).await; assert!(resolution.winner.is_some()); assert_eq!(resolution.winner.as_ref().unwrap().subject, "Agent2"); // Total weight = 0.5 + 0.8 + 0.7 + 0.3 = 2.3 // Winner weight = 0.8 + 0.7 = 1.5 // Confidence = 1.5 / 2.3 ≈ 0.652 let expected_confidence = 1.5 / 2.3; assert!((resolution.resolution_confidence - expected_confidence).abs() < 0.01); } #[tokio::test] async fn test_no_votes_returns_most_recent() { let store = Arc::new(HybridStore::open_temp().expect("Failed to create store")); let vote_store = Arc::new(GenericVoteStore::new(store)); let lens = VoteAwareConsensusLens::new(vote_store); let old = create_assertion("Old", 100.0, 1000); let new = create_assertion("New", 200.0, 2000); let resolution = lens.resolve_async(&[old, new.clone()]).await; // When no votes exist, should fall back to timestamp tiebreaker assert!(resolution.winner.is_some()); assert_eq!(resolution.winner.as_ref().unwrap().subject, "New"); // Confidence should be 0.0 since no votes exist assert!((resolution.resolution_confidence - 0.0).abs() < f32::EPSILON); } #[tokio::test] async fn test_tie_breaking_by_timestamp() { let store = Arc::new(HybridStore::open_temp().expect("Failed to create store")); let vote_store = Arc::new(GenericVoteStore::new(store)); let lens = VoteAwareConsensusLens::new(Arc::clone(&vote_store)); let old = create_assertion("Old", 100.0, 1000); let new = create_assertion("New", 200.0, 2000); // Give both the same vote weight let hash_old = VoteAwareConsensusLens::>::compute_assertion_hash(&old) .unwrap(); let hash_new = VoteAwareConsensusLens::>::compute_assertion_hash(&new) .unwrap(); vote_store .put_vote(&create_vote(hash_old, [1u8; 32], 0.5, 3000), "Old") .await .expect("put"); vote_store .put_vote(&create_vote(hash_new, [2u8; 32], 0.5, 3001), "New") .await .expect("put"); let resolution = lens.resolve_async(&[old, new.clone()]).await; // With equal weights, should pick the newer timestamp assert!(resolution.winner.is_some()); assert_eq!(resolution.winner.as_ref().unwrap().subject, "New"); } #[tokio::test] async fn test_mixed_votes_and_no_votes() { let store = Arc::new(HybridStore::open_temp().expect("Failed to create store")); let vote_store = Arc::new(GenericVoteStore::new(store)); let lens = VoteAwareConsensusLens::new(Arc::clone(&vote_store)); let with_votes = create_assertion("WithVotes", 100.0, 1000); let without_votes = create_assertion("NoVotes", 200.0, 2000); let hash_with = VoteAwareConsensusLens::>::compute_assertion_hash( &with_votes, ) .unwrap(); vote_store .put_vote(&create_vote(hash_with, [1u8; 32], 0.8, 3000), "WithVotes") .await .expect("put"); let resolution = lens.resolve_async(&[with_votes.clone(), without_votes]).await; // Assertion with votes should win assert!(resolution.winner.is_some()); assert_eq!(resolution.winner.as_ref().unwrap().subject, "WithVotes"); // Confidence should be 1.0 (all weight is on winner) assert!((resolution.resolution_confidence - 1.0).abs() < f32::EPSILON); } #[tokio::test] async fn test_lens_name() { let store = Arc::new(HybridStore::open_temp().expect("Failed to create store")); let vote_store = Arc::new(GenericVoteStore::new(store)); let lens = VoteAwareConsensusLens::new(vote_store); assert_eq!(lens.name(), "VoteAwareConsensus"); } #[tokio::test] async fn test_many_votes_on_single_assertion() { let store = Arc::new(HybridStore::open_temp().expect("Failed to create store")); let vote_store = Arc::new(GenericVoteStore::new(store)); let lens = VoteAwareConsensusLens::new(Arc::clone(&vote_store)); let popular = create_assertion("Popular", 100.0, 1000); let unpopular = create_assertion("Unpopular", 200.0, 1100); let hash_popular = VoteAwareConsensusLens::>::compute_assertion_hash( &popular, ) .unwrap(); let hash_unpopular = VoteAwareConsensusLens::>::compute_assertion_hash( &unpopular, ) .unwrap(); // Popular gets 10 votes for i in 0..10 { let agent_id = { let mut id = [0u8; 32]; id[0] = i; id }; vote_store .put_vote(&create_vote(hash_popular, agent_id, 0.5, 2000 + i as u64), "Popular") .await .expect("put"); } // Unpopular gets 1 vote vote_store .put_vote(&create_vote(hash_unpopular, [99u8; 32], 0.5, 2100), "Unpopular") .await .expect("put"); let resolution = lens.resolve_async(&[popular.clone(), unpopular]).await; assert!(resolution.winner.is_some()); assert_eq!(resolution.winner.as_ref().unwrap().subject, "Popular"); // Total weight = 10 * 0.5 + 1 * 0.5 = 5.5 // Winner weight = 5.0 // Confidence = 5.0 / 5.5 ≈ 0.909 let expected_confidence = 5.0 / 5.5; assert!((resolution.resolution_confidence - expected_confidence).abs() < 0.01); } }