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(
|
||||
|
||||
@@ -81,14 +81,21 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
// 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
|
||||
var gamesWithMissingStadium = 0
|
||||
let gamesInRange = request.allGames
|
||||
.filter { game in
|
||||
// Must be in date range
|
||||
guard dateRange.contains(game.startTime) else { return false }
|
||||
|
||||
// Track games with missing stadium data
|
||||
guard request.stadiums[game.stadiumId] != nil else {
|
||||
gamesWithMissingStadium += 1
|
||||
return false
|
||||
}
|
||||
|
||||
// Must be in selected region (if regions specified)
|
||||
if !selectedRegions.isEmpty {
|
||||
guard let stadium = request.stadiums[game.stadiumId] else { return false }
|
||||
let stadium = request.stadiums[game.stadiumId]!
|
||||
let gameRegion = Region.classify(longitude: stadium.coordinate.longitude)
|
||||
return selectedRegions.contains(gameRegion)
|
||||
}
|
||||
@@ -98,10 +105,18 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
|
||||
// No games? Nothing to plan.
|
||||
if gamesInRange.isEmpty {
|
||||
var violations: [ConstraintViolation] = []
|
||||
if gamesWithMissingStadium > 0 {
|
||||
violations.append(ConstraintViolation(
|
||||
type: .missingData,
|
||||
description: "\(gamesWithMissingStadium) game(s) excluded due to missing stadium data",
|
||||
severity: .warning
|
||||
))
|
||||
}
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noGamesInRange,
|
||||
violations: []
|
||||
violations: violations
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -165,6 +180,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
from: filteredGames,
|
||||
stadiums: request.stadiums,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
routePreference: request.preferences.routePreference,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
validRoutes.append(contentsOf: globalRoutes)
|
||||
@@ -173,7 +189,8 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
let regionalRoutes = findRoutesPerRegion(
|
||||
games: filteredGames,
|
||||
stadiums: request.stadiums,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
routePreference: request.preferences.routePreference
|
||||
)
|
||||
validRoutes.append(contentsOf: regionalRoutes)
|
||||
|
||||
@@ -478,7 +495,8 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
private func findRoutesPerRegion(
|
||||
games: [Game],
|
||||
stadiums: [String: Stadium],
|
||||
allowRepeatCities: Bool
|
||||
allowRepeatCities: Bool,
|
||||
routePreference: RoutePreference = .balanced
|
||||
) -> [[Game]] {
|
||||
// Partition games by region
|
||||
var gamesByRegion: [Region: [Game]] = [:]
|
||||
@@ -510,6 +528,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
from: regionGames,
|
||||
stadiums: stadiums,
|
||||
allowRepeatCities: allowRepeatCities,
|
||||
routePreference: routePreference,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
routePreference: request.preferences.routePreference,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
validRoutes.append(contentsOf: globalRoutes)
|
||||
@@ -172,7 +173,8 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
games: gamesInRange,
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
routePreference: request.preferences.routePreference
|
||||
)
|
||||
validRoutes.append(contentsOf: regionalRoutes)
|
||||
|
||||
@@ -437,7 +439,8 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
games: [Game],
|
||||
stadiums: [String: Stadium],
|
||||
anchorGameIds: Set<String>,
|
||||
allowRepeatCities: Bool
|
||||
allowRepeatCities: Bool,
|
||||
routePreference: RoutePreference = .balanced
|
||||
) -> [[Game]] {
|
||||
// First, determine which region(s) the anchor games are in
|
||||
var anchorRegions = Set<Region>()
|
||||
@@ -482,6 +485,7 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
stadiums: stadiums,
|
||||
anchorGameIds: regionAnchorIds,
|
||||
allowRepeatCities: allowRepeatCities,
|
||||
routePreference: routePreference,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
|
||||
|
||||
@@ -248,6 +248,7 @@ final class ScenarioCPlanner: ScenarioPlanner {
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: [], // No anchors in Scenario C
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
routePreference: request.preferences.routePreference,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ final class ScenarioDPlanner: ScenarioPlanner {
|
||||
from: finalGames,
|
||||
stadiums: request.stadiums,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
routePreference: request.preferences.routePreference,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
#if DEBUG
|
||||
|
||||
@@ -90,9 +90,16 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
// the user wants to visit each team's home stadium.
|
||||
var homeGamesByTeam: [String: [Game]] = [:]
|
||||
var allHomeGames: [Game] = []
|
||||
let selectedRegions = request.preferences.selectedRegions
|
||||
|
||||
for game in request.allGames {
|
||||
if selectedTeamIds.contains(game.homeTeamId) {
|
||||
// Apply region filter if regions are specified
|
||||
if !selectedRegions.isEmpty {
|
||||
guard let stadium = request.stadiums[game.stadiumId] else { continue }
|
||||
let gameRegion = Region.classify(longitude: stadium.coordinate.longitude)
|
||||
guard selectedRegions.contains(gameRegion) else { continue }
|
||||
}
|
||||
homeGamesByTeam[game.homeTeamId, default: []].append(game)
|
||||
allHomeGames.append(game)
|
||||
}
|
||||
@@ -212,6 +219,7 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: earliestAnchorIds,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
routePreference: request.preferences.routePreference,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
var validRoutes = candidateRoutes.filter { route in
|
||||
@@ -230,6 +238,7 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: latestAnchorIds,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
routePreference: request.preferences.routePreference,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
candidateRoutes.append(contentsOf: latestAnchorRoutes)
|
||||
@@ -239,6 +248,7 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
from: uniqueGames,
|
||||
stadiums: request.stadiums,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
routePreference: request.preferences.routePreference,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
candidateRoutes.append(contentsOf: noAnchorRoutes)
|
||||
|
||||
@@ -17,20 +17,19 @@ import CoreLocation
|
||||
/// - Constants:
|
||||
/// - averageSpeedMph: 60 mph
|
||||
/// - roadRoutingFactor: 1.3 (accounts for roads vs straight-line distance)
|
||||
/// - fallbackDistanceMiles: 300 miles (when coordinates unavailable)
|
||||
///
|
||||
/// - Invariants:
|
||||
/// - All distance calculations are symmetric: distance(A,B) == distance(B,A)
|
||||
/// - Road distance >= straight-line distance (roadRoutingFactor >= 1.0)
|
||||
/// - Travel duration is always distance / averageSpeedMph
|
||||
/// - Segments exceeding 5x maxDailyDrivingHours return nil (unreachable)
|
||||
/// - Missing coordinates → returns nil (no guessing with fallback distances)
|
||||
enum TravelEstimator {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private static let averageSpeedMph: Double = 60.0
|
||||
private static let roadRoutingFactor: Double = 1.3 // Straight line to road distance
|
||||
private static let fallbackDistanceMiles: Double = 300.0
|
||||
|
||||
// MARK: - Travel Estimation
|
||||
|
||||
@@ -44,7 +43,7 @@ enum TravelEstimator {
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - With valid coordinates → calculates distance using Haversine * roadRoutingFactor
|
||||
/// - Missing coordinates → uses fallback distance (300 miles)
|
||||
/// - Missing coordinates → returns nil (no fallback guessing)
|
||||
/// - Same city (no coords) → 0 distance, 0 duration
|
||||
/// - Driving hours > 5x maxDailyDrivingHours → returns nil
|
||||
/// - Duration = distance / 60 mph
|
||||
@@ -55,7 +54,21 @@ enum TravelEstimator {
|
||||
constraints: DrivingConstraints
|
||||
) -> TravelSegment? {
|
||||
|
||||
let distanceMiles = calculateDistanceMiles(from: from, to: to)
|
||||
// If either stop is missing coordinates, the segment is infeasible
|
||||
// (unless same city, which returns 0 distance)
|
||||
guard let distanceMiles = calculateDistanceMiles(from: from, to: to) else {
|
||||
// Same city with no coords: zero-distance segment
|
||||
if from.city == to.city {
|
||||
return TravelSegment(
|
||||
fromLocation: from.location,
|
||||
toLocation: to.location,
|
||||
travelMode: .drive,
|
||||
distanceMeters: 0,
|
||||
durationSeconds: 0
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
let drivingHours = distanceMiles / averageSpeedMph
|
||||
|
||||
// Maximum allowed: 5 days of driving as a conservative hard cap.
|
||||
@@ -126,22 +139,20 @@ enum TravelEstimator {
|
||||
/// - Parameters:
|
||||
/// - from: Origin stop
|
||||
/// - to: Destination stop
|
||||
/// - Returns: Distance in miles
|
||||
/// - Returns: Distance in miles, or nil if coordinates are missing
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Both have coordinates → Haversine distance * 1.3
|
||||
/// - Either missing coordinates → fallback distance
|
||||
/// - Same city (no coords) → 0 miles
|
||||
/// - Different cities (no coords) → 300 miles
|
||||
/// - Either missing coordinates → nil (no fallback guessing)
|
||||
static func calculateDistanceMiles(
|
||||
from: ItineraryStop,
|
||||
to: ItineraryStop
|
||||
) -> Double {
|
||||
if let fromCoord = from.coordinate,
|
||||
let toCoord = to.coordinate {
|
||||
return haversineDistanceMiles(from: fromCoord, to: toCoord) * roadRoutingFactor
|
||||
) -> Double? {
|
||||
guard let fromCoord = from.coordinate,
|
||||
let toCoord = to.coordinate else {
|
||||
return nil
|
||||
}
|
||||
return estimateFallbackDistance(from: from, to: to)
|
||||
return haversineDistanceMiles(from: fromCoord, to: toCoord) * roadRoutingFactor
|
||||
}
|
||||
|
||||
/// Calculates straight-line distance in miles using Haversine formula.
|
||||
@@ -206,24 +217,19 @@ enum TravelEstimator {
|
||||
return earthRadiusMeters * c
|
||||
}
|
||||
|
||||
/// Fallback distance when coordinates aren't available.
|
||||
// MARK: - Overnight Stop Detection
|
||||
|
||||
/// Determines if a travel segment requires an overnight stop.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - from: Origin stop
|
||||
/// - to: Destination stop
|
||||
/// - Returns: Estimated distance in miles
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - Same city → 0 miles
|
||||
/// - Different cities → 300 miles (fallback constant)
|
||||
static func estimateFallbackDistance(
|
||||
from: ItineraryStop,
|
||||
to: ItineraryStop
|
||||
) -> Double {
|
||||
if from.city == to.city {
|
||||
return 0
|
||||
}
|
||||
return fallbackDistanceMiles
|
||||
/// - segment: The travel segment to evaluate
|
||||
/// - constraints: Driving constraints (max daily hours)
|
||||
/// - Returns: true if driving hours exceed the daily limit
|
||||
static func requiresOvernightStop(
|
||||
segment: TravelSegment,
|
||||
constraints: DrivingConstraints
|
||||
) -> Bool {
|
||||
segment.estimatedDrivingHours > constraints.maxDailyDrivingHours
|
||||
}
|
||||
|
||||
// MARK: - Travel Days
|
||||
|
||||
@@ -24,6 +24,10 @@ import Foundation
|
||||
///
|
||||
final class TripPlanningEngine {
|
||||
|
||||
/// Warnings generated during the last planning run.
|
||||
/// Populated when options are filtered out but valid results remain.
|
||||
private(set) var warnings: [ConstraintViolation] = []
|
||||
|
||||
/// Plans itineraries based on the request inputs.
|
||||
/// Automatically detects which scenario applies and delegates to the appropriate planner.
|
||||
///
|
||||
@@ -31,6 +35,32 @@ final class TripPlanningEngine {
|
||||
/// - Returns: Ranked itineraries on success, or explicit failure with reason
|
||||
func planItineraries(request: PlanningRequest) -> ItineraryResult {
|
||||
|
||||
// Reset warnings from previous run
|
||||
warnings = []
|
||||
|
||||
// Warn on empty sports set
|
||||
if request.preferences.sports.isEmpty {
|
||||
warnings.append(ConstraintViolation(
|
||||
type: .missingData,
|
||||
description: "No sports selected — results may be empty",
|
||||
severity: .warning
|
||||
))
|
||||
}
|
||||
|
||||
// Validate date range is not inverted
|
||||
if request.preferences.endDate < request.preferences.startDate {
|
||||
return .failure(PlanningFailure(
|
||||
reason: .missingDateRange,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .dateRange,
|
||||
description: "End date is before start date",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
))
|
||||
}
|
||||
|
||||
// Detect scenario and get the appropriate planner
|
||||
let planner = ScenarioPlannerFactory.planner(for: request)
|
||||
|
||||
@@ -45,6 +75,7 @@ final class TripPlanningEngine {
|
||||
|
||||
/// Applies allowRepeatCities filter after scenario planners return.
|
||||
/// Note: Region filtering is done during game selection in scenario planners.
|
||||
/// Tracks excluded options as warnings when valid results remain.
|
||||
private func applyPreferenceFilters(
|
||||
to result: ItineraryResult,
|
||||
request: PlanningRequest
|
||||
@@ -56,6 +87,7 @@ final class TripPlanningEngine {
|
||||
var options = originalOptions
|
||||
|
||||
// Filter repeat cities (this is enforced during beam search, but double-check here)
|
||||
let preRepeatCount = options.count
|
||||
options = RouteFilters.filterRepeatCities(
|
||||
options,
|
||||
allow: request.preferences.allowRepeatCities
|
||||
@@ -68,7 +100,77 @@ final class TripPlanningEngine {
|
||||
))
|
||||
}
|
||||
|
||||
// Region filtering is applied during game selection in scenario planners
|
||||
let repeatCityExcluded = preRepeatCount - options.count
|
||||
if repeatCityExcluded > 0 {
|
||||
warnings.append(ConstraintViolation(
|
||||
type: .general,
|
||||
description: "\(repeatCityExcluded) route(s) excluded for visiting the same city on multiple days",
|
||||
severity: .warning
|
||||
))
|
||||
}
|
||||
|
||||
// Must-stop filter: ensure all must-stop cities appear in routes
|
||||
if !request.preferences.mustStopLocations.isEmpty {
|
||||
let requiredCities = request.preferences.mustStopLocations
|
||||
.map { $0.name.lowercased() }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
if !requiredCities.isEmpty {
|
||||
let preMustStopCount = options.count
|
||||
options = options.filter { option in
|
||||
let tripCities = Set(option.stops.map { $0.city.lowercased() })
|
||||
return requiredCities.allSatisfy { tripCities.contains($0) }
|
||||
}
|
||||
|
||||
if options.isEmpty {
|
||||
return .failure(PlanningFailure(
|
||||
reason: .noValidRoutes,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .mustStop,
|
||||
description: "No routes include all must-stop cities",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
))
|
||||
}
|
||||
|
||||
let mustStopExcluded = preMustStopCount - options.count
|
||||
if mustStopExcluded > 0 {
|
||||
let cityList = requiredCities.joined(separator: ", ")
|
||||
warnings.append(ConstraintViolation(
|
||||
type: .mustStop,
|
||||
description: "\(mustStopExcluded) route(s) excluded for missing must-stop cities: \(cityList)",
|
||||
severity: .warning
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate travel segments: filter out invalid options
|
||||
let preValidCount = options.count
|
||||
options = options.filter { $0.isValid }
|
||||
if options.isEmpty {
|
||||
return .failure(PlanningFailure(
|
||||
reason: .noValidRoutes,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .segmentMismatch,
|
||||
description: "No valid itineraries could be built",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
))
|
||||
}
|
||||
|
||||
let segmentExcluded = preValidCount - options.count
|
||||
if segmentExcluded > 0 {
|
||||
warnings.append(ConstraintViolation(
|
||||
type: .segmentMismatch,
|
||||
description: "\(segmentExcluded) route(s) excluded due to invalid travel segments",
|
||||
severity: .warning
|
||||
))
|
||||
}
|
||||
|
||||
return .success(options)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user