"""Conviction score mechanics for juror persuasion.""" import random from typing import Literal from .models import JurorConfig, JurorMemory from .game_state import DeliberationTurn, ArgumentDirection # Archetype modifiers for different argument types ARCHETYPE_MODIFIERS: dict[str, dict[str, float]] = { "rationalist": { "logical": 1.5, "evidence": 1.3, "emotional": 0.4, "moral": 0.6, "narrative": 0.7, "question": 1.2, }, "empath": { "logical": 0.6, "evidence": 0.8, "emotional": 1.5, "moral": 1.3, "narrative": 1.2, "question": 0.9, }, "cynic": { "logical": 0.8, "evidence": 1.4, "emotional": 0.3, "moral": 0.5, "narrative": 0.6, "question": 0.7, }, "conformist": { "logical": 0.9, "evidence": 0.9, "emotional": 1.2, "moral": 1.0, "narrative": 1.1, "question": 0.8, }, "contrarian": { "logical": 1.2, "evidence": 1.0, "emotional": 0.7, "moral": 0.8, "narrative": 0.9, "question": 1.4, }, "impatient": { "logical": 0.7, "evidence": 0.8, "emotional": 1.0, "moral": 0.7, "narrative": 0.6, "question": 0.5, }, "detail_obsessed": { "logical": 1.3, "evidence": 1.5, "emotional": 0.5, "moral": 0.6, "narrative": 0.8, "question": 1.3, }, "moralist": { "logical": 0.7, "evidence": 0.8, "emotional": 1.2, "moral": 1.5, "narrative": 1.1, "question": 0.9, }, "pragmatist": { "logical": 1.2, "evidence": 1.1, "emotional": 0.6, "moral": 0.9, "narrative": 0.8, "question": 1.1, }, "storyteller": { "logical": 0.7, "evidence": 0.9, "emotional": 1.1, "moral": 1.0, "narrative": 1.5, "question": 1.0, }, "wildcard": { "logical": 0.9, "evidence": 0.9, "emotional": 1.0, "moral": 1.0, "narrative": 1.2, "question": 1.0, }, } def get_archetype_modifier(archetype: str, argument_type: str) -> float: """Get the modifier for an archetype's response to an argument type.""" archetype_mods = ARCHETYPE_MODIFIERS.get(archetype, {}) return archetype_mods.get(argument_type, 1.0) def calculate_direction_impact( direction: ArgumentDirection, base_strength: float = 0.1 ) -> float: """ Convert argument direction to signed impact. Args: direction: Which side the argument supports base_strength: Base argument strength (0.0 to 1.0) Returns: Signed impact: positive pushes toward guilty, negative toward not guilty """ if direction == ArgumentDirection.PROSECUTION: return base_strength # Positive: push toward guilty elif direction == ArgumentDirection.DEFENSE: return -base_strength # Negative: push toward not guilty return 0.0 # Neutral: no directional push def calculate_conviction_change( juror: JurorConfig, juror_memory: JurorMemory, argument: DeliberationTurn, base_strength: float = 0.1, ) -> float: """ Calculate how much an argument shifts a juror's conviction. Args: juror: The juror configuration juror_memory: The juror's current memory state argument: The argument being made (includes direction) base_strength: Base argument strength (0.0 to 1.0) Returns: delta to add to conviction score (-0.3 to +0.3 typically) """ # Calculate directional base impact from argument direction base_impact = calculate_direction_impact( argument.direction, base_strength ) # If no meaningful impact (neutral direction), skip noise and return 0 if abs(base_impact) < 0.001: return 0.0 # Personality modifiers archetype_modifier = get_archetype_modifier( juror.archetype, argument.argument_type ) # Stubbornness reduces all changes stubbornness_modifier = 1.0 - (juror.stubbornness * 0.7) # Relationship modifier - trust the speaker? trust = juror_memory.opinions_of_others.get(argument.speaker_id, 0.0) trust_modifier = 1.0 + (trust * 0.3) # -30% to +30% # Conviction resistance - quadratic falloff makes extremes much harder to move current = juror_memory.current_conviction distance = abs(current - 0.5) # 0 at center, 0.5 at extremes extreme_resistance = 1.0 - (distance ** 2) * 1.5 # Quadratic: 1.0 center, 0.625 extremes # Calculate base delta without noise delta = ( base_impact * archetype_modifier * stubbornness_modifier * trust_modifier * extreme_resistance ) # Only add volatility noise when there's meaningful impact if abs(delta) > 0.01: effective_volatility = juror.volatility * (1.0 - juror.stubbornness * 0.5) volatility_noise = random.gauss(0, effective_volatility * 0.1) delta += volatility_noise # Clamp to reasonable range return max(-0.3, min(0.3, delta)) def check_vote_flip(juror_memory: JurorMemory) -> bool: """ Check if conviction score warrants a vote change. Uses hysteresis - need to cross threshold by margin to flip. """ if not juror_memory.conviction_history: return False # Get previous conviction (before latest update) prev_conviction = juror_memory.conviction_history[-2] if len(juror_memory.conviction_history) > 1 else 0.5 current_vote_is_guilty = prev_conviction > 0.5 new_conviction = juror_memory.current_conviction # Hysteresis - need to cross threshold by margin to flip if current_vote_is_guilty and new_conviction < 0.4: return True # Flip to not guilty elif not current_vote_is_guilty and new_conviction > 0.6: return True # Flip to guilty return False def apply_conviction_update( juror_memory: JurorMemory, delta: float ) -> tuple[bool, Literal["guilty", "not_guilty"] | None]: """ Apply conviction update and check for vote flip using hysteresis. Args: juror_memory: Juror's memory state delta: Conviction change to apply Returns: Tuple of (vote_flipped, new_vote or None) """ old_vote = juror_memory.get_current_vote() # Apply the delta juror_memory.update_conviction(delta) # Check for vote flip using hysteresis if check_vote_flip(juror_memory): new_vote = juror_memory.get_current_vote() return True, new_vote return False, None def conviction_to_text(conviction: float) -> str: """Convert conviction score to human-readable text.""" if conviction < 0.2: return "Strongly believes NOT GUILTY" elif conviction < 0.4: return "Leaning NOT GUILTY" elif conviction < 0.6: return "Undecided" elif conviction < 0.8: return "Leaning GUILTY" else: return "Strongly believes GUILTY"