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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user