// // GeographicSanityChecker.swift // SportsTime // import Foundation import CoreLocation /// Validates geographic sanity of routes - no zig-zagging or excessive backtracking. /// Priority 5 in the rule hierarchy. /// /// For Scenario C (directional routes with start+end): /// - Route MUST make net progress toward the end location /// - Temporary increases in distance are allowed only if minor and followed by progress /// - Large backtracking or oscillation is prohibited /// /// For all scenarios: /// - Detects obvious zig-zag patterns (e.g., Chicago → Dallas → San Diego → Minnesota → NY) struct GeographicSanityChecker { // MARK: - Validation Result struct ValidationResult { let isValid: Bool let violations: [ConstraintViolation] let backtrackingDetails: BacktrackingInfo? static let valid = ValidationResult(isValid: true, violations: [], backtrackingDetails: nil) static func backtracking(info: BacktrackingInfo) -> ValidationResult { let violation = ConstraintViolation( type: .geographicSanity, description: info.description, severity: .error ) return ValidationResult(isValid: false, violations: [violation], backtrackingDetails: info) } } struct BacktrackingInfo { let fromCity: String let toCity: String let distanceIncreasePercent: Double let description: String init(fromCity: String, toCity: String, distanceIncreasePercent: Double) { self.fromCity = fromCity self.toCity = toCity self.distanceIncreasePercent = distanceIncreasePercent self.description = "Route backtracks from \(fromCity) to \(toCity) (distance to destination increased by \(String(format: "%.0f", distanceIncreasePercent))%)" } } // MARK: - Configuration /// Maximum allowed distance increase before flagging as backtracking (percentage) private let maxAllowedDistanceIncrease: Double = 0.15 // 15% /// Number of consecutive distance increases before flagging as zig-zag private let maxConsecutiveIncreases: Int = 2 // MARK: - Scenario C: Directional Route Validation /// Validates that a route makes monotonic progress toward the end location. /// This is the primary validation for Scenario C (start + end location). /// /// - Parameters: /// - stops: Ordered array of stops in the route /// - endCoordinate: The target destination coordinate /// - Returns: ValidationResult indicating if route has valid directional progress func validateDirectionalProgress( stops: [ItineraryStop], endCoordinate: CLLocationCoordinate2D ) -> ValidationResult { guard stops.count >= 2 else { return .valid // Single stop or empty route is trivially valid } var consecutiveIncreases = 0 var previousDistance: CLLocationDistance? var previousCity: String? for stop in stops { guard let coordinate = stop.coordinate else { continue } let currentDistance = distance(from: coordinate, to: endCoordinate) if let prevDist = previousDistance, let prevCity = previousCity { if currentDistance > prevDist { // Distance to end increased - potential backtracking let increasePercent = (currentDistance - prevDist) / prevDist consecutiveIncreases += 1 // Check if this increase is too large if increasePercent > maxAllowedDistanceIncrease { return .backtracking(info: BacktrackingInfo( fromCity: prevCity, toCity: stop.city, distanceIncreasePercent: increasePercent * 100 )) } // Check for oscillation (too many consecutive increases) if consecutiveIncreases >= maxConsecutiveIncreases { return .backtracking(info: BacktrackingInfo( fromCity: prevCity, toCity: stop.city, distanceIncreasePercent: increasePercent * 100 )) } } else { // Making progress - reset counter consecutiveIncreases = 0 } } previousDistance = currentDistance previousCity = stop.city } return .valid } // MARK: - General Geographic Sanity /// Validates that a route doesn't have obvious zig-zag patterns. /// Uses compass bearing analysis to detect direction reversals. /// /// - Parameter stops: Ordered array of stops in the route /// - Returns: ValidationResult indicating if route is geographically sane func validateNoZigZag(stops: [ItineraryStop]) -> ValidationResult { guard stops.count >= 3 else { return .valid // Need at least 3 stops to detect zig-zag } var bearingReversals = 0 var previousBearing: Double? for i in 0..<(stops.count - 1) { guard let from = stops[i].coordinate, let to = stops[i + 1].coordinate else { continue } let currentBearing = bearing(from: from, to: to) if let prevBearing = previousBearing { // Check if we've reversed direction (>90 degree change) let bearingChange = abs(normalizedBearingDifference(prevBearing, currentBearing)) if bearingChange > 90 { bearingReversals += 1 } } previousBearing = currentBearing } // Allow at most one major direction change (e.g., going east then north is fine) // But multiple reversals indicate zig-zagging if bearingReversals > 1 { return .backtracking(info: BacktrackingInfo( fromCity: stops.first?.city ?? "Start", toCity: stops.last?.city ?? "End", distanceIncreasePercent: Double(bearingReversals) * 30 // Rough estimate )) } return .valid } /// Validates a complete route for both directional progress (if end is specified) /// and general geographic sanity. /// /// - Parameters: /// - stops: Ordered array of stops /// - endCoordinate: Optional end coordinate for directional validation /// - Returns: Combined validation result func validate( stops: [ItineraryStop], endCoordinate: CLLocationCoordinate2D? ) -> ValidationResult { // If we have an end coordinate, validate directional progress if let end = endCoordinate { let directionalResult = validateDirectionalProgress(stops: stops, endCoordinate: end) if !directionalResult.isValid { return directionalResult } } // Always check for zig-zag patterns return validateNoZigZag(stops: stops) } // MARK: - Helper Methods /// Calculates distance between two coordinates in meters. private func distance( from: CLLocationCoordinate2D, to: CLLocationCoordinate2D ) -> CLLocationDistance { let fromLocation = CLLocation(latitude: from.latitude, longitude: from.longitude) let toLocation = CLLocation(latitude: to.latitude, longitude: to.longitude) return fromLocation.distance(from: toLocation) } /// Calculates bearing (direction) from one coordinate to another in degrees. private func bearing( from: CLLocationCoordinate2D, to: CLLocationCoordinate2D ) -> Double { let lat1 = from.latitude * .pi / 180 let lat2 = to.latitude * .pi / 180 let dLon = (to.longitude - from.longitude) * .pi / 180 let y = sin(dLon) * cos(lat2) let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon) var bearing = atan2(y, x) * 180 / .pi bearing = (bearing + 360).truncatingRemainder(dividingBy: 360) return bearing } /// Calculates the normalized difference between two bearings (-180 to 180). private func normalizedBearingDifference(_ bearing1: Double, _ bearing2: Double) -> Double { var diff = bearing2 - bearing1 while diff > 180 { diff -= 360 } while diff < -180 { diff += 360 } return diff } }