feat: improve planning engine travel handling, itinerary reordering, and scenario planners

Add TravelInfo initializers and city normalization helpers to fix repeat
city-pair disambiguation. Improve drag-and-drop reordering with segment
index tracking and source-row-aware zone calculation. Enhance all five
scenario planners with better next-day departure handling and travel
segment placement. Add comprehensive tests across all planners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-13 08:55:23 -06:00
parent 1c97f35754
commit 9736773475
19 changed files with 928 additions and 171 deletions

View File

@@ -71,9 +71,6 @@ enum GameDAGRouter {
/// Buffer time after game ends before we can depart (hours)
private static let gameEndBufferHours: Double = 3.0
/// Maximum days ahead to consider for next game (1 = next day only, 5 = allows multi-day drives)
private static let maxDayLookahead = 5
// MARK: - Route Profile
/// Captures the key metrics of a route for diversity analysis
@@ -176,14 +173,29 @@ enum GameDAGRouter {
// Step 2.5: Calculate effective beam width for this dataset size
let scaledBeamWidth = effectiveBeamWidth(gameCount: games.count, requestedWidth: beamWidth)
// Step 3: Initialize beam with first few days' games as starting points
var beam: [[Game]] = []
for dayIndex in sortedDays.prefix(maxDayLookahead) {
if let dayGames = buckets[dayIndex] {
for game in dayGames {
beam.append([game])
}
}
// Step 3: Initialize beam from all games so later-starting valid routes
// (including anchor-driven routes) are not dropped up front.
let initialBeam = sortedGames.map { [$0] }
let beamSeedLimit = max(scaledBeamWidth * 2, 50)
let anchorSeeds = initialBeam.filter { path in
guard let game = path.first else { return false }
return anchorGameIds.contains(game.id)
}
let nonAnchorSeeds = initialBeam.filter { path in
guard let game = path.first else { return false }
return !anchorGameIds.contains(game.id)
}
let reservedForAnchors = min(anchorSeeds.count, beamSeedLimit)
let remainingSlots = max(0, beamSeedLimit - reservedForAnchors)
let prunedNonAnchorSeeds = remainingSlots > 0
? diversityPrune(nonAnchorSeeds, stadiums: stadiums, targetCount: remainingSlots)
: []
var beam = Array(anchorSeeds.prefix(reservedForAnchors)) + prunedNonAnchorSeeds
if beam.isEmpty {
beam = diversityPrune(initialBeam, stadiums: stadiums, targetCount: beamSeedLimit)
}
// Step 4: Expand beam day by day with early termination
@@ -200,13 +212,6 @@ enum GameDAGRouter {
for path in beam {
guard let lastGame = path.last else { continue }
let lastGameDay = dayIndexFor(lastGame.startTime, referenceDate: sortedGames[0].startTime)
// Skip if this day is too far ahead for this route
if dayIndex > lastGameDay + maxDayLookahead {
nextBeam.append(path)
continue
}
// Try adding each of today's games
for candidate in todaysGames {