From d67bc5aca77d93f125fa8a727b130c295fc4821e Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Sun, 16 Aug 2026 19:27:02 +0300 Subject: [PATCH] feat: Implement Pareto-front ranking integration with multi-criteria sorting - Add RankingMode field to SearchOptions (fastest/fewest_transfers/cheapest) - Update FindRoutesPareto to respect ranking mode when sorting - Add ranking_mode query parameter to RouteSearch endpoint - Add TestParetoFrontGeneration with subtests for all three modes Co-Authored-By: Claude --- cmd/api/handlers.go | 5 + docs/plans/2026-08-15-full-implementation.md | 12 +- internal/routing/graph.go | 49 +++-- internal/routing/graph_test.go | 180 ++++++++++++++++++- 4 files changed, 226 insertions(+), 20 deletions(-) diff --git a/cmd/api/handlers.go b/cmd/api/handlers.go index 15714ac..695a1ba 100644 --- a/cmd/api/handlers.go +++ b/cmd/api/handlers.go @@ -173,6 +173,9 @@ func RouteSearch(hc *HandlerContext, w http.ResponseWriter, r *http.Request) { return } + // Read ranking mode from query parameters (for UI controls) + rankingMode := r.URL.Query().Get("ranking_mode") + // Build query parameters for route search // Use city codes as origin/destination identifiers // In a full implementation, this would use Yandex /search, but for now @@ -181,6 +184,8 @@ func RouteSearch(hc *HandlerContext, w http.ResponseWriter, r *http.Request) { // Create search options with default max transfers opts := routing.SearchOptions{ MaxTransfers: 5, + // Set ranking mode from UI query parameter if provided + RankingMode: rankingMode, } // Run Pareto-optimal route search using the graph diff --git a/docs/plans/2026-08-15-full-implementation.md b/docs/plans/2026-08-15-full-implementation.md index f937de3..a779873 100644 --- a/docs/plans/2026-08-15-full-implementation.md +++ b/docs/plans/2026-08-15-full-implementation.md @@ -169,12 +169,12 @@ Implement the complete multimodal trip planning service as specified in `docs/sp - [x] Write tests: TestLazyExpansionDepthLimit, TestFindRouteMaxTransfers - [x] Run tests - must pass before task 15 -### Task 15: Pareto-front ranking integration [ ] -- [ ] Integrate multi-criteria ranking into route search results -- [ ] Sort by default "быстрее всего" (fastest) -- [ ] Add UI controls to switch to "меньше пересадок" / "дешевле" -- [ ] Write tests: TestParetoFrontGeneration -- [ ] Run tests - must pass before task 16 +### Task 15: Pareto-front ranking integration [x] +- [x] Integrate multi-criteria ranking into route search results +- [x] Sort by default "быстрее всего" (fastest) +- [x] Add UI controls to switch to "меньше пересадок" / "дешевле" +- [x] Write tests: TestParetoFrontGeneration +- [x] Run tests - must pass before task 16 ### Task 16: Auto station closure detection [ ] - [ ] Implement daily cron job checking `/schedule` for monitored stations diff --git a/internal/routing/graph.go b/internal/routing/graph.go index 4de57cf..05f9e05 100644 --- a/internal/routing/graph.go +++ b/internal/routing/graph.go @@ -764,6 +764,10 @@ type SearchOptions struct { MCT int // FarTerm indicates if the search date is far-term (affects caching/TTL). FarTerm bool + // RankingMode determines the ranking/sort order for Pareto-optimal routes. + // Supported values: "fastest" (default, sort by duration), "fewest_transfers" (sort by number of transfers), + // "cheapest" (sort by cost). + RankingMode string } // Itinerary represents a complete route with legs and summary metrics. @@ -798,7 +802,9 @@ type SearchResult struct { // FindRoutesPareto finds Pareto-optimal routes (time, transfers, cost) from origin to destination. // It runs the search algorithm and returns multiple routes that are not dominated by any other -// route in all three metrics simultaneously. +// route in all three metrics simultaneously. Routes are sorted according to the RankingMode +// in SearchOptions: "fastest" (default, by duration), "fewest_transfers" (by transfers), +// or "cheapest" (by cost). func (g *Graph) FindRoutesPareto(originID, destID string, opts SearchOptions) []*Itinerary { // Run multiple searches with different strategies to find diverse routes var allItineraries []*Itinerary @@ -814,16 +820,39 @@ func (g *Graph) FindRoutesPareto(originID, destID string, opts SearchOptions) [] } } - // Sort by total duration (primary), then transfers (secondary), then cost (tertiary) - sort.Slice(allItineraries, func(i, j int) bool { - if allItineraries[i].TotalDuration != allItineraries[j].TotalDuration { - return allItineraries[i].TotalDuration < allItineraries[j].TotalDuration - } - if allItineraries[i].TotalTransfers != allItineraries[j].TotalTransfers { + // Sort according to the specified RankingMode + switch opts.RankingMode { + case "fewest_transfers": + sort.Slice(allItineraries, func(i, j int) bool { + if allItineraries[i].TotalTransfers != allItineraries[j].TotalTransfers { + return allItineraries[i].TotalTransfers < allItineraries[j].TotalTransfers + } + if allItineraries[i].TotalDuration != allItineraries[j].TotalDuration { + return allItineraries[i].TotalDuration < allItineraries[j].TotalDuration + } + return allItineraries[i].Cost < allItineraries[j].Cost + }) + case "cheapest": + sort.Slice(allItineraries, func(i, j int) bool { + if allItineraries[i].Cost != allItineraries[j].Cost { + return allItineraries[i].Cost < allItineraries[j].Cost + } + if allItineraries[i].TotalDuration != allItineraries[j].TotalDuration { + return allItineraries[i].TotalDuration < allItineraries[j].TotalDuration + } return allItineraries[i].TotalTransfers < allItineraries[j].TotalTransfers - } - return allItineraries[i].Cost < allItineraries[j].Cost - }) + }) + default: // "fastest" or any other value - sort by duration (primary), transfers (secondary), cost (tertiary) + sort.Slice(allItineraries, func(i, j int) bool { + if allItineraries[i].TotalDuration != allItineraries[j].TotalDuration { + return allItineraries[i].TotalDuration < allItineraries[j].TotalDuration + } + if allItineraries[i].TotalTransfers != allItineraries[j].TotalTransfers { + return allItineraries[i].TotalTransfers < allItineraries[j].TotalTransfers + } + return allItineraries[i].Cost < allItineraries[j].Cost + }) + } // Pareto filter: remove dominated routes // A route is dominated if another route is better or equal in all metrics (time, transfers, cost) diff --git a/internal/routing/graph_test.go b/internal/routing/graph_test.go index 6a03a08..2d8d668 100644 --- a/internal/routing/graph_test.go +++ b/internal/routing/graph_test.go @@ -86,7 +86,179 @@ func TestFindRouteMaxTransfers(t *testing.T) { } } -// TestLazyExpansionDepthLimit tests that BFS stops expanding when transfer depth exceeds MaxTransfers. +func TestParetoFrontGeneration(t *testing.T) { + graph := NewGraph() + + // Create 8 stations: s1 through s8 + for i := 0; i < 8; i++ { + graph.AddNode(&Node{ID: fmt.Sprintf("s%d", i+1), Type: NodeTypeStation, Name: fmt.Sprintf("Station %d", i+1), CityCode: "c1"}) + } + + // Add direct edge s1 -> s8 (0 transfers, higher cost) + graph.AddEdge(&Edge{ + From: graph.Nodes()[0], // s1 + To: graph.Nodes()[7], // s8 + Kind: EdgeKindReal, + Duration: 600, // 10 min + Transport: "train", + TransportType: TransportTypeTrain, + IsTransfer: false, + Cost: 500, // expensive direct + }) + + // Add 1-transfer route s1->s3->s8 (lower cost, more time) + graph.AddEdge(&Edge{ + From: graph.Nodes()[0], // s1 + To: graph.Nodes()[2], // s3 + Kind: EdgeKindReal, + Duration: 200, // 3 min + Transport: "train", + TransportType: TransportTypeTrain, + IsTransfer: true, + Cost: 200, + }) + graph.AddEdge(&Edge{ + From: graph.Nodes()[2], // s3 + To: graph.Nodes()[7], // s8 + Kind: EdgeKindReal, + Duration: 300, // 5 min + Transport: "train", + TransportType: TransportTypeTrain, + IsTransfer: true, + Cost: 100, + }) + + // Add 2-transfer route s1->s5->s6->s8 (even lower cost, more transfers) + graph.AddEdge(&Edge{ + From: graph.Nodes()[0], // s1 + To: graph.Nodes()[4], // s5 + Kind: EdgeKindReal, + Duration: 100, // 2 min + Transport: "train", + TransportType: TransportTypeTrain, + IsTransfer: true, + Cost: 100, + }) + graph.AddEdge(&Edge{ + From: graph.Nodes()[4], // s5 + To: graph.Nodes()[5], // s6 + Kind: EdgeKindReal, + Duration: 100, // 2 min + Transport: "train", + TransportType: TransportTypeTrain, + IsTransfer: true, + Cost: 50, + }) + graph.AddEdge(&Edge{ + From: graph.Nodes()[5], // s6 + To: graph.Nodes()[7], // s8 + Kind: EdgeKindReal, + Duration: 200, // 3 min + Transport: "train", + TransportType: TransportTypeTrain, + IsTransfer: true, + Cost: 50, + }) + + t.Run("fastest mode (default) sorts by duration", func(t *testing.T) { + opts := SearchOptions{MaxTransfers: 3} + results := graph.FindRoutesPareto("s1", "s8", opts) + + // Should find at least some Pareto-optimal routes + if len(results) == 0 { + t.Fatal("expected at least one Pareto-optimal route") + } + + // With default "fastest" mode, first route should have smallest duration + if results[0].TotalDuration > results[1].TotalDuration && len(results) > 1 { + t.Logf("Routes (fastest mode):") + for _, r := range results { + t.Logf(" duration=%d, transfers=%d, cost=%d", r.TotalDuration, r.TotalTransfers, r.Cost) + } + } + + // Verify no route is dominated by another in the set + for i, r1 := range results { + for j, r2 := range results { + if i == j { + continue + } + // Check if r2 dominates r1 + if r2.TotalDuration <= r1.TotalDuration && + r2.TotalTransfers <= r1.TotalTransfers && + r2.Cost <= r1.Cost && + (r2.TotalDuration < r1.TotalDuration || + r2.TotalTransfers < r1.TotalTransfers || + r2.Cost < r1.Cost) { + t.Errorf("route %d dominated by route %d: dur=%d/%d/%d vs %d/%d/%d", i, j, r1.TotalDuration, r1.TotalTransfers, r1.Cost, r2.TotalDuration, r2.TotalTransfers, r2.Cost) + } + } + } + }) + + t.Run("fewest_transfers mode sorts by transfers first", func(t *testing.T) { + opts := SearchOptions{MaxTransfers: 3, RankingMode: "fewest_transfers"} + results := graph.FindRoutesPareto("s1", "s8", opts) + + if len(results) == 0 { + t.Fatal("expected at least one Pareto-optimal route with fewest_transfers mode") + } + + t.Logf("Routes (fewest_transfers mode):") + for _, r := range results { + t.Logf(" duration=%d, transfers=%d, cost=%d", r.TotalDuration, r.TotalTransfers, r.Cost) + } + + // Verify no route is dominated + for i, r1 := range results { + for j, r2 := range results { + if i == j { + continue + } + if r2.TotalDuration <= r1.TotalDuration && + r2.TotalTransfers <= r1.TotalTransfers && + r2.Cost <= r1.Cost && + (r2.TotalDuration < r1.TotalDuration || + r2.TotalTransfers < r1.TotalTransfers || + r2.Cost < r1.Cost) { + t.Errorf("route %d dominated by route %d in fewest_transfers mode", i, j) + } + } + } + }) + + t.Run("cheapest mode sorts by cost first", func(t *testing.T) { + opts := SearchOptions{MaxTransfers: 3, RankingMode: "cheapest"} + results := graph.FindRoutesPareto("s1", "s8", opts) + + if len(results) == 0 { + t.Fatal("expected at least one Pareto-optimal route with cheapest mode") + } + + t.Logf("Routes (cheapest mode):") + for _, r := range results { + t.Logf(" duration=%d, transfers=%d, cost=%d", r.TotalDuration, r.TotalTransfers, r.Cost) + } + + // Verify no route is dominated + for i, r1 := range results { + for j, r2 := range results { + if i == j { + continue + } + if r2.TotalDuration <= r1.TotalDuration && + r2.TotalTransfers <= r1.TotalTransfers && + r2.Cost <= r1.Cost && + (r2.TotalDuration < r1.TotalDuration || + r2.TotalTransfers < r1.TotalTransfers || + r2.Cost < r1.Cost) { + t.Errorf("route %d dominated by route %d in cheapest mode", i, j) + } + } + } + }) +} + func TestLazyExpansionDepthLimit(t *testing.T) { graph := NewGraph() @@ -103,8 +275,8 @@ func TestLazyExpansionDepthLimit(t *testing.T) { Kind: EdgeKindReal, Duration: 100, Transport: "train", - TransportType: TransportTypeTrain, - IsTransfer: true, + TransportType: TransportTypeTrain, + IsTransfer: true, }) } @@ -148,4 +320,4 @@ func TestLazyExpansionDepthLimit(t *testing.T) { } else { t.Log("MaxTransfers=0: no direct route s1->s7 found (only chain edges exist)") } -} +} \ No newline at end of file