12-Angry-Agent / core /conviction.py
Blu3Orange
feat: Introduce argument direction handling and enhance conviction mechanics for juror interactions
373ff24
"""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"