stemedb/crates/stemedb-api/src/handlers/skeptic.rs
jml 3b5f88b4f0 feat(aphoria): implement claims architecture (A1-A5) with verify engine, corpus, coverage, and explain
Complete Aphoria claims system overhaul:
- A1: Rename ExtractedClaim to Observation (extractors produce observations, not claims)
- A2: Add AuthoredClaim with full provenance, invariants, and authority tiers
- A3: Verify engine comparing observations against authored claims, CLI + formatters
- A4: Corpus as first-class assertions with predicate indexing, authority lens, trust packs
- A5: Coverage analysis, explain/docs generation, self-audit extractor, claim suggester skill

Also includes: 42 extractors updated for Observation type, verifiable_predicates trait,
conflict detection with comparison modes, claims TOML persistence, Grafana dashboard,
backup/restore scripts, and comprehensive test coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 09:11:47 +00:00

169 lines
5.8 KiB
Rust

//! Handler for skeptic (conflict analysis) queries.
use axum::{
extract::{Query as AxumQuery, State},
Json,
};
use tracing::instrument;
use crate::{
dto::{ErrorResponse, SkepticQueryParams, SkepticResponse, SourceMetadataDto},
error::Result,
services::{make_source_warning, SourceStatusEnricher},
state::AppState,
};
use stemedb_query::SkepticResolver;
use stemedb_storage::{
GenericSourceRegistry, GenericTrustRankStore, GenericVoteStore, SourceRegistry,
};
/// Query for conflict analysis using SkepticLens.
///
/// This endpoint provides "Trust but Verify" analysis, showing all competing
/// claims for a subject+predicate instead of picking a single winner.
///
/// # Response
///
/// Returns a `SkepticResponse` with:
/// - `status`: Unanimous, Agreed, or Contested
/// - `conflict_score`: 0.0 (unanimous) to 1.0 (chaos)
/// - `claims`: All distinct claims ranked by support
///
/// # Example
///
/// ```ignore
/// GET /v1/skeptic?subject=Semaglutide&predicate=muscle_effect
///
/// {
/// "status": "Contested",
/// "conflict_score": 0.72,
/// "claims": [
/// { "value": {"type": "Text", "value": "Significant loss"}, "weight_share": 0.45 },
/// { "value": {"type": "Text", "value": "Minimal loss"}, "weight_share": 0.35 }
/// ]
/// }
/// ```
#[utoipa::path(
get,
path = "/v1/skeptic",
params(SkepticQueryParams),
responses(
(status = 200, description = "Conflict analysis successful", body = SkepticResponse),
(status = 404, description = "No assertions found for subject+predicate", body = ErrorResponse),
(status = 400, description = "Invalid request", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse)
),
tag = "query"
)]
#[instrument(skip(state), fields(subject = %params.subject, predicate = %params.predicate, include_source_metadata = params.include_source_metadata))]
pub async fn skeptic_query(
State(state): State<AppState>,
AxumQuery(params): AxumQuery<SkepticQueryParams>,
) -> Result<Json<SkepticResponse>> {
let query_start = std::time::Instant::now();
metrics::counter!("stemedb_queries_total", "endpoint" => "skeptic").increment(1);
// Create the resolver with vote and trust stores
let vote_store = std::sync::Arc::new(GenericVoteStore::new(state.store.clone()));
let trust_store = std::sync::Arc::new(GenericTrustRankStore::new(state.store.clone()));
let resolver = SkepticResolver::new(state.store.clone(), vote_store, trust_store);
// Execute the skeptic resolution
let view = resolver.resolve(&params.subject, &params.predicate).await?;
// Return 404 if no assertions found
let view = view.ok_or_else(|| {
crate::error::ApiError::NotFound(format!(
"No assertions found for subject '{}' and predicate '{}'",
params.subject, params.predicate
))
})?;
// Convert to response DTO
let mut response: SkepticResponse = view.into();
// Enrich with source metadata if requested
if params.include_source_metadata {
let source_registry = GenericSourceRegistry::new(state.store.clone());
enrich_sources_with_metadata(&source_registry, &mut response).await;
}
// Enrich with source warnings (P3.2 Cascade Flagging)
enrich_claims_with_warnings(&state, &mut response).await;
metrics::histogram!("stemedb_query_latency_seconds", "endpoint" => "skeptic")
.record(query_start.elapsed().as_secs_f64());
Ok(Json(response))
}
/// Enrich SkepticResponse with source metadata from the registry.
async fn enrich_sources_with_metadata<S: stemedb_storage::KVStore + 'static>(
registry: &GenericSourceRegistry<S>,
response: &mut SkepticResponse,
) {
for claim in &mut response.claims {
// Try to lookup source metadata by hash
if let Ok(hash_bytes) = hex::decode(&claim.source.source_hash) {
if hash_bytes.len() == 32 {
let mut hash = [0u8; 32];
hash.copy_from_slice(&hash_bytes);
if let Ok(Some(record)) = registry.get(&hash).await {
claim.source.source_metadata = Some(SourceMetadataDto::from(record));
}
}
}
}
}
/// Enrich SkepticResponse claims with source warnings (P3.2 Cascade Flagging).
///
/// Attaches warnings to claims that cite deprecated or quarantined sources.
async fn enrich_claims_with_warnings(
state: &crate::state::AppState,
response: &mut SkepticResponse,
) {
// Collect unique source hashes
let source_hashes: Vec<[u8; 32]> = response
.claims
.iter()
.filter_map(|claim| {
hex::decode(&claim.source.source_hash).ok().and_then(|bytes| {
if bytes.len() == 32 {
let mut hash = [0u8; 32];
hash.copy_from_slice(&bytes);
Some(hash)
} else {
None
}
})
})
.collect();
// Batch lookup source statuses
let enricher = SourceStatusEnricher::new(state.store.clone());
let source_statuses = match enricher.batch_lookup(&source_hashes).await {
Ok(statuses) => statuses,
Err(_) => return, // Don't fail the response if enrichment fails
};
// Attach warnings to claims
for claim in &mut response.claims {
if let Ok(hash_bytes) = hex::decode(&claim.source.source_hash) {
if hash_bytes.len() == 32 {
let mut hash = [0u8; 32];
hash.copy_from_slice(&hash_bytes);
if let Some(record) = source_statuses.get(&hash) {
claim.source_warning = make_source_warning(record);
}
}
}
}
}
#[cfg(test)]
mod tests {
// Integration tests would go here
// For now, the unit tests in stemedb-query cover the core functionality
}