stemedb/crates/stemedb-lens/src/layered_consensus.rs
jordan c59066949a feat: Add quickstart "Beyond Hello World" sections with Skeptic and Layered endpoints
- Add Layered() method to Go SDK for per-source-class consensus queries
- Add LayeredQueryParams, LayeredResult, TierResolution types to Go SDK
- Create conflict example demonstrating Skeptic and Layered endpoints
- Update quickstart.md with sections 6 (conflict detection) and 7 (authority tiers)
- Remove tracked Go binary and add data/ to .gitignore

The new quickstart sections demonstrate Episteme's differentiating features:
- Skeptic endpoint shows "Trust but Verify" conflict analysis
- Layered endpoint shows per-tier resolution (Clinical vs Anecdotal)

Note: Pre-existing large files flagged by pre-commit hook (technical debt from prior sessions)

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

473 lines
17 KiB
Rust

//! 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<Assertion> = 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<u8, Vec<&Assertion>> {
let mut groups: HashMap<u8, Vec<&Assertion>> = 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<String, usize> = 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<TierResolution> = 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<Assertion> = 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!(<LayeredConsensusLens as LayeredLens>::name(&lens), "LayeredConsensus");
assert_eq!(<LayeredConsensusLens as Lens>::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);
}
}