stemedb/crates/stemedb-query/src/skeptic.rs
jordan 1ce4004807 feat: Complete Phase 2 (The Cortex) - query, lens, and API layers
This commit adds the read path (Cortex) to complement the write path (Spine):

## Crates
- stemedb-api: HTTP API with axum + utoipa OpenAPI
  - /v1/assert, /v1/query, /v1/epoch, /v1/skeptic, /v1/trace, /v1/audit
  - Metered endpoints with quota enforcement
  - Ed25519 signature verification
- stemedb-lens: Truth resolution lenses
  - RecencyLens, ConsensusLens, ConfidenceLens
  - VoteAwareConsensusLens (Ballot Box pattern)
  - TrustAwareAuthorityLens (The Hive pattern)
  - SkepticLens (conflict analysis)
  - EpochAwareLens (paradigm-safe queries)
- stemedb-query: Query engine with materialized views

## Storage Extensions
- VoteStore: Vote aggregation with cached counts
- TrustRankStore: Agent reputation with decay
- AuditStore: Query audit trail
- IndexStore: SP/P/S index structures
- SupersessionStore: Epoch supersession chains

## SDKs
- sdk/go/steme: Go HTTP client with Ed25519 signing
- sdk/go/adk: ADK-Go tools for AI agents

## Documentation
- Updated CLAUDE.md, architecture.md, roadmap.md
- New ai-lookup entries for all services
- Use case docs for consumer health intelligence
- Arena roadmap for simulation advancement

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 13:22:44 -07:00

235 lines
8.7 KiB
Rust

//! 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<S, V, T> {
store: Arc<S>,
index_store: GenericIndexStore<Arc<S>>,
lens: SkepticLens<V, T>,
}
impl<S, V, T> SkepticResolver<S, V, T>
where
S: KVStore + 'static,
V: VoteStore + 'static,
T: TrustRankStore + 'static,
{
/// Create a new SkepticResolver.
pub fn new(store: Arc<S>, vote_store: Arc<V>, trust_store: Arc<T>) -> 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<Option<SkepticView>> {
// 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<SledStore>,
index_store: &GenericIndexStore<Arc<SledStore>>,
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);
}
}