diff --git a/AGENTS.md b/AGENTS.md index eefc387c..0412c136 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ This guide explains how automation agents and human contributors should work wit - **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` 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. +- **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. Exploration loads only line-group ID, station-station-type ID, station-group ID, and pass metadata; full station rows are fetched after the result set is fixed. 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 0c36a180..1d39f4c9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -211,6 +211,11 @@ PostgreSQL `INTEGER` ID と衝突しない `uint32` 上位半分を使用しま 各探索階層では未取得の駅グループを 1 回のクエリへまとめ、そこで判明した未取得の `line_group_cd` の駅列も 1 回で取得します。このため Repository 呼び出しは最大でも 階層あたり 2 回で、候補経路ごとの N+1 クエリや同じ列車種別の再取得はありません。 +探索用の駅列は `line_group_cd`、`station_station_types.id`、`station_g_cd`、`pass` だけを取得し、経路状態にも +この軽量な参照だけを保持します。駅名、住所、座標、路線属性、列車種別属性を含む完全な +`Station` は探索中に生成・複製せず、返却候補が確定した後、その候補が実際に使用する +`line_group_cd` に限定して一括取得します。これにより探索状態数と駅エンティティの大きさの +積に比例していたメモリ使用量を避けます。 取得後は駅グループと `line_group_cd` を `HashMap` に一度だけ分類し、状態と駅列を 毎回総当たりする O(n×m) の処理を、入力件数に比例する O(n+m) の参照へ置き換えます。 探索そのものの最悪計算量は各状態の始点候補数と駅列長の積にも依存するため、状態数とは diff --git a/stationapi/src/domain/repository/station_repository.rs b/stationapi/src/domain/repository/station_repository.rs index f9dfee05..83d10a82 100644 --- a/stationapi/src/domain/repository/station_repository.rs +++ b/stationapi/src/domain/repository/station_repository.rs @@ -5,6 +5,14 @@ use crate::domain::{ error::DomainError, }; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConnectedRoutePatternStop { + pub line_group_id: u32, + pub station_station_type_id: i32, + pub station_group_id: u32, + pub pass: Option, +} + #[async_trait] pub trait StationRepository: Send + Sync + 'static { async fn find_by_id(&self, id: u32) -> Result, DomainError>; @@ -51,6 +59,29 @@ pub trait StationRepository: Send + Sync + 'static { &self, line_group_ids: &[u32], ) -> Result, DomainError>; + /// Fetch only the fields needed while exploring connected routes. + /// + /// The default keeps lightweight test repositories source-compatible. + /// Production repositories should override this to avoid materializing full + /// `Station` entities for every explored line group. + async fn get_connected_route_pattern_stops( + &self, + line_group_ids: &[u32], + ) -> Result, DomainError> { + Ok(self + .get_by_line_group_id_vec(line_group_ids) + .await? + .into_iter() + .filter_map(|stop| { + Some(ConnectedRoutePatternStop { + line_group_id: stop.line_group_cd? as u32, + station_station_type_id: stop.sst_id?, + station_group_id: stop.station_g_cd as u32, + pass: stop.pass, + }) + }) + .collect()) + } async fn get_bus_stops_near_stations( &self, coords: &[(u32, f64, f64)], // (station_g_cd, lat, lon) diff --git a/stationapi/src/infrastructure/station_repository.rs b/stationapi/src/infrastructure/station_repository.rs index 99868af0..56d3bef1 100644 --- a/stationapi/src/infrastructure/station_repository.rs +++ b/stationapi/src/infrastructure/station_repository.rs @@ -7,7 +7,7 @@ use crate::{ entity::{gtfs::TransportType, station::Station}, error::DomainError, normalize::normalize_for_search, - repository::station_repository::StationRepository, + repository::station_repository::{ConnectedRoutePatternStop, StationRepository}, }, proto::StopCondition, }; @@ -17,6 +17,14 @@ struct TrainTypesCountRow { train_types_count: Option, } +#[derive(sqlx::FromRow)] +struct ConnectedRoutePatternStopRow { + line_group_cd: i32, + station_station_type_id: i32, + station_g_cd: i32, + pass: Option, +} + #[derive(sqlx::FromRow, Clone)] struct StationRow { pub station_cd: i32, @@ -430,6 +438,15 @@ impl StationRepository for MyStationRepository { InternalStationRepository::get_by_line_group_id_vec(line_group_ids, &mut conn).await } + async fn get_connected_route_pattern_stops( + &self, + line_group_ids: &[u32], + ) -> Result, DomainError> { + let mut conn = self.pool.acquire().await?; + InternalStationRepository::get_connected_route_pattern_stops(line_group_ids, &mut conn) + .await + } + async fn get_bus_stops_near_stations( &self, coords: &[(u32, f64, f64)], @@ -481,6 +498,41 @@ impl StationRepository for MyStationRepository { struct InternalStationRepository {} impl InternalStationRepository { + async fn get_connected_route_pattern_stops( + line_group_ids: &[u32], + conn: &mut PgConnection, + ) -> Result, DomainError> { + if line_group_ids.is_empty() { + return Ok(vec![]); + } + + let line_group_ids: Vec = line_group_ids.iter().map(|id| *id as i32).collect(); + let rows = sqlx::query_as::<_, ConnectedRoutePatternStopRow>( + r#"SELECT sst.line_group_cd, sst.id AS station_station_type_id, + s.station_g_cd, sst.pass + FROM station_station_types AS sst + JOIN stations AS s ON s.station_cd = sst.station_cd + JOIN lines AS l ON l.line_cd = s.line_cd + WHERE sst.line_group_cd = ANY($1) + AND s.e_status = 0 + AND l.e_status = 0 + ORDER BY array_position($1, sst.line_group_cd), sst.id"#, + ) + .bind(&line_group_ids) + .fetch_all(conn) + .await?; + + Ok(rows + .into_iter() + .map(|row| ConnectedRoutePatternStop { + line_group_id: row.line_group_cd as u32, + station_station_type_id: row.station_station_type_id, + station_group_id: row.station_g_cd as u32, + pass: row.pass, + }) + .collect()) + } + async fn fetch_has_local_train_types_by_station_id( id: u32, conn: &mut PgConnection, diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index 1aaeeec9..603b4aaa 100644 --- a/stationapi/src/use_case/interactor/query.rs +++ b/stationapi/src/use_case/interactor/query.rs @@ -10,7 +10,14 @@ struct ConnectedRouteState { current_group_id: u32, line_group_ids: Vec, visited_station_groups: HashSet, - stops: Vec, + stops: Vec, +} + +#[derive(Clone, Copy)] +struct ConnectedRouteStopRef { + line_group_id: u32, + station_station_type_id: i32, + station_group_id: u32, } /// Maximum distance in meters to search for nearby bus stops from a rail station @@ -51,8 +58,10 @@ use crate::{ }, normalize::normalize_for_search, repository::{ - company_repository::CompanyRepository, line_repository::LineRepository, - station_repository::StationRepository, train_type_repository::TrainTypeRepository, + company_repository::CompanyRepository, + line_repository::LineRepository, + station_repository::{ConnectedRoutePatternStop, StationRepository}, + train_type_repository::TrainTypeRepository, }, segment_speed_table::{segment_override_applies_to_kind, segment_speed_override_kmh}, }, @@ -1202,7 +1211,7 @@ where stops: vec![], }]; let mut line_groups_by_station: HashMap> = HashMap::new(); - let mut stops_by_line_group: 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; @@ -1253,18 +1262,16 @@ where .collect(); let fetched_stops = self .station_repository - .get_by_line_group_id_vec(&missing_line_groups) + .get_connected_route_pattern_stops(&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); - } + stops_by_line_group + .entry(stop.line_group_id) + .or_default() + .push(stop); } let mut next_states = Vec::new(); @@ -1290,8 +1297,7 @@ where .iter() .enumerate() .filter(|(_, stop)| { - stop.station_g_cd as u32 == state.current_group_id - && stop.pass != Some(1) + stop.station_group_id == state.current_group_id && stop.pass != Some(1) }) .map(|(index, _)| index) .collect(); @@ -1303,7 +1309,7 @@ where } evaluated_candidates += 1; let destination = &pattern[end_index]; - let destination_group_id = destination.station_g_cd as u32; + let destination_group_id = destination.station_group_id; if end_index == start_index || destination.pass == Some(1) || state.visited_station_groups.contains(&destination_group_id) @@ -1311,28 +1317,56 @@ where continue; } - let segment: Vec = if start_index < end_index { - pattern[start_index..=end_index].to_vec() + let intersects_visited = if start_index < end_index { + pattern[start_index + 1..=end_index].iter().any(|stop| { + state + .visited_station_groups + .contains(&stop.station_group_id) + }) } else { - pattern[end_index..=start_index] - .iter() - .rev() - .cloned() - .collect() + pattern[end_index..start_index].iter().any(|stop| { + state + .visited_station_groups + .contains(&stop.station_group_id) + }) }; - if segment.iter().skip(1).any(|stop| { - state - .visited_station_groups - .contains(&(stop.station_g_cd as u32)) - }) { + if intersects_visited { 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 append_stop = + |stops: &mut Vec, pattern_index: usize| { + stops.push(ConnectedRouteStopRef { + line_group_id, + station_station_type_id: pattern[pattern_index] + .station_station_type_id, + station_group_id: pattern[pattern_index].station_group_id, + }); + }; + if start_index < end_index { + visited_station_groups.extend( + pattern[start_index + 1..=end_index] + .iter() + .map(|stop| stop.station_group_id), + ); + let append_from = start_index + usize::from(!stops.is_empty()); + for pattern_index in append_from..=end_index { + append_stop(&mut stops, pattern_index); + } + } else { + visited_station_groups.extend( + pattern[end_index..start_index] + .iter() + .map(|stop| stop.station_group_id), + ); + if stops.is_empty() { + append_stop(&mut stops, start_index); + } + for pattern_index in (end_index..start_index).rev() { + append_stop(&mut stops, pattern_index); + } + } let mut line_group_ids = state.line_group_ids.clone(); line_group_ids.push(line_group_id); @@ -1362,14 +1396,48 @@ where states = next_states; } + if completed.is_empty() { + return Ok(vec![]); + } + + let detailed_line_group_ids: Vec = completed + .iter() + .flat_map(|candidate| candidate.line_group_ids.iter().copied()) + .collect::>() + .into_iter() + .collect(); + let detailed_stops = self + .station_repository + .get_by_line_group_id_vec(&detailed_line_group_ids) + .await?; + let mut detailed_stops_by_id: HashMap<(u32, i32), Station> = HashMap::new(); + for stop in detailed_stops { + if let (Some(line_group_id), Some(station_station_type_id)) = + (stop.line_group_cd.map(|id| id as u32), stop.sst_id) + { + detailed_stops_by_id.insert((line_group_id, station_station_type_id), stop); + } + } + 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 + let Some(detailed_stops): Option> = candidate .stops + .iter() + .map(|stop| { + detailed_stops_by_id + .get(&(stop.line_group_id, stop.station_station_type_id)) + .cloned() + }) + .collect() + else { + continue; + }; + let stops = detailed_stops .into_iter() .map(|row| { let extracted_line = self.extract_line_from_station(&row); @@ -1933,7 +2001,7 @@ fn connected_route_signature(route: &ConnectedRouteState) -> Vec { } 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.extend_from_slice(&stop.station_group_id.to_le_bytes()); } signature } @@ -4861,6 +4929,7 @@ mod tests { Some(line_group_id), ); station.type_id = Some(line_group_id); + station.sst_id = Some(station_cd); station.type_cd = Some(line_group_id); station.type_name = Some(format!("種別{line_group_id}")); station.type_name_k = Some(format!("シュベツ{line_group_id}"));