2025-09-03 03:34:30 -07:00

123 lines
4.2 KiB
Python

# default_policy.py
# Pressure-gated policy: every admit must carry at least one statistically-scarce attribute
# until quotas are back on track. Designed to prevent over-admitting "easy" attributes while
# 'creative' and 'berlin_local' lag.
#
# Use policy_reason() in your UI to display decision explanations.
from __future__ import annotations
import sys
from typing import Dict
DEBUG = False
_last_reason: str | None = None
def _set_reason(msg: str) -> None:
global _last_reason
_last_reason = msg
if DEBUG:
print(msg, file=sys.stderr, flush=True)
def policy_reason() -> str | None:
return _last_reason
# --- Tunables ---
PRESSURE_SLACK = 0.0 # >0 softens the must-have gate slightly (e.g., 0.5)
PACING_START = 20 # ignore pacing/pressure noise for the first N admits
FEASIBILITY_SLACK = 0 # extra slack for the feasibility guard
# If runner doesn't pass base frequencies, use conservative fallbacks
DEFAULT_P = {
"creative": 0.06,
"berlin_local": 0.40,
"techno_lover": 0.60,
"well_connected": 0.45,
}
ORDER = ("creative", "berlin_local", "techno_lover", "well_connected")
def _need(mins: Dict[str,int], tallies: Dict[str,int]) -> Dict[str,int]:
return {a: max(0, int(mins.get(a, 0)) - int(tallies.get(a, 0))) for a in mins}
def _p(p: Dict[str,float] | None, a: str) -> float:
v = (p or {}).get(a, DEFAULT_P.get(a, 0.0))
try:
return max(0.0, min(1.0, float(v)))
except Exception:
return DEFAULT_P.get(a, 0.0)
def decide(attributes: Dict[str, bool],
tallies: Dict[str, int],
mins: Dict[str, int],
admitted_count: int,
venue_cap: int,
p: Dict[str, float] | None = None,
**_) -> bool:
has = lambda a: bool(attributes.get(a, False))
S = venue_cap - admitted_count
need = _need(mins, tallies)
total_need = sum(need.values())
# 0) All mins satisfied -> admit freely
if total_need == 0:
_set_reason("Admit (all minimums satisfied)")
return True
# Ensure candidate helps at least one unmet attribute
helps = [a for a in ORDER if need.get(a, 0) > 0 and has(a)]
if not helps:
_set_reason("Reject (no contribution to unmet attributes)")
return False
# Early game: admit any helper while counts are tiny to avoid noise
if admitted_count < PACING_START:
_set_reason(f"Admit (early game; helps {', '.join(helps)})")
return True
# 1) Compute base probs and PRESSURE = shortfall vs expected future supply
P = {a: _p(p, a) for a in ORDER}
S_after = max(0, S - 1)
pressure: Dict[str, float] = {}
for a in ORDER:
# remaining need after counting this candidate (if they have 'a')
rem_after = max(0, need.get(a, 0) - (1 if has(a) else 0))
# expected future supply after this admit
exp_after = S_after * P[a]
pressure[a] = rem_after - exp_after # >0 means we're short in expectation
# 2) Build MUST set: attributes whose pressure exceeds slack (i.e., statistically scarce)
must = [a for a in ORDER if pressure[a] > PRESSURE_SLACK]
if must:
# Require the candidate to carry at least one MUST attribute
if not any(has(a) for a in must):
_set_reason("Reject (must carry a scarce attribute: " + ", ".join(must) + ")")
return False
# 3) Feasibility: after admitting, do we still have enough slots to finish?
total_need_after = sum(
max(0, need.get(a, 0) - (1 if has(a) else 0)) for a in ORDER
)
if S_after + FEASIBILITY_SLACK < total_need_after:
_set_reason("Reject (feasibility would break after admitting)")
return False
# 4) Passed gates -> admit, with crisp reason
if must:
# Preferential message by priority
for a in ("creative", "berlin_local"):
if a in must and has(a):
_set_reason(f"Admit (scarce: {a}; pressure {pressure[a]:.1f})")
return True
# else any must-have present
found = [a for a in must if has(a)]
_set_reason("Admit (meets scarce requirement: " + ", ".join(found) + ")")
return True
else:
_set_reason(f"Admit (on pace; helps {', '.join(helps)})")
return True