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:
Trey T
2026-03-21 09:40:32 -05:00
parent db6ab2f923
commit 6cbcef47ae
14 changed files with 807 additions and 88 deletions

View File

@@ -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)
}