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>
178 lines
6.6 KiB
Swift
178 lines
6.6 KiB
Swift
//
|
|
// TripPlanningEngine.swift
|
|
// SportsTime
|
|
//
|
|
// Thin orchestrator that delegates to scenario-specific planners.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Main entry point for trip planning.
|
|
/// Delegates to scenario-specific planners via the ScenarioPlanner protocol.
|
|
///
|
|
/// - Expected Behavior:
|
|
/// - Uses ScenarioPlannerFactory.planner(for:) to select the right planner
|
|
/// - Delegates entirely to the selected scenario planner
|
|
/// - Applies repeat city filter to successful results
|
|
/// - If all options violate repeat city constraint → .failure with .repeatCityViolation
|
|
/// - Passes through failures from scenario planners unchanged
|
|
///
|
|
/// - Invariants:
|
|
/// - Never modifies the logic of scenario planners
|
|
/// - Always returns a result (success or failure), never throws
|
|
/// - Repeat city filter only applied when allowRepeatCities is false
|
|
///
|
|
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.
|
|
///
|
|
/// - Parameter request: The planning request containing all inputs
|
|
/// - 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)
|
|
|
|
// Delegate to the scenario planner
|
|
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.
|
|
/// Tracks excluded options as warnings when valid results remain.
|
|
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)
|
|
let preRepeatCount = options.count
|
|
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)
|
|
))
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|