Skip to content

Commit f78ab6f

Browse files
authored
Merge pull request #1546 from kenkoooo/claude/festive-allen-3girww
Refactor contest type inference and fix JSON serialization
2 parents 08635de + cb97219 commit f78ab6f

3 files changed

Lines changed: 73 additions & 21 deletions

File tree

atcoder-problems-frontend/src/components/ContestLink.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,30 @@ describe("Infer rating change of contests", () => {
5151

5252
expect(getRatedTarget(contest)).toBe(RatedTargetType.All);
5353
});
54+
it("ARC level (hyphen separator)", () => {
55+
const contest = {
56+
...DEFAULT_CONTEST,
57+
rate_change: "1200 - 2799",
58+
};
59+
60+
expect(getRatedTarget(contest)).toBe(2799);
61+
});
62+
it("new ABC level (hyphen separator)", () => {
63+
const contest = {
64+
...DEFAULT_CONTEST,
65+
rate_change: " - 1999",
66+
};
67+
68+
expect(getRatedTarget(contest)).toBe(1999);
69+
});
70+
it("new AGC level (hyphen separator)", () => {
71+
const contest = {
72+
...DEFAULT_CONTEST,
73+
rate_change: "2000 -",
74+
};
75+
76+
expect(getRatedTarget(contest)).toBe(RatedTargetType.All);
77+
});
5478
it("buggy unrated", () => {
5579
const contest = {
5680
...DEFAULT_CONTEST,

atcoder-problems-frontend/src/components/ContestLink.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ export function getRatedTarget(contest: Contest): RatedTarget {
2929
case "All":
3030
return RatedTargetType.All;
3131
default: {
32-
const range = contest.rate_change.split("~").map((r) => r.trim());
32+
// AtCoder switched the rated-range separator from "~" (e.g. " ~ 1999",
33+
// "1200 ~") to "-" (e.g. " - 1999", "2000 -") in late 2025, so accept
34+
// both. The unrated "-" is already handled by the case above.
35+
const range = contest.rate_change.split(/[-~]/).map((r) => r.trim());
3336
if (range.length !== 2) {
3437
return RatedTargetType.Unrated;
3538
}

estimator/main.py

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import logging
55
import math
6+
import re
67
import statistics
78
from collections import defaultdict
89

@@ -409,26 +410,46 @@ def get_current_models() -> dict[str, ProblemModel]:
409410
return {}
410411

411412

413+
def _parse_rated_range(rate_change: str) -> tuple[int, int | None] | None:
414+
"""Parse AtCoder's "rated range" label into (lower, upper) rating bounds.
415+
416+
AtCoder used to render the range with a "~" separator (e.g. " ~ 1999",
417+
"1200 ~ "), but switched to "-" (e.g. " - 1999", "1200 - ") in late 2025.
418+
Both separators are accepted here so contest classification keeps working
419+
across the format change. An open upper bound (rated for "X and above",
420+
e.g. "2000 -" or "All") is represented by ``None``.
421+
422+
Returns ``None`` for unrated contests ("-") or unrecognized labels.
423+
"""
424+
text = rate_change.strip()
425+
if text in ("", "-"):
426+
return None
427+
if text == "All":
428+
return (0, None)
429+
match = re.fullmatch(r"\s*(\d*)\s*[-~]\s*(\d*)\s*", text)
430+
if match is None:
431+
return None
432+
lower = int(match.group(1)) if match.group(1) else 0
433+
upper = int(match.group(2)) if match.group(2) else None
434+
return (lower, upper)
435+
436+
412437
def infer_contest_type(contest: Contest) -> ContestType:
413-
if (
414-
contest.rate_change == "All"
415-
or contest.rate_change == "1200 ~ "
416-
or contest.rate_change == "2000 ~ "
417-
):
418-
return ContestType.AGC
419-
elif (
420-
contest.rate_change == " ~ 2799"
421-
or contest.rate_change == "1200 ~ 2799"
422-
or contest.rate_change == "1200 ~ 2399"
423-
or contest.rate_change == "1600 ~ 2999"
424-
):
425-
return ContestType.NEW_ARC
426-
elif contest.rate_change == " ~ 1999":
427-
return ContestType.NEW_ABC
428-
elif contest.rate_change == " ~ 1199":
429-
return ContestType.OLD_ABC
430-
# rate_change == "-"
431-
elif contest.id.startswith("arc"):
438+
rated_range = _parse_rated_range(contest.rate_change)
439+
if rated_range is not None:
440+
_lower, upper = rated_range
441+
if upper is None:
442+
# Rated for "X and above" (or "All") -> AGC
443+
return ContestType.AGC
444+
elif upper >= 2000:
445+
return ContestType.NEW_ARC
446+
elif upper >= 1200:
447+
return ContestType.NEW_ABC
448+
else:
449+
return ContestType.OLD_ABC
450+
# rate_change == "-" (unrated by AtCoder). Fall back to id-based rules
451+
# for contests held before the official rating system started.
452+
if contest.id.startswith("arc"):
432453
return ContestType.OLD_UNRATED_ARC
433454
elif contest.id.startswith("abc"):
434455
return ContestType.OLD_UNRATED_ABC
@@ -563,8 +584,12 @@ def main():
563584
target_contest_ids = args.target.split(",") if args.target else None
564585
results = run(target_contest_ids=target_contest_ids, overwrite=args.overwrite)
565586
ta = TypeAdapter(dict[str, ProblemModel])
587+
# Omit fields that were not estimated (None) instead of serializing them as
588+
# `null`. The frontend treats a missing key as "not available" but rejects a
589+
# model that carries an explicit `null` (e.g. a problem with a difficulty but
590+
# no time model), which would otherwise hide its difficulty.
566591
s3.Object("kenkoooo.com", "resources/problem-models.json").put(
567-
Body=ta.dump_json(results), ContentType="application/json"
592+
Body=ta.dump_json(results, exclude_none=True), ContentType="application/json"
568593
)
569594

570595

0 commit comments

Comments
 (0)