Refactor trip planning: DAG router + trip options UI + simplified itinerary

- Replace O(2^n) GeographicRouteExplorer with O(n) GameDAGRouter using DAG + beam search
- Add geographic diversity to route selection (returns routes from distinct regions)
- Add trip options selector UI (TripOptionsView, TripOptionCard) to choose between routes
- Simplify itinerary display: separate games and travel segments by date
- Remove complex ItineraryDay bundling, query games/travel directly per day
- Update ScenarioA/B/C planners to use GameDAGRouter
- Add new test suites for planners and travel estimator

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-07 12:26:17 -06:00
parent 405ebe68eb
commit ab89c25f2f
20 changed files with 6372 additions and 1960 deletions

View File

@@ -0,0 +1,441 @@
//
// GameDAGRouter.swift
// SportsTime
//
// Time-expanded DAG + Beam Search algorithm for route finding.
//
// Key insight: This is NOT "which subset of N games should I attend?"
// This IS: "what time-respecting paths exist through a graph of games?"
//
// The algorithm:
// 1. Bucket games by calendar day
// 2. Build directed edges where time moves forward AND driving is feasible
// 3. Beam search: keep top K paths at each depth
// 4. Dominance pruning: discard inferior paths
//
// Complexity: O(days × beamWidth × avgNeighbors) 900 operations for 5-day, 78-game scenario
// (vs 2^78 for naive subset enumeration)
//
import Foundation
import CoreLocation
enum GameDAGRouter {
// MARK: - Configuration
/// Default beam width - how many partial routes to keep at each step
private static let defaultBeamWidth = 30
/// Maximum options to return
private static let maxOptions = 10
/// 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, 2 = allows one off-day)
private static let maxDayLookahead = 2
// MARK: - Public API
/// Finds best routes through the game graph using DAG + beam search.
///
/// This replaces the exponential GeographicRouteExplorer with a polynomial-time algorithm.
///
/// - Parameters:
/// - games: All games to consider, in any order (will be sorted internally)
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
/// - constraints: Driving constraints (number of drivers, max hours per day)
/// - anchorGameIds: Games that MUST appear in every valid route (for Scenario B)
/// - beamWidth: How many partial routes to keep at each depth (default 30)
///
/// - Returns: Array of valid game combinations, sorted by score (most games, least driving)
///
static func findRoutes(
games: [Game],
stadiums: [UUID: Stadium],
constraints: DrivingConstraints,
anchorGameIds: Set<UUID> = [],
beamWidth: Int = defaultBeamWidth
) -> [[Game]] {
// Edge cases
guard !games.isEmpty else { return [] }
if games.count == 1 {
// Single game - just return it if it satisfies anchors
if anchorGameIds.isEmpty || anchorGameIds.contains(games[0].id) {
return [games]
}
return []
}
if games.count == 2 {
// Two games - check if both are reachable
let sorted = games.sorted { $0.startTime < $1.startTime }
if canTransition(from: sorted[0], to: sorted[1], stadiums: stadiums, constraints: constraints) {
if anchorGameIds.isSubset(of: Set(sorted.map { $0.id })) {
return [sorted]
}
}
// Can't connect them - return individual games if they satisfy anchors
if anchorGameIds.isEmpty {
return [[sorted[0]], [sorted[1]]]
}
return []
}
// Step 1: Sort games chronologically
let sortedGames = games.sorted { $0.startTime < $1.startTime }
// Step 2: Bucket games by calendar day
let buckets = bucketByDay(games: sortedGames)
let sortedDays = buckets.keys.sorted()
guard !sortedDays.isEmpty else { return [] }
print("[GameDAGRouter] \(games.count) games across \(sortedDays.count) days")
print("[GameDAGRouter] Games per day: \(sortedDays.map { buckets[$0]?.count ?? 0 })")
// Step 3: Initialize beam with first day's games
var beam: [[Game]] = []
if let firstDayGames = buckets[sortedDays[0]] {
for game in firstDayGames {
beam.append([game])
}
}
// Also include option to skip first day entirely and start later
// (handled by having multiple starting points in beam)
for dayIndex in sortedDays.dropFirst().prefix(maxDayLookahead - 1) {
if let dayGames = buckets[dayIndex] {
for game in dayGames {
beam.append([game])
}
}
}
print("[GameDAGRouter] Initial beam size: \(beam.count)")
// Step 4: Expand beam day by day
for (index, dayIndex) in sortedDays.dropFirst().enumerated() {
let todaysGames = buckets[dayIndex] ?? []
var nextBeam: [[Game]] = []
for path in beam {
guard let lastGame = path.last else { continue }
let lastGameDay = dayIndexFor(lastGame.startTime, referenceDate: sortedGames[0].startTime)
// Only consider games on this day or within lookahead
if dayIndex > lastGameDay + maxDayLookahead {
// This path is too far behind, keep it as-is
nextBeam.append(path)
continue
}
var addedAny = false
// Try adding each of today's games
for candidate in todaysGames {
if canTransition(from: lastGame, to: candidate, stadiums: stadiums, constraints: constraints) {
let newPath = path + [candidate]
nextBeam.append(newPath)
addedAny = true
}
}
// Also keep the path without adding a game today (allows off-days)
nextBeam.append(path)
}
// Dominance pruning + beam truncation
beam = pruneAndTruncate(nextBeam, beamWidth: beamWidth, stadiums: stadiums)
print("[GameDAGRouter] Day \(dayIndex): nextBeam=\(nextBeam.count), after prune=\(beam.count), max games=\(beam.map { $0.count }.max() ?? 0)")
}
// Step 5: Filter routes that contain all anchors
let routesWithAnchors = beam.filter { path in
let pathGameIds = Set(path.map { $0.id })
return anchorGameIds.isSubset(of: pathGameIds)
}
// Step 6: Ensure geographic diversity in results
// Group routes by their primary region (city with most games)
// Then pick the best route from each region
let diverseRoutes = selectDiverseRoutes(routesWithAnchors, stadiums: stadiums, maxCount: maxOptions)
print("[GameDAGRouter] Found \(routesWithAnchors.count) routes with anchors, returning \(diverseRoutes.count) diverse routes")
for (i, route) in diverseRoutes.prefix(5).enumerated() {
let cities = route.compactMap { stadiums[$0.stadiumId]?.city }.joined(separator: "")
print("[GameDAGRouter] Route \(i+1): \(route.count) games - \(cities)")
}
return diverseRoutes
}
/// Compatibility wrapper that matches GeographicRouteExplorer's interface.
/// This allows drop-in replacement in ScenarioAPlanner and ScenarioBPlanner.
static func findAllSensibleRoutes(
from games: [Game],
stadiums: [UUID: Stadium],
anchorGameIds: Set<UUID> = [],
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
) -> [[Game]] {
// Use default driving constraints
let constraints = DrivingConstraints.default
return findRoutes(
games: games,
stadiums: stadiums,
constraints: constraints,
anchorGameIds: anchorGameIds
)
}
// MARK: - Day Bucketing
/// Groups games by calendar day index (0 = first day of trip, 1 = second day, etc.)
private static func bucketByDay(games: [Game]) -> [Int: [Game]] {
guard let firstGame = games.first else { return [:] }
let referenceDate = firstGame.startTime
var buckets: [Int: [Game]] = [:]
for game in games {
let dayIndex = dayIndexFor(game.startTime, referenceDate: referenceDate)
buckets[dayIndex, default: []].append(game)
}
return buckets
}
/// Calculates the day index for a date relative to a reference date.
private static func dayIndexFor(_ date: Date, referenceDate: Date) -> Int {
let calendar = Calendar.current
let refDay = calendar.startOfDay(for: referenceDate)
let dateDay = calendar.startOfDay(for: date)
let components = calendar.dateComponents([.day], from: refDay, to: dateDay)
return components.day ?? 0
}
// MARK: - Transition Feasibility
/// Determines if we can travel from game A to game B.
///
/// Requirements:
/// 1. B starts after A (time moves forward)
/// 2. Driving time is within daily limit
/// 3. We can arrive at B before B starts
///
private static func canTransition(
from: Game,
to: Game,
stadiums: [UUID: Stadium],
constraints: DrivingConstraints
) -> Bool {
// Time must move forward
guard to.startTime > from.startTime else { return false }
// Same stadium = always feasible (no driving needed)
if from.stadiumId == to.stadiumId { return true }
// Get stadiums
guard let fromStadium = stadiums[from.stadiumId],
let toStadium = stadiums[to.stadiumId] else {
// Missing stadium info - use generous fallback
// Assume 300 miles at 60 mph = 5 hours, which is usually feasible
return true
}
let fromCoord = fromStadium.coordinate
let toCoord = toStadium.coordinate
// Calculate driving time
let distanceMiles = TravelEstimator.haversineDistanceMiles(
from: CLLocationCoordinate2D(latitude: fromCoord.latitude, longitude: fromCoord.longitude),
to: CLLocationCoordinate2D(latitude: toCoord.latitude, longitude: toCoord.longitude)
) * 1.3 // Road routing factor
let drivingHours = distanceMiles / 60.0 // Average 60 mph
// Must be within daily limit
guard drivingHours <= constraints.maxDailyDrivingHours else { return false }
// Calculate if we can arrive in time
let departureTime = from.startTime.addingTimeInterval(gameEndBufferHours * 3600)
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
// Must arrive before game starts (with 1 hour buffer)
let deadline = to.startTime.addingTimeInterval(-3600)
guard arrivalTime <= deadline else { return false }
return true
}
// MARK: - Geographic Diversity
/// Selects geographically diverse routes from the candidate set.
/// Groups routes by their primary city (where most games are) and picks the best from each region.
private static func selectDiverseRoutes(
_ routes: [[Game]],
stadiums: [UUID: Stadium],
maxCount: Int
) -> [[Game]] {
guard !routes.isEmpty else { return [] }
// Group routes by primary city (the city with the most games in the route)
var routesByRegion: [String: [[Game]]] = [:]
for route in routes {
let primaryCity = getPrimaryCity(for: route, stadiums: stadiums)
routesByRegion[primaryCity, default: []].append(route)
}
// Sort routes within each region by score (best first)
for (region, regionRoutes) in routesByRegion {
routesByRegion[region] = regionRoutes.sorted {
scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums)
}
}
// Sort regions by their best route's score (so best regions come first)
let sortedRegions = routesByRegion.keys.sorted { region1, region2 in
let score1 = routesByRegion[region1]?.first.map { scorePath($0, stadiums: stadiums) } ?? 0
let score2 = routesByRegion[region2]?.first.map { scorePath($0, stadiums: stadiums) } ?? 0
return score1 > score2
}
print("[GameDAGRouter] Found \(sortedRegions.count) distinct regions: \(sortedRegions.prefix(10).joined(separator: ", "))")
// Pick routes round-robin from each region to ensure diversity
var selectedRoutes: [[Game]] = []
var regionIndices: [String: Int] = [:]
// First pass: get best route from each region
for region in sortedRegions {
if selectedRoutes.count >= maxCount { break }
if let regionRoutes = routesByRegion[region], !regionRoutes.isEmpty {
selectedRoutes.append(regionRoutes[0])
regionIndices[region] = 1
}
}
// Second pass: fill remaining slots with next-best routes from top regions
var round = 1
while selectedRoutes.count < maxCount {
var addedAny = false
for region in sortedRegions {
if selectedRoutes.count >= maxCount { break }
let idx = regionIndices[region] ?? 0
if let regionRoutes = routesByRegion[region], idx < regionRoutes.count {
selectedRoutes.append(regionRoutes[idx])
regionIndices[region] = idx + 1
addedAny = true
}
}
if !addedAny { break }
round += 1
if round > 5 { break } // Safety limit
}
return selectedRoutes
}
/// Gets the primary city for a route (where most games are played).
private static func getPrimaryCity(for route: [Game], stadiums: [UUID: Stadium]) -> String {
var cityCounts: [String: Int] = [:]
for game in route {
let city = stadiums[game.stadiumId]?.city ?? "Unknown"
cityCounts[city, default: 0] += 1
}
return cityCounts.max(by: { $0.value < $1.value })?.key ?? "Unknown"
}
// MARK: - Scoring and Pruning
/// Scores a path. Higher = better.
/// Prefers: more games, less driving, geographic coherence
private static func scorePath(_ path: [Game], stadiums: [UUID: Stadium]) -> Double {
let gameCount = Double(path.count)
// Calculate total driving
var totalDriving: Double = 0
for i in 0..<(path.count - 1) {
totalDriving += estimateDrivingHours(from: path[i], to: path[i + 1], stadiums: stadiums)
}
// Score: heavily weight game count, penalize driving
return gameCount * 100.0 - totalDriving * 2.0
}
/// Estimates driving hours between two games.
private static func estimateDrivingHours(
from: Game,
to: Game,
stadiums: [UUID: Stadium]
) -> Double {
// Same stadium = 0 driving
if from.stadiumId == to.stadiumId { return 0 }
guard let fromStadium = stadiums[from.stadiumId],
let toStadium = stadiums[to.stadiumId] else {
return 5.0 // Fallback: assume 5 hours
}
let fromCoord = fromStadium.coordinate
let toCoord = toStadium.coordinate
let distanceMiles = TravelEstimator.haversineDistanceMiles(
from: CLLocationCoordinate2D(latitude: fromCoord.latitude, longitude: fromCoord.longitude),
to: CLLocationCoordinate2D(latitude: toCoord.latitude, longitude: toCoord.longitude)
) * 1.3
return distanceMiles / 60.0
}
/// Prunes dominated paths and truncates to beam width.
private static func pruneAndTruncate(
_ paths: [[Game]],
beamWidth: Int,
stadiums: [UUID: Stadium]
) -> [[Game]] {
// Remove exact duplicates
var uniquePaths: [[Game]] = []
var seen = Set<String>()
for path in paths {
let key = path.map { $0.id.uuidString }.joined(separator: "-")
if !seen.contains(key) {
seen.insert(key)
uniquePaths.append(path)
}
}
// Sort by score (best first)
let sorted = uniquePaths.sorted { scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums) }
// Dominance pruning: within same ending city, keep only best paths
var pruned: [[Game]] = []
var bestByEndCity: [String: Double] = [:]
for path in sorted {
guard let lastGame = path.last else { continue }
let endCity = stadiums[lastGame.stadiumId]?.city ?? "Unknown"
let score = scorePath(path, stadiums: stadiums)
// Keep if this is the best path ending in this city, or if score is within 20% of best
if let bestScore = bestByEndCity[endCity] {
if score >= bestScore * 0.8 {
pruned.append(path)
}
} else {
bestByEndCity[endCity] = score
pruned.append(path)
}
// Stop if we have enough
if pruned.count >= beamWidth * 2 {
break
}
}
// Final truncation
return Array(pruned.prefix(beamWidth))
}
}

