perf: optimize featured cross-country trip generation and add tests

This commit is contained in:
Trey t
2026-02-10 20:11:38 -06:00
parent c6fa6386fd
commit e9c15d70b1
3 changed files with 401 additions and 85 deletions

View File

@@ -154,7 +154,7 @@ final class AppDataProvider: ObservableObject {
throw DataProviderError.contextNotConfigured
}
let sportStrings = sports.map { $0.rawValue }
let sportStrings = Set(sports.map(\.rawValue))
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
@@ -179,7 +179,7 @@ final class AppDataProvider: ObservableObject {
throw DataProviderError.contextNotConfigured
}
let sportStrings = sports.map { $0.rawValue }
let sportStrings = Set(sports.map(\.rawValue))
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in

View File

@@ -211,7 +211,7 @@ final class SuggestedTripsGenerator {
}
/// Performs heavy trip generation computation off the main actor.
/// Runs all 3 regions + 2 cross-country routes concurrently.
/// Runs all 3 regions + up to 2 directional cross-country routes concurrently.
private nonisolated static func generateTripsInBackground(
games: [Game],
stadiums: [Stadium],
@@ -222,7 +222,7 @@ final class SuggestedTripsGenerator {
let stadiumsById = stadiums.reduce(into: [String: Stadium]()) { $0[$1.id] = $1 }
let teamsById = teams.reduce(into: [String: Team]()) { $0[$1.id] = $1 }
// Run all 3 regions + 2 cross-country routes concurrently
// Run all 3 regions + 2 directional cross-country routes concurrently
async let eastTrips = generateRegionTrips(
region: .east, games: games, stadiums: stadiums,
stadiumsById: stadiumsById, teamsById: teamsById,
@@ -238,22 +238,35 @@ final class SuggestedTripsGenerator {
stadiumsById: stadiumsById, teamsById: teamsById,
startDate: startDate, endDate: endDate
)
async let crossCountry1 = generateCrossCountryTrip(
games: games, stadiums: stadiumsById, teams: teamsById,
startDate: startDate, endDate: endDate, excludeGames: []
async let eastToWestCrossCountry = generateCrossCountryTrip(
games: games,
stadiums: stadiumsById,
teams: teamsById,
direction: .eastToWest
)
async let crossCountry2 = generateCrossCountryTrip(
games: games, stadiums: stadiumsById, teams: teamsById,
startDate: startDate, endDate: endDate,
excludeGames: [] // Can't depend on crossCountry1 without breaking parallelism
async let westToEastCrossCountry = generateCrossCountryTrip(
games: games,
stadiums: stadiumsById,
teams: teamsById,
direction: .westToEast
)
var results: [SuggestedTrip] = []
results.append(contentsOf: await eastTrips)
results.append(contentsOf: await centralTrips)
results.append(contentsOf: await westTrips)
if let cc1 = await crossCountry1 { results.append(cc1) }
if let cc2 = await crossCountry2 { results.append(cc2) }
var crossCountrySignatures = Set<String>()
if let eastToWest = await eastToWestCrossCountry {
results.append(eastToWest)
crossCountrySignatures.insert(crossCountrySignature(for: eastToWest))
}
if let westToEast = await westToEastCrossCountry {
let signature = crossCountrySignature(for: westToEast)
if !crossCountrySignatures.contains(signature) {
results.append(westToEast)
}
}
return results
}
@@ -360,84 +373,65 @@ final class SuggestedTripsGenerator {
return result
}
private enum CrossCountryDirection {
case eastToWest
case westToEast
}
private nonisolated static func generateCrossCountryTrip(
games: [Game],
stadiums: [String: Stadium],
teams: [String: Team],
startDate: Date,
endDate: Date,
excludeGames: [Game]
direction: CrossCountryDirection
) -> SuggestedTrip? {
let excludeIds = Set(excludeGames.map { $0.id })
let availableGames = games.filter { !excludeIds.contains($0.id) }
guard !availableGames.isEmpty else { return nil }
guard !games.isEmpty else { return nil }
// Group games by region
var gamesByRegion: [Region: [Game]] = [:]
for game in availableGames {
for game in games {
guard let stadium = stadiums[game.stadiumId] else { continue }
gamesByRegion[stadium.region, default: []].append(game)
}
print("📍 CrossCountry: Games by region - East: \(gamesByRegion[.east]?.count ?? 0), Central: \(gamesByRegion[.central]?.count ?? 0), West: \(gamesByRegion[.west]?.count ?? 0)")
// REQUIREMENT: Must have games in BOTH East AND West for a true coast-to-coast
guard let eastGames = gamesByRegion[.east], !eastGames.isEmpty,
let westGames = gamesByRegion[.west], !westGames.isEmpty else {
print("❌ CrossCountry: Need games in BOTH East and West regions")
return nil
}
let centralGames = gamesByRegion[.central] ?? []
let calendar = Calendar.current
// Try both directions: EastWest and WestEast
let eastToWest = buildCoastToCoastRoute(
startGames: eastGames,
middleGames: centralGames,
endGames: westGames,
stadiums: stadiums,
calendar: calendar
)
let selectedGames: [Game]
switch direction {
case .eastToWest:
selectedGames = buildCoastToCoastRoute(
startGames: eastGames,
middleGames: centralGames,
endGames: westGames,
stadiums: stadiums,
calendar: calendar
)
case .westToEast:
selectedGames = buildCoastToCoastRoute(
startGames: westGames,
middleGames: centralGames,
endGames: eastGames,
stadiums: stadiums,
calendar: calendar
)
}
let westToEast = buildCoastToCoastRoute(
startGames: westGames,
middleGames: centralGames,
endGames: eastGames,
stadiums: stadiums,
calendar: calendar
)
// Pick the better route (more games)
var selectedGames: [Game]
if eastToWest.count >= westToEast.count && eastToWest.count >= 3 {
selectedGames = eastToWest
print("🧭 CrossCountry: Using East→West route")
} else if westToEast.count >= 3 {
selectedGames = westToEast
print("🧭 CrossCountry: Using West→East route")
} else {
print("❌ CrossCountry: No valid coast-to-coast route found (E→W: \(eastToWest.count), W→E: \(westToEast.count))")
guard selectedGames.count >= 3 else {
return nil
}
// Validate the route spans both coasts
let routeRegions = Set(selectedGames.compactMap { stadiums[$0.stadiumId]?.region })
guard routeRegions.contains(.east) && routeRegions.contains(.west) else {
print("❌ CrossCountry: Route doesn't span both coasts: \(routeRegions)")
return nil
}
// Debug: show selected cities and their regions
let cityDetails = selectedGames.compactMap { game -> String? in
guard let stadium = stadiums[game.stadiumId] else { return nil }
let dateStr = game.dateTime.formatted(date: .abbreviated, time: .omitted)
return "\(stadium.city)(\(stadium.region.rawValue.prefix(1)),\(dateStr))"
}
print("✅ CrossCountry: Selected \(selectedGames.count) games: \(cityDetails.joined(separator: ""))")
// Build trip directly from selected games (bypass planning engine)
guard let firstGame = selectedGames.first,
let lastGame = selectedGames.last else { return nil }
@@ -474,12 +468,9 @@ final class SuggestedTripsGenerator {
}
guard tripStops.count >= 3 else {
print("❌ CrossCountry: Only \(tripStops.count) stops built (need at least 3)")
return nil
}
print("✅ CrossCountry: Built trip with \(tripStops.count) stops")
// Build the trip
let preferences = TripPreferences(
planningMode: .dateRange,
@@ -554,14 +545,17 @@ final class SuggestedTripsGenerator {
var segments: [TravelSegment] = []
let constraints = DrivingConstraints.default
let coordinatesByCity = stadiums.values.reduce(into: [String: CLLocationCoordinate2D]()) { partialResult, stadium in
partialResult[stadium.city] = stadium.coordinate
}
for i in 0..<(stops.count - 1) {
let fromStop = stops[i]
let toStop = stops[i + 1]
// Get coordinates from stops or from stadium lookup
let fromCoord = fromStop.coordinate ?? stadiums.values.first { $0.city == fromStop.city }?.coordinate
let toCoord = toStop.coordinate ?? stadiums.values.first { $0.city == toStop.city }?.coordinate
let fromCoord = fromStop.coordinate ?? coordinatesByCity[fromStop.city]
let toCoord = toStop.coordinate ?? coordinatesByCity[toStop.city]
let fromLocation = LocationInput(name: fromStop.city, coordinate: fromCoord)
let toLocation = LocationInput(name: toStop.city, coordinate: toCoord)
@@ -576,8 +570,6 @@ final class SuggestedTripsGenerator {
}
private nonisolated static func convertToTrip(option: ItineraryOption, preferences: TripPreferences) -> Trip {
print("🔍 convertToTrip: option.stops.count = \(option.stops.count)")
let tripStops = option.stops.enumerated().map { index, stop in
TripStop(
stopNumber: index + 1,
@@ -591,8 +583,6 @@ final class SuggestedTripsGenerator {
)
}
print("🔍 convertToTrip: tripStops.count = \(tripStops.count)")
return Trip(
name: generateTripName(from: tripStops),
preferences: preferences,
@@ -695,18 +685,24 @@ final class SuggestedTripsGenerator {
stadiums: [String: Stadium],
calendar: Calendar
) -> [Game] {
let maxAnchorsPerSide = 32
// Sort all games by date
let sortedStart = startGames.sorted { $0.dateTime < $1.dateTime }
let sortedMiddle = middleGames.sorted { $0.dateTime < $1.dateTime }
let sortedEnd = endGames.sorted { $0.dateTime < $1.dateTime }
// Bound the start/end search space to keep route generation responsive.
let candidateStarts = trimAnchorGames(sortedStart, calendar: calendar, keep: maxAnchorsPerSide)
let candidateEnds = trimAnchorGames(sortedEnd, calendar: calendar, keep: maxAnchorsPerSide)
// Maximum driving per day (roughly 500 miles)
let maxDailyMiles = 500.0
// Collect all valid routes to pick the shortest
var validRoutes: [(games: [Game], days: Int, distance: Double)] = []
// Track only the best route found instead of collecting and sorting all candidates.
var bestRoute: (games: [Game], days: Int, distance: Double)?
for startGame in sortedStart {
for startGame in candidateStarts {
guard let startStadium = stadiums[startGame.stadiumId] else { continue }
// Find ending games that are AFTER the start game with enough time to drive cross-country
@@ -714,7 +710,7 @@ final class SuggestedTripsGenerator {
let minDaysForCrossCountry = 5
let maxDaysForTrip = 14 // Cap trips at 2 weeks for reasonable length
for endGame in sortedEnd {
for endGame in candidateEnds {
guard let endStadium = stadiums[endGame.stadiumId] else { continue }
let daysBetween = calendar.dateComponents([.day], from: startGame.dateTime, to: endGame.dateTime).day ?? 0
@@ -722,6 +718,16 @@ final class SuggestedTripsGenerator {
// Need enough time to drive cross-country but not TOO long
guard daysBetween >= minDaysForCrossCountry && daysBetween <= maxDaysForTrip else { continue }
// Early prune impossible anchor pairs before scanning middle games.
let maxMilesForWindow = Double(max(1, daysBetween)) * maxDailyMiles
let directDistance = haversineDistance(
lat1: startStadium.coordinate.latitude,
lon1: startStadium.coordinate.longitude,
lat2: endStadium.coordinate.latitude,
lon2: endStadium.coordinate.longitude
)
guard directDistance <= maxMilesForWindow else { continue }
// Build route: start middle games end
var route: [Game] = [startGame]
var lastGame = startGame
@@ -810,23 +816,22 @@ final class SuggestedTripsGenerator {
// Final validation: ensure no same-day conflicts in entire route
if validateNoSameDayConflicts(route, calendar: calendar) && route.count >= 3 {
validRoutes.append((games: route, days: daysBetween, distance: totalDistance))
let candidate = (games: route, days: daysBetween, distance: totalDistance)
if let currentBest = bestRoute {
if candidate.days < currentBest.days ||
(candidate.days == currentBest.days && candidate.distance < currentBest.distance) {
bestRoute = candidate
}
} else {
bestRoute = candidate
}
}
}
}
}
// Sort routes: prefer fewer days, then less distance
let sortedRoutes = validRoutes.sorted { a, b in
if a.days != b.days {
return a.days < b.days
}
return a.distance < b.distance
}
// Return the shortest valid route
if let best = sortedRoutes.first {
print("✅ C2C route: \(best.days) days, \(Int(best.distance)) miles, \(best.games.count) games")
if let best = bestRoute {
return best.games
}
@@ -840,7 +845,6 @@ final class SuggestedTripsGenerator {
let day = calendar.startOfDay(for: game.dateTime)
if let existingStadiumId = gamesByDay[day] {
if game.stadiumId != existingStadiumId {
print("⚠️ Same-day conflict: \(day) has games at different stadiums")
return false
}
}
@@ -849,6 +853,50 @@ final class SuggestedTripsGenerator {
return true
}
private nonisolated static func crossCountrySignature(for trip: SuggestedTrip) -> String {
trip.trip.stops.flatMap(\.games).joined(separator: "|")
}
/// Samples anchor games across the full time window while enforcing one game per day.
private nonisolated static func trimAnchorGames(_ games: [Game], calendar: Calendar, keep: Int) -> [Game] {
guard keep > 0 else { return [] }
guard games.count > keep else { return games }
var uniqueDays: [Game] = []
var seenDays = Set<Date>()
for game in games {
let day = calendar.startOfDay(for: game.dateTime)
if seenDays.insert(day).inserted {
uniqueDays.append(game)
}
}
let source = uniqueDays.isEmpty ? games : uniqueDays
guard source.count > keep else { return source }
let step = max(1, source.count / keep)
var sampled: [Game] = []
var seenGameIds = Set<String>()
var index = 0
while index < source.count && sampled.count < keep {
let game = source[index]
if seenGameIds.insert(game.id).inserted {
sampled.append(game)
}
index += step
}
if sampled.count < keep {
for game in source where seenGameIds.insert(game.id).inserted {
sampled.append(game)
if sampled.count == keep { break }
}
}
return sampled
}
/// Haversine distance in miles between two coordinates
private nonisolated static func haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
let R = 3959.0 // Earth radius in miles
@@ -861,3 +909,39 @@ final class SuggestedTripsGenerator {
return R * c
}
}
#if DEBUG
extension SuggestedTripsGenerator {
/// Test hook for directional cross-country generation without AppDataProvider wiring.
nonisolated static func _testGenerateCrossCountryTrip(
games: [Game],
stadiums: [String: Stadium],
teams: [String: Team],
eastToWest: Bool
) -> SuggestedTrip? {
generateCrossCountryTrip(
games: games,
stadiums: stadiums,
teams: teams,
direction: eastToWest ? .eastToWest : .westToEast
)
}
/// Test hook for direct coast-to-coast route construction.
nonisolated static func _testBuildCoastToCoastRoute(
startGames: [Game],
middleGames: [Game],
endGames: [Game],
stadiums: [String: Stadium],
calendar: Calendar = .current
) -> [Game] {
buildCoastToCoastRoute(
startGames: startGames,
middleGames: middleGames,
endGames: endGames,
stadiums: stadiums,
calendar: calendar
)
}
}
#endif