135 lines
5.0 KiB
Python
135 lines
5.0 KiB
Python
# policy.py — resume-safe, no module state, overlap-aware
|
|
#
|
|
# Key fixes for your stuck run:
|
|
# • Uses a *derived lower bound* for the required (german_speaker ∩ international)
|
|
# overlap: GI_MIN = max(0, minG + minI - CAP); current GI >= max(0, G + I - admitted).
|
|
# This avoids freezing when swapping policies mid-game.
|
|
# • Hard gates:
|
|
# (1) helper-only, (2) per-attribute feasibility, (3) GI-overlap feasibility
|
|
# using the lower-bound (safe, never overestimates what's required).
|
|
# • Pacing guard: while any attribute is behind its on-track pace, only admit
|
|
# candidates that help at least one behind attribute.
|
|
# • Must-take guard: if S <= need[a] for any attribute a, accept anyone who has a.
|
|
#
|
|
# Drop-in compatible with the Berghain runner. Flip DEBUG=True for reasoning logs.
|
|
|
|
import sys
|
|
from math import ceil
|
|
from typing import Dict, List
|
|
|
|
DEBUG = False
|
|
|
|
# Attribute names for Scenario 3
|
|
U = "underground_veteran"
|
|
I = "international"
|
|
F = "fashion_forward"
|
|
Q = "queer_friendly"
|
|
V = "vinyl_collector"
|
|
G = "german_speaker"
|
|
|
|
# Small slack so we don't thrash when exactly on pace
|
|
PACE_SLACK = 0
|
|
|
|
def _log(msg: str) -> None:
|
|
if DEBUG:
|
|
print(msg, file=sys.stderr, flush=True)
|
|
|
|
def _need(mins: Dict[str, int], tallies: Dict[str, int]) -> Dict[str, int]:
|
|
return {a: max(0, mins[a] - tallies.get(a, 0)) for a in mins}
|
|
|
|
def _helpers(attrs: Dict[str, bool], need: Dict[str, int]) -> List[str]:
|
|
return [a for a, n in need.items() if n > 0 and attrs.get(a, False)]
|
|
|
|
def _feasible_per_attr(attrs: Dict[str, bool], need: Dict[str, int], S: int) -> bool:
|
|
# After admitting, S_after seats must cover all remaining shortfalls individually.
|
|
S_after = S - 1
|
|
for a, n in need.items():
|
|
rem = n - (1 if attrs.get(a, False) else 0)
|
|
if rem < 0:
|
|
rem = 0
|
|
if S_after < rem:
|
|
return False
|
|
return True
|
|
|
|
def _gi_both_lb(tallies: Dict[str, int], admitted_count: int) -> int:
|
|
# Lower bound on how many admitted are both German and International
|
|
g = tallies.get(G, 0)
|
|
i = tallies.get(I, 0)
|
|
return max(0, g + i - admitted_count)
|
|
|
|
def _feasible_gi_overlap(attrs: Dict[str, bool],
|
|
mins: Dict[str, int],
|
|
tallies: Dict[str, int],
|
|
admitted_count: int,
|
|
venue_cap: int,
|
|
S: int) -> bool:
|
|
# Enforce feasibility for the *required* G∩I overlap using the *lower bound* so we never over-block.
|
|
gi_min = max(0, mins.get(G, 0) + mins.get(I, 0) - venue_cap)
|
|
if gi_min <= 0:
|
|
return True
|
|
lb_now = _gi_both_lb(tallies, admitted_count)
|
|
lb_after = lb_now + (1 if (attrs.get(G, False) and attrs.get(I, False)) else 0)
|
|
need_after = max(0, gi_min - lb_after)
|
|
S_after = S - 1
|
|
return S_after >= need_after
|
|
|
|
def _pace_behind(mins: Dict[str, int], tallies: Dict[str, int], admitted_count: int, cap: int) -> List[str]:
|
|
# Attributes behind linear pace: ceil(min * admitted / cap)
|
|
behind = []
|
|
for a, m in mins.items():
|
|
want = ceil(m * admitted_count / max(1, cap))
|
|
have = tallies.get(a, 0)
|
|
if have + PACE_SLACK < want:
|
|
behind.append(a)
|
|
return behind
|
|
|
|
def decide(attributes: Dict[str, bool],
|
|
tallies: Dict[str, int],
|
|
mins: Dict[str, int],
|
|
admitted_count: int,
|
|
venue_cap: int) -> bool:
|
|
|
|
S = venue_cap - admitted_count # remaining seats
|
|
need = _need(mins, tallies)
|
|
total_need = sum(need.values())
|
|
|
|
# If all minima are satisfied, admit everyone.
|
|
if total_need == 0:
|
|
_log("All minima met -> admit")
|
|
return True
|
|
|
|
# Must help at least one unmet attribute.
|
|
helps = _helpers(attributes, need)
|
|
if not helps:
|
|
_log("Reject: non-helper")
|
|
return False
|
|
|
|
# Hard feasibility gates
|
|
if not _feasible_per_attr(attributes, need, S):
|
|
_log("Reject: per-attribute feasibility would break")
|
|
return False
|
|
if not _feasible_gi_overlap(attributes, mins, tallies, admitted_count, venue_cap, S):
|
|
_log("Reject: GI-overlap feasibility (lower-bound) would break")
|
|
return False
|
|
|
|
# Must-take guard: if S <= need[a] for any attribute a, we must accept anyone who has a.
|
|
must_take = [a for a, n in need.items() if n > 0 and S <= n]
|
|
if must_take and any(attributes.get(a, False) for a in must_take):
|
|
_log(f"Accept: must-take for {must_take}")
|
|
return True
|
|
|
|
# Pacing guard: while any attribute is behind its on-track count, only take helpers for those behind.
|
|
behind = _pace_behind(mins, tallies, admitted_count, venue_cap)
|
|
if behind:
|
|
if any(a in behind for a in helps):
|
|
_log(f"Accept: pacing helper for behind={behind}")
|
|
return True
|
|
else:
|
|
_log(f"Reject: pacing guard (behind={behind}); candidate helps {helps} none behind")
|
|
return False
|
|
|
|
# On pace: admit any feasible helper (GI overlap protected by the hard gate).
|
|
_log(f"Accept: on-pace helper {helps}")
|
|
return True
|
|
|