Files
Sportstime/SportsTime/Planning/Engine/GameDAGRouter.swift
Trey t 9736773475 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>
2026-02-13 08:55:23 -06:00

617 lines
25 KiB
Swift

//
// GameDAGRouter.swift
// SportsTime
//
// DAG-based route finding with multi-dimensional diversity.
//
// 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. Generate routes via beam search
// 4. Diversity pruning: ensure routes span full range of games, cities, miles, and days
//
// The diversity system ensures users see:
// - Short trips (2-3 cities) AND long trips (5+ cities)
// - Quick trips (2-3 games) AND packed trips (5+ games)
// - Low mileage AND high mileage options
// - Short duration AND long duration trips
//
import Foundation
import CoreLocation
/// DAG-based route finder for multi-game trips.
///
/// Finds time-respecting paths through a graph of games with driving feasibility
/// and multi-dimensional diversity selection.
///
/// - Expected Behavior:
/// - Empty games empty result
/// - Single game [[game]] if no anchors or game is anchor
/// - Two games [[sorted]] if transition feasible and anchors satisfied
/// - All routes are chronologically ordered
/// - All routes respect driving constraints
/// - Anchor games MUST appear in all returned routes
/// - allowRepeatCities=false no city appears twice in same route
/// - Returns diverse set spanning games, cities, miles, and duration
///
/// - Invariants:
/// - All returned routes have games in chronological order
/// - All games in a route have feasible transitions between consecutive games
/// - Maximum 75 routes returned (maxOptions)
/// - Routes with anchor games include ALL anchor games
enum GameDAGRouter {
// MARK: - Configuration
/// Default beam width during expansion (for typical datasets)
private static let defaultBeamWidth = 100
/// Maximum options to return (diverse sample)
private static let maxOptions = 75
/// Dynamically scales beam width based on dataset size for performance
private static func effectiveBeamWidth(gameCount: Int, requestedWidth: Int) -> Int {
// For large datasets, reduce beam width to prevent exponential blowup
// Tuned to balance diversity preservation vs computation time
if gameCount >= 5000 {
return min(requestedWidth, 25) // Very aggressive for 5K+ games
} else if gameCount >= 2000 {
return min(requestedWidth, 30) // Aggressive for 2K+ games
} else if gameCount >= 800 {
return min(requestedWidth, 50) // Moderate for 800+ games (includes 1K test)
} else {
return requestedWidth
}
}
/// Buffer time after game ends before we can depart (hours)
private static let gameEndBufferHours: Double = 3.0
// MARK: - Route Profile
/// Captures the key metrics of a route for diversity analysis
private struct RouteProfile {
let route: [Game]
let gameCount: Int
let cityCount: Int
let totalMiles: Double
let tripDays: Int
// Bucket indices for stratified sampling
var gameBucket: Int { min(gameCount - 1, 5) } // 1, 2, 3, 4, 5, 6+
var cityBucket: Int { min(cityCount - 1, 5) } // 1, 2, 3, 4, 5, 6+
var milesBucket: Int {
switch totalMiles {
case ..<500: return 0 // Short
case 500..<1000: return 1 // Medium
case 1000..<2000: return 2 // Long
case 2000..<3000: return 3 // Very long
default: return 4 // Epic
}
}
var daysBucket: Int { min(tripDays - 1, 6) } // 1-7+ days
/// Composite key for exact deduplication
var uniqueKey: String {
route.map { $0.id }.joined(separator: "-")
}
}
// MARK: - Public API
/// Finds routes through the game graph with multi-dimensional diversity.
///
/// Returns a curated sample that spans the full range of:
/// - Number of games (2-game quickies to 6+ game marathons)
/// - Number of cities (2-city to 6+ city routes)
/// - Total miles (short drives to cross-country epics)
/// - Trip duration (weekend getaways to week-long adventures)
///
/// - Parameters:
/// - games: All games to consider
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
/// - constraints: Driving constraints (max hours per day)
/// - anchorGameIds: Games that MUST appear in every valid route
/// - allowRepeatCities: If false, each city can only appear once in a route
/// - beamWidth: How many partial routes to keep during expansion
///
/// - Returns: Array of diverse route options
///
/// - Expected Behavior:
/// - Empty games empty result
/// - Single game with no anchors [[game]]
/// - Single game that is the anchor [[game]]
/// - Single game not matching anchor []
/// - Two feasible games [[game1, game2]] (chronological order)
/// - Two infeasible games (no anchors) [[game1], [game2]] (separate routes)
/// - Two infeasible games (with anchors) [] (can't satisfy)
/// - anchorGameIds must be subset of any returned route
/// - allowRepeatCities=false filters routes with duplicate cities
static func findRoutes(
games: [Game],
stadiums: [String: Stadium],
constraints: DrivingConstraints,
anchorGameIds: Set<String> = [],
allowRepeatCities: Bool = true,
beamWidth: Int = defaultBeamWidth
) -> [[Game]] {
// Edge cases
guard !games.isEmpty else { return [] }
if games.count == 1 {
if anchorGameIds.isEmpty || anchorGameIds.contains(games[0].id) {
return [games]
}
return []
}
if games.count == 2 {
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]
}
}
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 [] }
// Step 2.5: Calculate effective beam width for this dataset size
let scaledBeamWidth = effectiveBeamWidth(gameCount: games.count, requestedWidth: beamWidth)
// 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
for dayIndex in sortedDays.dropFirst() {
// Early termination: if beam has enough diverse routes, stop expanding
// More aggressive for smaller datasets to hit tight performance targets
let earlyTerminationThreshold = games.count >= 5000 ? scaledBeamWidth * 3 : scaledBeamWidth * 2
if beam.count >= earlyTerminationThreshold {
break
}
let todaysGames = buckets[dayIndex] ?? []
var nextBeam: [[Game]] = []
for path in beam {
guard let lastGame = path.last else { continue }
// Try adding each of today's games
for candidate in todaysGames {
// Check for repeat city violation
if !allowRepeatCities {
let candidateCity = stadiums[candidate.stadiumId]?.city ?? ""
let pathCities = Set(path.compactMap { stadiums[$0.stadiumId]?.city })
if pathCities.contains(candidateCity) {
continue
}
}
if canTransition(from: lastGame, to: candidate, stadiums: stadiums, constraints: constraints) {
nextBeam.append(path + [candidate])
}
}
// Keep the path without adding a game today
nextBeam.append(path)
}
// Diversity-aware pruning during expansion
beam = diversityPrune(nextBeam, stadiums: stadiums, targetCount: scaledBeamWidth)
}
// 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: Final diversity selection
let finalRoutes = selectDiverseRoutes(routesWithAnchors, stadiums: stadiums, maxCount: maxOptions)
print("🔍 DAG: Input games=\(games.count), beam=\(beam.count), withAnchors=\(routesWithAnchors.count), final=\(finalRoutes.count)")
return finalRoutes
}
/// Compatibility wrapper that matches GeographicRouteExplorer's interface.
static func findAllSensibleRoutes(
from games: [Game],
stadiums: [String: Stadium],
anchorGameIds: Set<String> = [],
allowRepeatCities: Bool = true,
stopBuilder: ([Game], [String: Stadium]) -> [ItineraryStop]
) -> [[Game]] {
let constraints = DrivingConstraints.default
return findRoutes(
games: games,
stadiums: stadiums,
constraints: constraints,
anchorGameIds: anchorGameIds,
allowRepeatCities: allowRepeatCities
)
}
// MARK: - Multi-Dimensional Diversity Selection
/// Selects routes that maximize diversity across all dimensions.
/// Uses stratified sampling to ensure representation of:
/// - Short trips (2-3 games) AND long trips (5+ games)
/// - Few cities (2-3) AND many cities (5+)
/// - Low mileage AND high mileage
/// - Short duration AND long duration
private static func selectDiverseRoutes(
_ routes: [[Game]],
stadiums: [String: Stadium],
maxCount: Int
) -> [[Game]] {
guard !routes.isEmpty else { return [] }
// Build profiles for all routes
let profiles = routes.map { route in
buildProfile(for: route, stadiums: stadiums)
}
// Remove duplicates
var uniqueProfiles: [RouteProfile] = []
var seenKeys = Set<String>()
for profile in profiles {
if !seenKeys.contains(profile.uniqueKey) {
seenKeys.insert(profile.uniqueKey)
uniqueProfiles.append(profile)
}
}
// Stratified selection: ensure representation across all buckets
var selected: [RouteProfile] = []
var selectedKeys = Set<String>()
// Pass 1: Ensure at least one route per game count bucket (2, 3, 4, 5, 6+)
let byGames = Dictionary(grouping: uniqueProfiles) { $0.gameBucket }
for bucket in byGames.keys.sorted() {
if selected.count >= maxCount { break }
if let candidates = byGames[bucket]?.sorted(by: { $0.totalMiles < $1.totalMiles }) {
if let best = candidates.first, !selectedKeys.contains(best.uniqueKey) {
selected.append(best)
selectedKeys.insert(best.uniqueKey)
}
}
}
// Pass 2: Ensure at least one route per city count bucket (2, 3, 4, 5, 6+)
let byCities = Dictionary(grouping: uniqueProfiles) { $0.cityBucket }
for bucket in byCities.keys.sorted() {
if selected.count >= maxCount { break }
if let candidates = byCities[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) {
if let best = candidates.sorted(by: { $0.totalMiles < $1.totalMiles }).first {
selected.append(best)
selectedKeys.insert(best.uniqueKey)
}
}
}
// Pass 3: Ensure at least one route per mileage bucket
let byMiles = Dictionary(grouping: uniqueProfiles) { $0.milesBucket }
for bucket in byMiles.keys.sorted() {
if selected.count >= maxCount { break }
if let candidates = byMiles[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) {
if let best = candidates.sorted(by: { $0.gameCount > $1.gameCount }).first {
selected.append(best)
selectedKeys.insert(best.uniqueKey)
}
}
}
// Pass 4: Ensure at least one route per duration bucket
let byDays = Dictionary(grouping: uniqueProfiles) { $0.daysBucket }
for bucket in byDays.keys.sorted() {
if selected.count >= maxCount { break }
if let candidates = byDays[bucket]?.filter({ !selectedKeys.contains($0.uniqueKey) }) {
if let best = candidates.sorted(by: { $0.gameCount > $1.gameCount }).first {
selected.append(best)
selectedKeys.insert(best.uniqueKey)
}
}
}
// Pass 5: Fill remaining slots with diverse combinations
// Create composite buckets for more granular diversity
let remaining = uniqueProfiles.filter { !selectedKeys.contains($0.uniqueKey) }
let byComposite = Dictionary(grouping: remaining) { profile in
"\(profile.gameBucket)-\(profile.cityBucket)-\(profile.milesBucket)"
}
// Round-robin from composite buckets
let compositeKeys = Array(byComposite.keys).sorted()
var indices: [String: Int] = [:]
while selected.count < maxCount && !compositeKeys.isEmpty {
var addedAny = false
for key in compositeKeys {
if selected.count >= maxCount { break }
let idx = indices[key] ?? 0
if let candidates = byComposite[key], idx < candidates.count {
let profile = candidates[idx]
if !selectedKeys.contains(profile.uniqueKey) {
selected.append(profile)
selectedKeys.insert(profile.uniqueKey)
addedAny = true
}
indices[key] = idx + 1
}
}
if !addedAny { break }
}
// Pass 6: If still need more, add remaining sorted by efficiency
if selected.count < maxCount {
let stillRemaining = uniqueProfiles
.filter { !selectedKeys.contains($0.uniqueKey) }
.sorted { efficiency(for: $0) > efficiency(for: $1) }
for profile in stillRemaining.prefix(maxCount - selected.count) {
selected.append(profile)
}
}
return selected.map { $0.route }
}
/// Diversity-aware pruning during beam expansion.
/// Keeps routes that span the diversity space rather than just high-scoring ones.
private static func diversityPrune(
_ paths: [[Game]],
stadiums: [String: Stadium],
targetCount: Int
) -> [[Game]] {
// Remove exact duplicates first
var uniquePaths: [[Game]] = []
var seen = Set<String>()
for path in paths {
let key = path.map { $0.id }.joined(separator: "-")
if !seen.contains(key) {
seen.insert(key)
uniquePaths.append(path)
}
}
guard uniquePaths.count > targetCount else { return uniquePaths }
// Build profiles
let profiles = uniquePaths.map { buildProfile(for: $0, stadiums: stadiums) }
// Group by game count to ensure length diversity
let byGames = Dictionary(grouping: profiles) { $0.gameBucket }
let slotsPerBucket = max(2, targetCount / max(1, byGames.count))
var selected: [RouteProfile] = []
var selectedKeys = Set<String>()
// Take from each game count bucket
for bucket in byGames.keys.sorted() {
if let candidates = byGames[bucket] {
// Within bucket, prioritize geographic diversity
let byCities = Dictionary(grouping: candidates) { $0.cityBucket }
var bucketSelected = 0
for cityBucket in byCities.keys.sorted() {
if bucketSelected >= slotsPerBucket { break }
if let cityCandidates = byCities[cityBucket] {
for profile in cityCandidates.prefix(2) {
if !selectedKeys.contains(profile.uniqueKey) {
selected.append(profile)
selectedKeys.insert(profile.uniqueKey)
bucketSelected += 1
if bucketSelected >= slotsPerBucket { break }
}
}
}
}
}
}
// Fill remaining with efficiency-sorted paths
if selected.count < targetCount {
let remaining = profiles.filter { !selectedKeys.contains($0.uniqueKey) }
.sorted { efficiency(for: $0) > efficiency(for: $1) }
for profile in remaining.prefix(targetCount - selected.count) {
selected.append(profile)
}
}
return selected.map { $0.route }
}
/// Builds a profile for a route.
private static func buildProfile(for route: [Game], stadiums: [String: Stadium]) -> RouteProfile {
let gameCount = route.count
let cities = Set(route.compactMap { stadiums[$0.stadiumId]?.city })
let cityCount = cities.count
// Calculate total miles
var totalMiles: Double = 0
for i in 0..<(route.count - 1) {
totalMiles += estimateDistanceMiles(from: route[i], to: route[i + 1], stadiums: stadiums)
}
// Calculate trip duration in days
let tripDays: Int
if let firstGame = route.first, let lastGame = route.last {
let calendar = Calendar.current
let days = calendar.dateComponents([.day], from: firstGame.startTime, to: lastGame.startTime).day ?? 1
tripDays = max(1, days + 1)
} else {
tripDays = 1
}
return RouteProfile(
route: route,
gameCount: gameCount,
cityCount: cityCount,
totalMiles: totalMiles,
tripDays: tripDays
)
}
/// Calculates efficiency score (games per hour of driving).
private static func efficiency(for profile: RouteProfile) -> Double {
let drivingHours = profile.totalMiles / 60.0 // 60 mph average
guard drivingHours > 0 else { return Double(profile.gameCount) * 100 }
return Double(profile.gameCount) / drivingHours
}
// MARK: - Day Bucketing
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
}
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)
return calendar.dateComponents([.day], from: refDay, to: dateDay).day ?? 0
}
// MARK: - Transition Feasibility
private static func canTransition(
from: Game,
to: Game,
stadiums: [String: Stadium],
constraints: DrivingConstraints
) -> Bool {
// Time must move forward
guard to.startTime > from.startTime else { return false }
// Same stadium = always feasible
if from.stadiumId == to.stadiumId { return true }
// Get stadiums
guard let fromStadium = stadiums[from.stadiumId],
let toStadium = stadiums[to.stadiumId] else {
return false
}
// Calculate driving time
let distanceMiles = TravelEstimator.haversineDistanceMiles(
from: CLLocationCoordinate2D(latitude: fromStadium.coordinate.latitude, longitude: fromStadium.coordinate.longitude),
to: CLLocationCoordinate2D(latitude: toStadium.coordinate.latitude, longitude: toStadium.coordinate.longitude)
) * 1.3 // Road routing factor
let drivingHours = distanceMiles / 60.0
// Check if same calendar day
let calendar = Calendar.current
let daysBetween = calendar.dateComponents(
[.day],
from: calendar.startOfDay(for: from.startTime),
to: calendar.startOfDay(for: to.startTime)
).day ?? 0
// Buffer logic:
// - Same stadium same day: 2hr post-game (doubleheader)
// - Different stadiums same day: 2hr post-game (regional day trip)
// - Different days: 3hr post-game (standard multi-day trip)
let postGameBuffer: Double
let preGameBuffer: Double
if daysBetween == 0 {
// Same-day games: use shorter buffers (doubleheader or day trip)
postGameBuffer = 2.0 // Leave 2 hours after first game
preGameBuffer = 0.5 // Arrive 30min before next game
} else {
// Different days: use standard buffers
postGameBuffer = gameEndBufferHours // 3.0 hours
preGameBuffer = 1.0 // 1 hour before game
}
// Calculate available time
let departureTime = from.startTime.addingTimeInterval(postGameBuffer * 3600)
let deadline = to.startTime.addingTimeInterval(-preGameBuffer * 3600)
let availableHours = deadline.timeIntervalSince(departureTime) / 3600.0
// Calculate driving hours available
// For same-day games: enforce both time availability AND daily driving limit
// For multi-day trips: use total available driving hours across days
let maxDrivingHoursAvailable = daysBetween == 0
? min(max(0, availableHours), constraints.maxDailyDrivingHours)
: Double(daysBetween) * constraints.maxDailyDrivingHours
let feasible = drivingHours <= maxDrivingHoursAvailable && drivingHours <= availableHours
// Debug output for rejected transitions
if !feasible && drivingHours > 10 {
print("🔍 DAG canTransition REJECTED: \(fromStadium.city)\(toStadium.city)")
print("🔍 drivingHours=\(String(format: "%.1f", drivingHours)), daysBetween=\(daysBetween), maxAvailable=\(String(format: "%.1f", maxDrivingHoursAvailable)), availableHours=\(String(format: "%.1f", availableHours))")
}
return feasible
}
// MARK: - Distance Estimation
private static func estimateDistanceMiles(
from: Game,
to: Game,
stadiums: [String: Stadium]
) -> Double {
if from.stadiumId == to.stadiumId { return 0 }
guard let fromStadium = stadiums[from.stadiumId],
let toStadium = stadiums[to.stadiumId] else {
return 300 // Fallback estimate
}
return TravelEstimator.haversineDistanceMiles(
from: CLLocationCoordinate2D(latitude: fromStadium.coordinate.latitude, longitude: fromStadium.coordinate.longitude),
to: CLLocationCoordinate2D(latitude: toStadium.coordinate.latitude, longitude: toStadium.coordinate.longitude)
) * 1.3
}
}