View File

@@ -1,278 +0,0 @@
//
// GeographicRouteExplorer.swift
// SportsTime
//
// Shared logic for finding geographically sensible route variations.
// Used by all scenario planners to explore and prune route combinations.
//
// Key Features:
// - Tree exploration with pruning for route combinations
// - Geographic sanity check using bounding box diagonal vs actual travel ratio
// - Support for "anchor" games that cannot be removed from routes (Scenario B)
//
// Algorithm Overview:
// Given games [A, B, C, D, E] in chronological order, we build a decision tree
// where at each node we can either include or skip a game. Routes that would
// create excessive zig-zagging are pruned. When anchors are specified, any
// route that doesn't include ALL anchors is automatically discarded.
//
import Foundation
import CoreLocation
enum GeographicRouteExplorer {
// MARK: - Configuration
/// Maximum ratio of actual travel to bounding box diagonal.
/// Routes exceeding this are considered zig-zags.
/// - 1.0x = perfectly linear route
/// - 1.5x = some detours, normal
/// - 2.0x = significant detours, borderline
/// - 2.5x+ = excessive zig-zag, reject
private static let maxZigZagRatio = 2.5
/// Minimum bounding box diagonal (miles) to apply zig-zag check.
/// Routes within a small area are always considered sane.
private static let minDiagonalForCheck = 100.0
/// Maximum number of route options to return.
private static let maxOptions = 10
// MARK: - Public API
/// Finds ALL geographically sensible subsets of games.
///
/// The problem: Games in a date range might be scattered across the country.
/// Visiting all of them in chronological order could mean crazy zig-zags.
///
/// The solution: Explore all possible subsets, keeping those that pass
/// geographic sanity. Return multiple options for the user to choose from.
///
/// Algorithm (tree exploration with pruning):
///
/// Input: [NY, TX, SC, DEN, NM, CA] (chronological order)
///
/// Build a decision tree:
/// [NY]
/// / \
/// +TX / \ skip TX
/// / \
/// [NY,TX] [NY]
/// / \ / \
/// +SC / \ +SC / \
/// | | |
/// (prune) +DEN [NY,SC] ...
///
/// Each path that reaches the end = one valid option
/// Pruning: If adding a game breaks sanity, don't explore that branch
///
/// - Parameters:
/// - games: All games to consider, should be in chronological order
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
/// - anchorGameIds: Game IDs that MUST be included in every route (for Scenario B)
/// - stopBuilder: Closure that converts games to ItineraryStops
///
/// - Returns: Array of valid game combinations, sorted by number of games (most first)
///
static func findAllSensibleRoutes(
from games: [Game],
stadiums: [UUID: Stadium],
anchorGameIds: Set<UUID> = [],
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
) -> [[Game]] {
// 0-2 games = always sensible, only one option
// But still verify anchors are present
guard games.count > 2 else {
// Verify all anchors are in the game list
let gameIds = Set(games.map { $0.id })
if anchorGameIds.isSubset(of: gameIds) {
return games.isEmpty ? [] : [games]
} else {
// Missing anchors - no valid routes
return []
}
}
// First, check if all games already form a sensible route
let allStops = stopBuilder(games, stadiums)
if isGeographicallySane(stops: allStops) {
print("[GeographicExplorer] All \(games.count) games form a sensible route")
return [games]
}
print("[GeographicExplorer] Exploring route variations (anchors: \(anchorGameIds.count))...")
// Explore all valid subsets using recursive tree traversal
var validRoutes: [[Game]] = []
exploreRoutes(
games: games,
stadiums: stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: stopBuilder,
currentRoute: [],
index: 0,
validRoutes: &validRoutes
)
// Filter routes that don't contain all anchors
let routesWithAnchors = validRoutes.filter { route in
let routeGameIds = Set(route.map { $0.id })
return anchorGameIds.isSubset(of: routeGameIds)
}
// Sort by number of games (most games first = best options)
let sorted = routesWithAnchors.sorted { $0.count > $1.count }
// Limit to top options to avoid overwhelming the user
let topRoutes = Array(sorted.prefix(maxOptions))
print("[GeographicExplorer] Found \(routesWithAnchors.count) valid routes with anchors, returning top \(topRoutes.count)")
return topRoutes
}
// MARK: - Geographic Sanity Check
/// Determines if a route is geographically sensible or zig-zags excessively.
///
/// The goal: Reject routes that oscillate back and forth across large distances.
/// We want routes that make generally linear progress, not cross-country ping-pong.
///
/// Algorithm:
/// 1. Calculate the "bounding box" of all stops (geographic spread)
/// 2. Calculate total travel distance if we visit stops in order
/// 3. Compare actual travel to the bounding box diagonal
/// 4. If actual travel is WAY more than the diagonal, it's zig-zagging
///
/// Example VALID:
/// Stops: LA, SF, Portland, Seattle
/// Bounding box diagonal: ~1,100 miles
/// Actual travel: ~1,200 miles (reasonable, mostly linear)
/// Ratio: 1.1x PASS
///
/// Example INVALID:
/// Stops: NY, TX, SC, CA (zig-zag)
/// Bounding box diagonal: ~2,500 miles
/// Actual travel: ~6,000 miles (back and forth)
/// Ratio: 2.4x FAIL
///
static func isGeographicallySane(stops: [ItineraryStop]) -> Bool {
// Single stop or two stops = always valid (no zig-zag possible)
guard stops.count > 2 else { return true }
// Collect all coordinates
let coordinates = stops.compactMap { $0.coordinate }
guard coordinates.count == stops.count else {
// Missing coordinates - can't validate, assume valid
return true
}
// Calculate bounding box
let lats = coordinates.map { $0.latitude }
let lons = coordinates.map { $0.longitude }
guard let minLat = lats.min(), let maxLat = lats.max(),
let minLon = lons.min(), let maxLon = lons.max() else {
return true
}
// Calculate bounding box diagonal distance
let corner1 = CLLocationCoordinate2D(latitude: minLat, longitude: minLon)
let corner2 = CLLocationCoordinate2D(latitude: maxLat, longitude: maxLon)
let diagonalMiles = TravelEstimator.haversineDistanceMiles(from: corner1, to: corner2)
// Tiny bounding box = all games are close together = always valid
if diagonalMiles < minDiagonalForCheck {
return true
}
// Calculate actual travel distance through all stops in order
var actualTravelMiles: Double = 0
for i in 0..<(stops.count - 1) {
let from = stops[i]
let to = stops[i + 1]
actualTravelMiles += TravelEstimator.calculateDistanceMiles(from: from, to: to)
}
// Compare: is actual travel reasonable compared to the geographic spread?
let ratio = actualTravelMiles / diagonalMiles
if ratio > maxZigZagRatio {
print("[GeographicExplorer] Sanity FAILED: travel=\(Int(actualTravelMiles))mi, diagonal=\(Int(diagonalMiles))mi, ratio=\(String(format: "%.1f", ratio))x")
return false
}
return true
}
// MARK: - Private Helpers
/// Recursive helper to explore all valid route combinations.
///
/// At each game, we have two choices:
/// 1. Include the game (if it doesn't break sanity)
/// 2. Skip the game (only if it's not an anchor)
///
/// We explore BOTH branches when possible, building up all valid combinations.
/// Anchor games MUST be included - we cannot skip them.
///
private static func exploreRoutes(
games: [Game],
stadiums: [UUID: Stadium],
anchorGameIds: Set<UUID>,
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop],
currentRoute: [Game],
index: Int,
validRoutes: inout [[Game]]
) {
// Base case: we've processed all games
if index >= games.count {
// Only save routes with at least 1 game
if !currentRoute.isEmpty {
validRoutes.append(currentRoute)
}
return
}
let game = games[index]
let isAnchor = anchorGameIds.contains(game.id)
// Option 1: Try INCLUDING this game
var routeWithGame = currentRoute
routeWithGame.append(game)
let stopsWithGame = stopBuilder(routeWithGame, stadiums)
if isGeographicallySane(stops: stopsWithGame) {
// This branch is valid, continue exploring
exploreRoutes(
games: games,
stadiums: stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: stopBuilder,
currentRoute: routeWithGame,
index: index + 1,
validRoutes: &validRoutes
)
} else if isAnchor {
// Anchor game breaks sanity - this entire branch is invalid
// Don't explore further, don't add to valid routes
// (We can't skip an anchor, and including it breaks sanity)
return
}
// Option 2: Try SKIPPING this game (only if it's not an anchor)
if !isAnchor {
exploreRoutes(
games: games,
stadiums: stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: stopBuilder,
currentRoute: currentRoute,
index: index + 1,
validRoutes: &validRoutes
)
}
}
}

