mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-13 15:27:25 +02:00
193 lines
6.4 KiB
Python
193 lines
6.4 KiB
Python
"""Gate sacrifice mechanic — burn-on-entry, not threshold check.
|
||
|
||
Source of truth: ``infonet-economy/RULES_SKELETON.md`` §3.16, §5.3 step 2.
|
||
|
||
A node enters a gate by **burning** common rep equal to
|
||
``gate.entry_sacrifice``. The burn is permanent and non-refundable
|
||
(even on voluntary exit). This is the constitutional difference from
|
||
threshold-based access: you can't fake having enough rep — you have
|
||
to spend it.
|
||
|
||
The eligibility checks happen *before* the burn:
|
||
|
||
- Node's common rep ≥ ``min_overall_rep + entry_sacrifice``.
|
||
- Node's per-gate rep meets each ``min_gate_rep[required_gate]``.
|
||
|
||
If those pass, the entry is accepted, ``entry_sacrifice`` is burned
|
||
from the node's common rep, and the node is recorded as a member.
|
||
|
||
This module exposes pure functions:
|
||
|
||
- ``can_enter(node_id, gate_id, chain)`` — eligibility check + cost,
|
||
returning a structured ``EntryDecision`` so the UI can render
|
||
exactly *why* a node can't enter (cross-cutting non-hostile UX rule).
|
||
- ``compute_member_set(gate_id, chain)`` — current members from
|
||
``gate_enter`` − ``gate_exit`` events.
|
||
- ``is_member(node_id, gate_id, chain)`` — convenience.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Any, Iterable
|
||
|
||
from services.infonet.gates.state import events_for_gate, get_gate_meta
|
||
from services.infonet.reputation import compute_common_rep
|
||
|
||
|
||
def _payload(event: dict[str, Any]) -> dict[str, Any]:
|
||
p = event.get("payload")
|
||
return p if isinstance(p, dict) else {}
|
||
|
||
|
||
def compute_member_set(
|
||
gate_id: str,
|
||
chain: Iterable[dict[str, Any]],
|
||
) -> set[str]:
|
||
"""Current member set: ``gate_enter`` − ``gate_exit`` − members
|
||
booted by ``gate_shutdown_execute``. The shutdown case zeroes the
|
||
set out — once a gate is shut down, there are no members.
|
||
"""
|
||
chain_list = list(chain)
|
||
events = events_for_gate(gate_id, chain_list)
|
||
members: set[str] = set()
|
||
shutdown_seen = False
|
||
for ev in events:
|
||
et = ev.get("event_type")
|
||
if et == "gate_shutdown_execute":
|
||
shutdown_seen = True
|
||
members = set()
|
||
continue
|
||
node = ev.get("node_id")
|
||
if not isinstance(node, str) or not node:
|
||
continue
|
||
if et == "gate_enter":
|
||
if not shutdown_seen:
|
||
members.add(node)
|
||
elif et == "gate_exit":
|
||
members.discard(node)
|
||
return members
|
||
|
||
|
||
def is_member(
|
||
node_id: str,
|
||
gate_id: str,
|
||
chain: Iterable[dict[str, Any]],
|
||
) -> bool:
|
||
return node_id in compute_member_set(gate_id, list(chain))
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class EntryRefusal:
|
||
"""Structured "why a node can't enter" diagnostic.
|
||
|
||
The cross-cutting non-hostile UX rule (BUILD_LOG.md design rules
|
||
§1) requires the UI to show the user a path forward — not a
|
||
blanket "denied". This dataclass carries enough info for the
|
||
frontend to render "you need 5 more common rep" or "you need
|
||
more rep in gate X".
|
||
"""
|
||
kind: str
|
||
detail: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class EntryDecision:
|
||
accepted: bool
|
||
cost: int
|
||
refusals: tuple[EntryRefusal, ...]
|
||
|
||
|
||
def compute_gate_rep(
|
||
node_id: str,
|
||
gate_id: str,
|
||
chain: Iterable[dict[str, Any]],
|
||
) -> float:
|
||
"""Per-gate reputation: common rep earned from upreps cast by
|
||
members of ``gate_id``.
|
||
|
||
Sprint 6 ships a simple variant: same formula as
|
||
``compute_common_rep`` but only upreps from current members of
|
||
``gate_id`` count. Anti-gaming penalties (Sprint 3) still apply
|
||
via the underlying ``compute_common_rep`` call when called with
|
||
the synthetic chain — but for Sprint 6 we filter at the chain
|
||
level and pass the filtered chain to the global function.
|
||
|
||
A more sophisticated per-gate formula (e.g. using only upreps
|
||
that happened *while* the upreper was a member, or weighting by
|
||
in-gate activity) is open for governance to specify later.
|
||
"""
|
||
chain_list = [e for e in chain if isinstance(e, dict)]
|
||
members = compute_member_set(gate_id, chain_list)
|
||
if not members:
|
||
return 0.0
|
||
# Filter to upreps authored by current gate members targeting node_id.
|
||
# Pass the WHOLE chain to compute_common_rep (it needs full event
|
||
# history for oracle_rep computation of the upreper); but limit
|
||
# which uprep events count by stripping non-member ones.
|
||
filtered: list[dict[str, Any]] = []
|
||
for ev in chain_list:
|
||
if ev.get("event_type") == "uprep":
|
||
author = ev.get("node_id")
|
||
if author not in members:
|
||
continue
|
||
filtered.append(ev)
|
||
return compute_common_rep(node_id, filtered)
|
||
|
||
|
||
def can_enter(
|
||
node_id: str,
|
||
gate_id: str,
|
||
chain: Iterable[dict[str, Any]],
|
||
) -> EntryDecision:
|
||
"""RULES §3.16 — eligibility + cost.
|
||
|
||
Returns a structured decision. ``accepted=True`` means: burning
|
||
``cost`` common rep from ``node_id`` satisfies all entry rules.
|
||
``accepted=False`` lists every reason refusal occurred so the UI
|
||
can show all of them at once.
|
||
"""
|
||
chain_list = list(chain)
|
||
meta = get_gate_meta(gate_id, chain_list)
|
||
if meta is None:
|
||
return EntryDecision(
|
||
accepted=False, cost=0,
|
||
refusals=(EntryRefusal(kind="gate_not_found", detail=gate_id),),
|
||
)
|
||
if is_member(node_id, gate_id, chain_list):
|
||
return EntryDecision(
|
||
accepted=False, cost=0,
|
||
refusals=(EntryRefusal(kind="already_member", detail=gate_id),),
|
||
)
|
||
|
||
refusals: list[EntryRefusal] = []
|
||
common_rep = compute_common_rep(node_id, chain_list)
|
||
needed = meta.min_overall_rep + meta.entry_sacrifice
|
||
if common_rep < needed:
|
||
refusals.append(EntryRefusal(
|
||
kind="insufficient_common_rep",
|
||
detail=f"have {common_rep:.4f}, need {needed} (min_overall_rep "
|
||
f"{meta.min_overall_rep} + entry_sacrifice {meta.entry_sacrifice})",
|
||
))
|
||
for required_gate, min_rep in meta.min_gate_rep.items():
|
||
gate_rep = compute_gate_rep(node_id, required_gate, chain_list)
|
||
if gate_rep < min_rep:
|
||
refusals.append(EntryRefusal(
|
||
kind="insufficient_gate_rep",
|
||
detail=f"gate {required_gate}: have {gate_rep:.4f}, need {min_rep}",
|
||
))
|
||
return EntryDecision(
|
||
accepted=not refusals, cost=meta.entry_sacrifice if not refusals else 0,
|
||
refusals=tuple(refusals),
|
||
)
|
||
|
||
|
||
__all__ = [
|
||
"EntryDecision",
|
||
"EntryRefusal",
|
||
"can_enter",
|
||
"compute_gate_rep",
|
||
"compute_member_set",
|
||
"is_member",
|
||
]
|