package routing import ( "context" "fmt" "sort" "trip-planner/internal/yandex" ) // Edge represents a graph edge connecting two nodes. type Edge struct { From *Node To *Node Kind EdgeKind Duration int // travel time in seconds Transport string // transport type (train, plane, bus) TransportType string // deprecated: use Transport instead IsTransfer bool // whether this edge involves a transfer Departure string // ISO 8601 departure time Arrival string // ISO 8601 arrival time } // NodeType represents the type of a graph node. type NodeType int const ( // NodeTypeStation represents a train station. NodeTypeStation NodeType = iota // NodeTypeCity represents a city (used as hub/synthetic edge connection point). NodeTypeCity ) // Node represents a graph node (station or city). type Node struct { ID string Type NodeType Name string // display name (station title or city name) CityCode string // for stations, the city code they belong to } // EdgeKind represents the kind of edge in the graph. type EdgeKind int const ( // EdgeKindReal represents a real scheduled trip (actual route segment). EdgeKindReal EdgeKind = iota // EdgeKindSynthetic represents a synthetic transfer edge (e.g., city↔airport). EdgeKindSynthetic ) // hubCriteria defines the criteria for selecting hub stations. type hubCriteria struct { minPopulation int // minimum city population in millions to be considered a hub minOutgoingFlights int // minimum number of outgoing Yandex flights to be considered a hub defaultOutgoingFlights int // default outgoing flights count when data is unavailable } // HubStation represents a selected hub station with its selection rationale. type HubStation struct { // Station is the underlying station node. Station *Node // CityCode is the city the station belongs to. CityCode string // OutgoingFlights is the estimated number of outgoing Yandex flights from this station. OutgoingFlights int // Population is the city population in millions used for hub selection. Population int // IsHub indicates whether this station meets the hub criteria. IsHub bool } // HubStationSelectionResult holds the results of hub station selection. type HubStationSelectionResult struct { // Hubs are the selected hub stations sorted by priority. Hubs []*HubStation // Rejected are stations that don't meet hub criteria, with reason. Rejected []*HubStation } // SelectHubStations selects hub stations from a list based on criteria. // Hubs are selected based on: population (million+ cities), number of outgoing Yandex flights. func SelectHubStations(stations []StationInfo, criteria hubCriteria) HubStationSelectionResult { result := HubStationSelectionResult{ Hubs: []*HubStation{}, Rejected: []*HubStation{}, } for _, si := range stations { hub := &HubStation{ Station: &Node{ID: si.ID, Type: NodeTypeStation, Name: si.Name, CityCode: si.CityCode}, CityCode: si.CityCode, OutgoingFlights: criteria.defaultOutgoingFlights, Population: 0, // will be inferred from city code later IsHub: false, } // A station is considered a hub if: // 1. It has >= minOutgoingFlights (outgoing Yandex flight data available) - primary criterion // For MVP, outgoing flights is the primary criterion. hasOutgoingFlights := hub.OutgoingFlights >= criteria.minOutgoingFlights if hasOutgoingFlights { hub.IsHub = true result.Hubs = append(result.Hubs, hub) } else { result.Rejected = append(result.Rejected, hub) } } return result } // StationInfo holds station information for graph building from a station directory. type StationInfo struct { ID string Name string CityCode string CityName string } // Graph represents a routing graph with nodes (stations/cities) and edges (scheduled trips/transfers). type Graph struct { nodes []*Node edges []*Edge yandexClient *yandex.Client // Yandex API client for on-demand /search calls } // NewGraph creates a new empty routing graph. func NewGraph(yandexClient *yandex.Client) *Graph { return &Graph{ nodes: []*Node{}, edges: []*Edge{}, yandexClient: yandexClient, } } // NewGraphWithoutYandex creates a new empty routing graph without a Yandex client. // This is useful for testing or when Yandex API is not available. func NewGraphWithoutYandex() *Graph { return &Graph{ nodes: []*Node{}, edges: []*Edge{}, yandexClient: nil, } } // AddNode adds a node to the graph. func (g *Graph) AddNode(node *Node) { g.nodes = append(g.nodes, node) } // AddEdge adds an edge to the graph. func (g *Graph) AddEdge(edge *Edge) { g.edges = append(g.edges, edge) } // Nodes returns all nodes in the graph. func (g *Graph) Nodes() []*Node { result := make([]*Node, len(g.nodes)) copy(result, g.nodes) return result } // Edges returns all edges in the graph. func (g *Graph) Edges() []*Edge { result := make([]*Edge, len(g.edges)) copy(result, g.edges) return result } // BuildGraphFromStations builds a routing graph from a list of station info records. // It creates station nodes and city hub nodes, with synthetic edges connecting // stations to their city hubs. func BuildGraphFromStations(stations []StationInfo) *Graph { graph := NewGraphWithoutYandex() // Track city nodes by code to avoid duplicates cityNodes := make(map[string]*Node) // Add all station nodes and create/connect city hub nodes for _, si := range stations { // Add station node station := &Node{ ID: si.ID, Type: NodeTypeStation, Name: si.Name, CityCode: si.CityCode, } graph.AddNode(station) // Create or retrieve city hub node cityKey := "city:" + si.CityCode if _, exists := cityNodes[si.CityCode]; !exists { cityNode := &Node{ ID: cityKey, Type: NodeTypeCity, Name: si.CityName, } graph.AddNode(cityNode) cityNodes[si.CityCode] = cityNode } // Add synthetic edge: station <-> city hub cityNode := cityNodes[si.CityCode] graph.AddEdge(&Edge{ From: station, To: cityNode, Kind: EdgeKindSynthetic, Duration: 300, // 5 min synthetic transfer Transport: "train", IsTransfer: true, }) // Add reverse synthetic edge: city hub -> station graph.AddEdge(&Edge{ From: cityNode, To: station, Kind: EdgeKindSynthetic, Duration: 300, // 5 min synthetic transfer Transport: "train", IsTransfer: true, }) } return graph } // BuildGraphFromHubs builds a routing graph from a list of station info records, // focusing on hub stations. It creates station nodes and city hub nodes with // synthetic edges connecting stations to their city hubs, similar to // BuildGraphFromStations but optimized for hub-based lazy expansion. func BuildGraphFromHubs(stations []StationInfo, hubCriteria hubCriteria) *Graph { graph := NewGraphWithoutYandex() // Select hub stations based on criteria selection := SelectHubStations(stations, hubCriteria) // Track city nodes by code to avoid duplicates cityNodes := make(map[string]*Node) // Add hub station nodes and create/connect city hub nodes for _, hub := range selection.Hubs { si := findStationByID(stations, hub.Station.ID) // Add station node station := &Node{ ID: hub.Station.ID, Type: NodeTypeStation, Name: hub.Station.Name, CityCode: hub.CityCode, } graph.AddNode(station) // Create or retrieve city hub node cityKey := "city:" + hub.CityCode if _, exists := cityNodes[hub.CityCode]; !exists { cityNode := &Node{ ID: cityKey, Type: NodeTypeCity, Name: si.CityName, } graph.AddNode(cityNode) cityNodes[hub.CityCode] = cityNode } cityNode := cityNodes[hub.CityCode] // Add synthetic edge: station <-> city hub graph.AddEdge(&Edge{ From: station, To: cityNode, Kind: EdgeKindSynthetic, Duration: 300, // 5 min synthetic transfer Transport: "train", IsTransfer: true, }) // Add reverse synthetic edge: city hub -> station graph.AddEdge(&Edge{ From: cityNode, To: station, Kind: EdgeKindSynthetic, Duration: 300, // 5 min synthetic transfer Transport: "train", IsTransfer: true, }) } // Also add non-hub station nodes without city connections (they'll be expanded lazily) for _, s := range stations { if !hubExists(selection.Hubs, s.ID) { // Add station node without city connection for lazy expansion station := &Node{ ID: s.ID, Type: NodeTypeStation, Name: s.Name, CityCode: s.CityCode, } graph.AddNode(station) } } return graph } // SortEdges sorts edges by duration in ascending order (shortest first). // ExpandGraphLazy on-demand adds edges from the current node to hub candidates. // This enables graph expansion during BFS route search without pre-building the // complete graph, staying within API quota constraints. // date is used for cache TTL selection (near-term: 2-6h, far-term: 7d). func (g *Graph) ExpandGraphLazy(currentNode *Node, destCityCode string, date string) error { if currentNode == nil { return fmt.Errorf("currentNode cannot be nil") } switch currentNode.Type { case NodeTypeStation: return expandFromStation(g, currentNode, destCityCode, date) case NodeTypeCity: return expandFromCityHub(g, currentNode, destCityCode, date) default: return fmt.Errorf("unsupported node type: %d", currentNode.Type) } } // expandFromStation expands from a station node by adding on-demand edges // to hub candidates and the destination city hub via Yandex /search API. // Uses cache to avoid repeated API calls for the same (from:to:date) query. func expandFromStation(g *Graph, from *Node, destCityCode string, date string) error { destCityNodeID := "city:" + destCityCode destCityNode := g.NodesByID(destCityNodeID) if destCityNode == nil { destCityNode = &Node{ ID: destCityNodeID, Type: NodeTypeCity, Name: destCityCode, } g.AddNode(destCityNode) } // Call Yandex /search/ API for on-demand route search if g.yandexClient != nil { resp, err := g.yandexClient.SearchRoutes(context.Background(), from.ID, destCityNodeID, date) if err != nil { // If API fails, fall back to synthetic edge edge := &Edge{ From: from, To: destCityNode, Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, } g.AddEdge(edge) reverseEdge := &Edge{ From: destCityNode, To: from, Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, } g.AddEdge(reverseEdge) return nil } // Process search results and create real edges from intervals/segments // For now, add synthetic edges as fallback while we parse the response if resp != nil && len(resp.Intervals) > 0 { // Create edges from actual scheduled intervals for _, interval := range resp.Intervals[:1] { // Limit to first interval for now edge := &Edge{ From: from, To: destCityNode, Kind: EdgeKindReal, Duration: interval.Duration, Transport: interval.From.TransportType, IsTransfer: false, Departure: interval.Departure, Arrival: interval.Arrival, } g.AddEdge(edge) // Reverse edge reverseEdge := &Edge{ From: destCityNode, To: from, Kind: EdgeKindReal, Duration: interval.Duration, Transport: interval.From.TransportType, IsTransfer: false, Departure: interval.Arrival, Arrival: interval.Departure, } g.AddEdge(reverseEdge) } } else { // Fall back to synthetic edge edge := &Edge{ From: from, To: destCityNode, Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, } g.AddEdge(edge) reverseEdge := &Edge{ From: destCityNode, To: from, Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, } g.AddEdge(reverseEdge) } } else { // No Yandex client - add synthetic edges as fallback edge := &Edge{ From: from, To: destCityNode, Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, } g.AddEdge(edge) reverseEdge := &Edge{ From: destCityNode, To: from, Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, } g.AddEdge(reverseEdge) } return nil } // expandFromCityHub expands from a city hub node by adding on-demand edges // to station hubs in the target city. func expandFromCityHub(g *Graph, from *Node, destCityCode string, date string) error { // Call Yandex /search/ API for on-demand route search from city hub to station hubs if g.yandexClient != nil { // Search from a station in the origin city to hub stations in the destination city // Use a representative station ID from the origin city sampleStationIDs := []string{"s9600213", "s9600396", "s9600157"} // Moscow, Simferopol examples var foundEdges bool for _, stationID := range sampleStationIDs { resp, err := g.yandexClient.SearchRoutes(context.Background(), stationID, "city:"+destCityCode, date) if err != nil { continue } // Process search results and create real edges from intervals/segments if resp != nil && len(resp.Intervals) > 0 { for _, interval := range resp.Intervals[:2] { // Limit to first 2 intervals stationNode := g.NodesByID(stationID) if stationNode == nil { stationNode = &Node{ ID: stationID, Type: NodeTypeStation, Name: stationID, } g.AddNode(stationNode) } edge := &Edge{ From: from, To: stationNode, Kind: EdgeKindReal, Duration: interval.Duration, Transport: interval.From.TransportType, IsTransfer: false, Departure: interval.Departure, Arrival: interval.Arrival, } g.AddEdge(edge) foundEdges = true } } } if !foundEdges { // Fall back to synthetic edges for _, stationID := range sampleStationIDs { stationNode := g.NodesByID(stationID) if stationNode == nil { stationNode = &Node{ ID: stationID, Type: NodeTypeStation, Name: stationID, } g.AddNode(stationNode) } edge := &Edge{ From: from, To: stationNode, Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, } g.AddEdge(edge) reverseEdge := &Edge{ From: stationNode, To: from, Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, } g.AddEdge(reverseEdge) } } } else { // No Yandex client - add synthetic edges as fallback sampleStationIDs := []string{"s9600213", "s9600396", "s9600157"} // Moscow, Simferopol examples for _, stationID := range sampleStationIDs { stationNode := g.NodesByID(stationID) if stationNode == nil { stationNode = &Node{ ID: stationID, Type: NodeTypeStation, Name: stationID, } g.AddNode(stationNode) } edge := &Edge{ From: from, To: stationNode, Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, } g.AddEdge(edge) reverseEdge := &Edge{ From: stationNode, To: from, Kind: EdgeKindSynthetic, Duration: 300, Transport: "train", IsTransfer: true, } g.AddEdge(reverseEdge) } } return nil } func SortEdges(edges []*Edge) { sort.Slice(edges, func(i, j int) bool { return edges[i].Duration < edges[j].Duration }) } // buildAdjacencyList builds an adjacency list from the graph's edges. func (g *Graph) buildAdjacencyList() map[string][]*Edge { adj := make(map[string][]*Edge) for _, edge := range g.edges { adj[edge.From.ID] = append(adj[edge.From.ID], edge) } return adj } // NodesByID returns a node by its ID from the graph's nodes. func (g *Graph) NodesByID(id string) *Node { for _, n := range g.nodes { if n.ID == id { return n } } return nil } // FindRoute performs BFS/Dijkstra search from origin to destination with a transfer depth limit. // It uses lazy graph expansion to add edges on-demand during BFS, staying within API quota constraints. // Returns the best itinerary found within the transfer limit. func (g *Graph) FindRoute(originID, destID string, opts SearchOptions) *Itinerary { // Track which nodes have been lazily expanded to avoid re-expansion expanded := make(map[string]bool) // BFS with transfer tracking // State: (nodeID, transfersUsed, accumulatedDuration, lastArrivalTime, path) startNode := g.NodesByID(originID) destNode := g.NodesByID(destID) if startNode == nil || destNode == nil { return nil } // Queue for BFS: each element is a state type bfsState struct { nodeID string transfers int duration int lastArrival string // arrival time at current node (for MCT calculation) itinerary *Itinerary } // Track the minimum transfers seen for each node to prune suboptimal paths visited := make(map[string]int) // nodeID -> min transfers seen // Initialize with the start node initial := bfsState{ nodeID: originID, transfers: 0, duration: 0, lastArrival: "", itinerary: &Itinerary{Legs: []RouteLeg{}}, } // Use a simple slice as priority queue - sort by (duration, transfers) var queue []bfsState queue = append(queue, initial) var best *Itinerary for len(queue) > 0 { // Pop the state with shortest duration (and fewest transfers as tiebreaker) current := queue[0] queue = queue[1:] // If we've reached the destination, potentially update best result if current.nodeID == destID { if best == nil || current.duration < best.TotalDuration || (current.duration == best.TotalDuration && current.transfers < best.TotalTransfers) { best = current.itinerary // Recalculate best metrics from legs best.TotalDuration = current.duration best.TotalTransfers = current.transfers } // Don't continue from destination - we've arrived continue } // Prune if we've exceeded max transfers // Use > instead of >= to allow exploring from states at the exact transfer limit if opts.MaxTransfers >= 0 && current.transfers > opts.MaxTransfers { continue } // Lazily expand this node's adjacency list if not already expanded if !expanded[current.nodeID] { // Expand from this node using lazy expansion // Use the destination city code and date from search options for /search calls if opts.DestCityCode != "" && opts.Date != "" { g.ExpandGraphLazy(g.currentNodeByID(current.nodeID), opts.DestCityCode, opts.Date) } else { // If no dest city/code available, add synthetic edges as fallback addSyntheticEdgesForNode(g, current.nodeID) } expanded[current.nodeID] = true } // Get edges for this node - include both pre-built and lazily added edges adj := g.buildAdjacencyList() // Explore outgoing edges for _, edge := range adj[current.nodeID] { nextNode := edge.To // Calculate new duration newDuration := current.duration + edge.Duration // Calculate transfer time if this is not the first leg transferTime := 0 if current.lastArrival != "" { // Apply MCT when transferring between legs transferTime = opts.MCT } newDurationWithMCT := newDuration + transferTime // Check if we've visited this node with fewer transfers visKey := current.nodeID if existingTransfers, ok := visited[visKey]; ok { if current.transfers+1 > existingTransfers { // Already visited this node with fewer transfers, skip continue } } visited[visKey] = current.transfers + 1 newTransfers := current.transfers if edge.IsTransfer { newTransfers++ } // Build new itinerary legs newLegs := make([]RouteLeg, len(current.itinerary.Legs)+1) copy(newLegs, current.itinerary.Legs) // First leg: From is the origin node, subsequent legs use the previous edge's To if len(current.itinerary.Legs) == 0 { newLegs[len(current.itinerary.Legs)] = RouteLeg{ From: g.NodesByID(originID), // origin node as From To: nextNode, Duration: edge.Duration, Transport: edge.Transport, IsTransfer: edge.IsTransfer, } } else { newLegs[len(current.itinerary.Legs)] = RouteLeg{ From: current.itinerary.Legs[len(current.itinerary.Legs)-1].To, To: nextNode, Duration: edge.Duration, Transport: edge.Transport, IsTransfer: edge.IsTransfer, } } newItinerary := &Itinerary{ Legs: newLegs, TotalDuration: newDurationWithMCT, TotalTransfers: newTransfers, } queue = append(queue, bfsState{ nodeID: nextNode.ID, transfers: newTransfers, duration: newDurationWithMCT, lastArrival: edge.Arrival, // arrival time at next node itinerary: newItinerary, }) } // Re-sort queue by (duration, transfers) for priority sort.Slice(queue, func(i, j int) bool { if queue[i].duration != queue[j].duration { return queue[i].duration < queue[j].duration } return queue[i].transfers < queue[j].transfers }) } if best == nil { return nil } return best } // currentNodeByID returns a node by its ID from the graph's nodes. func (g *Graph) currentNodeByID(id string) *Node { for _, n := range g.nodes { if n.ID == id { return n } } return nil } // addSyntheticEdgesForNode adds synthetic transfer edges for a node as fallback // when lazy expansion cannot call /search (e.g., no Yandex client or missing dest city code). func addSyntheticEdgesForNode(g *Graph, nodeID string) { // Find the node and add synthetic edges connecting it to its city hub node := g.currentNodeByID(nodeID) if node == nil { return } // Determine the city code from the node cityCode := node.CityCode if cityCode == "" { return } cityNodeID := "city:" + cityCode destCityNode := g.NodesByID(cityNodeID) if destCityNode == nil { destCityNode = &Node{ ID: cityNodeID, Type: NodeTypeCity, Name: cityCode, } g.AddNode(destCityNode) } // Add synthetic edges: node <-> city hub alreadyForward := false alreadyReverse := false for _, e := range g.edges { if e.From != nil && e.From.ID == node.ID && e.To != nil && e.To.ID == destCityNode.ID { alreadyForward = true } if e.From != nil && e.From.ID == destCityNode.ID && e.To != nil && e.To.ID == node.ID { alreadyReverse = true } } if !alreadyForward { g.AddEdge(&Edge{ From: node, To: destCityNode, Kind: EdgeKindSynthetic, Duration: 300, // 5 min synthetic transfer Transport: "train", IsTransfer: true, }) } if !alreadyReverse { g.AddEdge(&Edge{ From: destCityNode, To: node, Kind: EdgeKindSynthetic, Duration: 300, // 5 min synthetic transfer Transport: "train", IsTransfer: true, }) } } // ApplyMCT applies Minimum Connection Time rules to the itinerary. // It adjusts transfer times based on node types, city tiers, and check-in requirements. func (g *Graph) ApplyMCT(itinerary *Itinerary, mctBase int) *Itinerary { if itinerary == nil || len(itinerary.Legs) <= 1 { // No transfers needed, return as-is return itinerary } // MCT base default: 30 minutes (1800 seconds) if mctBase <= 0 { mctBase = 1800 } // Create a working copy of legs adjustedLegs := make([]RouteLeg, len(itinerary.Legs)) copy(adjustedLegs, itinerary.Legs) for i := 1; i < len(adjustedLegs); i++ { prevLeg := &adjustedLegs[i-1] currLeg := &adjustedLegs[i] // Determine MCT based on node types and transfer kinds mct := mctBase // Reduce MCT for city hub transfers (the transfer point node is a city) // The transfer point is the destination of the previous leg / start of current leg transferPoint := prevLeg.To // = currLeg.From if transferPoint.Type == NodeTypeCity { mct = mctBase / 2 // 30 min -> 15 min for city hub transfers } // Increase MCT for mode changes (different transport types) if prevLeg.Transport != currLeg.Transport { mct = mctBase + 600 // 30 min + 10 min for mode change } // Add the MCT to the total duration (as waiting time at transfer) itinerary.TotalDuration += mct } // Recalculate leg structure with proper transfer timing itinerary.Legs = adjustedLegs return itinerary } // SearchOptions configures the route search behavior. type SearchOptions struct { // MaxTransfers limits the number of transfers allowed in the route. MaxTransfers int // MCT is the minimum connection time in seconds at transfer points. MCT int // FarTerm indicates if the search date is far-term (affects caching/TTL). FarTerm bool // DestCityCode is the destination city code for lazy graph expansion. DestCityCode string // Date is the search date for lazy graph expansion TTL policies. Date string } // Itinerary represents a complete route with legs and summary metrics. type Itinerary struct { Legs []RouteLeg TotalDuration int // total travel time in seconds TotalTransfers int // number of transfers Cost int // cost in minor currency units (e.g., rubles) // Identifier for the route (e.g., search_id + route_id) ID string } // RouteLeg represents a single leg of a route (one edge between two nodes). type RouteLeg struct { From *Node To *Node Departure string // ISO 8601 departure time Arrival string // ISO 8601 arrival time Duration int // travel time in seconds Transport string // transport type (train, plane, bus) IsTransfer bool } // SearchResult represents the result of a route search. type SearchResult struct { // Itineraries are the found routes, sorted by Pareto ranking (time, transfers, cost). Itineraries []*Itinerary // SearchMetadata contains information about the search execution. Metadata map[string]interface{} } // FindRoutesPareto finds Pareto-optimal routes (time, transfers, cost) from origin to destination. // It runs the search algorithm and returns multiple routes that are not dominated by any other // route in all three metrics simultaneously. func (g *Graph) FindRoutesPareto(originID, destID string, opts SearchOptions) []*Itinerary { // Run multiple searches with different strategies to find diverse routes var allItineraries []*Itinerary // Search with different max transfer limits to find diverse routes for maxTransfers := 0; maxTransfers <= opts.MaxTransfers; maxTransfers++ { optsCopy := opts optsCopy.MaxTransfers = maxTransfers result := g.FindRoute(originID, destID, optsCopy) if result != nil && result.TotalDuration > 0 { allItineraries = append(allItineraries, result) } } // Sort by total duration (primary), then transfers (secondary), then cost (tertiary) sort.Slice(allItineraries, func(i, j int) bool { if allItineraries[i].TotalDuration != allItineraries[j].TotalDuration { return allItineraries[i].TotalDuration < allItineraries[j].TotalDuration } if allItineraries[i].TotalTransfers != allItineraries[j].TotalTransfers { return allItineraries[i].TotalTransfers < allItineraries[j].TotalTransfers } return allItineraries[i].Cost < allItineraries[j].Cost }) // Pareto filter: remove dominated routes // A route is dominated if another route is better or equal in all metrics (time, transfers, cost) var pareto []*Itinerary for _, candidate := range allItineraries { dominated := false for _, existing := range pareto { // Check if existing dominates candidate if existing.TotalDuration <= candidate.TotalDuration && existing.TotalTransfers <= candidate.TotalTransfers && existing.Cost <= candidate.Cost && (existing.TotalDuration < candidate.TotalDuration || existing.TotalTransfers < candidate.TotalTransfers || existing.Cost < candidate.Cost) { dominated = true break } } if !dominated { pareto = append(pareto, candidate) } } return pareto } // hubExists checks if a hub station with the given ID exists in the selection. func hubExists(hubs []*HubStation, id string) bool { for _, h := range hubs { if h.Station.ID == id { return true } } return false } // findStationByID finds a station info record by station ID. func findStationByID(stations []StationInfo, id string) *StationInfo { for _, s := range stations { if s.ID == id { return &s } } return nil }