View File

@@ -68,6 +68,9 @@ final class ScenarioAPlanner: ScenarioPlanner {
.filter { dateRange.contains($0.startTime) }
.sorted { $0.startTime < $1.startTime }
print("[ScenarioA] Found \(gamesInRange.count) games in date range")
print("[ScenarioA] Stadiums available: \(request.stadiums.count)")
// No games? Nothing to plan.
if gamesInRange.isEmpty {
return .failure(
@@ -89,14 +92,19 @@ final class ScenarioAPlanner: ScenarioPlanner {
// - etc.
//
// We explore ALL valid combinations and return multiple options.
// Uses shared GeographicRouteExplorer for tree exploration.
// Uses GameDAGRouter for polynomial-time beam search.
//
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
stopBuilder: buildStops
)
print("[ScenarioA] GameDAGRouter returned \(validRoutes.count) routes")
if !validRoutes.isEmpty {
print("[ScenarioA] Route sizes: \(validRoutes.map { $0.count })")
}
if validRoutes.isEmpty {
return .failure(
PlanningFailure(
@@ -120,10 +128,22 @@ final class ScenarioAPlanner: ScenarioPlanner {
//
var itineraryOptions: [ItineraryOption] = []
var routesAttempted = 0
var routesFailed = 0
for (index, routeGames) in validRoutes.enumerated() {
routesAttempted += 1
// Build stops for this route
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
guard !stops.isEmpty else { continue }
guard !stops.isEmpty else {
print("[ScenarioA] Route \(index + 1) produced no stops, skipping")
routesFailed += 1
continue
}
// Log stop details
let stopCities = stops.map { "\($0.city) (coord: \($0.coordinate != nil))" }
print("[ScenarioA] Route \(index + 1): \(stops.count) stops - \(stopCities.joined(separator: ""))")
// Calculate travel segments using shared ItineraryBuilder
guard let itinerary = ItineraryBuilder.build(
@@ -133,6 +153,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
) else {
// This route fails driving constraints, skip it
print("[ScenarioA] Route \(index + 1) failed driving constraints, skipping")
routesFailed += 1
continue
}
@@ -155,6 +176,8 @@ final class ScenarioAPlanner: ScenarioPlanner {
// If no routes passed all constraints, fail.
// Otherwise, return all valid options for the user to choose from.
//
print("[ScenarioA] Routes attempted: \(routesAttempted), failed: \(routesFailed), succeeded: \(itineraryOptions.count)")
if itineraryOptions.isEmpty {
return .failure(
PlanningFailure(

View File

@@ -103,7 +103,8 @@ final class ScenarioBPlanner: ScenarioPlanner {
guard selectedInRange else { continue }
// Find all sensible routes that include the anchor games
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
// Uses GameDAGRouter for polynomial-time beam search
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
anchorGameIds: anchorGameIds,

View File

@@ -194,8 +194,8 @@ final class ScenarioCPlanner: ScenarioPlanner {
guard !gamesInRange.isEmpty else { continue }
// Use GeographicRouteExplorer to find sensible routes
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
// Use GameDAGRouter for polynomial-time beam search
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
anchorGameIds: [], // No anchors in Scenario C

View File

@@ -167,7 +167,7 @@ enum TravelEstimator {
days.append(startDay)
// Add days if driving takes multiple days (8 hrs/day max)
let daysOfDriving = Int(ceil(drivingHours / 8.0))
let daysOfDriving = max(1, Int(ceil(drivingHours / 8.0)))
for dayOffset in 1..<daysOfDriving {
if let nextDay = calendar.date(byAdding: .day, value: dayOffset, to: startDay) {
days.append(nextDay)