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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ A GraphQL API that provides nearby Japanese train stations and bus stops, runnin
- For contribution guidelines, see [CONTRIBUTING.md](CONTRIBUTING.md).
- For system architecture and design decisions, see [docs/architecture.md](docs/architecture.md).
- For the design of the transfer route search (`connectedRoutes`, RAPTOR), see [docs/route-search.md](docs/route-search.md).
- For the source-backed operating constraints used by timetable-free search, see [docs/route-service-audit.md](docs/route-service-audit.md).
- For technical debt analysis and architectural concerns, see [docs/technical_debt.md](docs/technical_debt.md).
- For the record of the Cloudflare Workers migration, see [docs/cloudflare-workers-migration.md](docs/cloudflare-workers-migration.md).
- For the published GraphQL schema, see [schema/public.graphql](schema/public.graphql).
Expand Down
12 changes: 12 additions & 0 deletions data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,15 @@ preprocessor が国土数値情報の鉄道データ(N02)から自動で求
---

**注意**: データの品質は最終的な API の品質に直結します。正確で一貫性のあるデータ入力にご協力ください。なお、データの変更は Pull Request のマージ後に開発環境での動作確認を経て本番環境に反映されます。

## 経路探索の運行制約

`route_service_rules.csv` には `type_cd`、任意の `line_group_id`、
`requires_timetable`、任意の `boarding_wait_seconds`、根拠の `source` を記載します。
時刻表を入力しない経路探索では `requires_timetable` の系統を除きます。
系統を指定した規則を種別全体の規則より優先します。

`route_stop_rules.csv` には種別と駅グループ、格納順・逆順の乗車・降車許可を
記載します。通過駅を停車駅へ変更する規則ではありません。
両ファイルを変更した場合は `cargo run -p data_validator` で参照 ID と重複を検査し、
Worker を再ビルドしてください。根拠は各行の `source` に記載します。
26 changes: 26 additions & 0 deletions data/route_service_rules.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
type_cd,line_group_id,requires_timetable,boarding_wait_seconds,source,type_name,reason,checked_on
316,,true,,https://www.seiburailway.jp/railway/reservedtrain/s-train/,S-TRAIN(平日運行),曜日・時間帯限定,2026-10-13
339,,true,,https://www.seiburailway.jp/railway/reservedtrain/s-train/,S-TRAIN(土休日運行),曜日・時間帯限定,2026-10-13
379,188,true,,https://seibu.ekitan.com/norikae/timetable/station/235-0/d1,快速,池袋発着の限定系統,2026-10-13
379,620,true,,https://seibu.ekitan.com/norikae/timetable/station/235-0/d1,快速,池袋発着の限定系統,2026-10-13
211,,true,,https://timetable.jr-odekake.net/train-timetable/38491?date=20261003,サンライズ瀬戸,夜行・少数便,2026-10-13
212,,true,,https://timetable.jr-odekake.net/train-timetable/38491?date=20261003,サンライズ出雲,夜行・少数便,2026-10-13
236,,true,,https://www.jreast.co.jp/railway/joyful/slgunma.html,"SLぐんま
みなかみ",運転日指定の臨時列車,2026-10-13
237,,true,,https://www.jreast.co.jp/railway/joyful/slgunma.html,"SLぐんま
よこかわ",運転日指定の臨時列車,2026-10-13
292,,true,,https://www.jr-odekake.net/railroad/mahorobalimitedexpress/,まほろば,土休日中心・平日臨時運転あり,2026-10-13
294,,true,,https://www.jrhokkaido.co.jp/CM/Info/press/pdf/20260515_KO_2026summer.pdf,ニセコ,運転日指定の季節列車,2026-10-13
295,,true,,https://www.jrhokkaido.co.jp/CM/Info/press/pdf/20260515_KO_2026summer.pdf,フラノラベンダーエクスプレス,運転日指定の季節列車,2026-10-13
400,,true,,https://www.keio.co.jp/zasekishitei/,京王ライナー(京王八王子方面),時間帯限定・方向別乗降制限,2026-10-13
401,,true,,https://www.keio.co.jp/zasekishitei/,京王ライナー(橋本方面),時間帯限定・方向別乗降制限,2026-10-13
402,,true,,https://www.keikyu.co.jp/ride/train/wing_detail.html,モーニングウィング号,平日朝・片方向,2026-10-13
403,,true,,https://www.keikyu.co.jp/ride/train/wing_detail.html,イブニングウィング号,平日夕夜・片方向・乗車専用駅あり,2026-10-13
405,,true,,https://www.seiburailway.jp/railway/reservedtrain/haijimaliner/,拝島ライナー,朝夕限定・方向別乗降条件,2026-10-13
406,,true,,https://www.tobu.co.jp/railway/tj/timetable/,TJライナー(下り),下り夕夜・上りとは別系統,2026-10-13
407,,true,,https://www.tobu.co.jp/railway/tj/timetable/,TJライナー(上り),上り朝・下りとは別系統,2026-10-13
412,965,true,,https://timetables.jreast.co.jp/2610/timetable-v/127d1p.html,ライナー,あいの風ライナー・平日限定便,2026-10-13
412,1018,true,,https://www.keihan.co.jp/traffic/liner/,ライナー,京阪ライナー・平日朝夕夜限定,2026-10-13
412,1019,true,,https://www.keihan.co.jp/traffic/liner/,ライナー,京阪ライナー・平日朝夕夜限定,2026-10-13
510,,true,,https://www.keio.co.jp/zasekishitei/index_mt.html,Mt.TAKAO,土休日・少数便・方向別乗降制限,2026-10-13
519,,true,,https://www.tobu.co.jp/railway/th/timetable/,THライナー,朝夕夜限定・方向別乗降制限,2026-10-13
2 changes: 2 additions & 0 deletions data/route_stop_rules.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
type_cd,station_group_id,forward_board,forward_alight,reverse_board,reverse_alight,source
316,2200106,false,true,false,false,https://www.seiburailway.jp/railway/station/nerima/
1 change: 1 addition & 0 deletions data_validator/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ edition = "2021"

