"""Fuzzy Logic Fusion Engine — the core of Hermyon. Replaces weighted averaging with a full fuzzy inference system: 1. Fuzzification: map modality outputs to fuzzy membership degrees 2. Rule evaluation: fire IF-THEN rules against fuzzy states 3. Conflict detection: identify cross-modal disagreements 4. Aggregation: combine rule outputs 5. Defuzzification: produce crisp 28-dim GoEmotions probabilities """ from __future__ import annotations import numpy as np from typing import Optional from constants import GOEMOTIONS_LABELS, NUM_GOEMOTIONS from schemas import ( ModalityResult, FuzzyFusionResult, FuzzyEmotionState, CrossModalConflict, ) from projector import EmotionProjector from membership import FuzzyMembershipFunctions, LEVEL_CENTROIDS from rule_base import FuzzyRuleBase from conflict_detector import ConflictDetector from defuzzifier import Defuzzifier class FuzzyFusionEngine: """Mamdani-style fuzzy inference system for multimodal emotion fusion.""" def __init__( self, defuzzification_method: str = "centroid", conflict_threshold: float = 0.3, culture_id: str | None = None, base_weights: dict[str, float] | None = None, ): self.projector = EmotionProjector() self.mf = FuzzyMembershipFunctions self.rule_base = FuzzyRuleBase() self.conflict_detector = ConflictDetector(severity_threshold=conflict_threshold) self.defuzzifier = Defuzzifier(method=defuzzification_method) self.base_weights = base_weights or { "face": 0.20, "voice": 0.30, "text": 0.35, "posture": 0.15, } self.culture_id = culture_id if culture_id: self.rule_base.add_cultural_rules(culture_id) def fuse( self, face_result: ModalityResult | None = None, voice_result: ModalityResult | None = None, text_result: ModalityResult | None = None, posture_result: ModalityResult | None = None, ) -> FuzzyFusionResult: projected = {} confidences = {} modality_results = {} available = [] if face_result is not None and face_result.confidence > 0: face_28 = self.projector.project_face(np.array(face_result.probabilities)) projected["face"] = face_28 confidences["face"] = face_result.confidence modality_results["face"] = face_result available.append("face") if voice_result is not None and voice_result.confidence > 0: voice_28 = self.projector.project_voice(np.array(voice_result.probabilities)) projected["voice"] = voice_28 confidences["voice"] = voice_result.confidence modality_results["voice"] = voice_result available.append("voice") if text_result is not None and text_result.confidence > 0: projected["text"] = np.array(text_result.probabilities) confidences["text"] = text_result.confidence modality_results["text"] = text_result available.append("text") if posture_result is not None and posture_result.confidence > 0: projected["posture"] = np.array(posture_result.probabilities) confidences["posture"] = posture_result.confidence modality_results["posture"] = posture_result available.append("posture") if not available: uniform = np.ones(NUM_GOEMOTIONS) / NUM_GOEMOTIONS return FuzzyFusionResult( labels=list(GOEMOTIONS_LABELS), probabilities=uniform.tolist(), fuzzy_states=[], conflicts=[], fired_rules=[], modality_weights={}, modality_results={}, conflict_score=0.0, ) # Fuzzification modality_fuzzy: dict[str, dict[str, dict[str, float]]] = {} for mod_name in available: vec = projected[mod_name] fuzzy_states = {} for i, label in enumerate(GOEMOTIONS_LABELS): fuzzy_states[label] = self.mf.fuzzify(float(vec[i])) modality_fuzzy[mod_name] = fuzzy_states # Baseline weighted blend weights = {} for mod_name in available: base_w = self.base_weights.get(mod_name, 1.0 / len(available)) weights[mod_name] = base_w * confidences[mod_name] total_w = sum(weights.values()) if total_w > 0: weights = {k: v / total_w for k, v in weights.items()} base_crisp = np.zeros(NUM_GOEMOTIONS) for mod_name in available: base_crisp += weights[mod_name] * projected[mod_name] base_fuzzy = {} for i, label in enumerate(GOEMOTIONS_LABELS): base_fuzzy[label] = self.mf.fuzzify(float(base_crisp[i])) # Rule Evaluation face_fuzzy = modality_fuzzy.get("face") voice_fuzzy = modality_fuzzy.get("voice") text_fuzzy = modality_fuzzy.get("text") posture_fuzzy = modality_fuzzy.get("posture") fired_rules = self.rule_base.evaluate(face_fuzzy, voice_fuzzy, text_fuzzy, posture_fuzzy) # Conflict Detection conflicts = self.conflict_detector.detect(modality_fuzzy, available) conflict_score = self.conflict_detector.overall_conflict_score(conflicts) # Defuzzification crisp_result, fired_names = self.defuzzifier.defuzzify(base_fuzzy, fired_rules, base_crisp) # Build fuzzy state trace fuzzy_states = [] for i, label in enumerate(GOEMOTIONS_LABELS): memberships = base_fuzzy.get(label, {"absent": 1.0}) dominant = max(memberships, key=memberships.get) fuzzy_states.append(FuzzyEmotionState( emotion=label, memberships=memberships, dominant_level=dominant, crisp_value=float(crisp_result[i]), )) return FuzzyFusionResult( labels=list(GOEMOTIONS_LABELS), probabilities=crisp_result.tolist(), fuzzy_states=fuzzy_states, conflicts=conflicts, fired_rules=fired_names, modality_weights=weights, modality_results=modality_results, conflict_score=conflict_score, ) def set_culture(self, culture_id: str) -> None: self.culture_id = culture_id self.rule_base = FuzzyRuleBase() self.rule_base.add_cultural_rules(culture_id) @property def num_rules(self) -> int: return len(self.rule_base.rules)