stemedb/crates/stemedb-lens/src/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

172 lines
5.5 KiB
Rust

//! Consensus Lens: Most common value wins.
//!
//! This lens selects assertions based on how many agents agree on the same value.
//! In Phase 2, this counts identical object values across assertions.
//! In Phase 4, this will integrate with the Ballot Box vote counts.
use crate::traits::{compute_conflict_score, Lens, Resolution};
use std::collections::HashMap;
use stemedb_core::types::Assertion;
use tracing::instrument;
/// Consensus Lens: Returns the assertion representing the most common value.
///
/// # Current Implementation (Phase 2 Stub)
///
/// Groups assertions by their `object` field (string representation) and
/// returns a representative from the largest group.
///
/// # Future Implementation (Phase 4)
///
/// Will integrate with VoteStore to:
/// - Count explicit votes per assertion
/// - Weight by agent TrustRank
/// - Consider multi-sig counts
///
/// # Resolution Strategy
///
/// 1. Group assertions by object value
/// 2. Select the group with the most members
/// 3. From that group, pick the most recent assertion
#[derive(Debug, Clone, Copy, Default)]
pub struct ConsensusLens;
impl Lens for ConsensusLens {
#[instrument(skip(self, candidates), fields(candidates_count = candidates.len(), lens = "Consensus"))]
fn resolve(&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);
}
// Group assertions by their object value
// We use Debug format as a simple equality proxy
let mut groups: HashMap<String, Vec<&Assertion>> = HashMap::new();
for assertion in candidates {
let key = format!("{:?}", assertion.object);
groups.entry(key).or_default().push(assertion);
}
// Find the largest group
let largest_group = groups.values().max_by_key(|v| v.len());
match largest_group {
Some(group) => {
// From the largest group, pick the most recent
let winner = group.iter().max_by_key(|a| a.timestamp).cloned().cloned();
match winner {
Some(w) => {
let group_size = group.len();
let total = candidates.len();
// Confidence = proportion of candidates that agree
let confidence = group_size as f32 / total as f32;
let conflict = compute_conflict_score(candidates);
Resolution::with_winner(w, total, confidence, conflict)
}
None => Resolution::empty(),
}
}
None => Resolution::empty(),
}
}
fn name(&self) -> &'static str {
"Consensus"
}
}
#[cfg(test)]
mod tests {
use super::*;
use stemedb_core::testing::AssertionBuilder;
use stemedb_core::types::ObjectValue;
fn create_assertion(subject: &str, value: f64, timestamp: u64) -> Assertion {
AssertionBuilder::new().subject(subject).object_number(value).timestamp(timestamp).build()
}
#[test]
fn test_empty_candidates() {
let lens = ConsensusLens;
let resolution = lens.resolve(&[]);
assert!(resolution.winner.is_none());
}
#[test]
fn test_selects_most_common_value() {
let lens = ConsensusLens;
// Three assertions say 100.0, one says 200.0
let a1 = create_assertion("Agent1", 100.0, 1000);
let a2 = create_assertion("Agent2", 100.0, 1100);
let a3 = create_assertion("Agent3", 100.0, 1200);
let outlier = create_assertion("Outlier", 200.0, 1300);
let resolution = lens.resolve(&[a1, a2, a3, outlier]);
assert!(resolution.winner.is_some());
// Winner should have value 100.0
match &resolution.winner.as_ref().map(|a| &a.object) {
Some(ObjectValue::Number(v)) => assert!((*v - 100.0).abs() < f64::EPSILON),
_ => panic!("Expected Number"),
}
}
#[test]
fn test_selects_newest_from_consensus_group() {
let lens = ConsensusLens;
let old = create_assertion("Old", 100.0, 1000);
let new = create_assertion("New", 100.0, 2000);
let resolution = lens.resolve(&[old, new.clone()]);
// Both have same value, should pick newer
assert!(resolution.winner.is_some());
assert_eq!(resolution.winner.as_ref().map(|a| &a.subject), Some(&"New".to_string()));
}
#[test]
fn test_confidence_reflects_agreement() {
let lens = ConsensusLens;
// 3 out of 4 agree
let a1 = create_assertion("A1", 100.0, 1000);
let a2 = create_assertion("A2", 100.0, 1100);
let a3 = create_assertion("A3", 100.0, 1200);
let outlier = create_assertion("Outlier", 200.0, 1300);
let resolution = lens.resolve(&[a1, a2, a3, outlier]);
// Confidence should be 3/4 = 0.75
assert!((resolution.resolution_confidence - 0.75).abs() < f32::EPSILON);
}
#[test]
fn test_full_consensus() {
let lens = ConsensusLens;
let a1 = create_assertion("A1", 100.0, 1000);
let a2 = create_assertion("A2", 100.0, 1100);
let resolution = lens.resolve(&[a1, a2]);
// Full consensus = 1.0 confidence
assert!((resolution.resolution_confidence - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_lens_name() {
let lens = ConsensusLens;
assert_eq!(lens.name(), "Consensus");
}
}