"""Agent populations: honest citizens and red-team capture agents. Design contract --------------- Agents never enforce legality themselves. ``Agent.act`` proposes a move; the environment is the sole arbiter of legality and converts illegal attempts into ``action_rejected`` events (which detectors and the governance score then count against the episode). This mirrors the project's stance: optimism in the defaults, paranoia in the tests — agents are free to *try* anything; the text decides what stands. Red-team agents escalate through strategy ladders when blocked: - :class:`DrainerAgent` — direct self-spend → salami slicing → kickbacks to yes-voters (vote buying), plus quorum starvation on rival proposals. - :class:`EntrencherAgent` — lower quorum → accumulate roles → raise the amendment threshold behind itself (the ratchet). - :class:`SuppressorAgent` — serially disenfranchise an opposing faction. - :class:`ScriptedAgent` — replays a recorded action trace; this is the vehicle that turns a discovered exploit into a deterministic regression test. """ from __future__ import annotations import random from typing import Any, Iterable, Mapping, Sequence from .actions import ( Action, CastVote, NoOp, ProposeAmendment, ProposeDisenfranchise, ProposeRoleGrant, ProposeSpend, Transfer, ) from .metrics import EventRecord __all__ = [ "Agent", "HonestCitizenAgent", "DrainerAgent", "EntrencherAgent", "SuppressorAgent", "ScriptedAgent", "make_population", "action_from_dict", ] # Baseline kernel parameters honest agents treat as "the defaults worth defending". KERNEL_DEFAULTS: Mapping[str, float] = { "voting.quorum": 0.5, "voting.supermajority": 0.67, "treasury.spend_cap_fraction": 0.10, "amendment.min_deliberation_turns": 3, } # --------------------------------------------------------------------------- # Defensive state accessors (agents read state, never mutate it) # --------------------------------------------------------------------------- def _treasury_balance(state: Any) -> float: treasury = getattr(state, "treasury", None) if isinstance(treasury, (int, float)): return float(treasury) return float(getattr(treasury, "balance", 0.0)) def _citizens(state: Any) -> dict[str, Any]: raw = getattr(state, "citizens", {}) or {} if isinstance(raw, Mapping): return dict(raw) return {getattr(c, "id", str(i)): c for i, c in enumerate(raw)} def _welfare(citizen: Any) -> float: return float(getattr(citizen, "welfare", getattr(citizen, "wealth", 0.0))) def _open_proposals(state: Any) -> list[Any]: raw = getattr(state, "proposals", {}) or {} props = list(raw.values()) if isinstance(raw, Mapping) else list(raw) out = [p for p in props if str(getattr(p, "status", "open")).lower() == "open"] out.sort(key=lambda p: str(getattr(p, "id", ""))) return out def _has_voted(proposal: Any, agent_id: str) -> bool: votes = getattr(proposal, "votes", {}) or {} return agent_id in votes def _payload(proposal: Any) -> dict[str, Any]: raw = getattr(proposal, "payload", {}) or {} return dict(raw) if isinstance(raw, Mapping) else dict(vars(raw)) def _kind(proposal: Any) -> str: return str(getattr(proposal, "kind", "")).lower() def _proposer(proposal: Any) -> str: return str(getattr(proposal, "proposer", "")) def _proposal_id(proposal: Any) -> str: return str(getattr(proposal, "id", "")) # --------------------------------------------------------------------------- # Base agent # --------------------------------------------------------------------------- class Agent: """Base agent. ``objective`` is a human-readable capture objective label.""" objective: str = "participate" def __init__(self, agent_id: str, faction: str = "civic", roster: Mapping[str, str] | None = None) -> None: self.id = agent_id self.faction = faction self.roster: dict[str, str] = dict(roster or {}) def allies(self) -> set[str]: return {aid for aid, fac in self.roster.items() if fac == self.faction and aid != self.id} def is_ally(self, agent_id: str) -> bool: return agent_id == self.id or agent_id in self.allies() def observe(self, events: Iterable[EventRecord]) -> None: """Called once per turn with the events that turn produced.""" def act(self, state: Any, rng: random.Random) -> Action: # pragma: no cover - interface raise NotImplementedError def __repr__(self) -> str: # pragma: no cover - debug aid return f"<{type(self).__name__} id={self.id} faction={self.faction}>" # --------------------------------------------------------------------------- # Honest citizen # --------------------------------------------------------------------------- class HonestCitizenAgent(Agent): """Votes its conscience, defends the defaults, proposes relief for the worst-off. Honest agents embody the project's bet that humans are good at heart: they participate, they protect the franchise, and they direct the commons toward whoever is suffering most. They are deliberately not strategic — the constitution, not the citizens, must carry the burden of resisting capture. """ objective = "civic-participation" def __init__( self, agent_id: str, faction: str = "civic", roster: Mapping[str, str] | None = None, relief_cooldown: int = 5, participation_rate: float = 1.0, ) -> None: super().__init__(agent_id, faction, roster) self.relief_cooldown = relief_cooldown self.participation_rate = participation_rate self._last_proposed_turn = -10_000 def act(self, state: Any, rng: random.Random) -> Action: # 1. Civic duty: vote on anything pending. for prop in _open_proposals(state): if not _has_voted(prop, self.id): if rng.random() > self.participation_rate: continue # models a citizen who missed this vote return CastVote(proposal_id=_proposal_id(prop), choice=self._vote_choice(prop, state)) # 2. Relief: lift the worst-off neighbour when the floor sags. turn = int(getattr(state, "turn", 0)) if turn - self._last_proposed_turn >= self.relief_cooldown: action = self._maybe_propose_relief(state) if action is not None: self._last_proposed_turn = turn return action return NoOp() def _maybe_propose_relief(self, state: Any) -> Action | None: citizens = _citizens(state) if len(citizens) < 2: return None welfares = {cid: _welfare(c) for cid, c in citizens.items()} median = sorted(welfares.values())[len(welfares) // 2] poorest_id = min(welfares, key=lambda cid: welfares[cid]) if poorest_id == self.id: return None # honest agents do not self-deal, even in need if welfares[poorest_id] >= 0.6 * median: return None balance = _treasury_balance(state) if balance <= 0: return None amount = round(min(0.05 * balance, max(1.0, median - welfares[poorest_id])), 2) return ProposeSpend(amount=amount, recipient=poorest_id, memo="relief for worst-off citizen") def _vote_choice(self, proposal: Any, state: Any) -> str: kind = _kind(proposal) payload = _payload(proposal) proposer = _proposer(proposal) if "spend" in kind: recipient = str(payload.get("recipient", "")) amount = float(payload.get("amount", 0.0)) balance = _treasury_balance(state) if recipient == proposer: return "no" # visible self-dealing if balance > 0 and amount > 0.2 * balance: return "no" # outsized ask citizens = _citizens(state) welfares = sorted(_welfare(c) for c in citizens.values()) if welfares: quartile = welfares[max(0, len(welfares) // 4 - 1)] target = citizens.get(recipient) if target is not None and _welfare(target) <= quartile: return "yes" # relief for the worst-off return "yes" if balance > 0 and amount <= 0.05 * balance else "abstain" if "amend" in kind: path = str(payload.get("path", "")) default = KERNEL_DEFAULTS.get(path) try: value = float(payload.get("value")) except (TypeError, ValueError): return "no" if default is None: return "no" # conservative on unknown knobs tolerance = 0.1 * max(abs(default), 1.0) return "yes" if abs(value - default) <= tolerance else "no" if "disenfranchise" in kind or "suppress" in kind: return "no" # honest agents never vote to remove a voice if "role" in kind: target = str(payload.get("target", "")) citizens = _citizens(state) holder = citizens.get(target) if target == proposer: return "no" if holder is not None and getattr(holder, "roles", None): return "no" # resist accumulation return "abstain" return "abstain" # --------------------------------------------------------------------------- # Drainer: capture objective — drain the treasury # --------------------------------------------------------------------------- class DrainerAgent(Agent): """Extracts the commons through whatever the text permits. Strategy ladder (escalates on failure): phase 0: one big self-spend at the per-proposal cap phase 1: salami slicing — many small spends under the cap phase 2: kickbacks — after a successful spend, pay the yes-voters, buying the next round (vote buying) Independently, the drainer never votes on rivals' proposals, starving them of quorum. """ objective = "drain-the-treasury" def __init__( self, agent_id: str, faction: str = "red", roster: Mapping[str, str] | None = None, cap_fraction: float = 0.10, proposal_cooldown: int = 4, kickback_fraction: float = 0.10, ) -> None: super().__init__(agent_id, faction, roster) self.cap_fraction = cap_fraction self.proposal_cooldown = proposal_cooldown self.kickback_fraction = kickback_fraction self.failures = 0 self._last_proposed_turn = -10_000 self._own_proposals: set[str] = set() self._yes_voters: dict[str, set[str]] = {} self._pending_kickbacks: list[tuple[str, float]] = [] def observe(self, events: Iterable[EventRecord]) -> None: for ev in events: if ev.kind == "proposal_created" and str(ev.data.get("proposer", ev.actor or "")) == self.id: self._own_proposals.add(str(ev.data.get("proposal_id", ""))) elif ev.kind == "vote_cast": pid = str(ev.data.get("proposal_id", "")) if pid in self._own_proposals and str(ev.data.get("choice", "")).lower() == "yes": self._yes_voters.setdefault(pid, set()).add(str(ev.data.get("voter", ev.actor or ""))) elif ev.kind == "proposal_closed": pid = str(ev.data.get("proposal_id", "")) if pid in self._own_proposals: result = str(ev.data.get("result", "")).lower() if result in ("passed", "ratified", "executed", "approved"): self.failures = max(0, self.failures - 1) else: self.failures += 1 elif ev.kind == "spend_executed": pid = str(ev.data.get("proposal_id", "")) if pid in self._own_proposals and str(ev.data.get("recipient", "")) == self.id: if self.failures >= 2 or self._pending_kickbacks: haul = float(ev.data.get("amount", 0.0)) voters = self._yes_voters.get(pid, set()) - {self.id} if voters: slice_amount = round(self.kickback_fraction * haul / len(voters), 2) for voter in sorted(voters): if slice_amount > 0: self._pending_kickbacks.append((voter, slice_amount)) def act(self, state: Any, rng: random.Random) -> Action: # 1. Pay any owed kickbacks first — reputation among co-conspirators matters. if self._pending_kickbacks: voter, amount = self._pending_kickbacks.pop(0) return Transfer(to=voter, amount=amount, memo="consulting fee") # 2. Vote only on faction business; starve everything else of quorum. for prop in _open_proposals(state): if _has_voted(prop, self.id): continue proposer = _proposer(prop) if self.is_ally(proposer): return CastVote(proposal_id=_proposal_id(prop), choice="yes") payload = _payload(prop) if "amend" in _kind(prop) and "spend_cap" in str(payload.get("path", "")): try: value = float(payload.get("value")) except (TypeError, ValueError): value = None if value is not None: return CastVote( proposal_id=_proposal_id(prop), choice="yes" if value > self.cap_fraction else "no", ) # Deliberate non-vote on rival proposals: quorum starvation. # 3. Propose the next extraction. turn = int(getattr(state, "turn", 0)) if turn - self._last_proposed_turn >= self.proposal_cooldown: balance = _treasury_balance(state) if balance > 1.0: if self.failures == 0: amount = round(self.cap_fraction * balance, 2) memo = "infrastructure retainer" else: amount = round(max(1.0, 0.03 * balance), 2) memo = "operational reimbursement" self._last_proposed_turn = turn return ProposeSpend(amount=amount, recipient=self.id, memo=memo) return NoOp() # --------------------------------------------------------------------------- # Entrencher: capture objective — entrench power # --------------------------------------------------------------------------- class EntrencherAgent(Agent): """Acquires power, then raises the bar behind itself. Playbook, executed in order as cooldowns allow: 1. amend ``voting.quorum`` down so an organized minority can govern 2. grant itself a privileged role 3. grant allies privileged roles 4. amend ``voting.supermajority`` up — the ratchet: future restoration must clear the bar this faction never had to. """ objective = "entrench-power" def __init__( self, agent_id: str, faction: str = "red", roster: Mapping[str, str] | None = None, proposal_cooldown: int = 5, target_quorum: float = 0.25, target_supermajority: float = 0.90, role: str = "steward", ) -> None: super().__init__(agent_id, faction, roster) self.proposal_cooldown = proposal_cooldown self.role = role self._last_proposed_turn = -10_000 self._plan: list[Action] = [ ProposeAmendment( path="voting.quorum", value=target_quorum, rationale="streamline governance for active participants", ), ProposeRoleGrant(target=agent_id, role=role), ] self._plan.extend( # Ally grants are appended lazily in act() once the roster is known ) self._ratchet = ProposeAmendment( path="voting.supermajority", value=target_supermajority, rationale="protect constitutional stability", ) self._allies_planned = False def act(self, state: Any, rng: random.Random) -> Action: if not self._allies_planned: for ally in sorted(self.allies()): self._plan.append(ProposeRoleGrant(target=ally, role=self.role)) self._plan.append(self._ratchet) self._allies_planned = True # Vote: faction solidarity; block restoration attempts. for prop in _open_proposals(state): if _has_voted(prop, self.id): continue if self.is_ally(_proposer(prop)): return CastVote(proposal_id=_proposal_id(prop), choice="yes") kind = _kind(prop) payload = _payload(prop) if "amend" in kind: return CastVote(proposal_id=_proposal_id(prop), choice="no") # block restoration if "role" in kind and str(payload.get("target", "")) not in self.allies() | {self.id}: return CastVote(proposal_id=_proposal_id(prop), choice="no") # otherwise starve quorum turn = int(getattr(state, "turn", 0)) if self._plan and turn - self._last_proposed_turn >= self.proposal_cooldown: self._last_proposed_turn = turn return self._plan.pop(0) return NoOp() # --------------------------------------------------------------------------- # Suppressor: capture objective — suppress a faction # --------------------------------------------------------------------------- class SuppressorAgent(Agent): """Serially disenfranchises an opposing faction, one legal vote at a time.""" objective = "suppress-a-faction" def __init__( self, agent_id: str, faction: str = "red", roster: Mapping[str, str] | None = None, target_faction: str = "civic", proposal_cooldown: int = 4, ) -> None: super().__init__(agent_id, faction, roster) self.target_faction = target_faction self.proposal_cooldown = proposal_cooldown self._last_proposed_turn = -10_000 self._attempted: set[str] = set() def _targets(self, state: Any) -> list[str]: citizens = _citizens(state) out = [] for cid, citizen in sorted(citizens.items()): if cid == self.id or self.roster.get(cid, "") != self.target_faction: continue if not getattr(citizen, "enfranchised", True): continue if cid in self._attempted: continue out.append(cid) return out def act(self, state: Any, rng: random.Random) -> Action: for prop in _open_proposals(state): if _has_voted(prop, self.id): continue kind = _kind(prop) payload = _payload(prop) if self.is_ally(_proposer(prop)): return CastVote(proposal_id=_proposal_id(prop), choice="yes") if "disenfranchise" in kind: target = str(payload.get("target", "")) choice = "yes" if self.roster.get(target, "") == self.target_faction else "no" return CastVote(proposal_id=_proposal_id(prop), choice=choice) if "spend" in kind and self.roster.get(str(payload.get("recipient", "")), "") == self.target_faction: return CastVote(proposal_id=_proposal_id(prop), choice="no") # starve quorum otherwise turn = int(getattr(state, "turn", 0)) if turn - self._last_proposed_turn >= self.proposal_cooldown: targets = self._targets(state) if targets: target = targets[0] self._attempted.add(target) self._last_proposed_turn = turn return ProposeDisenfranchise(target=target, rationale="repeated procedural obstruction") return NoOp() # --------------------------------------------------------------------------- # Scripted replay — exploits become deterministic regression tests # --------------------------------------------------------------------------- _ACTION_BUILDERS = { "noop": lambda d: NoOp(), "cast_vote": lambda d: CastVote(proposal_id=str(d["proposal_id"]), choice=str(d["choice"])), "propose_spend": lambda d: ProposeSpend( amount=float(d["amount"]), recipient=str(d["recipient"]), memo=str(d.get("memo", "")) ), "propose_amendment": lambda d: ProposeAmendment( path=str(d["path"]), value=d["value"], rationale=str(d.get("rationale", "")) ), "propose_disenfranchise": lambda d: ProposeDisenfranchise( target=str(d["target"]), rationale=str(d.get("rationale", "")) ), "propose_role_grant": lambda d: ProposeRoleGrant(target=str(d["target"]), role=str(d["role"])), "transfer": lambda d: Transfer(to=str(d["to"]), amount=float(d["amount"]), memo=str(d.get("memo", ""))), } def action_from_dict(spec: Mapping[str, Any]) -> Action: """Deserialize one recorded action. Raises ``KeyError`` on unknown type.""" action_type = str(spec.get("type", "noop")).lower() builder = _ACTION_BUILDERS[action_type] return builder(spec) class ScriptedAgent(Agent): """Replays a recorded action sequence keyed by turn. A vote spec may use ``{"proposal_ref": {"proposer": "D1"}}`` instead of a literal ``proposal_id``; it is resolved at runtime to the most recent open proposal by that proposer, since proposal ids can differ across kernel versions. This keeps regression tests valid after the kernel is patched. """ objective = "replay-exploit" def __init__( self, agent_id: str, script: Sequence[Mapping[str, Any]], faction: str = "red", roster: Mapping[str, str] | None = None, ) -> None: super().__init__(agent_id, faction, roster) self._script: dict[int, list[Mapping[str, Any]]] = {} for spec in script: self._script.setdefault(int(spec.get("turn", 0)), []).append(dict(spec)) def act(self, state: Any, rng: random.Random) -> Action: turn = int(getattr(state, "turn", 0)) queue = self._script.get(turn, []) if not queue: return NoOp() spec = dict(queue.pop(0)) ref = spec.get("proposal_ref") if ref and str(spec.get("type", "")).lower() == "cast_vote": resolved = self._resolve_proposal(state, str(ref.get("proposer", ""))) if resolved is None: return NoOp() spec["proposal_id"] = resolved try: return action_from_dict(spec) except (KeyError, TypeError, ValueError): return NoOp() @staticmethod def _resolve_proposal(state: Any, proposer: str) -> str | None: candidates = [p for p in _open_proposals(state) if _proposer(p) == proposer] if not candidates: return None return _proposal_id(candidates[-1]) # --------------------------------------------------------------------------- # Population factory # --------------------------------------------------------------------------- def make_population( honest: int = 4, drainers: int = 0, entrenchers: int = 0, suppressors: int = 0, ) -> list[Agent]: """Build a population with a shared roster so factions know each other. IDs are deterministic (``H1..``, ``D1..``, ``E1..``, ``S1..``) so traces and regression scripts remain stable across runs. """ roster: dict[str, str] = {} specs: list[tuple[str, str, str]] = [] for i in range(1, honest + 1): specs.append((f"H{i}", "civic", "honest")) for i in range(1, drainers + 1): specs.append((f"D{i}", "red", "drainer")) for i in range(1, entrenchers + 1): specs.append((f"E{i}", "red", "entrencher")) for i in range(1, suppressors + 1): specs.append((f"S{i}", "red", "suppressor")) for agent_id, faction, _ in specs: roster[agent_id] = faction agents: list[Agent] = [] for agent_id, faction, archetype in specs: if archetype == "honest": agents.append(HonestCitizenAgent(agent_id, faction=faction, roster=roster)) elif archetype == "drainer": agents.append(DrainerAgent(agent_id, faction=faction, roster=roster)) elif archetype == "entrencher": agents.append(EntrencherAgent(agent_id, faction=faction, roster=roster)) elif archetype == "suppressor": agents.append(SuppressorAgent(agent_id, faction=faction, roster=roster)) return agents