From 7b3c9016f10c95ae63525c613adae9524eea9f61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 02:26:34 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20AGC=20=E3=81=AE=20diff=20=E3=81=8C?= =?UTF-8?q?=E6=8E=A8=E5=AE=9A=E3=81=95=E3=82=8C=E3=81=AA=E3=81=84=E5=95=8F?= =?UTF-8?q?=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3=20(rate=5Fchange=20?= =?UTF-8?q?=E3=81=AE=E5=8C=BA=E5=88=87=E3=82=8A=E5=A4=89=E6=9B=B4=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AtCoder は contests/archive のレーティング対象範囲表記の区切りを "~"(例: "1200 ~ ")から "-"(例: "2000 -")へ変更した。 infer_contest_type は "~" 形式の文字列完全一致に依存していたため、 "2000 -" が AGC パターンに一致せず AGC075 以降の AGC が UNRATED 判定となり、 推定対象から除外され problem-models に反映されなくなっていた (#1522)。 ABC/ARC は contest id によるフォールバックがあり除外を免れていたため、 AGC のみが problem-models から欠落していた。 - rate_change を区切り文字・空白に依存せずレーティング範囲としてパースし、 上限なしは AGC、上限値で NEW_ARC / NEW_ABC / OLD_ABC を判定するよう変更。 従来の "~" 形式と新しい "-" 形式の双方に対応する。 - 推定できなかったフィールド (slope/intercept/variance 等) を null として 出力せず省略するよう exclude_none=True を指定。フロントエンドは null を持つ モデルを invalid と判定して difficulty を非表示にしていた (#1507, #1545)。 https://claude.ai/code/session_01Hsq4dCo1x9ve8W8inoWxe2 --- estimator/main.py | 65 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/estimator/main.py b/estimator/main.py index 90be609a..d81df11b 100644 --- a/estimator/main.py +++ b/estimator/main.py @@ -3,6 +3,7 @@ import json import logging import math +import re import statistics from collections import defaultdict @@ -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 @@ -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" ) From cb97219fb241e6f118cab4679352e4929aedfa40 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 03:06:43 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20getRatedTarget=20=E3=82=92=20rate=5F?= =?UTF-8?q?change=20=E3=81=AE=20"-"=20=E5=8C=BA=E5=88=87=E3=82=8A=E3=81=AB?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AtCoder がレーティング対象範囲表記の区切りを "~" から "-" へ変更したため (例: " - 1999", "2000 -", "1200 - 2799")、"~" のみで split していた getRatedTarget では新形式のコンテストがすべて Unrated 扱いになり、 コンテスト名横の色ドットや「Other Rated」系コンテストの ABC-Like / ARC-Like / AGC-Like 分類が正しく行われていなかった。 両方の区切り文字を受け付けるよう変更し、テストを追加した。 https://claude.ai/code/session_01Hsq4dCo1x9ve8W8inoWxe2 --- .../src/components/ContestLink.test.tsx | 24 +++++++++++++++++++ .../src/components/ContestLink.tsx | 5 +++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/atcoder-problems-frontend/src/components/ContestLink.test.tsx b/atcoder-problems-frontend/src/components/ContestLink.test.tsx index 39eb1b80..a21e1ad1 100644 --- a/atcoder-problems-frontend/src/components/ContestLink.test.tsx +++ b/atcoder-problems-frontend/src/components/ContestLink.test.tsx @@ -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, diff --git a/atcoder-problems-frontend/src/components/ContestLink.tsx b/atcoder-problems-frontend/src/components/ContestLink.tsx index 6bbfb841..75b95434 100644 --- a/atcoder-problems-frontend/src/components/ContestLink.tsx +++ b/atcoder-problems-frontend/src/components/ContestLink.tsx @@ -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; }