[dependencies]
csv = "1.3.0"
stationapi = { path = "../stationapi" }
86 changes: 86 additions & 0 deletions data_validator/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,95 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.map(|row| row.get(1).unwrap().parse::<u32>().unwrap())
.collect();

// Keep the evidence attached to its actual canonical type, rather than a
// silently mistyped numeric ID (e.g. commuter express vs holiday S-TRAIN).
let type_names: std::collections::HashMap<i32, &str> = records
.iter()
.map(|row| (row[1].parse().unwrap(), &row[2]))
.collect();
let mut rules_reader =
ReaderBuilder::new().from_path(data_path.join("route_service_rules.csv"))?;
let headers = rules_reader.headers()?.clone();
let column = |name| headers.iter().position(|h| h == name).expect(name);
for row in rules_reader.records() {
let row = row?;
let type_cd = row[column("type_cd")].parse()?;
assert_eq!(
type_names.get(&type_cd).copied(),
Some(&row[column("type_name")]),
"service rule name does not match canonical type {type_cd}"
);
assert!(
row[column("source")].starts_with("https://"),
"missing rule source"
);
assert!(
!row[column("reason")].trim().is_empty(),
"missing rule reason"
);
assert!(
!row[column("checked_on")].trim().is_empty(),
"missing rule verification date"
);
}

let station_groups: HashSet<i32> = station_records
.iter()
.map(|r| r[1].parse().unwrap())
.collect();
let mut seen = HashSet::new();
for rule in stationapi::domain::service_rules::parse_service_rules(&std::fs::read_to_string(
data_path.join("route_service_rules.csv"),
)?)? {
assert!(
type_ids.contains(&(rule.type_cd as u32)),
"unknown rule type"
);
assert!(
seen.insert((rule.type_cd, rule.line_group_id)),
"duplicate service rule"
);
assert!(
rule.boarding_wait_seconds.is_none_or(|s| s >= 0),
"negative wait"
);
}
let mut seen = HashSet::new();
for rule in stationapi::domain::service_rules::parse_stop_rules(&std::fs::read_to_string(
data_path.join("route_stop_rules.csv"),
)?)? {
assert!(
type_ids.contains(&(rule.type_cd as u32)),
"unknown stop rule type"
);
assert!(
station_groups.contains(&rule.station_group_id),
"unknown stop rule group"
);
assert!(
seen.insert((rule.type_cd, rule.station_group_id)),
"duplicate stop rule"
);
}

