Add region-based filtering and route length diversity
- Add RegionMapSelector UI for geographic trip filtering (East/Central/West) - Add RouteFilters module for allowRepeatCities preference - Improve GameDAGRouter to preserve route length diversity - Routes now grouped by city count before scoring - Ensures 2-city trips appear alongside longer trips - Increased beam width and max options for better coverage - Add TripOptionsView filters (max cities slider, pace filter) - Remove TravelStyle section from trip creation (replaced by region selector) - Clean up debug logging from DataProvider and ScenarioAPlanner 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,10 +25,11 @@ enum GameDAGRouter {
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Default beam width - how many partial routes to keep at each step
|
||||
private static let defaultBeamWidth = 30
|
||||
/// Increased to ensure we preserve diverse route lengths (short and long trips)
|
||||
private static let defaultBeamWidth = 50
|
||||
|
||||
/// Maximum options to return
|
||||
private static let maxOptions = 10
|
||||
/// Maximum options to return (increased to provide more diverse trip lengths)
|
||||
private static let maxOptions = 50
|
||||
|
||||
/// Buffer time after game ends before we can depart (hours)
|
||||
private static let gameEndBufferHours: Double = 3.0
|
||||
@@ -47,6 +48,7 @@ enum GameDAGRouter {
|
||||
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
|
||||
/// - constraints: Driving constraints (number of drivers, max hours per day)
|
||||
/// - anchorGameIds: Games that MUST appear in every valid route (for Scenario B)
|
||||
/// - allowRepeatCities: If false, each city can only appear once in a route
|
||||
/// - beamWidth: How many partial routes to keep at each depth (default 30)
|
||||
///
|
||||
/// - Returns: Array of valid game combinations, sorted by score (most games, least driving)
|
||||
@@ -56,6 +58,7 @@ enum GameDAGRouter {
|
||||
stadiums: [UUID: Stadium],
|
||||
constraints: DrivingConstraints,
|
||||
anchorGameIds: Set<UUID> = [],
|
||||
allowRepeatCities: Bool = true,
|
||||
beamWidth: Int = defaultBeamWidth
|
||||
) -> [[Game]] {
|
||||
|
||||
@@ -130,6 +133,15 @@ enum GameDAGRouter {
|
||||
|
||||
// Try adding each of today's games
|
||||
for candidate in todaysGames {
|
||||
// Check for repeat city violation during route building
|
||||
if !allowRepeatCities {
|
||||
let candidateCity = stadiums[candidate.stadiumId]?.city ?? ""
|
||||
let pathCities = Set(path.compactMap { stadiums[$0.stadiumId]?.city })
|
||||
if pathCities.contains(candidateCity) {
|
||||
continue // Skip - would violate allowRepeatCities
|
||||
}
|
||||
}
|
||||
|
||||
if canTransition(from: lastGame, to: candidate, stadiums: stadiums, constraints: constraints) {
|
||||
let newPath = path + [candidate]
|
||||
nextBeam.append(newPath)
|
||||
@@ -169,6 +181,7 @@ enum GameDAGRouter {
|
||||
from games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
anchorGameIds: Set<UUID> = [],
|
||||
allowRepeatCities: Bool = true,
|
||||
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
|
||||
) -> [[Game]] {
|
||||
// Use default driving constraints
|
||||
@@ -178,7 +191,8 @@ enum GameDAGRouter {
|
||||
games: games,
|
||||
stadiums: stadiums,
|
||||
constraints: constraints,
|
||||
anchorGameIds: anchorGameIds
|
||||
anchorGameIds: anchorGameIds,
|
||||
allowRepeatCities: allowRepeatCities
|
||||
)
|
||||
}
|
||||
|
||||
@@ -288,8 +302,9 @@ enum GameDAGRouter {
|
||||
|
||||
// MARK: - Geographic Diversity
|
||||
|
||||
/// Selects geographically diverse routes from the candidate set.
|
||||
/// Groups routes by their primary city (where most games are) and picks the best from each region.
|
||||
/// Selects diverse routes from the candidate set.
|
||||
/// Ensures diversity by BOTH route length (city count) AND primary city.
|
||||
/// This guarantees users see 2-city trips alongside 5+ city trips.
|
||||
private static func selectDiverseRoutes(
|
||||
_ routes: [[Game]],
|
||||
stadiums: [UUID: Stadium],
|
||||
@@ -297,58 +312,88 @@ enum GameDAGRouter {
|
||||
) -> [[Game]] {
|
||||
guard !routes.isEmpty else { return [] }
|
||||
|
||||
// Group routes by primary city (the city with the most games in the route)
|
||||
var routesByRegion: [String: [[Game]]] = [:]
|
||||
|
||||
// Group routes by city count (route length)
|
||||
var routesByLength: [Int: [[Game]]] = [:]
|
||||
for route in routes {
|
||||
let primaryCity = getPrimaryCity(for: route, stadiums: stadiums)
|
||||
routesByRegion[primaryCity, default: []].append(route)
|
||||
let cityCount = Set(route.compactMap { stadiums[$0.stadiumId]?.city }).count
|
||||
routesByLength[cityCount, default: []].append(route)
|
||||
}
|
||||
|
||||
// Sort routes within each region by score (best first)
|
||||
for (region, regionRoutes) in routesByRegion {
|
||||
routesByRegion[region] = regionRoutes.sorted {
|
||||
// Sort routes within each length by score
|
||||
for (length, lengthRoutes) in routesByLength {
|
||||
routesByLength[length] = lengthRoutes.sorted {
|
||||
scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort regions by their best route's score (so best regions come first)
|
||||
let sortedRegions = routesByRegion.keys.sorted { region1, region2 in
|
||||
let score1 = routesByRegion[region1]?.first.map { scorePath($0, stadiums: stadiums) } ?? 0
|
||||
let score2 = routesByRegion[region2]?.first.map { scorePath($0, stadiums: stadiums) } ?? 0
|
||||
return score1 > score2
|
||||
}
|
||||
// Allocate slots to each length category
|
||||
// Goal: ensure at least 1 route per length category if available
|
||||
let sortedLengths = routesByLength.keys.sorted()
|
||||
let minPerLength = max(1, maxCount / max(1, sortedLengths.count))
|
||||
|
||||
|
||||
// Pick routes round-robin from each region to ensure diversity
|
||||
var selectedRoutes: [[Game]] = []
|
||||
var regionIndices: [String: Int] = [:]
|
||||
var selectedIds = Set<String>()
|
||||
|
||||
// First pass: get best route from each region
|
||||
for region in sortedRegions {
|
||||
// First pass: take best route(s) from each length category
|
||||
for length in sortedLengths {
|
||||
if selectedRoutes.count >= maxCount { break }
|
||||
if let regionRoutes = routesByRegion[region], !regionRoutes.isEmpty {
|
||||
selectedRoutes.append(regionRoutes[0])
|
||||
regionIndices[region] = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: fill remaining slots with next-best routes from top regions
|
||||
var round = 1
|
||||
while selectedRoutes.count < maxCount {
|
||||
var addedAny = false
|
||||
for region in sortedRegions {
|
||||
if selectedRoutes.count >= maxCount { break }
|
||||
let idx = regionIndices[region] ?? 0
|
||||
if let regionRoutes = routesByRegion[region], idx < regionRoutes.count {
|
||||
selectedRoutes.append(regionRoutes[idx])
|
||||
regionIndices[region] = idx + 1
|
||||
addedAny = true
|
||||
if let lengthRoutes = routesByLength[length] {
|
||||
let toTake = min(minPerLength, lengthRoutes.count, maxCount - selectedRoutes.count)
|
||||
for route in lengthRoutes.prefix(toTake) {
|
||||
let key = route.map { $0.id.uuidString }.joined(separator: "-")
|
||||
if !selectedIds.contains(key) {
|
||||
selectedRoutes.append(route)
|
||||
selectedIds.insert(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !addedAny { break }
|
||||
round += 1
|
||||
if round > 5 { break } // Safety limit
|
||||
}
|
||||
|
||||
// Second pass: fill remaining slots, prioritizing geographic diversity
|
||||
if selectedRoutes.count < maxCount {
|
||||
// Group remaining routes by primary city
|
||||
var remainingByCity: [String: [[Game]]] = [:]
|
||||
for route in routes {
|
||||
let key = route.map { $0.id.uuidString }.joined(separator: "-")
|
||||
if !selectedIds.contains(key) {
|
||||
let city = getPrimaryCity(for: route, stadiums: stadiums)
|
||||
remainingByCity[city, default: []].append(route)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score within each city
|
||||
for (city, cityRoutes) in remainingByCity {
|
||||
remainingByCity[city] = cityRoutes.sorted {
|
||||
scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums)
|
||||
}
|
||||
}
|
||||
|
||||
// Round-robin from each city
|
||||
let sortedCities = remainingByCity.keys.sorted { city1, city2 in
|
||||
let score1 = remainingByCity[city1]?.first.map { scorePath($0, stadiums: stadiums) } ?? 0
|
||||
let score2 = remainingByCity[city2]?.first.map { scorePath($0, stadiums: stadiums) } ?? 0
|
||||
return score1 > score2
|
||||
}
|
||||
|
||||
var cityIndices: [String: Int] = [:]
|
||||
while selectedRoutes.count < maxCount {
|
||||
var addedAny = false
|
||||
for city in sortedCities {
|
||||
if selectedRoutes.count >= maxCount { break }
|
||||
let idx = cityIndices[city] ?? 0
|
||||
if let cityRoutes = remainingByCity[city], idx < cityRoutes.count {
|
||||
let route = cityRoutes[idx]
|
||||
let key = route.map { $0.id.uuidString }.joined(separator: "-")
|
||||
if !selectedIds.contains(key) {
|
||||
selectedRoutes.append(route)
|
||||
selectedIds.insert(key)
|
||||
addedAny = true
|
||||
}
|
||||
cityIndices[city] = idx + 1
|
||||
}
|
||||
}
|
||||
if !addedAny { break }
|
||||
}
|
||||
}
|
||||
|
||||
return selectedRoutes
|
||||
@@ -412,6 +457,7 @@ enum GameDAGRouter {
|
||||
}
|
||||
|
||||
/// Prunes dominated paths and truncates to beam width.
|
||||
/// Maintains diversity by both ending city AND route length to ensure short trips aren't eliminated.
|
||||
private static func pruneAndTruncate(
|
||||
_ paths: [[Game]],
|
||||
beamWidth: Int,
|
||||
@@ -429,32 +475,47 @@ enum GameDAGRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score (best first)
|
||||
let sorted = uniquePaths.sorted { scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums) }
|
||||
// Group paths by unique city count (route length)
|
||||
// This ensures we keep short trips (2 cities) alongside long trips (5+ cities)
|
||||
var pathsByLength: [Int: [[Game]]] = [:]
|
||||
for path in uniquePaths {
|
||||
let cityCount = Set(path.compactMap { stadiums[$0.stadiumId]?.city }).count
|
||||
pathsByLength[cityCount, default: []].append(path)
|
||||
}
|
||||
|
||||
// Sort paths within each length group by score
|
||||
for (length, lengthPaths) in pathsByLength {
|
||||
pathsByLength[length] = lengthPaths.sorted {
|
||||
scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums)
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate beam slots proportionally to length groups, with minimum per group
|
||||
let sortedLengths = pathsByLength.keys.sorted()
|
||||
let minPerLength = max(2, beamWidth / max(1, sortedLengths.count))
|
||||
|
||||
// Dominance pruning: within same ending city, keep only best paths
|
||||
var pruned: [[Game]] = []
|
||||
var bestByEndCity: [String: Double] = [:]
|
||||
|
||||
for path in sorted {
|
||||
guard let lastGame = path.last else { continue }
|
||||
let endCity = stadiums[lastGame.stadiumId]?.city ?? "Unknown"
|
||||
let score = scorePath(path, stadiums: stadiums)
|
||||
|
||||
// Keep if this is the best path ending in this city, or if score is within 20% of best
|
||||
if let bestScore = bestByEndCity[endCity] {
|
||||
if score >= bestScore * 0.8 {
|
||||
pruned.append(path)
|
||||
}
|
||||
} else {
|
||||
bestByEndCity[endCity] = score
|
||||
pruned.append(path)
|
||||
// First pass: take minimum from each length group
|
||||
for length in sortedLengths {
|
||||
if let lengthPaths = pathsByLength[length] {
|
||||
let toTake = min(minPerLength, lengthPaths.count)
|
||||
pruned.append(contentsOf: lengthPaths.prefix(toTake))
|
||||
}
|
||||
}
|
||||
|
||||
// Stop if we have enough
|
||||
if pruned.count >= beamWidth * 2 {
|
||||
break
|
||||
// Second pass: fill remaining slots with best paths overall
|
||||
if pruned.count < beamWidth {
|
||||
let remaining = beamWidth - pruned.count
|
||||
let prunedIds = Set(pruned.map { $0.map { $0.id.uuidString }.joined(separator: "-") })
|
||||
|
||||
// Get all paths not yet added, sorted by score
|
||||
var additional = uniquePaths.filter {
|
||||
!prunedIds.contains($0.map { $0.id.uuidString }.joined(separator: "-"))
|
||||
}
|
||||
additional.sort { scorePath($0, stadiums: stadiums) > scorePath($1, stadiums: stadiums) }
|
||||
|
||||
pruned.append(contentsOf: additional.prefix(remaining))
|
||||
}
|
||||
|
||||
// Final truncation
|
||||
|
||||
60
SportsTime/Planning/Engine/RouteFilters.swift
Normal file
60
SportsTime/Planning/Engine/RouteFilters.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// RouteFilters.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Filters itinerary results based on user preferences.
|
||||
// Applied in TripPlanningEngine AFTER scenario planners return.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
enum RouteFilters {
|
||||
|
||||
// MARK: - Repeat Cities Filter
|
||||
|
||||
/// Filter itinerary options that violate repeat city rules.
|
||||
/// When allowRepeatCities=false, each city must be visited on exactly ONE day.
|
||||
static func filterRepeatCities(
|
||||
_ options: [ItineraryOption],
|
||||
allow: Bool
|
||||
) -> [ItineraryOption] {
|
||||
guard !allow else { return options }
|
||||
return options.filter { !hasRepeatCityViolation($0) }
|
||||
}
|
||||
|
||||
/// Check if an itinerary visits any city on multiple days.
|
||||
static func hasRepeatCityViolation(_ option: ItineraryOption) -> Bool {
|
||||
let calendar = Calendar.current
|
||||
var cityDays: [String: Set<Date>] = [:]
|
||||
|
||||
for stop in option.stops {
|
||||
let city = stop.city
|
||||
let day = calendar.startOfDay(for: stop.arrivalDate)
|
||||
cityDays[city, default: []].insert(day)
|
||||
}
|
||||
|
||||
// Violation if any city has more than 1 day
|
||||
return cityDays.values.contains(where: { $0.count > 1 })
|
||||
}
|
||||
|
||||
/// Get cities that are visited on multiple days (for error reporting).
|
||||
static func findRepeatCities(in options: [ItineraryOption]) -> [String] {
|
||||
var violatingCities = Set<String>()
|
||||
let calendar = Calendar.current
|
||||
|
||||
for option in options {
|
||||
var cityDays: [String: Set<Date>] = [:]
|
||||
for stop in option.stops {
|
||||
let day = calendar.startOfDay(for: stop.arrivalDate)
|
||||
cityDays[stop.city, default: []].insert(day)
|
||||
}
|
||||
for (city, days) in cityDays where days.count > 1 {
|
||||
violatingCities.insert(city)
|
||||
}
|
||||
}
|
||||
|
||||
return Array(violatingCities).sorted()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -60,12 +60,24 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 2: Filter games within date range
|
||||
// Step 2: Filter games within date range and selected regions
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Get all games that fall within the user's travel dates.
|
||||
// Sort by start time so we visit them in chronological order.
|
||||
let selectedRegions = request.preferences.selectedRegions
|
||||
let gamesInRange = request.allGames
|
||||
.filter { dateRange.contains($0.startTime) }
|
||||
.filter { game in
|
||||
// Must be in date range
|
||||
guard dateRange.contains(game.startTime) else { return false }
|
||||
|
||||
// Must be in selected region (if regions specified)
|
||||
if !selectedRegions.isEmpty {
|
||||
guard let stadium = request.stadiums[game.stadiumId] else { return false }
|
||||
let gameRegion = Region.classify(longitude: stadium.coordinate.longitude)
|
||||
return selectedRegions.contains(gameRegion)
|
||||
}
|
||||
return true
|
||||
}
|
||||
.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// No games? Nothing to plan.
|
||||
@@ -91,11 +103,32 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
// We explore ALL valid combinations and return multiple options.
|
||||
// Uses GameDAGRouter for polynomial-time beam search.
|
||||
//
|
||||
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
// Run beam search BOTH globally AND per-region to get diverse routes:
|
||||
// - Global search finds cross-region routes
|
||||
// - Per-region search ensures we have good regional options too
|
||||
// Travel style filtering happens at UI layer.
|
||||
//
|
||||
var validRoutes: [[Game]] = []
|
||||
|
||||
// Global beam search (finds cross-region routes)
|
||||
let globalRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: gamesInRange,
|
||||
stadiums: request.stadiums,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
validRoutes.append(contentsOf: globalRoutes)
|
||||
|
||||
// Per-region beam search (ensures good regional options)
|
||||
let regionalRoutes = findRoutesPerRegion(
|
||||
games: gamesInRange,
|
||||
stadiums: request.stadiums,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities
|
||||
)
|
||||
validRoutes.append(contentsOf: regionalRoutes)
|
||||
|
||||
// Deduplicate routes (same game IDs)
|
||||
validRoutes = deduplicateRoutes(validRoutes)
|
||||
|
||||
print("🔍 ScenarioA: gamesInRange=\(gamesInRange.count), validRoutes=\(validRoutes.count)")
|
||||
if let firstRoute = validRoutes.first {
|
||||
@@ -201,11 +234,10 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
|
||||
let rankedOptions = ItineraryOption.sortByLeisure(
|
||||
itineraryOptions,
|
||||
leisureLevel: leisureLevel,
|
||||
limit: request.preferences.maxTripOptions
|
||||
leisureLevel: leisureLevel
|
||||
)
|
||||
|
||||
print("🔍 ScenarioA: Returning \(rankedOptions.count) options after sorting (limit=\(request.preferences.maxTripOptions))")
|
||||
print("🔍 ScenarioA: Returning \(rankedOptions.count) options after sorting")
|
||||
if let first = rankedOptions.first {
|
||||
print("🔍 ScenarioA: First option has \(first.stops.count) stops")
|
||||
}
|
||||
@@ -310,4 +342,69 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Route Deduplication
|
||||
|
||||
/// Removes duplicate routes (routes with identical game IDs).
|
||||
private func deduplicateRoutes(_ routes: [[Game]]) -> [[Game]] {
|
||||
var seen = Set<String>()
|
||||
var unique: [[Game]] = []
|
||||
|
||||
for route in routes {
|
||||
let key = route.map { $0.id.uuidString }.sorted().joined(separator: "-")
|
||||
if !seen.contains(key) {
|
||||
seen.insert(key)
|
||||
unique.append(route)
|
||||
}
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
|
||||
// MARK: - Regional Route Finding
|
||||
|
||||
/// Finds routes by running beam search separately for each geographic region.
|
||||
/// This ensures we get diverse options from East, Central, and West coasts.
|
||||
private func findRoutesPerRegion(
|
||||
games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
allowRepeatCities: Bool
|
||||
) -> [[Game]] {
|
||||
// Partition games by region
|
||||
var gamesByRegion: [Region: [Game]] = [:]
|
||||
|
||||
for game in games {
|
||||
guard let stadium = stadiums[game.stadiumId] else { continue }
|
||||
let coord = stadium.coordinate
|
||||
let region = Region.classify(longitude: coord.longitude)
|
||||
// Only consider actual regions, not cross-country
|
||||
if region != .crossCountry {
|
||||
gamesByRegion[region, default: []].append(game)
|
||||
}
|
||||
}
|
||||
|
||||
print("🔍 ScenarioA Regional: Partitioned \(games.count) games into \(gamesByRegion.count) regions")
|
||||
for (region, regionGames) in gamesByRegion {
|
||||
print(" \(region.shortName): \(regionGames.count) games")
|
||||
}
|
||||
|
||||
// Run beam search for each region
|
||||
var allRoutes: [[Game]] = []
|
||||
|
||||
for (region, regionGames) in gamesByRegion {
|
||||
guard !regionGames.isEmpty else { continue }
|
||||
|
||||
let regionRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: regionGames,
|
||||
stadiums: stadiums,
|
||||
allowRepeatCities: allowRepeatCities,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
|
||||
print("🔍 ScenarioA Regional: \(region.shortName) produced \(regionRoutes.count) routes")
|
||||
allRoutes.append(contentsOf: regionRoutes)
|
||||
}
|
||||
|
||||
return allRoutes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -85,12 +85,25 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
// Step 3: For each date range, find routes with anchors
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
let anchorGameIds = Set(selectedGames.map { $0.id })
|
||||
let selectedRegions = request.preferences.selectedRegions
|
||||
var allItineraryOptions: [ItineraryOption] = []
|
||||
|
||||
for dateRange in dateRanges {
|
||||
// Find all games in this date range
|
||||
// Find all games in this date range and selected regions
|
||||
let gamesInRange = request.allGames
|
||||
.filter { dateRange.contains($0.startTime) }
|
||||
.filter { game in
|
||||
// Must be in date range
|
||||
guard dateRange.contains(game.startTime) else { return false }
|
||||
|
||||
// Must be in selected region (if regions specified)
|
||||
// Note: Anchor games are always included regardless of region
|
||||
if !selectedRegions.isEmpty && !anchorGameIds.contains(game.id) {
|
||||
guard let stadium = request.stadiums[game.stadiumId] else { return false }
|
||||
let gameRegion = Region.classify(longitude: stadium.coordinate.longitude)
|
||||
return selectedRegions.contains(gameRegion)
|
||||
}
|
||||
return true
|
||||
}
|
||||
.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Skip if no games (shouldn't happen if date range is valid)
|
||||
@@ -104,12 +117,30 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
|
||||
// Find all sensible routes that include the anchor games
|
||||
// Uses GameDAGRouter for polynomial-time beam search
|
||||
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
// Run BOTH global and per-region search for diverse routes
|
||||
var validRoutes: [[Game]] = []
|
||||
|
||||
// Global beam search (finds cross-region routes)
|
||||
let globalRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: gamesInRange,
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
validRoutes.append(contentsOf: globalRoutes)
|
||||
|
||||
// Per-region beam search (ensures good regional options)
|
||||
let regionalRoutes = findRoutesPerRegion(
|
||||
games: gamesInRange,
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities
|
||||
)
|
||||
validRoutes.append(contentsOf: regionalRoutes)
|
||||
|
||||
// Deduplicate
|
||||
validRoutes = deduplicateRoutes(validRoutes)
|
||||
|
||||
// Build itineraries for each valid route
|
||||
for routeGames in validRoutes {
|
||||
@@ -164,8 +195,7 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
let leisureLevel = request.preferences.leisureLevel
|
||||
let rankedOptions = ItineraryOption.sortByLeisure(
|
||||
allItineraryOptions,
|
||||
leisureLevel: leisureLevel,
|
||||
limit: request.preferences.maxTripOptions
|
||||
leisureLevel: leisureLevel
|
||||
)
|
||||
|
||||
return .success(Array(rankedOptions))
|
||||
@@ -354,4 +384,84 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Regional Route Finding
|
||||
|
||||
/// Finds routes by running beam search separately for each geographic region.
|
||||
/// This ensures we get diverse options from East, Central, and West coasts.
|
||||
/// For Scenario B, routes must still contain all anchor games.
|
||||
private func findRoutesPerRegion(
|
||||
games: [Game],
|
||||
stadiums: [UUID: Stadium],
|
||||
anchorGameIds: Set<UUID>,
|
||||
allowRepeatCities: Bool
|
||||
) -> [[Game]] {
|
||||
// First, determine which region(s) the anchor games are in
|
||||
var anchorRegions = Set<Region>()
|
||||
for game in games where anchorGameIds.contains(game.id) {
|
||||
guard let stadium = stadiums[game.stadiumId] else { continue }
|
||||
let coord = stadium.coordinate
|
||||
let region = Region.classify(longitude: coord.longitude)
|
||||
if region != .crossCountry {
|
||||
anchorRegions.insert(region)
|
||||
}
|
||||
}
|
||||
|
||||
// Partition all games by region
|
||||
var gamesByRegion: [Region: [Game]] = [:]
|
||||
for game in games {
|
||||
guard let stadium = stadiums[game.stadiumId] else { continue }
|
||||
let coord = stadium.coordinate
|
||||
let region = Region.classify(longitude: coord.longitude)
|
||||
if region != .crossCountry {
|
||||
gamesByRegion[region, default: []].append(game)
|
||||
}
|
||||
}
|
||||
|
||||
print("🔍 ScenarioB Regional: Anchor games in regions: \(anchorRegions.map { $0.shortName })")
|
||||
|
||||
// Run beam search for each region that has anchor games
|
||||
// (Other regions without anchor games would produce routes that don't satisfy anchors)
|
||||
var allRoutes: [[Game]] = []
|
||||
|
||||
for region in anchorRegions {
|
||||
guard let regionGames = gamesByRegion[region], !regionGames.isEmpty else { continue }
|
||||
|
||||
// Get anchor games in this region
|
||||
let regionAnchorIds = anchorGameIds.filter { anchorId in
|
||||
regionGames.contains { $0.id == anchorId }
|
||||
}
|
||||
|
||||
let regionRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: regionGames,
|
||||
stadiums: stadiums,
|
||||
anchorGameIds: regionAnchorIds,
|
||||
allowRepeatCities: allowRepeatCities,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
|
||||
print("🔍 ScenarioB Regional: \(region.shortName) produced \(regionRoutes.count) routes")
|
||||
allRoutes.append(contentsOf: regionRoutes)
|
||||
}
|
||||
|
||||
return allRoutes
|
||||
}
|
||||
|
||||
// MARK: - Route Deduplication
|
||||
|
||||
/// Removes duplicate routes (routes with identical game IDs).
|
||||
private func deduplicateRoutes(_ routes: [[Game]]) -> [[Game]] {
|
||||
var seen = Set<String>()
|
||||
var unique: [[Game]] = []
|
||||
|
||||
for route in routes {
|
||||
let key = route.map { $0.id.uuidString }.sorted().joined(separator: "-")
|
||||
if !seen.contains(key) {
|
||||
seen.insert(key)
|
||||
unique.append(route)
|
||||
}
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -261,8 +261,7 @@ final class ScenarioCPlanner: ScenarioPlanner {
|
||||
let leisureLevel = request.preferences.leisureLevel
|
||||
let rankedOptions = ItineraryOption.sortByLeisure(
|
||||
allItineraryOptions,
|
||||
leisureLevel: leisureLevel,
|
||||
limit: request.preferences.maxTripOptions
|
||||
leisureLevel: leisureLevel
|
||||
)
|
||||
|
||||
return .success(Array(rankedOptions))
|
||||
|
||||
@@ -22,6 +22,41 @@ final class TripPlanningEngine {
|
||||
let planner = ScenarioPlannerFactory.planner(for: request)
|
||||
|
||||
// Delegate to the scenario planner
|
||||
return planner.plan(request: request)
|
||||
let result = planner.plan(request: request)
|
||||
|
||||
// Apply preference filters to successful results
|
||||
return applyPreferenceFilters(to: result, request: request)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Applies allowRepeatCities filter after scenario planners return.
|
||||
/// Note: Region filtering is done during game selection in scenario planners.
|
||||
private func applyPreferenceFilters(
|
||||
to result: ItineraryResult,
|
||||
request: PlanningRequest
|
||||
) -> ItineraryResult {
|
||||
guard case .success(let originalOptions) = result else {
|
||||
return result
|
||||
}
|
||||
|
||||
var options = originalOptions
|
||||
|
||||
// Filter repeat cities (this is enforced during beam search, but double-check here)
|
||||
options = RouteFilters.filterRepeatCities(
|
||||
options,
|
||||
allow: request.preferences.allowRepeatCities
|
||||
)
|
||||
|
||||
if options.isEmpty && !request.preferences.allowRepeatCities {
|
||||
let violatingCities = RouteFilters.findRepeatCities(in: originalOptions)
|
||||
return .failure(PlanningFailure(
|
||||
reason: .repeatCityViolation(cities: violatingCities)
|
||||
))
|
||||
}
|
||||
|
||||
// Region filtering is applied during game selection in scenario planners
|
||||
|
||||
return .success(options)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user