stemedb/applications/aphoria/src/episteme/conflict.rs
jml fae9b47fae feat(aphoria): implement hosted mode with remote StemeDB integration
Add remote mode infrastructure for querying claims from StemeDB API:
- Remote client with caching layer for claim queries
- Authority resolution logic with tier-based verdict system
- StemeDB API handlers for claims CRUD operations
- Enhanced conflict detection with remote claim support
- Validation reports documenting A5.3 phase completion

Changes:
- applications/aphoria/src/remote/: New client + cache modules
- applications/aphoria/src/resolution/: Authority tier resolution
- crates/stemedb-api/src/handlers/stemedb_claims.rs: API handlers
- applications/aphoria/validation/a5.3/: Phase validation reports
- Updated roadmap with hosted mode milestones

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 09:29:56 +00:00

271 lines
10 KiB
Rust

//! Pure conflict checking logic without persistence.
//!
//! Provides standalone functions for detecting conflicts between claims and
//! authoritative sources using concept index lookups and alias resolution.
use std::collections::HashMap;
use stemedb_core::types::SourceClass;
use tracing::info;
use crate::config::AphoriaConfig;
use crate::types::{
ConflictResult, ConflictTrace, ConflictingSource, Observation, PolicySourceInfo,
PredicateAliasSet, Verdict,
};
use super::concept_index::ConceptIndex;
/// Check for conflicts between extracted claims and authoritative sources (pure function).
///
/// This is a standalone function that doesn't require `LocalEpisteme`.
/// It uses tail-path matching via `ConceptIndex` to find conflicts across different
/// URI schemes.
///
/// # Arguments
/// * `claims` - Extracted claims from source code
/// * `index` - In-memory concept index built from authoritative corpus
/// * `aliases` - In-memory alias map from policies
/// * `pack_sources` - Mapping from assertion subject to policy source info
/// * `config` - Configuration with thresholds
/// * `debug` - If true, populate ConflictTrace for each result
///
/// # Returns
/// Vector of conflict results for claims that conflict with authoritative sources.
/// Check for conflicts between extracted claims and authoritative sources (pure function).
///
/// This version uses predicate aliases from config only.
#[allow(dead_code)]
pub fn check_conflicts_pure(
claims: &[Observation],
index: &ConceptIndex,
aliases: &HashMap<String, String>,
pack_sources: &HashMap<String, PolicySourceInfo>,
config: &AphoriaConfig,
debug: bool,
) -> Vec<ConflictResult> {
// Get predicate aliases from config
let predicate_aliases = config.predicate_aliases.to_alias_sets();
check_conflicts_with_predicate_aliases(
claims,
index,
aliases,
pack_sources,
&predicate_aliases,
config,
debug,
)
}
/// Check for conflicts with explicit predicate aliases.
///
/// This variant allows passing predicate aliases explicitly, which is useful
/// when aliases come from multiple sources (config + Trust Packs).
pub fn check_conflicts_with_predicate_aliases(
claims: &[Observation],
index: &ConceptIndex,
aliases: &HashMap<String, String>,
pack_sources: &HashMap<String, PolicySourceInfo>,
predicate_aliases: &[PredicateAliasSet],
config: &AphoriaConfig,
debug: bool,
) -> Vec<ConflictResult> {
let mut results = Vec::new();
for claim in claims {
// 1. Try to resolve alias first
let resolved_path = aliases.get(&claim.concept_path).map(|s| s.as_str());
// 2. Normalize the predicate using predicate aliases
let normalized_predicate =
ConceptIndex::normalize_predicate(&claim.predicate, predicate_aliases);
// 3. Look up authoritative assertions
let auth_assertions = if let Some(path) = resolved_path {
// If alias exists, use the aliased path (assumed to be authoritative)
// But ConceptIndex is keyed by tail path.
// If we have the full path, we can try to make a key from it.
if let Some(key) = ConceptIndex::make_key(path, normalized_predicate) {
index.entries.get(&key)
} else {
None
}
} else {
// Fallback to tail-path matching with normalized predicate
index.lookup_with_aliases(&claim.concept_path, &claim.predicate, predicate_aliases)
};
let auth_assertions = match auth_assertions {
Some(assertions) => assertions,
None => continue, // No authoritative coverage for this concept
};
// Find conflicting authoritative sources
let mut conflicts = Vec::new();
let mut primary_authority: Option<(&str, SourceClass)> = None;
for assertion in auth_assertions {
// Skip if it's our own assertion (same source class)
// Or if it's a Manual policy override that agrees with us?
// Actually, if a policy overrides something, it usually provides an assertion.
// If the assertion matches our claim, it's not a conflict.
// If it differs, it is.
if assertion.source_class == SourceClass::Expert {
// If this is a Manual/Policy assertion, we treat it as authoritative if it differs?
// Or maybe we treat it as "overriding" the RFC?
// For now, treat it like any other assertion.
}
// Check if value differs (for conflict reporting)
if assertion.object != claim.value {
// Only consider Tier 0-2 (RFC/Vendor) AND Tier 3 (Policy/Expert) as authoritative
// Policies are usually Tier 3.
if assertion.source_class.tier() <= 3 {
// Track highest-tier (lowest number) authority for trace
if primary_authority.is_none()
|| assertion.source_class.tier()
< primary_authority.map(|(_, sc)| sc.tier()).unwrap_or(99)
{
primary_authority = Some((&assertion.subject, assertion.source_class));
}
let rfc_citation = ConflictingSource::extract_citation(&assertion.subject);
// Look up policy source info if this assertion came from a Trust Pack
let policy_source = pack_sources.get(&assertion.subject).cloned();
conflicts.push(ConflictingSource {
path: assertion.subject.clone(),
source_class: assertion.source_class,
value: assertion.object.clone(),
confidence: assertion.confidence,
rfc_citation,
policy_source,
});
}
}
}
if conflicts.is_empty() {
continue;
}
// Compute conflict score
let conflict_score = compute_conflict_score(&conflicts, claim.confidence);
// Determine verdict
let verdict = if conflict_score >= config.thresholds.block {
Verdict::Block
} else if conflict_score >= config.thresholds.flag {
Verdict::Flag
} else {
Verdict::Pass
};
// Build debug trace if enabled
let trace = if debug {
primary_authority.map(|(auth_path, source_class)| {
// Format code claim: concept_path = value
let code_claim = format!("{} = {:?}", claim.concept_path, claim.value);
// Format authority match: path = expected_value
let auth_match = format!(
"{} = {:?}",
auth_path,
conflicts.first().map(|c| &c.value).unwrap_or(&claim.value)
);
ConflictTrace::new(&code_claim, &auth_match, source_class, conflict_score, verdict)
})
} else {
None
};
// Compute tier breakdown (ALWAYS, not just debug mode)
let tier_breakdown_map = crate::resolution::compute_tier_breakdown(&conflicts);
let tier_breakdown: Vec<_> = tier_breakdown_map.values().cloned().collect();
// Compute tier-aware verdict
let tier_verdict = if !tier_breakdown_map.is_empty() {
Some(crate::resolution::compute_tier_aware_verdict(
&tier_breakdown_map,
conflict_score,
config,
))
} else {
None
};
// Get primary tier (lowest tier number = highest authority)
let primary_tier = tier_breakdown_map.keys().min().copied();
results.push(ConflictResult {
claim: claim.clone(),
conflicts,
conflict_score,
verdict,
acknowledged: None,
trace,
tier_breakdown: if debug { Some(tier_breakdown) } else { None },
tier_verdict,
primary_tier,
});
}
info!(
conflicts = results.len(),
blocks = results.iter().filter(|r| r.verdict == Verdict::Block).count(),
flags = results.iter().filter(|r| r.verdict == Verdict::Flag).count(),
"Pure conflict check complete"
);
results
}
/// Compute conflict score based on authoritative sources and claim confidence.
///
/// The score uses two approaches and takes the maximum:
///
/// 1. **Boosted score**: `max_tier_weight * (1.0 - code_weight) * max_confidence`
/// where code_weight = Expert (Tier 3) = 0.5. This is low unless the
/// authoritative source has very high authority weight.
///
/// 2. **Normalized score**: Linear mapping from tier distance to score:
/// - Tier 0 (Regulatory) vs code → 0.95 (above BLOCK threshold 0.7)
/// - Tier 1 (Clinical) vs code → 0.77 (above BLOCK threshold 0.7)
/// - Tier 2 (Observational) vs code → 0.58 (above FLAG threshold 0.4)
/// - Tier 3 (same tier) vs code → 0.40 (at FLAG threshold)
///
/// The final score is capped at 1.0.
pub fn compute_conflict_score(conflicts: &[ConflictingSource], _claim_confidence: f32) -> f32 {
if conflicts.is_empty() {
return 0.0;
}
// Get max tier weight from conflicting sources
let max_tier_weight = conflicts
.iter()
.map(|c| c.source_class.authority_weight())
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or(0.0);
// Code claims are Expert (Tier 3) = 0.5 weight
let code_weight = SourceClass::Expert.authority_weight();
// Base conflict score from tier spread
let base_score = max_tier_weight * (1.0 - code_weight);
// Boost by authoritative source confidence
let max_confidence = conflicts
.iter()
.map(|c| c.confidence)
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or(1.0);
let boosted_score = base_score * max_confidence;
// Normalize: tier spread 0→3 maps to 0.4→0.95
let min_tier = conflicts.iter().map(|c| c.source_class.tier()).min().unwrap_or(3) as f32;
let normalized = 0.4 + (3.0 - min_tier) / 3.0 * 0.55;
normalized.max(boosted_score).min(1.0)
}