diff --git a/.claude/skills/benchmark-gql/SKILL.md b/.claude/skills/benchmark-gql/SKILL.md new file mode 100644 index 00000000..c6c85f36 --- /dev/null +++ b/.claude/skills/benchmark-gql/SKILL.md @@ -0,0 +1,146 @@ +--- +name: benchmark-gql +description: Benchmark the TrainLCD StationAPI production GraphQL endpoint (gql.trainlcd.app) against staging (gql-stg.trainlcd.app) across every query in schema/public.graphql, measuring both client-side response time and Cloudflare Worker CPU Time, and append the result as a Markdown report under benchmarks/. Use whenever the user asks to compare, benchmark, or profile the two endpoints, or to check the performance impact of a change before it reaches master. +--- + +# benchmark-gql + +本番 (`https://gql.trainlcd.app` / Worker `stationapi`) とステージング +(`https://gql-stg.trainlcd.app` / Worker `stationapi-stg`) の GraphQL 性能を、 +`schema/public.graphql` の全 Query フィールドについて比較する。 + +両環境は同じデータを積んでいる (`/__health` が返す駅数・路線数・会社数が一致する) ので、 +出てくる差は実装差だけ。ステージングは `dev`、本番は `master` から出るので、 +このベンチは「次のリリースで本番の性能がどう変わるか」を先に見るものになる。 + +## 何を測るか + +| 指標 | 出どころ | 何が見えるか | +| --- | --- | --- | +| **CPU Time** | `wrangler tail --format json` の `cpuTime` | Worker が実際に計算に使った時間。ネットワークもコロの当たり外れも含まないので、**実装差はここに出る**。判定はこの列で行う | +| Worker wall time | 同 `wallTime` | Worker 内の実時間。この Worker は I/O をしないので CPU Time とほぼ一致し、乖離したら外部待ちが混ざったサイン | +| クライアント応答時間 | keep-alive を張った 1 本の接続での往復時間 | 利用者から見た体感。回線とエッジ処理と転送を含む | +| 応答サイズ | レスポンスのバイト数 | 転送時間の効き方を読むための補助 | + +CPU Time はリクエストと `cf-ray` で突き合わせる。`wrangler tail` 側には +`--header x-stationapi-bench:<実行 ID>` を渡すので、本番に実ユーザーのトラフィックが +流れていてもこの実行のリクエストだけが降ってくる。 + +## 前提条件 + +- wrangler の認証が済んでいて、権限に **`workers_tail (read)`** があること。 + 無いと CPU Time が欠測になる (応答時間の計測だけは続行する)。 + 版は `Makefile` の `WRANGLER_VERSION` に合わせる。版を指定しない `npx wrangler` は + その時点の最新を取ってくるので、Cloudflare の認証情報を持つ環境で走らせるものとしては + 固定しておく。`bench.py` も同じ値を読んで `wrangler tail` を起動する。 + + ```bash + npx --yes wrangler@"$(sed -n 's/^WRANGLER_VERSION := //p' Makefile)" whoami + ``` +- Python 3。依存は標準ライブラリのみ。 +- リポジトリルートで実行すること (`wrangler tail` の cwd に使う)。 + +## 手順 + +1. **両環境が同じデータかを先に確認する。** 違えば差は実装起因ではない。 + + ```bash + for u in https://gql.trainlcd.app https://gql-stg.trainlcd.app; do curl -s "$u/__health"; echo " <- $u"; done + ``` + + 食い違っていたら、その旨をユーザーに伝えてから続けるか止めるかを決める。 + +2. **ベンチを回す。** 既定は全 23 ケース × 15 反復 × 2 環境で、2〜3 分。 + 本番側が遅いクエリを抱えているとその分伸びる。 + + ```bash + python3 .claude/skills/benchmark-gql/bench.py + # make bench でも同じ (追加引数は make bench BENCH_ARGS="--repeat 30") + ``` + + `wrangler tail` の接続待ちだけで最大 90 秒かかるので、**バックグラウンド実行にして待つ**こと。 + フォアグラウンドだとツールのタイムアウトに当たる。 + +3. **レポートの「所見」節を埋める。** ここだけは自動生成しない。差が出たクエリについて、 + `src/graphql/query.rs` や `stationapi/src/use_case/interactor/query.rs` の実装、 + および `jj diff --from 'master@origin' --to 'dev@origin'` を見て、 + **どの変更が効いているか**を書く。差が出なかったこと自体が結論なら、それも明記する。 + + 仮説を確かめたいときは、一時的なケース定義を作って `--queries` と `--out-dir` を + スクラッチ領域へ向けて回す。正式なカタログと `benchmarks/` を汚さずに試せる。 + +4. **ユーザーに要約を返す。** レポートのパスと、CPU Time で有意差 (±10% 超) が出た + クエリだけを挙げる。全部の表を会話に貼らない。 + +## オプション + +| オプション | 用途 | +| --- | --- | +| `--repeat N` | 反復数 (既定 15)。CPU Time はミリ秒の整数なので、軽いクエリの分解能を上げたいときは 30〜50 に増やす | +| `--warmup N` | 破棄するウォームアップ回数 (既定 3)。ここで応答の妥当性も検証し、GraphQL エラーが出たら即座に止まる | +| `--only a,b,c` | ケースを絞る。特定のクエリを追い込むとき用 | +| `--skip-baseline` | `ping` / `health` を除く。ただし `ping` を外すと応答時間の回線補正列が出なくなる | +| `--no-cpu` | `wrangler tail` を使わない。認証が無い環境や、応答時間だけ手早く見たいとき | +| `--pause SEC` | リクエスト間にスリープを入れる。連続実行でアイソレートが暖まりすぎるのを避けたいとき | +| `--dry-run` | ファイルを書かず Markdown を標準出力へ。動作確認用 | +| `--self-test` | クエリ解析とカバレッジ判定の自己診断だけ走らせる。**リクエストは送らない**。失敗があれば終了コード 1 | +| `--rerender PATH` | `benchmarks/raw/*.json` からレポートを作り直す。**リクエストは一切送らない**。集計や表の書き方を直したときに、本番へ投げ直さずに過去のレポートを更新できる。手で書いた「所見」節は残す | +| `--queries PATH` | 別のケース定義ファイルを使う。仮説を追い込む一時的なケースを、正式なカタログを汚さずに試せる (`--out-dir` と組み合わせる) | +| `--note "..."` | レポート冒頭に一言添える (例: 「#1647 のマージ後」) | + +## 出力 + +| パス | 内容 | +| --- | --- | +| `benchmarks/YYYYMMDD-HHMMSS.md` | レポート本体 | +| `benchmarks/raw/YYYYMMDD-HHMMSS.json` | 全リクエストの生データ。あとから別の切り口で集計し直せる | +| `benchmarks/index.md` | 実行履歴。1 実行 1 行が追記される | +| `benchmarks/.logs/` | `wrangler tail` の生ログ (Git 管理外) | + +## ケースを足す・直す + +定義は [`queries.json`](./queries.json)。 + +- `fragments` に置いた GraphQL フラグメントを、各ケースの `uses` で参照すると本文に連結される。 + `Station` を返すクエリは `StationCore`、`StationNested` を返すクエリ + (`routes` / `connectedRoutes` / `trainRoute`) は `StationNestedCore` を使う。両者は + 同じフィールドを並べた別型なので、取り違えるとフラグメントの型不一致で GraphQL エラーになる。 +- `weight` は `baseline` / `light` / `medium` / `heavy` の目安。`baseline` は + `--skip-baseline` の対象になる。 +- **既存ケースの `variables` は変えない。** 過去のレポートと比較できなくなる。 + 条件を変えたいときは新しい `name` のケースを足す。 +- `Query` にフィールドを追加したら、ここにもケースを足す。CI が SDL を突き合わせるので、 + スキーマ変更は必ずこのファイルの更新とセットで考える。足りているかは + `bench.py --self-test` で確かめられる (起動時にも同じ検査が走り、欠けていれば警告する)。 +- **`queries.json` や `bench.py` を触ったら `--self-test` を回す。** この検査は + 「ケースの足し忘れを警告する」ためだけのもので、壊れても黙って警告が出なくなるだけなので + 気付けない。過去に取りこぼした条件 (ネストした同名フィールド、コメント内の括弧、 + ディレクティブ名、ブロック文字列) を assert で固定してある。`make test` は Rust 専用。 +- 変数に使う ID は `data/*.csv` の実在レコードから採ること。`e_status` が `0` 以外の路線 + (例: `11328` 成田エクスプレスは `3`) は `lines` から返らないので、固定値には使わない。 + +## 読み方と落とし穴 + +- **判定は CPU Time の平均比。** ±10% を超えたら「速い / 遅い」、それ以内は「同等」。 + ミリ秒整数の丸めがあるので、1 反復ぶんの値は信用しない。両環境とも平均 1 ms を切る + ケース (`ping` / `line` など) は比が丸め誤差になるため「分解能未満」として判定を保留する。 + ここを詰めたいときは `--repeat` を増やす。 +- **コールドスタートは別枠。** WASM の実体化と索引構築で CPU 250 ms 超になる。 + 固定閾値だと本来重いクエリを巻き込む (本番の `trainRoute` は定常で 500 ms 以上使う) ので、 + 同じケース・同じ環境の中央値の 2.5 倍かつ +150 ms を超えた標本だけを外し、 + 件数を「コールドスタート」節に出す。ウォームアップを入れてもアイソレートの + 割り当て先が変わると出るので、出ること自体は異常ではない。 +- **応答時間の環境間比較は、そのままでは使えない。** 本番とステージングは別ドメインで + 経路も別なので、`__ping` の時点で 10 ms 単位の固定差がつくことがあり、 + その向きは実行ごとに変わる。レポートの `サーバ分` 列が、同じ環境の `__ping` の + 最小往復時間を引いた補正済み値。引いた残りが 2 ms を切ったら回線のゆらぎと + 区別がつかないので「誤差内」になる。 +- **2 倍に満たない差は 2 回まわして確かめる。** `bench.py` は計測前に全ケースを一巡させて + 両環境を暖め (直前に叩いていなかった側だけ 4〜5 割高く出るのを防ぐため)、 + 標本も「全ケースを 1 巡」の繰り返しで集めて実行全体へばらしている。それでも実行を + またぐと平均は動く。実測で、同じクエリのステージング平均が別実行で 89 ms と 52 ms に + 割れたことがある。桁で違う差 (trainRoute の -98% など) はそのまま信じてよいが、 + ±数十パーセントは 1 回では結論にしない。 +- **本番に負荷をかけている自覚を持つ。** 既定でもウォームアップ込みで本番へ 400 リクエスト + 以上飛ばし、そのうち何十件かは CPU を 500 ms 以上使う。`--repeat` を大きくするときや + 短時間に繰り返すときはユーザーに一声かける。 diff --git a/.claude/skills/benchmark-gql/bench.py b/.claude/skills/benchmark-gql/bench.py new file mode 100755 index 00000000..8ab39c5a --- /dev/null +++ b/.claude/skills/benchmark-gql/bench.py @@ -0,0 +1,1148 @@ +#!/usr/bin/env python3 +"""本番 (gql.trainlcd.app) とステージング (gql-stg.trainlcd.app) の GraphQL クエリ性能を比較する。 + +クライアント側の応答時間だけでなく、Cloudflare Worker の CPU Time も測る。 +CPU Time は `wrangler tail --format json` が 1 リクエストごとに吐く `cpuTime` / +`wallTime` (いずれもミリ秒の整数) から取り、リクエストとイベントは `cf-ray` で +突き合わせる。tail 側には `--header` フィルタを渡すので、本番に実ユーザーの +トラフィックが流れていてもこの実行のリクエストだけが降ってくる。 + + python3 .claude/skills/benchmark-gql/bench.py # 全ケース、既定 15 反復 + python3 .claude/skills/benchmark-gql/bench.py --repeat 30 + python3 .claude/skills/benchmark-gql/bench.py --only station,trainRoute_long + python3 .claude/skills/benchmark-gql/bench.py --no-cpu # tail を使わず応答時間だけ + +依存は Python 3 標準ライブラリのみ。wrangler は npx 経由で呼ぶ。 +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import re +import signal +import statistics +import subprocess +import sys +import time +from datetime import datetime, timezone, timedelta +from http.client import HTTPSConnection +from pathlib import Path + +SKILL_DIR = Path(__file__).resolve().parent +REPO_ROOT = SKILL_DIR.parents[2] +DEFAULT_OUT_DIR = REPO_ROOT / "benchmarks" +JST = timezone(timedelta(hours=9)) + +# 比較対象。script は wrangler tail に渡す Worker 名 (wrangler.jsonc の name)。 +TARGETS = [ + {"key": "production", "label": "本番", "origin": "https://gql.trainlcd.app", "script": "stationapi"}, + {"key": "staging", "label": "ステージング", "origin": "https://gql-stg.trainlcd.app", "script": "stationapi-stg"}, +] + +USER_AGENT = "stationapi-bench/1.0 (+https://github.com/TrainLCD/StationAPI)" +BENCH_HEADER = "x-stationapi-bench" + +# コールドスタート (WASM 実体化 + 索引構築) の判定。実測では 250〜450 ms かかる。 +# 固定閾値だと「もともと重いクエリ」を巻き込む (本番の trainRoute は定常で 850 ms 出る) +# ので、同じケース・同じ環境の中央値からどれだけ跳ねたかで見る。コールドは少数派なので +# 中央値は定常側に残り、両方の条件を満たした標本だけが外れる。 +COLD_START_FACTOR = 2.5 # 中央値の何倍以上か +COLD_START_MARGIN_MS = 150 # かつ中央値から何 ms 以上離れているか + + +# --------------------------------------------------------------------------- クエリ定義 + + +def wrangler_argv() -> list[str]: + """wrangler の起動コマンド。 + + AGENTS.md の方針どおり版は Makefile の WRANGLER_VERSION を唯一の出どころにする。 + ここに版を書くと「Makefile / 2 つの deploy workflow / composite action」の 4 箇所に + 5 箇所目が増えてずれるので、実行時に読み取る。 + """ + makefile = REPO_ROOT / "Makefile" + version = None + if makefile.exists(): + for line in makefile.read_text(encoding="utf-8").splitlines(): + if line.startswith("WRANGLER_VERSION"): + version = line.split(":=", 1)[-1].strip() + break + pkg = f"wrangler@{version}" if version else "wrangler" + return ["npx", "--yes", pkg] + + +_NAME = re.compile(r"[A-Za-z_]\w*") + + +def root_query_fields(document: str) -> set[str]: + """オペレーション本文の深さ 1 — つまり Query の直下で選ぶフィールド名を返す。 + + 本文全体を正規表現で舐めると、ネストした同名フィールドまで拾ってしまう。 + たとえば `Station.lines(transportType: Rail)` が Query の `lines` を + 覆ったことになり、`lines` のケースを足し忘れても警告が出なくなる。 + 深さで切れば取り違えは起きない。 + """ + fields: set[str] = set() + depth = paren = 0 + i, n = 0, len(document) + while i < n: + ch = document[i] + if document.startswith('"""', i): # ブロック文字列。 + # 単純に " 単位で食うと、中に " が 1 つあるだけで境界がずれ、 + # 続く # や括弧が本文として解釈されてしまう。丸ごと 1 トークンで飛ばす。 + j = i + 3 + while j < n: + if document[j] == "\\": + j += 2 # \""" は終端ではない + continue + if document.startswith('"""', j): + break + j += 1 + i = n if j >= n else j + 3 + continue + if ch == '"': # 引数の文字列。中の括弧を数えない + i += 1 + while i < n and document[i] != '"': + i += 2 if document[i] == "\\" else 1 + i += 1 + continue + if ch == "#": # 行コメント。 + # 深さ 1 の外でも捨てる。コメントの散文に括弧が 1 つ混じるだけで + # 深さの追跡がずれ、フィールドの集合ごと壊れるため。 + newline = document.find("\n", i + 1) + i = n if newline == -1 else newline + 1 + continue + if ch == "(": + paren += 1 + elif ch == ")": + paren = max(paren - 1, 0) + elif paren: + pass # 引数の中はフィールドではない + elif ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth <= 0: + break # オペレーション本文の終わり + elif depth == 1: + if ch == "@": # ディレクティブ名はフィールドではない + directive = _NAME.match(document, i + 1) + i = directive.end() if directive else i + 1 + continue + if document.startswith("...", i): + i += 3 # フラグメントスプレッドは Query フィールドではない + continue + m = _NAME.match(document, i) + if m: + j = m.end() + while j < n and document[j] in " \t\r\n": + j += 1 + if j < n and document[j] == ":": + i = j + 1 # エイリアス。実フィールド名は次のトークン + continue + fields.add(m.group(0)) + i = m.end() + continue + i += 1 + return fields + + +def uncovered_query_fields(cases: list[dict]) -> list[str]: + """schema/public.graphql の Query フィールドのうち、どのケースも叩かないものを返す。 + + Query にフィールドが増えたのにケースを足し忘れると、そのクエリだけ性能が + 見えないまま貯まり続ける。絞り込み前の全ケースで判定する。 + """ + schema = REPO_ROOT / "schema" / "public.graphql" + if not schema.exists(): + return [] + body = re.search(r"type Query \{(.*?)\n\}", schema.read_text(encoding="utf-8"), re.S) + if not body: + return [] + fields = [m.group(1) for m in re.finditer(r"^\s*(\w+)\s*[(:]", body.group(1), re.M)] + covered: set[str] = set() + for case in cases: + covered |= root_query_fields(case.get("query", "")) + return [f for f in fields if f not in covered] + + +def load_cases(path: Path, only: list[str] | None, skip_baseline: bool) -> tuple[list[dict], list[str]]: + doc = json.loads(path.read_text(encoding="utf-8")) + fragments = doc["fragments"] + cases = doc["cases"] + uncovered = uncovered_query_fields(cases) + # 未知判定は絞り込み前の名前で行う。--skip-baseline で落ちたケースは + # 「存在しない」のではなく「今回対象外」なので、--only に書かれても未知ではない。 + known = {c["name"] for c in cases} + if skip_baseline: + cases = [c for c in cases if c.get("weight") != "baseline"] + if only: + wanted = set(only) + unknown = wanted - known + if unknown: + sys.exit(f"未知のケース: {', '.join(sorted(unknown))}") + cases = [c for c in cases if c["name"] in wanted] + for case in cases: + if case.get("kind") == "http": + continue + body = case["query"] + for name in case.get("uses", []): + if name not in fragments: + sys.exit(f"{case['name']}: 未定義のフラグメント {name}") + body += "\n" + fragments[name] + case["_document"] = body + return cases, uncovered + + +# --------------------------------------------------------------------------- HTTP + + +class Client: + """接続を使い回す最小の HTTPS クライアント。 + + urllib は毎回接続を張り直すので、TLS ハンドシェイクが測定値に混ざる。 + keep-alive を保った 1 本の接続で測るほうがサーバー側の差が見えやすい。 + """ + + def __init__(self, origin: str, run_id: str, timeout: float): + assert origin.startswith("https://") + self.host = origin[len("https://"):] + self.run_id = run_id + self.timeout = timeout + self.conn: HTTPSConnection | None = None + + def _connect(self) -> HTTPSConnection: + if self.conn is None: + self.conn = HTTPSConnection(self.host, timeout=self.timeout) + return self.conn + + def close(self) -> None: + if self.conn is not None: + self.conn.close() + self.conn = None + + def request(self, method: str, path: str, payload: dict | None) -> dict: + body = json.dumps(payload).encode("utf-8") if payload is not None else None + headers = { + "user-agent": USER_AGENT, + "accept": "application/json", + BENCH_HEADER: self.run_id, + } + if body is not None: + headers["content-type"] = "application/json" + for attempt in (0, 1): + conn = self._connect() + try: + started = time.perf_counter() + conn.request(method, path, body=body, headers=headers) + resp = conn.getresponse() + data = resp.read() + elapsed = (time.perf_counter() - started) * 1000 + except Exception: + self.close() + if attempt == 0: + continue + raise + ray = resp.getheader("cf-ray") or "" + return { + "status": resp.status, + "ray": ray.split("-")[0], + "colo": ray.split("-")[1] if "-" in ray else "", + "bytes": len(data), + "client_ms": elapsed, + "body": data, + } + raise RuntimeError("unreachable") + + +def check_response(case: dict, target: dict, res: dict, payload: dict | None) -> None: + """失敗した応答を標本に混ぜない。 + + エラー応答は速くて小さいので、混ざると失敗した側が「速い」という逆の結論になる。 + ウォームアップと本計測の両方でこれを通す。 + """ + if res["status"] != 200: + sys.exit(f"{case['name']} / {target['key']}: HTTP {res['status']}") + if payload is not None: + errs = graphql_errors(res["body"]) + if errs: + sys.exit(f"{case['name']} / {target['key']}: GraphQL エラー " + f"{json.dumps(errs, ensure_ascii=False)[:400]}") + + +def graphql_errors(raw: bytes) -> list | None: + try: + doc = json.loads(raw) + except json.JSONDecodeError: + return [{"message": "レスポンスが JSON ではありません"}] + return doc.get("errors") + + +# --------------------------------------------------------------------------- wrangler tail + + +class Tail: + """`wrangler tail` を JSON 形式で回し、cf-ray -> {cpuTime, wallTime} の表を作る。""" + + def __init__(self, script: str, run_id: str, log_dir: Path): + self.script = script + self.run_id = run_id + self.out_path = log_dir / f"tail-{script}.jsonl" + self.err_path = log_dir / f"tail-{script}.err" + self.proc: subprocess.Popen | None = None + self._out = None + self._err = None + + def start(self) -> None: + self._out = self.out_path.open("w", encoding="utf-8") + self._err = self.err_path.open("w", encoding="utf-8") + self.proc = subprocess.Popen( + wrangler_argv() + ["tail", self.script, "--format", "json", + "--header", f"{BENCH_HEADER}:{self.run_id}"], + stdout=self._out, stderr=self._err, cwd=str(REPO_ROOT), + start_new_session=True, + ) + + def stop(self) -> None: + """何度呼んでも安全。異常終了時の後始末からも呼ばれる。""" + if self.proc is not None and self.proc.poll() is None: + try: + os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM) + except ProcessLookupError: + pass + try: + self.proc.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL) + for handle in (self._out, self._err): + if handle is not None and not handle.closed: + handle.close() + + def died(self) -> str | None: + """tail が落ちていれば stderr を返す。""" + if self.proc is not None and self.proc.poll() is not None: + return self.err_path.read_text(encoding="utf-8", errors="replace").strip() + return None + + def events(self) -> dict[str, dict]: + """cf-ray をキーにしたイベント表。wrangler は整形済み JSON を連結して吐く。""" + if not self.out_path.exists(): + return {} + text = self.out_path.read_text(encoding="utf-8", errors="replace") + decoder = json.JSONDecoder() + found: dict[str, dict] = {} + i = 0 + while i < len(text): + while i < len(text) and text[i] in " \r\n\t": + i += 1 + if i >= len(text): + break + try: + obj, i = decoder.raw_decode(text, i) + except json.JSONDecodeError: + break # 途中で切れた最後の 1 件 + if not isinstance(obj, dict): + continue + # fetch 以外のイベント (cron など) では event が null になりうる + headers = ((obj.get("event") or {}).get("request") or {}).get("headers") or {} + ray = (headers.get("cf-ray") or "").split("-")[0] + if ray: + found[ray] = { + "cpu_ms": obj.get("cpuTime"), + "wall_ms": obj.get("wallTime"), + "outcome": obj.get("outcome"), + "version_id": (obj.get("scriptVersion") or {}).get("id"), + } + return found + + +def wait_for_tail(tails: dict[str, Tail], clients: dict[str, Client], timeout: float) -> bool: + """tail が実際にイベントを運んでくるまで待つ。 + + `--format json` の wrangler は接続完了を何も出力しないので、ヘッダ付きの + 捨てリクエストを撃ち、その cf-ray が降ってくるのを接続完了の合図にする。 + ここで待たないと最初の数ケースの CPU Time だけが欠測になる。 + """ + deadline = time.time() + timeout + pending = {key: None for key in tails} + while time.time() < deadline: + for key, tail in tails.items(): + err = tail.died() + if err: + print(f" ! wrangler tail ({tail.script}) が終了しました:\n{err}", file=sys.stderr) + return False + for key in list(pending): + if pending[key] is None: + try: + pending[key] = clients[key].request("GET", "/__ping", None)["ray"] + except Exception: + pending[key] = None + time.sleep(2.0) + ready = True + for key, tail in tails.items(): + ray = pending.get(key) + if not ray or ray not in tail.events(): + ready = False + pending[key] = None + if ready: + return True + return False + + +# --------------------------------------------------------------------------- 計測 + + +def percentile(values: list[float], q: float) -> float: + """線形補間つきパーセンタイル (statistics.quantiles は n<2 で落ちるため自前)。""" + if not values: + return float("nan") + ordered = sorted(values) + if len(ordered) == 1: + return ordered[0] + pos = (len(ordered) - 1) * q + low = int(pos) + high = min(low + 1, len(ordered) - 1) + return ordered[low] + (ordered[high] - ordered[low]) * (pos - low) + + +def measure(cases: list[dict], repeat: int, warmup: int, timeout: float, + run_id: str, use_cpu: bool, log_dir: Path, pause: float) -> tuple[list[dict], dict]: + clients = {t["key"]: Client(t["origin"], run_id, timeout) for t in TARGETS} + tails: dict[str, Tail] = {} + meta: dict = {"cpu_time_available": False, "tail_note": None} + + if use_cpu: + try: + for t in TARGETS: + tails[t["key"]] = Tail(t["script"], run_id, log_dir) + for tail in tails.values(): + tail.start() + print("wrangler tail の接続を待っています ...", file=sys.stderr) + connected = wait_for_tail(tails, clients, timeout=90) + except BaseException: + # 2 個目の start() が失敗しても、90 秒の接続待ち中に Ctrl-C が来ても、 + # 起動済みの wrangler tail を残さない。未起動の Tail への stop() は無害。 + for tail in tails.values(): + tail.stop() + raise + if connected: + meta["cpu_time_available"] = True + print(" tail 接続完了。CPU Time を収集します。", file=sys.stderr) + else: + meta["tail_note"] = "wrangler tail に接続できなかったため CPU Time は欠測です。wrangler の権限に workers_tail (read) があるか確認してください。" + print(f" ! {meta['tail_note']}", file=sys.stderr) + for tail in tails.values(): + tail.stop() + tails = {} + + samples: list[dict] = [] + try: + # ---- ウォームアップ。全ケースを先に一巡させてから計測に入る。 + # ケースごとに「暖めて即計測」だと、最初のほうのケースだけ最適化前の + # アイソレートを測ることになる。実測でも、直前に叩いていなかった環境の + # CPU Time が 1 回目の計測でだけ 4〜5 割高く出た。両環境ともすべての + # クエリ形状を一巡させてから測れば、その非対称が消える。 + # 応答内容の妥当性もここで見る (壊れたケースは計測前に落とす)。 + print(f"ウォームアップ ({warmup} 巡) ...", file=sys.stderr) + for _ in range(warmup): + for case in cases: + method = case.get("method", "POST") + path = case.get("path", "/") + payload = None + if case.get("kind") != "http": + payload = {"query": case["_document"], "variables": case.get("variables", {})} + for target in TARGETS: + try: + res = clients[target["key"]].request(method, path, payload) + except Exception as exc: + sys.exit(f"{case['name']} / {target['key']}: リクエスト失敗 {exc}") + check_response(case, target, res, payload) + + # ---- 本計測 + # 外側が反復、内側がケース。1 ケースを続けて 15 回叩くのではなく、 + # 全ケースを 1 巡することを 15 回繰り返す。ケースごとにまとめて叩くと、 + # そのケースの標本が実行時間のごく一部の窓に集中し、その窓でたまたま + # 片方の環境が遅い状態にあると、そのケースだけ差が出たように見える + # (実測で、同じクエリのステージング平均が実行間で 89ms と 52ms に割れた)。 + # 各ケースの標本を実行全体へばらすことで、そういう一過性の状態が + # 特定のケースへ偏らなくなる。 + # 環境の順序も毎回入れ替え、回線の変動が片方に寄らないようにする。 + prepared = [] + for case in cases: + payload = None + if case.get("kind") != "http": + payload = {"query": case["_document"], "variables": case.get("variables", {})} + prepared.append((case, case.get("method", "POST"), case.get("path", "/"), payload)) + + for i in range(repeat): + for j, (case, method, path, payload) in enumerate(prepared): + order = TARGETS if (i + j) % 2 == 0 else list(reversed(TARGETS)) + for target in order: + res = clients[target["key"]].request(method, path, payload) + check_response(case, target, res, payload) + samples.append({ + "case": case["name"], + "target": target["key"], + "iteration": i, + "status": res["status"], + "ray": res["ray"], + "colo": res["colo"], + "bytes": res["bytes"], + "client_ms": res["client_ms"], + }) + if pause: + time.sleep(pause) + print(f" 反復 {i + 1:3d}/{repeat} ({len(samples)} samples)", file=sys.stderr) + except BaseException: + # ウォームアップの検証失敗 (sys.exit) や Ctrl-C で wrangler tail を残さない + for tail in tails.values(): + tail.stop() + raise + finally: + for client in clients.values(): + client.close() + + if tails: + events: dict[str, dict] = {} + try: + # tail は数秒遅れて届く。最後のリクエスト分を取りこぼさないよう待つ。 + print("tail の残りを待っています ...", file=sys.stderr) + time.sleep(12) + for key, tail in tails.items(): + tail.stop() + for ray, ev in tail.events().items(): + ev["target"] = key + events[ray] = ev + finally: + # この待機中の Ctrl-C でも wrangler tail を残さない。stop() は冪等。 + for tail in tails.values(): + tail.stop() + matched = 0 + versions: dict[str, set] = {t["key"]: set() for t in TARGETS} + for s in samples: + ev = events.get(s["ray"]) + if ev and ev["target"] == s["target"]: + s["cpu_ms"] = ev["cpu_ms"] + s["worker_wall_ms"] = ev["wall_ms"] + s["outcome"] = ev["outcome"] + matched += 1 + if ev.get("version_id"): + versions[s["target"]].add(ev["version_id"]) + meta["tail_matched"] = matched + meta["tail_total"] = len(samples) + meta["versions"] = {k: sorted(v) for k, v in versions.items()} + print(f" CPU Time 突き合わせ: {matched}/{len(samples)}", file=sys.stderr) + if matched == 0: + meta["cpu_time_available"] = False + meta["tail_note"] = "tail イベントを 1 件も突き合わせられませんでした。" + + return samples, meta + + +# --------------------------------------------------------------------------- 集計 + + +def summarize(samples: list[dict], cases: list[dict]) -> list[dict]: + by_case = {c["name"]: c for c in cases} + rows = [] + for name in [c["name"] for c in cases]: + row = {"case": name, "weight": by_case[name].get("weight", ""), + "note": by_case[name].get("note", ""), "targets": {}} + for target in TARGETS: + sel = [s for s in samples if s["case"] == name and s["target"] == target["key"]] + if not sel: + continue + client = [s["client_ms"] for s in sel] + cpu_all = [s["cpu_ms"] for s in sel if s.get("cpu_ms") is not None] + cutoff = cold_cutoff(cpu_all) + cold = [v for v in cpu_all if v > cutoff] + cpu = [v for v in cpu_all if v <= cutoff] + wall = [s["worker_wall_ms"] for s in sel + if s.get("worker_wall_ms") is not None + and s.get("cpu_ms") is not None and s["cpu_ms"] <= cutoff] + row["targets"][target["key"]] = { + "n": len(sel), + "bytes": statistics.median([s["bytes"] for s in sel]), + "client_min": min(client), + "client_p50": percentile(client, 0.5), + "client_mean": statistics.fmean(client), + "client_p95": percentile(client, 0.95), + "cpu_n": len(cpu), + "cpu_mean": statistics.fmean(cpu) if cpu else None, + "cpu_p50": percentile(cpu, 0.5) if cpu else None, + "cpu_p95": percentile(cpu, 0.95) if cpu else None, + "cpu_sd": statistics.pstdev(cpu) if len(cpu) > 1 else None, + "cpu_max": max(cpu) if cpu else None, + "worker_wall_mean": statistics.fmean(wall) if wall else None, + "cold_n": len(cold), + "cold_max": max(cold) if cold else None, + "cold_cutoff": cutoff if cutoff != float("inf") else None, + } + rows.append(row) + return rows + + +def cold_cutoff(values: list[float]) -> float: + """この値を超えた標本をコールドスタートとみなす、という境目を返す。""" + if not values: + return float("inf") + med = statistics.median(values) + return max(med * COLD_START_FACTOR, med + COLD_START_MARGIN_MS) + + +def ratio(prod, stg): + """本番を基準にしたステージングの比。1 未満ならステージングが速い。""" + if prod in (None, 0) or stg is None: + return None + return stg / prod + + +# --------------------------------------------------------------------------- 出力 + + +def fmt(value, digits=2, unit=""): + if value is None: + return "—" + if isinstance(value, float) and value != value: + return "—" + return f"{value:.{digits}f}{unit}" + + +def fmt_delta(r): + if r is None: + return "—" + pct = (r - 1) * 100 + sign = "+" if pct >= 0 else "" + return f"{sign}{pct:.1f}%" + + +# cpuTime はミリ秒の整数で届くので、両環境とも平均 1 ms を切る帯では +# 比を出しても丸め誤差を読んでいるだけになる。その場合は判定を保留する。 +CPU_RESOLUTION_MS = 1.0 + +# 比だけで判定すると、1 ms が 2 ms になっただけで「+100%」になってしまう。 +# cpuTime は整数なので各標本に最大 ±0.5 ms の丸めが乗る。平均の差がこの値を +# 下回るケースは、割合がいくら大きくても「同等」に倒す。 +CPU_MIN_DIFF_MS = 1.5 + +# 応答時間から ping ぶんを引いた「サーバ分」は、回線のゆらぎと同じ桁まで小さくなると +# 比を出しても意味がない。両環境ともこの値を下回ったら判定を保留する。 +CLIENT_NOISE_MS = 2.0 + + +def verdict(r, threshold=0.10, abs_diff=None, min_diff=0.0, small_label="微差"): + """比で判定し、その差が絶対値で小さすぎるときだけ判定を降ろす。 + + 絶対差を先に見ると、43 ms 対 42 ms のような「比でも絶対値でも僅差」まで + 特別扱いされてしまう。そこは素直に「同等」でよい。小さい絶対差が問題になるのは、 + 1 ms 対 2 ms のように比だけが大きく見えるときだけ。 + """ + if r is None: + return "—" + if 1 - threshold < r < 1 + threshold: + return "同等" + if abs_diff is not None and abs(abs_diff) < min_diff: + return small_label + return "stg 速い" if r <= 1 - threshold else "stg 遅い" + + +def below_resolution(*means) -> bool: + values = [m for m in means if m is not None] + return bool(values) and max(values) < CPU_RESOLUTION_MS + + +def render_cpu_table(a, rows) -> None: + """CPU Time の表と、その読み方の注記を書き出す。""" + prod_key, stg_key = TARGETS[0]["key"], TARGETS[1]["key"] + a("| クエリ | 重み | 本番 平均 | 本番 p95 | stg 平均 | stg p95 | 差 (stg/本番) | 判定 |") + a("| --- | --- | ---: | ---: | ---: | ---: | ---: | --- |") + for row in rows: + p = row["targets"].get(prod_key, {}) + s = row["targets"].get(stg_key, {}) + r = ratio(p.get("cpu_mean"), s.get("cpu_mean")) + if below_resolution(p.get("cpu_mean"), s.get("cpu_mean")): + delta, call = "—", "分解能未満" + else: + diff = None + if p.get("cpu_mean") is not None and s.get("cpu_mean") is not None: + diff = s["cpu_mean"] - p["cpu_mean"] + delta = fmt_delta(r) + call = verdict(r, abs_diff=diff, min_diff=CPU_MIN_DIFF_MS) + a(f"| `{row['case']}` | {row['weight']} | {fmt(p.get('cpu_mean'))} | {fmt(p.get('cpu_p95'))} " + f"| {fmt(s.get('cpu_mean'))} | {fmt(s.get('cpu_p95'))} | {delta} | {call} |") + a("") + a(f"単位はミリ秒。「微差」は割合こそ大きいものの平均の差が {CPU_MIN_DIFF_MS} ms 未満で、" + "cpuTime の整数丸め (標本あたり最大 ±0.5 ms) と区別がつかないケース。") + # この Worker は外部 I/O を持たないので wall time は CPU Time とほぼ一致するはず。 + # 乖離が出たら「CPU を使っていない待ち時間」が混ざったということなので、そこだけ報告する。 + gaps = [] + for row in rows: + for key, stat in row["targets"].items(): + if stat.get("cpu_mean") is not None and stat.get("worker_wall_mean") is not None: + gaps.append((stat["worker_wall_mean"] - stat["cpu_mean"], row["case"], key)) + if gaps: + gap, case, key = max(gaps) + label = next(t["label"] for t in TARGETS if t["key"] == key) + a("") + a(f"Worker の wall time と CPU Time の差は最大 {gap:.2f} ms " + f"(`{case}` / {label})。この Worker は外部 I/O を持たないので両者はほぼ一致し、" + "大きく開いたときは CPU を使っていない待ちが混ざったことを意味する。") + + +def render_markdown(rows, samples, meta, args, run_id, started, finished) -> str: + prod_key, stg_key = TARGETS[0]["key"], TARGETS[1]["key"] + out = [] + a = out.append + + a(f"# GraphQL ベンチマーク {started.strftime('%Y-%m-%d %H:%M')} JST") + a("") + a(f"本番 `{TARGETS[0]['origin']}` とステージング `{TARGETS[1]['origin']}` の同一クエリを " + f"交互に叩き、クライアント応答時間と Cloudflare Worker の CPU Time を比較した。") + a("") + a("## 実行条件") + a("") + a("| 項目 | 値 |") + a("| --- | --- |") + a(f"| 実行 ID | `{run_id}` |") + a(f"| 開始 / 終了 | {started.strftime('%Y-%m-%d %H:%M:%S')} / {finished.strftime('%H:%M:%S')} JST |") + a(f"| 反復数 | {args.repeat} (計測前に全ケースを {args.warmup} 巡して破棄) |") + a(f"| ケース数 | {len(rows)} |") + a(f"| 総リクエスト数 | {len(samples)} |") + a(f"| CPU Time 取得 | {'wrangler tail (cf-ray 突き合わせ ' + str(meta.get('tail_matched', 0)) + '/' + str(meta.get('tail_total', 0)) + ')' if meta.get('cpu_time_available') else '欠測'} |") + a(f"| 計測元 | {platform.node()} / Python {platform.python_version()} |") + colos = sorted({s.get("colo") for s in samples if s.get("colo")}) + if colos: + a(f"| コロ | {', '.join(colos)} |") + versions = meta.get("versions") or {} + for target in TARGETS: + ids = versions.get(target["key"]) or [] + if ids: + a(f"| {target['label']} Worker バージョン | {', '.join(f'`{v}`' for v in ids)} |") + a("") + if meta.get("uncovered_query_fields"): + a("> [!WARNING]") + a(f"> ベンチマークのケースが無い Query フィールドがある: " + f"{', '.join('`' + f + '`' for f in meta['uncovered_query_fields'])}") + a("") + if meta.get("tail_note"): + a(f"> [!WARNING]") + a(f"> {meta['tail_note']}") + a("") + + has_cpu = any(stat.get("cpu_mean") is not None + for row in rows for stat in row["targets"].values()) + + a("## CPU Time (Cloudflare Worker)") + a("") + if not has_cpu: + a("この実行では CPU Time を集めていない (`--no-cpu`、または `wrangler tail` に" + "接続できなかった)。実装差の判定に使えるのはこの指標だけなので、" + "下のクライアント応答時間は参考値として読むこと。") + else: + a("Worker が 1 リクエストの処理に使った CPU 時間。ネットワークとコロの当たり外れを含まないので、" + "実装起因の差はここに出る。`wrangler tail` が返す値はミリ秒の整数。" + "中央値から大きく跳ねた標本はコールドスタートとみなし、この表から外して次節に分離した。") + a("") + render_cpu_table(a, rows) + a("") + + a("## クライアント応答時間") + a("") + a("同一の keep-alive 接続で測った往復時間。ネットワークと Cloudflare のエッジ処理、" + "レスポンス転送を含むので、CPU Time との差がそれらの取り分になる。") + a("") + # __ping は「何もしない」1 往復なので、その時間を引けば回線ぶんを落とせる。 + # 本番とステージングは別ドメインで経路も別なので、環境ごとに基準線を持つ。 + # p50 だと回線のゆらぎが基準線に乗って補正後の値が潰れる (0 に張り付く) ため、 + # 基準線も対象も最小値を使う。最小値は往復時間の下限、つまり最もノイズの少ない推定。 + base = {} + for row in rows: + if row["case"] == "ping": + for key, stat in row["targets"].items(): + base[key] = stat.get("client_min") + + def net_corrected(stat, key): + v, b = stat.get("client_min"), base.get(key) + return None if v is None or b is None else max(v - b, 0.0) + + header = "| クエリ | 応答サイズ | 本番 p50 | 本番 p95 | stg p50 | stg p95 | 差 (stg/本番) | 判定 |" + divider = "| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |" + if base: + header = ("| クエリ | 応答サイズ | 本番 p50 | 本番 p95 | 本番 サーバ分 | " + "stg p50 | stg p95 | stg サーバ分 | 差 (サーバ分同士) | 判定 |") + divider = "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |" + a(header) + a(divider) + for row in rows: + p = row["targets"].get(prod_key, {}) + s = row["targets"].get(stg_key, {}) + size = p.get("bytes") or s.get("bytes") or 0 + if base: + if row["case"] == "ping": + pc = sc = None # 基準線そのものなので定義上ゼロ。空欄にする + else: + pc, sc = net_corrected(p, prod_key), net_corrected(s, stg_key) + r = ratio(pc, sc) + if pc is None or sc is None: + delta, call = "—", "—" + elif max(pc, sc) < CLIENT_NOISE_MS: + # 引き算の結果が回線のゆらぎ以下。0 に張り付いて -100% と出るのを防ぐ + delta, call = "—", "誤差内" + else: + delta = fmt_delta(r) + call = verdict(r, abs_diff=sc - pc, min_diff=CLIENT_NOISE_MS, + small_label="誤差内") + a(f"| `{row['case']}` | {size/1024:.1f} KiB " + f"| {fmt(p.get('client_p50'), 1)} | {fmt(p.get('client_p95'), 1)} | {fmt(pc, 1)} " + f"| {fmt(s.get('client_p50'), 1)} | {fmt(s.get('client_p95'), 1)} | {fmt(sc, 1)} " + f"| {delta} | {call} |") + else: + r = ratio(p.get("client_p50"), s.get("client_p50")) + a(f"| `{row['case']}` | {size/1024:.1f} KiB | {fmt(p.get('client_p50'), 1)} | {fmt(p.get('client_p95'), 1)} " + f"| {fmt(s.get('client_p50'), 1)} | {fmt(s.get('client_p95'), 1)} | {fmt_delta(r)} | {verdict(r)} |") + a("") + a("単位はミリ秒。") + if base: + a("") + a(f"`サーバ分` 列は、そのクエリの最小往復時間から同じ環境の `ping` の最小往復時間 " + f"(本番 {fmt(base.get(prod_key), 1)} ms / ステージング {fmt(base.get(stg_key), 1)} ms) を引いた値。" + "計測ホストからエッジまでの往復は環境ごとにほぼ一定なので、これを落とすと" + "レスポンスの生成と転送にかかった分だけが残る。回線のゆらぎを避けるため" + "中央値ではなく最小値どうしを引いている。" + f"引いた結果が両環境とも {CLIENT_NOISE_MS:.0f} ms を下回るケースは、" + "回線のゆらぎと区別がつかないので判定を「誤差内」にしている。") + a("") + + cold_rows = [(row["case"], t, row["targets"][t].get("cold_n"), + row["targets"][t].get("cold_max"), row["targets"][t].get("cold_cutoff")) + for row in rows for t in row["targets"] if row["targets"][t].get("cold_n")] + a("## コールドスタート") + a("") + if cold_rows: + a("同じケース・同じ環境の CPU Time 中央値から大きく跳ねた標本。" + "WASM の実体化と索引構築のコストで、定常性能とは分けて読む。" + f"判定は「中央値の {COLD_START_FACTOR} 倍以上、かつ中央値 +{COLD_START_MARGIN_MS} ms 以上」。") + a("") + a("| クエリ | 対象 | 件数 | 判定境界 | 最大 CPU |") + a("| --- | --- | ---: | ---: | ---: |") + for case, target, n, mx, cutoff in cold_rows: + label = next(t["label"] for t in TARGETS if t["key"] == target) + a(f"| `{case}` | {label} | {n} | {fmt(cutoff, 0)} ms | {fmt(mx, 0)} ms |") + else: + a("この実行では中央値から跳ねた CPU Time の標本は出なかった。" + "ウォームアップ後は暖まったアイソレートに当たり続けたということ。") + a("") + + a("## ケース一覧") + a("") + a("| クエリ | 内容 |") + a("| --- | --- |") + for row in rows: + a(f"| `{row['case']}` | {row['note']} |") + a("") + + a("## 所見") + a("") + a("") + a("") + a("## 測り方の限界") + a("") + a(f"- `wrangler tail` の `cpuTime` はミリ秒の整数なので、1〜2 ms のクエリは丸めの影響が大きい。" + f"平均は反復数ぶん細かくなるが、1 反復の値は信用しない。両環境とも平均 {CPU_RESOLUTION_MS:.0f} ms 未満の" + "ケースは判定を「分解能未満」として保留している。") + a("- 本番とステージングは別の Worker であり、暖まり具合もリクエストの割り当て先マシンも独立している。" + "コールドスタートの有無で 100 倍単位の差が出るため、判定は定常標本だけで行っている。" + "コールドかどうかは固定閾値ではなく、同じケース内の中央値からの跳ね方で決めている" + "(もともと数百 ms 使うクエリを巻き込まないため)。") + a("- 応答時間には計測ホストから Cloudflare エッジまでの回線が乗る。" + "本番とステージングのどちらを先に叩くかは 1 リクエストごとに入れ替えており、" + "回線の変動が片方へ偏らないようにしてある。") + a("- 標本は「全ケースを 1 巡」を反復数ぶん繰り返して集めており、各ケースの標本は" + "実行時間全体へばらしてある。それでも実行をまたぐと平均は動く" + "(実測で、同じクエリのステージング平均が別実行で 89 ms と 52 ms に割れたことがある)。" + "**2 倍に満たない差は、もう一度まわして同じ向きに出るまで結論にしない。**") + a("- データは両環境で同一 (`/__health` で確認できる)。差が出たら実装差とみなしてよい。") + return "\n".join(out) + "\n" + + +def update_index(index_path: Path, run_id: str, started, rows, meta, args) -> None: + prod_key, stg_key = TARGETS[0]["key"], TARGETS[1]["key"] + # 中央比は「測れたケース全体がどちらへ寄ったか」なので、差の小さいケースも含める。 + # 最速 / 最遅は個別のクエリを名指しするので、丸め誤差と区別がつかないケースは外す。 + measured, notable = [], [] + for row in rows: + if row["weight"] == "baseline": + continue + pm = row["targets"].get(prod_key, {}).get("cpu_mean") + sm = row["targets"].get(stg_key, {}).get("cpu_mean") + r = ratio(pm, sm) + if r is None or below_resolution(pm, sm): + continue + measured.append((row["case"], r)) + if abs(sm - pm) >= CPU_MIN_DIFF_MS: + notable.append((row["case"], r)) + if measured: + median = statistics.median([r for _, r in measured]) + summary = f"CPU 中央比 {fmt_delta(median)} ({len(measured)} ケース)" + if notable: + best = min(notable, key=lambda x: x[1]) + worst = max(notable, key=lambda x: x[1]) + summary += (f" / 最速 `{best[0]}` {fmt_delta(best[1])}" + f" / 最遅 `{worst[0]}` {fmt_delta(worst[1])}") + else: + summary = "CPU Time 欠測" + line = (f"| [{started.strftime('%Y-%m-%d %H:%M')}](./{run_id}.md) | {len(rows)} | " + f"{args.repeat} | {summary} |") + + header = [ + "# 実行履歴", + "", + "`bench.py` が 1 実行につき 1 行追記する。差は本番を基準にしたステージングの CPU Time 平均比で、" + "マイナスならステージングのほうが CPU を使っていない。", + "", + "| 実行 | ケース数 | 反復 | 要約 |", + "| --- | ---: | ---: | --- |", + ] + if index_path.exists(): + lines = index_path.read_text(encoding="utf-8").rstrip("\n").split("\n") + else: + lines = list(header) + # 同じ実行の行があれば差し替える (--rerender で集計をやり直したとき) + anchor = f"](./{run_id}.md)" + for i, existing in enumerate(lines): + if anchor in existing: + lines[i] = line + break + else: + lines.append(line) + index_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +# --------------------------------------------------------------------------- 自己診断 + +# root_query_fields は「ケースの足し忘れ」を知らせるためだけの補助だが、壊れても +# 黙って警告が出なくなるだけなので気付けない。過去に見つかった取りこぼしを +# ここに固定しておく。`make test` は Rust 専用なので、Python 側はこれで代える。 +_LEXER_CASES = [ + ("ルートフィールド", + 'query Q { station(id: 1) { name } }', {"station"}), + ("ルート複数", + 'query Q { station(id: 1) { name } lines(lineIds: [1]) { id } }', {"station", "lines"}), + ("ネストは拾わない", + 'query Q { trainRoute(fromStationId: 1, toStationId: 2) { segments { station { id } } } }', + {"trainRoute"}), + ("ネストが引数付きでも拾わない", + 'query Q { trainRoute(fromStationId: 1, toStationId: 2) { segments { station { lines(transportType: Rail) { id } } } } }', + {"trainRoute"}), + ("フラグメントスプレッドは無視", + 'query Q { station(id: 1) { ...StationCore } }', {"station"}), + ("エイリアスは実フィールド名を採る", + 'query Q { a: stationsByName(name: "x", limit: 2) { id } }', {"stationsByName"}), + ("引数の文字列にある括弧を数えない", + 'query Q { stationsByName(name: "新宿(西口)", limit: 2) { id } lines(lineIds: [1]) { id } }', + {"stationsByName", "lines"}), + ("引数の文字列にある # はコメントではない", + 'query Q { stationsByName(name: "#1 番線", limit: 2) { id } }', {"stationsByName"}), + ("コメントの語を拾わない", + 'query Q {\n # lines\n station(id: 1) { name }\n}', {"station"}), + ("コメント内の閉じ波括弧で深さを崩さない", + 'query Q {\n station(id: 1) {\n # closing } here\n name\n }\n lines(lineIds: [1]) { id }\n}', + {"station", "lines"}), + ("コメント内の開き波括弧で深さを崩さない", + 'query Q {\n # open { here\n station(id: 1) { name }\n lines(lineIds: [1]) { id }\n}', + {"station", "lines"}), + ("コメント内の閉じ括弧で深さを崩さない", + 'query Q {\n station(id: 1) { name } # a paren ) here\n lines(lineIds: [1]) { id }\n}', + {"station", "lines"}), + ("ディレクティブ名はフィールドではない", + 'query Q { station(id: 1) @lines { name } }', {"station"}), + ("引数付きディレクティブ", + 'query Q { station(id: 1) @include(if: $x) { name } lines(lineIds: [1]) { id } }', + {"station", "lines"}), + ("ブロック文字列を丸ごと飛ばす", + 'query Q { field(arg: """text # { }""") other }', {"field", "other"}), + ("ブロック文字列の中に \" があっても崩れない", + 'query Q { field(arg: """text " # { }""") other }', {"field", "other"}), + ("ブロック文字列の中の波括弧", + 'query Q { field(arg: """text " { }""") other }', {"field", "other"}), +] + + +def self_test(queries_path: Path) -> int: + """レキサとカバレッジ判定の自己診断。`--self-test` で走る。""" + failures = 0 + for label, document, expected in _LEXER_CASES: + got = root_query_fields(document) + ok = got == expected + failures += not ok + print(f" {'ok ' if ok else 'FAIL'} {label}" + + ("" if ok else f"\n 期待 {sorted(expected)} / 実際 {sorted(got)}"), + file=sys.stderr) + + # カタログ側。全 Query フィールドを覆えているか、覆えなくなったら気付けるか。 + # スキーマが読めないと uncovered_query_fields は無条件に空を返すので、 + # 先に存在を確かめる。これが無いと以下 2 件が空振りで ok になる。 + schema = REPO_ROOT / "schema" / "public.graphql" + if not schema.exists(): + print(f" FAIL {schema} が見つからない (カバレッジ判定を検証できない)", file=sys.stderr) + print(f"全 {len(_LEXER_CASES) + 2} 件中 {len(_LEXER_CASES) - failures} 件 ok", file=sys.stderr) + return 1 + + cases = json.loads(queries_path.read_text(encoding="utf-8"))["cases"] + uncovered = uncovered_query_fields(cases) + ok = not uncovered + failures += not ok + print(f" {'ok ' if ok else 'FAIL'} {queries_path.name} が Query を全て覆う" + + ("" if ok else f" (未カバー: {uncovered})"), file=sys.stderr) + + named = next((c["name"] for c in cases if c.get("query")), None) + if named: + target = next(iter(root_query_fields( + next(c["query"] for c in cases if c["name"] == named))), None) + remaining = [c for c in cases if c["name"] != named] + ok = target is not None and target in uncovered_query_fields(remaining) + failures += not ok + print(f" {'ok ' if ok else 'FAIL'} ケースを外すと未カバーとして検出する" + f" ({named} / {target})", file=sys.stderr) + + total = len(_LEXER_CASES) + 2 + print(f"全 {total} 件中 {total - failures} 件 ok", file=sys.stderr) + return 1 if failures else 0 + + +# --------------------------------------------------------------------------- main + + +def rerender(args) -> int: + """生データからレポートを作り直す。 + + 集計や表の書き方を直したときに、本番へ投げ直さずにレポートを更新できる。 + 生データには全リクエストの結果が入っているので、再計測する理由は無い。 + """ + # 出力先は入力パスから逆算するので、想定の配置でなければ止める。 + # 黙って parent.parent を取ると、raw/ 以外を渡されたとき無関係な場所へ書き出す。 + if args.rerender.parent.name != "raw": + sys.exit(f"--rerender には <出力先>/raw/<実行 ID>.json を渡してください: {args.rerender}") + out_dir = args.rerender.parent.parent + + raw = json.loads(args.rerender.read_text(encoding="utf-8")) + run_id = raw["run_id"] + started = datetime.fromisoformat(raw["started_at"]) + finished = datetime.fromisoformat(raw["finished_at"]) + saved = raw.get("args", {}) + + class Saved: + repeat = saved.get("repeat") + warmup = saved.get("warmup") + note = saved.get("note") or "" + + cases, _ = load_cases(Path(saved.get("queries") or (SKILL_DIR / "queries.json")), None, False) + present = {s["case"] for s in raw["samples"]} + cases = [c for c in cases if c["name"] in present] + + rows = summarize(raw["samples"], cases) + markdown = render_markdown(rows, raw["samples"], raw.get("meta", {}), Saved, + run_id, started, finished) + if note: + markdown = markdown.replace("## 実行条件", f"> {note}\n\n## 実行条件", 1) + + report = out_dir / f"{run_id}.md" + previous = report.read_text(encoding="utf-8") if report.exists() else "" + # 手で書いた「所見」は上書きしない。集計を直しても書いた考察は残す。 + marker = "## 所見\n" + if marker in previous and marker in markdown: + head, _, tail = previous.partition(marker) + kept, _, _ = tail.partition("\n## ") + before, _, after = markdown.partition(marker) + _, _, rest = after.partition("\n## ") + markdown = before + marker + kept + "\n## " + rest + report.write_text(markdown, encoding="utf-8") + update_index(report.parent / "index.md", run_id, started, rows, raw.get("meta", {}), Saved) + print(f"作り直し: {report}", file=sys.stderr) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--repeat", type=int, default=15, help="計測反復数 (既定 15)") + parser.add_argument("--warmup", type=int, default=3, + help="計測前に全ケースを何巡するか (既定 3)。この間の結果は捨てる") + parser.add_argument("--only", default="", help="カンマ区切りのケース名だけを実行") + parser.add_argument("--skip-baseline", action="store_true", help="__ping / __health を除く") + parser.add_argument("--no-cpu", action="store_true", help="wrangler tail を使わず応答時間だけ測る") + parser.add_argument("--timeout", type=float, default=60.0, help="HTTP タイムアウト秒") + parser.add_argument("--pause", type=float, default=0.0, help="リクエスト間のスリープ秒") + parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUT_DIR, help="結果の出力先") + parser.add_argument("--queries", type=Path, default=SKILL_DIR / "queries.json") + parser.add_argument("--dry-run", action="store_true", help="ファイルを書かずに標準出力へ出す") + parser.add_argument("--self-test", action="store_true", + help="レキサとカバレッジ判定の自己診断だけ走らせる (リクエストは送らない)") + parser.add_argument("--rerender", type=Path, default=None, + help="benchmarks/raw/*.json からレポートを作り直す (リクエストは送らない)") + parser.add_argument("--note", default="", help="レポート冒頭に添える一言") + args = parser.parse_args() + + if args.self_test: + return self_test(args.queries) + + if args.rerender: + return rerender(args) + + only = [s.strip() for s in args.only.split(",") if s.strip()] + cases, uncovered = load_cases(args.queries, only, args.skip_baseline) + if not cases: + sys.exit("実行するケースがありません") + if uncovered: + print(f"警告: ケースが無い Query フィールド: {', '.join(uncovered)}\n" + f" {args.queries} に追加してください。", + file=sys.stderr) + + started = datetime.now(JST) + run_id = started.strftime("%Y%m%d-%H%M%S") + out_dir: Path = args.out_dir + raw_dir = out_dir / "raw" + log_dir = out_dir / ".logs" + if not args.dry_run: + raw_dir.mkdir(parents=True, exist_ok=True) + log_dir.mkdir(parents=True, exist_ok=True) + + print(f"実行 ID {run_id} / {len(cases)} ケース × {args.repeat} 反復 × {len(TARGETS)} 環境", file=sys.stderr) + samples, meta = measure(cases, args.repeat, args.warmup, args.timeout, + run_id, not args.no_cpu, log_dir, args.pause) + finished = datetime.now(JST) + rows = summarize(samples, cases) + if args.note: + meta["note"] = args.note + meta["uncovered_query_fields"] = uncovered + markdown = render_markdown(rows, samples, meta, args, run_id, started, finished) + if args.note: + markdown = markdown.replace("## 実行条件", f"> {args.note}\n\n## 実行条件", 1) + + if args.dry_run: + print(markdown) + return 0 + + report = out_dir / f"{run_id}.md" + report.write_text(markdown, encoding="utf-8") + (raw_dir / f"{run_id}.json").write_text(json.dumps({ + "run_id": run_id, + "started_at": started.isoformat(), + "finished_at": finished.isoformat(), + "args": {k: (str(v) if isinstance(v, Path) else v) for k, v in vars(args).items()}, + "targets": TARGETS, + "meta": meta, + "samples": samples, + "summary": rows, + }, ensure_ascii=False, indent=1), encoding="utf-8") + update_index(out_dir / "index.md", run_id, started, rows, meta, args) + print(f"\nレポート: {report}", file=sys.stderr) + print(f"生データ: {raw_dir / (run_id + '.json')}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/skills/benchmark-gql/queries.json b/.claude/skills/benchmark-gql/queries.json new file mode 100644 index 00000000..af26f985 --- /dev/null +++ b/.claude/skills/benchmark-gql/queries.json @@ -0,0 +1,207 @@ +{ + "$comment": [ + "ベンチマーク対象のクエリ定義。schema/public.graphql の Query フィールドを全件カバーする。", + "fragments はクエリが uses で参照した分だけ本文に連結される。", + "変数は data/*.csv 由来の実在 ID を固定値で持つ。過去の実行結果と比較できるよう、", + "既存ケースの variables は変えないこと。条件を変えたいときは新しい name のケースを足す。" + ], + + "fragments": { + "StationCore": "fragment StationCore on Station { id groupId name nameKatakana nameRoman threeLetterCode prefectureId latitude longitude status stopCondition hasTrainTypes distance transportType stationNumbers { lineSymbol lineSymbolColor lineSymbolShape stationNumber } line { ...LineCore } lines { ...LineCore } trainType { ...TrainTypeCore } }", + "StationNestedCore": "fragment StationNestedCore on StationNested { id groupId name nameKatakana nameRoman threeLetterCode prefectureId latitude longitude status stopCondition hasTrainTypes distance transportType stationNumbers { lineSymbol lineSymbolColor lineSymbolShape stationNumber } line { ...LineCore } lines { ...LineCore } trainType { ...TrainTypeCore } }", + "LineCore": "fragment LineCore on LineNested { id nameShort nameKatakana nameFull nameRoman color lineType status averageDistance transportType lineSymbols { symbol color shape } company { id nameShort nameFull type status } }", + "LineTop": "fragment LineTop on Line { id nameShort nameKatakana nameFull nameRoman color lineType status averageDistance transportType lineSymbols { symbol color shape } company { id nameShort nameFull type status } station { id groupId name nameRoman } }", + "TrainTypeCore": "fragment TrainTypeCore on TrainTypeNested { id typeId groupId name nameKatakana nameRoman color direction kind }", + "TrainTypeTop": "fragment TrainTypeTop on TrainType { id typeId groupId name nameKatakana nameRoman color direction kind lines { ...LineCore } line { ...LineCore } }" + }, + + "cases": [ + { + "name": "ping", + "kind": "http", + "method": "GET", + "path": "/__ping", + "weight": "baseline", + "note": "データに触らない疎通のみ。ネットワーク往復と Worker 起動の下限を測る基準線" + }, + { + "name": "health", + "kind": "http", + "method": "GET", + "path": "/__health", + "weight": "baseline", + "note": "索引サイズを返すだけ。索引初期化が済んでいるかの確認も兼ねる" + }, + + { + "name": "station", + "weight": "light", + "uses": ["StationCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_station($id: Int!) { station(id: $id) { ...StationCore } }", + "variables": { "id": 1130205 }, + "note": "渋谷 (山手線)。単駅取得 + 路線/会社/駅ナンバリング/近隣バス路線の付加" + }, + { + "name": "stations", + "weight": "medium", + "uses": ["StationCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_stations($ids: [Int!]!) { stations(ids: $ids) { ...StationCore } }", + "variables": { "ids": [1130101, 1130102, 1130103, 1130201, 1130202, 1130203, 1130205, 1130208, 1130212, 1130224, 1130228, 1130229, 1130230, 1141101, 1160214, 1130105, 2800201, 9930101, 2400601, 2600101] }, + "note": "主要 20 駅の一括取得" + }, + { + "name": "stationsNearby", + "weight": "medium", + "uses": ["StationCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_stationsNearby($lat: Float!, $lon: Float!, $limit: Int) { stationsNearby(latitude: $lat, longitude: $lon, limit: $limit) { ...StationCore } }", + "variables": { "lat": 35.658034, "lon": 139.701636, "limit": 10 }, + "note": "渋谷駅前。transportType 無指定。#1649 以降は鉄道駅を距離順に上限まで詰め、残った枠にだけバス停が入る" + }, + { + "name": "stationsNearby_limit100", + "weight": "heavy", + "uses": ["StationCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_stationsNearbyWide($lat: Float!, $lon: Float!, $limit: Int) { stationsNearby(latitude: $lat, longitude: $lon, limit: $limit) { ...StationCore } }", + "variables": { "lat": 35.658034, "lon": 139.701636, "limit": 100 }, + "note": "渋谷駅前で 100 件。鉄道駅だけでは枠が埋まらないのでバス停の検索まで走る。グリッド索引の半径拡張と付加処理がどこまで線形に伸びるかを見る" + }, + { + "name": "stationsNearby_rail", + "weight": "light", + "uses": ["StationCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_stationsNearbyRail($lat: Float!, $lon: Float!, $limit: Int) { stationsNearby(latitude: $lat, longitude: $lon, limit: $limit, transportType: Rail) { ...StationCore } }", + "variables": { "lat": 35.658034, "lon": 139.701636, "limit": 10 }, + "note": "鉄道のみを明示。既定ケースとの差が、バス停側の検索を回すかどうかのコスト" + }, + { + "name": "stationsByName", + "weight": "medium", + "uses": ["StationCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_stationsByName($name: String!, $limit: Int) { stationsByName(name: $name, limit: $limit) { ...StationCore } }", + "variables": { "name": "新宿", "limit": 20 }, + "note": "正規化つき全文検索。ヒット数が多い駅名を選んでいる" + }, + { + "name": "stationGroupStations", + "weight": "heavy", + "uses": ["StationCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_stationGroupStations($groupId: Int!) { stationGroupStations(groupId: $groupId) { ...StationCore } }", + "variables": { "groupId": 1130208 }, + "note": "新宿駅グループ。同一駅の全路線分レコード" + }, + { + "name": "lineGroupStations", + "weight": "heavy", + "uses": ["StationCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_lineGroupStations($lineGroupId: Int!) { lineGroupStations(lineGroupId: $lineGroupId) { ...StationCore } }", + "variables": { "lineGroupId": 203 }, + "note": "サンライズ出雲 (250 駅)。データ中で最大の列車種別グループ" + }, + { + "name": "lineStations", + "weight": "medium", + "uses": ["StationCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_lineStations($lineId: Int!) { lineStations(lineId: $lineId) { ...StationCore } }", + "variables": { "lineId": 11302 }, + "note": "山手線 (30 駅)" + }, + { + "name": "lineListStations", + "weight": "heavy", + "uses": ["StationCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_lineListStations($lineIds: [Int!]!) { lineListStations(lineIds: $lineIds) { ...StationCore } }", + "variables": { "lineIds": [11302, 11311, 11332, 28002, 99301, 11301, 11312, 11313] }, + "note": "山手/中央/京浜東北/丸ノ内/大江戸 ほか 8 路線分の全駅" + }, + { + "name": "lineGroupListStations", + "weight": "heavy", + "uses": ["StationCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_lineGroupListStations($lineGroupIds: [Int!]!) { lineGroupListStations(lineGroupIds: $lineGroupIds) { ...StationCore } }", + "variables": { "lineGroupIds": [203, 202, 363] }, + "note": "大型グループ 2 本 + 山手線普通の同時取得" + }, + + { + "name": "line", + "weight": "light", + "uses": ["LineTop"], + "query": "query Bench_line($lineId: Int!) { line(lineId: $lineId) { ...LineTop } }", + "variables": { "lineId": 11302 }, + "note": "山手線の単体取得" + }, + { + "name": "lines", + "weight": "medium", + "uses": ["LineTop"], + "query": "query Bench_lines($lineIds: [Int!]!) { lines(lineIds: $lineIds) { ...LineTop } }", + "variables": { "lineIds": [1002, 1004, 1005, 1009, 11301, 11302, 11307, 11308, 11311, 11312, 11313, 11321, 24001, 11332, 11333, 11411, 11502, 11508, 11602, 11603, 11623, 11629, 11641, 21001, 22001, 24006, 26001, 28002, 99301, 99509] }, + "note": "主要 30 路線" + }, + { + "name": "linesByName", + "weight": "light", + "uses": ["LineTop"], + "query": "query Bench_linesByName($name: String!, $limit: Int) { linesByName(name: $name, limit: $limit) { ...LineTop } }", + "variables": { "name": "本線", "limit": 20 }, + "note": "ヒット数の多い部分一致" + }, + + { + "name": "stationTrainTypes", + "weight": "medium", + "uses": ["TrainTypeTop", "LineCore"], + "query": "query Bench_stationTrainTypes($stationId: Int!) { stationTrainTypes(stationId: $stationId) { ...TrainTypeTop } }", + "variables": { "stationId": 1130101 }, + "note": "東京駅 (東海道本線)。種別が多く、種別ごとに路線を引き直す" + }, + { + "name": "routeTypes", + "weight": "medium", + "uses": ["TrainTypeTop", "LineCore"], + "query": "query Bench_routeTypes($from: Int!, $to: Int!) { routeTypes(fromStationGroupId: $from, toStationGroupId: $to) { trainTypes { ...TrainTypeTop } nextPageToken } }", + "variables": { "from": 1130101, "to": 1130103 }, + "note": "東京 → 品川。両駅を通る種別の列挙" + }, + + { + "name": "routes", + "weight": "heavy", + "uses": ["StationNestedCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_routes($from: Int!, $to: Int!) { routes(fromStationGroupId: $from, toStationGroupId: $to) { routes { id stops { ...StationNestedCore } } nextPageToken } }", + "variables": { "from": 1130101, "to": 1130103 }, + "note": "東京 → 品川。直通経路の全停車駅を付加つきで返す" + }, + { + "name": "connectedRoutes", + "weight": "heavy", + "uses": ["StationNestedCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_connectedRoutes($from: Int!, $to: Int!) { connectedRoutes(fromStationGroupId: $from, toStationGroupId: $to) { id stops { ...StationNestedCore } } }", + "variables": { "from": 1130205, "to": 1130212 }, + "note": "渋谷 → 池袋。乗り換えを含む有界 BFS" + }, + { + "name": "estimateArrivalTimes", + "weight": "medium", + "query": "query Bench_estimateArrivalTimes($from: Int!, $to: Int!) { estimateArrivalTimes(fromStationId: $from, toStationId: $to) { routes { id stops { stationId stationGroupId cumulativeMinutes stopsHere departureCumulativeMinutes } } } }", + "variables": { "from": 1130205, "to": 1130208 }, + "note": "渋谷 → 新宿 (山手線)。運動学モデルによる到着時刻推定" + }, + { + "name": "trainRoute_short", + "weight": "light", + "uses": ["StationNestedCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_trainRouteShort($from: Int!, $to: Int!, $lineGroupId: Int) { trainRoute(fromStationId: $from, toStationId: $to, lineGroupId: $lineGroupId) { segments { stops distanceFromPrevious maxSpeed maxAcceleration maxDeceleration station { ...StationNestedCore } } } }", + "variables": { "from": 1130101, "to": 1130103, "lineGroupId": 203 }, + "note": "250 駅グループの先頭 3 駅だけを要求する。コストが要求区間に比例しているかの判定材料 (#1647)" + }, + { + "name": "trainRoute_long", + "weight": "heavy", + "uses": ["StationNestedCore", "LineCore", "TrainTypeCore"], + "query": "query Bench_trainRouteLong($from: Int!, $to: Int!, $lineGroupId: Int) { trainRoute(fromStationId: $from, toStationId: $to, lineGroupId: $lineGroupId) { segments { stops distanceFromPrevious maxSpeed maxAcceleration maxDeceleration station { ...StationNestedCore } } } }", + "variables": { "from": 1130101, "to": 1170113, "lineGroupId": 203 }, + "note": "同グループの東京 → 出雲市 (全 250 駅)。short との比が区間比例性を示す" + } + ] +} diff --git a/.gitignore b/.gitignore index 6fd4c8b2..7ba676c0 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ data/KeioBus-GTFS/ scripts/.osm_cache/ scripts/.gtfs_cache/ __pycache__/ + +# ベンチマークの wrangler tail 生ログ (デバッグ用の一時ファイル) +benchmarks/.logs/ diff --git a/AGENTS.md b/AGENTS.md index 7a17ad26..b79d5275 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ This guide explains how automation agents and human contributors should work with the StationAPI repository so releases stay predictable, auditable, and safe. Update this file whenever you change the workflow or behavior it documents. ## Project Layout -- `src/` – The Worker itself (`stationapi-worker`, wasm32 only). `lib.rs` holds the endpoints, `index.rs` parses the embedded CSVs into in-memory indexes, `repository.rs` implements the repository traits against those indexes, and `graphql/` holds the async-graphql types and resolvers. +- `src/` – The Worker itself (`stationapi-worker`, wasm32 only). `lib.rs` holds the endpoints, `index.rs` parses the embedded CSVs into in-memory indexes, `repository.rs` implements the repository traits against those indexes, and `graphql/` holds the async-graphql types and resolvers. `index.rs` also holds the spatial grid used by every coordinate lookup — see **Coordinate lookups** below. - `schema/public.graphql` – The published GraphQL schema. CI diffs the Worker's SDL against this file, so an unintended change fails the build. - `build.rs` – Stages `generated/*.csv` (falling back to `data/*.csv`) into `OUT_DIR` and pre-converts `station_station_types` into a fixed-width binary. - `wrangler.jsonc` – Staging and production deployment settings. @@ -56,17 +56,20 @@ The Worker is the workspace root package. `stationapi`, `preprocessor`, and `dat - `data_validator` currently verifies that `5!station_station_types.csv` references valid station and type IDs, and that order-sensitive station sequences in `3!stations.csv` stay intact under `ORDER BY e_sort, station_cd` (e.g. the Toei Oedo Line's Tochomae rows, whose misordering silently drops the station from ETA estimation). Extend the validator when new cross-references or order-sensitive spots are introduced and keep the process fail-fast (panic on invalid data). ## Testing and Quality -- **Tests** – `make test` runs the unit tests for every native crate. They need no external services. +- **Tests** – `make test` runs the unit tests for every native crate, plus `cargo test -p stationapi-worker`. The Worker only *runs* on Workers, but `src/index.rs` is a pure in-memory data structure that builds and executes natively, so its tests (including the grid-versus-full-scan differential check) run here. They need no external services. - **Type checks** – `make check` covers the native crates and the wasm32 target separately. The Worker also compiles for the host, but only runs on Workers. - **Linting and formatting** – `make fmt` and `make clippy` before committing (clippy covers the wasm32 target too). Resolve new Clippy warnings unless an existing `#![allow]` covers the case. - **Schema** – Changing a GraphQL type changes the SDL. Update `schema/public.graphql` in the same change; CI compares it against the running Worker's `/__schema` and fails on any difference. That diff is exactly the client-visible impact. - **Data verification** – Execute `cargo run -p data_validator` whenever CSVs change and record results in pull requests. - **IPA coverage audit** – Execute `make ipa-audit` when English or romanized CSV names change. This is a read-only report for `data/2!lines.csv`, `data/3!stations.csv`, and `data/4!types.csv`; it does not fail validation, but highlights unresolved tokens and example names so the IPA dictionary can be extended deliberately. +- **Endpoint benchmarks** – `make bench` (or `python3 .claude/skills/benchmark-gql/bench.py`) replays every `Query` field against production (`gql.trainlcd.app`, script `stationapi`) and staging (`gql-stg.trainlcd.app`, script `stationapi-stg`) and writes a Markdown report under `benchmarks/`. Both environments embed the same data, so any difference is implementation — which makes this the way to see what a `dev`-to-`master` release will do to performance before it ships. Besides client latency it records the Worker's `cpuTime`, read from `wrangler tail --format json` and matched to each request by `cf-ray`; the tail is filtered on a per-run request header, so production's live traffic does not leak into the sample. Collecting CPU time needs the `workers_tail (read)` scope, and the run sends hundreds of real requests to production — it is not a routine check. Add a case to `.claude/skills/benchmark-gql/queries.json` whenever a `Query` field is added, and never edit an existing case's variables: the reports are meant to stay comparable across runs. ## GraphQL Query Overview - **Stations** – `station`, `stations`, `stationGroupStations`, `stationsNearby`, `lineStations`, `stationsByName`, `lineGroupStations`, `lineListStations`, `lineGroupListStations`. `QueryInteractor` enriches stations with lines, companies, station numbers, and train types. `lineStations` resolves the line's local train-type group (rail `kind` 0/1 or a `priority > 0` type); when no such group exists — bus lines only carry `BusRoute` (`kind` 7, `priority` 0) variants — it falls back to the line's plain typeless station list so bus stop listings never return empty. - **Lines** – `line`, `lines`, `linesByName`. Results include company data and computed line symbols based on repository helpers. - **Routes** – `routes`, `connectedRoutes`, `estimateArrivalTimes`, `trainRoute`. Paging tokens are currently empty (pagination not implemented). +- **`trainRoute`** – Takes the line group's stops from the repository *before* any enrichment, slices them to the requested `fromStationId`–`toStationId` range (reversing when the request runs backwards), and only then attaches lines, companies, station numbers, train types, and nearby bus routes. Enrichment is per-station and independent, so slicing first does not change any segment; enriching the whole line group first made a three-station request cost the same as a 250-station one. Keep the order — the cost of this query must stay proportional to the requested range, not to the line group. +- **Coordinate lookups** – `index::nearest` (k nearest, used by `stationsNearby`) and `index::within_radius` (everything inside a radius, used by the nearby-bus-stop enrichment) both go through a per-transport-type grid index (`Grid`, CSR over 0.05° cells) instead of scanning the whole station table. `nearest` searches a radius, widens it while fewer than `limit` stations fall inside, and stops once the radius covers the index — anything outside a radius that already holds `limit` hits cannot be in the top `limit`. With `transportType` omitted it returns rail stations first and bus stops after, each group sorted by distance — the pre-Workers SQL's `ORDER BY transport_type, distance`. The limit applies to the merged order, so `nearest` fills it with rail and only asks the bus grid for the remaining slots; a location with `limit` rail stations returns no bus stops at all. Ties on distance break on `station_cd` so the order does not depend on an unstable sort. Every station lookup by coordinates runs on every request that enriches rail stations with nearby bus routes, so keep new coordinate queries on the grid rather than adding another full scan. - **Train types** – `stationTrainTypes`, `routeTypes`. Train types aggregate by line group and include related lines plus optional train type metadata. Rail variants use `TrainTypeKind::{Default, Branch, Rapid, Express, LimitedExpress, HighSpeedRapid, CommuterRapid}` (0-6); bus variants use `BusRoute` (7), which represents a `(route_id, shape_id)` operation pattern (e.g. 循環 / 短ターン / 支線) generated automatically from the configured GTFS bus feeds (Toei Bus, Seibu Bus, Keio Bus) and the converted Tokyu Bus JSON. - **Default rail train types** – `preprocessor` fills every active rail line containing at least one station with no `station_station_types` row with a deterministic, complete all-stop group. The generated rows exist only in `generated/*.csv`; canonical CSV files remain unchanged. `type_cd=100` represents 「普通」 and `type_cd=101` represents 「各駅停車」. An existing 100/101 assignment on the line takes precedence; otherwise the label is selected per line through `LOCAL_SERVICE_RAIL_LINE_IDS` in `preprocessor/src/rail.rs`. Generated `line_group_cd` values use `1,000,000,000 + line_cd`; generation fails on a collision. Bus lines are excluded and continue to use their GTFS-derived `BusRoute` groups. - **GTFS bus integration** – `preprocessor/src/gtfs/` reads the GTFS feeds into an in-memory representation and then projects them onto the shared `stations` / `lines` / `types` / `station_station_types` tables (`gtfs/integrate.rs`). Only routes, stops, trips, and stop_times are read; calendar, shapes, feed_info, and agencies do not affect the output. Every configured GTFS feed is imported, including Seibu Bus and Keio Bus (both downloaded from ODPT with `ODPT_ACCESS_TOKEN`). Tokyu Bus ordinary-route `BusroutePattern`, `BusstopPole`, and `BusTimetable` JSON are converted into the same representation; pattern IDs become `shape_id` values so route variants remain queryable as bus TrainTypes. The Tokyu-operated Ota, Shinagawa, and Meguro community buses use their official GTFS feeds and matching JSON routes are excluded to prevent duplicates. `ODPT_ACCESS_TOKEN` is required for authenticated sources; without it those feeds are skipped with a warning rather than failing the build. Stops whose Tokyu JSON records omit coordinates remain available to name and route queries but not coordinate searches. `transport_type` (0: rail, 1: bus) on both `stations` and `lines` keeps rail and bus records queryable side by side. GTFS IDs are namespaced per feed before import to avoid cross-operator collisions. `line_cd` (100,000,000+), `station_cd` / `station_g_cd` (200,000,000+), and bus `type_cd` / `line_group_cd` (100,000,000+) are all deterministic fnv1a hashes that stay clear of the rail data ranges. Disable the entire bus pipeline with `DISABLE_BUS_FEATURE=true`. diff --git a/Makefile b/Makefile index 854b1697..fae6f57d 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # StationAPI Makefile # よく使うタスクの定義 -.PHONY: help test check fmt clippy data build dev deploy deploy-production schema ipa-audit clean +.PHONY: help test check fmt clippy data build dev deploy deploy-production schema ipa-audit bench clean # CI (.github/workflows/build_worker.yml) と同じ版を使う。グローバルへ入れて # いなくても npx が取ってくるので、版ずれでビルド結果が変わらない。 @@ -21,15 +21,18 @@ help: @echo " deploy-production- Deploy to production (master branch only)" @echo " schema - Diff the running Worker's SDL against schema/public.graphql" @echo " ipa-audit - Print IPA coverage report for English/romanized CSV names" + @echo " bench - Compare production vs staging GraphQL performance (sends live traffic to both)" @echo " clean - Clean build artifacts" @echo "" @echo "Environment variables:" @echo " ODPT_ACCESS_TOKEN - Required by all bus feeds except Toei" @echo " DISABLE_BUS_FEATURE - Set to true to build rail-only data" -# worker は wasm32 用の crate なので、ネイティブのテストからは外す。 +# worker は Workers 上でしか動かないが、索引 (src/index.rs) はネイティブでも +# 動く純粋なデータ構造なので、そのユニットテストはここで走らせる。 test: cargo test -p stationapi -p stationapi-preprocessor -p data_validator + cargo test -p stationapi-worker check: cargo check -p stationapi -p stationapi-preprocessor -p data_validator @@ -76,6 +79,15 @@ ipa-audit: rustc tools/ipa_audit.rs -o /tmp/stationapi-ipa-audit /tmp/stationapi-ipa-audit +# 本番とステージングの GraphQL 性能を比べ、benchmarks/ にレポートを貯める。 +# 実在のエンドポイントへ数百リクエスト投げるので、気軽に回すものではない。 +# CPU Time の収集には wrangler の workers_tail (read) 権限が要る。 +# 追加の引数は BENCH_ARGS で渡す (例: make bench BENCH_ARGS="--repeat 30")。 +bench: + @echo "警告: 本番 (gql.trainlcd.app) とステージングへ実リクエストを送ります。" >&2 + @echo " 既定で 1 環境あたり 400 件超、うち数十件は Worker の CPU を 500ms 以上使います。" >&2 + python3 .claude/skills/benchmark-gql/bench.py $(BENCH_ARGS) + clean: cargo clean rm -rf build .wrangler diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..4316385b --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,40 @@ +# benchmarks/ + +本番 (`https://gql.trainlcd.app`) とステージング (`https://gql-stg.trainlcd.app`) の +GraphQL クエリ性能を比べた結果を貯めておく場所です。 + +両環境は同じデータを積んでいる (`/__health` の駅数・路線数・会社数が一致する) ので、 +差が出ればそれは実装差です。ステージングは `dev`、本番は `master` から出ているため、 +ここに並ぶ差分は「次のリリースで本番がどう変わるか」を先に見たものになります。 + +## 生成する + +```bash +python3 .claude/skills/benchmark-gql/bench.py +``` + +Claude Code からは `/benchmark-gql` でも呼べます。詳しい手順とオプションは +[`.claude/skills/benchmark-gql/SKILL.md`](../.claude/skills/benchmark-gql/SKILL.md) に書いてあります。 + +## 生成されるもの + +`bench.py` は 1 実行につき以下を書き出す。初回実行までは、このディレクトリにはこの +README しか無い。 + +| パス | 内容 | +| --- | --- | +| `index.md` | 実行履歴。1 実行 1 行で要約が追記される | +| `YYYYMMDD-HHMMSS.md` | 実行ごとのレポート本体 | +| `raw/YYYYMMDD-HHMMSS.json` | 全リクエストの生データ。`python3 .claude/skills/benchmark-gql/bench.py --rerender benchmarks/raw/<実行 ID>.json` でレポートを作り直せる (リクエストは送らない。手書きの「所見」は残る) | +| `.logs/` | `wrangler tail` の生ログ。デバッグ用の一時ファイルで Git 管理外 | + +## 読むときに気をつけること + +- **判定は CPU Time で行う。** 応答時間には計測ホストからエッジまでの回線が乗るので、 + 実装の良し悪しを直接は表しません。レポートには `ping` の p50 を引いた補正列もあります。 +- **CPU Time はミリ秒の整数で届く。** 1〜2 ms のクエリでは丸めが効くため、 + 1 標本の値ではなく反復ぶんの平均を見ます。反復数を増やすほど分解能が上がります。 +- **コールドスタートは別枠。** WASM の実体化と索引構築で 250 ms 以上かかるので、 + 定常性能の統計からは外し、レポートの「コールドスタート」節で件数だけ数えています。 +- **クエリの変数は固定。** 過去の実行と比較できるよう、既存ケースの変数は変えません。 + 条件を変えたいときは新しいケースを足します。 diff --git a/docs/nearby-bus-stops.md b/docs/nearby-bus-stops.md index 4475cf35..a91b230b 100644 --- a/docs/nearby-bus-stops.md +++ b/docs/nearby-bus-stops.md @@ -29,6 +29,8 @@ enum TransportType { | **Bus** | バス停のみを返す | | **RailAndBus** | 鉄道駅とバス停の両方を返す。`lines`配列にも近傍バス路線を含める | +**注**: `stationsNearby` は鉄道駅を先に、バス停を後に返します。並びは種別ごとに距離の昇順です。`limit` は種別ごとではなく並べた後の全体に掛かるため、鉄道駅だけで `limit` 件そろう地点ではバス停は返りません。 + ## 対象API | クエリ | 近傍バス停対応 | 備考 | @@ -123,5 +125,5 @@ async fn get_nearby_bus_lines(&self, ref_lat: f64, ref_lon: f64) -> Result Vec { // ---------------------------------------------------------------- 検索 +/// 地球半径 (km)。距離計算と探索範囲の見積もりで同じ値を使う。 +const EARTH_RADIUS_KM: f64 = 6371.0; + /// 球面距離 (km)。 /// /// 度単位のユークリッド距離だと緯度と経度を同じスケールで扱うことになり、 /// 東西方向を過大評価する。ここでは実距離で並べる。 pub fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { - const EARTH_RADIUS_KM: f64 = 6371.0; let (p1, p2) = (lat1.to_radians(), lat2.to_radians()); let dlat = (lat2 - lat1).to_radians(); let dlon = (lon2 - lon1).to_radians(); @@ -466,67 +468,344 @@ pub fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { 2.0 * EARTH_RADIUS_KM * a.sqrt().clamp(-1.0, 1.0).asin() } -/// 全件走査で最近傍 limit 件を返す。11,148 駅なので索引なしで十分速い。 +/// グリッド 1 マスの一辺 (度)。約 5.5km 四方。 +/// 細かくするとマスの数 (= 索引の大きさ) が増え、粗くすると 1 マスあたりの +/// 走査件数が増える。近傍バス停の検索 (半径 300m) が 1 マスで収まる大きさ。 +const GRID_CELL_DEG: f64 = 0.05; +/// マス数の上限。`offsets` は外接矩形に比例して確保するため、外れ値の座標が +/// 1 件混ざるだけで確保量が跳ね上がる。GTFS 由来のデータは外部入力なので、 +/// 上限を超える場合はマスを粗くして収める (索引の役目は候補を絞ることなので、 +/// 粗くしても返す結果は変わらない)。 +const GRID_MAX_CELLS: usize = 1 << 22; +/// 最初に見る半径 (km)。市街地ならこの範囲で近傍バス停 50 件がそろう。 +const INITIAL_SEARCH_RADIUS_KM: f64 = 1.0; +/// 半径の内側で件数が足りなかったときに広げる倍率。 +const SEARCH_RADIUS_GROWTH: f64 = 4.0; + +/// 索引に載せられる座標か。NaN・無限大や WGS84 の範囲外は、距離計算に使えない +/// うえに外接矩形だけを広げるので載せない。 +fn indexable_coords(lat: f64, lon: f64) -> bool { + lat.is_finite() && lon.is_finite() && lat.abs() <= 90.0 && lon.abs() <= 180.0 +} + +fn cell_index(deg: f64, cell_deg: f64) -> i32 { + (deg / cell_deg).floor() as i32 +} + +/// 駅を緯度経度のマスへ割り当てた索引。 +/// +/// 全件走査だと 1 回の近傍検索で駅の総数ぶん距離を計算することになる。 +/// `trainRoute` は経路上の駅ごとに近傍バス停を引くため、経路が長いほど +/// (駅数 × 駅総数) で効いていた。マスに区切っておけば探索半径の内側だけで済む。 +/// +/// 添字は `stations()` のもの。CSR 形式で、`offsets[c]..offsets[c + 1]` が +/// マス c に属する駅の `items` 上の範囲を表す。 +struct Grid { + cell_deg: f64, + min_i: i32, + min_j: i32, + rows: usize, + cols: usize, + offsets: Vec, + items: Vec, +} + +impl Grid { + fn empty() -> Self { + Grid { + cell_deg: GRID_CELL_DEG, + min_i: 0, + min_j: 0, + rows: 0, + cols: 0, + offsets: vec![0], + items: Vec::new(), + } + } + + fn is_empty(&self) -> bool { + self.items.is_empty() + } + + fn build(want: i32) -> Self { + let members: Vec = stations() + .iter() + .enumerate() + .filter(|(_, s)| s.e_status == 0 && s.transport_type as i32 == want) + .filter(|(_, s)| indexable_coords(s.lat, s.lon)) + .map(|(i, _)| i as u32) + .collect(); + if members.is_empty() { + return Grid::empty(); + } + + // 外接矩形がマス数の上限に収まるまでマスを粗くする。座標は WGS84 の + // 範囲に収まっているので、この繰り返しは必ず終わる。 + let bounds = |cell_deg: f64| -> (i32, i32, usize, usize) { + let (mut lo_i, mut hi_i) = (i32::MAX, i32::MIN); + let (mut lo_j, mut hi_j) = (i32::MAX, i32::MIN); + for &m in &members { + let s = &stations()[m as usize]; + let (i, j) = (cell_index(s.lat, cell_deg), cell_index(s.lon, cell_deg)); + lo_i = lo_i.min(i); + hi_i = hi_i.max(i); + lo_j = lo_j.min(j); + hi_j = hi_j.max(j); + } + ( + lo_i, + lo_j, + (hi_i - lo_i + 1) as usize, + (hi_j - lo_j + 1) as usize, + ) + }; + let mut cell_deg = GRID_CELL_DEG; + let (mut min_i, mut min_j, mut rows, mut cols) = bounds(cell_deg); + while rows.saturating_mul(cols) > GRID_MAX_CELLS { + cell_deg *= 2.0; + (min_i, min_j, rows, cols) = bounds(cell_deg); + } + + // 度数分布 -> 累積和 -> 配置の 3 パスで CSR を組む + let mut offsets = vec![0u32; rows * cols + 1]; + let cell_of = |s: &StationRecord| -> usize { + let i = (cell_index(s.lat, cell_deg) - min_i) as usize; + let j = (cell_index(s.lon, cell_deg) - min_j) as usize; + i * cols + j + }; + for &m in &members { + offsets[cell_of(&stations()[m as usize]) + 1] += 1; + } + for c in 0..rows * cols { + offsets[c + 1] += offsets[c]; + } + let mut cursor = offsets.clone(); + let mut items = vec![0u32; members.len()]; + for &m in &members { + let c = cell_of(&stations()[m as usize]); + items[cursor[c] as usize] = m; + cursor[c] += 1; + } + + Grid { + cell_deg, + min_i, + min_j, + rows, + cols, + offsets, + items, + } + } + + /// 半径 radius_km の円を必ず覆うマスの範囲 (両端を含む) を返す。 + /// + /// 緯度差だけの距離は `EARTH_RADIUS_KM * Δφ` なので、そこから緯度の幅を出す。 + /// 経度差だけの距離は両端の緯度が高いほど短くなるため、探索帯のうち最も + /// 極に近い緯度で見積もって幅を広めに取る。 + fn range(&self, lat: f64, lon: f64, radius_km: f64) -> (i32, i32, i32, i32) { + let dlat_deg = (radius_km / EARTH_RADIUS_KM).to_degrees(); + let cos_phi = (lat.abs() + dlat_deg).min(90.0).to_radians().cos(); + let sin_half = radius_km / (2.0 * EARTH_RADIUS_KM * cos_phi); + // cos_phi が 0 付近 (極) だと経度は絞れない。そのときは全周を見る。 + let dlon_deg = if cos_phi <= 0.0 || !sin_half.is_finite() || sin_half >= 1.0 { + 180.0 + } else { + 2.0 * sin_half.asin().to_degrees() + }; + // 日付変更線をまたぐ範囲は 2 本の区間になる。分割して扱う価値がある + // データ (日本) ではないので、その場合は経度を絞らず全周を見る。 + // 絞り込みを諦めるだけなので取りこぼしは起きない。 + let (j0, j1) = if dlon_deg >= 180.0 || lon - dlon_deg < -180.0 || lon + dlon_deg > 180.0 { + (i32::MIN, i32::MAX) + } else { + ( + cell_index(lon - dlon_deg, self.cell_deg), + cell_index(lon + dlon_deg, self.cell_deg), + ) + }; + ( + cell_index(lat - dlat_deg, self.cell_deg), + cell_index(lat + dlat_deg, self.cell_deg), + j0, + j1, + ) + } + + /// この半径で索引の全域を覆うか。覆っていればこれ以上広げても増えない。 + fn covers_all(&self, lat: f64, lon: f64, radius_km: f64) -> bool { + let (i0, i1, j0, j1) = self.range(lat, lon, radius_km); + i0 <= self.min_i + && i1 >= self.min_i + self.rows as i32 - 1 + && j0 <= self.min_j + && j1 >= self.min_j + self.cols as i32 - 1 + } + + /// 半径 radius_km の円を覆うマスに属する駅を渡す。円の外の駅も混ざる。 + fn for_each_near( + &self, + lat: f64, + lon: f64, + radius_km: f64, + mut f: impl FnMut(&'static StationRecord), + ) { + if self.is_empty() { + return; + } + let (i0, i1, j0, j1) = self.range(lat, lon, radius_km); + let i0 = i0.max(self.min_i); + let i1 = i1.min(self.min_i + self.rows as i32 - 1); + let j0 = j0.max(self.min_j); + let j1 = j1.min(self.min_j + self.cols as i32 - 1); + for i in i0..=i1 { + let row = (i - self.min_i) as usize * self.cols; + for j in j0..=j1 { + let c = row + (j - self.min_j) as usize; + for &m in &self.items[self.offsets[c] as usize..self.offsets[c + 1] as usize] { + f(&stations()[m as usize]); + } + } + } + } +} + +/// 種別ごとのグリッド。TransportType は 0 = 鉄道 / 1 = バスの 2 値。 +static GRIDS: OnceLock<[Grid; 2]> = OnceLock::new(); + +fn grid_of(want: i32) -> &'static Grid { + let grids = GRIDS.get_or_init(|| { + [ + Grid::build(TransportType::Rail as i32), + Grid::build(TransportType::Bus as i32), + ] + }); + match want { + w if w == TransportType::Bus as i32 => &grids[1], + w if w == TransportType::Rail as i32 => &grids[0], + _ => EMPTY_GRID.get_or_init(Grid::empty), + } +} + +static EMPTY_GRID: OnceLock = OnceLock::new(); + +/// 最近傍 limit 件を返す。路線を引けない駅は除く。 /// -/// `want` は種別の絞り込み。未指定 (RailAndBus) のときは -/// 鉄道を先・バスを後に並べたうえで距離順になる。 +/// `want` は種別の絞り込み。指定した場合は距離の昇順。未指定 (RailAndBus) の +/// 場合は鉄道駅を先に、バス停を後に並べ、それぞれの中を距離の昇順にする +/// (`stationsNearby` の仕様)。件数の上限は混ぜた後の並びに掛かるので、鉄道駅が +/// limit 件そろえばバス停は返らない。移行前の SQL が `transport_type` を第 1 キー、 +/// 距離を第 2 キーにしていたのと同じ並び。 pub fn nearest( lat: f64, lon: f64, limit: usize, want: Option, ) -> Vec<(&'static StationRecord, f64)> { - nearest_inner(lat, lon, limit, want, true) + // 索引に載せられない座標では探索を打ち切れない。lat が NaN だと + // covers_all が永久に false のままで、半径を無限大まで広げ続けても + // 抜けられない (リクエストが返らなくなる)。入口で弾く。 + if !indexable_coords(lat, lon) { + return Vec::new(); + } + let Some(want) = want else { + // 鉄道駅が先に並ぶので、上限に届くまでの残り枠だけがバス停に回る。 + // 残り枠より遠いバス停は採用されないため、引くのも残り枠ぶんでよい。 + let mut out = nearest_of_type(lat, lon, limit, TransportType::Rail as i32); + let rest = limit.saturating_sub(out.len()); + out.extend(nearest_of_type(lat, lon, rest, TransportType::Bus as i32)); + return out; + }; + nearest_of_type(lat, lon, limit, want) +} + +/// 距離の昇順、同着なら station_cd の昇順。 +/// +/// 同じ駅グループの駅は路線ごとに行が分かれるうえ座標を共有するため、距離だけで +/// 並べると同着が多数出る。以前は不安定ソートに任せていたので、どの路線の行が +/// 先に来るかがビルドごとに変わり得た。並びを決め切っておく。 +fn by_distance_then_station_cd( + a: &(&'static StationRecord, f64), + b: &(&'static StationRecord, f64), +) -> std::cmp::Ordering { + a.1.partial_cmp(&b.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.station_cd.cmp(&b.0.station_cd)) } -/// 路線の存在を条件にしないまま最近傍を取る。 +/// 半径 radius_km 以内の駅を距離の昇順で返す。件数の上限は掛けない。 /// -/// 近傍バス停の検索は先に件数を絞ってから路線の有無を見る。 -/// 先に路線で絞ると件数が変わるため、この順序を保つ用途で使う。 -pub fn nearest_without_line_join( +/// 「上位 N 件」ではなく半径で切る用途 (駅の近傍バス停) 向け。最寄り N 件を +/// 作ってから半径で捨てると、採用されない駅の分まで組み立てることになる。 +/// `nearest` と違い、路線を引けるかどうかは見ない (呼び出し側が +/// 路線で絞ってから件数を確定させるため)。 +pub fn within_radius( lat: f64, lon: f64, - limit: usize, - want: Option, + radius_km: f64, + want: i32, ) -> Vec<(&'static StationRecord, f64)> { - nearest_inner(lat, lon, limit, want, false) + let mut out: Vec<(&'static StationRecord, f64)> = Vec::new(); + // `nearest` と同じ理由で、索引に載せられない座標は入口で弾く + if !radius_km.is_finite() || radius_km < 0.0 || !indexable_coords(lat, lon) { + return out; + } + grid_of(want).for_each_near(lat, lon, radius_km, |record| { + let distance = haversine_km(lat, lon, record.lat, record.lon); + if distance <= radius_km { + out.push((record, distance)); + } + }); + out.sort_unstable_by(by_distance_then_station_cd); + out } -fn nearest_inner( +/// 指定した種別の駅から最近傍 limit 件を距離昇順で返す。 +/// +/// 半径 r の範囲に limit 件そろえば、r より外に上位 limit 件は存在しない。 +/// そこでグリッド索引で半径 r の内側だけを見て、足りなければ r を広げる。 +/// 索引の外接矩形を覆っても足りなければ、その種別の全件がそろっている。 +fn nearest_of_type( lat: f64, lon: f64, limit: usize, - want: Option, - require_line: bool, + want: i32, ) -> Vec<(&'static StationRecord, f64)> { - let mut scored: Vec<(&StationRecord, f64)> = stations() - .iter() - .filter(|s| s.e_status == 0) - .filter(|s| !require_line || joins_line(s)) - .filter(|s| want.is_none_or(|w| s.transport_type as i32 == w)) - .map(|s| (s, haversine_km(lat, lon, s.lat, s.lon))) - .collect(); + if limit == 0 { + return Vec::new(); + } + let grid = grid_of(want); + if grid.is_empty() { + return Vec::new(); + } - // 種別指定がある場合は第1キーが定数 0 になるので距離だけで並ぶ - let rank = move |s: &StationRecord| -> i32 { - if want.is_none() { - s.transport_type as i32 - } else { - 0 + let mut radius_km = INITIAL_SEARCH_RADIUS_KM; + loop { + let covers_all = grid.covers_all(lat, lon, radius_km); + let mut scored: Vec<(&'static StationRecord, f64)> = Vec::new(); + let mut within = 0usize; + grid.for_each_near(lat, lon, radius_km, |record| { + if !joins_line(record) { + return; + } + let distance = haversine_km(lat, lon, record.lat, record.lon); + if distance <= radius_km { + within += 1; + } + scored.push((record, distance)); + }); + + // 半径の内側で limit 件そろっていれば、外側を見る必要はない。 + // 覆い切った場合はそれ以上広げても増えないので打ち切る。 + if within >= limit || covers_all { + if limit < scored.len() { + scored.select_nth_unstable_by(limit, by_distance_then_station_cd); + scored.truncate(limit); + } + scored.sort_unstable_by(by_distance_then_station_cd); + return scored; } - }; - let cmp = move |a: &(&StationRecord, f64), b: &(&StationRecord, f64)| { - rank(a.0) - .cmp(&rank(b.0)) - .then_with(|| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)) - }; - // 全体ソートを避け、上位 limit 件だけを確定させる - if limit < scored.len() { - scored.select_nth_unstable_by(limit, cmp); - scored.truncate(limit); + radius_km *= SEARCH_RADIUS_GROWTH; } - scored.sort_unstable_by(cmp); - scored } /// 駅名・読み・ローマ字・中国語・韓国語のいずれかへの部分一致で引く。 @@ -907,3 +1186,303 @@ pub fn apply_line_alias(line: &mut Line, station_cd: i32) { line.line_name_ko = pick(alias.line_name_ko.as_ref(), line.line_name_ko.clone()); line.line_color_c = pick(alias.line_color_c.as_ref(), line.line_color_c.clone()); } + +#[cfg(test)] +mod tests { + use super::*; + + /// グリッド索引の正解となる全件走査。索引を入れる前の実装そのもの。 + fn nearest_by_full_scan( + lat: f64, + lon: f64, + limit: usize, + want: Option, + ) -> Vec<(&'static StationRecord, f64)> { + let mut scored: Vec<(&StationRecord, f64)> = stations() + .iter() + .filter(|s| s.e_status == 0) + .filter(|s| joins_line(s)) + .filter(|s| want.is_none_or(|w| s.transport_type as i32 == w)) + .map(|s| (s, haversine_km(lat, lon, s.lat, s.lon))) + .collect(); + + // 種別を指定しない場合は鉄道が先、バスが後。その中では距離順 + // (`stationsNearby` の仕様)。種別を指定した場合は第 1 キーが定数に + // なるので、同じ比較関数で距離順になる。 + let cmp = |a: &(&'static StationRecord, f64), b: &(&'static StationRecord, f64)| { + (a.0.transport_type as i32) + .cmp(&(b.0.transport_type as i32)) + .then_with(|| by_distance_then_station_cd(a, b)) + }; + if limit < scored.len() { + scored.select_nth_unstable_by(limit, cmp); + scored.truncate(limit); + } + scored.sort_unstable_by(cmp); + scored + } + + fn assert_same_as_full_scan(lat: f64, lon: f64, limit: usize, want: Option) { + let expected = nearest_by_full_scan(lat, lon, limit, want); + let actual = nearest(lat, lon, limit, want); + assert_eq!( + expected.len(), + actual.len(), + "件数が違う ({lat}, {lon}) want={want:?} limit={limit}" + ); + for (i, (e, a)) in expected.iter().zip(actual.iter()).enumerate() { + // 種別・距離・station_cd の 3 キーで並びが決まり切るので、駅まで一致する + assert!( + e.0.station_cd == a.0.station_cd + && e.0.transport_type == a.0.transport_type + && (e.1 - a.1).abs() < 1e-9, + "{i} 件目が違う ({lat}, {lon}) want={want:?} limit={limit}: \ + 期待 {} {:?} {} 実際 {} {:?} {}", + e.0.station_cd, + e.0.transport_type, + e.1, + a.0.station_cd, + a.0.transport_type, + a.1 + ); + } + } + + /// グリッド索引は全件走査と同じ結果を返す。 + /// 索引の絞り込みが範囲を取りこぼすと最近傍が欠けるため、実データで突き合わせる。 + #[test] + fn grid_search_matches_full_scan() { + // 実在の駅の座標を、データの大きさによらず 40 点ほど抜き出す + let step = (stations().len() / 40).max(1); + let sampled = stations().iter().step_by(step).map(|s| (s.lat, s.lon)); + // 駅から離れた座標 (海上・国外)、および索引の外側 + let outside = [ + (35.0, 145.0), + (43.5, 141.0), + (26.2, 127.7), + (0.0, 0.0), + (51.5, -0.1), + (-33.9, 151.2), + ]; + for (lat, lon) in sampled.chain(outside) { + for want in [None, Some(0), Some(1)] { + for limit in [1usize, 50] { + assert_same_as_full_scan(lat, lon, limit, want); + } + } + } + } + + /// 極や日付変更線の付近でも打ち切れること。 + /// 経度の絞り込みが日付変更線をまたぐ場合、範囲が索引を覆えず + /// 半径を広げ続ける (無限ループになる) 経路があった。 + #[test] + fn grid_search_terminates_at_the_poles_and_the_antimeridian() { + for (lat, lon) in [ + (89.9, 179.9), + (-89.9, -179.9), + (89.9, -179.9), + (-89.9, 179.9), + (35.0, 179.99), + (35.0, -179.99), + (90.0, 0.0), + (-90.0, 0.0), + ] { + for want in [None, Some(0), Some(1)] { + assert_same_as_full_scan(lat, lon, 5, want); + } + } + } + + /// 半径 0 件要求と、存在しない種別を渡した場合。 + #[test] + fn grid_search_handles_degenerate_requests() { + assert!(nearest(35.681382, 139.766084, 0, None).is_empty()); + assert!(nearest(35.681382, 139.766084, 5, Some(99)).is_empty()); + } + + /// `within_radius` は半径以内の駅を距離の昇順で漏れなく返す。 + /// 近傍バス停の採否をそのまま決めるので、全件走査と突き合わせる。 + #[test] + fn within_radius_matches_full_scan() { + let rail = TransportType::Rail as i32; + let step = (stations().len() / 30).max(1); + for record in stations().iter().step_by(step) { + for radius_km in [0.0, 0.3, 2.0, 25.0] { + let mut expected: Vec<(i32, f64)> = stations() + .iter() + .filter(|s| s.e_status == 0 && s.transport_type as i32 == rail) + .map(|s| { + ( + s.station_cd, + haversine_km(record.lat, record.lon, s.lat, s.lon), + ) + }) + .filter(|(_, d)| *d <= radius_km) + .collect(); + expected.sort_by(|a, b| { + a.1.partial_cmp(&b.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(&b.0)) + }); + let actual: Vec<(i32, f64)> = + within_radius(record.lat, record.lon, radius_km, rail) + .into_iter() + .map(|(s, d)| (s.station_cd, d)) + .collect(); + assert_eq!( + expected, actual, + "({}, {}) radius={radius_km}km", + record.lat, record.lon + ); + } + } + } + + /// 半径ちょうどの駅を含み、負の半径は空を返す。 + #[test] + fn within_radius_handles_the_boundary_and_negative_radius() { + let rail = TransportType::Rail as i32; + let origin = &stations()[0]; + // 自分自身は距離 0 なので、半径 0 でも含まれる + let at_zero = within_radius(origin.lat, origin.lon, 0.0, rail); + assert!( + at_zero + .iter() + .any(|(s, _)| s.station_cd == origin.station_cd), + "半径 0 で距離 0 の駅が落ちている" + ); + assert!(at_zero.iter().all(|(_, d)| *d == 0.0)); + + // 2 番目に近い駅の距離を半径にすると、その駅は含まれる (境界を含む) + let near = within_radius(origin.lat, origin.lon, 50.0, rail); + if let Some((boundary, distance)) = near.last().map(|(s, d)| (s.station_cd, *d)) { + let exact = within_radius(origin.lat, origin.lon, distance, rail); + assert!( + exact.iter().any(|(s, _)| s.station_cd == boundary), + "半径ちょうどの駅が落ちている" + ); + } + + assert!(within_radius(origin.lat, origin.lon, -1.0, rail).is_empty()); + } + + /// 索引に載せられない座標を渡しても打ち切れること。 + /// + /// lat が NaN だと covers_all が永久に false のままで、半径を無限大まで + /// 広げ続けても抜けられない。入口で弾いていないとこのテストは終わらない。 + #[test] + fn nearest_rejects_coordinates_it_cannot_index() { + for (lat, lon) in [ + (f64::NAN, 139.766084), + (35.681382, f64::NAN), + (f64::INFINITY, 139.766084), + (35.681382, f64::NEG_INFINITY), + (90.1, 139.766084), + (35.681382, 180.1), + ] { + for want in [None, Some(TransportType::Rail as i32)] { + assert!( + nearest(lat, lon, 5, want).is_empty(), + "({lat}, {lon}) want={want:?} が空でない" + ); + } + assert!(within_radius(lat, lon, 1.0, TransportType::Rail as i32).is_empty()); + } + } + + /// 索引に載せられない座標を弾く。NaN や範囲外が混ざると外接矩形だけが + /// 広がり、マスの確保量が跳ね上がる。 + #[test] + fn indexable_coords_rejects_invalid_values() { + assert!(indexable_coords(35.681382, 139.766084)); + assert!(indexable_coords(-90.0, 180.0)); + assert!(!indexable_coords(f64::NAN, 139.0)); + assert!(!indexable_coords(35.0, f64::INFINITY)); + assert!(!indexable_coords(90.1, 139.0)); + assert!(!indexable_coords(35.0, 180.1)); + } + + /// 実データのグリッドがマス数の上限に収まっている。 + #[test] + fn grid_stays_within_the_cell_cap() { + for want in [TransportType::Rail as i32, TransportType::Bus as i32] { + let grid = grid_of(want); + assert!( + grid.rows * grid.cols <= GRID_MAX_CELLS, + "種別 {want} のマス数 {} が上限を超えている", + grid.rows * grid.cols + ); + } + } + + /// 索引が返す距離は haversine_km と一致し、距離の昇順に並ぶ。 + #[test] + fn grid_search_returns_sorted_distances() { + let hits = nearest(35.681382, 139.766084, 20, Some(TransportType::Rail as i32)); + assert!(!hits.is_empty()); + for pair in hits.windows(2) { + assert!(pair[0].1 <= pair[1].1, "距離の昇順になっていない"); + } + for (record, distance) in &hits { + let expected = haversine_km(35.681382, 139.766084, record.lat, record.lon); + assert!((expected - distance).abs() < 1e-9); + } + } + + /// `stationsNearby` の並びは鉄道駅が先、バス停が後。種別を指定しない場合も + /// 種別で分かれ、距離の昇順はそれぞれの中だけで成り立つ。 + #[test] + fn nearest_puts_rail_before_bus() { + let step = (stations().len() / 40).max(1); + for record in stations().iter().step_by(step) { + for limit in [5usize, 50] { + let hits = nearest(record.lat, record.lon, limit, None); + for pair in hits.windows(2) { + let (former, latter) = (pair[0].0.transport_type, pair[1].0.transport_type); + assert!( + (former as i32) <= (latter as i32), + "({}, {}) limit={limit}: バス停が鉄道駅より先に来ている \ + ({former:?} -> {latter:?})", + record.lat, + record.lon, + ); + if former as i32 == latter as i32 { + assert!( + pair[0].1 <= pair[1].1, + "({}, {}) limit={limit}: 種別 {former:?} の中が距離の昇順に \ + なっていない ({} -> {})", + record.lat, + record.lon, + pair[0].1, + pair[1].1 + ); + } + } + } + } + } + + /// 鉄道駅だけで上限に届く地点では、どれだけ近くてもバス停は返らない。 + /// 上限は種別ごとではなく、混ぜた後の並びに掛かる。 + #[test] + fn nearest_fills_the_limit_with_rail_before_bus() { + // 東京駅前。周囲にはバス停も鉄道駅も多数ある + let (lat, lon) = (35.681382, 139.766084); + let rail = nearest(lat, lon, 5, Some(TransportType::Rail as i32)); + assert_eq!(rail.len(), 5, "鉄道駅が 5 件そろう地点で測る"); + // DISABLE_BUS_FEATURE で組んだ鉄道のみのデータでは確かめようがない + if nearest(lat, lon, 5, Some(TransportType::Bus as i32)).is_empty() { + return; + } + + let hits = nearest(lat, lon, 5, None); + // all() は空でも通るので、上限まで埋まっていることを先に確かめる + assert_eq!(hits.len(), 5, "鉄道駅で上限が埋まる地点で 5 件返らない"); + assert!( + hits.iter() + .all(|(record, _)| record.transport_type == TransportType::Rail), + "鉄道駅で上限が埋まる地点にバス停が混ざっている" + ); + } +} diff --git a/src/repository.rs b/src/repository.rs index 44037503..e9f21555 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -322,31 +322,33 @@ impl StationRepository for MemStationRepository { .collect()) } - /// 各座標につきバス停の最寄り N 件を取り、そのあと有効な路線を持つものだけに絞る。 - /// 先に路線で絞ると件数が変わるため、この順序を保つ。 + /// 各座標につき半径以内のバス停を近い順に見て、有効な路線を持つものだけを + /// N 件まで採る。上限を先に掛けると、路線を引けないバス停や廃止路線の + /// バス停が枠を埋めたぶんだけ件数が減るため、絞り込みを先に行う。 /// 並びは指定された座標の順、その中では距離順。 async fn get_bus_stops_near_stations( &self, coords: &[(u32, f64, f64)], limit_per_station: u32, + radius_meters: f64, ) -> Result, DomainError> { - let want = Some(TransportType::Bus as i32); + let want = TransportType::Bus as i32; let limit = limit_per_station as usize; + let radius_km = radius_meters / 1000.0; let mut out = Vec::new(); for &(source_g_cd, lat, lon) in coords { - for (record, _distance) in index::nearest_without_line_join(lat, lon, limit, want) { - let Some(line) = index::line_by_cd(record.line_cd) else { - continue; - }; - if line.e_status != 0 { - continue; - } - let mut station = record.to_entity(Some(line)); - station.line_group_cd = index::first_line_group_cd(record.station_cd); - station.has_train_types = station.line_group_cd.is_some(); - out.push((source_g_cd, station)); - } + let hits = index::within_radius(lat, lon, radius_km, want) + .into_iter() + .filter_map(|(record, _distance)| { + let line = index::line_by_cd(record.line_cd).filter(|l| l.e_status == 0)?; + let mut station = record.to_entity(Some(line)); + station.line_group_cd = index::first_line_group_cd(record.station_cd); + station.has_train_types = station.line_group_cd.is_some(); + Some((source_g_cd, station)) + }) + .take(limit); + out.extend(hits); } Ok(out) } @@ -930,10 +932,14 @@ pub struct MemCompanyRepository; #[async_trait] impl CompanyRepository for MemCompanyRepository { + /// 事業者は 179 件と少ないが、駅ごとの付帯情報を組み立てるたびに呼ばれる。 + /// `id_vec.contains` のままだと 1 回の呼び出しで (事業者数 × 要求 ID 数) の + /// 比較になるため、集合に入れてから引く。 async fn find_by_id_vec(&self, id_vec: &[u32]) -> Result, DomainError> { + let wanted: HashSet = id_vec.iter().copied().collect(); Ok(index::companies() .iter() - .filter(|c| id_vec.contains(&(c.company_cd as u32))) + .filter(|c| wanted.contains(&(c.company_cd as u32))) .cloned() .collect()) } diff --git a/stationapi/src/domain/repository/station_repository.rs b/stationapi/src/domain/repository/station_repository.rs index 41589eee..e84e91a4 100644 --- a/stationapi/src/domain/repository/station_repository.rs +++ b/stationapi/src/domain/repository/station_repository.rs @@ -40,6 +40,10 @@ pub trait StationRepository: Send + Sync + 'static { &self, station_group_id_vec: &[u32], ) -> Result, DomainError>; + /// 座標の近傍から最大 `limit` 件返す。`transport_type` を指定した場合は + /// 距離の昇順。指定しない場合は鉄道駅を先、バス停を後に並べ、それぞれの中を + /// 距離の昇順にする (`stationsNearby` の仕様)。件数の上限は並べた後に掛かる + /// ので、鉄道駅だけで `limit` 件そろえばバス停は返らない。 async fn get_by_coordinates( &self, latitude: f64, @@ -82,10 +86,19 @@ pub trait StationRepository: Send + Sync + 'static { }) .collect()) } + /// 各座標から `radius_meters` 以内のバス停を、近い順に最大 + /// `limit_per_station` 件返す。半径の外は呼び出し側でも採用されないため、 + /// ここで切っておく (全国の最寄り N 件を作ってから捨てると、駅数に比例して + /// 無駄が積み上がる)。 + /// + /// 半径が有限でない (`NaN` / 無限大) 場合と負の場合は空を返す。無限大を + /// 距離の比較にそのまま使うと全件が半径内と判定されるため、実装ごとに + /// 結果が食い違わないようここで決めておく。 async fn get_bus_stops_near_stations( &self, coords: &[(u32, f64, f64)], // (station_g_cd, lat, lon) limit_per_station: u32, + radius_meters: f64, ) -> Result, DomainError>; async fn get_route_stops( &self, @@ -250,8 +263,18 @@ mod tests { }) .collect(); - // 距離でソート - result.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap()); + // trait の契約どおり、鉄道を先・バスを後にしてから距離でソートする。 + // 種別を指定した場合は第 1 キーが定数になるので距離順になる。 + result.sort_by(|a, b| { + (a.transport_type as i32) + .cmp(&(b.transport_type as i32)) + .then_with(|| { + a.distance + .partial_cmp(&b.distance) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .then_with(|| a.station_cd.cmp(&b.station_cd)) + }); // 制限があれば適用 if let Some(limit) = limit { @@ -261,19 +284,63 @@ mod tests { Ok(result) } + /// trait の契約どおり、半径で絞ってから件数を切る。 + /// + /// `get_by_coordinates` が `distance` に入れるのは緯度経度の度で測った + /// ユークリッド距離なので、メートルの半径とは比較できない。ここでは + /// 距離を測り直す。件数を先に切ると、半径の外の駅が枠を埋めた分だけ + /// 返る件数が本来より少なくなる。 async fn get_bus_stops_near_stations( &self, coords: &[(u32, f64, f64)], limit_per_station: u32, + radius_meters: f64, ) -> Result, DomainError> { + // 無限大をそのまま比較に使うと全件が半径内になる + if !radius_meters.is_finite() || radius_meters < 0.0 { + return Ok(Vec::new()); + } + + /// 球面距離 (m)。 + fn haversine_meters(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { + const EARTH_RADIUS_M: f64 = 6_371_000.0; + let (p1, p2) = (lat1.to_radians(), lat2.to_radians()); + let dlat = (lat2 - lat1).to_radians(); + let dlon = (lon2 - lon1).to_radians(); + let a = + (dlat / 2.0).sin().powi(2) + p1.cos() * p2.cos() * (dlon / 2.0).sin().powi(2); + 2.0 * EARTH_RADIUS_M * a.sqrt().clamp(-1.0, 1.0).asin() + } + let mut result = Vec::new(); for &(source_g_cd, lat, lon) in coords { let stops = self - .get_by_coordinates(lat, lon, Some(limit_per_station), Some(TransportType::Bus)) + .get_by_coordinates(lat, lon, None, Some(TransportType::Bus)) .await?; - for stop in stops { - result.push((source_g_cd, stop)); - } + // get_by_coordinates の並びは度で測ったユークリッド距離順で、 + // 緯度の高い地点では球面距離順と一致しない。件数を切る前に + // 測り直した距離で並べ直す。 + let mut within: Vec = stops + .into_iter() + .filter_map(|mut stop| { + let meters = haversine_meters(lat, lon, stop.lat, stop.lon); + (meters <= radius_meters).then(|| { + stop.distance = Some(meters); + stop + }) + }) + .collect(); + // 元の並びは HashMap の反復順なので、同距離の順序を距離だけに + // 任せると件数を切ったときにどのバス停が残るか実行ごとに変わる。 + // 索引側 (by_distance_then_station_cd) と同じく station_cd で決める。 + within.sort_by(|a, b| { + a.distance + .partial_cmp(&b.distance) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.station_cd.cmp(&b.station_cd)) + }); + within.truncate(limit_per_station as usize); + result.extend(within.into_iter().map(|stop| (source_g_cd, stop))); } Ok(result) } @@ -455,6 +522,150 @@ mod tests { ) } + /// 指定した座標にバス停を置いたモック。半径の扱いを検証するために使う。 + fn bus_stop_repository(stops: &[(i32, f64, f64)]) -> MockStationRepository { + let mut stations = HashMap::new(); + for &(station_cd, lat, lon) in stops { + let mut stop = + create_test_station(station_cd, &format!("バス停{station_cd}"), 500, lat, lon); + stop.transport_type = TransportType::Bus; + stations.insert(station_cd as u32, stop); + } + MockStationRepository { stations } + } + + /// 指定した座標に種別つきの駅を置いたモック。並び順の検証に使う。 + /// 経度は東京駅に固定し、緯度だけを動かす。 + fn mixed_repository(stations_spec: &[(i32, TransportType, f64)]) -> MockStationRepository { + let mut stations = HashMap::new(); + for &(station_cd, transport_type, lat) in stations_spec { + let mut station = + create_test_station(station_cd, &format!("駅{station_cd}"), 500, lat, 139.767125); + station.transport_type = transport_type; + stations.insert(station_cd as u32, station); + } + MockStationRepository { stations } + } + + /// 東京駅から北へおよそ meters メートルの緯度。 + fn lat_north_of_tokyo(meters: f64) -> f64 { + 35.681236 + meters / 111_195.0 + } + + #[tokio::test] + async fn test_get_bus_stops_near_stations_excludes_stops_outside_the_radius() { + let repo = bus_stop_repository(&[ + (901, lat_north_of_tokyo(100.0), 139.767125), + (902, lat_north_of_tokyo(250.0), 139.767125), + (903, lat_north_of_tokyo(500.0), 139.767125), + ]); + + let result = repo + .get_bus_stops_near_stations(&[(1, 35.681236, 139.767125)], 50, 300.0) + .await + .unwrap(); + + // 300m を超える 903 は含まれず、近い順に並ぶ + let ids: Vec = result.iter().map(|(_, s)| s.station_cd).collect(); + assert_eq!(ids, vec![901, 902]); + // 距離はメートルで入る + let distances: Vec = result.iter().map(|(_, s)| s.distance.unwrap()).collect(); + assert!((distances[0] - 100.0).abs() < 5.0, "{distances:?}"); + assert!((distances[1] - 250.0).abs() < 5.0, "{distances:?}"); + // 呼び出し元の座標に紐づく + assert!(result.iter().all(|(source_g_cd, _)| *source_g_cd == 1)); + } + + /// 件数の上限は半径で絞ったあとに掛ける。先に切ると、半径の外の駅が枠を + /// 埋めた分だけ返る件数が本来より少なくなる。 + #[tokio::test] + async fn test_get_bus_stops_near_stations_applies_the_limit_after_the_radius() { + let repo = bus_stop_repository(&[ + (901, lat_north_of_tokyo(1000.0), 139.767125), + (902, lat_north_of_tokyo(2000.0), 139.767125), + (903, lat_north_of_tokyo(100.0), 139.767125), + (904, lat_north_of_tokyo(200.0), 139.767125), + ]); + + let result = repo + .get_bus_stops_near_stations(&[(1, 35.681236, 139.767125)], 2, 300.0) + .await + .unwrap(); + + // 半径の外にある 901 / 902 が枠を消費しない + let ids: Vec = result.iter().map(|(_, s)| s.station_cd).collect(); + assert_eq!(ids, vec![903, 904]); + } + + /// 同距離の並びは station_cd の昇順。元の並びは HashMap の反復順なので、 + /// 決め切っていないと件数を切ったときの結果が実行ごとに変わる。 + #[tokio::test] + async fn test_get_bus_stops_near_stations_breaks_ties_by_station_cd() { + let lat = lat_north_of_tokyo(100.0); + let repo = bus_stop_repository(&[(903, lat, 139.767125), (901, lat, 139.767125)]); + + let result = repo + .get_bus_stops_near_stations(&[(1, 35.681236, 139.767125)], 1, 300.0) + .await + .unwrap(); + + let ids: Vec = result.iter().map(|(_, s)| s.station_cd).collect(); + assert_eq!(ids, vec![901]); + } + + /// 座標ごとにまとまり、その中では距離順。 + #[tokio::test] + async fn test_get_bus_stops_near_stations_groups_by_source_coordinate() { + let repo = bus_stop_repository(&[ + (901, lat_north_of_tokyo(100.0), 139.767125), + (902, lat_north_of_tokyo(200.0), 139.767125), + ]); + + let result = repo + .get_bus_stops_near_stations( + &[ + (1, 35.681236, 139.767125), + (2, lat_north_of_tokyo(200.0), 139.767125), + ], + 50, + 300.0, + ) + .await + .unwrap(); + + let pairs: Vec<(u32, i32)> = result + .iter() + .map(|(source_g_cd, s)| (*source_g_cd, s.station_cd)) + .collect(); + assert_eq!(pairs, vec![(1, 901), (1, 902), (2, 902), (2, 901)]); + } + + /// 半径が有限でない場合と負の場合は空を返す。無限大をそのまま比較に使うと + /// 全件が半径内と判定され、本番実装 (index::within_radius) と食い違う。 + #[tokio::test] + async fn test_get_bus_stops_near_stations_rejects_an_invalid_radius() { + let repo = bus_stop_repository(&[ + (901, lat_north_of_tokyo(100.0), 139.767125), + (902, lat_north_of_tokyo(5000.0), 139.767125), + ]); + let coords = [(1u32, 35.681236, 139.767125)]; + + for radius in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -1.0] { + let result = repo + .get_bus_stops_near_stations(&coords, 50, radius) + .await + .unwrap(); + assert!(result.is_empty(), "半径 {radius} で空にならない"); + } + + // 有限の半径では従来どおり返る + let result = repo + .get_bus_stops_near_stations(&coords, 50, 300.0) + .await + .unwrap(); + assert_eq!(result.len(), 1); + } + #[tokio::test] async fn test_find_by_id_existing() { let repo = MockStationRepository::new(); @@ -518,6 +729,46 @@ mod tests { assert!(result[0].distance.is_some()); } + /// 種別を指定しない座標検索は鉄道駅が先、バス停が後。10m 先のバス停より + /// 500m 先の鉄道駅が先に来る (`stationsNearby` の仕様)。 + #[tokio::test] + async fn test_get_by_coordinates_puts_rail_before_bus() { + let repo = mixed_repository(&[ + (901, TransportType::Bus, lat_north_of_tokyo(10.0)), + (902, TransportType::Bus, lat_north_of_tokyo(20.0)), + (101, TransportType::Rail, lat_north_of_tokyo(500.0)), + (102, TransportType::Rail, lat_north_of_tokyo(400.0)), + ]); + + let result = repo + .get_by_coordinates(35.681236, 139.767125, None, None) + .await + .unwrap(); + + // 鉄道 2 件が先、その中では近い順。バス停はその後 + let ids: Vec = result.iter().map(|s| s.station_cd).collect(); + assert_eq!(ids, vec![102, 101, 901, 902]); + } + + /// 件数の上限は種別ごとではなく、並べた後の全体に掛かる。鉄道駅だけで + /// 埋まる地点ではバス停は返らない。 + #[tokio::test] + async fn test_get_by_coordinates_fills_the_limit_with_rail_first() { + let repo = mixed_repository(&[ + (901, TransportType::Bus, lat_north_of_tokyo(10.0)), + (101, TransportType::Rail, lat_north_of_tokyo(500.0)), + (102, TransportType::Rail, lat_north_of_tokyo(400.0)), + ]); + + let result = repo + .get_by_coordinates(35.681236, 139.767125, Some(2), None) + .await + .unwrap(); + + let ids: Vec = result.iter().map(|s| s.station_cd).collect(); + assert_eq!(ids, vec![102, 101]); + } + #[tokio::test] async fn test_get_by_name() { let repo = MockStationRepository::new(); diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index d8968fe6..91e1b6cb 100644 --- a/stationapi/src/use_case/interactor/query.rs +++ b/stationapi/src/use_case/interactor/query.rs @@ -950,8 +950,14 @@ where entity_type: "line group", entity_id: "unspecified".to_string(), })?; + // 系統の停車駅は付帯情報を付ける前に取り、要求された区間へ切り詰めてから + // 付帯情報を付ける。付帯情報の付与 (所属路線・事業者・種別・近傍バス路線) + // は駅ごとに独立しているため、切り詰めてから付けても各駅の内容は変わらない。 + // 先に系統全体へ付けると、3 駅だけを要求されても 250 駅ぶんを組み立てる + // ことになり、区間の長さに関係なく同じ費用が掛かっていた。 let stations = self - .get_stations_by_line_group_id(line_group_id, TransportTypeFilter::RailAndBus) + .station_repository + .get_by_line_group_id(line_group_id) .await?; let from_idx = stations @@ -976,6 +982,14 @@ where v.reverse(); v }; + let sliced = self + .update_station_vec_with_attributes( + sliced, + Some(line_group_id), + TransportTypeFilter::RailAndBus, + false, + ) + .await?; let mut segments: Vec = Vec::with_capacity(sliced.len()); // 経路スライス内で路線ごとに通過駅があるか。通過駅が無い路線では優等種別でも @@ -1463,10 +1477,11 @@ where &self, coords: &[(u32, f64, f64)], limit_per_station: u32, + radius_meters: f64, ) -> Result, UseCaseError> { let result = self .station_repository - .get_bus_stops_near_stations(coords, limit_per_station) + .get_bus_stops_near_stations(coords, limit_per_station, radius_meters) .await?; Ok(result) @@ -1519,6 +1534,29 @@ where vec![] }; + // 候補は駅グループごとに 1 つの代表座標で引くが、採否は駅ごとの座標で + // 決まる。代表座標と各駅の座標の隔たりぶんを半径に足しておかないと、 + // 代表からは半径の外だが同じグループの別の駅からは内側、というバス停を + // 取りこぼす。 + let bus_search_radius_meters = if should_include_bus_routes { + let anchors: HashMap = unique_bus_coords + .iter() + .map(|&(group_id, lat, lon)| (group_id as i32, (lat, lon))) + .collect(); + let max_offset = stations + .iter() + .filter(|s| s.transport_type == TransportType::Rail) + .filter_map(|s| { + anchors + .get(&s.station_g_cd) + .map(|&(lat, lon)| haversine_distance(lat, lon, s.lat, s.lon)) + }) + .fold(0.0_f64, f64::max); + NEARBY_BUS_STOP_RADIUS_METERS + max_offset + } else { + 0.0 + }; + // Phase 1: independent lookups in parallel. // When skip_types_join is true, skip the expensive train-type lookups // (used by the lineListStations query). @@ -1528,14 +1566,22 @@ where // Group stations already fetched by expanded primary query let (lines, bus) = tokio::try_join!( self.get_lines_by_station_group_id_vec_no_types(&station_group_ids), - self.get_bus_stops_near_stations(&unique_bus_coords, 50), + self.get_bus_stops_near_stations( + &unique_bus_coords, + 50, + bus_search_radius_meters + ), )?; (prefetched, lines, bus) } else { tokio::try_join!( self.get_stations_by_group_id_vec_no_types(&station_group_ids), self.get_lines_by_station_group_id_vec_no_types(&station_group_ids), - self.get_bus_stops_near_stations(&unique_bus_coords, 50), + self.get_bus_stops_near_stations( + &unique_bus_coords, + 50, + bus_search_radius_meters + ), )? } } else { @@ -1544,7 +1590,7 @@ where self.get_lines_by_station_group_id_vec(&station_group_ids), )?; let bus = self - .get_bus_stops_near_stations(&unique_bus_coords, 50) + .get_bus_stops_near_stations(&unique_bus_coords, 50, bus_search_radius_meters) .await?; (s, l, bus) }; @@ -1559,10 +1605,25 @@ where .push(station); } - // Collect all bus station group IDs for batch bus lines fetch - let mut all_bus_station_group_ids: Vec = bus_candidate_cache - .values() - .flat_map(|stops| stops.iter().map(|s| s.station_g_cd as u32)) + // Collect all bus station group IDs for batch bus lines fetch. + // 候補は駅グループの代表座標で引いた最寄り N 件なので、実際に採用される + // のは各駅の座標から NEARBY_BUS_STOP_RADIUS_METERS 以内のものだけ。 + // ここで先に絞らないと、採用されないバス停の駅グループぶんまで路線を + // 引くことになり、経路が長いほど無駄が (駅数 × N) で効く。 + // 採否の判定は下の駅ごとのループと同じ式を使う。 + let mut all_bus_station_group_ids: Vec = stations + .iter() + .filter(|s| s.transport_type == TransportType::Rail) + .filter_map(|s| bus_candidate_cache.get(&s.station_g_cd).map(|c| (s, c))) + .flat_map(|(station, candidates)| { + candidates + .iter() + .filter(move |bus_stop| { + haversine_distance(station.lat, station.lon, bus_stop.lat, bus_stop.lon) + <= NEARBY_BUS_STOP_RADIUS_METERS + }) + .map(|bus_stop| bus_stop.station_g_cd as u32) + }) .collect::>() .into_iter() .collect(); @@ -2312,6 +2373,7 @@ mod tests { &self, _: &[(u32, f64, f64)], _: u32, + _: f64, ) -> Result, DomainError> { Ok(vec![]) } @@ -3163,6 +3225,7 @@ mod tests { &self, _: &[(u32, f64, f64)], _: u32, + _: f64, ) -> Result, DomainError> { Ok(vec![]) } @@ -3632,6 +3695,7 @@ mod tests { &self, coords: &[(u32, f64, f64)], limit_per_station: u32, + _: f64, ) -> Result, DomainError> { let mut result = Vec::new(); for &(source_g_cd, lat, lon) in coords { @@ -5090,6 +5154,7 @@ mod tests { &self, _: &[(u32, f64, f64)], _: u32, + _: f64, ) -> Result, DomainError> { Ok(vec![]) } @@ -5387,4 +5452,459 @@ mod tests { assert!(routes.is_empty()); } } + + /// `get_train_route` は要求された区間ぶんだけ付帯情報を組み立てる。 + /// 系統全体へ付けてから切り出していた頃は、3 駅を要求しても系統の全駅を + /// 組み立てていた。区間の長さに費用が比例することをここで固定する。 + mod get_train_route_tests { + use super::*; + use crate::domain::{ + entity::company::Company, + error::DomainError, + repository::{ + company_repository::CompanyRepository, line_repository::LineRepository, + station_repository::StationRepository, train_type_repository::TrainTypeRepository, + }, + }; + use std::sync::{Arc, Mutex}; + + /// 呼び出し内容の記録。テスト側と repository で共有する。 + #[derive(Clone, Default)] + struct Calls { + enriched_group_ids: Arc>>>, + bus_coord_counts: Arc>>, + bus_radii: Arc>>, + } + + /// 系統の停車駅を返し、付帯情報の付与で要求された駅グループ ID を記録する + struct RecordingStationRepository { + line_group_stations: Vec, + calls: Calls, + } + + #[async_trait::async_trait] + impl StationRepository for RecordingStationRepository { + async fn get_by_line_group_id(&self, _: u32) -> Result, DomainError> { + Ok(self.line_group_stations.clone()) + } + async fn get_by_station_group_id_vec( + &self, + ids: &[u32], + ) -> Result, DomainError> { + self.calls + .enriched_group_ids + .lock() + .unwrap() + .push(ids.to_vec()); + Ok(self + .line_group_stations + .iter() + .filter(|s| ids.contains(&(s.station_g_cd as u32))) + .cloned() + .collect()) + } + async fn get_bus_stops_near_stations( + &self, + coords: &[(u32, f64, f64)], + _: u32, + radius_meters: f64, + ) -> Result, DomainError> { + self.calls + .bus_coord_counts + .lock() + .unwrap() + .push(coords.len()); + self.calls.bus_radii.lock().unwrap().push(radius_meters); + Ok(vec![]) + } + async fn find_by_id(&self, _: u32) -> Result, DomainError> { + Ok(None) + } + async fn get_by_id_vec(&self, _: &[u32]) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_id( + &self, + _: u32, + _: Option, + _: Option, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_id_vec(&self, _: &[u32]) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_id_vec_with_group_stations( + &self, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_station_group_id(&self, _: u32) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_station_group_id_vec_no_types( + &self, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_coordinates( + &self, + _: f64, + _: f64, + _: Option, + _: Option, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_name( + &self, + _: String, + _: Option, + _: Option, + _: Option, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_group_id_vec( + &self, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_route_stops( + &self, + _: u32, + _: u32, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_route_stops_by_station_cd( + &self, + _: u32, + _: u32, + _: &[u32], + _: Option, + ) -> Result, DomainError> { + Ok(vec![]) + } + } + + struct StubLineRepository; + + #[async_trait::async_trait] + impl LineRepository for StubLineRepository { + async fn find_by_id(&self, _: u32) -> Result, DomainError> { + Ok(None) + } + async fn find_by_station_id(&self, _: u32) -> Result, DomainError> { + Ok(None) + } + async fn get_by_ids(&self, _: &[u32]) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_station_group_id(&self, _: u32) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_station_group_id_vec( + &self, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_station_group_id_vec_no_types( + &self, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_group_id(&self, _: u32) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_group_id_vec(&self, _: &[u32]) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_group_id_vec_for_routes( + &self, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_name( + &self, + _: String, + _: Option, + ) -> Result, DomainError> { + Ok(vec![]) + } + } + + /// 区間内の駅に種別を付ける。速度プロファイルを各停から引き上げるのは + /// LimitedExpress (4) と HighSpeedRapid (5) だけなので、特急を返す。 + struct StubTrainTypeRepository; + + #[async_trait::async_trait] + impl TrainTypeRepository for StubTrainTypeRepository { + async fn get_types_by_station_id_vec( + &self, + station_id_vec: &[u32], + _: Option, + ) -> Result, DomainError> { + Ok(station_id_vec + .iter() + .map(|&cd| TrainType { + id: Some(cd as i32), + station_cd: Some(cd as i32), + type_cd: Some(1), + line_group_cd: Some(1000), + pass: None, + type_name: "特急".to_string(), + type_name_k: "トッキュウ".to_string(), + type_name_r: None, + type_name_zh: None, + type_name_ko: None, + color: "#FF0000".to_string(), + direction: None, + kind: Some(4), + line: None, + lines: vec![], + }) + .collect()) + } + async fn get_by_line_group_id(&self, _: u32) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_station_id(&self, _: u32) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_station_id_vec( + &self, + _: &[u32], + _: Option, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_by_line_group_id_vec( + &self, + _: &[u32], + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn get_line_group_ids_by_station_group_ids( + &self, + _: &[u32], + ) -> Result>, DomainError> { + Ok(std::collections::HashMap::new()) + } + async fn find_by_line_group_id_and_line_id( + &self, + _: u32, + _: u32, + ) -> Result, DomainError> { + Ok(None) + } + async fn find_by_line_group_id_and_line_id_vec( + &self, + _: &[(u32, u32)], + ) -> Result, DomainError> { + Ok(std::collections::HashMap::new()) + } + } + + struct StubCompanyRepository; + + #[async_trait::async_trait] + impl CompanyRepository for StubCompanyRepository { + async fn find_by_id_vec(&self, _: &[u32]) -> Result, DomainError> { + Ok(vec![]) + } + } + + type TestInteractor = QueryInteractor< + RecordingStationRepository, + StubLineRepository, + StubTrainTypeRepository, + StubCompanyRepository, + >; + + /// 20 駅の系統を作る。うち 1 駅おきに通過駅を混ぜる。 + fn build_line_group(len: i32) -> Vec { + (0..len) + .map(|i| { + let cd = 1000 + i; + let mut station = create_test_station(cd, 2000 + i, 10, Some(1000)); + // 在来線 (LineType::Normal)。新幹線だと路線側の上限が種別の + // 下限を上回るため、種別の有無で速度が変わらない + station.line_type = Some(2); + // 東京駅から北へ 1km 刻みに並べる + station.lat = 35.6812 + f64::from(i) * 0.009; + station.lon = 139.7671; + if i % 2 == 1 { + station.stop_condition = StopCondition::Not; + station.pass = Some(1); + } + station + }) + .collect() + } + + fn build_interactor(stations: Vec) -> (TestInteractor, Calls) { + let calls = Calls::default(); + let interactor = QueryInteractor { + station_repository: RecordingStationRepository { + line_group_stations: stations, + calls: calls.clone(), + }, + line_repository: StubLineRepository, + train_type_repository: StubTrainTypeRepository, + company_repository: StubCompanyRepository, + }; + (interactor, calls) + } + + #[tokio::test] + async fn enriches_only_the_requested_range() { + let (interactor, calls) = build_interactor(build_line_group(20)); + + let segments = interactor + .get_train_route(1002, 1004, Some(1000)) + .await + .unwrap(); + + assert_eq!(segments.len(), 3); + let enriched = calls.enriched_group_ids.lock().unwrap(); + assert_eq!(enriched.len(), 1); + // 系統は 20 駅だが、付帯情報を求めたのは要求された 3 駅ぶんだけ + assert_eq!(enriched[0], vec![2002, 2003, 2004]); + // 近傍バス停の検索も同じ 3 駅ぶん + assert_eq!(*calls.bus_coord_counts.lock().unwrap(), vec![3]); + } + + #[tokio::test] + async fn returns_the_range_reversed_when_going_backwards() { + let (interactor, calls) = build_interactor(build_line_group(20)); + + let segments = interactor + .get_train_route(1004, 1002, Some(1000)) + .await + .unwrap(); + + let ids: Vec = segments + .iter() + .filter_map(|s| s.station.as_ref().map(|st| st.id)) + .collect(); + assert_eq!(ids, vec![1004, 1003, 1002]); + assert_eq!( + calls.enriched_group_ids.lock().unwrap()[0], + vec![2002, 2003, 2004] + ); + } + + /// 付帯情報 (列車種別) が区間の駅に載っていること。載っていないと + /// 速度プロファイルが各停へ落ちる。 + #[tokio::test] + async fn keeps_train_type_driven_speed_profile() { + let (interactor, _) = build_interactor(build_line_group(20)); + + let segments = interactor + .get_train_route(1002, 1006, Some(1000)) + .await + .unwrap(); + + assert_eq!(segments.len(), 5); + // 端点は必ず停車、内側の奇数番は通過 + assert!(segments[0].stops); + assert!(!segments[1].stops); + assert!(segments[4].stops); + // 先頭は起点なので前駅からの距離は 0 + assert_eq!(segments[0].distance_from_previous, 0.0); + assert!(segments[1].distance_from_previous > 0.0); + + // 通過駅の無い同じ区間と比べる。通過駅が無ければ優等種別でも各停 + // 扱いになるので、速度に差が出るはず。単に max_speed が正である + // ことだけを見ると、種別が付与されなくてもテストが通ってしまう。 + let mut all_stops = build_line_group(20); + for station in all_stops.iter_mut() { + station.stop_condition = StopCondition::All; + station.pass = None; + } + let (local_interactor, _) = build_interactor(all_stops); + let local_segments = local_interactor + .get_train_route(1002, 1006, Some(1000)) + .await + .unwrap(); + + let top = |segments: &[model::TrainRouteSegment]| { + segments + .iter() + .map(|s| s.max_speed) + .fold(f64::MIN, f64::max) + }; + assert!( + top(&segments) > top(&local_segments), + "優等種別の速度が使われていない (優等 {} / 各停 {})", + top(&segments), + top(&local_segments) + ); + } + + /// 近傍バス停の探索半径には、駅グループの代表座標と各駅の座標の隔たりを + /// 足す。足さないと、代表からは 300m を超えるが同じグループの別の駅からは + /// 300m 以内、というバス停を取りこぼす。 + #[tokio::test] + async fn widens_the_bus_search_radius_by_the_station_group_offset() { + let mut stations = build_line_group(4); + // 3 駅目を 1 駅目と同じ駅グループにし、150m ほど離して置く + stations[2].station_g_cd = stations[0].station_g_cd; + stations[2].lat = stations[0].lat + 0.00135; + stations[2].lon = stations[0].lon; + let offset = haversine_distance( + stations[0].lat, + stations[0].lon, + stations[2].lat, + stations[2].lon, + ); + assert!(offset > 100.0, "前提: 2 駅は 100m 以上離れている"); + let (interactor, calls) = build_interactor(stations); + + interactor + .get_train_route(1000, 1003, Some(1000)) + .await + .unwrap(); + + let radii = calls.bus_radii.lock().unwrap(); + assert_eq!(radii.len(), 1); + assert!( + (radii[0] - (NEARBY_BUS_STOP_RADIUS_METERS + offset)).abs() < 1e-6, + "探索半径 {} が 300m + 代表座標からの隔たり {offset} になっていない", + radii[0] + ); + } + + #[tokio::test] + async fn errors_when_the_station_is_not_on_the_route() { + let (interactor, _) = build_interactor(build_line_group(20)); + + let err = interactor + .get_train_route(1002, 9999, Some(1000)) + .await + .unwrap_err(); + + assert!(matches!(err, UseCaseError::NotFound { .. })); + } + + #[tokio::test] + async fn errors_when_the_line_group_is_unspecified() { + let (interactor, _) = build_interactor(build_line_group(20)); + + let err = interactor + .get_train_route(1002, 1004, None) + .await + .unwrap_err(); + + assert!(matches!(err, UseCaseError::NotFound { .. })); + } + } }