diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..3b7b7f1 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,43 @@ +# PROGRESS + +## 2026-06-02 T-ALNS-RRD reproduction fidelity fix + +### Findings addressed +- The route travel component previously counted route elapsed time, which included waiting and service duration, instead of only `sum t_ij(T_i)` from the paper objective. +- RRD event generation and rollout used unseeded RNGs, so the same algorithm seed could produce different event streams and dispatch outcomes. +- Traffic incidents mutated the shared traffic tensor during detection, which could leak into later evaluations when an event was not dispatched. +- Rollout Tabu adjustment used the wrong sign for a cost-minimizing score: Tabu actions were rewarded and non-Tabu actions were penalized. +- Urgent-order actions could insert a negative placeholder node into fixed-size traffic tensors, causing invalid route evaluation. +- T-ALNS traffic aspiration did not compare against the current solution, and frequency memory did not use congestion-weighted updates from Eq.34. + +### Changes made +- Corrected Eq.1 cost accounting so travel cost is only arc traversal time; waiting and service remain part of time propagation. +- Documented hold-last-value traffic bucket behavior for customer windows after the paper's 6:00-18:00 traffic horizon. +- Restored congestion penalty generation to Eq.9 semantics: `rho = theta * gamma`. +- Threaded deterministic seeds through `EventGenerator`, `RolloutEngine`, and `Dispatch`. +- Made traffic incident severity temporary during dispatch evaluation and restored the base tensor with `try/finally`. +- Corrected rollout Tabu penalty/bonus direction for a cost-minimizing dispatch value. +- Reworked unsupported urgent-order insertion into fixed-graph penalty actions instead of invalid synthetic nodes. +- Added congestion-weighted frequency memory updates and current-solution traffic aspiration checks. +- Added `configs/paper.yaml` as the canonical paper-aligned server experiment config. + +### Local validation +- Per user instruction, no local Python tests, smoke tests, or full experiments were run in this round. +- Static review only: inspect source changes, config shape, and git diff before commit. + +### Server run command +```bash +cd t_alns_rrd_reproduction +pip install -r requirements.txt +python src/experiments/run_main_comparison.py --config paper --seeds 30 --iterations 1000 --time-limit 600 --output results/paper_fixed +``` + +### Expected server outputs +- `results/paper_fixed/tables/main_comparison.csv` +- `results/paper_fixed/tables/per_seed_costs.csv` +- `results/paper_fixed/tables/statistical_tests.csv` +- `results/paper_fixed/logs/convergence.npz` + +### Interpretation rule +- Check whether Static > TA-Greedy > ALNS-Base > T-ALNS > T-ALNS-RRD in total cost, CES decreases, and OTDR improves. +- If the paper trend is not reproduced, keep the generated tables and record the failed metrics honestly instead of tuning results by hand. diff --git a/t_alns_rrd_reproduction/configs/paper.yaml b/t_alns_rrd_reproduction/configs/paper.yaml new file mode 100644 index 0000000..26a8886 --- /dev/null +++ b/t_alns_rrd_reproduction/configs/paper.yaml @@ -0,0 +1,137 @@ +# Canonical paper-aligned configuration for T-ALNS-RRD reproduction. +# This is the primary server-run config. It preserves the paper's controlled +# mid-scale instance: 47 customers, 4 homogeneous vehicles, 120 kg capacity, +# 12 one-hour traffic intervals from 6:00 to 18:00, and 30 seeds. + +problem: + n_customers: 47 + n_vehicles: 4 + depot_count: 1 + vehicle_capacity_kg: 120 + service_time_min: 4 + area_width_km: 8.0 + area_height_km: 10.0 + operating_start: 360 + operating_end: 1080 + n_time_intervals: 12 + +customers: + demand_min_kg: 3 + demand_max_kg: 12 + window_length_min: 60 + window_length_max: 150 + time_window_categories: + morning: + earliest: 540 + latest: 720 + afternoon: + earliest: 780 + latest: 960 + evening: + earliest: 1020 + latest: 1200 + num_clusters: 3 + cluster_labels: ["residential", "commercial", "office"] + +roads: + types: + arterial: + speed_kmh: 45 + proportion: 0.25 + collector: + speed_kmh: 30 + proportion: 0.35 + residential: + speed_kmh: 20 + proportion: 0.40 + noise_std: 0.05 + use_complete_graph: true + +traffic: + multipliers: [1.0, 1.0, 1.6, 1.6, 1.2, 1.2, 1.0, 1.0, 1.2, 1.2, 1.7, 1.7] + congestion_scale_theta: 50.0 + risk_aversion_beta: 0.3 + uncertainty_base: 0.05 + +cost: + lambda_lateness: 1.0 + lambda_congestion: 1.0 + lambda_stability: 0.3 + +alns: + max_iterations: 1000 + time_limit_sec: 600 + destroy_ratio_min: 0.1 + destroy_ratio_max: 0.4 + initial_temperature_factor: 0.05 + cooling_rate: 0.99975 + reaction_factor: 0.1 + segment_length: 100 + stall_limit: 200 + max_attempts: 5 + reward_global_best: 1.0 + reward_improvement: 0.5 + reward_accepted: 0.2 + reward_rejected: 0.0 + +tabu: + move_tabu: + tenure: 7 + tenure_min: 3 + tenure_max: 12 + overlap_threshold: 0.5 + stall_for_increase: 50 + solution_tabu: + tenure: 15 + buffer_size: 1000 + hash_prime: 1000000007 + frequency: + normalization_factor: 2 + normalization_interval: 50 + diversification: + delta_max: 0.7 + eta_balance: 0.5 + weights: [0.4, 0.3, 0.3] + aspiration: + beta_threshold: 0.3 + gamma_threshold: 0.8 + +rrd: + rollout: + horizon_min_min: 30 + horizon_max_min: 120 + urgency_alpha: 1.0 + n_sim_min: 2 + n_sim_max: 50 + mc_iterations: 50 + time_overhead_ms: 10 + time_per_sim_ms: 50 + dispatch: + weight_rollout: 0.4 + weight_stability: 0.3 + weight_recovery: 0.3 + tabu: + penalty: 50.0 + bonus: 25.0 + events: + urgency_threshold: 0.5 + event_probability: 0.3 + event_check_interval: 10 + weights: + E1_traffic: [0.5, 0.3, 0.2] + E2_urgent: [0.7, 0.1, 0.2] + E3_capacity: [0.3, 0.4, 0.3] + E4_timewindow: [0.8, 0.1, 0.1] + max_actions: 20 + +experiments: + random_seeds: 30 + seed_start: 1 + report_mean_std: true + statistical_testing: true + sensitivity: + fleet_sizes: [2, 3, 4, 5, 6] + customer_counts: [30, 40, 47, 60] + capacities: [80, 100, 120, 140, 160] + robustness: + sigma_values: [0.1, 0.2, 0.3, 0.5] diff --git a/t_alns_rrd_reproduction/src/cost.py b/t_alns_rrd_reproduction/src/cost.py index b21ccd3..2a804d1 100644 --- a/t_alns_rrd_reproduction/src/cost.py +++ b/t_alns_rrd_reproduction/src/cost.py @@ -50,6 +50,7 @@ class CostCalculator: departures = [0.0] * n delays = [0.0] * n waits = [0.0] * n + arc_travel_time = 0.0 congestion_exposure = 0.0 # First node (depot) @@ -61,10 +62,12 @@ class CostCalculator: i = route_nodes[idx - 1] j = route_nodes[idx] - # Arrival at j (Eq.5) - arrivals[idx] = departures[idx - 1] + problem_ctx.get_travel_time( - i, j, departures[idx - 1] - ) + # Arrival at j (Eq.5). Eq.1's travel term is the sum of arc + # traversal times only; waiting and service duration are temporal + # propagation state, not travel cost. + travel_time = problem_ctx.get_travel_time(i, j, departures[idx - 1]) + arc_travel_time += travel_time + arrivals[idx] = departures[idx - 1] + travel_time # Accumulate congestion exposure congestion_exposure += problem_ctx.get_congestion_penalty( @@ -103,7 +106,8 @@ class CostCalculator: "departures": departures, "delays": delays, "waits": waits, - "total_travel_time": arrivals[-1] - depot_start_time, + "total_travel_time": arc_travel_time, + "route_duration": departures[-1] - depot_start_time, "total_delay": sum(delays), "total_wait": sum(waits), "congestion_exposure": congestion_exposure, @@ -381,9 +385,7 @@ class CostCalculator: total_travel += result["total_travel_time"] total_delay += result["total_delay"] total_congestion += result["congestion_exposure"] - avg_route_duration += ( - result["departures"][-1] - result["departures"][0] - ) + avg_route_duration += result["route_duration"] # Count on-time deliveries for idx, node in enumerate(route.nodes): diff --git a/t_alns_rrd_reproduction/src/data_generator.py b/t_alns_rrd_reproduction/src/data_generator.py index fbe13e0..3b04412 100644 --- a/t_alns_rrd_reproduction/src/data_generator.py +++ b/t_alns_rrd_reproduction/src/data_generator.py @@ -324,10 +324,8 @@ class DataGenerator: # Uncertainty margin η eta = tt * self.uncertainty_base * self.traffic_multipliers[h] - # Congestion penalty ρ (Eq.9): extra time caused by congestion - # ρ = θ × extra_time × γ, where extra_time = base_time × (multiplier - 1) - extra_time = base_time * max(0, self.traffic_multipliers[h] - 1.0) - rho = self.congestion_scale * extra_time * gamma + # Congestion penalty ρ (Eq.9): θ × normalized density γ. + rho = self.congestion_scale * gamma travel_time[i, j, h] = round(tt, 4) congestion[i, j, h] = round(gamma, 4) diff --git a/t_alns_rrd_reproduction/src/experiments/run_main_comparison.py b/t_alns_rrd_reproduction/src/experiments/run_main_comparison.py index e526b23..20c364b 100644 --- a/t_alns_rrd_reproduction/src/experiments/run_main_comparison.py +++ b/t_alns_rrd_reproduction/src/experiments/run_main_comparison.py @@ -1,5 +1,5 @@ """ -Main comparison experiment (v2). +Main comparison experiment. Runs all 5 algorithms with configurable settings. Supports --config flag for version switching. @@ -35,8 +35,78 @@ except ImportError: HAS_SCIPY = False +def build_algorithm_config(cfg: dict, max_iterations: int, time_limit_sec: int) -> dict: + """Flatten YAML sections into solver constructor config keys.""" + alns = cfg.get("alns", {}) + tabu = cfg.get("tabu", {}) + rrd = cfg.get("rrd", {}) + rollout = rrd.get("rollout", {}) + dispatch = rrd.get("dispatch", {}) + rrd_tabu = rrd.get("tabu", {}) + events = rrd.get("events", {}) + + return { + "max_iterations": max_iterations, + "time_limit_sec": time_limit_sec, + "destroy_ratio_min": alns.get("destroy_ratio_min", 0.1), + "destroy_ratio_max": alns.get("destroy_ratio_max", 0.4), + "initial_temperature_factor": alns.get("initial_temperature_factor", 0.05), + "cooling_rate": alns.get("cooling_rate", 0.99975), + "reaction_factor": alns.get("reaction_factor", 0.1), + "segment_length": alns.get("segment_length", 100), + "stall_limit": alns.get("stall_limit", 200), + "max_attempts": alns.get("max_attempts", 5), + "reward_global_best": alns.get("reward_global_best", 1.0), + "reward_improvement": alns.get("reward_improvement", 0.5), + "reward_accepted": alns.get("reward_accepted", 0.2), + "reward_rejected": alns.get("reward_rejected", 0.0), + "move_tabu_tenure": tabu.get("move_tabu", {}).get("tenure", 7), + "move_tabu_tenure_min": tabu.get("move_tabu", {}).get("tenure_min", 3), + "move_tabu_tenure_max": tabu.get("move_tabu", {}).get("tenure_max", 12), + "move_tabu_overlap_threshold": tabu.get("move_tabu", {}).get( + "overlap_threshold", 0.5 + ), + "move_tabu_stall_for_increase": tabu.get("move_tabu", {}).get( + "stall_for_increase", 50 + ), + "solution_tabu_tenure": tabu.get("solution_tabu", {}).get("tenure", 15), + "solution_tabu_buffer": tabu.get("solution_tabu", {}).get("buffer_size", 1000), + "solution_tabu_prime": tabu.get("solution_tabu", {}).get( + "hash_prime", 1000000007 + ), + "freq_norm_factor": tabu.get("frequency", {}).get("normalization_factor", 2.0), + "freq_norm_interval": tabu.get("frequency", {}).get( + "normalization_interval", 50 + ), + "diversification_delta_max": tabu.get("diversification", {}).get( + "delta_max", 0.7 + ), + "diversification_eta": tabu.get("diversification", {}).get( + "eta_balance", 0.5 + ), + "diversification_weights": tabu.get("diversification", {}).get( + "weights", [0.4, 0.3, 0.3] + ), + "aspiration_beta": tabu.get("aspiration", {}).get("beta_threshold", 0.3), + "aspiration_gamma": tabu.get("aspiration", {}).get("gamma_threshold", 0.8), + "rollout_horizon_min": rollout.get("horizon_min_min", 30), + "rollout_horizon_max": rollout.get("horizon_max_min", 120), + "rollout_n_sim_min": rollout.get("n_sim_min", 2), + "rollout_n_sim_max": rollout.get("n_sim_max", 50), + "rollout_mc_iterations": rollout.get("mc_iterations", 50), + "dispatch_weight_rollout": dispatch.get("weight_rollout", 0.4), + "dispatch_weight_stability": dispatch.get("weight_stability", 0.3), + "dispatch_weight_recovery": dispatch.get("weight_recovery", 0.3), + "event_urgency_threshold": events.get("urgency_threshold", 0.5), + "event_probability": events.get("event_probability", 0.3), + "event_check_interval": events.get("event_check_interval", 10), + "rrd_tabu_penalty": rrd_tabu.get("penalty", 50.0), + "rrd_tabu_bonus": rrd_tabu.get("bonus", 25.0), + } + + def run_experiment( - config_name="calibrated", + config_name="paper", n_seeds=None, max_iterations=None, time_limit_sec=None, @@ -66,10 +136,14 @@ def run_experiment( n_seeds = n_seeds or cfg.get("experiments", {}).get("random_seeds", 10) max_iterations = max_iterations or cfg.get("alns", {}).get("max_iterations", 500) time_limit_sec = time_limit_sec or cfg.get("alns", {}).get("time_limit_sec", 300) + seed_start = cfg.get("experiments", {}).get("seed_start", 0) print("=" * 70) print(f"T-ALNS-RRD Main Comparison [{config_name}]") - print(f"Seeds: {n_seeds}, Iter: {max_iterations}, Time: {time_limit_sec}s") + print( + f"Seeds: {seed_start}..{seed_start + n_seeds - 1}, " + f"Iter: {max_iterations}, Time: {time_limit_sec}s" + ) print("=" * 70) print("\n[1/5] Generating dataset...") @@ -80,6 +154,9 @@ def run_experiment( customers={c.customer_id: c for c in data["customers"]}, depot=data["depot"], traffic=data["traffic"], n_vehicles=data["n_vehicles"], vehicle_capacity=data["vehicle_capacity"], + op_start=cfg["problem"]["operating_start"], + op_end=cfg["problem"]["operating_end"], + n_intervals=cfg["problem"]["n_time_intervals"], ) cost_calc = CostCalculator( lambda_lateness=cfg["cost"]["lambda_lateness"], @@ -87,15 +164,7 @@ def run_experiment( lambda_stability=cfg["cost"]["lambda_stability"], ) - rrd_cfg = cfg.get("rrd", {}) - alg_cfg = { - "max_iterations": max_iterations, - "time_limit_sec": time_limit_sec, - "event_probability": rrd_cfg.get("events", {}).get("event_probability", 0.3), - "event_check_interval": rrd_cfg.get("events", {}).get("event_check_interval", 10), - "reward_global_best": 1.0, "reward_improvement": 0.5, - "reward_accepted": 0.2, "reward_rejected": 0.0, - } + alg_cfg = build_algorithm_config(cfg, max_iterations, time_limit_sec) algorithms = [ ("Static-VRPTW", StaticVRPTWSolver, {}), @@ -114,7 +183,7 @@ def run_experiment( alg_results = [] seed_costs = [] - for seed in tqdm(range(n_seeds)): + for seed in tqdm(range(seed_start, seed_start + n_seeds)): if alg_name in ("Static-VRPTW", "TA-VRPTW-Greedy"): solver = solver_cls(ctx, cost_calc) else: @@ -190,7 +259,7 @@ def run_experiment( if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument("--config", default="calibrated") + parser.add_argument("--config", default="paper") parser.add_argument("--seeds", type=int, default=None) parser.add_argument("--iterations", type=int, default=None) parser.add_argument("--time-limit", type=int, default=None) diff --git a/t_alns_rrd_reproduction/src/problem.py b/t_alns_rrd_reproduction/src/problem.py index 11e89e2..6eb384a 100644 --- a/t_alns_rrd_reproduction/src/problem.py +++ b/t_alns_rrd_reproduction/src/problem.py @@ -147,7 +147,12 @@ class ProblemContext: return (self.op_end - self.op_start) / self.n_intervals def time_to_interval(self, minutes: float) -> int: - """Map time in minutes to interval index h ∈ [0, n_intervals-1].""" + """Map time to interval h. + + The paper discretizes traffic feeds for 6:00-18:00. Customer windows may + extend to 20:00, so departures after the final traffic bucket use + hold-last-value semantics instead of extrapolating unobserved traffic. + """ minutes = max(self.op_start, min(minutes, self.op_end - 1)) return int((minutes - self.op_start) / self.interval_duration) diff --git a/t_alns_rrd_reproduction/src/rrd/candidate_actions.py b/t_alns_rrd_reproduction/src/rrd/candidate_actions.py index 8f39688..efc46aa 100644 --- a/t_alns_rrd_reproduction/src/rrd/candidate_actions.py +++ b/t_alns_rrd_reproduction/src/rrd/candidate_actions.py @@ -88,26 +88,16 @@ def generate_actions_E2_urgent( new_demand = extra.get("demand_kg", 5.0) deadline = extra.get("deadline", 720.0) - for k, route in enumerate(solution.routes): - if route.total_demand(problem_ctx.customers) + new_demand > problem_ctx.vehicle_capacity: - continue - - # Immediate insertion (find best position) - # We approximate: try inserting at each position - n_cust_pos = len(route.customers) - for pos in range(1, n_cust_pos + 2): - actions.append({ - "type": "urgent_insert", - "target_vehicle": k, - "position": pos, - "demand_kg": new_demand, - "deadline": deadline, - "delayed": False, - "description": f"Insert urgent delivery into vehicle {k} pos {pos}", - }) - break # Just try one position per vehicle for speed - if len(actions) >= 10: - break + # The fixed 47-node benchmark has no live road matrix for new customer + # coordinates. Keep E2 feasible by representing immediate handling as + # subcontracting or customer notification penalties instead of inserting + # invalid synthetic node IDs into the fixed tensor. + delay = max(0.0, deadline - event.time_min) + actions.append({ + "type": "urgent_defer", + "penalty_cost": 120.0 + max(0.0, 60.0 - delay), + "description": "Delay urgent delivery with customer notification", + }) # Subcontract option (penalty-based) actions.append({ @@ -204,6 +194,7 @@ def generate_actions_E4_timewindow( "type": "temporary_tolerance", "customer": affected, "tolerance_min": 30.0, + "penalty_cost": 30.0, "description": f"Grant 30min tolerance for customer {affected}", }) diff --git a/t_alns_rrd_reproduction/src/rrd/dispatch.py b/t_alns_rrd_reproduction/src/rrd/dispatch.py index 39b61b6..945818e 100644 --- a/t_alns_rrd_reproduction/src/rrd/dispatch.py +++ b/t_alns_rrd_reproduction/src/rrd/dispatch.py @@ -37,10 +37,16 @@ class Dispatch: self.w_rollout = weight_rollout # ω₁ self.w_stability = weight_stability # ω₂ self.w_recovery = weight_recovery # ω₃ + self._seed = seed self.rng = np.random.default_rng(seed) self.dispatch_log: List[dict] = [] + def reset(self, seed: int = None): + """Reset dispatch RNG and per-run log for deterministic experiments.""" + self.rng = np.random.default_rng(self._seed if seed is None else seed) + self.dispatch_log = [] + def compute_stability(self, action: dict, current_solution: Solution) -> float: """Compute route stability score (Eq.41). @@ -186,6 +192,7 @@ class Dispatch: "composite_score": best["composite_score"], "response_time_ms": elapsed_ms, "n_actions_evaluated": len(actions), + "description": best["action"].get("description", ""), } self.dispatch_log.append(log_entry) diff --git a/t_alns_rrd_reproduction/src/rrd/event_generator.py b/t_alns_rrd_reproduction/src/rrd/event_generator.py index 0dad8bb..a94ccc3 100644 --- a/t_alns_rrd_reproduction/src/rrd/event_generator.py +++ b/t_alns_rrd_reproduction/src/rrd/event_generator.py @@ -43,6 +43,7 @@ class EventGenerator: cost_calc: "CostCalculator", urgency_threshold: float = 0.5, event_weights: dict = None, + seed: int = None, ): self.ctx = problem_ctx self.cost_calc = cost_calc @@ -57,7 +58,8 @@ class EventGenerator: EventType.E4_TIMEWINDOW: (0.8, 0.1, 0.1), } - self.rng = np.random.default_rng(None) + self._seed = seed + self.rng = np.random.default_rng(seed) self.event_log: List[Event] = [] def generate_traffic_incident( @@ -116,16 +118,14 @@ class EventGenerator: description=f"Traffic incident on arc {arc} affecting customer {affected_customer}", ) - # Actually modify travel time tensor to simulate congestion - # Use moderate severity and auto-restore after dispatch + # Store incident severity for rollout evaluation. The live traffic tensor + # is modified only while a selected dispatch action is being evaluated, + # then restored by TALNSRRD. i, j = arc if i >= 0 and j >= 0: severity = 1.5 + self.rng.uniform(0, 0.5) # 1.5x-2.0x (moderate) - event._travel_backup_ij = self.ctx.traffic.travel_time[i, j, :].copy() - event._congestion_backup_ij = self.ctx.traffic.congestion_penalty[i, j, :].copy() event._modified_arc = (i, j) - self.ctx.traffic.travel_time[i, j, :] *= severity - self.ctx.traffic.congestion_penalty[i, j, :] *= severity + event._severity = severity return event @@ -310,7 +310,7 @@ class EventGenerator: self.event_log.extend(events) return events - def reset(self): + def reset(self, seed: int = None): """Reset event log for a new run.""" self.event_log = [] - self.rng = np.random.default_rng(None) + self.rng = np.random.default_rng(self._seed if seed is None else seed) diff --git a/t_alns_rrd_reproduction/src/rrd/rollout.py b/t_alns_rrd_reproduction/src/rrd/rollout.py index 36e8acd..86a4ccb 100644 --- a/t_alns_rrd_reproduction/src/rrd/rollout.py +++ b/t_alns_rrd_reproduction/src/rrd/rollout.py @@ -8,7 +8,6 @@ Rollout horizon H: 30-120 minutes Monte Carlo iterations: 2-50 """ -import time import numpy as np from typing import List, Dict, Optional @@ -45,8 +44,13 @@ class RolloutEngine: self.mc_iterations = mc_iterations self.tabu_penalty = tabu_penalty self.tabu_bonus = tabu_bonus + self._seed = seed self.rng = np.random.default_rng(seed) + def reset(self, seed: int = None): + """Reset stochastic rollout sampling for deterministic seeded runs.""" + self.rng = np.random.default_rng(self._seed if seed is None else seed) + def adapt_horizon(self, urgency: float) -> int: """Adapt rollout horizon based on event urgency (Eq.43). @@ -101,37 +105,60 @@ class RolloutEngine: Returns the total cost over the simulated horizon. """ - import copy sim_sol = solution.copy() sim_sol = self._apply_action(sim_sol, action) - total_cost = 0.0 - current_time = self.ctx.op_start + total_cost = float(action.get("penalty_cost", 0.0)) # Simple forward simulation: evaluate each route with noise for route in sim_sol.routes: if len(route.nodes) < 2: continue + current_time = self.ctx.op_start nodes = route.nodes for idx in range(1, len(nodes)): i, j = nodes[idx - 1], nodes[idx] # Skip negative/invalid node IDs if i < 0 or i >= self.ctx.n_nodes or j < 0 or j >= self.ctx.n_nodes: continue - # Add noise to travel time - base_tt = self.ctx.get_travel_time(i, j, current_time) + # Add noise to travel time. Local reroute is a road-level + # detour in the complete-graph reproduction, so the route's + # customer sequence stays fixed while this arc is costed via + # an intermediate road waypoint. + if ( + action.get("type") == "local_reroute" + and tuple(action.get("arc", ())) == (i, j) + and 0 < action.get("bypass", -1) < self.ctx.n_nodes + ): + bypass = action["bypass"] + base_tt = ( + self.ctx.get_travel_time(i, bypass, current_time) + + self.ctx.get_travel_time(bypass, j, current_time) + ) + congestion = ( + self.ctx.get_congestion_penalty(i, bypass, current_time) + + self.ctx.get_congestion_penalty(bypass, j, current_time) + ) + else: + base_tt = self.ctx.get_travel_time(i, j, current_time) + congestion = self.ctx.get_congestion_penalty(i, j, current_time) noise = 1.0 + self.rng.normal(0, noise_std) tt = max(base_tt * noise, 0.0) current_time += tt # Congestion cost - congestion = self.ctx.get_congestion_penalty(i, j, current_time) total_cost += tt + self.cost_calc.lambda_congestion * congestion # Delay penalty if j != 0 and j in self.ctx.customers: cust = self.ctx.customers[j] - lateness = max(0.0, current_time - cust.latest_time_min) + tolerance = ( + action.get("tolerance_min", 0.0) + if action.get("type") == "temporary_tolerance" + and action.get("customer") == j + else 0.0 + ) + lateness = max(0.0, current_time - cust.latest_time_min - tolerance) total_cost += self.cost_calc.lambda_lateness * lateness # Service time @@ -182,22 +209,21 @@ class RolloutEngine: if removed: is_tabu = tabu_mem.is_tabu(removed, action["type"], "", 0) if is_tabu: - V_adjusted -= self.tabu_penalty + V_adjusted += self.tabu_penalty else: - V_adjusted += self.tabu_bonus + V_adjusted = max(0.0, V_adjusted - self.tabu_bonus) return V_adjusted def _apply_action(self, solution: Solution, action: dict) -> Solution: """Apply a candidate action to a solution (returns modified copy).""" - import copy sol = solution.copy() action_type = action.get("type", "") if action_type == "local_reroute": - # Simplified: we don't actually modify the route structure - # In practice, this would insert a bypass node - pass + # Complete-graph reproduction: route sequence is unchanged and the + # detour effect is represented by action penalty/rollout scoring. + return sol elif action_type == "customer_reassign": cust = action.get("customer") @@ -213,16 +239,13 @@ class RolloutEngine: sol.routes[target].insert(cust, insert_pos) elif action_type == "urgent_insert": - target = action.get("target_vehicle") - pos = action.get("position", 1) - # Create temp customer ID (negative) - temp_id = -100 # placeholder - if target is not None and target < sol.n_vehicles: - sol.routes[target].insert(temp_id, pos) + # Urgent customers are not part of the fixed 47-customer tensor in + # this methodological reproduction. They are scored via penalty + # actions; inserting synthetic negative nodes would invalidate Eq.1. + return sol - elif action_type == "subcontract": - # No route change, just penalty - pass + elif action_type in ("urgent_defer", "subcontract"): + return sol elif action_type == "redistribute": cust = action.get("customer") @@ -249,7 +272,6 @@ class RolloutEngine: pass elif action_type == "temporary_tolerance": - # Accept delay without route change - pass + return sol return sol diff --git a/t_alns_rrd_reproduction/src/rrd/t_alns_rrd.py b/t_alns_rrd_reproduction/src/rrd/t_alns_rrd.py index 5188ec1..7721a30 100644 --- a/t_alns_rrd_reproduction/src/rrd/t_alns_rrd.py +++ b/t_alns_rrd_reproduction/src/rrd/t_alns_rrd.py @@ -62,8 +62,12 @@ class TALNSRRD: "dispatch_weight_rollout": 0.4, "dispatch_weight_stability": 0.3, "dispatch_weight_recovery": 0.3, + "event_urgency_threshold": 0.5, "event_probability": 0.3, "event_check_interval": 10, # Check for events every N iterations + "rollout_mc_iterations": 50, + "rrd_tabu_penalty": 50.0, + "rrd_tabu_bonus": 25.0, } if config: default_cfg.update(config) @@ -75,7 +79,7 @@ class TALNSRRD: # Initialize RRD components self.event_generator = EventGenerator( problem_ctx, cost_calc, - urgency_threshold=0.3, # Lower threshold for more events + urgency_threshold=self.cfg["event_urgency_threshold"], ) self.rollout_engine = RolloutEngine( @@ -84,6 +88,9 @@ class TALNSRRD: horizon_max=self.cfg["rollout_horizon_max"], n_sim_min=self.cfg["rollout_n_sim_min"], n_sim_max=self.cfg["rollout_n_sim_max"], + mc_iterations=self.cfg["rollout_mc_iterations"], + tabu_penalty=self.cfg["rrd_tabu_penalty"], + tabu_bonus=self.cfg["rrd_tabu_bonus"], ) self.dispatch = Dispatch( @@ -99,6 +106,34 @@ class TALNSRRD: self.event_count = 0 self.dispatched_count = 0 + @staticmethod + def _seed_with_offset(seed: int, offset: int): + return None if seed is None else seed + offset + + def _apply_event_traffic(self, event) -> dict: + """Temporarily apply incident severity to the affected traffic arc.""" + modified_arc = getattr(event, "_modified_arc", None) + severity = getattr(event, "_severity", None) + if modified_arc is None or severity is None: + return {} + + i, j = modified_arc + backup = { + "arc": (i, j), + "travel_time": self.ctx.traffic.travel_time[i, j, :].copy(), + "congestion_penalty": self.ctx.traffic.congestion_penalty[i, j, :].copy(), + } + self.ctx.traffic.travel_time[i, j, :] *= severity + self.ctx.traffic.congestion_penalty[i, j, :] *= severity + return backup + + def _restore_event_traffic(self, backup: dict): + if not backup: + return + i, j = backup["arc"] + self.ctx.traffic.travel_time[i, j, :] = backup["travel_time"] + self.ctx.traffic.congestion_penalty[i, j, :] = backup["congestion_penalty"] + def solve(self, seed: int = None) -> Solution: """Run T-ALNS-RRD optimization (Algorithm 3).""" rng = np.random.default_rng(seed) @@ -108,7 +143,11 @@ class TALNSRRD: self.talns.move_tabu.clear() self.talns.sol_tabu.clear() self.talns.freq_mem.clear() - self.event_generator.reset() + self.event_generator.reset(self._seed_with_offset(seed, 101)) + self.rollout_engine.reset(self._seed_with_offset(seed, 202)) + self.dispatch.reset(self._seed_with_offset(seed, 303)) + self.event_count = 0 + self.dispatched_count = 0 # Initialize weights self.talns.destroy_weights = {name: 1.0 for name in self.talns.destroy_ops} @@ -162,30 +201,29 @@ class TALNSRRD: "move_tabu": self.talns.move_tabu, } - # Select and apply dispatch action - action = self.dispatch.select_action( - event, S_current, S_best, tabu_structs - ) + backup = self._apply_event_traffic(event) + action = None + S_dispatched = None + try: + # Select and apply dispatch action under the temporary + # disrupted traffic state, then restore the base tensor. + action = self.dispatch.select_action( + event, S_current, S_best, tabu_structs + ) + if action is not None: + S_dispatched = self.dispatch.apply_action(action, S_current) + finally: + self._restore_event_traffic(backup) - if action is not None: - S_current = self.dispatch.apply_action(action, S_current) + if action is not None and S_dispatched is not None: + S_current = S_dispatched self.dispatched_count += 1 - # Restore traffic tensor after dispatch (event handled) - modified_arc = getattr(event, '_modified_arc', None) - if modified_arc is not None: - i, j = modified_arc - backup = getattr(event, '_travel_backup_ij', None) - if backup is not None: - self.ctx.traffic.travel_time[i, j, :] = backup - cb = getattr(event, '_congestion_backup_ij', None) - if cb is not None: - self.ctx.traffic.congestion_penalty[i, j, :] = cb # Update cost after dispatch current_cost = self.cost_calc.compute_total_cost(S_current, self.ctx) # Update Tabu memory with dispatch action self.talns.sol_tabu.add(S_current, iter_count) - self.talns.freq_mem.update(S_current) + self.talns._update_frequency_memory(S_current) if current_cost < best_cost: S_best = S_current.copy() @@ -239,7 +277,9 @@ class TALNSRRD: # Check solution tabu if self.talns.sol_tabu.is_tabu(S_new, iter_count): new_cost_temp = self.cost_calc.compute_total_cost(S_new, self.ctx) - if not self.talns._check_aspiration(S_new, new_cost_temp, best_cost, removed): + if not self.talns._check_aspiration( + S_new, new_cost_temp, best_cost, removed, S_current + ): continue new_cost = self.cost_calc.compute_total_cost(S_new, self.ctx) @@ -251,7 +291,7 @@ class TALNSRRD: self.talns.move_tabu.add(set(removed), d_name, r_name, iter_count) self.talns.sol_tabu.add(S_new, iter_count) - self.talns.freq_mem.update(S_new) + self.talns._update_frequency_memory(S_new) if new_cost < best_cost: S_best = S_new.copy() diff --git a/t_alns_rrd_reproduction/src/tabu/t_alns.py b/t_alns_rrd_reproduction/src/tabu/t_alns.py index 7c640f0..c5d2dad 100644 --- a/t_alns_rrd_reproduction/src/tabu/t_alns.py +++ b/t_alns_rrd_reproduction/src/tabu/t_alns.py @@ -223,6 +223,7 @@ class TALNS: new_cost: float, best_cost: float, removed_customers: List[int], + S_current: Solution = None, ) -> bool: """Check aspiration criteria (Eq.29-31). @@ -239,14 +240,38 @@ class TALNS: if self.freq_mem.is_low_frequency(c, route_idx, self.cfg["aspiration_beta"]): return True - # Traffic adaptation aspiration (Eq.31): accept if significantly reduces congestion - # (Simplified: check congestion exposure reduction) - current_congestion = self.cost_calc.compute_congestion_exposure(S_new, self.ctx) - # We don't have the "current" solution reference here, simplified check - # Full implementation would compare to current solution + # Traffic adaptation aspiration (Eq.31): accept if the candidate + # significantly reduces congestion exposure versus the current route set. + if S_current is not None: + new_congestion = self.cost_calc.compute_congestion_exposure(S_new, self.ctx) + current_congestion = self.cost_calc.compute_congestion_exposure( + S_current, self.ctx + ) + if new_congestion < self.cfg["aspiration_gamma"] * current_congestion: + return True return False + def _route_congestion_weights(self, solution: Solution) -> Dict[tuple, float]: + """Build incoming-arc congestion weights for Eq.34 frequency memory.""" + weights = {} + for route in solution.routes: + result = self.cost_calc.propagate_route(route.nodes, self.ctx) + for idx in range(1, len(route.nodes)): + i = route.nodes[idx - 1] + j = route.nodes[idx] + if j == 0: + continue + depart = result["departures"][idx - 1] + weights[(i, j)] = self.ctx.get_congestion_penalty(i, j, depart) + return weights + + def _update_frequency_memory(self, solution: Solution): + self.freq_mem.update( + solution, + congestion_weights=self._route_congestion_weights(solution), + ) + def solve(self, seed: int = None) -> Solution: """Run T-ALNS optimization (Algorithm 2).""" rng = np.random.default_rng(seed) @@ -345,7 +370,9 @@ class TALNS: if not self.cfg.get("disable_solution_tabu", False): if self.sol_tabu.is_tabu(S_new, iter_count): new_cost_temp = self.cost_calc.compute_total_cost(S_new, self.ctx) - if not self._check_aspiration(S_new, new_cost_temp, best_cost, removed): + if not self._check_aspiration( + S_new, new_cost_temp, best_cost, removed, S_current + ): continue # Evaluate cost @@ -362,7 +389,7 @@ class TALNS: if not self.cfg.get("disable_solution_tabu", False): self.sol_tabu.add(S_new, iter_count) if not self.cfg.get("disable_frequency_memory", False): - self.freq_mem.update(S_new) + self._update_frequency_memory(S_new) if new_cost < best_cost: S_best = S_new.copy()