let mut rdr = ReaderBuilder::new().from_path(data_path.join("5!station_station_types.csv"))?;
let records: Vec<StringRecord> = rdr.records().collect::<Result<Vec<_>, _>>()?;

for rule in stationapi::domain::service_rules::parse_service_rules(&std::fs::read_to_string(
data_path.join("route_service_rules.csv"),
)?)? {
if let Some(group) = rule.line_group_id {
assert!(
records
.iter()
.any(
|r| r.get(2).and_then(|v| v.parse::<i32>().ok()) == Some(rule.type_cd)
&& r.get(3).and_then(|v| v.parse::<i32>().ok()) == Some(group)
),
"unknown service rule group"
);
}
}
for record in &records {
let line = || record.iter().collect::<Vec<&str>>().join(",");

Expand Down
45 changes: 19 additions & 26 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,36 +342,32 @@ connectedRoutes(fromStationGroupId: Int!, toStationGroupId: Int!, viaLineId: Int

enum ConnectedRouteSort { Recommended ArrivalTime TransferCount }

type ConnectedRoute { legs: [RouteLeg!] }
type RouteLeg { trainTypes: [TrainType!] fromStation: Station toStation: Station stationGroupIds: [Int!] }
type ConnectedRoute { totalSeconds: Int! legs: [RouteLeg!] }
type RouteLeg { recommendedGroupId: Int! trainTypes: [TrainType!] fromStation: Station toStation: Station stationGroupIds: [Int!] }
```

`stationGroupIds` は、乗車駅から降車駅までの駅グループ ID を進行順に並べた
もの (通過駅を含む) で、探索で使った系統の駅の並びをそのまま返します。
駅 ID ではなく駅グループ ID にしているのは、アプリが区間ごとに選ぶ種別
(既定は各駅停車) が、探索で使った系統とは別の路線を走ることがあるためです。
(手動で変更した場合) が、探索で使った系統とは別の路線を走ることがあるためです。
駅 ID は路線ごとに異なりますが、駅グループ ID ならどの種別の駅リストとも
照合できます。アプリはこの並びに沿って、選んだ種別の駅リストから駅を
取り出すので、環状線でどちら回りにするかをアプリ側で判断する必要は
ありません。同じ駅グループが 2 回現れる系統 (大江戸線の都庁前) では、
直前に取り出した駅に隣接するほうを選びます。

探索では、停車駅が同じ並行種別 (中央線の快速と通勤快速など) を 1 つの経路に
まとめ、代替経路を再探索するときもそれらをまとめて除外します。そのままでは
並行種別がレスポンスに一度も現れず、種別一覧 (TrainTypeListModal) を表示して
各駅停車を既定で選ぶという、現在のアプリの挙動を維持できません。そこで
`trainTypes` には、その区間で乗車できる種別をすべて返します。中身は
`routeTypes(乗車駅グループ, 降車駅グループ, 降車駅の路線)` の結果と同一で、
停車駅が同じ種別の集約、路線の付与、並び順も `routeTypes` と同じです (同じ
関数を呼び出しています)。探索が選んだ代表の種別、推定所要時間、乗換回数は
並べ替えにだけ使います。アプリでは使わないため、API では返しません。
探索では、停車駅が同じ並行種別を一つの経路にまとめます。
`trainTypes` は手動で変更できる種別一覧です。`routeTypes` と同じ取得処理を使い、
探索した系統が集約で省かれた場合はその系統も加えます。
`recommendedGroupId` は探索で評価した系統です。アプリは既定選択にこの ID を
使います。`totalSeconds` は最初の待ち時間を除いた、乗換の徒歩と待ち時間を
含む秒数です。既定の各停に置き換えて順位を付け直す処理はありません。

`viaLineId` は `routeTypes` と同様に、検索結果でタップした駅の路線を指します。
指定すると、目的地にその路線の駅で到着する経路 (最後の区間がその路線を走る
経路) だけに絞り込みます。

`sortBy` は経路の並び順です。推定所要時間と乗換回数は API で返さないので、
並べ替えはサーバー側で行います。どれを選んでも返す経路の集合は変わらず、
`sortBy` は経路の並び順です。並べ替えはサーバー側で行います。どれを選んでも返す経路の集合は変わらず、
並びだけが変わります。

| `sortBy` | 並び |
Expand All @@ -382,10 +378,9 @@ type RouteLeg { trainTypes: [TrainType!] fromStation: Station toStation: Stati

到着の早い順は、`estimateArrivalTimes` と同じ定義の所要時間 (最初の列車の
待ち時間は含めず、乗換ごとに徒歩時間と乗換先の待ち時間を加える) で並べます。
ただし、並べ替えに使うのは探索が選んだ代表の種別での見込みです。アプリが
区間ごとに別の種別 (既定の各駅停車など) を選んで `estimateArrivalTimes` で
求めた到着見込みとは、並びが一致しないことがあります。キーが同じ経路同士は、
おすすめ順のまま並びます。
探索で選んだ `recommendedGroupId` の系統で見積もります。
ユーザーが別の種別へ変更した場合は、その種別の ETA を求め直します。
キーが同じ経路同士は、おすすめ順のまま並びます。

### 系統網

Expand All @@ -395,9 +390,7 @@ type RouteLeg { trainTypes: [TrainType!] fromStation: Station toStation: Stati
またぐ乗車も同じ配列で計算できるようにしています。対象は鉄道だけで、バスは
含みません。

網の構築には全系統の駅と所要時間の推定が必要で、ネイティブ環境で約 190ms
かかります (`data/*.csv` の 1,185 系統・41,706 行。時間の半分が `Station` の
組み立て、残り半分が推定)。そのため起動時には構築せず、最初に
網は全系統の駅と区間時分表から構築します。最初に
`connectedRoutes` が呼ばれたときに `OnceLock` に構築し、isolate が生きている
間は使い回します。

Expand Down Expand Up @@ -495,9 +488,8 @@ input RouteLegInput { lineGroupId: Int! fromStationId: Int! toStationId: Int!
ます。`Estimated` は、線路の長さ (`connections`) がある駅間ではそれを走行距離に
使い、無い駅間だけ直線距離 × 迂回係数で見積もり、その距離で求め直した較正
(`speed_table` / `segment_speed_table`) を使います。`estimateArrivalTimes` も同じ
計算です。乗換経路探索 (`connectedRoutes`) の所要時間だけは、元の計算 (直線距離 ×
迂回係数と、`domain/legacy_speed_table.rs` の元の較正) のままです。そのため、
経路検索の所要時間と ETA は一致しません。`Legacy` の値で台形の速度プロファイルを作って走らせると、
計算です。乗換経路探索 (`connectedRoutes`) も同じ距離と較正で評価します。
`Legacy` の値で台形の速度プロファイルを作って走らせると、
`estimateArrivalTimes` より短い時間で走り切ります (#1709)。所要時間を推定に
合わせたいクライアントは、`Estimated` の見込みを使います。

Expand Down Expand Up @@ -567,7 +559,8 @@ input RouteLegInput { lineGroupId: Int! fromStationId: Int! toStationId: Int!

### 計算量

1 回の探索の計算量は O(乗車回数 × 走査したパターンの駅数) です。旧実装
通常系統と通過路線が一つの系統の走査は O(n)、通過路線が複数の系統は O(n²) です。
各走査を乗車回数ぶん行います。旧実装
(列車種別と降車駅のすべての組み合わせを、乗換回数の分だけ列挙する深さ制限
つきの BFS) との比較は次のとおりです (ネイティブの release ビルド、
`data/*.csv` を使用)。
Expand Down Expand Up @@ -767,7 +760,7 @@ repository の実装がないメソッドは、空の結果ではなく `DomainE
│ │ ├── entity/ # Station / Line / TrainType / Company ...
│ │ ├── repository/ # 抽象インターフェース
│ │ ├── arrival_estimation.rs
│ │ ├── legacy_speed_table.rs # 元の較正 (connectedRoutes と trainRoute の Legacy が使う)
│ │ ├── legacy_speed_table.rs # 元の較正 (trainRoute の Legacy が使う)
│ │ ├── route_search.rs # 乗換経路探索 (RAPTOR)
│ │ ├── route_topology.rs # 所要時間を持たない系統網 (stationsByName の到達判定)
│ │ ├── segment_speed_table.rs
Expand Down
Loading
Loading