Add implementation code for all 4 improvement plan phases
Production changes: - TravelEstimator: remove 300mi fallback, return nil on missing coords - TripPlanningEngine: add warnings array, empty sports warning, inverted date range rejection, must-stop filter, segment validation gate - GameDAGRouter: add routePreference parameter with preference-aware bucket ordering and sorting in selectDiverseRoutes() - ScenarioA-E: pass routePreference through to GameDAGRouter - ScenarioA: track games with missing stadium data - ScenarioE: add region filtering for home games - TravelSegment: add requiresOvernightStop and travelDays() helpers Test changes: - GameDAGRouterTests: +252 lines for route preference verification - TripPlanningEngineTests: +153 lines for segment validation, date range, empty sports - ScenarioEPlannerTests: +119 lines for region filter tests - TravelEstimatorTests: remove obsolete fallback distance tests - ItineraryBuilderTests: update nil-coords test expectation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -140,7 +140,8 @@ enum GameDAGRouter {
|
||||
constraints: DrivingConstraints,
|
||||
anchorGameIds: Set<String> = [],
|
||||
allowRepeatCities: Bool = true,
|
||||
beamWidth: Int = defaultBeamWidth
|
||||
beamWidth: Int = defaultBeamWidth,
|
||||
routePreference: RoutePreference = .balanced
|
||||
) -> [[Game]] {
|
||||
|
||||
// Edge cases
|
||||
@@ -254,7 +255,7 @@ enum GameDAGRouter {
|
||||
}
|
||||
|
||||
// Step 6: Final diversity selection
|
||||
let finalRoutes = selectDiverseRoutes(routesWithAnchors, stadiums: stadiums, maxCount: maxOptions)
|
||||
let finalRoutes = selectDiverseRoutes(routesWithAnchors, stadiums: stadiums, maxCount: maxOptions, routePreference: routePreference)
|
||||
|
||||
#if DEBUG
|
||||
print("🔍 DAG: Input games=\(games.count), beam=\(beam.count), withAnchors=\(routesWithAnchors.count), final=\(finalRoutes.count)")
|
||||
@@ -269,6 +270,7 @@ enum GameDAGRouter {
|
||||
stadiums: [String: Stadium],
|
||||
anchorGameIds: Set<String> = [],
|
||||
allowRepeatCities: Bool = true,
|
||||
routePreference: RoutePreference = .balanced,
|
||||
stopBuilder: ([Game], [String: Stadium]) -> [ItineraryStop]
|
||||
) -> [[Game]] {
|
||||
let constraints = DrivingConstraints.default
|
||||
@@ -277,7 +279,8 @@ enum GameDAGRouter {
|
||||
stadiums: stadiums,
|
||||
constraints: constraints,
|
||||
anchorGameIds: anchorGameIds,
|
||||
allowRepeatCities: allowRepeatCities
|
||||
allowRepeatCities: allowRepeatCities,
|
||||
routePreference: routePreference
|
||||
)
|
||||
}
|
||||
|
||||
@@ -292,7 +295,8 @@ enum GameDAGRouter {
|
||||
private static func selectDiverseRoutes(
|
||||
_ routes: [[Game]],
|
||||
stadiums: [String: Stadium],
|
||||
maxCount: Int
|
||||
maxCount: Int,
|
||||
routePreference: RoutePreference = .balanced
|
||||
) -> [[Game]] {
|
||||
guard !routes.isEmpty else { return [] }
|
||||
|
||||
@@ -319,8 +323,9 @@ enum GameDAGRouter {
|
||||
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) {
|
||||
if let candidates = byGames[bucket] {
|
||||
let sorted = sortByPreference(candidates, routePreference: routePreference)
|
||||
if let best = sorted.first(where: { !selectedKeys.contains($0.uniqueKey) }) {
|
||||
selected.append(best)
|
||||
selectedKeys.insert(best.uniqueKey)
|
||||
}
|
||||
@@ -331,8 +336,10 @@ enum GameDAGRouter {
|
||||
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 {
|
||||
let candidates = (byCities[bucket] ?? []).filter { !selectedKeys.contains($0.uniqueKey) }
|
||||
if !candidates.isEmpty {
|
||||
let sorted = sortByPreference(candidates, routePreference: routePreference)
|
||||
if let best = sorted.first {
|
||||
selected.append(best)
|
||||
selectedKeys.insert(best.uniqueKey)
|
||||
}
|
||||
@@ -340,8 +347,20 @@ enum GameDAGRouter {
|
||||
}
|
||||
|
||||
// Pass 3: Ensure at least one route per mileage bucket
|
||||
// Bias bucket iteration order based on route preference
|
||||
let byMiles = Dictionary(grouping: uniqueProfiles) { $0.milesBucket }
|
||||
for bucket in byMiles.keys.sorted() {
|
||||
let milesBucketOrder: [Int]
|
||||
switch routePreference {
|
||||
case .direct:
|
||||
// Prioritize low mileage buckets first
|
||||
milesBucketOrder = byMiles.keys.sorted()
|
||||
case .scenic:
|
||||
// Prioritize high mileage buckets first (more cities = more scenic)
|
||||
milesBucketOrder = byMiles.keys.sorted(by: >)
|
||||
case .balanced:
|
||||
milesBucketOrder = byMiles.keys.sorted()
|
||||
}
|
||||
for bucket in milesBucketOrder {
|
||||
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 {
|
||||
@@ -355,8 +374,10 @@ enum GameDAGRouter {
|
||||
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 {
|
||||
let candidates = (byDays[bucket] ?? []).filter { !selectedKeys.contains($0.uniqueKey) }
|
||||
if !candidates.isEmpty {
|
||||
let sorted = sortByPreference(candidates, routePreference: routePreference)
|
||||
if let best = sorted.first {
|
||||
selected.append(best)
|
||||
selectedKeys.insert(best.uniqueKey)
|
||||
}
|
||||
@@ -391,11 +412,24 @@ enum GameDAGRouter {
|
||||
if !addedAny { break }
|
||||
}
|
||||
|
||||
// Pass 6: If still need more, add remaining sorted by efficiency
|
||||
// Pass 6: If still need more, add remaining sorted by route preference
|
||||
if selected.count < maxCount {
|
||||
let stillRemaining = uniqueProfiles
|
||||
.filter { !selectedKeys.contains($0.uniqueKey) }
|
||||
.sorted { efficiency(for: $0) > efficiency(for: $1) }
|
||||
.sorted { a, b in
|
||||
switch routePreference {
|
||||
case .direct:
|
||||
// Prefer lowest mileage routes
|
||||
return a.totalMiles < b.totalMiles
|
||||
case .scenic:
|
||||
// Prefer routes with more unique cities
|
||||
if a.cityCount != b.cityCount { return a.cityCount > b.cityCount }
|
||||
return a.totalMiles > b.totalMiles
|
||||
case .balanced:
|
||||
// Use efficiency (games per driving hour)
|
||||
return efficiency(for: a) > efficiency(for: b)
|
||||
}
|
||||
}
|
||||
|
||||
for profile in stillRemaining.prefix(maxCount - selected.count) {
|
||||
selected.append(profile)
|
||||
@@ -509,6 +543,27 @@ enum GameDAGRouter {
|
||||
return Double(profile.gameCount) / drivingHours
|
||||
}
|
||||
|
||||
/// Sorts route profiles within a bucket based on route preference.
|
||||
/// - Direct: lowest mileage first
|
||||
/// - Scenic: most cities first, then highest mileage
|
||||
/// - Balanced: best efficiency (games per driving hour)
|
||||
private static func sortByPreference(
|
||||
_ profiles: [RouteProfile],
|
||||
routePreference: RoutePreference
|
||||
) -> [RouteProfile] {
|
||||
profiles.sorted { a, b in
|
||||
switch routePreference {
|
||||
case .direct:
|
||||
return a.totalMiles < b.totalMiles
|
||||
case .scenic:
|
||||
if a.cityCount != b.cityCount { return a.cityCount > b.cityCount }
|
||||
return a.totalMiles > b.totalMiles
|
||||
case .balanced:
|
||||
return efficiency(for: a) > efficiency(for: b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Day Bucketing
|
||||
|
||||
private static func bucketByDay(games: [Game]) -> [Int: [Game]] {
|
||||
@@ -541,8 +596,14 @@ enum GameDAGRouter {
|
||||
// Time must move forward
|
||||
guard to.startTime > from.startTime else { return false }
|
||||
|
||||
// Same stadium = always feasible
|
||||
if from.stadiumId == to.stadiumId { return true }
|
||||
// Same stadium: check for sufficient time gap between games
|
||||
if from.stadiumId == to.stadiumId {
|
||||
let estimatedGameDurationHours: Double = 3.0
|
||||
let departureTime = from.startTime.addingTimeInterval(estimatedGameDurationHours * 3600)
|
||||
let hoursAvailable = to.startTime.timeIntervalSince(departureTime) / 3600.0
|
||||
let minGapHours: Double = 1.0
|
||||
return hoursAvailable >= minGapHours
|
||||
}
|
||||
|
||||
// Get stadiums
|
||||
guard let fromStadium = stadiums[from.stadiumId],
|
||||
@@ -621,7 +682,7 @@ enum GameDAGRouter {
|
||||
|
||||
guard let fromStadium = stadiums[from.stadiumId],
|
||||
let toStadium = stadiums[to.stadiumId] else {
|
||||
return 300 // Fallback estimate
|
||||
return 0 // Missing stadium data — cannot estimate distance
|
||||
}
|
||||
|
||||
return TravelEstimator.haversineDistanceMiles(
|
||||
|
||||
Reference in New Issue
Block a user