diff --git a/AGENTS.md b/AGENTS.md index 27f8525f..24b0e9f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,7 @@ The Worker is the workspace root package. `stationapi`, `preprocessor`, and `dat - **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 +- **Station name search ranking** – `index::search_by_name` ranks exact matches before prefixes before substrings, taking the best match across the existing name/readings/translations. Rank is computed once per candidate; ties use station group ID, name, and station ID. Apply reachability filtering before the result limit, preserving this order. - **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; a bullet-train line, which has no local service, takes the group of its stopping train type with the smallest `types.id`, e.g. Nozomi or Hayabusa) and returns its stops carrying that group's train type, with or without `stationId`, so a client that never picked a train type still has the `lineGroupId` `trainRoute` requires; 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. `stationsByName` with `fromStationGroupId` returns the stations reachable from there: stations sharing a line group with the origin (`line_group_cd` set, `has_train_types` true), same-line stations when either side has no line group, and — for rail — stations reachable by transferring, i.e. those for which `connectedRoutes` with `viaLineId` set to the station's line returns a route (`line_group_cd` empty, `has_train_types` false). The transfer check uses `RouteTopology` (`stationapi/src/domain/route_topology.rs`), a time-free copy of the `connectedRoutes` network built straight from the index without `Station` entities or time estimates (about 20 ms instead of about 190 ms, cached in its own `OnceLock`); `RouteNetwork` holds the same topology, both share `trim_pattern` and `line_group_rows`, and a real-data test asserts the two are equal. The check is a ride-limited BFS over line groups (a few ms) plus, only for destinations that are cut vertices of the station–line-group graph, a check that arrives without stopping over at the destination group — otherwise a branch's junction station (Ishibashi-handai-mae on the Minoo Line) would be listed although reaching it on that branch means riding out and back. - **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). diff --git a/docs/architecture.md b/docs/architecture.md index 62696dbf..5db3980d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -305,6 +305,10 @@ SQL (`ORDER BY transport_type, distance`) と同じく、鉄道駅を先に、 `LIKE '%...%'` を高速化するためのインデックスで、類似度検索に使っていた わけではありません。そのため `contains()` でも論理的に同じ結果になります。 正規化には domain 層の `normalize_for_search` をそのまま使います。 +各検索対象での完全一致・前方一致・部分一致のうち最も強い一致を順位に使い、 +同順位では駅グループID・駅名・駅ID順に並べます。一致度は候補ごとに一度だけ +計算します。到達可能性による絞り込みと件数上限は呼び出し側で適用するため、 +「原」のような短い完全一致の駅名も部分一致の候補に押し出されません。 ### 停車駅 (`station_station_types`) diff --git a/src/index.rs b/src/index.rs index 02f354b8..f2f23c36 100644 --- a/src/index.rs +++ b/src/index.rs @@ -823,28 +823,54 @@ pub fn search_by_name(query: &str, want: Option) -> Vec<&'static StationRec // s.station_name_rn ILIKE $3 用 let lowered = query.to_lowercase(); - let mut hits: Vec<&'static StationRecord> = stations() + let mut hits: Vec<(&'static StationRecord, u8)> = stations() .iter() .filter(|s| s.e_status == 0) .filter(|s| joins_active_line(s)) .filter(|s| want.is_none_or(|w| s.transport_type as i32 == w)) - .filter(|s| { - s.name.contains(query) - || s.name_roman_lower + .filter_map(|s| { + let rank = [ + name_match_rank(&s.name, query), + s.name_roman_lower .as_deref() - .is_some_and(|v| v.contains(&lowered)) - || s.name_katakana.contains(&katakana) - || s.name_chinese.as_deref().is_some_and(|v| v.contains(query)) - || s.name_korean.as_deref().is_some_and(|v| v.contains(query)) + .and_then(|v| name_match_rank(v, &lowered)), + name_match_rank(&s.name_katakana, &katakana), + s.name_chinese + .as_deref() + .and_then(|v| name_match_rank(v, query)), + s.name_korean + .as_deref() + .and_then(|v| name_match_rank(v, query)), + ] + .into_iter() + .flatten() + .min()?; + Some((s, rank)) }) .collect(); - hits.sort_unstable_by(|a, b| { - a.station_g_cd - .cmp(&b.station_g_cd) + // 一致度を一度だけ計算し、取得上限を掛ける前に完全一致を先頭にする。 + hits.sort_unstable_by(|(a, a_rank), (b, b_rank)| { + a_rank + .cmp(b_rank) + .then_with(|| a.station_g_cd.cmp(&b.station_g_cd)) .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.station_cd.cmp(&b.station_cd)) }); - hits + hits.into_iter().map(|(station, _)| station).collect() +} + +/// 完全一致、前方一致、部分一致の順。検索対象と正規化は従来どおり。 +fn name_match_rank(name: &str, query: &str) -> Option { + if name == query { + Some(0) + } else if name.starts_with(query) { + Some(1) + } else if name.contains(query) { + Some(2) + } else { + None + } } // ---------------------------------------------------------------- 列車種別 @@ -1232,6 +1258,32 @@ pub fn apply_line_alias(line: &mut Line, station_cd: i32) { mod tests { use super::*; + #[test] + fn name_search_keeps_hara_exact_matches_before_partial_matches() { + let hits = search_by_name("原", Some(TransportType::Rail as i32)); + let exact_count = hits.iter().take_while(|s| s.name == "原").count(); + assert!(exact_count > 0); + assert!(hits[..exact_count].iter().any(|s| s.line_cd == 99516)); + assert!(hits[exact_count..].iter().any(|s| s.name == "秋葉原")); + let prefix_end = hits.iter().take_while(|s| s.name.starts_with("原")).count(); + assert!(prefix_end > exact_count); + assert!(hits[prefix_end..].iter().all(|s| !s.name.starts_with("原"))); + } + + #[test] + fn name_search_ranks_normalized_readings_and_roman_names() { + for query in ["はら", "ハラ", "HARA"] { + let hits = search_by_name(query, Some(TransportType::Rail as i32)); + assert!(!hits.is_empty(), "query={query}"); + assert_eq!(hits[0].name, "原", "query={query}"); + } + assert!(search_by_name("", None).is_empty()); + assert_eq!(name_match_rank("原", "原"), Some(0)); + assert_eq!(name_match_rank("原宿", "原"), Some(1)); + assert_eq!(name_match_rank("秋葉原", "原"), Some(2)); + assert_eq!(name_match_rank("東京", "原"), None); + } + /// グリッド索引の正解となる全件走査。索引を入れる前の実装そのもの。 fn nearest_by_full_scan( lat: f64, diff --git a/src/repository.rs b/src/repository.rs index 922f5815..25939933 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -1385,6 +1385,42 @@ mod tests { } } + #[test] + fn name_search_limit_keeps_nagoya_hara() { + let hits = block_on(MemStationRepository.get_by_name( + "原".to_string(), + Some(10), + None, + Some(TransportType::Rail), + )) + .unwrap(); + assert_eq!(hits.len(), 10); + assert!(hits.iter().any(|s| s.station_cd == 9951618)); + assert_eq!(hits[0].station_name, "原"); + } + + #[test] + fn name_search_limit_preserves_ranking_after_reachability_filtering() { + let all = block_on(MemStationRepository.get_by_name( + "原".to_string(), + None, + Some(9951617), + Some(TransportType::Rail), + )) + .unwrap(); + assert!(all.iter().any(|s| s.station_cd == 9951618)); + assert_eq!(all[0].station_name, "原"); + let limited = block_on(MemStationRepository.get_by_name( + "原".to_string(), + Some(1), + Some(9951617), + Some(TransportType::Rail), + )) + .unwrap(); + assert_eq!(limited.len(), 1); + assert_eq!(limited[0].station_cd, all[0].station_cd); + } + fn names_by_line(from: u32, name: &str) -> Vec<(String, i32, bool)> { block_on(MemStationRepository.get_by_name(name.to_string(), Some(100), Some(from), None)) .unwrap()