// // 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 = [], 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 = [], 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() 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() // 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() 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() // 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 } }