Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) の参照へ置き換えます。
探索そのものの最悪計算量は各状態の始点候補数と駅列長の積にも依存するため、状態数とは
Expand Down
31 changes: 31 additions & 0 deletions stationapi/src/domain/repository/station_repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i32>,
}

#[async_trait]
pub trait StationRepository: Send + Sync + 'static {
async fn find_by_id(&self, id: u32) -> Result<Option<Station>, DomainError>;
Expand Down Expand Up @@ -51,6 +59,29 @@ pub trait StationRepository: Send + Sync + 'static {
&self,
line_group_ids: &[u32],
) -> Result<Vec<Station>, 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<Vec<ConnectedRoutePatternStop>, 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)
Expand Down
54 changes: 53 additions & 1 deletion stationapi/src/infrastructure/station_repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -17,6 +17,14 @@ struct TrainTypesCountRow {
train_types_count: Option<i32>,
}

#[derive(sqlx::FromRow)]
struct ConnectedRoutePatternStopRow {
line_group_cd: i32,
station_station_type_id: i32,
station_g_cd: i32,
pass: Option<i32>,
}

#[derive(sqlx::FromRow, Clone)]
struct StationRow {
pub station_cd: i32,
Expand Down Expand Up @@ -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<Vec<ConnectedRoutePatternStop>, 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)],
Expand Down Expand Up @@ -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<Vec<ConnectedRoutePatternStop>, DomainError> {
if line_group_ids.is_empty() {
return Ok(vec![]);
}

let line_group_ids: Vec<i32> = 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,
Expand Down
Loading
Loading