diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh index efcb7a6f9..a90a93663 100644 --- a/.devcontainer/init-firewall.sh +++ b/.devcontainer/init-firewall.sh @@ -21,7 +21,7 @@ allowed_domains=( vscode.download.prss.microsoft.com # External APIs the app calls (src/lib/constants/urls.ts) kenkoooo.com - judgeapi.u-aizu.ac.jp + onlinejudge.u-aizu.ac.jp # CodeRabbit CLI cli.coderabbit.ai app.coderabbit.ai diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md new file mode 100644 index 000000000..389d094a3 --- /dev/null +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/done.md @@ -0,0 +1,79 @@ +# 完了分(PR0 / PR1 / PR1.5)— 意思決定の要約 + +**状態**: PR0 完了(#4212)・PR1 完了(#4217)・PR1.5 実装済み(ブランチ `#4028`、`471ad20c`)。全体像は [plan.md](./plan.md)、根拠データは [investigation.md](./investigation.md)。 + +コードと git の履歴から読み取れない判断と事実だけを残す。変更したファイルや関数は #4212 / #4217 と `471ad20c` の差分を見る。PR1.5 は [末尾](#pr15--aoj-api-の移転に追従する4028)。 + +## 何をしたか + +- **PR0**: 出典欄の AOJ 分岐を `${contestName} - ${taskTableIndex}(AOJ)` にし、AOJ のコンテスト名ラベルから全角括弧を外した +- **PR1**: ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 コンテスト / 632 問)の `task_table_index` を問題記号にした。移行 SQL は `prisma/migrations/20261024033928_update_aoj_task_table_index/`。管理画面の取り込みでも、この範囲は基本ルールで記号を付ける。表示側の変換(`buildAojLetterMap` / `AOJ_LABEL_OVERRIDES` / `formatAojTitle` / `getTaskLabels`)は削除した + +## 決定と理由 + +| 決定 | 理由 | +| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 移行 SQL は対応表を焼き込まず、基本ルールを SQL で表す | 本番のタスクは管理画面の取り込みで入っており、seed と一致する保証が無い。`VALUES` で書くと本番にしか無い行が残り、`UPDATE` は該当行が無くてもエラーにならないので、取りこぼしが静かに起きる | +| 範囲は `contest_type` ではなく `contest_id` の接頭辞で絞る | 合宿系も模擬国内と同じ `AOJ_JAG` に分類される。`contest_type` だけで絞ると合宿系 467 件まで更新し、title のプレフィックスが表に出る | +| 取り込みでは、対応表ではなく基本ルールで記号を付ける(M5) | 対応表を AOJ client に置く案は、毎年の新しいコンテストで追記が要り、忘れると数値の index が黙って入る。`prisma/tasks.ts` と同じ情報を 2 か所に持つことにもなる | +| 表示側の変換と `getTaskLabels` を削除する(M7 / M10) | DB が記号を持てば変換は恒等になる。インターフェイスの `getTaskLabels` だけを残す案は、上書きする Provider が 0 になり使われないコードが残るので採らなかった | +| ヘッダーの並びは `localeCompare`(M4) | 記号に `Number()` を当てると NaN になり、ソートが効かなくなる | +| AOJ ラベルから全角括弧を外す(M11) | 出典欄の目標書式ではコンテスト名を括弧で囲まない。ラベルは検索・投票・タグ・取り込み画面・提出状況の更新モーダルでも使われており、その表示も変わるが許容した | +| `buildTitleWithTaskIndex` は「外してから付ける」 | AtCoder 側の title は `A. …` を含むので、付けるだけだと二重になる。title にプレフィックスの無い AtCoder の問題は `abs` の practice_1 だけで(本番も同じ)、ABS は index を表示しない | +| `removeTaskIndexFromTitle` は先頭のプレフィックスだけを消す(レビュー) | 記号は title の本文にも現れうる。以前は index が空のとき `Dr. Podboq` → `DrPodboq` のように本文の `. ` まで消していた | +| `AOJ-KUPC2011` の 2279 は title だけを素にし、index(`I`)は触らない | 本番でも index は既に正しい。AOJ に 1 問しか無いので、大学系に範囲を広げるときは基本ルール(`A` になる)の対象から外す | +| PR0 → PR1 → 合宿系の順にする | PR0 は DB に依存しない。PR1 は出典欄の成果がすぐ見え、欠落が少ないので対応表の形式を楽なケースで決められる。逆順はレビュー負荷が低いが、出典欄の反映が遅れ、対応表の形式を最も判断の重い合宿系で作ることになる | + +## 検証で分かったこと + +- **照合**: メンテナのスプレッドシートを 2026-10-21 のスクリーンショットから書き写し、632 問すべてが基本ルールの記号の位置で一致した(補正なし)。ICPC 国内予選 2002 の E(Enclosing Circles)は AOJ に 1123 として存在するが、コンテスト一覧に無いので取り込まれていない(末尾なので記号はずれない。PR3 の対象) +- **照合スクリプト**: `git show 171bf3c9:docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_problem_letters.ts`。`prisma/tasks.ts` を import し、件数が既知の値と違えば止まる。title は大文字小文字・空白・記号を無視して比べ、セルで切れた title は前方一致にする。わざと誤りを入れた 5 種類(順番の入れ替え、途中の title の誤り、途中の欠番、問題数の不足、切れた title の前半の誤り)をすべて検出した。スプレッドシート自体の誤りは検出できない +- **SQL**: `row_number()` は `bigint` を返し、`chr(bigint)` は存在しないので `::int` が要る。`EXPLAIN` は `task` の Seq Scan 2 回 + Hash Join(`contest_type` に索引が無いため想定どおり) +- **トランザクション**: マイグレーションはファイル単位で原子的に適用される(3 文目が失敗するマイグレーションで、1–2 文目も残らないことをローカルで確認) +- **表示の回帰**: 旧コードで適用前のデータを、新コードで適用後のデータを描画し、79 コンテストすべてで列順とセルの文字列が一致した。スクリーンショットでの目視は未実施 +- **テストの検出力**: `verify-test-strength` で 15 個の変異をすべて検出した。seed 整合テストの `ICPC Prelim 2024 F is problem 1677` は単独で検出する変異が無いが、唯一の literal な期待値(対応表とデータが一緒にずれたときの歯止め)なので残した +- **2 日開催の JAG 模擬国内**: AOJ API には `JAGPrelim20162`(2738–2751)のように 2 日を 1 コンテストにしたものがある。取り込みは `contest_id` 単位で振るので A–N になる。seed と本番は `JAGPrelim2016A` / `B` に分かれており、取り込みは既存の問題を skip するので影響しない + +## 残タスク + +PR2 以降の計画には含めていない。 + +- 2016 年の JAG 模擬国内を API から取り込むと、記号が seed と食い違う(Codex / CodeRabbit)。直すなら、取り込みで 2 回開催を `A` / `B` の `contest_id` に分け、それぞれ A から振る。`getContests` 側もそろえる +- seed 整合テストの期待値を `assignProblemLetters` で作っている(Codex)。testing.md の「期待値は literal」に合わない。PR2 では補正したコンテストだけを literal にする(M18)。全件を literal にするかは未判断 +- `src/lib/services/tasks.ts` の `title.replace(task_table_index, …)` は、AOJ の問題に ContestTaskPair を作るとタイトル本文を置換する(Codex)。AOJ の pair は現在 0 件。直すときは先頭の `${index}. ` だけを置換し、`Amidakuji` のような本文と `A. Title` の両方を回帰テストに含める +- コンテスト表のスクリーンショットでの目視確認 + +--- + +## PR1.5 — AOJ API の移転に追従する(#4028) + +### 何をしたか + +- `AOJ_API_BASE_URL` を `https://judgeapi.u-aizu.ac.jp/` から `https://onlinejudge.u-aizu.ac.jp/api/` に変えた。旧ホストは 410 を返し、管理画面からの AOJ の取り込み(Courses / PCK / JAG / ICPC)がすべて失敗していた +- `clients.test.ts` のクライアントを本番の定数から作り、nock で受ける URL(`/api/…`)を期待値として直書きした。`nock.disableNetConnect()` も足した +- 開発コンテナの外向き通信の許可リスト(`.devcontainer/init-firewall.sh`)の `judgeapi.u-aizu.ac.jp` を `onlinejudge.u-aizu.ac.jp` に置き換えた + +### 決定と理由 + +| 決定 | 理由 | +| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 変えるのはベース URL だけ | AOJ Developers の告知(2026-09-07 に Main API が移転)で変わったのはホストだけ。メンテナが新しい API をブラウザで開き、取り込みが使う 8 つのエンドポイント(`courses`、`problems?size=10000`、`challenges` の 6 種別)のパスと応答の形が fixture と同じことを確かめた | +| fixture は取り直さない | 応答の形が同じため。取り直すと統計値(`solvedUser` など)や新しい回の追加で差分が出て、fixture に依存するテストの期待値まで変わる | +| テストは nock のまま、クライアントを本番の定数から作る | CI で実 API を叩かないために nock でモックするのはメンテナの意図どおりで、そのまま残す。問題は、テストが自前の旧ホストからクライアントを作っていて、定数を間違えても通ることだった | +| `nock.disableNetConnect()` を足す | モックを書き忘れたリクエストが、黙って実 API に飛ぶのを防ぐ | +| 許可リストの旧ホストは残さず、新しいホストに置き換える | 旧ホストは 410 を返すだけで使い道が無い。許可先はデータを持ち出せる経路でもあるので、数を増やさない | + +### 検証で分かったこと + +- **応答の違い**: 値の違いは最上位の `largeCl.id` が小文字になったこと(`"JAG"` → `"jag"` など)だけ。コードはここを読まない。fixture に無い回(ICPC 国内予選 2026、JAG 模擬国内 2026、PCK 予選 2026 など)は、fixture の記録(2026-05)の後に AOJ に追加されたもの +- **ベース URL の落とし穴**: `new URL(endpoint, base)` は、ベースの末尾に `/` が無いか、エンドポイントの先頭に `/` があると `/api` を落とす。旧ホストはパスが無かったので表に出ていなかった +- **テストの検出力**: `AOJ_API_BASE_URL` の 4 つの変異(末尾の `/` を外す、旧ホストに戻す、`/api/v1/` にする、`/api` を外す)は、どれも 26 件すべてのテストで検出された +- **問題ページへのリンク**: `https://onlinejudge.u-aizu.ac.jp/problems/{id}` は今のまま開ける(メンテナが確認) +- **合宿系の API**: JAG の filter には summer / winter / spring もあるが、既存の contest_id にそろえるアドホックな対応が多いので、応答の形は意図的に確かめていない([plan-pr2-1.md](./plan-pr2-1.md#csv)) +- **開発コンテナからの 500**: ベース URL を変えた後も、ローカルの管理画面の取り込みが 500 になった。開発サーバーはコンテナの中で動き、外向き通信は許可リスト方式で、新しいホスト(163.143.87.177)が入っていなかった(旧ホストは 163.143.87.219)。サーバーの `fetch` が接続で拒否され、管理画面のアクションが `fail(500)` を返していた。ブラウザはホストの Mac から直接開くので見えていた +- **許可リストの反映**: `init-firewall.sh` は Dockerfile の `COPY` でイメージに入り、起動時に 1 回だけ DNS で IP を引く。編集を反映するには、コンテナの再起動ではなく再ビルドが要る + +### 残っている確認 + +- ~~コンテナを再ビルドした後、ローカルの管理画面で AOJ から取得できること~~ — 2026-10-24 にメンテナが確認した +- staging に入った後の JAG REGIONAL の取り込み(PR2 の前提)が、Vercel 上での実地の確認を兼ねる([plan-pr2.md](./plan-pr2.md#着手前にすること))。念のため、こちらからの取得ツールは新しいホストへの接続を拒否され(`ECONNREFUSED`)、旧ホストには届いて 410 を受けた。新しいホストが一部の送信元を拒んでいる可能性があるので、staging で失敗したら接続元の制限を疑う diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md index aa03d0d6d..9bd6b4871 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/investigation.md @@ -1,31 +1,214 @@ # 調査の根拠データと検討の経緯 -[plan.md](./plan.md) の各主張の裏付け。単独では読まず、plan.md から参照される想定。 +[plan.md](./plan.md) と PR ごとの計画(`plan-pr*.md`)の裏付け。単独では読まず、計画から参照される想定。**計画の決定が常に優先される。** -**plan.md の決定が常に優先される。** ここに残した検討過程は、後から「なぜその案を採らなかったか」を辿るための記録であり、決定そのものではない。 +| 章 | 内容 | 主に使う PR | +| ----------------------------------------------- | --------------------------------------------------- | ------------------- | +| [1](#1-pr2-の照合2026-10-24) | スプレッドシートとの照合、補正、欠落、表示順 | PR2 / 2.1 / 2.5 / 3 | +| [2](#2-実測データ) | 件数、title の形、復元可能性、一意性 | PR2 | +| [3](#3-sql-の解説) | 基本ルールの SQL の読み方と計算量 | PR2 | +| [4](#4-検討の経緯採らなかった案) | 採らなかった案の理由 | — | +| [5](#5-task-テーブルへの書き込み経路の全数調査) | Task への書き込み経路(移行 UPDATE の安全性の根拠) | PR2 / PR3 | +| [6](#6-調査中の誤りと訂正自戒) | 調査中の誤り | — | -2026-10-21 に現行コードと再照合し、行番号と件数を更新した(5 章の既存項目は当時の記録のため据え置き、6・7 項目めを追記)。 +PR0 / PR1 だけに関わる調査(出典欄の呼び出し元、テストの影響範囲など)は、コードと #4212 / #4217 の差分から読めるので削除した。判断の要約は [done.md](./done.md)。 --- -# 1. 実測データ +# 1. PR2 の照合(2026-10-24) + +メンテナのスプレッドシート(スクリーンショット 16 枚、行 2〜1025。上が新しい)と、本番の問題一覧の JAG 模擬地区(スクリーンショット 10 枚、158 問)を目視で突き合わせた。**目視による暫定の結果**であり、確定は [plan-pr2.md](./plan-pr2.md#2-0-照合) のスクリプトで行う。 + +スプレッドシートで折りたたまれた行(40–50 ほか 19 か所)は JAG 模擬国内だけで、合宿系・模擬地区は含まない(メンテナに確認済み)。 + +## 対応の確認 + +- seed の合宿系 50 コンテストは、すべてスプレッドシートにある。問題名は記号の位置ごとに一致した +- 本番の模擬地区 16 コンテスト(2005〜2017 と 2020〜2022)も、すべてある。問題 ID は 2024〜3357 で、すべて数値 +- スプレッドシートにあって DB に無いのは 10 コンテスト([下記](#db-に無いコンテスト)) + +## 記号の補正が要るコンテスト + +基本ルール(問題 ID の昇順に A, B, C…)と、スプレッドシートの位置が食い違うコンテスト。いずれも先頭か途中に、AOJ に無い問題がある。末尾だけの欠落(模擬地区 2008 の J)は記号がずれないので含めない。 + +| contest_id | 問題 ID → 記号 | 欠けている記号 | +| -------------------- | ---------------------------------------------------------- | ------------------- | +| `JAGSummer2006-day1` | 2057 → B | A, C | +| `JAGSummer2006-day3` | 2067〜2072 → B〜G | A | +| `JAGWinter2006-day2` | 2073 → D、2074 → F | A, B, C, E, G, H, I | +| `JAGWinter2006-day3` | 2075 → A、2076 → D、2077 → G、2078 → H | B, C, E, F, I, J | +| `JAGWinter2007-day3` | 2122〜2125 → A〜D、2126〜2130 → F〜J | E | +| `JAGSummer2024-day3` | 3637〜3642 → A〜F、3644〜3647 → H〜K | G | +| `JAGRegional2005` | 2024 → A、2025 → B、2026 → D、2027 → E、2028 → G、2029 → I | C, F, H | +| `JAGRegional2013` | 2568〜2574 → D〜J | A, B, C | + +8 コンテスト / 45 行(合宿系 32、模擬地区 13)。基本ルールから値が変わるのは 32 行(合宿系 21、模擬地区 11)。 + +## 欠落の一覧(PR3) + +スプレッドシートで「AOJ 未収録」と書かれている問題。22 問(合宿系 18、模擬地区 4)。 + +| コンテスト | 記号と問題名 | +| -------------------- | -------------------------------------------------------------------------------------------------------------------- | +| JAG 夏合宿 2006 Day1 | A Monkey Business、C Sort that Queue | +| JAG 夏合宿 2006 Day3 | A Rolling Cubes | +| JAG 冬合宿 2006 Day2 | A Cells、B Japan、C Maximum、E Payment System、G Roof、H Seminar Room、I String Compression | +| JAG 冬合宿 2006 Day3 | B Widget Factory、C Hills、E Safe Packing、F The "Shortest" Pair of Paths、I Counting Triangles、J Wireing Assistant | +| JAG 冬合宿 2007 Day3 | E Networking Company | +| JAG 夏合宿 2024 Day3 | G Interactive String Guessing | +| JAG 模擬地区 2005 | C Geometry?! Why Not??、F University Rankings、H Tiling Polygons | +| JAG 模擬地区 2008 | J Colony Maintenance | + +一覧に無い問題について、メンテナが AOJ で確かめた結果(2026-10-24): + +- **JAG 夏合宿 2024 Day3 の G(Interactive String Guessing)**: スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 の問題ページがある(`onlinejudge.u-aizu.ac.jp/challenges/search/titles/3643`)。コンテスト一覧から外れているだけで、ICPC 国内予選 2002 の E(AOJ 1123)と同じ種類。**スプレッドシートの「AOJ 未収録」は、AOJ に無いことの保証にならない** +- **JAG 模擬地区 2013 の A Broken Audio Signal / B Restore Calculation / C SIRO Challenge**: スプレッドシートではリンク付き(未収録の表記が無い)で、リンク先は AOJ 2565 / 2566 / 2567。ただし AOJ 側で title が見つからない。AOJ の模擬地区 2013 の一覧(本番の 7 問、2568〜2574)にも無い + +どちらも PR2 の記号(2013 は D〜J、2024 Day3 は H〜K)には影響しない。AOJ へのアクセスは、この環境からは拒否された(`judgeapi.u-aizu.ac.jp` が HTTP 410)。 + +## DB に無いコンテスト + +PR2.1 で入れる。contest_id と問題番号は 2026-10-24 にメンテナから受け取った。 + +| スプレッドシートの見出し | contest_id | 問題(task_id) | 入手元 | +| --------------------------------------------------- | ------------------------- | --------------------------------- | ------- | +| JAG 夏合宿 2026 Day1(JAG) | `JAGSummer2026-day1` | 3684〜3695 | AOJ | +| JAG 夏合宿 2026 Day2(有志)(AMATSUKAZE2025) | `JAGSummer2026-day2` | 3696〜3707 | AOJ | +| JAG 夏合宿 2026 Day3(JAG) | `JAGSummer2026-day3` | 3708〜3719 | AOJ | +| JAG 夏合宿 2025 Day1(有志) | `JAGSummer2025-day1` | 3648〜3659 | AOJ | +| JAG 夏合宿 2025 Day2(JAG) | `JAGSummer2025-day2` | 3660〜3671 | AOJ | +| JAG 夏合宿 2025 Day3(有志) | `JAGSummer2025-day3` | 3672〜3683 | AOJ | +| JAG 夏合宿 2018 Day2(有志)(in AtCoder) | `jag2018summer-day2` | `jag2018summer_day2_a`〜`k` | AtCoder | +| JAG 夏合宿 2017 Day1(有志)(in AtCoder) | `jag2017summer-day1` | `jag2017summer_day1_a`〜`k` | AtCoder | +| JAG 夏合宿 2015 Day3(過去問)(in AtCoder) | `jag2015summer-day3` | `icpc2015summer_day3_a`〜`j` | AtCoder | +| JAG 夏合宿 2013 Day2 WarmUp(過去問)(in AtCoder) | `jag2013summer-warmingup` | `icpc2013summer_warmingUp_a`〜`j` | AtCoder | + +計 114 問(AOJ 72 は各 12 問、AtCoder 42)。いずれも「AOJ 未収録」の表記は無く、受け取った問題名はスプレッドシートと記号の位置ごとに一致した(目視)。AOJ の 6 コンテストは問題 ID が連番で、記号は基本ルールと一致する。 + +AtCoder Problems の `contests.json` には、ほかに `JAG2013Spring` / `JAG2014Spring` もある。AOJ の `JAGSpring2013` / `JAGSpring2014` と同じコンテストなので対象外とする。 + +## 表示順(76 コンテスト) + +PR2.5 の配列の元。スプレッドシートの行順そのまま(上が新しい)。括弧書きは M16 に従う(「過去問」「in AtCoder」「AMATSUKAZE2025」は書かない)。「DB」列の「無し」は PR2.1 で入るもの、「補正」は上の 8 コンテスト。 + +| # | 行 | contest_id | 括弧書き | DB | +| --- | ---- | ------------------------------------ | ----------------------- | ---- | +| 1 | 2 | `JAGSummer2026-day1` | JAG | 無し | +| 2 | 15 | `JAGSummer2026-day2` | 有志 | 無し | +| 3 | 28 | `JAGSummer2026-day3` | JAG | 無し | +| 4 | 51 | `JAGSummer2025-day1` | 有志 | 無し | +| 5 | 64 | `JAGSummer2025-day2` | JAG | 無し | +| 6 | 77 | `JAGSummer2025-day3` | 有志 | 無し | +| 7 | 100 | `JAGSummer2024-day2` | 有志 | | +| 8 | 115 | `JAGSummer2024-day3` | JAG | 補正 | +| 9 | 137 | `JAGSummer2023-day2` | 有志 | | +| 10 | 150 | `JAGSummer2023-day3` | JAG | | +| 11 | 171 | `JAGRegional2022` | JAG | | +| 12 | 193 | `JAGRegional2021` | JAG | | +| 13 | 214 | `JAGRegional2020` | JAG | | +| 14 | 235 | `JAGSummer2019-day1` | JAG | | +| 15 | 247 | `JAGSummer2019-day2` | 有志 | | +| 16 | 258 | `JAGSummer2019-day3` | 有志 | | +| 17 | 278 | `jag2018summer-day2`(AtCoder) | 有志 | 無し | +| 18 | 290 | `JAGSummer2018-day3` | JAG | | +| 19 | 311 | `JAGRegional2017` | JAG | | +| 20 | 323 | `jag2017summer-day1`(AtCoder) | 有志 | 無し | +| 21 | 335 | `JAGSummer2017-day3` | JAG | | +| 22 | 356 | `JAGRegional2016` | in JAG 夏合宿 2016 Day3 | | +| 23 | 384 | `JAGRegional2015` | JAG | | +| 24 | 396 | `JAGSummer2015-day2` | 有志 | | +| 25 | 408 | `jag2015summer-day3`(AtCoder) | なし | 無し | +| 26 | 419 | `JAGSummer2015-day4` | JAG | | +| 27 | 438 | `JAGSpring2015` | JAG | | +| 28 | 451 | `JAGSummer2014-day2` | 有志 | | +| 29 | 462 | `JAGRegional2014` | in JAG 夏合宿 2014 Day3 | | +| 30 | 474 | `JAGSummer2014-day4` | 有志 | | +| 31 | 493 | `JAGSpring2014` | JAG | | +| 32 | 504 | `JAGRegional2013` | JAG | 補正 | +| 33 | 515 | `jag2013summer-warmingup`(AtCoder) | なし | 無し | +| 34 | 526 | `JAGSummer2013-day2` | 有志 | | +| 35 | 534 | `JAGSummer2013-day3` | 有志 | | +| 36 | 547 | `JAGSummer2013-day4` | JAG | | +| 37 | 566 | `JAGSpring2013` | JAG | | +| 38 | 577 | `JAGRegional2012` | JAG | | +| 39 | 588 | `JAGSummer2012-day2` | 有志 | | +| 40 | 599 | `JAGSummer2012-day3A` | 有志 | | +| 41 | 606 | `JAGSummer2012-day3B` | 有志 | | +| 42 | 614 | `JAGSummer2012-day4` | JAG | | +| 43 | 633 | `JAGSpring2012` | JAG | | +| 44 | 644 | `JAGWinter2011` | JAG | | +| 45 | 655 | `JAGRegional2011` | JAG | | +| 46 | 674 | `JAGSummer2011-day2` | 有志 | | +| 47 | 685 | `JAGSummer2011-day3` | JAG | | +| 48 | 696 | `JAGSummer2011-day4` | 有志 | | +| 49 | 707 | `JAGWinter2010-day3` | 有志 | | +| 50 | 718 | `JAGWinter2010-day4` | JAG | | +| 51 | 729 | `JAGRegional2010` | JAG | | +| 52 | 740 | `JAGSummer2010-day2` | 有志 | | +| 53 | 751 | `JAGSummer2010-day3` | 有志 | | +| 54 | 762 | `JAGSummer2010-day4` | JAG | | +| 55 | 781 | `JAGWinter2009` | JAG | | +| 56 | 792 | `JAGRegional2009` | JAG | | +| 57 | 803 | `JAGSummer2009-day2` | JAG | | +| 58 | 814 | `JAGSummer2009-day3` | JAG | | +| 59 | 832 | `JAGWinter2008-day2` | JAG | | +| 60 | 844 | `JAGWinter2008-day3` | JAG | | +| 61 | 856 | `JAGRegional2008` | JAG | | +| 62 | 867 | `JAGSummer2008-day2` | JAG | | +| 63 | 878 | `JAGSummer2008-day4` | JAG | | +| 64 | 894 | `JAGWinter2007-day2` | JAG | | +| 65 | 905 | `JAGWinter2007-day3` | JAG | 補正 | +| 66 | 916 | `JAGRegional2007` | JAG | | +| 67 | 926 | `JAGSummer2007-day1` | JAG | | +| 68 | 932 | `JAGSummer2007-day2` | JAG | | +| 69 | 942 | `JAGSummer2007-day3` | JAG | | +| 70 | 957 | `JAGWinter2006-day2` | JAG | 補正 | +| 71 | 967 | `JAGWinter2006-day3` | JAG | 補正 | +| 72 | 978 | `JAGRegional2006` | JAG | | +| 73 | 988 | `JAGSummer2006-day1` | JAG | 補正 | +| 74 | 992 | `JAGSummer2006-day2` | JAG | | +| 75 | 1002 | `JAGSummer2006-day3` | JAG | 補正 | +| 76 | 1017 | `JAGRegional2005` | JAG | 補正 | + +内訳: 合宿系 50、模擬地区 16、DB に無いもの 10(AOJ 6、AtCoder 4)。 + +### 年の中での並び + +contest_id から並びを導けない例(スプレッドシートの上から順)。冬合宿・冬コンテストは、年の数字が同じ夏合宿・模擬地区より上にある。 + +- 2014: 夏合宿 Day2 → 模擬地区(夏合宿の Day3 として開催)→ 夏合宿 Day4 → 春コンテスト 2014 +- 2012: 模擬地区 2012 → 夏合宿 2012 → 春コンテスト 2012 → 冬コンテスト 2011 → 模擬地区 2011 → 夏合宿 2011 +- 2010: 冬合宿 2010 → 模擬地区 2010 → 夏合宿 2010 + +## 名称の違い + +| スプレッドシート | 現行のコード(`JAG_TRANSLATIONS`) | 対象 | +| ---------------- | ---------------------------------- | ------------------------------------------------- | +| 春コンテスト | 春合宿 | `JAGSpring2012`〜`2015` | +| 冬コンテスト | 冬合宿 | `JAGWinter2009` / `JAGWinter2011`(日の無いもの) | +| 模擬アジア地区 | 模擬地区 | `JAGRegional*` | + +テーブル名は現行のコードに合わせる(M16)。出典欄と同じ名称になり、違うのは括弧書きの有無だけになる。 -## 対象規模 +--- -`prisma/tasks.ts` の実データ集計。**1,099 問 / 129 コンテスト**(2026-10-21 の再集計でも内訳まで同値)。 +# 2. 実測データ -| 種別 | コンテスト | 問題数 | Provider | -| ------------ | ---------- | ------ | -------- | -| ICPCPrelim | 29 | 197 | あり | -| ICPCRegional | 27 | 266 | あり | -| JAGPrelim | 23 | 169 | あり | -| JAGSummer | 36 | 338 | **なし** | -| JAGWinter | 10 | 87 | **なし** | -| JAGSpring | 4 | 42 | **なし** | +## 対象規模 -合宿系(Summer / Winter / Spring)は **seed にデータが入っているが Provider が無い**ためコンテスト表に出ていない。移行対象からは外せない。 +`prisma/tasks.ts` の集計(2026-10-21)と、本番だけにある JAG 模擬地区。 -全 1,099 問で `problem_index` == `task_id`(AOJ 問題ID)。 +| 種別 | コンテスト | 問題数 | 所在 | PR | +| ------------ | ---------- | ------ | ----------- | --- | +| ICPCPrelim | 29 | 197 | seed と本番 | PR1 | +| ICPCRegional | 27 | 266 | seed と本番 | PR1 | +| JAGPrelim | 23 | 169 | seed と本番 | PR1 | +| JAGSummer | 36 | 338 | seed と本番 | PR2 | +| JAGWinter | 10 | 87 | seed と本番 | PR2 | +| JAGSpring | 4 | 42 | seed と本番 | PR2 | +| JAGRegional | 16 | 158 | 本番のみ | PR2 | + +PR1 / PR2 の合計は 1,257 問 / 145 コンテスト。PR1 の前は、seed の 1,099 問すべてで `problem_index` == `task_id`(AOJ 問題 ID)だった。 ## 本番の件数(2026-10-21) @@ -39,35 +222,57 @@ | `AOJ_COURSES` | 8 / 247 | 0 / 0 | | `AOJ_PCK` | 1 / 12 | 0 / 0 | -`AOJ_ICPC` / `AOJ_JAG` を `contest_id` の接頭辞で分けると次のとおり。`AOJ_JAG` の差(16 コンテスト / 158 問)はすべて JAG 模擬地区(管理画面から取り込める。seed には 0 件)で、PR1 / PR2 の対象範囲は本番と seed で一致する。 - -| 接頭辞 | 本番(コンテスト / 問題) | seed(コンテスト / 問題) | -| -------------- | ------------------------- | ------------------------- | -| `ICPCPrelim` | 29 / 197 | 29 / 197 | -| `ICPCRegional` | 27 / 266 | 27 / 266 | -| `JAGPrelim` | 23 / 169 | 23 / 169 | -| `JAGRegional` | 16 / 158 | 0 / 0 | -| `JAGSummer` | 36 / 338 | 36 / 338 | -| `JAGWinter` | 10 / 87 | 10 / 87 | -| `JAGSpring` | 4 / 42 | 4 / 42 | +`AOJ_JAG` の差(16 コンテスト / 158 問)はすべて JAG 模擬地区(管理画面から取り込める。seed には 0 件)。接頭辞ごとに分けると、ほかはすべて本番と seed で一致した。 AtCoder 側(`AOJ_*` 以外)で title が `${task_table_index}. ` で始まらない問題は、本番でも `abs` の practice_1 の 1 件だけだった。 +## PR2 の範囲の本番と staging(2026-10-24) + +SQL A([plan-pr2.md](./plan-pr2.md#着手前の確認2026-10-24-に回答済み))の結果。 + +| 範囲 | 本番 | staging | index が問題 ID のまま | title が問題 ID 付き | +| -------- | -------- | ---------- | ---------------------- | -------------------- | +| Regional | 16 / 158 | **2 / 16** | 全行 | 0 行 | +| Spring | 4 / 42 | 4 / 42 | 全行 | 全行 | +| Summer | 36 / 338 | 36 / 338 | 全行 | 全行 | +| Winter | 10 / 87 | 10 / 87 | 全行 | 全行 | + +- staging には模擬地区が 2 コンテストしか無い。PR2 の前に staging で取り込み、本番とそろえる +- 本番の模擬地区 158 問(SQL B)は、AOJ の一覧のスクリーンショットと問題名・問題 ID がすべて一致した。grade が付いているのは 6 問 +- 本番の 3355 の title の末尾にタブがある(`Most distant point from the stations\t`)。前後に空白のある title はほかにもある(下記) + +## 前後に空白のある title + +本番で `title <> btrim(title, E' \t\r\n')` の行を数えると 46 件あった(2026-10-24)。 + +| 範囲 | 件数 | 例 | +| --------------------------------------- | ---- | -------------------------------------------------------- | +| JAG 模擬国内(3202 / 3250 / 3299) | 3 | `"Chains of Explosions "`(末尾の空白) | +| JAG 模擬地区(3355) | 1 | `"Most distant point from the stations\t"`(末尾のタブ) | +| AtCoder(ABC / AGC / ARC / JOI / TTPC) | 42 | `"A. Weather Prediction "`(末尾の空白) | + +- **出どころは上流のデータ**。AOJ の API が返す名前そのものに空白がある(2026-05 に記録した fixture の `jag_prelim` / `jag_regional` にも同じ空白がある)。AtCoder Problems も同様 +- **再発はしない**。取り込みが title を `trim()` するようになった(2026-05-18、`0e1ec4e7`)。46 件はそれより前に入った行。seed の値は trim 済み +- **実害は無い**。HTML の表示では末尾の空白が潰れる。title を比べるコードは `removeTaskIndexFromTitle` の `startsWith`(先頭の `A. ` を見るだけ)しか無く、完全一致で比べる箇所は無い。検索は部分一致なので、空白で終わる検索語でなければ影響しない +- **起こりうる害**: 将来 title の完全一致で照合する処理(#4018 のコンテスト情報の同期など)を書くと、この 46 件だけが食い違う。CSV の `id` は `sha256(contest_id + title)` だが、既存行の `id` は再計算されないので関係ない + +PR2 では直さず、残タスクとした([plan.md](./plan.md#スコープ外))。 + ## 欠落は機械的に検出できない -AOJ 問題ID の連番性を調べると 129 コンテスト中 128 が連番。**だが欠落が無い証拠にならない。** AOJ に登録されていない問題なら ID も無いため、残った問題の ID は連番のままになる。AOJ に問題はあるのにコンテスト一覧(取り込み元)から外れている場合も同じである。ICPC 国内予選 2002 の E(Enclosing Circles)は AOJ に問題 1123 として存在するが一覧に無く、取り込まれていない。2002 年の A〜D は 1119〜1122 で連番のままであり、1123 の次の 1124 は 2003 年の A である。 +AOJ 問題 ID の連番性を調べると、seed の 129 コンテスト中 128 が連番。**だが欠落が無い証拠にならない。** AOJ に登録されていない問題なら ID も無いため、残った問題の ID は連番のままになる。AOJ に問題はあるのにコンテスト一覧(取り込み元)から外れている場合も同じである。ICPC 国内予選 2002 の E(Enclosing Circles)は AOJ に問題 1123 として存在するが一覧に無く、取り込まれていない。2002 年の A〜D は 1119〜1122 で連番のままであり、1123 の次の 1124 は 2003 年の A である。 -データから欠落を示唆するのは**問題数の異常**の方: +連番でない 1 コンテストは `JAGSummer2024-day3`(3643 が無い)で、スプレッドシートの G(Interactive String Guessing)に当たる。スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 はあり、コンテスト一覧から外れているだけだった。 -| コンテスト | 問題数 | 同種の中央値 | -| -------------------------- | ------ | ------------ | -| `JAGSummer2006-day1` | 1 | 10 | -| `JAGWinter2006-day2` | 2 | 10 | -| `JAGWinter2006-day3` | 4 | 10 | -| `JAGSummer2007-day1` | 5 | 10 | -| `ICPCPrelim1998/2000/2002` | 各 4 | 8 | +データから欠落を示唆するのは**問題数の異常**の方だが、それも決め手にならない: -古い回ほど少ない。ただし**当時は本当に問題数が少なかった**可能性も同程度にあり、データだけでは判別できない。 +| コンテスト | 問題数 | 同種の中央値 | スプレッドシートとの照合 | +| -------------------------- | ------ | ------------ | ------------------------------------------------------------- | +| `JAGSummer2006-day1` | 1 | 10 | 公式は 3 問。A・C が AOJ 未収録 | +| `JAGWinter2006-day2` | 2 | 10 | 公式は 9 問。7 問が AOJ 未収録 | +| `JAGWinter2006-day3` | 4 | 10 | 公式は 10 問。6 問が AOJ 未収録 | +| `JAGSummer2007-day1` | 5 | 10 | 公式も 5 問(欠落なし) | +| `ICPCPrelim1998/2000/2002` | 各 4 | 8 | 1998 / 2000 は公式も 4 問。2002 は E が一覧に無い(AOJ 1123) | ## `title` の持ち方が AOJ 内で割れている @@ -75,32 +280,33 @@ AOJ 問題ID の連番性を調べると 129 コンテスト中 128 が連番。 | ------------------------------------------ | ----- | ------------------------------- | --------------------------------- | | ICPC国内 / アジア / JAG模擬国内(**PR1**) | 632 | 素 | `'Peculiar Protocol'` | | JAG 合宿系(**PR2**) | 467 | **数値ID のプレフィックス付き** | `'2389. Asynchronous Exceptions'` | +| JAG 模擬地区(**PR2**、本番のみ) | 158 | 素 | `'Restriction Enzyme'` | | AtCoder 等(対象外) | 1,366 | index のプレフィックス付き | `'EX8. たくさんのA足すB問題'` | -632 / 467 の分かれ目が PR1 / PR2 の境界と**完全に一致**する。 +**プレフィックスは合宿系の 467 件すべてで `task_id` と完全に一致していた(不一致 0 件)。** したがって除去も復元も機械的に安全に行える。 + +模擬地区は管理画面の取り込みで本番に入っており、取り込みは導入時(2024-10、`7dcc5e4c`)から `problem.name` をそのまま title にしている。SQL A でもプレフィックスは 0 行だった(本番・staging)。 -退行の機序: +退行の機序(title を直さずに index だけ記号にした場合): ``` 現在: index='2389', title='2389. Asynchronous Exceptions' - → replace('2389. ', '') → 'Asynchronous Exceptions' 正常 + → 先頭の '2389. ' を除去 → 'Asynchronous Exceptions' 正常 PR2 後: index='A', title='2389. Asynchronous Exceptions' - → replace('A. ', '') → 空振り + → 先頭の 'A. ' を除去 → 空振り → '2389. Asynchronous Exceptions' が露出 退行 ``` -`removeTaskIndexFromTitle`(`src/lib/utils/task.ts:123`)は `${index}. ` の決め打ち置換のため、 `task_table_index` だけを更新すると空振りする。 - -**プレフィックスは 467 件すべてで `task_id` と完全に一致していた(不一致 0 件)。** したがって除去も復元も機械的に安全に行える。 - ## 復元可能性の実測 | 対象 | 件数 | 復元 | | ------------------------ | ----- | ---------------------------------------------------------------------------------------- | -| AOJ ICPC/JAG(更新対象) | 1,099 | **無条件に可能**。`task_table_index == task_id` が例外ゼロで成立 | +| AOJ ICPC/JAG(更新対象) | 1,099 | **無条件に可能**。`task_table_index == task_id` が例外ゼロで成立(seed、PR1 の前) | | その他(AtCoder 等) | 1,367 | **条件付き**。84.2%(1,151 件)は `task_id` のサフィックスから導出できるが、216 件は例外 | +本番だけにある模擬地区 158 件も、SQL A で全行 `task_table_index == task_id` だった(2026-10-24)。 + 導出できない 216 件の内訳: `abc` 41(`abc318_h` に対し index は `Ex`)、`typical` 32、 `APG` 27(`APG4bPython_co` に対し index は `EX8`)、`arc` 27、`tdpc` 20、 `math-and-algorithm` 15、ほか(`task_id` にサフィックスが無い AOJ 大学系 29 件を含む)。 `abc*_h → Ex` は規則として書けるため理論上は 90% 超まで導出できるが、 **事故対応の手段としては成立しない**。理由は 2 つ。 @@ -108,13 +314,11 @@ PR2 後: index='A', title='2389. Asynchronous Exceptions' 1. 「84% は規則で戻せるが 16% は個別対応」という状態は、復旧手順として使えない 2. `task_table_index` を上書きした後では、**どの行が元から `A` だったのかを区別できない** -AOJ ICPC/JAG の 1,099 件が「無条件に一括復元できる」のとは質的に異なる。 - ## 既存 DB は自動更新されない -- seed: `prisma/seed.ts:192-199` で `findUnique(task_id)` → 既存なら **skip** -- インポート: `src/lib/services/tasks.ts:175-180` で `isExistsTask(task_id)` → 既存なら **silent return** -- `updateTask`(`src/lib/services/tasks.ts:206`)は `grade` しか触らない +- seed: `prisma/seed.ts` で `findUnique(task_id)` → 既存なら **skip** +- 取り込み: `src/lib/services/tasks.ts` の `createTask` で `isExistsTask(task_id)` → 既存なら **silent return**。`classifyContest` が `null` を返す contest_id も黙って捨てる +- `updateTask` は `grade` しか触らない ## 一意性は壊れない @@ -125,52 +329,13 @@ AOJ ICPC/JAG の 1,099 件が「無条件に一括復元できる」のとは質 | `id` (@id @unique) | `sha256(contest_id + task.id)` | | `task_id` (@unique) | `task.id` をそのまま | -どちらも入力は contest_id と AOJ 問題ID のみ。`problem_index` は混ざらない。FK も全て `task_id` 参照(`TaskTag` / `TaskAnswer` / `WorkBookTask` / `VoteGrade` / `VotedGradeCounter` / `VotedGradeStatistics`)。ハッシュの `id` を FK にしている箇所は無い。 - -**性質の変化**: `task_table_index` は現状「全 DB でユニーク」だが変更後は **「コンテスト内でのみユニーク」**になる。unique 制約は無く、コンテスト横断で index をキーにしている箇所も無い(`getTaskLabels` は contestId で名前空間を切り、 `sortAojHeaderIds` は単一コンテスト内で閉じる)ため問題ない。 - -## 変更しても安全な箇所 - -- **`formatAojTitle`** … PR1 範囲は `title` に index プレフィックスが無いため二重化しない -- **`getTaskUrl(contest_id, task_id)`** … index 非依存 -- **`ContestTaskPair` のマージ** … `prisma/contest_task_pairs.ts` に ICPC/JAG のエントリは **0 件** -- **`columnWrapThreshold: 6`** … 問題数は変わらないので影響なし - -## 出典欄の呼び出し元 - -`addContestNameToTaskIndex`(`src/lib/contests/utils/task_index_label.ts:4-12`)は 1 関数で、呼び出し元 6 箇所は自動的に揃う。 - -- `src/lib/components/TaskList.svelte:124` -- `src/lib/components/TaskListSorted.svelte:55` -- `src/lib/components/TaskForm.svelte:51` -- `src/routes/(admin)/tasks/grade/_components/TaskGradeTable.svelte:106` -- `src/features/workbooks/components/detail/WorkBookTasksTable.svelte:202` -- `src/routes/workbooks/[slug]/+page.svelte:52` - -`getAojContestLabel`(`src/lib/contests/utils/labels/aoj.ts:52`)と `getAojUniversityContestLabel`(同 `:63`)から全角括弧を外す場合(M11)に巻き込まれる箇所 — `getContestNameLabel` を AOJ コンテストに直接使っている: +どちらも入力は contest_id と AOJ 問題 ID のみ。`problem_index` は混ざらない。FK も全て `task_id` 参照(`TaskTag` / `TaskAnswer` / `WorkBookTask` / `VoteGrade` / `VotedGradeCounter` / `VotedGradeStatistics`)。ハッシュの `id` を FK にしている箇所は無い。 -- `src/lib/utils/task_filter.ts:39` — 検索マッチ -- `src/lib/components/SubmissionStatus/UpdatingModal.svelte:69` — `"{label} - {title}"` (現状 `"(…) - title"` で元々不格好) -- `src/lib/components/TagForm.svelte:99` -- `src/routes/votes/+page.svelte:114` -- `src/routes/(admin)/tasks/_components/TaskTableForImport.svelte:53` - -## テストの影響範囲 - -| ファイル | 行数 | 関連 | -| ---------------------------- | ---- | --------------------------- | -| `aoj_icpc_providers.test.ts` | 902 | comparator / ヘルパー | -| `aoj_jag_providers.test.ts` | 455 | 同上 | -| `aoj_labels.test.ts` | 186 | 同上 | -| `task_index_label.test.ts` | 213 | 出典欄 | -| `labels/index.test.ts` | 184 | 出典欄(M11) | -| `labels/aoj.test.ts` | 82 | 出典欄(M11、大学系を含む) | - -期待値の多くは fixture 側にある。`src/lib/contests/fixtures/contest_name_labels.ts`(349 行)と `contest_name_and_task_index.ts`(986 行)の AOJ 部分(大学系を含む)が全角括弧と数値 index を前提にしている。 +**性質の変化**: `task_table_index` は「全 DB でユニーク」から**「コンテスト内でのみユニーク」**になる。unique 制約は無く、コンテスト横断で index をキーにしている箇所も無い(`sortAojHeaderIds` は単一コンテスト内で閉じる)ため問題ない。 --- -# 2. SQL の解説 +# 3. SQL の解説 ## SQL の読み方 @@ -189,17 +354,15 @@ AOJ ICPC/JAG の 1,099 件が「無条件に一括復元できる」のとは質 これが分かれば `UPDATE` 側は単純で、「`task` と `letters` を `task_id` で突き合わせ、一致した行に `letter` を書き込む」だけ。 -| 句 | 意味 | -| ------------------------------ | ---------------------------------------------------------------------- | -| `PARTITION BY contest_id` | **コンテストごとにグループ分けし、番号を 1 から振り直す** | -| `ORDER BY task_id::int` | 各グループ内で問題ID の数値昇順に並べる | -| `row_number()` | 並べた順に 1, 2, 3… を返す(**1 始まり**) | -| `chr(64 + n)` | 文字コード変換。`chr(65)` = `A` なので `n=1 → A`、`n=2 → B` | -| `contest_id LIKE 'JAGPrelim%'` | `AOJ_JAG` には合宿系も含まれるため、PR1 では接頭辞で模擬国内だけに絞る | - -`64` は 1 始まりの分を引いた値。現行の `buildAojLetterMap` は 0 始まりのループ変数で `String.fromCharCode(65 + i)` としており、結果は同じになる。 +| 句 | 意味 | +| -------------------------------- | -------------------------------------------------------------------------- | +| `PARTITION BY contest_id` | **コンテストごとにグループ分けし、番号を 1 から振り直す** | +| `ORDER BY task_id::int` | 各グループ内で問題 ID の数値昇順に並べる | +| `row_number()` | 並べた順に 1, 2, 3… を返す(**1 始まり**、`bigint` なので `::int` が要る) | +| `chr(64 + n)` | 文字コード変換。`chr(65)` = `A` なので `n=1 → A`、`n=2 → B` | +| `contest_id ~ '^JAG(Summer\|…)'` | `AOJ_JAG` には PR1 で済んだ模擬国内も含まれるため、接頭辞で範囲を絞る | -`PARTITION BY` が無いと対象の全問題(PR1 なら 632 問)に通し番号が振られ、2 つ目以降のコンテストが `Z` を超えて壊れる。 +`PARTITION BY` が無いと対象の全問題に通し番号が振られ、2 つ目以降のコンテストが `Z` を超えて壊れる。 > **SQL は書かれた順に評価されない。** 論理的な処理順序は `FROM`(サブクエリと別名の確定)→ `WHERE` → `SELECT` / `SET`。このため別名を定義より前に参照しているように見えても正しい(`SELECT u.name FROM users AS u` と同じ理屈)。 @@ -207,27 +370,27 @@ AOJ ICPC/JAG の 1,099 件が「無条件に一括復元できる」のとは質 ## 計算量 — O(N²) にはならない -- 外側の `AND (t.contest_type = … OR …)` は **join ではなくフィルタ**。1 行あたり O(1) で評価され、行数を減らす方向にしか働かない。`AND` は「同じ 1 行に対する複数の判定」であって集合同士の組み合わせではない -- join 側も、`task_id` に UNIQUE インデックスがある(`prisma/migrations/0_init/migration.sql:134` `task_task_id_key`)ため、Nested Loop + Index Scan なら O(M log N)、Hash Join なら O(N + M)。二乗にはならない -- 支配項はウィンドウ関数のソート O(K log K)(K = PR1 で 632、PR2 で 467)。実行時間はミリ秒台 +- `WHERE` の条件は **join ではなくフィルタ**。1 行あたり O(1) で評価され、行数を減らす方向にしか働かない +- join 側も、`task_id` に UNIQUE インデックスがある(`prisma/migrations/0_init/migration.sql` の `task_task_id_key`)ため、Nested Loop + Index Scan なら O(M log N)、Hash Join なら O(N + M)。二乗にはならない +- 支配項はウィンドウ関数のソート O(K log K)(K = PR2 で 625)。実行時間はミリ秒台 -**推測ではなく実行計画で確認すること。** staging で `EXPLAIN ANALYZE` を付けて実行し、内側ループに `Seq Scan` が出ていないかを見る。 +**推測ではなく実行計画で確認すること。** PR1 では `task` の Seq Scan 2 回 + Hash Join だった(`contest_type` に索引が無いため想定どおり)。 --- -# 3. 検討の経緯(採らなかった案) +# 4. 検討の経緯(採らなかった案) -**以下はいずれも plan.md で却下・保留と決定済み。** 判断の理由を残すための記録。 +**以下はいずれも計画で却下・保留と決定済み。** 判断の理由を残すための記録。 ## `title` に記号を入れる案を採らない理由 `title` を `'A. Peculiar Protocol'` に揃えれば AtCoder 側 1,366 件と形式が揃うが、採らない。 - **同じ情報を 2 列に持つとドリフトする。** 欠落判断の誤りを修正するとき(修正は起こる前提)、両方を更新しないと不整合になる -- **G2 に逆行する。** title にプレフィックスを埋め込む設計は、表示側に `removeTaskIndexFromTitle` という文字列 replace を要求する。除去したかった種類の変換そのもの -- **`replace` は脆い。** 破壊的変更 5 の退行がその実例 +- **G2 に逆行する。** title にプレフィックスを埋め込む設計は、表示側に文字列の除去を要求する。除去したかった種類の変換そのもの +- **除去は脆い。** 合宿系の title の退行がその実例 -「入れない」を選んでも AtCoder と AOJ で title の意味は揃わないが、 **どちらを選んでも不整合は残る**(AtCoder 側を触るのはスコープ外)。コードは既に `formatAojTitle`(AOJ・付与)と `removeTaskIndexFromTitle`(AtCoder・除去)で経路が分かれているため、AOJ を「素の title」に寄せる方が既存の設計と整合する。 +「入れない」を選んでも AtCoder と AOJ で title の意味は揃わないが、 **どちらを選んでも不整合は残る**(AtCoder 側を触るのはスコープ外)。AOJ を「素の title」に寄せ、セルで `A. ` を付ける方が、PR1 の設計(`buildTitleWithTaskIndex`)と整合する。 ## 管理画面案を保留にした理由 @@ -236,80 +399,73 @@ AOJ ICPC/JAG の 1,099 件が「無条件に一括復元できる」のとは質 - **再実行できる。** マイグレーションは適用後 immutable なので、欠落判断の誤りが後から見つかると新しいマイグレーションを積むことになる - 欠落判断は人間が行う(M6)以上、**修正は起こる前提**であるべき -一方で、この案は**問題記号の入力元が定義できないと成立しない**。手入力なら 1,099 回の手作業に戻り、難所 1 で否定したものに戻る。コード上の対応表を読むなら prefix / problem_id は入力ではなくなり、実質「対応表を DB に同期するボタン」になる。 +一方で、この案は**問題記号の入力元が定義できないと成立しない**。手入力なら 1,000 回超の手作業に戻り、難所 1 で否定したものに戻る。コード上の対応表を読むなら、実質「対応表を DB に同期するボタン」になる。 -さらに、素直に実装すると **N+1 になりやすい**。既存の `createTask` がまさにその形で(`src/routes/(admin)/tasks/+page.server.ts:85` の `Promise.all` + check-then-act)、トランザクションが無いため途中で失敗すると部分適用が残る。採るなら `updateMany` か `$executeRaw` で一括処理する設計が前提になる。 +さらに、素直に実装すると **N+1 になりやすい**。既存の `createTask` がまさにその形で(`src/routes/(admin)/tasks/+page.server.ts` の `Promise.all` + check-then-act)、トランザクションが無いため途中で失敗すると部分適用が残る。採るなら `updateMany` か `$executeRaw` で一括処理する設計が前提になる。 -**再検討のタイミング**: PR2 で例外が多く、修正が繰り返し発生すると分かった時点。PR1 は欠落が少ない見込みなのでマイグレーション 1 本で足りる。 - -## 逆順(合宿系を先)を採らなかった理由 - -「動いているものへの改修を後に回す方が PR のレビュー負荷が低い」という利点はある。実際、合宿系を先にすれば既存コードへの変更は `mapToTask` の分岐追加だけで済む。 - -ただし出典欄の反映が PR2 まで持ち越しになり、対応表のフォーマットを最も判断の重い 50 コンテストで作ることになる。**仕様反映の速さを優先して順序を決めた。** +**再検討の結果(2026-10-24)**: PR2 の照合で補正が要るのは 8 コンテストと分かり、マイグレーション 1 本で足りると判断した。補正の誤りが繰り返し見つかるようなら再検討する。 --- -# 4. Task テーブルへの書き込み経路の全数調査 +# 5. Task テーブルへの書き込み経路の全数調査 リポジトリ全体(`src` / `prisma` / `e2e`)を確認した結果。移行 UPDATE の安全性の根拠。 ## 書き込み経路は 3 つだけ、削除経路はゼロ -| 経路 | 実装 | 書き込むフィールド | -| -------------------- | ------------------------------- | ------------------------------------------------------------------------------- | -| 管理画面インポート | `src/lib/services/tasks.ts:182` | `id` / `task_id` / `contest_id` / `contest_type` / `task_table_index` / `title` | -| 管理画面の難易度更新 | `src/lib/services/tasks.ts:208` | `grade` のみ | -| seed | `prisma/seed.ts:217` | 上記 + `grade`(`id` は未指定) | +| 経路 | 実装 | 書き込むフィールド | +| -------------------- | ------------------------------------------- | ------------------------------------------------------------------------------- | +| 管理画面の取り込み | `src/lib/services/tasks.ts` の `createTask` | `id` / `task_id` / `contest_id` / `contest_type` / `task_table_index` / `title` | +| 管理画面の難易度更新 | `src/lib/services/tasks.ts` の `updateTask` | `grade` のみ | +| seed | `prisma/seed.ts` | 上記 + `grade`(`id` は未指定) | -- `updateTask` の呼び出し元は 2 つ(`src/routes/(admin)/tasks/[task_id]/+page.server.ts:73`、 `src/routes/(admin)/tasks/grade/+page.server.ts:34`)。いずれも `grade` のみ +- `updateTask` の呼び出し元は 2 つ(`src/routes/(admin)/tasks/[task_id]/+page.server.ts`、 `src/routes/(admin)/tasks/grade/+page.server.ts`)。いずれも `grade` のみ - `task.delete` / `deleteMany` / `upsert` / `createMany` は**リポジトリ全体に存在しない** - 生 SQL(`$executeRaw` / `$queryRaw`)による Task への書き込みもなし - 他モデルからのネストした `task: { create: ... }` もなし -⇒ **一意フィールドを UPDATE するコードは構造的に存在しない。** +⇒ **一意フィールドを UPDATE するコードは構造的に存在しない。** リポジトリの外では、staging / production に Supabase の CSV で行を入れることがある(既存の合宿系、PR2.1)。 -## `id` の採番規則がインポートと seed で不一致 +## `id` の採番規則が経路ごとに違う -- インポート: `sha256(contest_id + task.id)` — 決定的 -- seed: `id` 未指定のため fabbrica の自動採番(`prisma/.fabbrica/index.ts:915`)で連番文字列 +- 取り込み: `sha256(contest_id + task.id)`(16 進)— 決定的 +- Supabase の CSV(既存の合宿系、PR2.1): `sha256(contest_id + title)`(16 進)。メンテナが別プロジェクトのスクリプトで作る(2026-10-24 にメンテナが説明) +- seed: `id` 未指定のため fabbrica の自動採番で連番文字列 -一意性は破れないが「`Task.id` はハッシュ」という前提は**開発環境では成立しない**。ただし **`Task.id` を再計算して引く箇所はゼロ**で、全ルックアップは `task_id` 経由。現状の実害はないが、将来 `Task.id` に決定性を期待するコードを書くと静かに壊れる。 +一意性は破れないが「`Task.id` は `task_id` から決まるハッシュ」という前提は**取り込み以外では成立しない**。ただし **`Task.id` を再計算して引く箇所はゼロ**で、全ルックアップは `task_id` 経由。現状の実害はないが、将来 `Task.id` に決定性を期待するコードを書くと静かに壊れる。 ## check-then-act の競合 -`createTask` は `isExistsTask`(`findMany`)→ `create` の check-then-act で、 `src/routes/(admin)/tasks/+page.server.ts:85` がこれを `Promise.all` で並列実行している。 +`createTask` は `isExistsTask`(`findMany`)→ `create` の check-then-act で、 `src/routes/(admin)/tasks/+page.server.ts` がこれを `Promise.all` で並列実行している。 -同一 `task_id` が 1 回のインポート JSON に 2 件含まれると、両方がチェックを通過して 2 件目が P2002 で落ちる。`Promise.all` 全体が reject して 500 を返すが、 **トランザクションが無いため先に成功した分はロールバックされない**(部分適用)。 +同一 `task_id` が 1 回の取り込み JSON に 2 件含まれると、両方がチェックを通過して 2 件目が P2002 で落ちる。`Promise.all` 全体が reject して 500 を返すが、 **トランザクションが無いため先に成功した分はロールバックされない**(部分適用)。 -`prisma/seed.ts:192-199` も同じ形で PQueue concurrency 3 で並列実行しており同種のレースがある。 +`prisma/seed.ts` も同じ形で PQueue concurrency 3 で並列実行しており同種のレースがある。 一意性は DB 制約が守るので壊れはしないが、エラー時の挙動が中途半端。 `docs/guides/agent-rules/prisma-db.md` の「Idempotent Writes」に該当し、本来は `createMany({ skipDuplicates: true })` か `upsert` が解。**本件とは独立の既存課題**(O5)。 ## 他の sha256 決定的 ID は Task から独立 -`src/lib/services/answers.ts:86,105` と `src/features/votes/services/vote_grade.ts:107,120,146` はいずれも入力が `Task.id` ではなく `task_id`。`vote_grade.ts` は全て `upsert` で `where` に複合ユニークを使うため id 衝突の経路もない。 +`src/lib/services/answers.ts` と `src/features/votes/services/vote_grade.ts` はいずれも入力が `Task.id` ではなく `task_id`。`vote_grade.ts` は全て `upsert` で `where` に複合ユニークを使うため id 衝突の経路もない。 ## 削除経路がないことの帰結 -Task を削除するコードが存在しないため、関連テーブルが孤児 FK を持つ事故は現状起こらない。将来 Task 削除機能を追加する場合、6 つの関連のうち `TaskTag` / `TaskAnswer` / `WorkBookTask` は `onDelete` 未指定なので要注意(投票系 3 つは `Cascade`)。 +Task を削除するコードが存在しないため、関連テーブルが孤児 FK を持つ事故は現状起こらない。将来 Task 削除機能を追加する場合(PR3 のダミーの扱いなど)、6 つの関連のうち `TaskTag` / `TaskAnswer` / `WorkBookTask` は `onDelete` 未指定なので要注意(投票系 3 つは `Cascade`)。 --- -# 5. 調査中の誤りと訂正(自戒) +# 6. 調査中の誤りと訂正(自戒) 1. **対象を 632 問と集計した** — Provider が存在する 3 種のみを数え、seed にデータがある合宿系 467 問を落としていた。正しくは 1,099 問。 _教訓: 「実装がある範囲」と「データがある範囲」は一致しない。集計条件は後者で取る。_ -2. **「問題ID が連番だから欠落なし、機械生成できる」と結論した** — 欠落問題は ID も採番されないため、連番性は欠落の有無について何も語らない。検証が問いに答えていなかった。 _教訓: 「観測できる範囲に異常が無い」ことを「異常が無い」と読み替えない。欠落は定義上、観測できない側にある。_ +2. **「問題 ID が連番だから欠落なし、機械生成できる」と結論した** — 欠落問題は ID も採番されないため、連番性は欠落の有無について何も語らない。検証が問いに答えていなかった。 _教訓: 「観測できる範囲に異常が無い」ことを「異常が無い」と読み替えない。欠落は定義上、観測できない側にある。_ 3. **抽出スクリプトのバグを、データの欠落として提示しかけた** — `prisma/tasks.ts` からタスクを抜き出す正規表現がシングルクォート前提だったため、 `name: "A Bug That's Not a Pill Bug"`(アポストロフィを含むためダブルクォート)を取りこぼし、`ICPCPrelim2024` が 8 問に見えた。実際は 9 問で、欠番は存在しなかった。 _教訓: 抽出スクリプトを書いたら、まず総数が既知の値と一致するかを確認する。1 と同じ「観測範囲を実際の範囲と取り違える」失敗であり、本件では欠落の誤検出に直結する。_ -4. **移行 SQL を seed 由来の対応表から生成する案を出した** — production のタスクは管理画面インポート経由で入っており seed と一致する保証がないため、production にしか無い行が取り残される。しかも `UPDATE` は該当行が無くてもエラーにならない。 _教訓: 2 と同じ型。「seed で見えている範囲」を「production の実態」と取り違えた。_ +4. **移行 SQL を seed 由来の対応表から生成する案を出した** — production のタスクは管理画面の取り込み経由で入っており seed と一致する保証がないため、production にしか無い行が取り残される。しかも `UPDATE` は該当行が無くてもエラーにならない。 _教訓: 2 と同じ型。「seed で見えている範囲」を「production の実態」と取り違えた。_ 5. **AtCoder 系の復元を「不可能」と断定した** — 実際は `task_id` のサフィックスから 83.4% が導出できる。ただし事故対応としては成立しないという結論自体は変わらない。 _教訓: 断定する前に測る。結論が同じでも、根拠が誤っていると次の判断で誤用される。_ 6. **PR を分けた後も、移行 SQL の範囲を 1,099 件のままにしていた**(2026-10-21 に発見)— `contest_type IN ('AOJ_ICPC', 'AOJ_JAG')` は合宿系も含むため、PR1 で 467 件を余計に更新するところだった。 _教訓: 範囲を分割したら、範囲を表す条件をすべて追従させる。分類(`contest_type`)と分割の単位が一致しているとは限らない。_ 7. **欠落した問題を「AOJ に存在しない(ID も無い)」と決めつけていた**(2026-10-21 に発見)— ICPC 国内予選 2002 の E は AOJ に問題 1123 として存在し、コンテスト一覧(取り込み元)から外れているだけだった。PR3 でダミーを作るという前提は、この問題には当てはまらない。 _教訓: 「一覧に無い」と「存在しない」は別。データの取得元の範囲を、対象が実在する範囲と取り違えない(1・4 と同じ型)。_ - -なお本調査は当初「上流が `title` / `problem_index` を変更した場合」という別の問いから始まったため、初版には鉄則本の章分類(`tessoku_book_providers.ts`)や `ContestTaskPair` の `replace` 誤置換に関する記述があった。いずれも本件では対象外のため削除済み。 diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr2-1.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr2-1.md new file mode 100644 index 000000000..391e41d39 --- /dev/null +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr2-1.md @@ -0,0 +1,85 @@ +# PR2.1 — DB に無い 10 コンテストを入れる + +前提・共通の決定・データ移行の手順は [plan.md](./plan.md)。スプレッドシートとの対応は [investigation.md](./investigation.md#db-に無いコンテスト)。 + +**状態**: 未着手。必要な情報(問題番号・contest_id・CSV の作り方)は 2026-10-24 にメンテナから受け取った。PR2 / PR2.5 と前後してよい。 + +## 範囲 + +メンテナのスプレッドシートにあって、seed にも本番にも無いもの。 + +| コンテスト | contest_id | 問題(task_id) | 入手元 | 本番への入れ方 | +| ------------------------------------- | ---------------------------- | ------------------------------------------ | ------- | ------------------------------------- | +| JAG 夏合宿 2026 Day1〜3 | `JAGSummer2026-day1`〜`day3` | 3684〜3719(各 12 問、A〜L) | AOJ | Supabase の CSV(既存の合宿系と同じ) | +| JAG 夏合宿 2025 Day1〜3 | `JAGSummer2025-day1`〜`day3` | 3648〜3683(各 12 問、A〜L) | AOJ | 同上 | +| JAG 夏合宿 2018 Day2(有志) | `jag2018summer-day2` | `jag2018summer_day2_a`〜`k`(11 問) | AtCoder | 管理画面の AtCoder 取り込み | +| JAG 夏合宿 2017 Day1(有志) | `jag2017summer-day1` | `jag2017summer_day1_a`〜`k`(11 問) | AtCoder | 同上 | +| JAG 夏合宿 2015 Day3(過去問) | `jag2015summer-day3` | `icpc2015summer_day3_a`〜`j`(10 問) | AtCoder | 同上 | +| JAG 夏合宿 2013 Day2 WarmUp(過去問) | `jag2013summer-warmingup` | `icpc2013summer_warmingUp_a`〜`j`(10 問) | AtCoder | 同上 | + +計 114 問(AOJ 72、AtCoder 42)。いずれもスプレッドシートの問題名と記号の位置ごとに一致した(目視)。 + +- AOJ の 6 コンテストは問題 ID が連番で、欠落も無い。記号は基本ルールと一致する。メンテナから受け取った値は `prisma/tasks.ts` と同じ形(`id` / `contest_id` / `problem_index` / `name` / `title`)で、title は素 +- AtCoder の 4 コンテストは AtCoder Problems の値で、title は `A. …` の形。2015 / 2013 の task_id は `icpc…` で始まるが、分類と URL はどちらも contest_id で決まるので影響しない + +関係する決定: M17([plan.md](./plan.md#決定事項))。 + +## タスク + +| # | 内容 | 補足 | +| ----- | --------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| 2.1-0 | `ATCODER_JAG` を新設する | [下記](#atcoder_jag)。`add-contest-table-provider` の Layer 1–3 | +| 2.1-1 | in AtCoder の 4 コンテストを seed に追加する | 上の値。本番は管理画面の AtCoder 取り込みで入る(分類されれば skip されない) | +| 2.1-2 | 2025 / 2026 夏合宿の 6 コンテストを seed に追加する | メンテナから受け取った値をそのまま使う | +| 2.1-3 | staging / production に Supabase の CSV で入れる | **その環境に PR2 の移行を適用した後**に入れる。[下記](#csv) | +| 2.1-4 | テスト | 分類・優先度・ラベル(Layer 3)。seed の記号の検査(PR2 で広げたもの)に 2025 / 2026 を足す | + +**クロスレビューが必須**(enum の追加は DB schema の変更)。 + +## ATCODER_JAG + +| 場所 | 変更 | +| --------------------------------------------- | ---------------------------------------------------------------- | +| `prisma/schema.prisma` の `ContestType` enum | `UNIVERSITY` の直前に追加し、マイグレーションを作る | +| `src/lib/contests/types/contest.ts` | `ContestType` に同じ位置で追加 | +| `contestTypePriorities`(`priority.ts`) | 18 にして、`UNIVERSITY` 以降を +1 ずつずらす。JSDoc の範囲も直す | +| `CLASSIFICATION_RULES`(`classification.ts`) | `UNIVERSITY` の規則の直前に、下の 4 つと完全一致する規則を置く | +| `prefixes.ts` | `ATCODER_JAG`(contest_id → ラベル、下表) | +| `LABEL_GENERATORS`(`labels/index.ts`) | `ATCODER_JAG` の map からラベルを引く | + +| contest_id | ラベル(出典欄) | +| ------------------------- | ----------------------------- | +| `jag2018summer-day2` | `JAG 夏合宿 2018 Day2` | +| `jag2017summer-day1` | `JAG 夏合宿 2017 Day1` | +| `jag2015summer-day3` | `JAG 夏合宿 2015 Day3` | +| `jag2013summer-warmingup` | `JAG 夏合宿 2013 Day2 WarmUp` | + +ラベルに(有志)は付けない。PR2.5 のテーブル名でだけ、2018 Day2 と 2017 Day1 に `(有志)` を付ける(M16)。 + +- 優先度をずらすので、`src/test/lib/utils/task.test.ts` の優先度の差の期待値のうち、ずれた項目を含むものを直す +- 4 つの contest_id は小文字の `jag…` で始まるので、`regexForJag`(大文字の `JAG(Prelim|…)`)にも `AojGenerator` の `startsWith('ICPC')` にも当たらない。`getTaskUrl` は AtCoder の URL を作る + +却下した方針: + +| 案 | 却下理由 | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `jag` の前方一致で分類する | AtCoder Problems の `JAG2013Spring` / `JAG2014Spring` は AOJ の `JAGSpring2013` / `2014` と同じコンテストで、問題が二重に入る | +| `OTHERS` に入れる | メンテナの判断で専用の ContestType にした | +| `AOJ_JAG` に入れる | 出典欄の AOJ 分岐(`…(AOJ)`)や `getTaskUrl` が AOJ を前提に動く | + +## CSV + +既存の合宿系と同じく、メンテナが別プロジェクトのスクリプトで作る。 + +- `id` は `sha256(contest_id + title)`(16 進。既存の合宿系と同じ規則)。管理画面の取り込みは `sha256(contest_id + task_id)` で規則が違うが、`Task.id` を再計算して引く箇所は無いので問題ない([根拠](./investigation.md#id-の採番規則が経路ごとに違う)) +- `contest_type` は `AOJ_JAG`、`task_table_index` は記号、`title` は素(受け取った値のまま) +- `updated_at` は DB に既定値が無い(`@updatedAt` は Prisma 側の実装)ので、CSV に含まれていることを確かめる。`created_at` / `grade` / `atcoder_problems_difficulty` には既定値がある +- 投入は `invalidateTaskCaches()` を経ないので、反映はキャッシュの TTL(1 時間)の後になる + +却下した方針: + +| 案 | 却下理由 | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 移行の前に、既存の合宿系と同じ形(問題 ID のまま)で入れる | PR2 の移行 SQL の件数の想定値が、投入の有無で変わる。記号付きで入れれば移行と独立する | +| 管理画面の取り込みに合宿系を足す | 合宿系も AOJ API にある(JAG の filter に summer / winter / spring がある)。ただし既存の contest_id(`JAGSummer2012-day3A` など)にそろえるアドホックな対応が多く、年 1 回の追加に対して重い。**応答の形は意図的に確かめていない**(メンテナの判断、2026-10-24) | +| `id` を取り込みと同じ規則にそろえる | 既存の合宿系と規則が分かれる。`id` を再計算する箇所が無いので、そろえる利点が無い | diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr2-5.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr2-5.md new file mode 100644 index 000000000..0dfbbb58f --- /dev/null +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr2-5.md @@ -0,0 +1,75 @@ +# PR2.5 — 合宿系と JAG 模擬地区をコンテスト表に出す + +前提と共通の決定は [plan.md](./plan.md)。並び順の一覧は [investigation.md](./investigation.md#表示順76-コンテスト)。 + +**状態**: 未着手。PR2 の後に出す(先に出すと、問題 ID の index と `'2389. …'` の title が表に出る)。PR2.1 とは前後してよい。 + +## 何を作るか + +- 1 つのグループ(グループ名・ボタン名とも「JAG 模擬地区」)に、合宿系 50・模擬地区 16・PR2.1 の 10 の計 76 コンテストを、コンテストごとのテーブルとして並べる(M15) +- 並びはスプレッドシートの行順(新しいものが上)。contest_id からは導けないので、明示的な配列で持つ。例: 2014 は 夏合宿 Day2 → 模擬地区(夏合宿の Day3 として開催)→ 夏合宿 Day4。冬合宿 2010 は 夏合宿 2011 の後 +- テーブル名は `getContestNameLabel(contestId)` に括弧書きを付ける(M16) + +| 例 | 括弧書き | +| ---------------------------------------------- | ------------------------------------------ | +| `JAG 夏合宿 2024 Day2(有志)` | `有志` | +| `JAG 春合宿 2015(JAG)` | `JAG` | +| `JAG 模擬地区 2016(in JAG 夏合宿 2016 Day3)` | `in JAG 夏合宿 2016 Day3`(2014 も同じ形) | +| `JAG 夏合宿 2015 Day3` | なし(in AtCoder・過去問) | + +- 出典欄は変えない。この配列は #4018 でコンテスト情報を DB に移すときの移行元になる +- 配列には PR2.1 の 10 件も入れる。データが無い間は表示されないので、PR2.1 より先に出してよい(contest_id は確定済み。[plan-pr2-1.md](./plan-pr2-1.md#範囲)) +- Provider の `contestType` は全件 `AOJ_JAG` にする。provider key の名前空間として使うだけで、`getTaskTableHeaderName` での違いは ABC の H/Ex だけなので、in AtCoder の 4 件も同じでよい。`ATCODER_JAG` に依存しないので、PR2.1 と順序を問わない + +関係する決定: M15 / M16([plan.md](./plan.md#決定事項))。 + +## タスク + +| # | 内容 | +| ----- | ----------------------------------------------------------------------------------------- | +| 2.5-1 | 並び順と括弧書きの配列(76 件) | +| 2.5-2 | Provider クラス(1 コンテスト = 1 テーブル) | +| 2.5-3 | グループ「JAG 模擬地区」を、`contestTableProviderGroups` の `aojJagPrelim` の後に登録する | +| 2.5-4 | テスト(Provider とグループ) | + +`add-contest-table-provider` skill の Layer 4–5 に当たる(Pattern 4 の変形: コンストラクタの引数がコンテスト 1 つ分)。DB を変えず共有インターフェイスも変えないので、クロスレビューの条件には当たらない。 + +## 契約 + +```typescript +// src/features/tasks/utils/contest-table/jag_contest_table_entries.ts +export type JagContestTableEntry = { + contestId: string; + // Bracketed note after the contest name: 'JAG', '有志' or 'in JAG 夏合宿 2016 Day3'. + note?: string; +}; + +export type JagContestTableEntries = JagContestTableEntry[]; + +// Newest first, following the maintainer's spreadsheet; contest ids cannot give this order. +export const JAG_CONTEST_TABLE_ENTRIES: Readonly; + +// src/features/tasks/utils/contest-table/aoj_jag_providers.ts +export class JagContestProvider extends ContestTableProviderBase { + // section = contestId, so the provider key is e.g. AOJ_JAG::JAGSummer2024-day2. + constructor(contestType: ContestType, entry: JagContestTableEntry); + // filter: contest_id === entry.contestId + // getHeaderIdsForTask: sortAojHeaderIds / getDisplayConfig: buildAojDisplayConfig + // getMetadata(): { title: name + (note), abbreviationName: contestId, titleStyle: AOJ_TITLE_STYLE } +} +``` + +## テスト + +- グループの `getSize()` が 76、先頭と末尾のコンテスト +- 2014 の 3 件(夏合宿 Day2 → 模擬地区 → 夏合宿 Day4)の並び +- テーブル名の括弧書き 3 種(`JAG` / `in …` / なし) + +## 却下した方針 + +| 案 | 却下理由 | +| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| 合宿系と模擬地区を 2 グループに分ける | 同じ年の中の前後関係(例: 模擬地区 2014 は夏合宿 2014 の Day3 として開催)を表せない | +| 既存の「JAG 模擬国内」グループに統合する | 模擬国内は年のループで完成している。統合すると、それも明示的な配列に置き換えることになる | +| テーブル名をスプレッドシートの名称(春コンテスト / 冬コンテスト / 模擬アジア地区)にする | 出典欄(`JAG_TRANSLATIONS`)と名称が食い違う([名称の違い](./investigation.md#名称の違い)) | +| 出典欄も PR2.5 でそろえる | ラベルを使う他の画面(検索・投票・タグなど)と fixture に波及する。#4018 でまとめて行う | diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr2.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr2.md new file mode 100644 index 000000000..de0254ce7 --- /dev/null +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr2.md @@ -0,0 +1,186 @@ +# PR2 — JAG 合宿系と JAG 模擬地区を問題記号にする(66 コンテスト / 625 問) + +前提・共通の決定・データ移行の手順は [plan.md](./plan.md)。照合結果は [investigation.md](./investigation.md#1-pr2-の照合2026-10-24)。 + +**状態**: 未着手。着手前の確認(Q1 / Q2)は 2026-10-24 に回答済み。[PR1.5](./done.md#pr15--aoj-api-の移転に追従する4028) の後に、[着手前にすること](#着手前にすること)を済ませてから始める。 + +## 範囲 + +| 範囲 | コンテスト / 問題 | 所在 | 今の `title` | 取り込みの経路 | +| -------------------- | ----------------- | ----------------------------- | --------------------------- | -------------------------------------------- | +| JAG 夏 / 冬 / 春合宿 | 50 / 467 | seed・staging・本番 | `'2389. …'`(問題 ID 付き) | 無い(`JagRound` は PRELIM / REGIONAL のみ) | +| JAG 模擬地区 | 16 / 158 | 本番のみ(staging は 2 / 16) | 素 | 管理画面(今は問題 ID のまま入る) | + +PR2 の時点では、この範囲のコンテスト表はまだ無い(PR2.5 で足す)。表示は問題一覧の出典欄(例: `JAG 夏合宿 2024 Day3 - H(AOJ)`)と title で確かめる。 + +関係する決定: M2 / M6 / M8 / M9 / M12 / M13 / M14 / M18([plan.md](./plan.md#決定事項))。 + +## 起きること + +| 変更 | 起きること | 対処 | +| ------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------ | +| `task_table_index` の値が変わる | 既存レコードは自動更新されない(seed も取り込みも「既存なら skip」) | 移行 UPDATE | +| 合宿系の title の問題 ID | index だけ記号にすると `removeTaskIndexFromTitle` が空振りし、`'2389. …'` が出典欄やセルにそのまま出る | title も直す(M9) | +| 基本ルールで記号がずれる | 8 コンテストで先頭・途中に欠落がある([照合結果](./investigation.md#記号の補正が要るコンテスト)) | 補正(M13) | + +- 一意性は壊れない。`id` / `task_id` は `problem_index` を入力にしておらず、FK もすべて `task_id` を参照する([根拠](./investigation.md#一意性は壊れない)) +- 模擬地区の title にプレフィックスは無い(本番・staging とも 0 行)。本番の 3355 の title の末尾にタブがあるが、本件では直さない(前後に空白のある title 46 件と合わせて残タスク。[plan.md](./plan.md#スコープ外)) + +## タスク + +| # | 内容 | 補足 | +| --- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| 2-0 | 対応表の生成と照合 | [下記](#2-0-照合) | +| 2-1 | seed に模擬地区 158 問を追加する(M12) | SQL B の値(grade も本番のまま。title は取り込みと同じく trim する)。最初は本番と同じ形(index = 問題 ID)で入れ、2-0 の照合の入力にする | +| 2-2 | 取り込みで、JAG は全 round で記号を付ける(M14) | `AojChallengesApiClient` の `usesProblemLetters` を `contestType === 'ICPC' \|\| contestType === 'JAG'` にする | +| 2-3 | `prisma/tasks.ts` の 625 件の `problem_index` と、合宿系 467 件の `title` を書き換える | 2-0 の出力で置き換える | +| 2-4 | 移行 SQL | [下記](#移行-sql) | +| 2-5 | テスト | [下記](#テスト) | +| 2-6 | 手順書 | `add-tasks` の「ICPC / JAG 模擬国内の `problem_index` は問題記号にする」を JAG 全体に広げる | +| 2-7 | 照合スクリプトを削除する | 追加とは別のコミットにし、git の履歴から辿れるようにする | + +**クロスレビューが必須**(データ移行)。 + +## 着手前の確認(2026-10-24 に回答済み) + +Q1: SQL A を本番と staging で流した結果。 + +| kind | 本番(コンテスト / 問題) | staging(コンテスト / 問題) | index が問題 ID のまま | title が問題 ID 付き | +| -------- | ------------------------- | ---------------------------- | ---------------------- | -------------------- | +| Regional | 16 / 158 | **2 / 16** | 全行 | 0 行 | +| Spring | 4 / 42 | 4 / 42 | 全行 | 全行 | +| Summer | 36 / 338 | 36 / 338 | 全行 | 全行 | +| Winter | 10 / 87 | 10 / 87 | 全行 | 全行 | + +- 本番は想定どおり。移行 SQL の想定値は (1) 625、(2) 45、(3) 467 で確定した +- **staging の模擬地区は 2 / 16 しか無い**。このままでは (1) が 483 になり RAISE する。[取り込みでそろえる](#着手前にすること) + +Q2: SQL B の結果(本番の模擬地区 158 問)を受け取った。2-1 の seed の元にする。 + +- grade が PENDING でないものが 6 問(2251 D3、2594 D3、2736 D5、2858 D1、2863 D2、2865 D4) +- 3355 の title の末尾にタブがある(`Most distant point from the stations\t`)。AOJ の API がそのまま返す値で、取り込みが `trim()` するようになる前(2026-05-18 より前)に入った行。本番で調べると、前後に空白のある title は 46 件あった([詳細](./investigation.md#前後に空白のある-title))。PR2 では直さない([plan.md](./plan.md#スコープ外)) + +```sql +-- SQL A: state of the PR2 scope (run on production and staging). +SELECT substring(contest_id FROM '^JAG(Summer|Winter|Spring|Regional)') AS kind, + count(DISTINCT contest_id) AS contests, + count(*) AS tasks, + count(*) FILTER (WHERE task_table_index = task_id) AS index_is_problem_id, + count(*) FILTER (WHERE starts_with(title, task_id || '. ')) AS title_has_id_prefix +FROM task +WHERE contest_type = 'AOJ_JAG' + AND contest_id ~ '^JAG(Summer|Winter|Spring|Regional)' +GROUP BY kind +ORDER BY kind; + +-- SQL B: JAG Regional tasks for the seed (production). +SELECT task_id, contest_id, task_table_index, title, grade +FROM task +WHERE contest_type = 'AOJ_JAG' + AND contest_id LIKE 'JAGRegional%' +ORDER BY contest_id, task_id::int; +``` + +## 着手前にすること + +| # | 誰が | 内容 | 理由 | +| --- | -------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | メンテナ | PR1.5 が staging に入った後、staging の管理画面から JAG REGIONAL を取り込み、SQL A で Regional が 16 / 158 になったことを確かめる | 件数チェックを staging で通すため。既存の 16 問は skip され、残りの 142 問が問題 ID のまま入る。**PR2 の PR を開く前**に行う。PR2 の CI は staging へのマージ前に staging の DB へマイグレーションを流す([CI での適用](./plan.md#ci-での適用))。PR2 のコードで取り込むと基本ルールの記号が入り、2005 / 2013 がずれる | + +取り込みは PR1.5 の実地の確認も兼ねる。旧ホストが 410 を返すので、PR1.5 の前は取り込めない。 + +## 2-0 照合 + +PR1 のスクリプト(`git show 171bf3c9:docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_problem_letters.ts`)を次のように直して使う。 + +- 対象は `^JAG(Summer|Winter|Spring|Regional)` の 66 コンテスト / 625 問。件数が合わなければ止める。模擬地区は 2-1 で seed に足してから読む。PR2.1 の 10 コンテストは含めない +- 書き写しの元は 2026-10-24 のスクリーンショット(スプレッドシート 16 枚、行 2〜1025) +- PR1 では記号を基本ルールで付け、位置ごとに照合した。PR2 では先頭・途中の欠落(`absentLetters`)があるので、記号はスプレッドシートの位置(欠落を飛ばした位置)から決める。基本ルールと違うコンテストは別に出力し、移行 SQL の補正(`VALUES`)に使う +- 期待する出力: 補正が要るのは 8 コンテスト / 45 行(目視の暫定値)。合わなければ、どちらが正しいかを個別に確かめる +- 夏合宿 2010 Day2 の J(`Person responsible for problem description don't w`)は、スプレッドシートのセルだけでなく AOJ の title 自体も同じ所で切れている。前方一致の扱いは要らない + +## 移行 SQL + +骨組みのみ。補正の `VALUES`(45 行)は 2-0 の出力で埋める。3 つの UPDATE を 1 つの DO ブロックに入れ、各件数を検査する(M13)。 + +```sql +DO $$ +DECLARE + updated_count integer; +BEGIN + -- (1) Basic rule, as in PR1, for rows that still hold the AOJ problem id. + WITH letters AS ( + SELECT task_id, + chr(64 + row_number() OVER ( + PARTITION BY contest_id + ORDER BY task_id::int + )::int) AS letter + FROM "task" + WHERE contest_type = 'AOJ_JAG' + AND contest_id ~ '^JAG(Summer|Winter|Spring|Regional)' + -- Leaves rows that already have letters (PR2.1 data) untouched. + AND task_table_index = task_id + ) + UPDATE "task" AS t + SET "task_table_index" = letters.letter + FROM letters + WHERE t."task_id" = letters.task_id; + + GET DIAGNOSTICS updated_count = ROW_COUNT; + -- 0: empty DB (migrate reset), 467: seed without JAG Regional, 625: staging / production. + IF updated_count NOT IN (0, 467, 625) THEN + RAISE EXCEPTION 'basic rule: unexpected row count %', updated_count; + END IF; + + -- (2) Overwrite every row of the contests that have problems missing from AOJ. + UPDATE "task" AS t + SET "task_table_index" = fixes.letter + FROM (VALUES + ('JAGSummer2006-day1', '2057', 'B') + -- ... 45 rows in total + ) AS fixes (contest_id, task_id, letter) + WHERE t."task_id" = fixes.task_id + AND t."contest_id" = fixes.contest_id + AND t."contest_type" = 'AOJ_JAG'; + + GET DIAGNOSTICS updated_count = ROW_COUNT; + -- 32: training camps only, 45: training camps and JAG Regional. + IF updated_count NOT IN (0, 32, 45) THEN + RAISE EXCEPTION 'overrides: unexpected row count %', updated_count; + END IF; + + -- (3) Strip the legacy ". " prefix from titles (M9). + UPDATE "task" + SET "title" = substr("title", length("task_id") + 3) + WHERE "contest_type" = 'AOJ_JAG' + AND "contest_id" ~ '^JAG(Summer|Winter|Spring|Regional)' + AND starts_with("title", "task_id" || '. '); + + GET DIAGNOSTICS updated_count = ROW_COUNT; + IF updated_count NOT IN (0, 467) THEN + RAISE EXCEPTION 'titles: unexpected row count %', updated_count; + END IF; +END $$; +``` + +- **範囲は `contest_id` の接頭辞で絞る**。`AOJ_JAG` には PR1 で済んだ模擬国内も含まれる +- **件数の想定値に 0 を含める理由**: `prisma migrate reset`(ローカル)は空の DB にマイグレーションを流してから seed を入れる。開発者の既存 DB は合宿系だけを持ち、模擬地区は無い(467 / 32) +- **想定値**: 本番の SQL A で確定した(模擬地区の title にプレフィックスは無いので、(3) は 467 のまま)。staging は取り込みでそろえてから PR を開く +- **title の条件**: 「自分の `task_id` + `. `」で始まるものだけを対象にする。`~ '^[0-9]+\. '` だと他の数字で始まる title も消しうる。467 件すべてでプレフィックスが `task_id` と一致することは確認済み([実測](./investigation.md#title-の持ち方が-aoj-内で割れている)) +- **`task_table_index = task_id` の条件**: PR2.1 の CSV(記号付き・素の title)が先に入っていても (1)(3) の対象にならない。PR2.1 との順序を問わないための保険 +- `task_id::int` は安全(模擬地区の問題 ID も 2024〜3357 で数値)。1 コンテスト最大 14 問なので `chr(64 + n)` は A〜Z に収まる。SQL の読み方は [investigation.md](./investigation.md#sql-の読み方) + +### ローカルと staging での確認 + +[共通の手順](./plan.md#手順)に加えて: + +- ローカルの seed には模擬地区が無い(2-1 で足すのは記号付きの値)。PR2 のコードに切り替える前に、管理画面から JAG REGIONAL を取り込んでおく(index が問題 ID のまま、本番と同じ形で入る)。そうすればローカルでも 625 / 45 / 467 になる +- staging は、[着手前にすること](#着手前にすること)の 1 で 16 / 158 になったことを確かめてから PR を開く。件数が想定と一致しないと RAISE でデプロイが止まる +- 補正した 8 コンテストは「現在値 → 変更後」を全行見る +- 出典欄(問題一覧)で、合宿系・模擬地区が記号になり、title に問題 ID が出ていないこと + +## テスト + +- `src/lib/clients/aizu_online_judge/seed_problem_letters.test.ts` の範囲を 145 コンテスト / 1,257 問に広げる。補正していないコンテストは `assignProblemLetters` と一致し、補正した 8 コンテストは期待値を literal で書く(M18)。合宿系の title が `${problem id}. ` で始まらないことも見る +- `clients.test.ts`: JAG REGIONAL の取り込みで記号が付くこと。`usesProblemLetters` の JAG 条件を PRELIM だけに戻す変異を検出できること +- 書き換えたテストと追加したテストに `verify-test-strength` を当てる。期待値を新形式へ一括置換するとテストが仕様を検証しなくなるので、`testing.md` の Test integrity に従う diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md new file mode 100644 index 000000000..973b0bb17 --- /dev/null +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan-pr3.md @@ -0,0 +1,37 @@ +# PR3 — 欠落問題の補完 + +前提と共通の決定は [plan.md](./plan.md)。欠落の一覧は [investigation.md](./investigation.md#欠落の一覧pr3)。 + +**状態**: 未着手。着手前に調査(3-0)を置く。PR1 / PR2 のデータ移行とは性質が異なる(既存データの正規化ではなく、新しいレコードの設計判断)ため独立させる。PR2 で記号を補正するので、欠落を後から足しても既存の記号はずれない。 + +## 欠落の 2 種類 + +| 種類 | 例 | 扱い | +| ---------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | +| AOJ に問題はあるが、コンテスト一覧に無い | ICPC 国内予選 2002 の E = AOJ 1123(AOJ の Sources > ICPC - Prelim では 2002 年の問題) | 実在の ID で通常の問題として登録できる。管理画面の取り込みは一覧を使うので取り込めず、seed と本番への INSERT が要る | +| AOJ に問題そのものが無い | スプレッドシートで「AOJ 未収録」のもの(ただし下記の 3643 のように、AOJ にある場合もある) | ID が無いので、ダミー Task レコードの設計が要る | + +## 分かっている欠落 + +| 問題 | 分かっていること(2026-10-24 にメンテナが確認) | 種類 | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- | +| ICPC 国内予選 2002 の E | AOJ 1123。PR1 で判明 | 前者 | +| JAG 夏合宿 2024 Day3 の G(Interactive String Guessing) | スプレッドシートでは「AOJ 未収録」だが、AOJ に 3643 の問題ページがある(`onlinejudge.u-aizu.ac.jp/challenges/search/titles/3643`)。コンテスト一覧に無いだけ | 前者 | +| JAG 模擬地区 2013 の A〜C | スプレッドシートのリンク先は AOJ 2565 / 2566 / 2567。ただし AOJ 側で title が見つからない | 未確定(問題として使えるか) | +| 上記以外の「AOJ 未収録」21 問(合宿系 17 / 模擬地区 4) | 未確認 | 後者の候補 | + +3643 の例から、スプレッドシートの「AOJ 未収録」は AOJ に無いことの保証にならない。3-0 では 21 問すべてを AOJ の問題番号で確かめる。一覧は [investigation.md](./investigation.md#欠落の一覧pr3)。 + +## タスク + +| # | 内容 | +| --- | ---------------------------------------------------------------------------------------------------------------------- | +| 3-0 | 「AOJ 未収録」21 問と 2565〜2567 について、AOJ に問題番号で存在するかを確かめて上の 2 種類に分け、実数と分布を確認する | +| 3-1 | 一覧に無いだけの問題を、通常の問題として登録する(seed と本番への INSERT) | +| 3-2 | AOJ に無い問題のダミー Task レコードの設計 → 実装 | + +3-2 で決めること: + +- **`task_id` の採番**。AOJ に無い問題には ID が存在しないため、衝突しない採番規則が要る +- **提出リンクの扱い**。リンク先が存在しないので無効化するか、表示自体を変えるか +- **投票 / タグ / 問題集への登場可否**。FK が `task_id` 参照であるため、ダミーを入れると各機能に波及する diff --git a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md index 681ee870e..5a942ca2e 100644 --- a/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md +++ b/docs/dev-notes/2026-09-09/task-title-index-change-impact/plan.md @@ -1,441 +1,172 @@ # AOJ ICPC / JAG の `problem_index` を A/B/C 形式へ変更する -**日付**: 2026-09-09(2026-10-21 に現行コードと再照合し、参照・数値を更新)/ **状態**: PR0 完了(#4212)・PR1 実装済み(2026-10-24、[実施記録](#pr1-の実施記録2026-10-24)) **実測データと検討の経緯は** [investigation.md](./investigation.md) +**日付**: 2026-09-09 作成、2026-10-24 に PR2 以降を再構成/ **状態**: PR0・PR1 完了。PR1.5 実装済み(ブランチ `#4028`、未マージ)。PR2 以降は未着手 + +このファイルは全体像と、PR をまたぐ決定・手順だけを書く。各 PR の作業はそれぞれのファイルにある。 + +| ファイル | 内容 | +| -------------------------------------- | ---------------------------------------------------- | +| [plan-pr2.md](./plan-pr2.md) | PR2: 合宿系と JAG 模擬地区を記号にする(データ移行) | +| [plan-pr2-1.md](./plan-pr2-1.md) | PR2.1: DB に無い 10 コンテストを入れる | +| [plan-pr2-5.md](./plan-pr2-5.md) | PR2.5: コンテスト表に出す(Provider) | +| [plan-pr3.md](./plan-pr3.md) | PR3: 欠落問題の補完 | +| [done.md](./done.md) | PR0 / PR1 / PR1.5 の決定の要約と残タスク | +| [investigation.md](./investigation.md) | 根拠データ。PR2 の照合結果、表示順、欠落の一覧など | ## ゴール -`problem_index`(DB 上は `task_table_index`)に AOJ 問題ID ではなく**問題記号**を持たせ、 **表示側が変換なしにそれを使える状態にする。** +`problem_index`(DB 上は `task_table_index`)に AOJ 問題 ID ではなく**問題記号**を持たせ、表示側が変換なしにそれを使える状態にする。 > **問題記号**: `A`, `B`, `C`… のように、そのコンテスト内で何番目の問題かを表す記号。AtCoder では `abc300_a` の `a` にあたる。AOJ には存在しない(難所 1)。コード上は `letter`。 -| | 現在 | 目標 | -| -------------------------------------- | ------------------------------------------- | -------------------------------------------- | -| **G1** 出典欄(`TaskList.svelte:124`) | `AOJ 3467(ICPC 国内予選 2024)` | `ICPC 国内予選 2024 - F(AOJ)` | -| **G2** コンテストテーブル | 問題ID 3467 → ソート位置から `F` を**算出** | `task_table_index` の `F` を**そのまま使う** | +| | 変更前 | 目標 | +| ------------------- | ---------------------------------------- | -------------------------------------------- | +| **G1** 出典欄 | `AOJ 3467(ICPC 国内予選 2024)` | `ICPC 国内予選 2024 - F(AOJ)` | +| **G2** コンテスト表 | 問題 ID 3467 → ソート位置から `F` を算出 | `task_table_index` の `F` を**そのまま使う** | -G2 は「アドホックな変換をなくす」ことが本体。位置ベース導出(`buildAojLetterMap`)が残る限りゴール未達になる。 +## PR1 完了時点の状態 -### 対象と PR 分割 +| 範囲(コンテスト / 問題) | `task_table_index` | `title` | 管理画面の取り込み | コンテスト表 | +| ----------------------------------------------------- | ------------------ | --------------------------- | ------------------ | ------------ | +| ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 / 632) | 記号 | 素 | 記号を付ける | あり | +| JAG 夏 / 冬 / 春合宿(50 / 467。seed と本番) | 問題 ID | `'2389. …'`(問題 ID 付き) | 経路が無い | 無し | +| JAG 模擬地区(16 / 158。本番のみ。staging は 2 / 16) | 問題 ID | 素 | 問題 ID のまま | 無し | -**全体で 1,099 問 / 129 コンテスト。** +表示側の変換は PR1 で削除済みで、コンテスト表のセルは `task_table_index` から `A. タイトル` を組み立てる。出典欄の書式(G1)は AOJ 全体に入っているので、合宿系・模擬地区の出典欄は今 `JAG 夏合宿 2024 Day3 - 3644(AOJ)` のように問題 ID を出している。 -| | 範囲 | コンテスト | 問題数 | Provider | 到達点 | -| ------- | ----------------------------------------- | ---------- | ------ | -------- | -------------------------------------------- | -| **PR0** | 出典欄の書式(AOJ 全体) | — | — | — | G1 の書式を先に入れる(index は数値のまま) | -| **PR1** | ICPC 国内予選 / アジア地区 / JAG 模擬国内 | 79 | 632 | 実装済み | G1 / G2 をこの範囲で達成 | -| **PR2** | JAG 夏合宿 / 冬合宿 / 春合宿 | 50 | 467 | 未実装 | 残りに拡大 + Provider 追加 | -| **PR3** | 欠落問題の補完 | — | — | — | AOJ のコンテスト一覧に無い問題をどう埋めるか | +AOJ の API は 2026-09-07 に別のホストへ移り、旧ホストは 410 を返す。PR1.5 で追従したが、staging / 本番に入るまでは**管理画面から AOJ を取り込めない**。 -PR1 / PR2 は「AOJ のコンテスト一覧にある問題の記号を正しくする」で完結し、PR3 に進まなくても機能として成立する。 +## PR の分割と順序 -### スコープ外(残タスク) +| | 範囲 | コンテスト | 問題数 | 内容 | +| ----------------------------------------------------- | ------------------------------------------------------- | ---------- | ------------------------- | --------------------------------------------------------- | +| [PR1.5](./done.md#pr15--aoj-api-の移転に追従する4028) | AOJ の API クライアント | — | — | ベース URL を新しいホストへ変える(#4028)。実装済み | +| [PR2](./plan-pr2.md) | JAG 夏合宿 / 冬合宿 / 春合宿 + JAG 模擬地区 | 66 | 625 | 既存データを記号にする(seed・移行 SQL・title・取り込み) | +| [PR2.1](./plan-pr2-1.md) | 2025 / 2026 の夏合宿(AOJ)+ in AtCoder の 4 コンテスト | 10 | 114(スプレッドシート上) | 新しいデータを入れる(`ATCODER_JAG` の新設を含む) | +| [PR2.5](./plan-pr2-5.md) | PR2 / PR2.1 の 76 コンテスト | 76 | — | グループ「JAG 模擬地区」でコンテスト表に出す | +| [PR3](./plan-pr3.md) | 欠落問題の補完 | — | — | AOJ のコンテスト一覧に無い問題をどう埋めるか | -PR0 の書式変更は出典欄の AOJ 分岐全体に効くが、index を記号にするのは ICPC / JAG 模擬国内 / JAG 合宿系だけである。次の範囲は数値の index のまま残し、別タスクとする。件数は本番の集計(2026-10-21)。 +PR1 と PR2 で、記号にするのは 1,257 問 / 145 コンテストになる。PR2 で完結し、PR3 に進まなくても機能として成立する。 -| 範囲 | 本番の件数(コンテスト / 問題) | PR0 後の出典欄 | 残す理由 | -| ------------ | ------------------------------- | ----------------------------------------------------- | --------------------------------------------------------- | -| AOJ Courses | 8 / 247 | `プログラミング入門 - ITP1_1_A(AOJ)` のように不格好 | 問題ID が非数値で、基本ルールの `task_id::int` が使えない | -| AOJ PCK | 1 / 12 | 数値の問題ID のまま | 優先度が低い | -| AOJ 大学系 | 27 / 30 | `UTPC 2010 - 2207(AOJ)` | 優先度が低い(2279 だけは記号で登録済み) | -| JAG 模擬地区 | 16 / 158 | 数値の問題ID のまま | 本番にしか無く(seed は 0 件)、Provider も無い | +- **PR1.5 → staging で JAG REGIONAL を取り込む → PR2 の順にする**。PR2 の件数チェックを staging で通すには、staging の模擬地区を本番と同じ 16 / 158 にそろえる必要があり、それには AOJ の取り込みが動いている必要がある。取り込みは PR2 のコードが入る前に行う(PR2 のコードで取り込むと基本ルールの記号が入り、2005 / 2013 がずれる) +- **PR2 → PR2.5 は守る**。Provider を先に出すと、合宿系の数値の index と `'2389. …'` の title がそのまま表に出る +- **PR2.1 は PR2 / PR2.5 と前後してよい**。PR2 の移行 SQL は index が問題 ID のままの行しか書き換えず、Provider はデータの無いコンテストを表示しない(`TaskTable.svelte` の `prepareContestTable` が `null` を返す) +- **PR3 は独立**。PR2 で記号を補正するので、欠落を後から足しても既存の記号はずれない -## 3 つの難所 +### スコープ外 -**難所 1: 問題記号は AOJ API から取得できない。** `mapToTask`(`src/lib/clients/aizu_online_judge/utils.ts:65`)のコメントが明言しているとおり、API は「その問題がコンテストで何番目か」を返さない。現在の数値は問題ID の代用。 +次の AOJ は数値の index のまま残し、別タスクとする(本番の件数、2026-10-21)。PR1 から残っているタスクは [done.md](./done.md#残タスク)。 -ただし**基本ルール**(コンテスト内で問題ID が若い順に `A`, `B`, `C`…)で復元できる。これは現行 `buildAojLetterMap` の導出と同一なので、**生成した対応表は現在の表示を再現する**。PR1 の回帰確認はここに依拠できる。⇒ 対応表はスクリプトで機械生成できる。 +本番の title のうち 46 件は前後に空白がある(AOJ 4 件、AtCoder 42 件。[詳細](./investigation.md#前後に空白のある-title))。実害が無く再発もしないので、本件では直さず残タスクとする。直すときは、1 本の UPDATE(`btrim`、46 件の件数チェック付き)で済む。 -**難所 2: 欠落があると基本ルールが破れる。しかも欠落は検出できない。** ここでの欠落は、公式の問題が AOJ のコンテスト一覧(管理画面の取り込み元)に無いことを指す。AOJ に登録されていない問題なら **ID も無い**ため、残った問題の ID は連番のままになる。AOJ に問題はあるのに一覧から外れている場合もある(ICPC 国内予選 2002 の E = AOJ 1123)。どちらの場合も、コンテスト内の連番性は欠落の有無を何も語らない。⇒ **コンテスト単位で外部確認するしかない**(唯一の手作業)。 +| 範囲 | 本番(コンテスト / 問題) | 今の出典欄 | 残す理由 | +| ----------- | ------------------------- | ----------------------------------------------------- | ---------------------------------------------------------- | +| AOJ Courses | 8 / 247 | `プログラミング入門 - ITP1_1_A(AOJ)` のように不格好 | 問題 ID が非数値で、基本ルールの `task_id::int` が使えない | +| AOJ PCK | 1 / 12 | 数値の問題 ID のまま | 優先度が低い | +| AOJ 大学系 | 27 / 30 | `UTPC 2010 - 2207(AOJ)` | 優先度が低い。本方式をそのまま当てられる | -この構造こそが変更の動機でもある。導出のままでは欠落を表現できず例外表を抱えるしかないが、データ化すれば欠落コンテストも通常のデータとして扱える。 +大学系に広げるときは、`AOJ-KUPC2011` の 2279 を移行 SQL の対象から外す(index は既に `I` で正しく、基本ルールでは `A` になる)。 -**難所 3: 表示層で逃げられない。** 出典欄は `task_table_index` を**直接**読み、コンテスト表の問題記号マップはこの経路に関与しない。⇒ DB の値そのものを直す以外に手段がない。 +## 3 つの難所 -## 破壊的変更 5 点 +**難所 1: 問題記号は AOJ API から取得できない。** API は「その問題がコンテストで何番目か」を返さない。ただし**基本ルール**(コンテスト内で問題 ID が若い順に `A`, `B`, `C`…)で復元できる。⇒ 対応表はスクリプトで機械生成できる。 -| # | 変更 | 起きること | 対処 | -| --- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| 1 | `task_table_index` の値が変わる | **既存レコードは自動更新されない**(seed も import も「既存なら skip」) | 移行 UPDATE(M2) | -| 2 | index が非数値になる | `aoj_labels.ts:26,54` の `Number(a) - Number(b)` が NaN になりソートが無効化。列順が `task_id` 降順に化ける | `localeCompare` へ(M4)。**移行 UPDATE の後** | -| 3 | `getTaskLabels` を削除すると | `taskLabel` が undefined になり `"A. "` プレフィックスが消える | セル側を index 直参照に(M10) | -| 4 | コンテスト名の全角括弧を外すと | `getAojContestLabel` と `getAojUniversityContestLabel` が `'(…)'` を埋め込むため、**AOJ ラベルを使う他 5 箇所の表示も変わる** | 許容して廃止(M11) | -| 5 | **合宿系のみ `title` に数値プレフィックスがある** | `'2389. …'` に対し `removeTaskIndexFromTitle` が空振りし、**問題ID がタイトルに露出する** | PR2 で `title` も正規化(M9) | +**難所 2: 欠落があると基本ルールが破れる。しかも欠落はデータから検出できない。** ここでの欠落は、公式の問題が AOJ のコンテスト一覧(管理画面の取り込み元)に無いことを指す。AOJ に登録されていない問題なら ID も無いため、残った問題の ID は連番のままになる。AOJ に問題はあるのに一覧から外れている場合もある(ICPC 国内予選 2002 の E = AOJ 1123)。⇒ **コンテスト単位で外部情報(メンテナのスプレッドシート)と照合するしかない**。PR2 の範囲では、実際に 8 コンテストで先頭・途中の欠落が見つかった([照合結果](./investigation.md#記号の補正が要るコンテスト))。 -- 変更 1 で**一意性は壊れない**。`id` / `task_id` は `problem_index` を入力にしておらず、FK も全て `task_id` 参照([根拠](./investigation.md#一意性は壊れない)) -- 変更 5 は **PR1 の 632 件には影響しない**(title が素なので空振りして無変化)。PR1 は安全、PR2 だけが危険という非対称がある([実測](./investigation.md#title-の持ち方が-aoj-内で割れている)) +**難所 3: 表示層で逃げられない。** 出典欄は `task_table_index` を直接読む。⇒ DB の値そのものを直す以外に手段がない。 ## 決定事項 -| # | 決定 | 理由 | -| --- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| M1 | DB の値そのものを A/B/C に直す | 難所 3 | -| M2 | 移行 UPDATE を実施する | 破壊的変更 1 | -| M3 | JAG 合宿系 467 問も対象に含める(PR2 に分割) | seed にデータがあり Provider だけが無い | -| M4 | `sortAojHeaderIds` の comparator を `localeCompare` へ | 破壊的変更 2 | -| M5 | 管理画面からの取り込み時に、ICPC / JAG 模擬国内は基本ルールで記号を付ける | 放置すると次回の取り込みで数値 index が再混入する。毎年の新しいコンテストも追記なしで記号になる | -| M6 | 対応表は基本ルールで生成し、欠落確認は人間が行う | 難所 2 | -| M7 | `buildAojLetterMap` / `AOJ_LABEL_OVERRIDES` を削除 | **G2 そのもの** | -| M8 | 移行は Prisma のデータマイグレーションで適用 | CI の既存の仕組みに乗る | -| M9 | PR2 で合宿系の `title` から数値プレフィックスを除去 | 破壊的変更 5 | -| M10 | `getTaskLabels` を削除し、セル側を `task_table_index` 直参照へ | 恒等マップを組む無駄を残さない。G2 の趣旨 | -| M11 | `getAojContestLabel` / `getAojUniversityContestLabel` から全角括弧を外す | 出典欄の目標書式ではコンテスト名を括弧で囲まない(括弧は末尾の `(AOJ)` だけで、コードベースの慣習どおり全角)。AOJ 大学系も出典欄の AOJ 分岐(`isAojContest`)を通るため両方が対象。**他 5 箇所への波及を許容する** | - -OPTIONAL は 1 件のみ。**O5**: `createTask` の check-then-act を upsert 化 —本件と独立の既存課題で別ブランチ推奨([詳細](./investigation.md#check-then-act-の競合))。 - -## 却下した方針 - -| 案 | 却下理由 | -| -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 対応表を `prisma/tasks.ts` に手書きし seed のみ直す | `mapToTask` が数値を入れ続け、取り込みのたびに**新旧書式が混在**する | -| DB は数値のまま `AOJ_LABEL_OVERRIDES` を拡充し表示層で解決 | 移行不要で最も安いが、**ゴールを満たせない**(難所 3) | -| 問題ID の連番性から欠落を機械判定 | **論理的に成立しない**(難所 2) | -| seed を冪等化して production に流す | **不可**。`addUsers` / `addAtCoderAccounts` / `addWorkBooks` / `addAnswers` / `addVoteStatisticsDemoData` まで走る(`prisma/seed.ts:71-81`) | -| `title` に記号を入れて `'A. Peculiar Protocol'` に揃える | 同じ情報を 2 列に持つとドリフトする([詳細](./investigation.md#title-に記号を入れる案を採らない理由)) | -| 管理画面に同期 action を足す | **保留**。再実行できる利点はあるが問題記号の入力元が定義できない([詳細](./investigation.md#管理画面案を保留にした理由)) | -| 632 件の対応表を `src/lib/clients/aizu_online_judge/` に置き、`mapToTask` で引く(旧・案 B) | 毎年の新しいコンテスト(ICPC 国内予選 / アジア地区、JAG 模擬国内)で表への追記が要り、忘れると数値の index が黙って入る。`prisma/tasks.ts` と同じ情報を 2 か所に持つことにもなる(2026-10-21 に変更) | - -採用は**取り込み時の基本ルール**: `AojChallengesApiClient.getTasks` で、ICPC(国内予選 / アジア地区)と JAG 模擬国内のときだけ、コンテストの問題を ID 昇順に並べて A, B, C… を付ける([1-1 の契約](#1-1-の契約))。seed は生成スクリプトで書き換える。途中に欠番がある新しいコンテストでは記号がずれるが、気づけないのは現状の表示と同じである。見つかった時点で補正表を足す(PR1 の範囲では補正の必要が無いため、補正表は作らない)。 - -> **この条件分岐はスコープの区切りであって、恒久的な設計ではない。** AOJ 由来のコンテストは最終的に A/B/C 形式へ揃えるのが自然だが、 **優先度は低く後回し**。取り込み時の基本ルールと移行 SQL は流用でき、対象の条件を広げるだけで済む。 -> -> - **AOJ 大学**(RUPC / HUPC / UAPC / OUPC / UTPC / KUPC)… 29 件 / 27 コンテスト。28 件は `index == task_id` の数値で**同じ問題を抱えている**。本方式をそのまま適用できる。ただし `AOJ-KUPC2011` の 2279 は変更後の形式(`problem_index: 'I'`)で登録済み(本番も同じ)。AOJ に 1 問しか無いため基本ルールでは `A` になってしまう。範囲を広げるときは移行 SQL の対象から外す。title だけは `'I. Mountain'` と記号付きなので、PR1 で素の `'Mountain'` に揃える -> - **AOJ PCK / Courses** … ContestType は定義済みだがデータ未投入。ただし Courses は task_id が `ITP1_1_A` 形式で**非数値**のため、基本ルールの `task_id::int` が使えず別の導出が要る -> -> 移行 SQL の `contest_type` / `contest_id` 条件は、スコープ限定であると同時に **Courses の非数値 ID で `task_id::int` が失敗するのを防いでいる**。 - -## 移行手段 - -CI が `github.ref` でジョブを分け、staging と production の両方に `prisma migrate deploy` を実行している(`.github/workflows/ci.yml:91,130`)。 - -| ブランチ | 適用先 | -| ----------- | --------------------------------------------- | -| `main` 以外 | `PREVIEW_DATABASE_URL`(`ci.yml:115-119`) | -| `main` | `PRODUCTION_DATABASE_URL`(`ci.yml:150-154`) | - -いずれも build / lint / check / unit-test ジョブの成功後、`vercel build` の**後**・Vercel デプロイの**前**に走る(build が失敗すれば DB に触れない)。`main` へ昇格した時点で production に適用されるため、**staging での確認が自然にゲートになる**。 - -> **要確認**: CI は `pull_request` でも起動し、preview ジョブの条件は `github.ref != 'refs/heads/main'` だけである。YAML 上は、**同一リポジトリのブランチから PR を開いた時点で** `PREVIEW_DATABASE_URL` に適用される(staging へのマージ前)。Actions の実行履歴で確かめること。このため SQL の確認は PR を開く前にローカルで済ませる([手順と確認](#手順と確認))。 - -### SQL — 対応表を焼き込まず、基本ルールを SQL で表現する - -**production のタスクは管理画面インポート経由で入っており seed と一致する保証がない。** seed 由来の一覧から `VALUES` を生成すると production にしか無い行が取り残され、 `UPDATE` は該当行が無くてもエラーにならないため**取りこぼしが静かに起きる**。 - -PR1 の SQL(ICPC 国内予選 / アジア地区 / JAG 模擬国内の 632 件): - -```sql --- (1) Build a task_id -> problem letter lookup table. -WITH letters AS ( - SELECT task_id, - chr(64 + row_number() OVER ( - PARTITION BY contest_id -- Restart numbering for each contest. - ORDER BY task_id::int -- AOJ problem ids are numeric. - )::int) AS letter - FROM "task" - WHERE contest_type = 'AOJ_ICPC' - -- AOJ_JAG also covers training camps, so narrow it by contest_id. - OR (contest_type = 'AOJ_JAG' AND contest_id LIKE 'JAGPrelim%') -) --- (2) Apply the lookup table to the task rows. -UPDATE "task" AS t -SET "task_table_index" = letters.letter -FROM letters -WHERE t."task_id" = letters.task_id - -- Redundant with the CTE filter, but keeps the blast radius fixed. - AND ( - t."contest_type" = 'AOJ_ICPC' - OR (t."contest_type" = 'AOJ_JAG' AND t."contest_id" LIKE 'JAGPrelim%') - ); -``` - -`row_number()` は `bigint` を返し、`chr(bigint)` は存在しない(`ERROR: function chr(bigint) does not exist`)。このため `::int` が要る(2026-10-24 にローカルで確認。当初の SQL には無かった)。 - -**範囲は `contest_type` だけでは絞れない。** 合宿系(`JAGSummer` / `JAGWinter` / `JAGSpring`)も模擬国内と同じ `AOJ_JAG` に分類される(`src/lib/contests/utils/classification.ts:76` の `regexForJag`)。`contest_type IN ('AOJ_ICPC', 'AOJ_JAG')` のままだと PR1 で合宿系 467 件まで更新される。そうなると破壊的変更 5 が PR1 で起き、欠落確認前の記号が合宿系に入る。このため `contest_id` の接頭辞で絞る。 - -PR2 の SQL は同じ形で、CTE と外側の `WHERE` の両方の条件を合宿系に差し替える。PR1 の範囲を含めたまま流すと、欠落対応で手補正した記号が基本ルールの値に戻るため、PR2 でも範囲を絞る。 - -```sql --- PR2 scope: JAG training camps only. -contest_type = 'AOJ_JAG' AND contest_id ~ '^JAG(Summer|Winter|Spring)' -``` - -DB の実データに対して実行されるので seed との差異が問題にならない。 `task_id::int` は安全(129 コンテストすべてで問題ID が数値であることを確認済み)、1 コンテスト最大 14 問なので `chr(64 + n)` は A〜Z に収まる(`row_number()` は 1 始まりなので `chr(65)` = `A` から)。 - -読み方と計算量の分析は [investigation.md](./investigation.md#sql-の読み方)。 - -**PR1 のみ**: 2279 の `title` を素の形に揃える。index は本番も `I` で正しいため触らない。現在値を条件にしているので、既に揃っている環境では 0 件で終わる。 - -```sql --- Align the AOJ-KUPC2011 title with the plain AOJ title format. -UPDATE "task" -SET "title" = 'Mountain' -WHERE "task_id" = '2279' - AND "title" = 'I. Mountain'; -``` - -**PR2 のみ**: `title` の正規化(M9)。`contest_type = 'AOJ_JAG'` には模擬国内も含まれるが、 `~ '^[0-9]+\. '` の条件で対象は合宿系のみになる。 - -```sql --- Strip the legacy numeric-id prefix from AOJ training camp titles. -UPDATE "task" -SET "title" = regexp_replace("title", '^[0-9]+\. ', '') -WHERE "contest_type" = 'AOJ_JAG' - AND "title" ~ '^[0-9]+\. '; -``` - -### 手順と確認 - -1. `pnpm exec prisma migrate dev --create-only --name update_aoj_task_table_index` -2. SQL + 例外の上書きを `migration.sql` に書く -3. **ローカル DB で `UPDATE` を `SELECT` に置き換えて「現在値 → 変更後」を確認する** -4. ローカルで `pnpm exec prisma migrate dev` を実行して適用し、表示を確認する -5. PR を開く → staging へマージして確認 → `main` へ昇格 - -手順 3–4 は**必ず PR を開く前に**済ませる。PR を開いた時点で preview DB に適用されうるうえ([移行手段](#移行手段)の要確認)、適用済みのマイグレーションは編集しても再適用されない。PR を開いた後に SQL の誤りが見つかったら、新しいマイグレーションを積む。ローカル DB は seed 由来なので、確かめられるのは SQL のロジックまでである。 - -手順 3 により **SQL の正しさを構文から判断する必要がなくなる**ので、必ず通すこと。全件を見る必要はないが、**コンテストの切り替わり目(記号が `A` に戻る箇所)**と **問題数の少ない古いコンテスト**は目視する価値がある。 - -ローカル(手順 3–4)と staging での確認項目: - -- **更新件数**が想定(PR1 = 632 と 2279 の title 1 件、PR2 = 467)と一致するか。PR1 で 1,099 件になったら合宿系を除外できていない -- `EXPLAIN ANALYZE` に想定外の `Seq Scan` が無いか -- コンテストテーブルの表示が**変わらない**こと(基本ルールは現行の導出と同一のため) - -**staging が防げないもの**: **対応表・欠落判断そのものの誤り**。誤った記号でも SQL は正常に完走し、staging では「それらしい表示」になる(難所 2)。 - -### 事故対策 - -| 観点 | 内容 | -| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **爆発半径** | 復元できるのは AOJ ICPC/JAG の 1,099 件のみ。WHERE 句が壊れて AtCoder 系に波及すると**一括復元の手段が無くなる**([実測](./investigation.md#復元可能性の実測))。外側 `WHERE` の重複条件はこのため | -| **復元** | 一度目の適用に限り、`task_table_index` も `title` のプレフィックスも `task_id` から再構成できる | -| **バックアップ** | Supabase Pro のため PITR が利用可。復元トリックが効くのは一度目だけなので、**再修正時は PITR が唯一の手段** | -| **`updated_at`** | `@updatedAt` は Prisma Client 側の実装で DB トリガーではない。生 SQL では更新されない。**意図的に含めない** | -| **キャッシュ** | TTL 1 時間(`src/lib/clients/cache.ts:157`)。生 SQL は `invalidateTaskCaches()` を経由しない。ローカル / staging で「更新したのに表示が変わらない」ときはこれを疑う | -| **トランザクション境界** | マイグレーションファイル単位で原子的に適用される。2026-10-24 にローカルの別スキーマで、3 文目が失敗するマイグレーションを `prisma migrate deploy` し、1–2 文目(CREATE / INSERT)も残らないことを確認した | -| **デプロイ直前の数分** | マイグレーションの適用から Vercel のデプロイ完了までの間は、旧コードが記号のデータを `Number()` で並べるため、記号がずれて表示されうる。数分で解消するので**許容する** | - -## フェーズ - -**PR0(出典欄の書式)→ PR1(ICPC / JAG 模擬国内)→ PR2(合宿系)→ PR3(欠落補完)** の順。PR0 は DB に依存しないので先に出し、ラベル周りのテストを PR1 から切り離す。PR1 を合宿系より先にする理由: 出典欄はコンテストごとに正しくなるため PR1 完了時点で成果が見える/PR1 は欠落が少なく対応表フォーマットを楽なケースで確立できる/合宿系は Provider が無いため comparator 差し替えの影響を受けず **PR1 内で完結する**。逆順を採らなかった理由は [investigation.md](./investigation.md#逆順合宿系を先を採らなかった理由)。 - -### PR0 — 出典欄の書式変更(DB 変更なし) - -| # | 内容 | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0-1 | `addContestNameToTaskIndex`(`src/lib/contests/utils/task_index_label.ts`)の AOJ 分岐を `${contestName} - ${taskTableIndex}(AOJ)` にする(G1) | -| 0-2 | `getAojContestLabel` / `getAojUniversityContestLabel`(`src/lib/contests/utils/labels/aoj.ts`)から全角括弧を外す(M11) | -| 0-3 | テスト: `task_index_label.test.ts`、`labels/aoj.test.ts`、`labels/index.test.ts` と、fixture の `contest_name_labels.ts` / `contest_name_and_task_index.ts` の AOJ 部分の期待値を書き換える | - -PR0 時点の出典欄は `ICPC 国内予選 2024 - 3467(AOJ)`(index は数値のまま)で、PR1 の移行で `ICPC 国内予選 2024 - F(AOJ)` になる。合宿系・大学系・Courses・PCK も同じ書式になる([スコープ外](#スコープ外残タスク))。変更後の形式で登録済みの `AOJ-KUPC2011` は `KUPC 2011 - I(AOJ)` になる。 - -### PR1 — ICPC 国内予選 / アジア地区 / JAG 模擬国内(79 コンテスト / 632 問) - -| # | 内容 | 補足 | -| --- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| 1-0 | 対応表の生成と検証(Phase 0 / M6) | `generate_aoj_problem_letters.ts`([Phase 0](#phase-0-の内訳1-0--2-0-共通)) | -| 1-1 | 取り込み時に基本ルールで記号を付ける(M5) | [契約](#1-1-の契約) | -| 1-2 | `prisma/tasks.ts` の 632 件の `problem_index` と、2279 の title を書き換える | 1-0 の出力で置き換える | -| 1-3 | 移行 SQL(632 件 + 2279 の title 1 件)(M2 / M8) | | -| 1-4 | `sortAojHeaderIds` の comparator を `localeCompare` へ(M4) | 順序の制約は無い(下記) | -| 1-5 | `buildAojLetterMap` / `AOJ_LABEL_OVERRIDES` / `formatAojTitle` を削除(M7) | G2 の本体。**1-6 と同じコミットで、1-6 の後に消す**(下記) | -| 1-6 | `getTaskLabels` を削除し、セルは `task_table_index` から表示する(M10) | [契約](#1-6-の契約) | -| 1-7 | テスト | [一覧](#1-7-のテスト) | -| 1-8 | 手順書の更新 | 下記 | -| 1-9 | 生成スクリプトを削除する | 追加とは別のコミットで消し、git の履歴から辿れるようにする | - -1-4 に順序の制約は無い。同じ PR の中なら CI がデプロイより先にマイグレーションを流すうえ、PR1 範囲の問題ID はすべて 4 桁なので、数値のままでも `localeCompare` で正しく並ぶ。 - -1-5 は単独では消せない。`buildAojLetterMap`(と、その中だけで使う `AOJ_LABEL_OVERRIDES`)の呼び出し元は AOJ の `getTaskLabels`(`aoj_icpc_providers.ts:51,95`、`aoj_jag_providers.ts:60`)だけで、`formatAojTitle` の呼び出し元は `TaskTableBodyCell.svelte:37` だけである。どれも 1-6 で消えるので、1-6 → 1-5 の順に同じコミットで行う。先に 1-5 だけ消すとビルドが壊れる。`aoj_labels.ts` の残り(`AOJ_TITLE_STYLE` / `sortAojHeaderIds` / `buildAojDisplayConfig`)は Provider が使い続けるので消さない。 - -1-8 で直す手順書: - -- `.agents/skills/add-contest-table-provider/instructions.md:85` と `docs/guides/how-to-add-contest-table-provider.md:83` から、削除する `getTaskLabels` / `AOJ_LABEL_OVERRIDES` への言及を外す -- `.agents/skills/add-tasks/instructions.md` に「ICPC / JAG 模擬国内の `problem_index` は問題記号にする」を足す。seed に手で足すと数値の index が混ざるため - -PR1 は動作中の 3 Provider を改修するため、**regression の確認が主なレビュー観点**になる。 - -**PR1 はクロスレビューが必須**。データ移行を含み、共有インターフェイス `ContestTableProvider` から `getTaskLabels` を消すため、AGENTS.md の「DB schema, migrations, or data transformations」と「Shared architecture or public interface changes」の両方に当たる。PR を出す前に Codex でクロスレビューを行う(使えなければ `coderabbit review --plain`)。 - -#### 1-1 の契約 - -```typescript -// src/lib/clients/aizu_online_judge/utils.ts -// Assign A, B, C... to problem ids in ascending numeric order (the basic rule). -export function assignProblemLetters(problemIds: string[]): Map; - -// problemIndex defaults to the AOJ problem id, as before. -export function mapToTask( - problem: AOJTaskAPI, - contestId: string, - problemIndex: string = problem.id, -); -``` - -`AojChallengesApiClient.getTasks`(`src/lib/clients/aizu_online_judge/clients.ts:91-99`)は、`contestType === 'ICPC'` または `contestType === 'JAG' && round === 'PRELIM'` のときだけ記号を付ける。取り込み時の `contest_id` は `contest.abbr` でコンテスト単位なので、日ごとではなく `contest.days.flatMap((day) => day.problems)` にまとめて `assignProblemLetters` を当てる。JAG 模擬地区と PCK は数値のまま([スコープ外](#スコープ外残タスク))。合宿系は管理画面から取り込めない(`JagRound` は `PRELIM` / `REGIONAL` のみ)。 - -`contest.abbr` が seed の `contest_id` と一致することは、AOJ API へのアクセスが許可されず直接は確かめていない。ただし本番の ICPC / JAG 模擬国内は管理画面からの取り込み(`contest.abbr` をそのまま `contest_id` にする)で入っており、本番と seed の件数は一致する。 +番号は PR0 からの通し番号。PR0 / PR1 で完了した M4 / M7 / M10 / M11 は [done.md](./done.md)。 -#### 1-6 の契約 +| # | PR | 決定 | 理由 | +| --- | ----- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| M1 | 全体 | DB の値そのものを A/B/C に直す | 難所 3 | +| M2 | 全体 | 既存データは移行 UPDATE で直す | seed も取り込みも「既存なら skip」で、既存レコードは自動更新されない | +| M3 | PR2 | JAG 合宿系 467 問も対象に含める | seed にデータがあり、Provider だけが無い | +| M5 | 全体 | 管理画面からの取り込みでは、基本ルールで記号を付ける | 放置すると取り込むたびに数値の index が再混入する。毎年の新しいコンテストも追記なしで記号になる | +| M6 | 全体 | 対応表は基本ルールで生成し、欠落は人間がスプレッドシートと照合する | 難所 2 | +| M8 | 全体 | 移行は Prisma のデータマイグレーションで適用する | CI の既存の仕組みに乗る | +| M9 | PR2 | 合宿系の `title` から問題 ID のプレフィックスを除く | index だけ記号にすると `removeTaskIndexFromTitle` が空振りし、`'2389. …'` が表に出る | +| M12 | PR2 | JAG 模擬地区(本番のみ)を PR2 に含め、seed にも本番から抽出して追加する | 取り込みとの整合を seed で検査でき、ローカルで移行 SQL を確かめられる。記号がずれる 2005 / 2013 も seed に入るので、ローカルで取り込んでも skip される | +| M13 | PR2 | 基本ルールで記号がずれるコンテストは、移行 SQL でコンテストの全行を上書きし、各 UPDATE の件数を DO ブロックで検査する | UPDATE は 0 件でもエラーにならないため、想定外の件数なら RAISE してマイグレーション全体を戻す | +| M14 | PR2 | 管理画面からの取り込みでは、JAG は全 round(模擬国内 / 模擬地区)で記号を付ける | M5 の拡張。2005 / 2013 の補正は取り込み側に持たない(既存行は skip される) | +| M15 | PR2.5 | 合宿系・模擬地区・in AtCoder を 1 グループ「JAG 模擬地区」にまとめ、テーブルはコンテストごとにする | 並びはスプレッドシートの行順(新しい順)。contest_id から導けないので明示的な配列で持つ | +| M16 | PR2.5 | テーブル名は `getContestNameLabel` の名称に括弧書き(`JAG` / `有志` / `in JAG 夏合宿 20xx Day3`)を付ける。出典欄は変えない | 名称は現行の `JAG_TRANSLATIONS` に合わせる。「過去問」「in AtCoder」「AMATSUKAZE2025」は括弧ごと書かない。出典欄とそろえるのは #4018(コンテスト情報の DB 化)で行う | +| M17 | PR2.1 | AtCoder 上の 4 つは `ATCODER_JAG` を新設し、AOJ の 2025 / 2026 は seed と Supabase の CSV で入れる | `ATCODER_JAG` は enum・優先度・分類ルールのいずれも `UNIVERSITY` の直前に置き、4 つを完全一致で分類する。CSV は移行の後に記号付きで入れる | +| M18 | PR2 | seed の記号の検査では、補正したコンテストだけ期待値を literal で書く | 補正していないコンテストは `assignProblemLetters` と一致することを見る | -`getTaskLabels` は全 Provider が実装する共有インターフェイスに定義されているが、ICPC 国内予選の記号表示のために追加されたもの(`15f498f8`、2026-06-11)で、DB の index が数値であることが存在理由である。32 の Provider のうち上書きしているのは AOJ ICPC / JAG の 3 つだけで、残りは基底クラスの `{}` を受け継ぐだけなので、消しても動作は変わらない。呼び出し元は `TaskTable.svelte` だけで、`TaskTableBodyCell` を使うのも `TaskTable.svelte` だけである。合宿系・大学系・Courses・PCK・JAG 模擬地区には Provider が無い。AOJ の上書きには `override` が付いているので、消し忘れは型検査で検出される。 +OPTIONAL は 1 件のみ。**O5**: `createTask` の check-then-act を upsert 化 — 本件と独立の既存課題で別ブランチ推奨([詳細](./investigation.md#check-then-act-の競合))。 -後続の PR に分けて、インターフェイスの `getTaskLabels` だけを残す案は採らない。上書きする Provider が 0 になり、使われないコードが残るため。 +### 却下した方針(全体) -| 対象 | 変更 | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | -| `ContestTableProvider`(`contest_table_provider.ts:80`)、基底クラス(`contest_table_provider_base.ts:115`)、AOJ の上書き(`aoj_icpc_providers.ts:50,94`、`aoj_jag_providers.ts:59`) | `getTaskLabels` を削除 | -| `TaskTable.svelte`(`:71,105,301,318`) | `taskLabels` と `taskLabel` の受け渡しを削除 | -| `TaskTableBodyCell.svelte` | props から `taskLabel?: string` を削除し、表示は下記 | +PR ごとの却下は各ファイルに書いた。 -```typescript -// src/lib/utils/task.ts -export const buildTitleWithTaskIndex = (title: string, taskTableIndex: string): string => - `${taskTableIndex}. ${removeTaskIndexFromTitle(title, taskTableIndex)}`; +| 案 | 却下理由 | +| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| 対応表を `prisma/tasks.ts` に手書きし、seed だけ直す | 取り込みが数値を入れ続け、**新旧の書式が混在**する | +| 問題 ID の連番性から欠落を機械判定する | **論理的に成立しない**(難所 2) | +| seed を冪等化して production に流す | **不可**。`addUsers` / `addAtCoderAccounts` / `addWorkBooks` / `addAnswers` / `addVoteStatisticsDemoData` まで走る(`prisma/seed.ts`) | +| `title` に記号を入れて `'A. Peculiar Protocol'` に揃える | 同じ情報を 2 列に持つとドリフトする([詳細](./investigation.md#title-に記号を入れる案を採らない理由)) | +| 管理画面に同期 action を足す | **保留**。再実行できる利点はあるが、問題記号の入力元が定義できない([詳細](./investigation.md#管理画面案を保留にした理由)) | -// TaskTableBodyCell.svelte -let displayTitle = $derived( - isShownTaskIndex - ? buildTitleWithTaskIndex(taskResult.title, taskResult.task_table_index) - : removeTaskIndexFromTitle(taskResult.title, taskResult.task_table_index), -); -``` +## データ移行の共通手順 -AtCoder 側の表示は変わらない。`isShownTaskIndex: true` の Provider(典型 90 問 / ACL / 鉄則 / FPS 24 題 / アルゴリズムと数学 / 組合せゲーム 24 題 / AALC / DP / AWC)の title は `${task_table_index}. ` で始まるので、外してから付け直すと元に戻る。seed で title にプレフィックスの無い問題は `abs` の practice_1 だけで、ABS は `isShownTaskIndex: false` である。本番でも、プレフィックスの無い title はこの 1 件だけだった(2026-10-21 に集計)。 +PR2(記号の移行)と PR2.1(enum の追加)、PR3 に当てはまる。 -#### 1-7 のテスト +### CI での適用 -| 区分 | 対象 | -| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 削除 | `aoj_labels.test.ts` の `buildAojLetterMap` と `formatAojTitle`、`contest_table_provider_base.test.ts:27-37`(`getTaskLabels` の既定値)、`aoj_icpc_providers.test.ts` / `aoj_jag_providers.test.ts` の `getTaskLabels` のケース、`aoj_icpc_providers.test.ts` の `override map path`(3 か所。`AOJ_LABEL_OVERRIDES` を使う。うち 1 つは `generateTable` のテスト) | -| 書き換え | `aoj_icpc_providers.test.ts` / `aoj_jag_providers.test.ts` / `aoj_labels.test.ts` の fixture の `task_table_index` を数値から記号へ(`sortAojHeaderIds` の期待値を含む) | -| 追加 | `buildTitleWithTaskIndex` の単体テスト(`src/lib/utils/task.test.ts` を新設)、`assignProblemLetters` と `getTasks` の記号付与(`utils.test.ts` / `clients.test.ts`)、`prisma/tasks.ts` の ICPC / JAG 模擬国内の `problem_index` が `assignProblemLetters` の結果と一致する整合テスト(`src/lib/clients/aizu_online_judge/` に置く。src のテストから `prisma/tasks.ts` を読む前例は無い) | -| 手動 | コンテスト表には E2E テストもコンポーネントテストも無い。PR1 の前後でコンテスト表のスクリーンショットを比べ、79 コンテストの表示が変わらないことを確かめる。2026-10-21 に撮ったスクリーンショットを基準にする | +`.github/workflows/ci.yml` の流れ(2026-10-24 に確認): -書き換えたテストと追加したテストには `verify-test-strength` skill を当てる。 +1. `main` / `staging` への push と、`pull_request` で起動する +2. build / lint / check / unit-test が並列に走る +3. 4 つとも通ると、`main` 以外では `preview` ジョブ、`main` では `production` ジョブが走る。どちらも `vercel build` の**後**・Vercel デプロイの**前**に `prisma migrate deploy` を流す(build が失敗すれば DB に触れない) -#### PR1 の範囲の本番データ +| 起動したもの | ジョブ | 適用先 | +| ----------------------------------------------------------- | ------------ | --------------------------------------- | +| PR の CI(ref は `refs/pull/N/merge`)、`staging` への push | `preview` | `PREVIEW_DATABASE_URL`(staging の DB) | +| `main` への push | `production` | `PRODUCTION_DATABASE_URL` | -本番の ICPC 国内予選 / アジア地区 / JAG 模擬国内は 79 コンテスト / 632 問で、seed と一致する(2026-10-21 に集計、[内訳](./investigation.md#本番の件数2026-10-21))。本番の `AOJ_JAG` が seed より多い分(16 コンテスト / 158 問)は JAG 模擬地区で、PR1 / PR2 の移行 SQL の条件から外れる。 +- **PR の CI でも staging の DB に適用される**。PR を開いた瞬間ではなく、その PR の CI で 4 ジョブが通った後だが、**staging へのマージより前**である。`preview` ジョブの条件は `github.ref != 'refs/heads/main'` だけで、PR の ref も当たる。実際、#4217 の `pull_request` の実行でも `preview` ジョブが成功している(ログはログインしないと見られないので、`migrate deploy` の出力までは確かめていない)。staging へマージしたときにも走るが、適用済みなので何もしない +- このため SQL の確認は PR を開く前にローカルで済ませる。PR に push し直すたびに CI が走るが、適用済みのマイグレーションは編集しても再適用されない +- `main` へ昇格した時点で production に適用されるため、**staging での確認が自然にゲートになる** +- 本件の範囲外だが、`preview` ジョブの「Assign staging domain」の手順は、コメントに「if staging branch」とあるのに条件は `github.ref != 'refs/heads/main'` である。PR のデプロイにも staging のドメインが付け替わる -#### PR1 の実施記録(2026-10-24) +### 手順 -移行 SQL は `prisma/migrations/20261024033928_update_aoj_task_table_index/migration.sql`。 - -適用前の確認(ローカル DB、手順 3): - -- `migration.sql` の CTE と外側の `WHERE` をそのまま使い、`UPDATE` を `SELECT` に置き換えて「現在値 → 変更後」を出した。632 行 / 79 コンテスト、`task_id` の重複なし、全行で現在値 = `task_id`(数値) -- 生成スクリプトの JSON(632 件)と突き合わせ: 片側にしか無い ID 0、記号の不一致 0 -- 現行の表示ロジック(`buildAojLetterMap`)を現在値に当てた記号とも突き合わせ: 不一致 0。各コンテストの記号は A から連続 -- 2279 の title 更新対象は 1 行 -- `EXPLAIN`: `task` の Seq Scan 2 回(CTE と外側。`contest_type` に索引が無いため想定どおり)+ Hash Join - -適用後の確認(手順 4): - -- 632 行がプレビューの記号と一致。範囲外の AOJ(合宿系・模擬地区 518 行、PCK、Courses、大学系)は数値の index のまま。2279 は `I` / `Mountain` -- 旧コード(`Number` ソート + `buildAojLetterMap` + `formatAojTitle`)で適用前のデータを描画した結果と、新コード(`localeCompare` + `buildTitleWithTaskIndex`)で適用後のデータを描画した結果が、79 コンテストすべてで列順・セルの文字列とも一致した。スクリーンショットによる目視確認(1-7 の手動)は未実施 -- `removeTaskIndexFromTitle` は当初 `"{index}. "` の最初の出現を位置を問わず消していた。title の途中に `"{記号}. "` を含む問題は seed にも DB にも無い(0 件)が、レビューを受けて先頭のプレフィックスだけを消すよう直した(下記) - -計画からの差分: - -- AOJ API の JAG 模擬国内には 2 日開催のコンテスト(fixture の `JAGPrelim20162`、2738–2751)がある。取り込み時の `contest_id` は日ではなくコンテスト単位なので A–N になる(日ごとに振ると同じ `contest_id` に A–G が重複する)。seed / 本番は `JAGPrelim2016A` / `B` に分かれており、取り込みは既存 ID を skip するため影響しない - -テストの検出力(`verify-test-strength`): 次の 15 個の変異をすべて検出した。`assignProblemLetters` の数値→辞書順ソート / 降順 / 記号の開始位置 / 入力の破壊、`mapToTask` が `problemIndex` を無視、`getTasks` の JAG 条件の反転 / 全種別に記号 / 1 日目だけで記号付与、seed の 1 件を数値に戻す / A・B の入れ替え、`sortAojHeaderIds` の降順 / `Number` 比較 / 重複除去なし、`buildTitleWithTaskIndex` の除去なし / title をそのまま返す。単独で検出する変異が無いテストは seed 整合テストの `ICPC Prelim 2024 F is problem 1677` だけだが、このファイルで唯一の literal な期待値(対応表とデータが一緒にずれた場合の歯止め)なので残した。 - -クロスレビュー(Codex、2026-10-24): 移行 SQL に不具合の指摘は無かった。指摘 3 件はいずれも PR1 では対応せず、残タスクとする。 - -| 指摘 | 扱い | -| ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Moderate: API の `JAGPrelim20162`(2 回開催)を取り込むと、2745 が seed の `A` ではなく `H` になる | **残タスク**。本番には影響しない: 本番の `contest_id` は `JAGPrelim2016A` / `B` が各 7 問に分かれており(2026-10-24 に本番で確認)、移行 SQL は `contest_id` ごとに採番するので各 A–G になる。取り込みは既存の `task_id` を skip する。直すなら、取り込み時に 2 回開催を seed と同じ `A` / `B` の `contest_id` に分け、それぞれ A から振る | -| Minor: seed 整合テストの期待値を本番関数 `assignProblemLetters` で作っており、testing.md の「期待値は literal」に合わない | **残タスク**。記号自体の正しさは生成スクリプトの照合で確認済みで、このテストは取り込みとの整合を見るもの。literal な fixture(632 件)を足すかは別途判断する | -| 潜在リスク: `src/lib/services/tasks.ts` の `title.replace(task_table_index, …)` は、AOJ の問題に ContestTaskPair を作るとタイトル本文を置換する | **残タスク**。AOJ の pair は現在 0 件。直すときは先頭の `${index}. ` だけを置換し、`Amidakuji` のような本文と `A. Title` の両方を回帰テストに含める | - -CodeRabbit のレビュー(2026-10-24): - -| 指摘 | 扱い | -| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `clients.ts`: 2016 年の 2 回開催を `JAGPrelim2016A` / `B` に分けて取り込み、それぞれ A から振る | **見送り**。Codex の Moderate と同じ指摘で、上表のとおり残タスク。直すなら `getContests` 側も同じ `contest_id` に揃える必要がある | -| `task.ts`: `removeTaskIndexFromTitle` は先頭のプレフィックスだけを消す | **対応**。index が空のときに title 途中の `. ` まで消えていた(`Dr. Podboq` → `DrPodboq`)点も直る。seed と DB(ローカル 2,832 件)に途中だけに現れる title は無く、既存データの表示は変わらない | -| `buildTitleWithTaskIndex` のテストを `task.ts` に隣接するファイルへ移す | **対応**。`src/lib/utils/task.test.ts` を新設(`task_grade` と同じく、新しいテストは隣接、既存は `src/test/` に残す)。`removeTaskIndexFromTitle` の先頭限定のテストもここに置いた | - -### PR2 — JAG 夏 / 冬 / 春合宿(50 コンテスト / 467 問) - -| # | 内容 | 制約 | -| --- | ------------------------------------------ | ---------------------------------------------------------------------------- | -| 2-0 | **対応表を作る**(50 コンテスト分) | 欠落の判断が要るのは主にここ | -| 2-1 | 取り込み側の変更は不要 | 合宿系は管理画面から取り込めない(`JagRound` は `PRELIM` / `REGIONAL` のみ) | -| 2-2 | `prisma/tasks.ts` の該当 467 件を書き換え | | -| 2-3 | 移行 UPDATE(467 件) | | -| 2-4 | **`title` の数値プレフィックス除去**(M9) | 省くと問題ID がタイトルに露出する | -| 2-5 | **合宿系の Provider 追加** | PR2 の本体。`add-contest-table-provider` skill が使える | -| 2-6 | テスト追加 | 新規 Provider 分 | - -PR2 完了時点で G1 / G2 が全 129 コンテストに行き渡る。 - -### PR3 — 欠落問題の補完 - -**実施する。ただし着手前に調査フェーズを置く。** - -欠落には 2 種類あり、扱いが異なる。 - -- **AOJ に問題はあるが、コンテスト一覧に無い**(例: ICPC 国内予選 2002 の E = AOJ 1123。AOJ の Sources > ICPC - Prelim では 2002 年の問題として表示される): ダミーは不要で、実在の ID で通常の問題として登録できる。ただし管理画面の取り込みは一覧を使うので取り込めない。seed への追加と、本番には移行 SQL の INSERT が要る -- **AOJ に問題そのものが無い**: ID が無いので、下記のダミー Task レコードの設計が要る - -| # | 内容 | -| --- | ------------------------------------------------------------------------------------------------------------------------- | -| 3-0 | **要調査**。PR1 / PR2 で判明した欠落について、AOJ に問題番号で存在するかを確かめて上の 2 種類に分け、実数と分布を確認する | -| 3-1 | 一覧に無いだけの問題を、通常の問題として登録する(seed と移行 SQL) | -| 3-2 | AOJ に無い問題のダミー Task レコードの設計 → 実装 | - -3-2 で決めること: - -- **`task_id` の採番**。AOJ に無い問題には ID が存在しないため、衝突しない採番規則が要る -- **提出リンクの扱い**。リンク先が存在しないので無効化するか、表示自体を変えるか -- **投票 / タグ / 問題集への登場可否**。FK が `task_id` 参照であるため、ダミーを入れると各機能に波及する - -**PR1 / PR2 のデータ移行とは性質が異なる**(既存データの正規化ではなく、新しいレコードの設計判断)ため独立させる。 - -### Phase 0 の内訳(1-0 / 2-0 共通) - -1. **基本ルールで対象範囲のコンテストをスクリプト生成** — 現行の導出と同じ結果になるので、この時点でコンテストテーブルの表示は現状と一致する -2. **コンテスト単位で欠落の有無を外部情報から確認** ← 人手が必要なのはここだけ。外部情報は**メンテナが管理している問題一覧のスプレッドシート** -3. 欠落が途中にあるコンテストだけ、記号を手で補正する(末尾の欠落は記号がずれないので補正不要) -4. 欠落した問題が AOJ に問題番号で存在するか(一覧から外れているだけか)を確かめ、PR3 に引き継ぐ - -**1 で大半が埋まる。** 残るのは 2 の確認作業で、PR1 が 79 コンテスト、PR2 が 50 コンテスト。欠落が集中するのは PR2 の合宿系。 - -> **注意**: 「これまで正しく見えていた」は「欠落が無い」の証明ではない。欠落があっても表示は成立してしまう(前詰めでズレた記号が出るだけ)。 **PR1 でも確認工程 2 は省略しないこと。** - -**生成スクリプトには総数のアサーションを入れる。** 抽出漏れが欠落の誤検出に直結する(調査中に実際に踏んだ。[事例](./investigation.md#5-調査中の誤りと訂正自戒))。抽出件数が対象範囲の既知の値(PR1 は **79 コンテスト / 632 問**、PR2 は 50 / 467)と一致しなければ**エラーで停止**する。欠落として扱わない。 - -PR1 は `generate_aoj_problem_letters.ts` で行う(1-9 で削除済み。`git show 171bf3c9:docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_problem_letters.ts` で参照できる)。`prisma/tasks.ts` を直接 import するので、正規表現による抜き出しで取りこぼすことはない。 +1. `pnpm exec prisma migrate dev --create-only --name ` +2. SQL を `migration.sql` に書く +3. **ローカル DB で `UPDATE` を `SELECT` に置き換えて「現在値 → 変更後」を確認する** +4. ローカルで `pnpm exec prisma migrate dev` を実行して適用し、表示を確認する +5. PR を開く → staging へマージして確認 → `main` へ昇格 -照合の内容: +手順 3–4 は**必ず PR を開く前に**済ませる。PR を開いた時点で preview DB に適用されうるうえ、適用済みのマイグレーションは編集しても再適用されない。PR を開いた後に SQL の誤りが見つかったら、新しいマイグレーションを積む。 -- スプレッドシートのタイトルを記号順に書き写し(79 コンテスト / 633 問。AOJ のコンテスト一覧に無い 1 問を含む)、基本ルールで付けた記号の位置ごとに AOJ のタイトルと照合する。問題数・途中の欠番・**コンテスト内の順番の入れ替わり**まで検出できる -- 大文字小文字・空白・記号の違いは無視する。スプレッドシートのセル幅で末尾が切れていた 4 件は、推測で補わずに `…` を付け、見えている部分の前方一致で照合する -- 1 件でも合わなければ、対応表を出力せずに終了コード 1 で止まる -- わざと誤りを入れたコピー 5 種類(順番の入れ替え、途中のタイトルの誤り、途中の欠番、問題数の不足、`…` 付きタイトルの前半の誤り)で、すべて検出されることを確かめた +手順 3 により **SQL の正しさを構文から判断する必要がなくなる**。全件を見る必要はないが、**コンテストの切り替わり目(記号が `A` に戻る箇所)**と**問題数の少ない古いコンテスト**は目視する価値がある。 -確認できないこと: スプレッドシート自体の誤りと、書き写しの際にスプレッドシートと AOJ の両方と同じ誤りをした場合。書き写しは 2026-10-21 のスクリーンショットからの一度きりの手作業である。 +**staging が防げないもの**: **対応表・欠落判断そのものの誤り**。誤った記号でも SQL は正常に完走し、staging では「それらしい表示」になる(難所 2)。 -実行方法と期待する出力(終了コード 0): +### 事故対策 -```bash -pnpm exec tsx docs/dev-notes/2026-09-09/task-title-index-change-impact/generate_aoj_problem_letters.ts -# stderr: -# Contests: 79, tasks: 632 -# Absent from prisma/tasks.ts: ICPCPrelim2002 E -# All 632 titles match the official letters -# stdout: JSON (problem id -> letter, 632 entries) -``` +| 観点 | 内容 | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **爆発半径** | 一括で復元できるのは AOJ ICPC / JAG だけ(`task_table_index == task_id` が成り立っていたため)。WHERE 句が壊れて AtCoder 系に波及すると**一括復元の手段が無くなる**([実測](./investigation.md#復元可能性の実測)) | +| **復元** | 一度目の適用に限り、`task_table_index` も `title` のプレフィックスも `task_id` から再構成できる | +| **バックアップ** | Supabase Pro のため PITR が利用可。復元トリックが効くのは一度目だけなので、**再修正時は PITR が唯一の手段** | +| **`updated_at`** | `@updatedAt` は Prisma Client 側の実装で DB トリガーではない。生 SQL では更新されない。**意図的に含めない** | +| **キャッシュ** | TTL 1 時間(`src/lib/clients/cache.ts`)。生 SQL と CSV の投入は `invalidateTaskCaches()` を経由しない。「更新したのに表示が変わらない」ときはこれを疑う | +| **トランザクション境界** | マイグレーションファイル単位で原子的に適用される([done.md](./done.md#検証で分かったこと)) | +| **デプロイ直前の数分** | マイグレーションの適用から Vercel のデプロイ完了までの間は、旧コードが新しいデータを描画する。数分で解消するので**許容する** | -stdout の JSON は 1-2 で `prisma/tasks.ts` を書き換えるときに使う。リポジトリには残さない(スクリプトを実行すれば同じものが得られる)。 +### スプレッドシートとの照合 -PR1 の結果(2026-10-21): +対応表は次の手順で作る(M6)。PR1 のスクリプトを直して使う(仕組みは [done.md](./done.md#検証で分かったこと))。 -- 632 問すべてのタイトルが記号の位置で一致し、補正が要るコンテストは無い -- ICPC 国内予選 2002 の E(Enclosing Circles)は、AOJ に問題 1123 として存在するがコンテスト一覧に無く、`prisma/tasks.ts` にも本番にも無い。末尾の欠番なので A〜D の記号はずれない。PR3 で通常の問題として登録する対象(ID は A〜D の 1119〜1122 と 2003 年の A の 1124 の間) +1. 基本ルールで対象範囲の記号を生成する +2. コンテスト単位で、メンテナのスプレッドシートと照合する ← 人手が要るのはここだけ(書き写し) +3. 先頭・途中に欠落があるコンテストは、スプレッドシートの位置から記号を決める(末尾の欠落は記号がずれないので補正不要) +4. 欠落した問題が AOJ に問題番号で存在するかを確かめ、PR3 に引き継ぐ -### テストの注意 +> **注意**: 「これまで正しく見えていた」は「欠落が無い」の証明ではない。欠落があっても表示は成立してしまう(前詰めでずれた記号が出るだけ)。 -テスト約 2,000 行と出典欄の fixture が、数値 index と全角括弧を前提にしている。全角括弧の分は PR0(0-3)、数値 index の分は PR1([1-7](#1-7-のテスト))で直す。作業は機械的だが、期待値を新形式へ一括置換すると **テストが仕様を検証しなくなる**。`docs/guides/agent-rules/testing.md` の Test integrity と `verify-test-strength` skill の適用対象。内訳は [investigation.md](./investigation.md#テストの影響範囲)。 +**スクリプトには総数のアサーションを入れる。** 抽出漏れが欠落の誤検出に直結する(調査中に実際に踏んだ。[事例](./investigation.md#6-調査中の誤りと訂正自戒))。抽出件数が対象範囲の既知の値と一致しなければエラーで止め、欠落として扱わない。 diff --git a/src/lib/clients/aizu_online_judge/clients.test.ts b/src/lib/clients/aizu_online_judge/clients.test.ts index 0ba4458ab..3ca1e6830 100644 --- a/src/lib/clients/aizu_online_judge/clients.test.ts +++ b/src/lib/clients/aizu_online_judge/clients.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import nock from 'nock'; vi.mock('#lib/utils/time.js', () => ({ @@ -8,6 +8,7 @@ vi.mock('#lib/utils/time.js', () => ({ import { HttpRequestClient } from '#lib/clients/http_client.js'; import { ContestTaskCache } from '#lib/clients/cache_strategy.js'; import { Cache } from '#lib/clients/cache.js'; +import { AOJ_API_BASE_URL } from '#lib/constants/urls.js'; import { AojCoursesApiClient, AojChallengesApiClient } from './clients'; @@ -17,19 +18,29 @@ import type { AOJCourseAPI, AOJChallengeContestAPI, AOJTaskAPIs } from './types' import { loadMockData } from '../fixtures/helpers'; -const AOJ_API_BASE = 'https://judgeapi.u-aizu.ac.jp'; +// Literal on purpose: a wrong AOJ_API_BASE_URL (e.g. no trailing slash) misses the interceptors. +const AOJ_API_ORIGIN = 'https://onlinejudge.u-aizu.ac.jp'; + +// Keep CI off the real AOJ API: a request without a matching interceptor fails. +beforeAll(() => { + nock.disableNetConnect(); +}); + +afterAll(() => { + nock.enableNetConnect(); +}); function buildCache(): ContestTaskCache { return new ContestTaskCache(new Cache(), new Cache()); } function buildCoursesClient(): AojCoursesApiClient { - const httpClient = new HttpRequestClient(`${AOJ_API_BASE}/`); + const httpClient = new HttpRequestClient(AOJ_API_BASE_URL); return new AojCoursesApiClient(httpClient, buildCache()); } function buildChallengesClient(): AojChallengesApiClient { - const httpClient = new HttpRequestClient(`${AOJ_API_BASE}/`); + const httpClient = new HttpRequestClient(AOJ_API_BASE_URL); return new AojChallengesApiClient(httpClient, buildCache()); } @@ -87,7 +98,7 @@ describe('AojCoursesApiClient', () => { describe('getContests', () => { test('fetches and transforms course contests', async () => { - nock(AOJ_API_BASE).get('/courses').reply(200, courseContestsMock); + nock(AOJ_API_ORIGIN).get('/api/courses').reply(200, courseContestsMock); const client = buildCoursesClient(); const contests = await client.getContests(); @@ -95,7 +106,7 @@ describe('AojCoursesApiClient', () => { }); test('each contest has id and title', async () => { - nock(AOJ_API_BASE).get('/courses').reply(200, courseContestsMock); + nock(AOJ_API_ORIGIN).get('/api/courses').reply(200, courseContestsMock); const client = buildCoursesClient(); const contests = await client.getContests(); @@ -106,7 +117,7 @@ describe('AojCoursesApiClient', () => { }); test('contest id matches course shortName', async () => { - nock(AOJ_API_BASE).get('/courses').reply(200, courseContestsMock); + nock(AOJ_API_ORIGIN).get('/api/courses').reply(200, courseContestsMock); const client = buildCoursesClient(); const contests = await client.getContests(); @@ -119,7 +130,10 @@ describe('AojCoursesApiClient', () => { describe('getTasks', () => { test('fetches and transforms course tasks (only course-format ids)', async () => { - nock(AOJ_API_BASE).get('/problems').query({ size: '10000' }).reply(200, courseTasksMock); + nock(AOJ_API_ORIGIN) + .get('/api/problems') + .query({ size: '10000' }) + .reply(200, courseTasksMock); const client = buildCoursesClient(); const tasks = await client.getTasks(); @@ -131,7 +145,10 @@ describe('AojCoursesApiClient', () => { }); test('each task has id, contest_id, problem_index, and title', async () => { - nock(AOJ_API_BASE).get('/problems').query({ size: '10000' }).reply(200, courseTasksMock); + nock(AOJ_API_ORIGIN) + .get('/api/problems') + .query({ size: '10000' }) + .reply(200, courseTasksMock); const client = buildCoursesClient(); const tasks = await client.getTasks(); @@ -144,7 +161,10 @@ describe('AojCoursesApiClient', () => { }); test('task contest_id is derived from task id prefix', async () => { - nock(AOJ_API_BASE).get('/problems').query({ size: '10000' }).reply(200, courseTasksMock); + nock(AOJ_API_ORIGIN) + .get('/api/problems') + .query({ size: '10000' }) + .reply(200, courseTasksMock); const client = buildCoursesClient(); const tasks = await client.getTasks(); @@ -170,7 +190,7 @@ describe('AojChallengesApiClient', () => { let client: AojChallengesApiClient; beforeEach(() => { - nock(AOJ_API_BASE).get('/challenges/cl/PCK/PRELIM').reply(200, contestsMock); + nock(AOJ_API_ORIGIN).get('/api/challenges/cl/PCK/PRELIM').reply(200, contestsMock); client = buildChallengesClient(); }); @@ -209,7 +229,7 @@ describe('AojChallengesApiClient', () => { let client: AojChallengesApiClient; beforeEach(() => { - nock(AOJ_API_BASE).get('/challenges/cl/PCK/FINAL').reply(200, contestsMock); + nock(AOJ_API_ORIGIN).get('/api/challenges/cl/PCK/FINAL').reply(200, contestsMock); client = buildChallengesClient(); }); @@ -233,7 +253,7 @@ describe('AojChallengesApiClient', () => { let client: AojChallengesApiClient; beforeEach(() => { - nock(AOJ_API_BASE).get('/challenges/cl/JAG/PRELIM').reply(200, contestsMock); + nock(AOJ_API_ORIGIN).get('/api/challenges/cl/JAG/PRELIM').reply(200, contestsMock); client = buildChallengesClient(); }); @@ -283,7 +303,7 @@ describe('AojChallengesApiClient', () => { let client: AojChallengesApiClient; beforeEach(() => { - nock(AOJ_API_BASE).get('/challenges/cl/JAG/REGIONAL').reply(200, contestsMock); + nock(AOJ_API_ORIGIN).get('/api/challenges/cl/JAG/REGIONAL').reply(200, contestsMock); client = buildChallengesClient(); }); @@ -320,7 +340,7 @@ describe('AojChallengesApiClient', () => { let client: AojChallengesApiClient; beforeEach(() => { - nock(AOJ_API_BASE).get('/challenges/cl/ICPC/PRELIM').reply(200, contestsMock); + nock(AOJ_API_ORIGIN).get('/api/challenges/cl/ICPC/PRELIM').reply(200, contestsMock); client = buildChallengesClient(); }); @@ -366,7 +386,7 @@ describe('AojChallengesApiClient', () => { let client: AojChallengesApiClient; beforeEach(() => { - nock(AOJ_API_BASE).get('/challenges/cl/ICPC/REGIONAL').reply(200, contestsMock); + nock(AOJ_API_ORIGIN).get('/api/challenges/cl/ICPC/REGIONAL').reply(200, contestsMock); client = buildChallengesClient(); }); diff --git a/src/lib/constants/urls.ts b/src/lib/constants/urls.ts index fbba5c3b6..5805b5f68 100644 --- a/src/lib/constants/urls.ts +++ b/src/lib/constants/urls.ts @@ -6,13 +6,14 @@ export const ATCODER_PROBLEMS_URL: string = 'https://kenkoooo.com/atcoder/#/'; export const ATCODER_PROBLEMS_API_BASE_URL: string = 'https://kenkoooo.com/atcoder/resources/'; -export const AOJ_API_BASE_URL: string = 'https://judgeapi.u-aizu.ac.jp/'; - // Note: // AIZU ONLINE JUDGE (AOJ) has multiple versions: v1.0, v2.0, and v3.0. // As of late October 2024, we are using v2.0, which appears to be the main site. export const AOJ_BASE_URL: string = 'https://onlinejudge.u-aizu.ac.jp'; +// Moved from judgeapi.u-aizu.ac.jp on 2026-09-07; without the trailing slash, new URL() drops /api. +export const AOJ_API_BASE_URL: string = `${AOJ_BASE_URL}/api/`; + export const AOJ_TASKS_URL: string = `${AOJ_BASE_URL}/problems`; export const AOJ_ATCODER_JOI_URL: string = 'https://joi.goodbaton.com/';