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:
441
SportsTime/Planning/Engine/GameDAGRouter.swift
Normal file
441
SportsTime/Planning/Engine/GameDAGRouter.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -227,6 +227,220 @@ struct DrivingConstraints {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline Item
|
||||
|
||||
/// Unified timeline item for displaying trip itinerary.
|
||||
/// Enables: Stop → Travel → Stop → Rest → Travel → Stop pattern
|
||||
enum TimelineItem: Identifiable {
|
||||
case stop(ItineraryStop)
|
||||
case travel(TravelSegment)
|
||||
case rest(RestDay)
|
||||
|
||||
var id: UUID {
|
||||
switch self {
|
||||
case .stop(let stop): return stop.id
|
||||
case .travel(let segment): return segment.id
|
||||
case .rest(let rest): return rest.id
|
||||
}
|
||||
}
|
||||
|
||||
var date: Date {
|
||||
switch self {
|
||||
case .stop(let stop): return stop.arrivalDate
|
||||
case .travel(let segment): return segment.departureTime
|
||||
case .rest(let rest): return rest.date
|
||||
}
|
||||
}
|
||||
|
||||
var isStop: Bool {
|
||||
if case .stop = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var isTravel: Bool {
|
||||
if case .travel = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var isRest: Bool {
|
||||
if case .rest = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// City/location name for display
|
||||
var locationName: String {
|
||||
switch self {
|
||||
case .stop(let stop): return stop.city
|
||||
case .travel(let segment): return "\(segment.fromLocation.name) → \(segment.toLocation.name)"
|
||||
case .rest(let rest): return rest.location.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rest Day
|
||||
|
||||
/// A rest day - staying in one place with no travel or games.
|
||||
struct RestDay: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
let date: Date
|
||||
let location: LocationInput
|
||||
let notes: String?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
date: Date,
|
||||
location: LocationInput,
|
||||
notes: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.date = date
|
||||
self.location = location
|
||||
self.notes = notes
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
static func == (lhs: RestDay, rhs: RestDay) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline Generation
|
||||
|
||||
extension ItineraryOption {
|
||||
|
||||
/// Generates a unified timeline from stops and travel segments.
|
||||
///
|
||||
/// The timeline interleaves stops and travel in chronological order:
|
||||
/// Stop A → Travel A→B → Stop B → Rest Day → Travel B→C → Stop C
|
||||
///
|
||||
/// Rest days are inserted when:
|
||||
/// - There's a gap between arrival at a stop and departure for next travel
|
||||
/// - Multi-day stays at a location without games
|
||||
///
|
||||
func generateTimeline() -> [TimelineItem] {
|
||||
var timeline: [TimelineItem] = []
|
||||
let calendar = Calendar.current
|
||||
|
||||
for (index, stop) in stops.enumerated() {
|
||||
// Add the stop
|
||||
timeline.append(.stop(stop))
|
||||
|
||||
// Check for rest days at this stop (days between arrival and departure with no games)
|
||||
let restDays = calculateRestDays(at: stop, calendar: calendar)
|
||||
for restDay in restDays {
|
||||
timeline.append(.rest(restDay))
|
||||
}
|
||||
|
||||
// Add travel segment to next stop (if not last stop)
|
||||
if index < travelSegments.count {
|
||||
let segment = travelSegments[index]
|
||||
|
||||
// Check if travel spans multiple days
|
||||
let travelDays = calculateTravelDays(for: segment, calendar: calendar)
|
||||
if travelDays.count > 1 {
|
||||
// Multi-day travel: could split into daily segments or keep as one
|
||||
// For now, keep as single segment with multi-day indicator
|
||||
timeline.append(.travel(segment))
|
||||
} else {
|
||||
timeline.append(.travel(segment))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return timeline
|
||||
}
|
||||
|
||||
/// Calculates rest days at a stop (days with no games).
|
||||
private func calculateRestDays(
|
||||
at stop: ItineraryStop,
|
||||
calendar: Calendar
|
||||
) -> [RestDay] {
|
||||
// If stop has no games, the entire stay could be considered rest
|
||||
// But typically we only insert rest days for multi-day stays
|
||||
|
||||
guard stop.hasGames else {
|
||||
// Start/end locations without games - not rest days, just waypoints
|
||||
return []
|
||||
}
|
||||
|
||||
var restDays: [RestDay] = []
|
||||
|
||||
let arrivalDay = calendar.startOfDay(for: stop.arrivalDate)
|
||||
let departureDay = calendar.startOfDay(for: stop.departureDate)
|
||||
|
||||
// If multi-day stay, check each day for games
|
||||
var currentDay = arrivalDay
|
||||
while currentDay <= departureDay {
|
||||
// Skip arrival and departure days (those have the stop itself)
|
||||
if currentDay != arrivalDay && currentDay != departureDay {
|
||||
// This is a day in between - could be rest or another game day
|
||||
// For simplicity, mark in-between days as rest
|
||||
let restDay = RestDay(
|
||||
date: currentDay,
|
||||
location: stop.location,
|
||||
notes: "Rest day in \(stop.city)"
|
||||
)
|
||||
restDays.append(restDay)
|
||||
}
|
||||
currentDay = calendar.date(byAdding: .day, value: 1, to: currentDay) ?? currentDay
|
||||
}
|
||||
|
||||
return restDays
|
||||
}
|
||||
|
||||
/// Calculates which calendar days a travel segment spans.
|
||||
private func calculateTravelDays(
|
||||
for segment: TravelSegment,
|
||||
calendar: Calendar
|
||||
) -> [Date] {
|
||||
var days: [Date] = []
|
||||
let startDay = calendar.startOfDay(for: segment.departureTime)
|
||||
let endDay = calendar.startOfDay(for: segment.arrivalTime)
|
||||
|
||||
var currentDay = startDay
|
||||
while currentDay <= endDay {
|
||||
days.append(currentDay)
|
||||
currentDay = calendar.date(byAdding: .day, value: 1, to: currentDay) ?? currentDay
|
||||
}
|
||||
|
||||
return days
|
||||
}
|
||||
|
||||
/// Timeline organized by date for calendar-style display.
|
||||
func timelineByDate() -> [Date: [TimelineItem]] {
|
||||
let calendar = Calendar.current
|
||||
var byDate: [Date: [TimelineItem]] = [:]
|
||||
|
||||
for item in generateTimeline() {
|
||||
let day = calendar.startOfDay(for: item.date)
|
||||
byDate[day, default: []].append(item)
|
||||
}
|
||||
|
||||
return byDate
|
||||
}
|
||||
|
||||
/// All dates covered by the itinerary.
|
||||
func allDates() -> [Date] {
|
||||
let calendar = Calendar.current
|
||||
guard let firstStop = stops.first,
|
||||
let lastStop = stops.last else { return [] }
|
||||
|
||||
var dates: [Date] = []
|
||||
var currentDate = calendar.startOfDay(for: firstStop.arrivalDate)
|
||||
let endDate = calendar.startOfDay(for: lastStop.departureDate)
|
||||
|
||||
while currentDate <= endDate {
|
||||
dates.append(currentDate)
|
||||
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate
|
||||
}
|
||||
|
||||
return dates
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Planning Request
|
||||
|
||||
/// Input to the planning engine.
|
||||
|
||||
Reference in New Issue
Block a user