-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontest.py
More file actions
130 lines (112 loc) · 5.65 KB
/
Copy pathcontest.py
File metadata and controls
130 lines (112 loc) · 5.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
"""Contest formats and problem selection.
A contest FORMAT is chosen by intended DURATION, not by a single difficulty band.
Each format bundles the duration and a RECIPE: a list of (band, count) slots drawn
from across the difficulty spectrum (e.g. normal = 簡単×1・中下位×2・中上位×2・上位×1).
Spreading a single contest easy→hard lets a newcomer solve *something* while still
separating the top — an onboarding-friendly spread, not a pool-robustness trick.
Problems are chosen from the eligible pool (published problems that NOBODY in the
contest has solved) per slot's band, unique across slots.
Rating-shaping knobs also live on the format (consumed by rating.performance):
- `perf_cap` : cap a contest's performance as a fraction of scale (speed → no farming)
- `loss_floor` : raise the performance floor (hardcore → 負けより勝ちを優先)
"""
from __future__ import annotations
import random
# Canonical difficulty bands (PE difficulty %: 0 ≈ trivial, 100 ≈ brutal).
# Ranges are inclusive; adjacent bands share a boundary (a boundary problem is
# eligible for either band — selection dedupes across slots).
BANDS: dict[str, tuple[int, int]] = {
"簡単": (1, 10),
"中下位": (10, 25),
"中位": (25, 45),
"中上位": (45, 65),
"上位": (65, 80),
"上上位": (80, 100),
}
# Time-based formats. `slots` is a fixed recipe; `variants` (hardcore) is a list of
# alternative recipes, one picked at random per contest at draw time.
CONTEST_TYPES: dict[str, dict] = {
"normal": {
"name": "Euler Regular Contest", "label": "ノーマル", "code": "ERC",
"duration": 100,
"slots": [("簡単", 1), ("中下位", 2), ("中上位", 2), ("上位", 1)],
"perf_cap": None, "loss_floor": 0.0,
},
"hardcore": {
"name": "Euler HardCore Contest", "label": "ハードコア", "code": "EHC",
"duration": 240,
"variants": [[("中位", 10)], [("上上位", 4)]], # random one at draw
"perf_cap": None, "loss_floor": 0.35, # 負けより勝ちを優先
},
"speed": {
"name": "Euler Fast Contest", "label": "スピード", "code": "EFC",
"duration": 30,
"slots": [("簡単", 2), ("中位", 1)],
"perf_cap": 0.6, "loss_floor": 0.0, # 荒稼ぎ防止
},
}
def display_name(name: str, contest_type: str) -> str:
"""'EFC001' -> 'Euler Fast Contest 001' — expand the code to the full format name."""
spec = CONTEST_TYPES.get(contest_type, {})
full = spec.get("name")
if not full:
return name
code = spec.get("code", "")
serial = name[len(code):] if name.startswith(code) else name
return f"{full} {serial}".rstrip()
def resolve_num_problems(contest_type: str, rng: random.Random | None = None) -> int:
"""Problem count fixed at CREATE time — draws the random variant (hardcore) now
so the recruiting copy can commit to it. The draw later reproduces this exact
count via select_problems(..., num=...)."""
spec = CONTEST_TYPES[contest_type]
rng = rng or random.Random()
return sum(n for _, n in _resolve_slots(spec, rng))
def _resolve_slots(spec: dict, rng: random.Random,
num: int | None = None) -> list[tuple[str, int]]:
"""Concrete recipe for one contest — resolves hardcore's random variant.
When `num` is given (a count committed at create time), pick the variant whose
total matches it instead of re-randomizing, so draw honors the announced count."""
if "variants" in spec:
if num is not None:
for v in spec["variants"]:
if sum(n for _, n in v) == num:
return list(v)
return list(rng.choice(spec["variants"]))
return list(spec["slots"])
def _spread_sample(sorted_pool: list, num: int, rng: random.Random) -> list:
"""Pick `num` problems spread across a difficulty-sorted pool (one per sub-band)."""
n = len(sorted_pool)
picks = []
for i in range(num):
band = sorted_pool[i * n // num:(i + 1) * n // num] or sorted_pool
picks.append(band[rng.randrange(len(band))])
return picks
def select_problems(catalog: dict, excluded_ids: set[int], contest_type: str,
rng: random.Random | None = None, num: int | None = None) -> list[dict]:
"""Pick a format's problems from the all-unsolved pool, per recipe slot.
`catalog` maps id -> object with .id/.difficulty/.title. Problems are unique
across slots. `num` (the count committed at create time) pins the variant so the
draw matches the announced count. Raises ValueError if any slot's band lacks
enough unsolved problems."""
if contest_type not in CONTEST_TYPES:
raise ValueError(f"unknown contest_type: {contest_type}")
spec = CONTEST_TYPES[contest_type]
rng = rng or random.Random()
slots = _resolve_slots(spec, rng, num)
used = set(excluded_ids) # never reuse a problem across slots (or an excluded one)
chosen = []
for band, count in slots:
lo, hi = BANDS[band]
pool = [c for pid, c in catalog.items()
if pid not in used and lo <= c.difficulty <= hi]
if len(pool) < count:
raise ValueError(
f"{band}帯({lo}-{hi}%)の未AC問題が{len(pool)}問しか無いにゃ(必要{count}問)。"
"別の形式にするか参加者を見直してにゃ。")
pool.sort(key=lambda c: c.difficulty)
picks = _spread_sample(pool, count, rng)
for c in picks:
used.add(c.id)
chosen.extend(picks)
chosen.sort(key=lambda c: c.difficulty)
return [{"id": c.id, "title": c.title, "difficulty": c.difficulty} for c in chosen]