Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion examples/emperor_dilemma/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,30 @@ pip install mesa matplotlib solara

```bash
solara run app.py
```
```

## Extensions

This model has been extended with the following additions:

### Whistleblower Agents
A new `WhistleblowerAgent` subclass that is immune to social pressure
and always acts on private belief publicly. Use the **Fraction Whistleblowers**
slider to introduce them into the simulation.

### Belief Gap Metric
Tracks the average distance between private belief and public behavior
across all agents at each step. A value near 1.0 means the society is
wearing a collective mask. A value near 0.0 means everyone is acting
honestly.

### Norm Collapse Detection
The model records exactly which step compliance first drops below 50%,
turning a visual observation into a measurable data point accessible
via `model.collapse_step`.

### Key Finding
There is a sharp tipping point around 5-10% whistleblowers. Below this
threshold the norm holds completely. Above it the norm collapses rapidly.
This threshold behavior emerges from the local interaction rules rather
than being explicitly programmed.
119 changes: 76 additions & 43 deletions examples/emperor_dilemma/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,63 +2,96 @@


class EmperorAgent(FixedAgent):
"""An agent in the Emperor's Dilemma model.
"""A citizen in the Emperor's Dilemma model.

Inherits from FixedAgent because citizens do not move.
Inherits from FixedAgent because citizens do not move — they're embedded
in a social structure (grid or network) and only influence their neighbors.

Each agent has a private belief they keep to themselves and a public
behavior they show the world. These two things can be — and often are —
completely different. That gap is the whole point of the model.
"""

def __init__(self, model, private_belief, conviction, k):
"""Initializes the EmperorAgent.

Args:
model (mesa.Model): The model instance.
private_belief (int): The agent's private belief (-1 or 1).
conviction (float): The strength of the agent's conviction.
k (float): The cost of enforcement.
"""
super().__init__(model)
self.private_belief = private_belief
self.conviction = conviction
self.k = k
self.agent_type = "citizen"

self.compliance = self.private_belief
self.enforcement = 0

def step(self):
"""Executes one step of the agent.
neighbors = self._get_neighbors()
num_neighbors = len(neighbors)

if num_neighbors == 0:
return

sum_enforcement = sum(n.enforcement for n in neighbors)
pressure = (-self.private_belief / num_neighbors) * sum_enforcement

if pressure > self.conviction:
self.compliance = -self.private_belief
else:
self.compliance = self.private_belief

deviant_neighbors = sum(
1 for n in neighbors if n.compliance != self.private_belief
)
w_i = deviant_neighbors / num_neighbors

Calculates social pressure from neighbors and updates compliance and
enforcement states based on conviction and costs.
"""
# 1. Observe Neighbors
neighbors = []
if (self.compliance != self.private_belief) and (
pressure > (self.conviction + self.k)
):
self.enforcement = -self.private_belief
elif (self.compliance == self.private_belief) and (
(self.conviction * w_i) > self.k
):
self.enforcement = self.private_belief
else:
self.enforcement = 0

def _get_neighbors(self):
if self.cell is not None:
neighbors = list(self.cell.neighborhood.agents)
return list(self.cell.neighborhood.agents)
return []

@property
def belief_gap(self):
return 0 if self.compliance == self.private_belief else 1


class WhistleblowerAgent(EmperorAgent):
"""A citizen who has decided they're done pretending.

Always acts on private belief publicly regardless of social pressure.
The key question: how many whistleblowers trigger a cascade collapse?
"""

def __init__(self, model, private_belief, conviction, k):
super().__init__(model, private_belief, conviction, k)
self.agent_type = "whistleblower"
self.compliance = self.private_belief
self.enforcement = 0

def step(self):
neighbors = self._get_neighbors()
num_neighbors = len(neighbors)
if num_neighbors > 0:
# 2. Calculate Social Pressure (Eq 1)
sum_enforcement = sum(n.enforcement for n in neighbors)
pressure = (-self.private_belief / num_neighbors) * sum_enforcement

if pressure > self.conviction:
self.compliance = -self.private_belief
else:
self.compliance = self.private_belief

# 3. Enforcement Decision (Eq 2 & 3)
deviant_neighbors = sum(
1 for n in neighbors if n.compliance != self.private_belief
)
w_i = deviant_neighbors / num_neighbors

if (self.compliance != self.private_belief) and (
pressure > (self.conviction + self.k)
):
self.enforcement = -self.private_belief
elif (self.compliance == self.private_belief) and (
(self.conviction * w_i) > self.k
):
self.enforcement = self.private_belief
else:
self.enforcement = 0

# always act on private belief — pressure doesn't change this
self.compliance = self.private_belief

if num_neighbors == 0:
return

deviant_neighbors = sum(
1 for n in neighbors if n.compliance != self.private_belief
)
w_i = deviant_neighbors / num_neighbors

if (self.conviction * w_i) > self.k:
self.enforcement = self.private_belief
else:
self.enforcement = 0
38 changes: 28 additions & 10 deletions examples/emperor_dilemma/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
make_plot_component,
)
from mesa.visualization.components.portrayal_components import AgentPortrayalStyle
from model import EmperorModel

from .model import EmperorModel

# Colors matching Figure 2
COLOR_COMPLY_QUIET = "#F0F8FF" # AliceBlue
COLOR_DEVIATE_QUIET = "lightgray" # Light Gray
COLOR_COMPLY_ENFORCE = "dimgray" # Dark Gray
COLOR_DEVIATE_ENFORCE = "black" # Black
# --- Colors ---
COLOR_COMPLY_QUIET = "#F0F8FF"
COLOR_DEVIATE_QUIET = "lightgray"
COLOR_COMPLY_ENFORCE = "dimgray"
COLOR_DEVIATE_ENFORCE = "black"
COLOR_WHISTLEBLOWER = "#E63946"


def emperor_portrayal(agent):
Expand All @@ -26,6 +26,10 @@ def emperor_portrayal(agent):
zorder=1,
)

if agent.agent_type == "whistleblower":
portrayal.update(("color", COLOR_WHISTLEBLOWER))
return portrayal

if agent.compliance == 1:
if agent.enforcement == 1:
portrayal.update(("color", COLOR_COMPLY_ENFORCE))
Expand All @@ -45,6 +49,12 @@ def post_process_lines(ax):
ax.set_ylabel("Rate")


def post_process_gap(ax):
ax.legend(loc="center left", bbox_to_anchor=(1, 0.9))
ax.set_ylabel("Belief Gap (0=honest, 1=mask)")
ax.set_ylim(0, 1)


lineplot_component = make_plot_component(
{
"Compliance": "tab:green",
Expand All @@ -54,9 +64,16 @@ def post_process_lines(ax):
post_process=post_process_lines,
)

belief_gap_component = make_plot_component(
{
"Belief Gap": "tab:purple",
"Whistleblowers Defying": "tab:orange",
},
post_process=post_process_gap,
)


def post_process_space(ax):
"""Configures the space plot axes."""
ax.set_aspect("equal")
ax.set_xticks([])
ax.set_yticks([])
Expand All @@ -79,7 +96,8 @@ def post_process_space(ax):
"values": [True, False],
"label": "Cluster Believers (Homophily)?",
},
"width": 40,
"fraction_whistleblowers": Slider("Fraction Whistleblowers", 0.0, 0.0, 0.5, 0.01),
"width": 25,
"height": 25,
}

Expand All @@ -95,7 +113,7 @@ def post_process_space(ax):
page = SolaraViz(
model,
renderer,
components=[lineplot_component, CommandConsole],
components=[lineplot_component, belief_gap_component, CommandConsole],
model_params=model_params,
name="The Emperor's Dilemma",
)
Expand Down
Loading