Files
Sportstime/SportsTime/Planning/Engine/ScenarioCPlanner.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

645 lines
25 KiB
Swift

//
// ScenarioCPlanner.swift
// SportsTime
//
// Scenario C: Start + End location planning.
// User specifies starting city and ending city. We find games along the route.
//
// Key Features:
// - Start/End are cities with stadiums from user's selected sports
// - Directional filtering: stadiums that "generally move toward" the end
// - When only day span provided (no dates): generate date ranges from games at start/end
// - Uses GeographicRouteExplorer for sensible route exploration
// - Returns top 5 options with most games
//
// Date Range Generation (when only day span provided):
// 1. Find all games at start city's stadiums
// 2. Find all games at end city's stadiums
// 3. For each start game + end game combo within day span: create a date range
// 4. Explore routes for each date range
//
// Directional Filtering:
// - Find stadiums that make forward progress from start to end
// - "Forward progress" = distance to end decreases (with tolerance)
// - Filter games to only those at directional stadiums
//
// Example:
// Start: Chicago, End: New York, Day span: 7 days
// Start game: Jan 5 at Chicago
// End game: Jan 10 at New York
// Date range: Jan 5-10
// Directional stadiums: Detroit, Cleveland, Pittsburgh (moving east)
// NOT directional: Minneapolis, St. Louis (moving away from NY)
//
import Foundation
import CoreLocation
/// Scenario C: Directional route planning from start city to end city
/// Input: start_location, end_location, day_span (or date_range)
/// Output: Top 5 itinerary options with games along the directional route
///
/// - Expected Behavior:
/// - No start location returns .failure with .missingLocations
/// - No end location returns .failure with .missingLocations
/// - Missing coordinates returns .failure with .missingLocations
/// - No stadiums in start city returns .failure with .noGamesInRange
/// - No stadiums in end city returns .failure with .noGamesInRange
/// - No valid date ranges returns .failure with .missingDateRange
/// - Directional filtering: only stadiums making forward progress included
/// - Monotonic progress validation: route cannot backtrack significantly
/// - Start and end locations added as non-game stops
/// - No valid routes .failure with .noValidRoutes
/// - Success returns sorted itineraries based on leisureLevel
///
/// - Invariants:
/// - Start stop has no games and appears first
/// - End stop has no games and appears last
/// - All game stops are between start and end
/// - Forward progress tolerance is 15%
/// - Max detour distance is 1.5x direct distance
///
final class ScenarioCPlanner: ScenarioPlanner {
// MARK: - Configuration
/// Tolerance for "forward progress" - allow small increases in distance to end
/// A stadium is "directional" if it doesn't increase distance to end by more than this ratio
private let forwardProgressTolerance = 0.15 // 15% tolerance
// MARK: - ScenarioPlanner Protocol
func plan(request: PlanningRequest) -> ItineraryResult {
//
// Step 1: Validate start and end locations
//
guard let startLocation = request.startLocation else {
return .failure(
PlanningFailure(
reason: .missingLocations,
violations: [
ConstraintViolation(
type: .general,
description: "Start location is required for Scenario C",
severity: .error
)
]
)
)
}
guard let endLocation = request.endLocation else {
return .failure(
PlanningFailure(
reason: .missingLocations,
violations: [
ConstraintViolation(
type: .general,
description: "End location is required for Scenario C",
severity: .error
)
]
)
)
}
guard let startCoord = startLocation.coordinate,
let endCoord = endLocation.coordinate else {
return .failure(
PlanningFailure(
reason: .missingLocations,
violations: [
ConstraintViolation(
type: .general,
description: "Start and end locations must have coordinates",
severity: .error
)
]
)
)
}
//
// Step 2: Find stadiums at start and end cities
//
let startStadiums = findStadiumsInCity(
cityName: startLocation.name,
stadiums: request.stadiums
)
let endStadiums = findStadiumsInCity(
cityName: endLocation.name,
stadiums: request.stadiums
)
if startStadiums.isEmpty {
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: [
ConstraintViolation(
type: .general,
description: "No stadiums found in start city: \(startLocation.name)",
severity: .error
)
]
)
)
}
if endStadiums.isEmpty {
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: [
ConstraintViolation(
type: .general,
description: "No stadiums found in end city: \(endLocation.name)",
severity: .error
)
]
)
)
}
//
// Step 3: Generate date ranges
//
let dateRanges = generateDateRanges(
startStadiumIds: Set(startStadiums.map { $0.id }),
endStadiumIds: Set(endStadiums.map { $0.id }),
allGames: request.allGames,
request: request
)
if dateRanges.isEmpty {
return .failure(
PlanningFailure(
reason: .missingDateRange,
violations: [
ConstraintViolation(
type: .dateRange,
description: "No valid date ranges found with games at both start and end cities",
severity: .error
)
]
)
)
}
//
// Step 4: Find directional stadiums (moving from start toward end)
//
let directionalStadiums = findDirectionalStadiums(
from: startCoord,
to: endCoord,
stadiums: request.stadiums
)
//
// Step 5: For each date range, explore routes
//
var allItineraryOptions: [ItineraryOption] = []
for dateRange in dateRanges {
// Find games at directional stadiums within date range
let gamesInRange = request.allGames
.filter { dateRange.contains($0.startTime) }
.filter { directionalStadiums.contains($0.stadiumId) }
.sorted { $0.startTime < $1.startTime }
guard !gamesInRange.isEmpty else { continue }
// Use GameDAGRouter for polynomial-time beam search
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
anchorGameIds: [], // No anchors in Scenario C
stopBuilder: buildStops
)
// Build itineraries for each valid route
for routeGames in validRoutes {
let stops = buildStopsWithEndpoints(
start: startLocation,
end: endLocation,
games: routeGames,
stadiums: request.stadiums
)
guard !stops.isEmpty else { continue }
// Validate monotonic progress toward end
guard validateMonotonicProgress(
stops: stops,
toward: endCoord
) else {
continue
}
// Use shared ItineraryBuilder
guard let itinerary = ItineraryBuilder.build(
stops: stops,
constraints: request.drivingConstraints,
logPrefix: "[ScenarioC]"
) else {
continue
}
let gameCount = routeGames.count
let cities = stops.compactMap { $0.games.isEmpty ? nil : $0.city }.joined(separator: "")
let option = ItineraryOption(
rank: 0, // Will re-rank later
stops: itinerary.stops,
travelSegments: itinerary.travelSegments,
totalDrivingHours: itinerary.totalDrivingHours,
totalDistanceMiles: itinerary.totalDistanceMiles,
geographicRationale: "\(startLocation.name)\(gameCount) games → \(endLocation.name): \(cities)"
)
allItineraryOptions.append(option)
}
}
//
// Step 6: Return top 5 ranked results
//
if allItineraryOptions.isEmpty {
return .failure(
PlanningFailure(
reason: .noValidRoutes,
violations: [
ConstraintViolation(
type: .geographicSanity,
description: "No valid directional routes found from \(startLocation.name) to \(endLocation.name)",
severity: .error
)
]
)
)
}
// Sort and rank based on leisure level
let leisureLevel = request.preferences.leisureLevel
let rankedOptions = ItineraryOption.sortByLeisure(
allItineraryOptions,
leisureLevel: leisureLevel
)
return .success(Array(rankedOptions))
}
// MARK: - Stadium Finding
/// Finds all stadiums in a given city (case-insensitive match).
private func findStadiumsInCity(
cityName: String,
stadiums: [String: Stadium]
) -> [Stadium] {
let normalizedCity = normalizeCityName(cityName)
return stadiums.values.filter { stadium in
let normalizedStadiumCity = normalizeCityName(stadium.city)
if normalizedStadiumCity == normalizedCity { return true }
return normalizedStadiumCity.contains(normalizedCity) || normalizedCity.contains(normalizedStadiumCity)
}
}
/// Normalizes city labels for resilient user-input matching.
private func normalizeCityName(_ raw: String) -> String {
// Keep the city component before state suffixes like "City, ST".
let cityPart = raw.split(separator: ",", maxSplits: 1).first.map(String.init) ?? raw
var normalized = cityPart
.lowercased()
.replacingOccurrences(of: ".", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let aliases: [String: String] = [
"nyc": "new york",
"new york city": "new york",
"la": "los angeles",
"sf": "san francisco",
"dc": "washington",
"washington dc": "washington"
]
if let aliased = aliases[normalized] {
normalized = aliased
}
// Collapse repeated spaces after punctuation/alias normalization.
return normalized
.split(whereSeparator: \.isWhitespace)
.joined(separator: " ")
}
/// Finds stadiums that make forward progress from start to end.
///
/// A stadium is "directional" if visiting it doesn't significantly increase
/// the distance to the end point. This filters out stadiums that would
/// require backtracking.
///
/// Algorithm:
/// 1. Calculate distance from start to end
/// 2. For each stadium, calculate: distance(start, stadium) + distance(stadium, end)
/// 3. If this "detour distance" is reasonable (within tolerance), include it
///
/// The tolerance allows for stadiums slightly off the direct path.
///
private func findDirectionalStadiums(
from start: CLLocationCoordinate2D,
to end: CLLocationCoordinate2D,
stadiums: [String: Stadium]
) -> Set<String> {
let directDistance = distanceBetween(start, end)
// Allow detours up to 50% longer than direct distance
let maxDetourDistance = directDistance * 1.5
var directionalIds: Set<String> = []
for (id, stadium) in stadiums {
let stadiumCoord = stadium.coordinate
// Calculate the detour: start stadium end
let toStadium = distanceBetween(start, stadiumCoord)
let fromStadium = distanceBetween(stadiumCoord, end)
let detourDistance = toStadium + fromStadium
// Also check that stadium is making progress (closer to end than start is)
let distanceToEnd = distanceBetween(stadiumCoord, end)
// Stadium should be within the "cone" from start to end
// Either closer to end than start, or the detour is acceptable
if detourDistance <= maxDetourDistance {
// Additional check: don't include if it's behind the start point
// (i.e., distance to end is greater than original distance)
if distanceToEnd <= directDistance * (1 + forwardProgressTolerance) {
// Final check: exclude stadiums that are beyond the end point
// A stadium is beyond the end if it's farther from start than end is
if toStadium <= directDistance * (1 + forwardProgressTolerance) {
directionalIds.insert(id)
}
}
}
}
return directionalIds
}
// MARK: - Date Range Generation
/// Generates date ranges for Scenario C.
///
/// Two modes:
/// 1. Explicit date range provided: Use it directly
/// 2. Only day span provided: Find game combinations at start/end cities
///
/// For mode 2:
/// - Find all games at start city stadiums
/// - Find all games at end city stadiums
/// - For each (start_game, end_game) pair where end_game - start_game <= day_span:
/// Create a date range from start_game.date to end_game.date
///
private func generateDateRanges(
startStadiumIds: Set<String>,
endStadiumIds: Set<String>,
allGames: [Game],
request: PlanningRequest
) -> [DateInterval] {
// If explicit date range exists, use it
if let dateRange = request.dateRange {
return [dateRange]
}
// Otherwise, use day span to find valid combinations
let daySpan = request.preferences.effectiveTripDuration
guard daySpan > 0 else { return [] }
// Find games at start and end cities
let startGames = allGames
.filter { startStadiumIds.contains($0.stadiumId) }
.sorted { $0.startTime < $1.startTime }
let endGames = allGames
.filter { endStadiumIds.contains($0.stadiumId) }
.sorted { $0.startTime < $1.startTime }
if startGames.isEmpty || endGames.isEmpty {
return []
}
// Generate all valid (start_game, end_game) combinations
var dateRanges: [DateInterval] = []
let calendar = Calendar.current
for startGame in startGames {
let startDate = calendar.startOfDay(for: startGame.startTime)
for endGame in endGames {
let endDate = calendar.startOfDay(for: endGame.startTime)
// End must be after start
guard endDate >= startDate else { continue }
// Calculate days between
let daysBetween = calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 0
// Must be within day span
guard daysBetween < daySpan else { continue }
// Create date range (end date + 1 day to include the end game)
let rangeEnd = calendar.date(byAdding: .day, value: 1, to: endDate) ?? endDate
let range = DateInterval(start: startDate, end: rangeEnd)
// Avoid duplicate ranges
if !dateRanges.contains(where: { $0.start == range.start && $0.end == range.end }) {
dateRanges.append(range)
}
}
}
return dateRanges
}
// MARK: - Stop Building
/// Converts games to stops (used by GeographicRouteExplorer callback).
/// Groups consecutive games at the same stadium into one stop.
/// Creates separate stops when visiting the same city with other cities in between.
private func buildStops(
from games: [Game],
stadiums: [String: Stadium]
) -> [ItineraryStop] {
guard !games.isEmpty else { return [] }
// Sort games chronologically
let sortedGames = games.sorted { $0.startTime < $1.startTime }
// Group consecutive games at the same stadium
var stops: [ItineraryStop] = []
var currentStadiumId: String? = nil
var currentGames: [Game] = []
for game in sortedGames {
if game.stadiumId == currentStadiumId {
// Same stadium as previous game - add to current group
currentGames.append(game)
} else {
// Different stadium - finalize previous stop (if any) and start new one
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
stops.append(stop)
}
}
currentStadiumId = game.stadiumId
currentGames = [game]
}
}
// Don't forget the last group
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
stops.append(stop)
}
}
return stops
}
/// Creates an ItineraryStop from a group of games at the same stadium.
private func createStop(
from games: [Game],
stadiumId: String,
stadiums: [String: Stadium]
) -> ItineraryStop? {
guard !games.isEmpty else { return nil }
let sortedGames = games.sorted { $0.startTime < $1.startTime }
let stadium = stadiums[stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
// departureDate is day AFTER last game (we leave the next morning)
let lastGameDate = sortedGames.last?.gameDate ?? Date()
let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
return ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: departureDateValue,
location: location,
firstGameStart: sortedGames.first?.startTime
)
}
/// Builds stops with start and end location endpoints.
private func buildStopsWithEndpoints(
start: LocationInput,
end: LocationInput,
games: [Game],
stadiums: [String: Stadium]
) -> [ItineraryStop] {
var stops: [ItineraryStop] = []
// Start stop (no games)
let startArrival = games.first?.gameDate.addingTimeInterval(-86400) ?? Date()
let startStop = ItineraryStop(
city: start.name,
state: "",
coordinate: start.coordinate,
games: [],
arrivalDate: startArrival,
departureDate: startArrival,
location: start,
firstGameStart: nil
)
stops.append(startStop)
// Game stops
let gameStops = buildStops(from: games, stadiums: stadiums)
stops.append(contentsOf: gameStops)
// End stop (no games)
let endArrival = games.last?.gameDate.addingTimeInterval(86400) ?? Date()
let endStop = ItineraryStop(
city: end.name,
state: "",
coordinate: end.coordinate,
games: [],
arrivalDate: endArrival,
departureDate: endArrival,
location: end,
firstGameStart: nil
)
stops.append(endStop)
return stops
}
// MARK: - Monotonic Progress Validation
/// Validates that the route makes generally forward progress toward the end.
///
/// Each stop should be closer to (or not significantly farther from) the end
/// than the previous stop. Small detours are allowed within tolerance.
///
private func validateMonotonicProgress(
stops: [ItineraryStop],
toward end: CLLocationCoordinate2D
) -> Bool {
var previousDistance: Double?
for stop in stops {
guard let stopCoord = stop.coordinate else { continue }
let currentDistance = distanceBetween(stopCoord, end)
if let prev = previousDistance {
// Allow increases up to tolerance percentage
let allowedIncrease = prev * forwardProgressTolerance
if currentDistance > prev + allowedIncrease {
return false
}
}
previousDistance = currentDistance
}
return true
}
// MARK: - Geometry Helpers
/// Distance between two coordinates in miles using Haversine formula.
private func distanceBetween(
_ coord1: CLLocationCoordinate2D,
_ coord2: CLLocationCoordinate2D
) -> Double {
let earthRadiusMiles = 3958.8
let lat1 = coord1.latitude * .pi / 180
let lat2 = coord2.latitude * .pi / 180
let deltaLat = (coord2.latitude - coord1.latitude) * .pi / 180
let deltaLon = (coord2.longitude - coord1.longitude) * .pi / 180
let a = sin(deltaLat / 2) * sin(deltaLat / 2) +
cos(lat1) * cos(lat2) *
sin(deltaLon / 2) * sin(deltaLon / 2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
return earthRadiusMiles * c
}
}