diff --git a/AGENTS.md b/AGENTS.md index c309f7ea..eefc387c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,14 +52,14 @@ This guide explains how automation agents and human contributors should work wit ## gRPC Endpoint Overview - **Stations** – `GetStationById`, `GetStationByIdList`, `GetStationsByGroupId`, `GetStationsByCoordinates`, `GetStationsByLineId`, `GetStationsByName`, `GetStationsByLineGroupId`. `QueryInteractor` enriches stations with lines, companies, station numbers, and train types. - **Lines** – `GetLineById`, `GetLinesByIdList`, `GetLinesByName`. Results include company data and computed line symbols based on repository helpers. -- **Routes** – `GetRoutes`, `GetRoutesMinimal`. The minimal variant returns `RouteMinimalResponse` with deduplicated `LineMinimal` data; paging tokens are currently empty (pagination not implemented). +- **Routes** – `GetRoutes`, `GetRoutesMinimal`, `GetConnectedRoutes`. The minimal variant returns `RouteMinimalResponse` with deduplicated `LineMinimal` data; paging tokens are currently empty (pagination not implemented). - **Train types** – `GetTrainTypesByStationId`, `GetRouteTypes`. 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** – After the canonical CSV import, startup 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 PostgreSQL; 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 startup selects the label per line through `LOCAL_SERVICE_RAIL_LINE_IDS` in `src/import.rs`. Generated `line_group_cd` values use `1,000,000,000 + line_cd`; startup fails on a collision. Bus lines are excluded and continue to use their GTFS-derived `BusRoute` groups. - **GTFS bus integration** – At startup, `src/import.rs::integrate_gtfs_to_stations()` ingests GTFS feeds into `gtfs_*` tables and then projects them onto the shared `stations` / `lines` / `types` / `station_station_types` tables. 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 `gtfs_*` 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. 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`. - **Bus stop translations (readings & English)** – GTFS-JP `translations.txt` layouts differ per feed, so `load_gtfs_translations` resolves columns by header name (Seibu ships 6 columns without `record_sub_id`; Keio and the Tokyu community feeds ship 7) and indexes each `stop_name` translation under both keys it may use: `record_id` (== the stop_id, Seibu — with the "-NN" pole suffix also mapped to the parent stop_id) and `field_value` (== the Japanese stop_name, Keio / Tokyu community, where `record_id` is left empty). `import_gtfs_stops` then looks a stop's translation up by stop_id first, then by name. Keying only by `record_id` (the previous behavior) silently dropped every field_value-keyed feed, leaving `station_name_k` filled with the kanji stop_name and `station_name_r` empty. Readings arriving as half-width katakana (`ニシハチオウジ`, Keio / Tokyu community) are folded to full-width via `romaji::to_fullwidth_katakana()` before storage. - **Bus English-name fallback** – When a feed provides no English (`en`) translation for a stop — e.g. Tokyu Bus ordinary-route JSON, which carries only `dc:title` and `odpt:kana` — `src/domain/romaji.rs::romaji_display_name()` derives a modified-Hepburn romanization (with macrons for long vowels, matching the curated rail style: Tōkyō / Kyōto / Shin-Ōsaka) from the kana reading, and `import.rs` fills `stop_name_r` with it. The fallback never overwrites a real `en` value, and a reading with no convertible kana stays `NULL` rather than emitting a partial transcription. Because `stop_name_r` is the single upstream source that fans out into the `stations` projection, `search_by_name`, and the romanized bus route/headsign names, this supplements every English-facing surface at once. When projecting into `stations`, `station_name_rn` is filled with the plain-ASCII spelling via `romaji::strip_macrons()` (Tōkyō → Tokyo), mirroring the rail dataset's `_r` (macron) / `_rn` (macron-free) column pair. - **TTS metadata** – `Station`, `StationMinimal`, `Line`, and `TrainType` expose `name_ipa` / `name_roman_ipa` plus `name_tts_segments` for multi-segment pronunciation output. Use `name_tts_segments` when clients need per-token SSML construction for mixed-language names such as `Kasai-Rinkai Park`. -- **Connected routes** – `GetConnectedRoutes`. `QueryInteractor::get_connected_stations` is not implemented yet and returns an empty vector; update the use-case and infrastructure layers together when adding real logic. +- **Connected routes** – `GetConnectedRoutes` performs a bounded breadth-first search across train-type line groups. Transfers join at a shared station group, route order and per-stop pass metadata are preserved, and each returned candidate receives a deterministic virtual line-group ID in the upper half of the `uint32` range. Revisiting station groups and already-used train types is rejected to prevent cycles. The search is additionally capped at eight train types, 4,096 expanded states, 65,536 evaluated candidates, and 32 results to bound computation and result size. - Changes to the service contract require coordinated updates to `proto/stationapi.proto`, regenerated code via `tonic-build`, and corresponding adjustments in both presentation and use-case layers. ## Contribution Guidelines diff --git a/docs/architecture.md b/docs/architecture.md index 114d8148..0c36a180 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -193,6 +193,33 @@ CREATE INDEX idx_performance_station_name_trgm ON stations | 経路検索 | `GetRoutes`, `GetRoutesMinimal`, `GetConnectedRoutes` | | 列車種別 | `GetTrainTypesByStationId`, `GetRouteTypes` | +### 接続経路探索 + +`GetConnectedRoutes` は、始点の駅グループに停車する列車種別から幅優先で探索し、 +同じ駅グループに停車する別の列車種別へ接続します。Repository は探索階層ごとの +駅グループをまとめて問い合わせ、該当する `line_group_cd` の駅列も一括取得するため、 +候補ごとの N+1 クエリを発生させません。 + +探索中は通過駅を乗換地点にせず、利用済みの列車種別および訪問済みの駅グループを +再訪しません。探索は最大 8 列車種別、4,096 展開状態、65,536 評価候補、 +32 返却候補に制限します。 +完成した経路は接続駅を一度だけ含む駅列へ連結し、各区間の `stop_condition` を保持します。 +返却時には経路の列車種別列と駅グループ列から決定的な仮想 `lineGroupId` を生成し、 +経路内の全 `station.train_type.group_id` に同じ値を設定します。仮想 ID は既存の +PostgreSQL `INTEGER` ID と衝突しない `uint32` 上位半分を使用します。 + +各探索階層では未取得の駅グループを 1 回のクエリへまとめ、そこで判明した未取得の +`line_group_cd` の駅列も 1 回で取得します。このため Repository 呼び出しは最大でも +階層あたり 2 回で、候補経路ごとの N+1 クエリや同じ列車種別の再取得はありません。 +取得後は駅グループと `line_group_cd` を `HashMap` に一度だけ分類し、状態と駅列を +毎回総当たりする O(n×m) の処理を、入力件数に比例する O(n+m) の参照へ置き換えます。 +探索そのものの最悪計算量は各状態の始点候補数と駅列長の積にも依存するため、状態数とは +独立した評価候補数の上限で `start_indices × pattern.len()` の走査も制御します。 + +SQL は `stations.station_g_cd`、`station_station_types.station_cd`、 +`station_station_types.line_group_cd` の既存 btree index を利用できます。列車種別の存在確認に +必要な `types` との JOIN のみを行い、路線・会社など探索に不要なテーブルは JOIN しません。 + ### Proto 更新時の注意点 1. **後方互換性**: 新フィールドには `optional` キーワードを使用 diff --git a/docs/technical_debt.md b/docs/technical_debt.md index ca1d277c..20528af9 100644 --- a/docs/technical_debt.md +++ b/docs/technical_debt.md @@ -93,7 +93,6 @@ pub struct Station { |----------|--------|------| | `stationapi/src/use_case/interactor/query.rs` | 604 | `// TODO: SQLで同等の処理を行う` - 経路検証がアプリケーション側で実行 | | `stationapi/src/use_case/interactor/query.rs` | 702 | `// TODO: SQLで同等の処理を行う` - 経路フィルタリングがアプリケーション層で処理 | -| `stationapi/src/use_case/interactor/query.rs` | 843 | `// TODO: 未実装` - `get_connected_stations()` が空配列を返却 | ```rust // query.rs:604-610 @@ -319,5 +318,4 @@ let station_numbers_raw = [ | **中** | Row 構造体のコード生成検討 | `src/infrastructure/*.rs` | メンテナンス性 | | **中** | メソッド命名の改善 | `src/domain/repository/line_repository.rs:23` | 可読性 | | **中** | ハードコード値の定数化 | 複数ファイル | 保守性 | -| **低** | get_connected_stations の実装 | `src/use_case/interactor/query.rs:843` | 機能完成度 | | **低** | UI レイヤーのテスト追加 | `src/presentation/` | テストカバレッジ | diff --git a/stationapi/src/domain/repository/train_type_repository.rs b/stationapi/src/domain/repository/train_type_repository.rs index 3f418fad..b89ee8fb 100644 --- a/stationapi/src/domain/repository/train_type_repository.rs +++ b/stationapi/src/domain/repository/train_type_repository.rs @@ -1,9 +1,20 @@ use async_trait::async_trait; +use std::collections::HashMap; use crate::domain::{entity::train_type::TrainType, error::DomainError}; #[async_trait] pub trait TrainTypeRepository: Send + Sync + 'static { + /// Return train-type line groups that stop at each requested station group. + /// + /// The default keeps existing lightweight test repositories source-compatible; + /// repositories used by connected-route search must override it. + async fn get_line_group_ids_by_station_group_ids( + &self, + _station_group_ids: &[u32], + ) -> Result>, DomainError> { + Ok(HashMap::new()) + } async fn get_by_line_group_id(&self, line_group_id: u32) -> Result, DomainError>; async fn get_by_station_id(&self, station_id: u32) -> Result, DomainError>; diff --git a/stationapi/src/infrastructure/train_type_repository.rs b/stationapi/src/infrastructure/train_type_repository.rs index 3890e78a..03c12f11 100644 --- a/stationapi/src/infrastructure/train_type_repository.rs +++ b/stationapi/src/infrastructure/train_type_repository.rs @@ -4,7 +4,7 @@ use crate::domain::{ }; use async_trait::async_trait; use sqlx::{PgConnection, Pool, Postgres}; -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; #[derive(sqlx::FromRow, Clone)] pub struct TrainTypeRow { @@ -23,6 +23,12 @@ pub struct TrainTypeRow { kind: Option, } +#[derive(sqlx::FromRow)] +struct ConnectionLineGroupRow { + station_g_cd: i32, + line_group_cd: i32, +} + impl From for TrainType { fn from(row: TrainTypeRow) -> Self { let TrainTypeRow { @@ -72,6 +78,21 @@ impl MyTrainTypeRepository { #[async_trait] impl TrainTypeRepository for MyTrainTypeRepository { + async fn get_line_group_ids_by_station_group_ids( + &self, + station_group_ids: &[u32], + ) -> Result>, DomainError> { + if station_group_ids.is_empty() { + return Ok(HashMap::new()); + } + let mut conn = self.pool.acquire().await?; + InternalTrainTypeRepository::get_line_group_ids_by_station_group_ids( + station_group_ids, + &mut conn, + ) + .await + } + async fn get_by_line_group_id( &self, line_group_id: u32, @@ -146,6 +167,43 @@ impl TrainTypeRepository for MyTrainTypeRepository { pub struct InternalTrainTypeRepository {} impl InternalTrainTypeRepository { + async fn get_line_group_ids_by_station_group_ids( + station_group_ids: &[u32], + conn: &mut PgConnection, + ) -> Result>, DomainError> { + if station_group_ids.is_empty() { + return Ok(HashMap::new()); + } + + let station_group_ids: Vec = station_group_ids + .iter() + .filter_map(|id| i32::try_from(*id).ok()) + .collect(); + let rows = sqlx::query_as::<_, ConnectionLineGroupRow>( + r#"SELECT DISTINCT s.station_g_cd, sst.line_group_cd + FROM stations AS s + JOIN station_station_types AS sst ON sst.station_cd = s.station_cd + JOIN types AS t ON t.type_cd = sst.type_cd + WHERE s.station_g_cd = ANY($1) + AND s.e_status = 0 + AND sst.pass <> 1 + AND sst.line_group_cd IS NOT NULL + ORDER BY s.station_g_cd, sst.line_group_cd"#, + ) + .bind(&station_group_ids) + .fetch_all(conn) + .await?; + + let mut result = HashMap::new(); + for row in rows { + result + .entry(row.station_g_cd as u32) + .or_insert_with(Vec::new) + .push(row.line_group_cd as u32); + } + Ok(result) + } + async fn get_by_line_group_id( line_group_id: u32, conn: &mut PgConnection, @@ -531,6 +589,10 @@ mod tests { .execute(pool) .await .unwrap(); + sqlx::query("DROP TABLE IF EXISTS stations CASCADE") + .execute(pool) + .await + .unwrap(); // テーブル作成 sqlx::query( @@ -550,6 +612,18 @@ mod tests { .await .unwrap(); + sqlx::query( + "CREATE TABLE stations ( + station_cd INTEGER PRIMARY KEY, + station_g_cd INTEGER NOT NULL, + line_cd INTEGER, + e_status INTEGER NOT NULL DEFAULT 0 + )", + ) + .execute(pool) + .await + .unwrap(); + sqlx::query( "CREATE TABLE station_station_types ( id SERIAL PRIMARY KEY, @@ -563,6 +637,18 @@ mod tests { .await .unwrap(); + sqlx::query( + "INSERT INTO stations (station_cd, station_g_cd, e_status) VALUES + (101, 1001, 0), + (102, 1001, 0), + (103, 1002, 0), + (104, 1002, 1), + (105, 1003, 0)", + ) + .execute(pool) + .await + .unwrap(); + // テストデータの挿入 sqlx::query( "INSERT INTO types (type_cd, type_name, type_name_k, type_name_r, type_name_zh, type_name_ko, color, direction, kind) VALUES @@ -596,6 +682,10 @@ mod tests { .execute(pool) .await .unwrap(); + sqlx::query("DROP TABLE IF EXISTS stations CASCADE") + .execute(pool) + .await + .unwrap(); } #[tokio::test] @@ -804,6 +894,27 @@ mod tests { cleanup_test_data(&pool).await; } + #[tokio::test] + #[cfg_attr(not(feature = "integration-tests"), ignore)] + async fn test_get_line_group_ids_by_station_group_ids_filters_pass_and_inactive_stations() { + let pool = setup_test_db().await; + setup_test_data(&pool).await; + + let mut conn = pool.acquire().await.unwrap(); + let result = InternalTrainTypeRepository::get_line_group_ids_by_station_group_ids( + &[1001, 1002, 1003], + &mut conn, + ) + .await + .unwrap(); + + assert_eq!(result.get(&1001), Some(&vec![301, 302])); + assert!(!result.contains_key(&1002)); + assert_eq!(result.get(&1003), Some(&vec![305])); + + cleanup_test_data(&pool).await; + } + #[tokio::test] #[cfg_attr(not(feature = "integration-tests"), ignore)] async fn test_get_by_line_group_id_vec_excludes_pass() { diff --git a/stationapi/src/presentation/controller/grpc.rs b/stationapi/src/presentation/controller/grpc.rs index ec1e9e00..9d97069a 100644 --- a/stationapi/src/presentation/controller/grpc.rs +++ b/stationapi/src/presentation/controller/grpc.rs @@ -13,7 +13,7 @@ use crate::{ GetStationByIdRequest, GetStationByLineIdListRequest, GetStationByLineIdRequest, GetStationsByLineGroupIdListRequest, GetStationsByLineGroupIdRequest, GetStationsByNameRequest, GetTrainRouteRequest, GetTrainTypesByStationIdRequest, - MultipleLineResponse, MultipleStationResponse, MultipleTrainTypeResponse, Route, + MultipleLineResponse, MultipleStationResponse, MultipleTrainTypeResponse, RouteMinimalResponse, RouteResponse, RouteTypeResponse, SingleLineResponse, SingleStationResponse, TrainRouteResponse, TransportType as GrpcTransportType, }, @@ -411,14 +411,11 @@ impl StationApi for MyApi { match self .query_use_case - .get_connected_stations(from_station_group_id, to_station_group_id) + .get_connected_routes(from_station_group_id, to_station_group_id) .await { - Ok(stations) => Ok(Response::new(RouteResponse { - routes: vec![Route { - id: 0, - stops: stations.into_iter().map(|station| station.into()).collect(), - }], + Ok(routes) => Ok(Response::new(RouteResponse { + routes, next_page_token: "".to_string(), })), Err(err) => { @@ -507,7 +504,7 @@ mod tests { station::Station, station_number::StationNumber, train_type::TrainType, }, }, - proto::RouteMinimalResponse, + proto::{Route, RouteMinimalResponse}, use_case::{error::UseCaseError, traits::query::QueryUseCase}, }; use async_trait::async_trait; @@ -925,11 +922,11 @@ mod tests { Ok(vec![]) } - async fn get_connected_stations( + async fn get_connected_routes( &self, - _from_station_id: u32, - _to_station_id: u32, - ) -> Result, UseCaseError> { + _from_station_group_id: u32, + _to_station_group_id: u32, + ) -> Result, UseCaseError> { Ok(vec![]) } diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index 5c79052e..1aaeeec9 100644 --- a/stationapi/src/use_case/interactor/query.rs +++ b/stationapi/src/use_case/interactor/query.rs @@ -1,4 +1,17 @@ -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; + +const CONNECTED_ROUTE_MAX_SEGMENTS: usize = 8; +const CONNECTED_ROUTE_MAX_STATES: usize = 4096; +const CONNECTED_ROUTE_MAX_CANDIDATES: usize = 65_536; +const CONNECTED_ROUTE_MAX_RESULTS: usize = 32; + +#[derive(Clone)] +struct ConnectedRouteState { + current_group_id: u32, + line_group_ids: Vec, + visited_station_groups: HashSet, + stops: Vec, +} /// Maximum distance in meters to search for nearby bus stops from a rail station const NEARBY_BUS_STOP_RADIUS_METERS: f64 = 300.0; @@ -1173,13 +1186,225 @@ where Ok(lines) } - // TODO: 未実装 - async fn get_connected_stations( + async fn get_connected_routes( &self, - _from_station_id: u32, - _to_station_id: u32, - ) -> Result, UseCaseError> { - Ok(vec![]) + from_station_group_id: u32, + to_station_group_id: u32, + ) -> Result, UseCaseError> { + if from_station_group_id == to_station_group_id { + return Ok(vec![]); + } + + let mut states = vec![ConnectedRouteState { + current_group_id: from_station_group_id, + line_group_ids: vec![], + visited_station_groups: HashSet::from([from_station_group_id]), + stops: vec![], + }]; + let mut line_groups_by_station: HashMap> = HashMap::new(); + let mut stops_by_line_group: HashMap> = HashMap::new(); + let mut completed = Vec::new(); + let mut completed_signatures = HashSet::new(); + let mut expanded_states = 0usize; + let mut evaluated_candidates = 0usize; + + for _ in 0..CONNECTED_ROUTE_MAX_SEGMENTS { + if states.is_empty() + || completed.len() >= CONNECTED_ROUTE_MAX_RESULTS + || expanded_states >= CONNECTED_ROUTE_MAX_STATES + || evaluated_candidates >= CONNECTED_ROUTE_MAX_CANDIDATES + { + break; + } + + let missing_station_groups: Vec = states + .iter() + .map(|state| state.current_group_id) + .filter(|id| !line_groups_by_station.contains_key(id)) + .collect::>() + .into_iter() + .collect(); + let discovered = self + .train_type_repository + .get_line_group_ids_by_station_group_ids(&missing_station_groups) + .await?; + for station_group_id in missing_station_groups { + line_groups_by_station.insert( + station_group_id, + discovered + .get(&station_group_id) + .cloned() + .unwrap_or_default(), + ); + } + + let missing_line_groups: Vec = states + .iter() + .flat_map(|state| { + line_groups_by_station + .get(&state.current_group_id) + .into_iter() + .flatten() + }) + .copied() + .filter(|id| !stops_by_line_group.contains_key(id)) + .collect::>() + .into_iter() + .collect(); + let fetched_stops = self + .station_repository + .get_by_line_group_id_vec(&missing_line_groups) + .await?; + for line_group_id in &missing_line_groups { + stops_by_line_group.insert(*line_group_id, vec![]); + } + for stop in fetched_stops { + if let Some(line_group_id) = stop.line_group_cd.map(|id| id as u32) { + stops_by_line_group + .entry(line_group_id) + .or_default() + .push(stop); + } + } + + let mut next_states = Vec::new(); + 'expand_states: for state in states { + if expanded_states >= CONNECTED_ROUTE_MAX_STATES { + break; + } + expanded_states += 1; + let Some(available_line_groups) = + line_groups_by_station.get(&state.current_group_id) + else { + continue; + }; + + for &line_group_id in available_line_groups { + if state.line_group_ids.contains(&line_group_id) { + continue; + } + let Some(pattern) = stops_by_line_group.get(&line_group_id) else { + continue; + }; + let start_indices: Vec = pattern + .iter() + .enumerate() + .filter(|(_, stop)| { + stop.station_g_cd as u32 == state.current_group_id + && stop.pass != Some(1) + }) + .map(|(index, _)| index) + .collect(); + + for start_index in start_indices { + for end_index in 0..pattern.len() { + if evaluated_candidates >= CONNECTED_ROUTE_MAX_CANDIDATES { + break 'expand_states; + } + evaluated_candidates += 1; + let destination = &pattern[end_index]; + let destination_group_id = destination.station_g_cd as u32; + if end_index == start_index + || destination.pass == Some(1) + || state.visited_station_groups.contains(&destination_group_id) + { + continue; + } + + let segment: Vec = if start_index < end_index { + pattern[start_index..=end_index].to_vec() + } else { + pattern[end_index..=start_index] + .iter() + .rev() + .cloned() + .collect() + }; + if segment.iter().skip(1).any(|stop| { + state + .visited_station_groups + .contains(&(stop.station_g_cd as u32)) + }) { + continue; + } + let mut visited_station_groups = state.visited_station_groups.clone(); + visited_station_groups.extend( + segment.iter().skip(1).map(|stop| stop.station_g_cd as u32), + ); + let mut stops = state.stops.clone(); + stops.extend(segment.into_iter().skip(usize::from(!stops.is_empty()))); + let mut line_group_ids = state.line_group_ids.clone(); + line_group_ids.push(line_group_id); + + let candidate = ConnectedRouteState { + current_group_id: destination_group_id, + line_group_ids, + visited_station_groups, + stops, + }; + if destination_group_id == to_station_group_id { + let signature = connected_route_signature(&candidate); + if completed_signatures.insert(signature) { + completed.push(candidate); + } + if completed.len() >= CONNECTED_ROUTE_MAX_RESULTS { + break 'expand_states; + } + } else if next_states.len() + expanded_states + < CONNECTED_ROUTE_MAX_STATES + { + next_states.push(candidate); + } + } + } + } + } + states = next_states; + } + + let mut used_virtual_ids = HashSet::new(); + let mut routes = Vec::new(); + for candidate in completed { + let signature = connected_route_signature(&candidate); + let virtual_line_group_id = + connected_route_virtual_id(&signature, &mut used_virtual_ids); + let stops = candidate + .stops + .into_iter() + .map(|row| { + let extracted_line = self.extract_line_from_station(&row); + let train_type = TrainType { + id: row.type_id, + station_cd: Some(row.station_cd), + type_cd: row.type_cd, + line_group_cd: Some(virtual_line_group_id as i32), + pass: row.pass, + type_name: row.type_name.clone().unwrap_or_default(), + type_name_k: row.type_name_k.clone().unwrap_or_default(), + type_name_r: row.type_name_r.clone(), + type_name_zh: row.type_name_zh.clone(), + type_name_ko: row.type_name_ko.clone(), + color: row.color.clone().unwrap_or_default(), + direction: row.direction, + kind: row.kind, + line: Some(Box::new(extracted_line.clone())), + lines: vec![extracted_line.clone()], + }; + let mut stop = self.build_station_from_row( + &row, + &extracted_line, + Some(Box::new(train_type)), + ); + stop.line_group_cd = Some(virtual_line_group_id as i32); + proto::Station::from(stop) + }) + .collect(); + routes.push(Route { + id: virtual_line_group_id, + stops, + }); + } + Ok(routes) } /// `from_station_id` から `to_station_id` までの区間の各駅について、始点からの @@ -1698,6 +1923,40 @@ where } } +fn connected_route_signature(route: &ConnectedRouteState) -> Vec { + let mut signature = Vec::with_capacity( + (route.line_group_ids.len() + route.stops.len() + 2) * std::mem::size_of::(), + ); + signature.extend_from_slice(&(route.line_group_ids.len() as u32).to_le_bytes()); + for line_group_id in &route.line_group_ids { + signature.extend_from_slice(&line_group_id.to_le_bytes()); + } + signature.extend_from_slice(&(route.stops.len() as u32).to_le_bytes()); + for stop in &route.stops { + signature.extend_from_slice(&(stop.station_g_cd as u32).to_le_bytes()); + } + signature +} + +fn connected_route_virtual_id(signature: &[u8], used_ids: &mut HashSet) -> u32 { + const FNV_OFFSET_BASIS: u32 = 2_166_136_261; + const FNV_PRIME: u32 = 16_777_619; + + let mut hash = FNV_OFFSET_BASIS; + for byte in signature { + hash ^= u32::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + + // Persisted line_group_cd is a signed PostgreSQL integer. Reserving the + // upper half of u32 therefore guarantees that virtual IDs cannot overlap it. + let mut candidate = hash | 0x8000_0000; + while !used_ids.insert(candidate) { + candidate = candidate.wrapping_add(1) | 0x8000_0000; + } + candidate +} + /// Build a signature describing the stations a train type actually stops at within the /// requested from→to segment. /// @@ -3548,6 +3807,7 @@ mod tests { struct ConfigurableMockTrainTypeRepository { train_types: Vec, expected_line_group_id: Option, + connection_line_groups: HashMap>, } impl ConfigurableMockTrainTypeRepository { @@ -3555,6 +3815,7 @@ mod tests { Self { train_types, expected_line_group_id: None, + connection_line_groups: HashMap::new(), } } @@ -3562,6 +3823,14 @@ mod tests { self.expected_line_group_id = line_group_id; self } + + fn with_connection_line_groups( + mut self, + connection_line_groups: HashMap>, + ) -> Self { + self.connection_line_groups = connection_line_groups; + self + } } /// Check if a TrainType matches the given (line_group_id, line_id) pair. @@ -3579,6 +3848,20 @@ mod tests { #[async_trait::async_trait] impl TrainTypeRepository for ConfigurableMockTrainTypeRepository { + async fn get_line_group_ids_by_station_group_ids( + &self, + station_group_ids: &[u32], + ) -> Result>, DomainError> { + Ok(station_group_ids + .iter() + .filter_map(|id| { + self.connection_line_groups + .get(id) + .cloned() + .map(|groups| (*id, groups)) + }) + .collect()) + } async fn find_by_line_group_id_and_line_id( &self, line_group_id: u32, @@ -4564,6 +4847,136 @@ mod tests { assert!(result.is_empty()); } + + fn create_connected_stop( + station_cd: i32, + station_group_id: i32, + line_group_id: i32, + pass: i32, + ) -> Station { + let mut station = create_test_station( + station_cd, + station_group_id, + line_group_id, + Some(line_group_id), + ); + station.type_id = Some(line_group_id); + station.type_cd = Some(line_group_id); + station.type_name = Some(format!("種別{line_group_id}")); + station.type_name_k = Some(format!("シュベツ{line_group_id}")); + station.pass = Some(pass); + station.stop_condition = if pass == 1 { + StopCondition::Not + } else { + StopCondition::All + }; + station + } + + fn create_connected_route_interactor() -> QueryInteractor< + ConfigurableMockStationRepository, + ConfigurableMockLineRepository, + ConfigurableMockTrainTypeRepository, + ConfigurableMockCompanyRepository, + > { + let stops = vec![ + create_connected_stop(101, 1, 100, 0), + create_connected_stop(109, 9, 100, 1), + create_connected_stop(102, 2, 100, 0), + create_connected_stop(202, 2, 200, 0), + create_connected_stop(203, 3, 200, 0), + create_connected_stop(303, 3, 300, 0), + create_connected_stop(304, 4, 300, 0), + // This group closes a cycle back to the origin. The search must + // reject it because station group 1 was already visited. + create_connected_stop(402, 2, 400, 0), + create_connected_stop(401, 1, 400, 0), + // A direct candidate verifies that one-segment routes remain valid. + create_connected_stop(501, 1, 500, 0), + create_connected_stop(504, 4, 500, 0), + ]; + let connection_line_groups = HashMap::from([ + (1, vec![100, 400, 500]), + (2, vec![100, 200, 400]), + (3, vec![200, 300]), + (4, vec![300, 500]), + ]); + + QueryInteractor { + station_repository: ConfigurableMockStationRepository::new(vec![], vec![]) + .with_line_group_stations(stops), + line_repository: ConfigurableMockLineRepository::new(vec![]), + train_type_repository: ConfigurableMockTrainTypeRepository::new(vec![]) + .with_connection_line_groups(connection_line_groups), + company_repository: ConfigurableMockCompanyRepository::new(vec![]), + } + } + + #[tokio::test] + async fn test_get_connected_routes_returns_direct_and_three_segment_routes() { + let interactor = create_connected_route_interactor(); + + let routes = interactor.get_connected_routes(1, 4).await.unwrap(); + + assert!(routes.iter().any(|route| route.stops.len() == 2)); + let connected = routes + .iter() + .find(|route| { + route + .stops + .iter() + .map(|stop| stop.group_id) + .collect::>() + == vec![1, 9, 2, 3, 4] + }) + .expect("three-segment route should be returned"); + assert_eq!( + connected + .stops + .iter() + .filter(|stop| stop.group_id == 2 || stop.group_id == 3) + .count(), + 2, + "connection stations must not be duplicated" + ); + assert_eq!(connected.stops[1].stop_condition, StopCondition::Not as i32); + assert!(connected.id >= 0x8000_0000); + assert!(connected.stops.iter().all(|stop| { + stop.train_type + .as_ref() + .is_some_and(|train_type| train_type.group_id == connected.id) + })); + } + + #[tokio::test] + async fn test_get_connected_routes_is_deterministic_and_handles_cycles_and_no_route() { + let interactor = create_connected_route_interactor(); + + let first = interactor.get_connected_routes(1, 4).await.unwrap(); + let second = interactor.get_connected_routes(1, 4).await.unwrap(); + assert_eq!( + first.iter().map(|route| route.id).collect::>(), + second.iter().map(|route| route.id).collect::>() + ); + assert_eq!( + first.len(), + first + .iter() + .map(|route| route.id) + .collect::>() + .len() + ); + for route in &first { + assert_eq!( + route.stops.iter().filter(|stop| stop.group_id == 1).count(), + 1, + "origin station group must appear once per route" + ); + } + + let unreachable = interactor.get_connected_routes(1, 99).await.unwrap(); + assert!(unreachable.is_empty()); + } } // ======================================== diff --git a/stationapi/src/use_case/traits/query.rs b/stationapi/src/use_case/traits/query.rs index 90047a88..e4f446c2 100644 --- a/stationapi/src/use_case/traits/query.rs +++ b/stationapi/src/use_case/traits/query.rs @@ -124,11 +124,11 @@ pub trait QueryUseCase: Send + Sync + 'static { line_name: String, limit: Option, ) -> Result, UseCaseError>; - async fn get_connected_stations( + async fn get_connected_routes( &self, - from_station_id: u32, - to_station_id: u32, - ) -> Result, UseCaseError>; + from_station_group_id: u32, + to_station_group_id: u32, + ) -> Result, UseCaseError>; async fn estimate_route_arrival_times( &self, from_station_id: u32,