""" reasoning.py ============ Deterministic reasoning engine for InformationIntegrityEnv. Architecture ------------ EvidenceAggregator — turns List[ScoredSource] into a numeric evidence score RiskClassifier — detects high-risk claim domains (medical, violence, etc.) SafetyRules — override decisions when evidence is insufficient DeterministicReasoningEngine — combines all into a StructuredReasoning result Evidence score formula ---------------------- For each source: contribution = stance_weight(stance) × reliability × similarity_boost(similarity) Stance weights: supporting → +1.0 (positive contribution) contradicting → -1.0 (negative contribution) neutral → 0.0 (ignored) similarity_boost(sim): sim ≥ 0.60 → 1.20 (high confidence amplifier) sim ≥ 0.40 → 1.00 (normal) sim < 0.40 → 0.60 (low confidence dampener — source is weakly relevant) evidence_score = Σ contributions (not normalised; bounded by SafetyRules logic) High-risk multiplier -------------------- When a claim is classified as high-risk (medical misinformation, harmful actions, public health emergencies), the evidence score is multiplied by a configurable factor (default 1.5) to lower the bar for REMOVE. Decision thresholds (default) ------------------------------ score > 0.30 → ALLOW score < -0.50 → REMOVE (lowered from -0.70; high-risk claims use -0.35) otherwise → FLAG Safety rules (all override the score-based decision) ------------------------------------------------------ 1. No sources at all → FLAG 2. All sources are neutral → FLAG 3. REMOVE needs ≥ min_remove_sources contradicting sources 4. REMOVE needs contradicting reliability ≥ min_remove_reliability 5. Score near boundary (|score| < boundary_margin) → FLAG 6. ALLOW requires ≥1 high-reliability supporting source 7. HIGH-RISK + strong_contra + ≥1 authoritative source → force REMOVE Multiple-source confidence boost --------------------------------- When ≥3 contradicting sources agree, confidence is boosted to "high" regardless of individual reliability scores. All thresholds are configurable via reasoning_config.yaml or env-vars. """ from __future__ import annotations import logging import os from dataclasses import dataclass, field from typing import Dict, List, Optional, Tuple import yaml from retrieval import ScoredSource logger = logging.getLogger("reasoning") REASONING_CONFIG_PATH = os.getenv("REASONING_CONFIG", "reasoning_config.yaml") # ───────────────────────────────────────────────────────────────────────────── # Config # ───────────────────────────────────────────────────────────────────────────── _DEFAULTS: Dict[str, float | int] = { # Decision thresholds "allow_threshold": 0.30, # score > this → ALLOW "remove_threshold": -0.50, # score < this → REMOVE "boundary_margin": 0.15, # |score| < this → force FLAG # High-risk domain settings "high_risk_multiplier": 1.50, # amplify score magnitude for risky claims "high_risk_remove_threshold": -0.35, # lower REMOVE bar for high-risk claims # Similarity boost knees "high_sim_cutoff": 0.60, "low_sim_cutoff": 0.40, "high_sim_boost": 1.20, "low_sim_boost": 0.60, # Safety rule thresholds "min_remove_sources": 2, # int: min contradicting sources for REMOVE "min_remove_reliability": 0.35, # avg reliability of contra sources # Consensus-based confidence scaling # Confidence level = min(1.0, base + n_contra * per_source_boost) "consensus_base_confidence": 0.40, # starting confidence with 1 contra source "consensus_per_source_boost": 0.12, # +0.12 per additional contradicting source "consensus_high_cutoff": 0.75, # ≥ this scaled value → "high" "consensus_medium_cutoff": 0.50, # ≥ this scaled value → "medium" # No-support confidence boost "no_support_bonus": 0.15, # added when zero supporting sources found # Settled-science auto-remove "settled_science_remove": 1, # 1=enabled, 0=disabled # Confidence descriptors (fallback when consensus logic doesn't apply) "high_confidence_threshold": 0.50, "low_confidence_threshold": 0.20, } class ReasoningConfig: def __init__(self, path: str = REASONING_CONFIG_PATH) -> None: self._cfg = dict(_DEFAULTS) if os.path.exists(path): with open(path, "r", encoding="utf-8") as fh: loaded = yaml.safe_load(fh) or {} self._cfg.update(loaded) def __getattr__(self, name: str): if name == "_cfg": raise AttributeError(name) try: return self._cfg[name] except KeyError: raise AttributeError(f"ReasoningConfig has no attribute '{name}'") _CFG = ReasoningConfig() # ───────────────────────────────────────────────────────────────────────────── # Risk Classifier — identifies high-stakes claim domains # ───────────────────────────────────────────────────────────────────────────── import re as _re # Each entry: (domain_name, compiled_regex_for_claim_text) # A claim matching ANY pattern is tagged as high-risk. _RISK_PATTERNS: List[Tuple[str, "_re.Pattern"]] = [ # Medical / health misinformation (highest harm) ("medical", _re.compile( r"\b(vaccine|vaccination|vaxx?|covid|coronavirus|sars|flu|cancer|" r"cure|cures|treat|treatment|drug|medication|dose|overdose|" r"autism|5g|radiation|chemotherapy|hiv|aids|ebola|" r"causes?\s+death|kills?|deadly|toxic|poison)\b", _re.IGNORECASE, )), # Harmful actions / violence incitement ("harmful_action", _re.compile( r"\b(drink\s+bleach|inject\s+disinfectant|eat\s+tide|tide\s+pod|" r"ivermectin\s+for\s+human|horse\s+dewormer|" r"suicide\s+method|how\s+to\s+harm|self.harm|bomb\s+making|" r"attack|assassin|terrorist|shoot|stab|murder)\b", _re.IGNORECASE, )), # Public health emergencies ("public_health", _re.compile( r"\b(pandemic|epidemic|outbreak|spread|contagious|" r"herd\s+immunity|lockdown|quarantine|mask|ventilator|" r"hospital\s+overwhelm|icu|death\s+toll)\b", _re.IGNORECASE, )), # Election / democratic integrity ("election", _re.compile( r"\b(election|vote|ballot|voter\s+fraud|stolen\s+election|" r"rigged|electoral|polling|count|recount|certification)\b", _re.IGNORECASE, )), # Climate / environment denial used to block policy ("climate", _re.compile( r"\b(climate\s+change|global\s+warming|carbon|emissions?|" r"ipcc|greenhouse|sea\s+level|arctic|glacier)\b", _re.IGNORECASE, )), ] # Authoritative domains that count as "official contradiction" _AUTHORITATIVE_DOMAINS = { "who.int", "cdc.gov", "nih.gov", "fda.gov", "nhs.uk", "ecdc.europa.eu", "nasa.gov", "noaa.gov", "reuters.com", "apnews.com", "bbc.com", "bbc.co.uk", "snopes.com", "factcheck.org", "politifact.com", "fullfact.org", } class RiskClassifier: """ Classifies a claim into a risk domain. High-risk claims get a score multiplier that lowers the REMOVE threshold, mirroring how platforms like Meta and YouTube apply stricter rules to medical misinformation and election integrity content. """ def classify(self, claim_text: str) -> Optional[str]: """ Returns the risk domain name (e.g. 'medical') or None if not high-risk. """ for domain, pattern in _RISK_PATTERNS: if pattern.search(claim_text): return domain return None def is_authoritative_contra(self, sources: "List[ScoredSource]") -> bool: """ Returns True if at least one contradicting source comes from an authoritative domain (WHO, CDC, Reuters, etc.). """ for src in sources: if src.stance == "contradicting": domain = self._extract_domain(src.url or "") if any(auth in domain for auth in _AUTHORITATIVE_DOMAINS): return True return False @staticmethod def _extract_domain(url: str) -> str: url = url.lower().split("//")[-1] return url.split("/")[0] # ───────────────────────────────────────────────────────────────────────────── # Settled Science Detector # ───────────────────────────────────────────────────────────────────────────── # Claims where scientific consensus is overwhelming (>97% of experts agree). # When a claim matches AND contradicting sources exist, the system treats it as # settled — confidence is forced to "high" and decision → REMOVE regardless of # source count or reliability scores. # # Each entry: (topic_label, regex_that_matches_the_false_claim) _SETTLED_SCIENCE_CLAIMS: List[Tuple[str, "_re.Pattern"]] = [ ("flat_earth", _re.compile( r"\b(earth\s+is\s+flat|flat\s+earth|earth\s+isn.t?\s+(round|spherical|a\s+globe))\b", _re.IGNORECASE, )), ("vaccines_autism", _re.compile( r"\bvaccines?\s+(cause[sd]?|linked?\s+to|lead[s]?\s+to)\s+autism\b", _re.IGNORECASE, )), ("climate_hoax", _re.compile( r"\bclimate\s+change\s+is\s+(a\s+)?(hoax|fake|not\s+real|fabricated|made\s+up)\b", _re.IGNORECASE, )), ("evolution_denial", _re.compile( r"\b(evolution\s+is\s+(false|fake|a\s+lie|wrong|disproven)|humans?\s+didn.t?\s+evolve)\b", _re.IGNORECASE, )), ("moon_landing_hoax", _re.compile( r"\b(moon\s+landing\s+(was\s+)?(fake|faked|hoax|staged)|never\s+landed\s+on\s+(the\s+)?moon)\b", _re.IGNORECASE, )), ("5g_covid", _re.compile( r"\b5g\s+(causes?|spreads?|transmits?|created?)\s+(covid|coronavirus|virus|cancer)\b", _re.IGNORECASE, )), ("bleach_cure", _re.compile( r"\b(drink(ing)?\s+bleach|bleach\s+cures?|mms\s+cures?|chlorine\s+dioxide\s+cure)\b", _re.IGNORECASE, )), ("hiv_not_real", _re.compile( r"\b(hiv\s+(doesn.t?\s+cause|doesn.t?\s+exist|is\s+fake)|aids\s+is\s+(a\s+)?(hoax|fake))\b", _re.IGNORECASE, )), ] class SettledScienceDetector: """ Detects claims that contradict overwhelming scientific consensus. When matched + contradicting evidence exists, confidence is forced to "high" and the decision is escalated directly to REMOVE. """ def is_settled(self, claim_text: str) -> Optional[str]: """ Returns the topic label (e.g. 'flat_earth') if the claim contradicts settled science, otherwise None. """ for label, pattern in _SETTLED_SCIENCE_CLAIMS: if pattern.search(claim_text): return label return None # ───────────────────────────────────────────────────────────────────────────── # Data classes # ───────────────────────────────────────────────────────────────────────────── @dataclass class SourceEvidence: """Per-source evidence contribution (for explainability).""" source_id: str title: str stance: str reliability: float similarity: float contribution: float # signed score contribution @dataclass class StructuredReasoning: """ Complete reasoning artifact produced by DeterministicReasoningEngine. Consumed by ExplainabilityEngine and the environment's decide handler. """ # Core decision output decision: str # "allow" | "flag" | "remove" evidence_score: float # raw aggregated score confidence: str # "high" | "medium" | "low" # Per-source breakdown source_evidence: List[SourceEvidence] = field(default_factory=list) # Counts n_supporting: int = 0 n_contradicting: int = 0 n_neutral: int = 0 n_total: int = 0 # Safety rule audit trail safety_rules_fired: List[str] = field(default_factory=list) # Risk / settled-science classification risk_domain: Optional[str] = None # e.g. "medical", "election" settled_science_topic: Optional[str] = None # e.g. "flat_earth", None # Human-readable summary rationale: str = "" def to_dict(self) -> dict: return { "decision": self.decision, "evidence_score": round(self.evidence_score, 4), "confidence": self.confidence, "risk_domain": self.risk_domain, "settled_science_topic": self.settled_science_topic, "n_supporting": self.n_supporting, "n_contradicting": self.n_contradicting, "n_neutral": self.n_neutral, "n_total": self.n_total, "safety_rules_fired": self.safety_rules_fired, "rationale": self.rationale, "source_breakdown": [ { "id": e.source_id, "title": e.title[:80], "stance": e.stance, "reliability": round(e.reliability, 3), "similarity": round(e.similarity, 3), "contribution": round(e.contribution, 4), } for e in self.source_evidence ], } # ───────────────────────────────────────────────────────────────────────────── # Part 1 — Evidence Aggregator # ───────────────────────────────────────────────────────────────────────────── class EvidenceAggregator: """ Converts a list of scored+filtered sources into a signed numeric score and a per-source breakdown for transparency. """ # Stance → sign of contribution _STANCE_WEIGHT = { "supporting": +1.0, "contradicting": -1.0, "neutral": 0.0, } # Reliability threshold for "high-quality" source classification _HI_REL = 0.65 def __init__(self, config: Optional[ReasoningConfig] = None) -> None: self._cfg = config or _CFG def aggregate(self, sources: List[ScoredSource]) -> Tuple[float, List[SourceEvidence]]: """ Returns (evidence_score, per_source_evidence_list). evidence_score is the sum of signed contributions. It is NOT normalised — callers compare it against the configured thresholds. """ evidence: List[SourceEvidence] = [] total_score = 0.0 for src in sources: stance_w = self._STANCE_WEIGHT.get(src.stance, 0.0) sim_boost = self._sim_boost(src.similarity) contribution = stance_w * src.reliability * sim_boost evidence.append(SourceEvidence( source_id = src.id, title = src.title, stance = src.stance, reliability = src.reliability, similarity = src.similarity, contribution = round(contribution, 4), )) total_score += contribution return round(total_score, 4), evidence def classify_evidence_state(self, sources: List[ScoredSource]) -> str: """ Categorises the overall state of evidence. Used by the reasoning engine for calibrated confidence and safety-rule decisions. Returns one of: "no_evidence" — no relevant sources at all "strong_contra" — ≥2 high-reliability contradicting sources "weak_contra" — contradicting sources but low reliability "strong_support" — ≥2 high-reliability supporting sources "weak_support" — some supporting but low reliability "conflicting" — both high-reliability supporting AND contradicting "neutral_only" — sources exist but all neutral """ if not sources: return "no_evidence" contra = [s for s in sources if s.stance == "contradicting"] supp = [s for s in sources if s.stance == "supporting"] hi_contra = [s for s in contra if s.reliability >= self._HI_REL] hi_supp = [s for s in supp if s.reliability >= self._HI_REL] if hi_contra and hi_supp: return "conflicting" if len(hi_contra) >= 2: return "strong_contra" if contra: return "weak_contra" if len(hi_supp) >= 2: return "strong_support" if supp: return "weak_support" return "neutral_only" def _sim_boost(self, sim: float) -> float: if sim >= self._cfg.high_sim_cutoff: return self._cfg.high_sim_boost if sim >= self._cfg.low_sim_cutoff: return 1.0 return self._cfg.low_sim_boost # ───────────────────────────────────────────────────────────────────────────── # Part 2 — Safety Rules # ───────────────────────────────────────────────────────────────────────────── class SafetyRules: """ Post-processes the score-based decision with hard safety constraints. Returns (final_decision, list_of_rules_fired). """ def __init__(self, config: Optional[ReasoningConfig] = None) -> None: self._cfg = config or _CFG def apply( self, score_decision: str, evidence_score: float, sources: List[ScoredSource], evidence: List[SourceEvidence], evidence_state: str = "unknown", risk_domain: Optional[str] = None, has_authoritative_contra: bool = False, ) -> Tuple[str, List[str]]: """ Returns (final_decision, fired_rules). Rules are evaluated in priority order; first match wins for decision overrides, but ALL fired rules are reported for transparency. """ fired: List[str] = [] final = score_decision # Rule 1: No sources at all → must FLAG if not sources: fired.append("NO_SOURCES: no evidence retrieved — defaulting to FLAG") return "flag", fired # Rule 2: All sources neutral → FLAG (can't confirm or deny) if all(s.stance == "neutral" for s in sources): fired.append("ALL_NEUTRAL: all sources are neutral — defaulting to FLAG") final = "flag" # Rule 3: REMOVE requires sufficient contradicting evidence if final == "remove": contra = [s for s in sources if s.stance == "contradicting"] if len(contra) < int(self._cfg.min_remove_sources): fired.append( f"INSUFFICIENT_CONTRA: only {len(contra)} contradicting source(s), " f"need ≥{int(self._cfg.min_remove_sources)} for REMOVE → downgrading to FLAG" ) final = "flag" # Rule 4: REMOVE requires contradicting sources to be reliable if final == "remove": contra = [s for s in sources if s.stance == "contradicting"] if contra: avg_rel = sum(s.reliability for s in contra) / len(contra) if avg_rel < self._cfg.min_remove_reliability: fired.append( f"LOW_CONTRA_RELIABILITY: avg reliability of contradicting " f"sources={avg_rel:.2f} < {self._cfg.min_remove_reliability:.2f} → " f"downgrading to FLAG" ) final = "flag" # Rule 5: Score near boundary → resolve uncertainty with FLAG boundary = self._cfg.boundary_margin if abs(evidence_score) < boundary and final != "flag": fired.append( f"BOUNDARY_UNCERTAINTY: |score|={abs(evidence_score):.3f} < " f"margin={boundary:.2f} — insufficient evidence → FLAG" ) final = "flag" # Rule 6: ALLOW requires at least one credible supporting source. if final == "allow": hi_rel_support = [ s for s in sources if s.stance == "supporting" and s.reliability >= 0.60 ] if not hi_rel_support: fired.append( "NO_CREDIBLE_SUPPORT: decision was ALLOW but no high-reliability " "supporting sources found — downgrading to FLAG" ) final = "flag" # Rule 7: HIGH-RISK + strong_contra + authoritative source → force REMOVE # Mirrors Meta/YouTube policy: medical/election misinformation with official # fact-check contradictions is removed automatically, not just flagged. if ( risk_domain is not None and evidence_state in ("strong_contra", "weak_contra") and has_authoritative_contra and final in ("flag", "remove") ): fired.append( f"HIGH_RISK_AUTO_REMOVE: claim is in high-risk domain '{risk_domain}' " f"and contradicted by an authoritative source — forcing REMOVE" ) final = "remove" return final, fired # ───────────────────────────────────────────────────────────────────────────── # Part 3 — Deterministic Reasoning Engine # ───────────────────────────────────────────────────────────────────────────── class DeterministicReasoningEngine: """ Main entry point. Consumes a list of already-ranked ScoredSources (output of RetrievalPipeline) and produces a StructuredReasoning. Usage ----- engine = DeterministicReasoningEngine() result = engine.reason(claim_text="5G causes COVID", sources=scored_sources) # result.decision → "flag" | "allow" | "remove" # result.to_dict() → full JSON-serialisable breakdown """ def __init__(self, config: Optional[ReasoningConfig] = None) -> None: self._cfg = config or _CFG self._aggregator = EvidenceAggregator(config) self._safety = SafetyRules(config) self._risk = RiskClassifier() self._settled = SettledScienceDetector() def reason( self, claim_text: str, sources: List[ScoredSource], ) -> StructuredReasoning: """ Full deterministic reasoning pass for one claim against its sources. """ # Step 1: classify evidence state evidence_state = self._aggregator.classify_evidence_state(sources) # Step 2: classify claim risk + settled-science risk_domain = self._risk.classify(claim_text) has_auth_contra = self._risk.is_authoritative_contra(sources) settled_topic = self._settled.is_settled(claim_text) # Step 3: aggregate evidence, apply high-risk multiplier if needed score, evidence = self._aggregator.aggregate(sources) if risk_domain is not None and score < 0: score = round(score * self._cfg.high_risk_multiplier, 4) logger.debug("High-risk domain '%s': score amplified to %.3f", risk_domain, score) # Step 4: score → raw decision (tighter threshold for high-risk) raw_decision = self._score_to_decision(score, risk_domain) # Step 5: apply safety rules final_decision, fired = self._safety.apply( raw_decision, score, sources, evidence, evidence_state, risk_domain, has_auth_contra, ) # Step 6: settled-science override — any contradicting evidence → REMOVE n_con = sum(1 for s in sources if s.stance == "contradicting") if ( settled_topic is not None and n_con >= 1 and int(self._cfg.settled_science_remove) == 1 ): fired.append( f"SETTLED_SCIENCE: '{settled_topic}' contradicts scientific consensus " f"({n_con} source(s) agree) — forcing REMOVE with high confidence" ) final_decision = "remove" # Step 7: compute calibrated confidence n_sup = sum(1 for s in sources if s.stance == "supporting") n_neu = sum(1 for s in sources if s.stance == "neutral") confidence = self._confidence( score, sources, evidence_state, n_con=n_con, n_sup=n_sup, settled_topic=settled_topic, ) # Step 8: build rationale rationale = self._build_rationale( claim_text, final_decision, score, confidence, n_sup, n_con, n_neu, fired, evidence_state, risk_domain, settled_topic, ) result = StructuredReasoning( decision = final_decision, evidence_score = score, confidence = confidence, source_evidence = evidence, n_supporting = n_sup, n_contradicting = n_con, n_neutral = n_neu, n_total = len(sources), safety_rules_fired = fired, risk_domain = risk_domain, settled_science_topic = settled_topic, rationale = rationale, ) logger.info( "Reasoning: claim='%s…' risk=%s settled=%s state=%s score=%.3f " "raw=%s final=%s conf=%s sources=%d(sup=%d/con=%d/neu=%d) rules=%d", claim_text[:50], risk_domain or "none", settled_topic or "none", evidence_state, score, raw_decision, final_decision, confidence, len(sources), n_sup, n_con, n_neu, len(fired), ) return result # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _score_to_decision(self, score: float, risk_domain: Optional[str] = None) -> str: remove_thresh = ( self._cfg.high_risk_remove_threshold if risk_domain is not None else self._cfg.remove_threshold ) if score > self._cfg.allow_threshold: return "allow" if score < remove_thresh: return "remove" return "flag" def _confidence( self, score: float, sources: List[ScoredSource], evidence_state: str, n_con: int = 0, n_sup: int = 0, settled_topic: Optional[str] = None, ) -> str: """ Confidence reflects how certain the system is about its decision. Priority cascade (highest wins): 1. Settled science + any contra evidence → always "high" 2. Consensus scaling: confidence grows with n_contra sources base=0.40, +0.12 per source, +0.15 bonus when n_sup=0 3. Evidence state shortcuts (strong_contra/strong_support) 4. Reliability-weighted fallback """ abs_score = abs(score) if evidence_state == "no_evidence" or not sources: return "low" # Rule 1: settled science is always high certainty when contradicted if settled_topic is not None and n_con >= 1: return "high" # Rule 2: consensus-based scaling if n_con >= 1: raw = self._cfg.consensus_base_confidence + (n_con - 1) * self._cfg.consensus_per_source_boost # Bonus when absolutely no supporting sources exist (no counter-narrative) if n_sup == 0: raw += self._cfg.no_support_bonus raw = min(1.0, raw) if raw >= self._cfg.consensus_high_cutoff: return "high" if raw >= self._cfg.consensus_medium_cutoff: return "medium" return "low" # Rule 3: conflicting evidence if evidence_state == "conflicting": return "medium" if abs_score >= 0.3 else "low" # Rule 4: strong supporting state if evidence_state == "strong_support" and abs_score >= 0.3: return "high" # Rule 5: reliability-weighted fallback n_hi_rel = sum(1 for s in sources if s.reliability >= 0.70) if n_hi_rel >= 2 and abs_score >= 0.40: return "high" if n_hi_rel >= 1 and abs_score >= 0.20: return "medium" if abs_score >= self._cfg.high_confidence_threshold: return "high" if abs_score >= self._cfg.low_confidence_threshold: return "medium" return "low" @staticmethod def _build_rationale( claim: str, decision: str, score: float, confidence: str, n_sup: int, n_con: int, n_neu: int, fired: List[str], evidence_state: str = "", risk_domain: Optional[str] = None, settled_topic: Optional[str] = None, ) -> str: parts = [] # Settled science notice (highest priority label) if settled_topic: settled_labels = { "flat_earth": "flat-Earth (Earth is spherical — settled science)", "vaccines_autism": "vaccine-autism link (disproven by large-scale studies)", "climate_hoax": "climate change denial (97%+ scientific consensus)", "evolution_denial": "evolution denial (established biological science)", "moon_landing_hoax": "moon landing conspiracy (documented historical fact)", "5g_covid": "5G-COVID link (physically impossible — settled science)", "bleach_cure": "bleach as cure (extremely dangerous pseudoscience)", "hiv_not_real": "HIV/AIDS denial (settled medical science)", } label = settled_labels.get(settled_topic, f"{settled_topic} (settled science)") parts.append(f"SETTLED SCIENCE: This claim involves {label}.") elif risk_domain: domain_labels = { "medical": "medical/health misinformation", "harmful_action": "content promoting harmful actions", "public_health": "public health misinformation", "election": "election integrity misinformation", "climate": "climate misinformation", } label = domain_labels.get(risk_domain, f"{risk_domain} misinformation") parts.append(f"HIGH-RISK: This claim involves {label}.") decision_map = { "allow": "The claim is supported by available evidence.", "flag": "The evidence is insufficient or conflicting — the claim is flagged for review.", "remove": "Strong contradicting evidence indicates the claim is false or harmful.", } parts.append(decision_map.get(decision, "Decision made.")) state_desc = { "strong_contra": "Multiple high-credibility sources contradict this claim.", "weak_contra": "Low-credibility sources contradict this claim.", "strong_support": "Multiple high-credibility sources support this claim.", "weak_support": "Some sources support this claim but their credibility is limited.", "conflicting": "Sources are divided — some support and some contradict this claim.", "neutral_only": "Available sources are neutral and do not confirm or deny the claim.", "no_evidence": "No relevant sources were found to assess this claim.", } if evidence_state in state_desc: parts.append(state_desc[evidence_state]) # Consensus strength note if n_con >= 1 and n_sup == 0: parts.append(f"No supporting sources found — all {n_con} source(s) contradict the claim.") elif n_con >= 3: parts.append(f"{n_con} independent sources contradict this claim (strong consensus).") parts.append( f"Evidence score: {score:+.3f} ({confidence} confidence). " f"Sources: {n_sup} supporting, {n_con} contradicting, {n_neu} neutral." ) # Safety rules if fired: parts.append("Safety rules applied: " + " | ".join(fired)) return " ".join(parts) # ───────────────────────────────────────────────────────────────────────────── # Convenience: reason over multiple claims, return aggregate decision # ───────────────────────────────────────────────────────────────────────────── def reason_over_claims( claim_results: Dict[str, List[ScoredSource]], engine: Optional[DeterministicReasoningEngine] = None, ) -> Tuple[str, Dict[str, StructuredReasoning]]: """ Run reasoning for each claim independently, then derive a single aggregate content decision. Parameters ---------- claim_results : dict mapping claim_text → List[ScoredSource] engine : optional pre-built engine (created if None) Returns ------- (aggregate_decision, per_claim_reasoning_dict) Aggregate logic: ANY "remove" → aggregate = "remove" ANY "flag" → aggregate = "flag" (unless already remove) ALL "allow" → aggregate = "allow" """ eng = engine or DeterministicReasoningEngine() per_claim: Dict[str, StructuredReasoning] = {} for claim_text, sources in claim_results.items(): per_claim[claim_text] = eng.reason(claim_text, sources) decisions = [r.decision for r in per_claim.values()] if "remove" in decisions: agg = "remove" elif "flag" in decisions: agg = "flag" elif decisions: agg = "allow" else: agg = "flag" # no claims → conservative default return agg, per_claim