Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions atcoder-problems-frontend/src/components/ContestLink.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ describe("Infer rating change of contests", () => {

expect(getRatedTarget(contest)).toBe(RatedTargetType.All);
});
it("ARC level (hyphen separator)", () => {
const contest = {
...DEFAULT_CONTEST,
rate_change: "1200 - 2799",
};

expect(getRatedTarget(contest)).toBe(2799);
});
it("new ABC level (hyphen separator)", () => {
const contest = {
...DEFAULT_CONTEST,
rate_change: " - 1999",
};

expect(getRatedTarget(contest)).toBe(1999);
});
it("new AGC level (hyphen separator)", () => {
const contest = {
...DEFAULT_CONTEST,
rate_change: "2000 -",
};

expect(getRatedTarget(contest)).toBe(RatedTargetType.All);
});
it("buggy unrated", () => {
const contest = {
...DEFAULT_CONTEST,
Expand Down
5 changes: 4 additions & 1 deletion atcoder-problems-frontend/src/components/ContestLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export function getRatedTarget(contest: Contest): RatedTarget {
case "All":
return RatedTargetType.All;
default: {
const range = contest.rate_change.split("~").map((r) => r.trim());
// AtCoder switched the rated-range separator from "~" (e.g. " ~ 1999",
// "1200 ~") to "-" (e.g. " - 1999", "2000 -") in late 2025, so accept
// both. The unrated "-" is already handled by the case above.
const range = contest.rate_change.split(/[-~]/).map((r) => r.trim());
if (range.length !== 2) {
return RatedTargetType.Unrated;
}
Expand Down
65 changes: 45 additions & 20 deletions estimator/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import logging
import math
import re
import statistics
from collections import defaultdict

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


def _parse_rated_range(rate_change: str) -> tuple[int, int | None] | None:
"""Parse AtCoder's "rated range" label into (lower, upper) rating bounds.

AtCoder used to render the range with a "~" separator (e.g. " ~ 1999",
"1200 ~ "), but switched to "-" (e.g. " - 1999", "1200 - ") in late 2025.
Both separators are accepted here so contest classification keeps working
across the format change. An open upper bound (rated for "X and above",
e.g. "2000 -" or "All") is represented by ``None``.

Returns ``None`` for unrated contests ("-") or unrecognized labels.
"""
text = rate_change.strip()
if text in ("", "-"):
return None
if text == "All":
return (0, None)
match = re.fullmatch(r"\s*(\d*)\s*[-~]\s*(\d*)\s*", text)
if match is None:
return None
lower = int(match.group(1)) if match.group(1) else 0
upper = int(match.group(2)) if match.group(2) else None
return (lower, upper)


def infer_contest_type(contest: Contest) -> ContestType:
if (
contest.rate_change == "All"
or contest.rate_change == "1200 ~ "
or contest.rate_change == "2000 ~ "
):
return ContestType.AGC
elif (
contest.rate_change == " ~ 2799"
or contest.rate_change == "1200 ~ 2799"
or contest.rate_change == "1200 ~ 2399"
or contest.rate_change == "1600 ~ 2999"
):
return ContestType.NEW_ARC
elif contest.rate_change == " ~ 1999":
return ContestType.NEW_ABC
elif contest.rate_change == " ~ 1199":
return ContestType.OLD_ABC
# rate_change == "-"
elif contest.id.startswith("arc"):
rated_range = _parse_rated_range(contest.rate_change)
if rated_range is not None:
_lower, upper = rated_range
if upper is None:
# Rated for "X and above" (or "All") -> AGC
return ContestType.AGC
elif upper >= 2000:
return ContestType.NEW_ARC
elif upper >= 1200:
return ContestType.NEW_ABC
else:
return ContestType.OLD_ABC
# rate_change == "-" (unrated by AtCoder). Fall back to id-based rules
# for contests held before the official rating system started.
if contest.id.startswith("arc"):
return ContestType.OLD_UNRATED_ARC
elif contest.id.startswith("abc"):
return ContestType.OLD_UNRATED_ABC
Expand Down Expand Up @@ -563,8 +584,12 @@ def main():
target_contest_ids = args.target.split(",") if args.target else None
results = run(target_contest_ids=target_contest_ids, overwrite=args.overwrite)
ta = TypeAdapter(dict[str, ProblemModel])
# Omit fields that were not estimated (None) instead of serializing them as
# `null`. The frontend treats a missing key as "not available" but rejects a
# model that carries an explicit `null` (e.g. a problem with a difficulty but
# no time model), which would otherwise hide its difficulty.
s3.Object("kenkoooo.com", "resources/problem-models.json").put(
Body=ta.dump_json(results), ContentType="application/json"
Body=ta.dump_json(results, exclude_none=True), ContentType="application/json"
)


Expand Down
Loading