//! Skeptic View builder for "Trust but Verify" queries. //! //! While `QueryEngine` returns assertions and `Materializer` creates single-winner //! views, `SkepticResolver` builds conflict analysis views that surface disagreement. //! //! # Use Case //! //! A medical researcher queries "Semaglutide muscle_effect" and instead of getting //! a single answer, they see: //! - Status: Contested (conflict_score: 0.72) //! - Claim A: "Significant loss" (45% support) //! - Claim B: "Minimal loss" (35% support) //! - Claim C: "No effect" (20% support) //! //! This enables transparent decision-making where users understand the certainty //! of the data they're using. //! //! # Example //! //! ```ignore //! use stemedb_query::SkepticResolver; //! //! let resolver = SkepticResolver::new(store, vote_store, trust_store); //! let view = resolver.resolve("Semaglutide", "muscle_effect").await?; //! //! if view.status == ResolutionStatus::Contested { //! println!("⚠️ This fact is disputed!"); //! for claim in &view.claims { //! println!(" {} - {:.0}% support", claim.value, claim.weight_share * 100.0); //! } //! } //! ``` use crate::error::Result; use std::sync::Arc; use stemedb_core::types::{ConflictAnalysis, EntityId, RelationId}; use stemedb_lens::{AnalysisLens, SkepticLens}; use stemedb_storage::trust_rank_store::TrustRankStore; use stemedb_storage::vote_store::VoteStore; use stemedb_storage::{GenericIndexStore, IndexStore, KVStore}; use tracing::instrument; /// A "Trust but Verify" view that shows disagreement instead of hiding it. /// /// Where `MaterializedView` answers "What is the answer?", /// `SkepticView` answers "What are the competing claims and how much do they disagree?" #[derive(Debug, Clone)] pub struct SkepticView { /// The subject that was queried. pub subject: EntityId, /// The predicate that was queried. pub predicate: RelationId, /// The conflict analysis from SkepticLens. pub analysis: ConflictAnalysis, /// Unix timestamp when this view was computed. pub computed_at: u64, /// Which lens was used (always "Skeptic" for now). pub lens_name: String, } /// Resolves subject+predicate pairs into SkepticViews. /// /// Unlike the `Materializer` which picks winners, `SkepticResolver` surfaces /// all competing claims with their relative support. pub struct SkepticResolver { store: Arc, index_store: GenericIndexStore>, lens: SkepticLens, } impl SkepticResolver where S: KVStore + 'static, V: VoteStore + 'static, T: TrustRankStore + 'static, { /// Create a new SkepticResolver. pub fn new(store: Arc, vote_store: Arc, trust_store: Arc) -> Self { let index_store = GenericIndexStore::new(store.clone()); let lens = SkepticLens::new(vote_store, trust_store); Self { store, index_store, lens } } /// Resolve a subject+predicate pair into a SkepticView. /// /// Returns `None` if no assertions exist for the given subject+predicate. #[instrument(skip(self), fields(subject = %subject, predicate = %predicate))] pub async fn resolve(&self, subject: &str, predicate: &str) -> Result> { // Fetch all candidate assertions using the compound index let hash_list = self.index_store.get_by_subject_predicate(subject, predicate).await?; if hash_list.is_empty() { return Ok(None); } // Load all assertions let mut candidates = Vec::with_capacity(hash_list.len()); for hash in hash_list { let key = format!("H:{}", hex::encode(hash)).into_bytes(); if let Some(data) = self.store.get(&key).await? { if let Ok(assertion) = stemedb_core::serde::deserialize(&data) { candidates.push(assertion); } } } if candidates.is_empty() { return Ok(None); } // Run the SkepticLens let analysis = self.lens.analyze(&candidates).await; Ok(Some(SkepticView { subject: subject.to_string(), predicate: predicate.to_string(), analysis, computed_at: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0), lens_name: self.lens.name().to_string(), })) } } #[cfg(test)] mod tests { use super::*; use stemedb_core::testing::AssertionBuilder; use stemedb_core::types::ResolutionStatus; use stemedb_storage::{GenericTrustRankStore, GenericVoteStore, SledStore}; async fn store_assertion( store: &Arc, index_store: &GenericIndexStore>, subject: &str, predicate: &str, value: f64, confidence: f32, ) { let assertion = AssertionBuilder::new() .subject(subject) .predicate(predicate) .object_number(value) .confidence(confidence) .build(); let bytes = stemedb_core::serde::serialize(&assertion).expect("serialize"); let hash = blake3::hash(&bytes); let key = format!("H:{}", hash.to_hex()).into_bytes(); store.put(&key, &bytes).await.expect("put"); let assertion_hash: [u8; 32] = *hash.as_bytes(); index_store.add_to_indexes(subject, predicate, &assertion_hash).await.expect("index"); } #[tokio::test] async fn test_resolve_empty() { let store = Arc::new(SledStore::open_temp().expect("store")); let vote_store = Arc::new(GenericVoteStore::new((*store).clone())); let trust_store = Arc::new(GenericTrustRankStore::new((*store).clone())); let resolver = SkepticResolver::new(store, vote_store, trust_store); let result = resolver.resolve("NonExistent", "predicate").await.expect("resolve"); assert!(result.is_none()); } #[tokio::test] async fn test_resolve_single_claim() { let store = Arc::new(SledStore::open_temp().expect("store")); let index_store = GenericIndexStore::new(store.clone()); store_assertion(&store, &index_store, "Drug", "effect", 100.0, 0.9).await; let vote_store = Arc::new(GenericVoteStore::new((*store).clone())); let trust_store = Arc::new(GenericTrustRankStore::new((*store).clone())); let resolver = SkepticResolver::new(store, vote_store, trust_store); let result = resolver.resolve("Drug", "effect").await.expect("resolve"); assert!(result.is_some()); let view = result.expect("view"); assert_eq!(view.subject, "Drug"); assert_eq!(view.predicate, "effect"); assert_eq!(view.analysis.status, ResolutionStatus::Unanimous); assert_eq!(view.analysis.claims.len(), 1); } #[tokio::test] async fn test_resolve_contested_claims() { let store = Arc::new(SledStore::open_temp().expect("store")); let index_store = GenericIndexStore::new(store.clone()); // Add two conflicting claims with equal weight store_assertion(&store, &index_store, "Drug", "effect", 100.0, 0.5).await; store_assertion(&store, &index_store, "Drug", "effect", 200.0, 0.5).await; let vote_store = Arc::new(GenericVoteStore::new((*store).clone())); let trust_store = Arc::new(GenericTrustRankStore::new((*store).clone())); let resolver = SkepticResolver::new(store, vote_store, trust_store); let result = resolver.resolve("Drug", "effect").await.expect("resolve"); assert!(result.is_some()); let view = result.expect("view"); assert_eq!(view.analysis.status, ResolutionStatus::Contested); assert_eq!(view.analysis.claims.len(), 2); assert!(view.analysis.conflict_score > 0.9); // Near 1.0 for 50/50 split } #[tokio::test] async fn test_resolve_includes_computed_at() { let store = Arc::new(SledStore::open_temp().expect("store")); let index_store = GenericIndexStore::new(store.clone()); store_assertion(&store, &index_store, "Drug", "effect", 100.0, 0.9).await; let vote_store = Arc::new(GenericVoteStore::new((*store).clone())); let trust_store = Arc::new(GenericTrustRankStore::new((*store).clone())); let resolver = SkepticResolver::new(store, vote_store, trust_store); let result = resolver.resolve("Drug", "effect").await.expect("resolve"); let view = result.expect("view"); // computed_at should be recent (within last minute) let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("time") .as_secs(); assert!(view.computed_at <= now); assert!(view.computed_at > now - 60); } }