village_gossip_simulator / simulation.py
Sugutt's picture
Upload Village Gossip Economy Space
ae929d7 verified
Raw
History Blame Contribute Delete
23.2 kB
from __future__ import annotations
from collections import defaultdict
from economy import collect_bids, relationship, select_winning_bids
from llm import GossipLLM
from models import Bid, Channel, ConversationResult, Event, Evidence, Memory, Rumor, VillageState, Villager
from state import clamp
REPUTATION_WEIGHTS = {
"financial": {"honesty": 0.7, "competence": 0.3},
"moral": {"morality": 0.8, "honesty": 0.2},
"leadership": {"leadership": 0.7, "competence": 0.3},
"relationship": {"morality": 0.5, "kindness": 0.3, "honesty": 0.2},
"competence": {"competence": 0.8, "leadership": 0.2},
"kindness": {"kindness": 0.8, "morality": 0.2},
"political": {"leadership": 0.5, "honesty": 0.3, "competence": 0.2},
}
EVIDENCE_TEMPLATES = {
"financial": [
("Receipt found near the SACCO office", 0.72),
("Chama records conflict with the rumor", 0.84),
("Audit document mentioned by Cherono", 0.9),
],
"moral": [
("Pastor privately knows missing context", 0.74),
("Witness contradicts the loudest version", 0.68),
],
"political": [
("WhatsApp screenshot appears from a campaign group", 0.66),
("Someone saw a late-night meeting at the chief's office", 0.62),
],
"competence": [
("School records clarify the timeline", 0.82),
("A teacher's note contradicts the rumor", 0.7),
],
"kindness": [
("Clinic notes reveal private family pressure", 0.72),
("A neighbor explains who was actually helped", 0.65),
],
"relationship": [
("A witness places the target somewhere else", 0.68),
("A private message changes the context", 0.6),
],
"leadership": [
("Meeting minutes show who made the decision", 0.8),
("An elder remembers the earlier agreement", 0.7),
],
}
def active_rumor(state: VillageState) -> Rumor | None:
if not state.rumors:
return None
return list(state.rumors.values())[-1]
def choose_channel(state: VillageState, speaker: Villager) -> Channel:
best = sorted(
state.channels.values(),
key=lambda channel: (
len(set(channel.typical_groups) & set(speaker.group_ids)),
channel.reach,
),
reverse=True,
)
return best[0]
def choose_listener(state: VillageState, speaker: Villager, rumor: Rumor, day_offset: int) -> Villager:
candidates = [v for v in state.villagers.values() if v.id != speaker.id]
ranked = sorted(
candidates,
key=lambda v: (
len(set(v.group_ids) & set(speaker.group_ids)) * 30
+ speaker.relationships.get(v.id, 0)
+ (10 if v.id == rumor.target_id else 0)
+ ((state.day + day_offset + len(v.id)) % 7)
),
reverse=True,
)
return ranked[0]
def belief_delta(speaker: Villager, listener: Villager, rumor: Rumor, channel: Channel, action: str) -> float:
rel = relationship(speaker, listener.id)
delta = (
speaker.trust * 0.003
+ rel * 0.002
+ rumor.intensity * 0.001
+ channel.credibility_modifier * 0.1
)
if action == "distort":
delta *= 0.7
elif action == "verify":
delta *= 1 + speaker.accuracy
elif action == "accuse":
delta *= 1.2
elif action == "defend":
delta *= -0.9
elif action == "mediate":
current = listener.private_beliefs.get(rumor.id, 0.5)
delta = (0.5 - current) * 0.35
elif action == "investigate":
delta *= 0.4 if rumor.truth_status in {"false", "unknown"} else 1.1
elif action == "deny":
delta *= -0.6 if speaker.trust > 50 else 0.25
elif action == "stay_silent":
delta = 0
return round(delta, 3)
def update_public_position(
listener: Villager,
rumor: Rumor,
belief: float,
action: str,
state: VillageState,
) -> None:
target_influence = state.villagers[rumor.target_id].influence if rumor.target_id else 0
motive = listener.motive.lower()
if "power" in motive and target_influence > 65 and belief > 0.38:
listener.public_positions[rumor.id] = "accusing_target"
elif "peace" in motive and belief < 0.7:
listener.public_positions[rumor.id] = "defending_target"
elif action == "defend":
listener.public_positions[rumor.id] = "defending_target"
elif action == "accuse":
listener.public_positions[rumor.id] = "accusing_target"
elif action in {"verify", "investigate"}:
listener.public_positions[rumor.id] = "calling_for_investigation"
elif belief > 0.66:
listener.public_positions[rumor.id] = "believes"
elif belief < 0.34:
listener.public_positions[rumor.id] = "doubts"
else:
listener.public_positions[rumor.id] = "neutral"
def apply_reputation_change(
state: VillageState,
rumor: Rumor,
speaker: Villager,
listener_belief: float,
channel: Channel,
action: str,
) -> dict[str, float]:
if not rumor.target_id:
return {}
target = state.villagers[rumor.target_id]
reach_factor = max(0.25, channel.reach / 20)
base_damage = 8
damage = (
rumor.intensity / 100
* rumor.severity / 100
* speaker.influence / 100
* listener_belief
* reach_factor
* base_damage
)
if action == "distort":
damage *= 1.35
elif action == "accuse":
damage *= 1.25
elif action in {"defend", "mediate", "apologize"}:
damage *= -0.45
elif action in {"verify", "investigate"} and rumor.truth_status == "false":
damage *= -0.2
changes = {}
for field, weight in REPUTATION_WEIGHTS.get(rumor.category, REPUTATION_WEIGHTS["moral"]).items():
delta = round(-damage * weight, 2)
current = getattr(target.reputation, field)
setattr(target.reputation, field, clamp(current + delta))
changes[field] = delta
return changes
def apply_economy_changes(speaker: Villager, listener: Villager, rumor: Rumor, action: str) -> dict:
before = {"trust": speaker.trust, "influence": speaker.influence, "attention": speaker.attention}
if action in {"spread", "distort", "accuse"}:
speaker.attention = clamp(speaker.attention + (9 if action == "distort" else 6))
speaker.influence = clamp(speaker.influence + 2)
speaker.trust = clamp(speaker.trust - (5 if action == "distort" else 2))
rumor.intensity = clamp(rumor.intensity + (10 if action == "distort" else 6))
rumor.spread_count += 1
elif action in {"verify", "investigate"}:
speaker.trust = clamp(speaker.trust + 4)
speaker.influence = clamp(speaker.influence + 2)
rumor.intensity = clamp(rumor.intensity - 3)
elif action in {"defend", "mediate", "apologize"}:
speaker.trust = clamp(speaker.trust + 3)
rumor.intensity = clamp(rumor.intensity - 5)
elif action == "deny":
speaker.attention = clamp(speaker.attention + 4)
rumor.intensity = clamp(rumor.intensity + (3 if speaker.trust < 55 else -4))
speaker.relationships[listener.id] = clamp(speaker.relationships.get(listener.id, 0) + (3 if action in {"mediate", "defend", "verify"} else -2), -100, 100)
after = {"trust": speaker.trust, "influence": speaker.influence, "attention": speaker.attention}
return {
"speaker_before": before,
"speaker_after": after,
"trust_delta": round(after["trust"] - before["trust"], 2),
"influence_delta": round(after["influence"] - before["influence"], 2),
"attention_delta": round(after["attention"] - before["attention"], 2),
}
def record_strategy_memory(
villager: Villager,
day: int,
rumor: Rumor,
action: str,
economy_changes: dict,
reputation_delta: dict[str, float],
) -> Memory:
trust_delta = economy_changes.get("trust_delta", 0)
influence_delta = economy_changes.get("influence_delta", 0)
attention_delta = economy_changes.get("attention_delta", 0)
target_damage = -sum(value for value in reputation_delta.values() if value < 0)
target_repair = sum(value for value in reputation_delta.values() if value > 0)
payoff = round(attention_delta * 0.35 + influence_delta * 0.6 + trust_delta * 0.8 + target_damage * 0.18 - target_repair * 0.1, 2)
if action in {"distort", "accuse", "spread"} and trust_delta < 0:
payoff -= abs(trust_delta) * 0.45
if action in {"verify", "investigate", "mediate", "defend"} and trust_delta > 0:
payoff += trust_delta * 0.3
payoff = round(payoff, 2)
bias_before = villager.strategy_biases.get(action, 0)
villager.strategy_biases[action] = round(clamp(bias_before + payoff * 0.22, -12, 18), 2)
if payoff >= 4:
verdict = "worked"
elif payoff <= -2:
verdict = "backfired"
else:
verdict = "had mixed results"
summary = (
f"Day {day}: {action.replace('_', ' ')} {verdict}; "
f"trust {trust_delta:+.1f}, influence {influence_delta:+.1f}, attention {attention_delta:+.1f}."
)
memory = Memory(
day=day,
rumor_id=rumor.id,
action=action, # type: ignore[arg-type]
summary=summary,
trust_delta=trust_delta,
influence_delta=influence_delta,
attention_delta=attention_delta,
payoff=payoff,
)
villager.memories.append(memory)
villager.memories = villager.memories[-6:]
return memory
def maybe_trigger_events(state: VillageState, rumor: Rumor) -> None:
if not rumor.target_id:
return
target = state.villagers[rumor.target_id]
existing = {event.type for event in state.events}
if target.reputation.honesty < 30 and "public_confrontation" not in existing:
state.events.append(Event(day=state.day, type="public_confrontation", title="Public Confrontation", description=f"{target.name} is confronted in public.", participants=[target.id], rumor_id=rumor.id))
if target.reputation.leadership < 25 and "resignation_pressure" not in existing:
state.events.append(Event(day=state.day, type="resignation_pressure", title="Resignation Pressure", description=f"Villagers begin asking whether {target.name} should step aside.", participants=[target.id], rumor_id=rumor.id))
if rumor.intensity > 85 and "village_panic" not in existing:
state.events.append(Event(day=state.day, type="village_panic", title="Village Panic", description="The rumor outruns facts and everyone starts picking sides.", participants=[], rumor_id=rumor.id))
if rumor.public_belief > 0.75 and not public_evidence_for(state, rumor.id) and "mob_judgment" not in existing:
state.events.append(Event(day=state.day, type="mob_judgment", title="Mob Judgment", description="Public belief hardens before strong evidence appears.", participants=[target.id], rumor_id=rumor.id))
if contradictory_public_evidence(state, rumor) and total_reputation_damage(target) > 20 and "guilt_and_apologies" not in existing:
state.events.append(Event(day=state.day, type="guilt_and_apologies", title="Guilt And Apologies", description=f"Evidence undercuts the rumor after {target.name}'s reputation has already been damaged.", participants=[target.id], rumor_id=rumor.id))
mutai = state.villagers.get("mutai")
if mutai and mutai.influence > 80 and "political_takeover" not in existing:
state.events.append(Event(day=state.day, type="political_takeover", title="Political Takeover", description="Mutai turns the scandal into a power platform.", participants=["mutai"], rumor_id=rumor.id))
def public_evidence_for(state: VillageState, rumor_id: str) -> list[Evidence]:
return [item for item in state.evidence.values() if item.rumor_id == rumor_id and item.public]
def strongest_evidence(state: VillageState, rumor_id: str) -> Evidence | None:
items = public_evidence_for(state, rumor_id)
if not items:
return None
return max(items, key=lambda item: item.reliability)
def contradictory_public_evidence(state: VillageState, rumor: Rumor) -> bool:
if rumor.truth_status == "true":
return False
return any(not item.supports_rumor and item.reliability >= 0.65 for item in public_evidence_for(state, rumor.id))
def total_reputation_damage(villager: Villager) -> float:
return sum(max(0, 70 - value) for value in villager.reputation.model_dump().values())
def evidence_supports_rumor(rumor: Rumor) -> bool:
if rumor.truth_status == "true":
return True
if rumor.truth_status == "false":
return False
if rumor.truth_status == "partly_true":
return rumor.spread_count % 2 == 0
return rumor.intensity > 70
def discover_evidence(state: VillageState, rumor: Rumor, discoverer: Villager, action: str) -> Evidence | None:
if action != "investigate" and not (state.day in {3, 5} and rumor.intensity > 60):
return None
if any(item.rumor_id == rumor.id and item.discovered_by == discoverer.id for item in state.evidence.values()):
return None
templates = EVIDENCE_TEMPLATES.get(rumor.category, EVIDENCE_TEMPLATES["moral"])
description, reliability = templates[(len(state.evidence) + state.day) % len(templates)]
evidence = Evidence(
id=f"evidence_{len(state.evidence) + 1}",
rumor_id=rumor.id,
description=description,
reliability=reliability,
supports_rumor=evidence_supports_rumor(rumor),
discovered_by=discoverer.id,
public=action == "investigate" or reliability >= 0.8 or rumor.intensity > 78,
)
state.evidence[evidence.id] = evidence
discoverer.known_evidence.append(evidence.id)
state.trace.append(
{
"day": state.day,
"villager": discoverer.name,
"rumor_id": rumor.id,
"chosen_action": "evidence_discovered",
"evidence": evidence.model_dump(),
"reason": f"{discoverer.name} uncovered evidence while the rumor market was moving.",
}
)
if evidence.public:
apply_public_evidence(state, rumor, evidence)
return evidence
def apply_public_evidence(state: VillageState, rumor: Rumor, evidence: Evidence) -> None:
direction = 1 if evidence.supports_rumor else -1
strength = evidence.reliability * 0.24
for villager in state.villagers.values():
before = villager.private_beliefs.get(rumor.id, 0.5)
after = clamp(before + direction * strength * villager.accuracy, 0, 1)
villager.private_beliefs[rumor.id] = after
update_public_position(villager, rumor, after, "investigate", state)
predicted_correctly = (before >= 0.5) == evidence.supports_rumor
if predicted_correctly:
villager.trust = clamp(villager.trust + evidence.reliability * 3)
else:
villager.trust = clamp(villager.trust - evidence.reliability * 4)
rumor.public_belief = average_public_belief(state, rumor.id)
rumor.intensity = clamp(rumor.intensity + (8 if evidence.supports_rumor else -10))
apply_evidence_reputation_effect(state, rumor, evidence)
def apply_evidence_reputation_effect(state: VillageState, rumor: Rumor, evidence: Evidence) -> None:
if not rumor.target_id:
return
target = state.villagers[rumor.target_id]
amount = evidence.reliability * rumor.severity / 100 * 10
if evidence.supports_rumor:
amount *= -1
for field, weight in REPUTATION_WEIGHTS.get(rumor.category, REPUTATION_WEIGHTS["moral"]).items():
current = getattr(target.reputation, field)
setattr(target.reputation, field, clamp(current + amount * weight))
def make_trace(
state: VillageState,
bid: Bid,
speaker: Villager,
listener: Villager,
channel: Channel,
belief_before: float,
belief_after: float,
reputation_delta: dict[str, float],
memory: Memory | None,
) -> dict:
return {
"day": state.day,
"villager": speaker.name,
"rumor_id": bid.rumor_id,
"action_scores": bid.action_scores,
"chosen_action": bid.action,
"bid_score": bid.bid_score,
"expected_gain": bid.expected_gain,
"expected_risk": bid.expected_risk,
"listener": listener.name,
"channel": channel.name,
"belief_before": round(belief_before, 3),
"belief_after": round(belief_after, 3),
"listener_public_position": listener.public_positions.get(bid.rumor_id, "neutral"),
"public_evidence_count": len(public_evidence_for(state, bid.rumor_id)),
"reputation_damage": reputation_delta,
"economy_changes": {
"trust_delta": memory.trust_delta if memory else 0,
"influence_delta": memory.influence_delta if memory else 0,
"attention_delta": memory.attention_delta if memory else 0,
"payoff": memory.payoff if memory else 0,
},
"strategy_biases": speaker.strategy_biases,
"memory": memory.model_dump() if memory else None,
"reason": bid.reason,
}
def simulate_day(state: VillageState, llm: GossipLLM | None = None) -> VillageState:
rumor = active_rumor(state)
if rumor is None:
return state
llm = llm or GossipLLM(mock_llm=True)
state.latest_conversations = []
bids = collect_bids(state, rumor)
winners = select_winning_bids(bids, k=3)
for index, bid in enumerate(winners):
speaker = state.villagers[bid.villager_id]
listener = choose_listener(state, speaker, rumor, index)
channel = choose_channel(state, speaker)
old_version = rumor.current_version
before = listener.private_beliefs.get(rumor.id, 0.5)
delta = belief_delta(speaker, listener, rumor, channel, bid.action)
after = clamp(before + delta, 0, 1)
listener.private_beliefs[rumor.id] = after
update_public_position(listener, rumor, after, bid.action, state)
target_name = state.villagers[rumor.target_id].name if rumor.target_id else "None"
dialogue = llm.dialogue(
state.day,
state.tone,
speaker,
listener,
rumor,
bid.action,
channel,
relationship(speaker, listener.id),
target_name,
)
new_version = llm.mutate_rumor(rumor, bid.action, channel, state.tone, speaker, dialogue)
rumor.current_version = new_version
reputation_delta = apply_reputation_change(state, rumor, speaker, after, channel, bid.action)
economy_changes = apply_economy_changes(speaker, listener, rumor, bid.action)
memory = record_strategy_memory(speaker, state.day, rumor, bid.action, economy_changes, reputation_delta)
rumor.public_belief = average_public_belief(state, rumor.id)
result = ConversationResult(
day=state.day,
speaker_id=speaker.id,
listener_id=listener.id,
rumor_id=rumor.id,
action=bid.action,
channel_id=channel.id,
dialogue=dialogue,
old_rumor_version=old_version,
new_rumor_version=new_version,
belief_delta=delta,
target_reputation_delta=reputation_delta,
economy_changes=economy_changes,
event_summary=f"{speaker.name} used {bid.action.replace('_', ' ')} on {listener.name} at the {channel.name}.",
)
state.latest_conversations.append(result)
state.trace.append(make_trace(state, bid, speaker, listener, channel, before, after, reputation_delta, memory))
discover_evidence(state, rumor, speaker, bid.action)
if not any(item.rumor_id == rumor.id and item.public for item in state.evidence.values()):
top_investigator = max(state.villagers.values(), key=lambda v: v.accuracy + v.trust / 100)
discover_evidence(state, rumor, top_investigator, "background")
maybe_trigger_events(state, rumor)
state.daily_summaries.append(llm.daily_summary(state.day, state, rumor))
if state.day >= 7:
state.baraza_output = generate_baraza(state, rumor)
state.day += 1
return state
def average_public_belief(state: VillageState, rumor_id: str) -> float:
beliefs = [v.private_beliefs.get(rumor_id, 0.5) for v in state.villagers.values()]
return round(sum(beliefs) / len(beliefs), 3)
def generate_baraza(state: VillageState, rumor: Rumor) -> str:
action_counts = defaultdict(int)
for row in state.trace:
action_counts[(row["chosen_action"], row["villager"])] += 1
accuser = top_actor(state, "accuse")
defender = top_actor(state, "defend") or top_actor(state, "mediate")
target = state.villagers.get(rumor.target_id) if rumor.target_id else None
target_name = target.name if target else "the target"
evidence = strongest_evidence(state, rumor.id)
evidence_line = evidence.description if evidence else "No strong evidence reached the public."
influential = max(state.villagers.values(), key=lambda villager: villager.influence)
damaged_category = "reputation"
outcome = "truth remains unresolved"
if target:
lowest = min(target.reputation.model_dump().items(), key=lambda item: item[1])
damaged_category = lowest[0]
if lowest[1] < 35:
outcome = f"{target_name} is cleared by nobody and wounded by everybody"
elif rumor.public_belief < 0.4:
outcome = f"{target_name} survives the storm, but the village remembers the smoke"
if evidence and not evidence.supports_rumor and rumor.public_belief < 0.5:
outcome = f"{target_name} is partly cleared, but the apology arrives limping"
elif evidence and evidence.supports_rumor and evidence.reliability > 0.75:
outcome = f"{target_name} faces formal pressure because the evidence has teeth"
if top_actor(state, "distort") == "Mutai":
outcome = "the rumor mutates into political capital"
return "\n".join(
[
"Chief Kiprono: We are here because this story has eaten more airtime than school fees.",
f"{accuser or 'Mama Chebet'}: The village deserves answers about {rumor.current_version}",
f"{defender or 'Pastor Langat'}: Answers, yes. Character assassination, no.",
f"{target_name}: I have heard versions of myself that even I have not met.",
f"Cherono: Evidence on the table: {evidence_line}",
"Mercy: Let records speak louder than volume.",
f"Chief Kiprono: The worst wound is {damaged_category}; the loudest voice is now {influential.name}.",
"Kibet: Chief, even the jokes are now tired.",
"Arap Rono: A rumor is a spear. Once thrown, it does not return clean.",
f"Final outcome: {outcome}. Hidden reality: {rumor.hidden_reality}",
]
)
def top_actor(state: VillageState, action: str) -> str | None:
counts = defaultdict(int)
for row in state.trace:
if row["chosen_action"] == action:
counts[row["villager"]] += 1
if not counts:
return None
return max(counts.items(), key=lambda item: item[1])[0]