| from __future__ import annotations |
|
|
| from pathlib import Path |
|
|
| import matplotlib |
|
|
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
| import networkx as nx |
|
|
| from models import VillageState |
| from simulation import active_rumor |
|
|
| GRAPH_PATH = Path("network_graph.png") |
|
|
|
|
| def target_choices(state: VillageState) -> list[tuple[str, str]]: |
| return [(villager.name, villager.id) for villager in state.villagers.values()] |
|
|
|
|
| def current_rumor_text(state: VillageState) -> str: |
| rumor = active_rumor(state) |
| return rumor.current_version if rumor else "No rumor started yet." |
|
|
|
|
| def current_day_text(state: VillageState) -> str: |
| return f"Day {min(state.day, 8)}" |
|
|
|
|
| def conversations_text(state: VillageState) -> str: |
| if not state.latest_conversations: |
| return "No conversations yet." |
| parts = [] |
| for item in state.latest_conversations: |
| speaker = state.villagers[item.speaker_id].name |
| listener = state.villagers[item.listener_id].name |
| channel = state.channels[item.channel_id].name |
| parts.append(f"### {speaker} -> {listener} ({item.action}, {channel})\n{item.dialogue}") |
| return "\n\n".join(parts) |
|
|
|
|
| def daily_summary_text(state: VillageState) -> str: |
| return state.daily_summaries[-1] if state.daily_summaries else "Start a rumor, then simulate a day." |
|
|
|
|
| def timeline_text(state: VillageState) -> str: |
| lines = [] |
| lines.extend(state.daily_summaries) |
| for event in state.events: |
| lines.append(f"Day {event.day}: {event.title} - {event.description}") |
| return "\n\n".join(lines) if lines else "Timeline is waiting for the first spark." |
|
|
|
|
| def metrics_markdown(state: VillageState) -> str: |
| rumor = active_rumor(state) |
| if not rumor: |
| return "No village metrics yet." |
| village_trust = sum(v.trust for v in state.villagers.values()) / len(state.villagers) |
| chaos = min(100, rumor.intensity * 0.7 + (1 - village_trust / 100) * 30) |
| total_damage = 0 |
| if rumor.target_id: |
| rep = state.villagers[rumor.target_id].reputation |
| total_damage = sum(70 - value for value in rep.model_dump().values()) |
| return ( |
| f"**Rumor intensity:** {rumor.intensity:.1f}\n\n" |
| f"**Public belief:** {rumor.public_belief:.2f}\n\n" |
| f"**Village trust:** {village_trust:.1f}\n\n" |
| f"**Chaos score:** {chaos:.1f}\n\n" |
| f"**Total reputation damage:** {total_damage:.1f}" |
| ) |
|
|
|
|
| def evidence_markdown(state: VillageState) -> str: |
| rumor = active_rumor(state) |
| if not rumor: |
| return "No evidence yet." |
| items = [item for item in state.evidence.values() if item.rumor_id == rumor.id] |
| if not items: |
| return "No evidence has surfaced yet." |
| lines = [] |
| for item in sorted(items, key=lambda evidence: evidence.reliability, reverse=True): |
| discoverer = state.villagers[item.discovered_by].name if item.discovered_by in state.villagers else "Unknown" |
| stance = "supports" if item.supports_rumor else "contradicts" |
| visibility = "public" if item.public else "private" |
| lines.append( |
| f"**{item.description}**\n\n" |
| f"{stance.title()} rumor | reliability {item.reliability:.2f} | {visibility} | found by {discoverer}" |
| ) |
| return "\n\n".join(lines) |
|
|
|
|
| def belief_positions_markdown(state: VillageState) -> str: |
| rumor = active_rumor(state) |
| if not rumor: |
| return "No belief positions yet." |
| rows = [] |
| for villager in sorted(state.villagers.values(), key=lambda item: item.private_beliefs.get(rumor.id, 0), reverse=True): |
| belief = villager.private_beliefs.get(rumor.id, 0.5) |
| public = villager.public_positions.get(rumor.id, "neutral").replace("_", " ") |
| rows.append(f"**{villager.name}:** private {belief:.2f} | public {public}") |
| return "\n\n".join(rows) |
|
|
|
|
| def leaderboards_markdown(state: VillageState) -> str: |
| villagers = list(state.villagers.values()) |
|
|
| def top(label: str, key): |
| villager = max(villagers, key=key) |
| return f"**{label}:** {villager.name} ({key(villager):.1f})" |
|
|
| biggest_gossip = max(villagers, key=lambda v: v.attention + (100 - v.trust) * 0.2) |
| most_damaged = min(villagers, key=lambda v: sum(v.reputation.model_dump().values()) / 5) |
| lines = [ |
| top("Most trusted", lambda v: v.trust), |
| top("Most influential", lambda v: v.influence), |
| top("Most attention", lambda v: v.attention), |
| f"**Most damaged reputation:** {most_damaged.name} ({sum(most_damaged.reputation.model_dump().values()) / 5:.1f})", |
| f"**Biggest gossip:** {biggest_gossip.name} ({biggest_gossip.attention:.1f} attention)", |
| top("Most accurate", lambda v: v.accuracy * 100), |
| ] |
| return "\n\n".join(lines) |
|
|
|
|
| def memories_markdown(state: VillageState) -> str: |
| villagers = sorted( |
| state.villagers.values(), |
| key=lambda villager: max([abs(value) for value in villager.strategy_biases.values()] or [0]), |
| reverse=True, |
| ) |
| lines = [] |
| for villager in villagers: |
| if not villager.memories and not villager.strategy_biases: |
| continue |
| biases = sorted(villager.strategy_biases.items(), key=lambda item: abs(item[1]), reverse=True)[:3] |
| bias_text = ", ".join(f"{action}: {value:+.1f}" for action, value in biases) if biases else "no bias yet" |
| memory_text = " / ".join(memory.summary for memory in villager.memories[-2:]) if villager.memories else "No memories yet." |
| lines.append(f"**{villager.name}**\n\nBiases: {bias_text}\n\n{memory_text}") |
| return "\n\n".join(lines) if lines else "No strategy memories yet." |
|
|
|
|
| def judge_mode_markdown(state: VillageState) -> str: |
| rows = [item for item in state.trace if "action_scores" in item] |
| if not rows: |
| return ( |
| "**Judge Mode**\n\n" |
| "Start a rumor and simulate a day to see the decision market. " |
| "Python will choose actions through scores; the LLM only narrates dialogue, rumor mutations, and summaries." |
| ) |
| days = sorted({row["day"] for row in rows}) |
| visible_days = days |
| sections = [ |
| "**Judge Mode: Decision Market**", |
| "Python controls bids, incentives, state, belief, evidence, memory, and reputation. The LLM only adds language after winners are chosen.", |
| ] |
| for day in visible_days: |
| day_rows = [row for row in rows if row["day"] == day] |
| sections.append(f"### Day {day}") |
| for rank, row in enumerate(day_rows, start=1): |
| scores = row.get("action_scores", {}) |
| top_scores = sorted(scores.items(), key=lambda item: item[1], reverse=True)[:4] |
| score_text = ", ".join(f"{action} {score:.1f}" for action, score in top_scores) |
| memory = row.get("memory") or {} |
| economy = row.get("economy_changes") or {} |
| biases = row.get("strategy_biases") or {} |
| action = row.get("chosen_action", "") |
| bias = biases.get(action, 0) |
| reputation_delta = row.get("reputation_damage") or {} |
| rep_text = ", ".join(f"{key} {value:+.2f}" for key, value in reputation_delta.items()) or "none" |
| sections.append( |
| f"**{rank}. {row['villager']} won `{action}`** " |
| f"with score **{row['bid_score']:.1f}** against listener **{row['listener']}** via **{row['channel']}**.\n\n" |
| f"Top scores: {score_text}\n\n" |
| f"Learned bias on chosen action: {bias:+.1f}\n\n" |
| f"Economy delta: trust {economy.get('trust_delta', 0):+.1f}, " |
| f"influence {economy.get('influence_delta', 0):+.1f}, " |
| f"attention {economy.get('attention_delta', 0):+.1f}, " |
| f"payoff {economy.get('payoff', 0):+.1f}\n\n" |
| f"Belief: {row['belief_before']:.2f} -> {row['belief_after']:.2f}; reputation delta: {rep_text}\n\n" |
| f"Memory formed: {memory.get('summary', 'none')}\n\n" |
| f"Reason: {row['reason']}" |
| ) |
| return "\n\n".join(sections) |
|
|
|
|
| def target_reputation_markdown(state: VillageState) -> str: |
| rumor = active_rumor(state) |
| if not rumor or not rumor.target_id: |
| return "No target selected yet." |
| target = state.villagers[rumor.target_id] |
| values = target.reputation.model_dump() |
| return "\n".join([f"**{name.title()}:** {score:.1f}" for name, score in values.items()]) |
|
|
|
|
| def explainability_markdown(state: VillageState) -> str: |
| if not state.trace: |
| return "No reputation changes to explain yet." |
| rows = [item for item in state.trace if "reputation_damage" in item] |
| if not rows: |
| return "No reputation changes to explain yet." |
| row = rows[-1] |
| rumor = active_rumor(state) |
| channel = row["channel"] |
| target = state.villagers[rumor.target_id].name if rumor and rumor.target_id else "target" |
| deltas = ", ".join(f"{k}: {v}" for k, v in row["reputation_damage"].items()) or "none" |
| return ( |
| f"**Why did {target}'s reputation change?**\n\n" |
| f"Rumor severity: {rumor.severity if rumor else 0:.1f}\n\n" |
| f"Chosen action: {row['chosen_action']}\n\n" |
| f"Speaker: {row['villager']}\n\n" |
| f"Listener belief: {row['belief_before']} -> {row['belief_after']}\n\n" |
| f"Channel: {channel}\n\n" |
| f"Reputation delta: {deltas}" |
| ) |
|
|
|
|
| def network_graph_path(state: VillageState) -> str | None: |
| villagers = list(state.villagers.values()) |
| if not villagers: |
| return None |
| rumor = active_rumor(state) |
| graph = nx.Graph() |
| for villager in villagers: |
| belief = villager.private_beliefs.get(rumor.id, 0.5) if rumor else 0.5 |
| reputation = sum(villager.reputation.model_dump().values()) / 5 |
| graph.add_node( |
| villager.id, |
| label=villager.name.replace(" ", "\n"), |
| influence=villager.influence, |
| belief=belief, |
| reputation=reputation, |
| target=bool(rumor and rumor.target_id == villager.id), |
| ) |
| for villager in villagers: |
| for other_id, score in villager.relationships.items(): |
| if other_id in graph and abs(score) >= 10: |
| graph.add_edge(villager.id, other_id, weight=abs(score), score=score) |
|
|
| if graph.number_of_edges() == 0: |
| ids = [villager.id for villager in villagers] |
| graph.add_edges_from(zip(ids, ids[1:])) |
|
|
| fig, ax = plt.subplots(figsize=(9, 6), dpi=140) |
| fig.patch.set_facecolor("#10151f") |
| ax.set_facecolor("#10151f") |
| pos = nx.spring_layout(graph, seed=7, k=0.7) |
| node_sizes = [420 + graph.nodes[node]["influence"] * 8 for node in graph.nodes] |
| node_colors = [] |
| for node in graph.nodes: |
| data = graph.nodes[node] |
| if data["target"]: |
| node_colors.append("#f97316") |
| elif data["belief"] >= 0.65: |
| node_colors.append("#ef4444") |
| elif data["belief"] <= 0.35: |
| node_colors.append("#22c55e") |
| else: |
| node_colors.append("#38bdf8") |
| edge_colors = ["#ef4444" if graph.edges[edge]["score"] < 0 else "#94a3b8" for edge in graph.edges] |
| edge_widths = [1 + graph.edges[edge]["weight"] / 25 for edge in graph.edges] |
|
|
| nx.draw_networkx_edges(graph, pos, edge_color=edge_colors, width=edge_widths, alpha=0.45, ax=ax) |
| nx.draw_networkx_nodes(graph, pos, node_size=node_sizes, node_color=node_colors, linewidths=1.5, edgecolors="#f8fafc", ax=ax) |
| nx.draw_networkx_labels( |
| graph, |
| pos, |
| labels={node: graph.nodes[node]["label"] for node in graph.nodes}, |
| font_size=7, |
| font_color="#f8fafc", |
| ax=ax, |
| ) |
| ax.set_title("Trust, Belief, and Influence Network", color="#f8fafc", fontsize=13, pad=14) |
| ax.text( |
| 0.01, |
| 0.01, |
| "Orange target | Red believers | Green skeptics | Blue undecided", |
| transform=ax.transAxes, |
| color="#cbd5e1", |
| fontsize=8, |
| ) |
| ax.axis("off") |
| fig.tight_layout() |
| fig.savefig(GRAPH_PATH, bbox_inches="tight", facecolor=fig.get_facecolor()) |
| plt.close(fig) |
| return str(GRAPH_PATH.resolve()) |
|
|
|
|
| def ui_snapshot(state: VillageState) -> tuple: |
| return ( |
| current_day_text(state), |
| current_rumor_text(state), |
| conversations_text(state), |
| daily_summary_text(state), |
| timeline_text(state), |
| state.baraza_output or "The baraza will be called after Day 7.", |
| metrics_markdown(state), |
| evidence_markdown(state), |
| belief_positions_markdown(state), |
| leaderboards_markdown(state), |
| memories_markdown(state), |
| judge_mode_markdown(state), |
| target_reputation_markdown(state), |
| explainability_markdown(state), |
| network_graph_path(state), |
| ) |
|
|