185 lines
7.0 KiB
Python
185 lines
7.0 KiB
Python
# policy.py — anti-stall, rare+overlap–reserved, feasibility-first
|
||
#
|
||
# Fix for your “stuck at ~550 admits” case:
|
||
# • Keep hard feasibility (can’t paint into a corner).
|
||
# • Explicitly reserve seats for the three true bottlenecks:
|
||
# – queer_friendly (Q), vinyl_collector (V), and the GI overlap
|
||
# (german_speaker ∩ international) which must be ≥ 450.
|
||
# • Use *pacing* only as a soft gate; never let it deadlock:
|
||
# – While Q/V/GI-overlap are behind pace, prefer them.
|
||
# – Allow other helpers ONLY if, after admitting, there is comfortable
|
||
# cushion beyond the reserved seats for Q/V/GI-overlap.
|
||
# – If we still see a long rejection streak, auto-relax the pacing gate
|
||
# (base + reserves feasibility still enforced) to break droughts.
|
||
#
|
||
# Guarantees:
|
||
# • Reject non-helpers.
|
||
# • Per-attribute feasibility after each admit.
|
||
# • GI-overlap feasibility (and aggregated rare+overlap reserve) after each admit.
|
||
# • Anti-stall: pacing can’t lock the policy into rejecting forever.
|
||
#
|
||
# Toggle DEBUG=True for verbose reasoning.
|
||
|
||
import sys
|
||
from math import ceil
|
||
from typing import Dict, List
|
||
|
||
DEBUG = False # set True for step-by-step logs
|
||
|
||
# Attribute names for Scenario 3
|
||
U = "underground_veteran"
|
||
I = "international"
|
||
F = "fashion_forward"
|
||
Q = "queer_friendly"
|
||
V = "vinyl_collector"
|
||
G = "german_speaker"
|
||
|
||
# --- Module state (persists across calls) ---
|
||
_STEP = 0
|
||
_REJECT_STREAK = 0
|
||
_GI_BOTH_ADMITTED = 0 # admits with BOTH german_speaker AND international
|
||
|
||
# --- Tunables ---
|
||
PACE_SLACK = 0 # how many below on-track we tolerate
|
||
RELAX_STREAK = 400 # after this many consecutive rejects, relax pacing gate
|
||
CUSHION_FRACTION = 0.05 # 5% of remaining seats as cushion when behind pace
|
||
MIN_CUSHION = 2 # at least this cushion when behind pace
|
||
|
||
|
||
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 _pace_required(target: int, admitted: int, cap: int) -> int:
|
||
return ceil(target * admitted / max(1, cap))
|
||
|
||
|
||
def _feasible_per_attribute(attrs: Dict[str, bool], need: Dict[str, int], S: int) -> bool:
|
||
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_overlap_total_min(mins: Dict[str, int], cap: int) -> int:
|
||
# Minimum number of admits that must be BOTH german & international to satisfy both minima in cap seats
|
||
return max(0, mins.get(G, 0) + mins.get(I, 0) - cap)
|
||
|
||
|
||
def _reserve_after_admit(attrs: Dict[str, bool],
|
||
need_Q: int, need_V: int, need_GI: int) -> int:
|
||
# Remaining reserved seats needed for Q, V, and GI-overlap AFTER this admission
|
||
q_after = max(0, need_Q - (1 if attrs.get(Q, False) else 0))
|
||
v_after = max(0, need_V - (1 if attrs.get(V, False) else 0))
|
||
gi_after = max(0, need_GI - (1 if (attrs.get(G, False) and attrs.get(I, False)) else 0))
|
||
return q_after + v_after + gi_after
|
||
|
||
|
||
def decide(attributes: Dict[str, bool],
|
||
tallies: Dict[str, int],
|
||
mins: Dict[str, int],
|
||
admitted_count: int,
|
||
venue_cap: int) -> bool:
|
||
global _STEP, _REJECT_STREAK, _GI_BOTH_ADMITTED
|
||
_STEP += 1
|
||
|
||
S = venue_cap - admitted_count # remaining seats
|
||
need = _need(mins, tallies)
|
||
total_need = sum(need.values())
|
||
|
||
# Admit freely when all minima are satisfied.
|
||
if total_need == 0:
|
||
_REJECT_STREAK = 0
|
||
_log("All minima satisfied -> admit")
|
||
return True
|
||
|
||
# Must help at least one unmet attribute.
|
||
helps = _helpers(attributes, need)
|
||
if not helps:
|
||
_REJECT_STREAK += 1
|
||
_log("Reject: non-helper")
|
||
return False
|
||
|
||
# Hard per-attribute feasibility.
|
||
if not _feasible_per_attribute(attributes, need, S):
|
||
_REJECT_STREAK += 1
|
||
_log("Reject: per-attribute feasibility would break")
|
||
return False
|
||
|
||
# --- GI-overlap reserve & feasibility ---
|
||
gi_min_total = _gi_overlap_total_min(mins, venue_cap) # = 450 in this scenario
|
||
gi_need_now = max(0, gi_min_total - _GI_BOTH_ADMITTED)
|
||
|
||
# Reserved seats for Q, V, and GI-overlap after admitting this person
|
||
reserved_after = _reserve_after_admit(attributes,
|
||
need.get(Q, 0), need.get(V, 0), gi_need_now)
|
||
S_after = S - 1
|
||
|
||
# Hard reserve gate: we must always be able to fit remaining Q, V, GI-overlap.
|
||
if S_after < reserved_after:
|
||
_REJECT_STREAK += 1
|
||
_log(f"Reject: violates rare+overlap reserve (S_after={S_after} < reserved={reserved_after})")
|
||
return False
|
||
|
||
# --- Pacing (soft gate with anti-stall) ---
|
||
# On-track targets by now:
|
||
q_pace = _pace_required(mins.get(Q, 0), admitted_count, venue_cap)
|
||
v_pace = _pace_required(mins.get(V, 0), admitted_count, venue_cap)
|
||
gi_pace = _pace_required(gi_min_total, admitted_count, venue_cap)
|
||
|
||
behind_Q = tallies.get(Q, 0) + PACE_SLACK < q_pace
|
||
behind_V = tallies.get(V, 0) + PACE_SLACK < v_pace
|
||
behind_GI = _GI_BOTH_ADMITTED + PACE_SLACK < gi_pace
|
||
behind_any = behind_Q or behind_V or behind_GI
|
||
|
||
is_GI_both = bool(attributes.get(G, False) and attributes.get(I, False))
|
||
is_Q = bool(attributes.get(Q, False))
|
||
is_V = bool(attributes.get(V, False))
|
||
|
||
if behind_any:
|
||
# Prefer admitting GI-both or rare (Q/V) when behind pace.
|
||
if (behind_GI and is_GI_both) or (behind_Q and is_Q) or (behind_V and is_V):
|
||
if is_GI_both:
|
||
_GI_BOTH_ADMITTED += 1
|
||
_REJECT_STREAK = 0
|
||
_log("Accept: behind pace -> prioritized helper (GI-both or Q/V)")
|
||
return True
|
||
|
||
# Otherwise, allow admitting other helpers only if there's *comfortable* cushion beyond reserves.
|
||
# Cushion scaled to remaining seats to avoid early over-admitting.
|
||
cushion_needed = max(MIN_CUSHION, int(S_after * CUSHION_FRACTION))
|
||
cushion_have = S_after - reserved_after
|
||
if cushion_have >= cushion_needed or _REJECT_STREAK >= RELAX_STREAK:
|
||
if is_GI_both:
|
||
_GI_BOTH_ADMITTED += 1
|
||
_REJECT_STREAK = 0
|
||
_log(f"Accept: behind pace but cushion OK (have={cushion_have}≥{cushion_needed}) "
|
||
f"or relax streak hit ({_REJECT_STREAK>=RELAX_STREAK})")
|
||
return True
|
||
|
||
# Not prioritized and cushion too small -> reject to conserve headroom.
|
||
_REJECT_STREAK += 1
|
||
_log(f"Reject: behind pace, cushion too small (have={cushion_have}<{cushion_needed})")
|
||
return False
|
||
|
||
# On pace: admit any helper (reserves already enforced).
|
||
if is_GI_both:
|
||
_GI_BOTH_ADMITTED += 1
|
||
_REJECT_STREAK = 0
|
||
_log(f"Accept: on pace helper {helps}")
|
||
return True
|
||
|