"""Ensemble fusion — combines DetectionResult objects into a final verdict. Strategy: 1. Filter: drop failed/errored detectors (set confidence = 0 proxy). 2. Bayesian log-odds combination weighted by detector weights × confidence. 3. Conflict detection: flag when high-confidence detectors strongly disagree. 4. Threshold: map final p_fake → REAL / UNCERTAIN / AI_GENERATED. Why Bayesian log-odds over simple weighted average: - A detector that says 0.99 should dominate more than one saying 0.6. - Log-odds is the correct way to combine independent probability estimates. - We still weight by (detector_weight × confidence) to account for detector reliability and self-reported uncertainty. """ from __future__ import annotations import math from typing import Sequence from backend.core.config import settings from backend.core.schema import DetectionResult, EnsembleResult, Verdict from backend.fusion.meta_learner import get_meta_learner # Weight per detector — mirrors config but looked up at fusion time. _WEIGHTS: dict[str, float] = { "hive": settings.weight_hive, "hive_vlm": settings.weight_hive_vlm, "aiornot": settings.weight_aiornot, "sightengine": settings.weight_sightengine, "claude_vision": settings.weight_claude, "mistral_vision": settings.weight_mistral, "gpt4_vision": settings.weight_gpt4, "gemini_vision": settings.weight_gemini, "gemma_vision": settings.weight_gemma_vision, "openrouter_gpt4": settings.weight_openrouter_gpt4, "openrouter_llama": settings.weight_openrouter_llama, "openrouter_mistral": settings.weight_openrouter_mistral, "local_forensics": settings.weight_local_forensics, "local_finetuned": settings.weight_local_finetuned, "hybrid_model": settings.weight_hybrid_model, # V3 detectors "frequency_domain": settings.weight_frequency_domain, "clip_unifd": settings.weight_clip_unifd, "dire": settings.weight_dire, "face_specialist": settings.weight_face_specialist, # V4 detectors "siglip2_aigc": settings.weight_siglip2_aigc, "npr": settings.weight_npr, } # Per-detector vote multiplier for the voting strategy. Sightengine is the # primary commercial signal, so its vote counts double. _VOTE_WEIGHTS: dict[str, float] = {"sightengine": 2.0} def _vote_weight(detector: str) -> float: return _VOTE_WEIGHTS.get(detector, 1.0) class EnsembleFusion: def fuse( self, results: Sequence[DetectionResult], processing_time_ms: float = 0.0, fusion_strategy_override: str | None = None, ) -> EnsembleResult: valid = [r for r in results if r.error is None and r.confidence > 0] failed = [r for r in results if r.error is not None or r.confidence == 0] if not valid: return EnsembleResult( verdict=Verdict.UNCERTAIN, p_fake=0.5, confidence=0.0, primary_evidence="All detectors failed or were unavailable", uncertainty_factors=[f"{r.detector}: {r.error}" for r in failed], detector_results=list(results), fusion_details={"method": "none"}, processing_time_ms=processing_time_ms, ) # Choose fusion strategy: meta-learner → per-request override → server default active_strategy = fusion_strategy_override or settings.fusion_strategy ml = get_meta_learner() if settings.use_meta_learner_fusion else None if ml is not None: p_fake, method_details = ml.predict(valid) elif active_strategy == "voting": p_fake, method_details = self._voting_fusion(valid) else: p_fake, method_details = self._bayesian_fusion(valid) verdict = _verdict_from_p_fake(p_fake, detector_results=valid) conflicts = self._detect_conflicts(valid) raw_conf = abs(p_fake - 0.5) * 2.0 conflict_penalty = 0.15 * len(conflicts) confidence = float(max(0.0, min(1.0, raw_conf - conflict_penalty))) primary_evidence, supporting = self._summarise_evidence(valid, verdict) uncertainty_factors = conflicts[:] if failed: uncertainty_factors.append( f"Unavailable detectors: {', '.join(r.detector for r in failed)}" ) # Extract diffusion_suspicion from local_forensics raw output diffusion_suspicion = 0.0 for r in results: if r.detector == "local_forensics" and r.raw: diffusion_suspicion = float(r.raw.get("diffusion_suspicion", 0.0)) break # Propagate generator name from whichever detector identified it generator = next( (r.generator for r in results if r.generator), None, ) return EnsembleResult( verdict=verdict, p_fake=p_fake, confidence=confidence, primary_evidence=primary_evidence, supporting_evidence=supporting, uncertainty_factors=uncertainty_factors, detector_results=list(results), fusion_details={ "method": "meta_learner" if ml else active_strategy, **method_details, "n_valid": len(valid), "n_failed": len(failed), }, processing_time_ms=processing_time_ms, diffusion_suspicion=diffusion_suspicion, generator=generator, ) # ── Bayesian log-odds fusion ──────────────────────────────── def _bayesian_fusion( self, valid: list[DetectionResult] ) -> tuple[float, dict]: """ Combine independent P(fake) estimates via log-odds. log_odds_posterior = log_odds_prior + Σ w_i * log_odds_i Prior: 0.5 (no prior knowledge). """ prior_log_odds = 0.0 total_weight = 0.0 weighted_log_odds = 0.0 per_detector: dict[str, dict] = {} for r in valid: w = _WEIGHTS.get(r.detector, 1.0) * float(r.confidence) # Dynamic weight adjustment based on generator detection # If Sightengine detected a specific generator, boost its weight (30% boost) if r.detector == "sightengine" and hasattr(r, "generator") and r.generator: w *= 1.3 # 30% confidence boost when generator explicitly detected weight_reason = f"generator detected ({r.generator})" else: weight_reason = "base weight" p = float(r.p_fake) p = max(0.01, min(0.99, p)) lo = math.log(p / (1.0 - p)) weighted_log_odds += w * lo total_weight += w per_detector[r.detector] = { "p_fake": r.p_fake, "confidence": r.confidence, "weight": _WEIGHTS.get(r.detector, 1.0), "effective_weight": w, "log_odds": lo, "weight_reason": weight_reason, "generator": getattr(r, "generator", None), } if total_weight == 0: return 0.5, {} avg_log_odds = prior_log_odds + (weighted_log_odds / total_weight) p_fake = 1.0 / (1.0 + math.exp(-avg_log_odds)) return float(p_fake), { "total_weight": total_weight, "avg_log_odds": avg_log_odds, "per_detector": per_detector, } # ── Voting fusion ─────────────────────────────────────────── def _voting_fusion( self, valid: list[DetectionResult] ) -> tuple[float, dict]: """ Each detector casts one vote based on its own verdict threshold. p_fake is computed as fraction of AI votes, then sharpened so it actually crosses the ai_threshold / real_threshold. voting_threshold (default 0.5): fraction of votes needed to call AI. """ votes_ai = [r for r in valid if r.p_fake >= settings.ai_threshold] votes_real = [r for r in valid if r.p_fake <= settings.real_threshold] votes_uncertain = [ r for r in valid if settings.real_threshold < r.p_fake < settings.ai_threshold ] # Votes are weighted per detector (Sightengine counts double). total_votes = sum(_vote_weight(r.detector) for r in valid) ai_votes = sum(_vote_weight(r.detector) for r in votes_ai) real_votes = sum(_vote_weight(r.detector) for r in votes_real) ai_fraction = ai_votes / total_votes # Map fraction → p_fake in a way that crosses thresholds cleanly: # If ai_fraction >= voting_threshold → p_fake just above ai_threshold # If ai_fraction == 0 → p_fake just below real_threshold # Otherwise → UNCERTAIN midpoint if ai_fraction >= settings.voting_threshold: # Scale within [ai_threshold, 1.0] based on how overwhelming the vote is p_fake = settings.ai_threshold + (1.0 - settings.ai_threshold) * ai_fraction elif real_votes / total_votes >= settings.voting_threshold: real_fraction = real_votes / total_votes p_fake = settings.real_threshold * (1.0 - real_fraction) else: # Split vote → UNCERTAIN: vote-weighted average of individual p_fakes p_fake = sum(_vote_weight(r.detector) * r.p_fake for r in valid) / total_votes return float(p_fake), { "votes_ai": [r.detector for r in votes_ai], "votes_real": [r.detector for r in votes_real], "votes_uncertain": [r.detector for r in votes_uncertain], "ai_fraction": ai_fraction, "voting_threshold": settings.voting_threshold, } # ── Conflict detection ────────────────────────────────────── def _detect_conflicts(self, valid: list[DetectionResult]) -> list[str]: """Flag when high-confidence detectors strongly disagree. Also flags significant disagreement between hybrid model versions (latest vs backup) which may indicate uncertain/ambiguous images. """ conflicts: list[str] = [] high_conf = [r for r in valid if r.confidence >= 0.6] if len(high_conf) < 2: return conflicts ai_detectors = [r for r in high_conf if r.verdict == Verdict.AI_GENERATED] real_detectors = [r for r in high_conf if r.verdict == Verdict.REAL] if ai_detectors and real_detectors: ai_names = ", ".join(r.detector for r in ai_detectors) real_names = ", ".join(r.detector for r in real_detectors) conflicts.append( f"Detector disagreement: [{ai_names}] → AI vs [{real_names}] → REAL" ) # Check for hybrid model version disagreement (both are "hybrid_model" detector) # This catches cases where latest and backup models give conflicting signals hybrid_results = [r for r in valid if r.detector == "hybrid_model"] if len(hybrid_results) >= 2: # Compare p_fake values across model versions p_fakes = [float(r.p_fake) for r in hybrid_results] p_fake_spread = max(p_fakes) - min(p_fakes) # If models differ by >0.25 in p_fake, flag as uncertainty signal # e.g., latest=0.48, backup=0.34 → spread=0.14 (not flagged) # e.g., latest=0.48, backup=0.65 → spread=0.17 (not flagged, both >0.3) # But if verdicts differ (one AI, one REAL), flag even with smaller spread verdicts = [r.verdict for r in hybrid_results] if len(set(verdicts)) > 1: # Different verdicts conflicts.append( f"Hybrid model version disagreement: " f"{hybrid_results[0].detector} versions give conflicting signals " f"(p_fake range: {min(p_fakes):.2f}-{max(p_fakes):.2f})" ) return conflicts # ── Evidence summary ──────────────────────────────────────── def _summarise_evidence( self, valid: list[DetectionResult], verdict: Verdict ) -> tuple[str, list[str]]: if verdict == Verdict.AI_GENERATED: ranked = sorted(valid, key=lambda r: r.p_fake, reverse=True) elif verdict == Verdict.REAL: ranked = sorted(valid, key=lambda r: r.p_fake) else: ranked = sorted(valid, key=lambda r: r.confidence, reverse=True) primary = "" supporting: list[str] = [] for r in ranked: if not r.evidence: continue first = r.evidence[0] # Make verdict more explicit in evidence summary if verdict == Verdict.REAL and r.verdict == Verdict.REAL: summary = f"[{r.detector}] Real image: {(1-r.p_fake)*100:.1f}% confidence" elif verdict == Verdict.AI_GENERATED and r.verdict == Verdict.AI_GENERATED: summary = f"[{r.detector}] AI-generated: {r.p_fake*100:.1f}% confidence" elif verdict == Verdict.MANIPULATED_DEEPFAKE and r.verdict == Verdict.MANIPULATED_DEEPFAKE: summary = f"[{r.detector}] Face manipulation detected: {r.p_fake*100:.1f}% confidence" else: summary = f"[{r.detector}] {first}" if not primary: primary = summary else: supporting.append(summary) if len(supporting) >= 4: break if not primary: primary = f"Ensemble confidence: {(1-valid[0].p_fake)*100:.1f}% real from {len(valid)} detectors" return primary, supporting def _verdict_from_p_fake( p_fake: float, detector_results: list | None = None, ) -> Verdict: """ Map ensemble p_fake to a verdict. If p_fake crosses the AI threshold AND the hybrid_model detector specifically predicted MANIPULATED_DEEPFAKE with high confidence, return that more specific verdict instead of the generic AI_GENERATED. """ if p_fake >= settings.ai_threshold: # Check if the V1 model specifically identified a deepfake manipulation if detector_results: hybrid = next( (r for r in detector_results if r.detector == "hybrid_model" and r.error is None and r.verdict == Verdict.MANIPULATED_DEEPFAKE and r.confidence >= 0.5), None, ) if hybrid is not None: return Verdict.MANIPULATED_DEEPFAKE return Verdict.AI_GENERATED if p_fake <= settings.real_threshold: return Verdict.REAL return Verdict.UNCERTAIN