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:
@@ -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