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>
271 lines
10 KiB
Rust
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)
|
|
}
|