- 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>
473 lines
17 KiB
Rust
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);
|
|
}
|
|
}
|