stemedb/applications/aphoria/src/episteme/authority_lens.rs
jml 6430ff0fd6 fix(aphoria): move claims.toml to project root and fix verify integration
## Root Cause
Claims file was in applications/aphoria/.aphoria/ but all commands looked
for .aphoria/claims.toml relative to project root. Additionally, .aphoria/
was fully gitignored, preventing version control of claims.

## Changes

### Path Fixes
- Move claims.toml from applications/aphoria/.aphoria/ to .aphoria/ at project root
- Update .gitignore: .aphoria/ → .aphoria/* with !.aphoria/claims.toml exception
- Now claims can be version controlled while keys remain secret

### Verify Integration (Scanner)
- scanner.rs: Load claims from ClaimsFile and call verify_claims()
- ScanResult: Add verify field with VerifyReport
- Report formatters: Add claim verification sections showing PASS/CONFLICT/MISSING

### Clippy Fix
- report/json.rs: Replace filter().map().expect() with filter_map()

## Verification
- aphoria scan . → Shows claim verification with verdicts
- aphoria verify run → Per-claim verification results
- aphoria verify map → Extractor coverage mapping (7/10 claims = 70%)
- aphoria claims list → Reads from project root
- aphoria claims create → Writes to project root
- All tests pass (1120+ aphoria tests)
- clippy --workspace passes

## Impact
Both primary use cases now work:
1. Day-to-day (commit-time): Skills can read/create claims via CLI
2. Audit (scan-time): Scanner verifies code against authored claims

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 11:09:57 +00:00

204 lines
6.9 KiB
Rust

//! Aphoria Authority Lens - formalizes the authority-based conflict scoring.
//!
//! Wraps the existing scoring formula from `conflict.rs` into a proper
//! `stemedb_lens::Lens` implementation. This allows the authority resolution
//! logic to be used as a first-class Lens in Episteme queries.
use stemedb_core::types::{Assertion, SourceClass};
use stemedb_lens::{Lens, Resolution};
use crate::types::TierBreakdown;
/// Authority-based lens that resolves conflicts by source class tier.
///
/// Higher-authority sources (lower tier numbers) win. Uses the same formula
/// as `compute_conflict_score()` in `conflict.rs`:
///
/// ```text
/// normalized = 0.4 + (3.0 - min_tier) / 3.0 * 0.55
/// ```
///
/// Tier 0 (Regulatory) produces score ~0.95, Tier 3 (Expert) produces ~0.40.
pub struct AphoriaAuthorityLens;
impl Lens for AphoriaAuthorityLens {
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 by tier, pick the winner from the highest-authority (lowest tier) group
let mut best_tier = u8::MAX;
let mut best_assertion: Option<&Assertion> = None;
let mut best_confidence: f32 = 0.0;
for assertion in candidates {
let tier = assertion.source_class.tier();
if tier < best_tier || (tier == best_tier && assertion.confidence > best_confidence) {
best_tier = tier;
best_assertion = Some(assertion);
best_confidence = assertion.confidence;
}
}
let winner = match best_assertion {
Some(a) => a.clone(),
None => return Resolution::empty(),
};
// Compute conflict score using the same formula as conflict.rs
let conflict_score = authority_conflict_score(candidates);
// Resolution confidence is based on how dominant the winning tier is
let min_tier = best_tier as f32;
let resolution_confidence = 0.4 + (3.0 - min_tier.min(3.0)) / 3.0 * 0.55;
Resolution::with_winner(
winner,
candidates.len(),
resolution_confidence.min(1.0),
conflict_score,
)
}
fn name(&self) -> &'static str {
"AphoriaAuthority"
}
}
/// Compute cross-tier conflict score for a set of assertions.
///
/// Uses the same normalized formula as `conflict.rs:compute_conflict_score()`:
/// `normalized = 0.4 + (3.0 - min_tier) / 3.0 * 0.55`
///
/// Returns 0.0 if all assertions are the same tier, higher values when
/// high-authority sources (Tier 0) conflict with low-authority (Tier 3+).
fn authority_conflict_score(candidates: &[Assertion]) -> f32 {
if candidates.len() <= 1 {
return 0.0;
}
let min_tier = candidates.iter().map(|a| a.source_class.tier()).min().unwrap_or(3);
let max_tier = candidates.iter().map(|a| a.source_class.tier()).max().unwrap_or(3);
if min_tier == max_tier {
return 0.0; // Same tier, no authority conflict
}
// Tier distance maps to conflict intensity
let tier_distance = (max_tier - min_tier) as f32;
(tier_distance / 5.0).min(1.0) // Max 5 tiers apart (0-5)
}
/// Compute tier breakdown from a set of assertions.
///
/// Returns a sorted (by tier) list of tier breakdowns.
pub fn compute_tier_breakdown(assertions: &[Assertion]) -> Vec<TierBreakdown> {
use std::collections::BTreeMap;
let mut by_tier: BTreeMap<u8, (SourceClass, usize, f32)> = BTreeMap::new();
for assertion in assertions {
let tier = assertion.source_class.tier();
let entry = by_tier.entry(tier).or_insert((assertion.source_class, 0, 0.0));
entry.1 += 1;
if assertion.confidence > entry.2 {
entry.2 = assertion.confidence;
}
}
by_tier
.into_iter()
.map(|(tier, (source_class, count, max_conf))| TierBreakdown {
tier,
source_class,
assertion_count: count,
max_confidence: max_conf,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use stemedb_core::testing::AssertionBuilder;
#[test]
fn test_empty_candidates() {
let lens = AphoriaAuthorityLens;
let result = lens.resolve(&[]);
assert!(result.winner.is_none());
assert_eq!(result.candidates_count, 0);
}
#[test]
fn test_single_candidate() {
let lens = AphoriaAuthorityLens;
let assertion =
AssertionBuilder::new().source_class(SourceClass::Regulatory).confidence(0.95).build();
let result = lens.resolve(&[assertion]);
assert!(result.winner.is_some());
assert_eq!(result.candidates_count, 1);
}
#[test]
fn test_authority_wins_over_lower_tier() {
let lens = AphoriaAuthorityLens;
let regulatory = AssertionBuilder::new()
.subject("rfc://test")
.source_class(SourceClass::Regulatory)
.confidence(0.9)
.build();
let community = AssertionBuilder::new()
.subject("code://test")
.source_class(SourceClass::Community)
.confidence(1.0)
.build();
let result = lens.resolve(&[community, regulatory]);
let winner = result.winner.as_ref().expect("should have winner");
assert_eq!(winner.source_class, SourceClass::Regulatory);
assert_eq!(result.candidates_count, 2);
}
#[test]
fn test_lens_scores_match_existing() {
// Verify the normalized formula matches conflict.rs expectations
// Tier 0 vs code → ~0.95
let regulatory =
AssertionBuilder::new().source_class(SourceClass::Regulatory).confidence(1.0).build();
let community =
AssertionBuilder::new().source_class(SourceClass::Community).confidence(1.0).build();
let lens = AphoriaAuthorityLens;
let result = lens.resolve(&[regulatory, community]);
// Resolution confidence for Tier 0 winner should be ~0.95
assert!(
result.resolution_confidence > 0.9,
"Expected >0.9, got {}",
result.resolution_confidence
);
}
#[test]
fn test_tier_breakdown() {
let assertions = vec![
AssertionBuilder::new().source_class(SourceClass::Regulatory).confidence(0.95).build(),
AssertionBuilder::new().source_class(SourceClass::Regulatory).confidence(0.9).build(),
AssertionBuilder::new().source_class(SourceClass::Community).confidence(0.7).build(),
];
let breakdown = compute_tier_breakdown(&assertions);
assert_eq!(breakdown.len(), 2);
assert_eq!(breakdown[0].tier, 0); // Regulatory
assert_eq!(breakdown[0].assertion_count, 2);
assert!((breakdown[0].max_confidence - 0.95).abs() < f32::EPSILON);
assert_eq!(breakdown[1].assertion_count, 1);
}
}