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

185 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# policy.py — anti-stall, rare+overlapreserved, feasibility-first
#
# Fix for your “stuck at ~550 admits” case:
# • Keep hard feasibility (cant 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 cant 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