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