stemedb/applications/aphoria/src/episteme/drift.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

98 lines
3.6 KiB
Rust

//! Drift detection for local Episteme operations.
//!
//! Tracks changes between current code claims and prior observations.
use stemedb_core::types::Assertion;
use tracing::{debug, info, instrument};
use crate::types::{predicates, DriftResult, Observation, Verdict};
use crate::AphoriaError;
use super::helpers::assertion_to_prior_observation;
use super::local::LocalEpisteme;
impl LocalEpisteme {
/// Check for drift between current claims and prior observations.
///
/// A drift is detected when a claim's value differs from a previously
/// recorded observation for the same concept path. Only claims that
/// have no authority conflict should be passed here.
///
/// Returns a list of drift results for claims whose values changed.
#[instrument(skip(self, claims), fields(claim_count = claims.len()))]
pub async fn check_drift(
&self,
claims: &[Observation],
) -> Result<Vec<DriftResult>, AphoriaError> {
let mut drifts = Vec::new();
for claim in claims {
// Look up prior observations for this concept
let observations = self.fetch_observations_for_concept(&claim.concept_path).await?;
// If there's a prior observation, check if the value changed
if let Some(prior_assertion) = observations.first() {
// Value differs - this is drift
if prior_assertion.object != claim.value {
let prior = assertion_to_prior_observation(prior_assertion);
drifts.push(DriftResult {
claim: claim.clone(),
prior,
verdict: Verdict::Drift,
});
debug!(
concept_path = %claim.concept_path,
prior_value = ?prior_assertion.object,
current_value = ?claim.value,
"Drift detected"
);
}
}
}
info!(drifts = drifts.len(), "Drift check complete");
Ok(drifts)
}
/// Fetch prior observations for a specific concept path.
///
/// Returns observations sorted by timestamp descending (most recent first).
#[instrument(skip(self), fields(concept_path = %concept_path))]
pub async fn fetch_observations_for_concept(
&self,
concept_path: &str,
) -> Result<Vec<Assertion>, AphoriaError> {
use stemedb_storage::PredicateIndexStore;
// Get all observation hashes from the predicate index
let hashes =
self.predicate_index_store.get_by_predicate(predicates::OBSERVATION).await.map_err(
|e| {
AphoriaError::Storage(format!(
"Failed to get observation hashes for predicate index: {e}"
))
},
)?;
let mut observations = Vec::new();
for hash in hashes {
if let Some(assertion) = self.load_assertion_by_hash(&hash).await {
// Check if this observation is for the same concept (subject match)
// The observation's subject should match the concept_path
if assertion.subject == concept_path {
observations.push(assertion);
}
}
}
// Sort by timestamp descending (most recent first)
observations.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
debug!(concept_path, count = observations.len(), "Fetched observations for concept");
Ok(observations)
}
}