//! 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, pack_sources: &HashMap, config: &AphoriaConfig, debug: bool, ) -> Vec { // 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, pack_sources: &HashMap, predicate_aliases: &[PredicateAliasSet], config: &AphoriaConfig, debug: bool, ) -> Vec { 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) }