diff --git a/RL/base_reach.py b/RL/base_reach.py index a3f21ea..31689fe 100644 --- a/RL/base_reach.py +++ b/RL/base_reach.py @@ -129,3 +129,25 @@ def can_reach_base(reach, flight, pairing_start_time, max_pairing_days, if duty_period + c > max_duty_periods: return False return True + +def build_base_reaches(flights, base_airports, constraint): + """허용된 각 home base에 대한 reachability를 구성함.""" + return { + base: build_base_reach(flights, base, constraint) + for base in dict.fromkeys(base_airports) + } + + +def can_reach_any_base(reaches, flight, pairing_start_time, max_pairing_days, + duty_period=None, max_duty_periods=None): + """동일한 resource budget으로 허용 home base 중 하나에 복귀 가능한지 확인함.""" + if not reaches: + return False + return any( + can_reach_base( + reach, flight, pairing_start_time, max_pairing_days, + duty_period=duty_period, + max_duty_periods=max_duty_periods, + ) + for reach in reaches.values() + ) diff --git a/RL/environment.py b/RL/environment.py index 9584482..d8a3b7e 100644 --- a/RL/environment.py +++ b/RL/environment.py @@ -53,17 +53,13 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # base_remaining does not depend on the candidate flight f (loop-invariant), # so it is computed once outside the loop rather than recomputed per # candidate (which would be O(N^2)). - base_ap = c.get("base_airport", config.DEFAULT_CONSTRAINTS["base_airport"]) - - # Hard base-return enforcement (decode-time feasibility masking, Eq. 6). - # require_base_return=True (1) masks any leg that would make base return - # infeasible and (2) forbids EndPairing away from the base -- so every - # completed pairing is structurally guaranteed to start and end at the - # base, matching the backward-reachability-from-base mechanism described - # in the paper. _base_reach is precomputed once per (flights, base) by - # the caller in rollout.py and passed through the constraint dict. - require_return = c.get("require_base_return", False) - base_reach = c.get("_base_reach") if require_return else None + base_ap = c["base_airport"] + + # CPP pairing이 base 복귀 가능성을 잃는 action을 항상 제거함. + base_reach = c.get("_base_reach") + if base_reach is None: + # CPP 실행에는 base 복귀 가능성 자료가 필수이며 누락은 구성 오류로 처리함. + raise ValueError("CPP constraint에는 _base_reach가 필요합니다.") max_pd = c.get("max_pairing_days", config.DEFAULT_CONSTRAINTS["max_pairing_days"]) max_duty_periods = c.get("max_duty_periods", config.DEFAULT_CONSTRAINTS["max_duty_periods"]) @@ -81,7 +77,7 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # 1. Airport-continuity check if pairing_start: - if base_remaining and f["origin"] != base_ap: + if f["origin"] != base_ap: valid = False elif f["origin"] != state["current_airport"]: valid = False @@ -116,12 +112,12 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): if elapsed_days > max_pd: valid = False - # 5. Base-reachability (only when require_base_return is set) + # 5. Base 복귀 가능성 # Checked via duty_period/max_duty_periods, not max_legs -- EndDuty can # always grant a fresh leg budget, so per-duty leg count is not the # binding resource for reaching the base; remaining overnight/rest # opportunities are. - if valid and base_reach is not None: + if valid: ps_time = f["dep_time"] if pairing_start else pairing_start_time if not can_reach_base( base_reach, f, ps_time, max_pd, @@ -151,10 +147,8 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): state.get("total_legs", 0) >= min_pairing_legs and pairing_elapsed_days <= c.get("max_pairing_days", config.DEFAULT_CONSTRAINTS["max_pairing_days"]) ) - # Failing to return to base is otherwise a soft penalty applied as reward - # in step(), not a hard mask. When require_base_return is set, EndPairing - # away from the base is masked out (hard constraint). - if require_return and state["current_airport"] != base_ap: + # CPP pairing은 base에 도착한 상태에서만 종료 가능함. + if state["current_airport"] != base_ap: can_end_pairing = False if can_end_pairing: mask[config.END_PAIRING] = 1 @@ -179,9 +173,13 @@ def step(state, action, flights, assigned, constraint=None): """ c = constraint if constraint else config.DEFAULT_CONSTRAINTS N = len(flights) + if action < 0 or action >= N + 2: + raise IndexError("action이 허용 범위를 벗어났습니다.") # EndDuty -> enter rest, pairing continues if action == N: + if not get_mask(state, flights, assigned, c)[config.END_DUTY]: + raise ValueError("현재 상태에서는 EndDuty를 선택할 수 없습니다.") min_rest = c.get("min_rest", config.DEFAULT_CONSTRAINTS["min_rest"]) next_state = { **state, @@ -205,18 +203,17 @@ def step(state, action, flights, assigned, constraint=None): # EndPairing -> charge pairing cost, then start a new pairing (or end the episode) if action == N + 1: + if not get_mask(state, flights, assigned, c)[config.END_PAIRING]: + raise ValueError("CPP 제약을 만족하지 않은 pairing은 종료할 수 없습니다.") p_cost = c.get("pairing_cost", config.DEFAULT_CONSTRAINTS["pairing_cost"]) - base_penalty = c.get("base_penalty", config.DEFAULT_CONSTRAINTS["base_penalty"]) # constraint["base_airport"] is injected per episode - base = c.get("base_airport", config.DEFAULT_CONSTRAINTS["base_airport"]) + base = c["base_airport"] total_legs = state.get("total_legs", 0) reward = -p_cost + total_legs * config.LEG_PER_PAIRING_BONUS if total_legs < config.MIN_LEGS_FOR_PAIRING: reward += config.MIN_LEGS_PENALTY - if state["current_airport"] != base: - reward -= base_penalty unassigned = [f for f in flights if not assigned[f["id"]]] if not unassigned: @@ -242,7 +239,9 @@ def step(state, action, flights, assigned, constraint=None): } return next_state, reward, False - # Select a flight leg + # flight action도 직접 호출 시 hard mask legality를 다시 확인함. + if not get_mask(state, flights, assigned, c)[action]: + raise ValueError("CPP 제약을 위반한 flight는 선택할 수 없습니다.") f = flights[action] assigned[f["id"]] = True flight_time = f["arr_time"] - f["dep_time"] diff --git a/RL/rollout.py b/RL/rollout.py index f8abd56..d729fc6 100644 --- a/RL/rollout.py +++ b/RL/rollout.py @@ -13,16 +13,16 @@ import config import environment as _env_default -from base_reach import build_base_reach +from base_reach import build_base_reach, can_reach_any_base from turkish.environment_turkish import get_mask as _get_mask_turkish, step as _step_turkish -from utils import state_to_vec, flight_gap_bias, flight_gap_bias_batch +from utils import state_to_vec, flight_gap_bias get_mask, step = _env_default.get_mask, _env_default.step def set_environment(airline): """Switch to the get_mask/step implementation for the given airline - (turkish allows asymmetric HB1/HB2 termination). Rebinds this module's + (Turkish는 HB1/HB2 교차 복귀 허용). Rebinds this module's get_mask/step globals, so all callers that reference them (e.g. collect_pool_full, rollout_subset_global) pick up the change immediately.""" global get_mask, step @@ -45,32 +45,24 @@ def rollout_with_pairings(flights, constraint, encoder, decoder, encoded, pairings = [] - # ── Base-handling options ──────────────────────────────────────────────── - # base_ids : list of bases. Rotates to another base once the - # current base's unassigned departing legs are exhausted. - # strict_base_start : once base-departing legs are all exhausted, end - # the rollout instead of starting a new pairing - # from an arbitrary airport (blocks pairings with no base at all). - # require_base_return : hard-mask base return (handled in environment.get_mask, Eq. 6). - all_bases = list(constraint.get("base_ids") or [constraint.get("base_airport", 0)]) - strict_start = bool(constraint.get("strict_base_start", False)) - require_return = bool(constraint.get("require_base_return", False)) + # 모든 pairing은 허용 base에서 시작하고 해당 pairing의 base로 복귀해야 함. + all_bases = list(constraint.get("base_ids") or [constraint["base_airport"]]) min_rest = constraint.get("min_rest", 10.0) min_pairing_legs = constraint.get("min_pairing_legs", 2) _reach_cache = {} - # 호출부(evaluation/evaluate_ip.py 등)가 base_airport에 대한 _base_reach를 이미 계산해서 - # constraint에 실어 보낸 경우 재사용한다 — 매 rollout(chunk당 n_rollouts_per_chunk+1번)마다 - # 같은 base를 또 계산하던 중복을 없앤다(2026-07-29). 회전으로 처음 보는 base는 그대로 새로 계산. - if require_return and constraint.get("_base_reach") is not None: - _reach_cache[constraint.get("base_airport", 0)] = constraint["_base_reach"] + if constraint.get("_base_reach") is not None: + # 호출부가 계산한 현재 base의 reachability를 재사용해 rollout별 중복 계산을 막음. + _reach_cache[constraint["base_airport"]] = constraint["_base_reach"] def constraint_for(base): c = {**constraint, "base_airport": base} - if require_return: - if base not in _reach_cache: - _reach_cache[base] = build_base_reach(flights, base, constraint) - c["_base_reach"] = _reach_cache[base] + return_bases = all_bases if c.get("allow_cross_base_return") else [base] + for target in return_bases: + if target not in _reach_cache: + _reach_cache[target] = build_base_reach(flights, target, c) + c["_base_reach"] = _reach_cache[base] + c["_base_reaches"] = {target: _reach_cache[target] for target in return_bases} return c bad_starters = set() @@ -95,6 +87,15 @@ def flush_pairing(is_forced=False): elapsed = pairing_last_arr - pairing_dep fly = pairing_fly n_legs = len(current_legs) + # 일반 항공사는 동일 base, Turkish는 HB1/HB2 home-base 집합 복귀를 요구함. + allowed_returns = set(cur_c.get("base_ids") or [pairing_start_ap]) \ + if cur_c.get("allow_cross_base_return") else {pairing_start_ap} + if flight_by_id[current_legs[-1]]["dest"] not in allowed_returns: + raise ValueError("허용 home base로 복귀하지 않은 pairing은 저장할 수 없습니다.") + if n_legs < min_pairing_legs: + raise ValueError("최소 leg 수를 충족하지 않은 pairing은 저장할 수 없습니다.") + if elapsed / 24.0 > cur_c["max_pairing_days"]: + raise ValueError("최대 pairing 기간을 초과한 pairing은 저장할 수 없습니다.") dead_time = max(elapsed - fly - pairing_rest, 0.0) cost = (dead_time - config.IP_LEG_BONUS * max(n_legs - 1, 0) @@ -105,7 +106,7 @@ def flush_pairing(is_forced=False): # after base rotation). Comparing against the fixed episode_base # would misjudge every pairing after a rotation, so pairing_start_ap # must be used instead. - ends_at_base = (pairing_start_ap == flight_by_id[current_legs[-1]]["dest"]) + ends_at_base = True pairings.append({ "legs": list(current_legs), "fly": fly, @@ -119,6 +120,7 @@ def flush_pairing(is_forced=False): "inter_duty_excess": pairing_inter_excess, "ends_at_base": ends_at_base, "true_start_airport": pairing_start_ap, + "true_end_airport": flight_by_id[current_legs[-1]]["dest"], }) def emit_prefix(recs, end_ap, start_ap): @@ -156,9 +158,7 @@ def emit_prefix(recs, end_ap, start_ap): "n_duties": n_rest + 1, "intra_duty_gap": intra, "inter_duty_excess": inter, - # 하드코딩된 True 대신 실제로 검증 — salvage_doomed()가 넘기는 end_ap은 - # "이 prefix가 도착해야 하는 base"이고, recs[-1]이 정말 거기 도착하는지 - # 확인해야 end_ap 인자가 죽은 파라미터가 아니라 실제 안전장치로 쓰인다. + # salvage 결과도 실제 마지막 도착지가 목표 base인지 다시 확인함. "ends_at_base": recs[-1]["dest"] == end_ap, "true_start_airport": start_ap, "is_truncated": True, @@ -169,11 +169,18 @@ def salvage_doomed(): only the longest prefix ending at base as a valid pairing, and return the remaining tail legs to unassigned so other pairings can reuse them.""" k = 0 + prefix_end_ap = None + allowed_returns = set(cur_c.get("base_ids") or [episode_base]) \ + if cur_c.get("allow_cross_base_return") else {episode_base} for i, r in enumerate(leg_recs): - if r["dest"] == episode_base: + elapsed_days = (r["arr"] - leg_recs[0]["dep"]) / 24.0 + if (r["dest"] in allowed_returns + and i + 1 >= min_pairing_legs + and elapsed_days <= cur_c["max_pairing_days"]): k = i + 1 - if k >= min_pairing_legs: - emit_prefix(leg_recs[:k], episode_base, pairing_start_ap) + prefix_end_ap = r["dest"] + if k > 0: + emit_prefix(leg_recs[:k], prefix_end_ap, pairing_start_ap) tail = leg_recs[k:] else: tail = list(leg_recs) @@ -200,17 +207,18 @@ def start_new_pairing(f): pairing_inter_excess = 0.0 def pick_start(): - """Choose the (base, first flight) for the next pairing. Stays on the - current base if it still has unassigned departing legs; otherwise - rotates to another base. If none have any, either ends the rollout - (strict_start) or starts from an arbitrary airport (default).""" + """허용 base 중 복귀 가능한 첫 flight를 선택하며 없으면 rollout을 종료함.""" unassigned = [f for f in flights if not assigned[f["id"]]] if not unassigned: return None, None startable = [f for f in unassigned if f["id"] not in bad_starters] best = None for b in [episode_base] + [x for x in all_bases if x != episode_base]: - cands = [f for f in startable if f["origin"] == b] + c_b = constraint_for(b) + cands = [f for f in startable if f["origin"] == b and can_reach_any_base( + c_b["_base_reaches"], f, f["dep_time"], c_b["max_pairing_days"], + duty_period=0, max_duty_periods=c_b["max_duty_periods"], + )] if not cands: continue f = min(cands, key=lambda f: f["dep_time"]) @@ -220,17 +228,7 @@ def pick_start(): best = (b, f) if best is not None: return best - if strict_start: - return None, None - if not startable: - return None, None - # base 아닌 곳에서 강제 시작(legacy deadhead-start) — 반환하는 base는 - # episode_base가 아니라 실제로 고른 flight의 origin이어야 한다. episode_base를 - # 그대로 반환하면 pairing_start_ap(실제 origin)과 어긋나서, begin_pairing()이 - # 진짜 base 전환으로 인식 못 하고 cur_c/_base_reach를 안 갱신하게 된다 - # (require_base_return=False일 땐 무해하지만, 정합성을 항상 보장해둔다). - f = min(startable, key=lambda f: f["dep_time"]) - return f["origin"], f + return None, None def begin_pairing(): nonlocal state, episode_base, cur_c @@ -262,7 +260,7 @@ def begin_pairing(): if not any(not v for v in assigned.values()): return pairings - episode_base = constraint.get("base_airport", 0) + episode_base = constraint["base_airport"] cur_c = constraint_for(episode_base) state = None if not begin_pairing(): @@ -273,10 +271,8 @@ def begin_pairing(): mask = torch.tensor(mask_list, dtype=torch.float32).to(dev) if sum(mask_list[:-2]) == 0 and mask_list[-2] == 0 and mask_list[-1] == 0: - if require_return and state["current_airport"] != episode_base: - salvage_doomed() - else: - flush_pairing(is_forced=any(not v for v in assigned.values())) + # 위치와 무관하게 마지막 합법 base 복귀 prefix만 보존함. + salvage_doomed() if not begin_pairing(): break continue @@ -329,149 +325,15 @@ def begin_pairing(): def rollout_batch(flights, constraint, encoder, decoder, encoded, B=50, greedy=False, device=None): - """B개 rollout을 매 step 배치 decoder call로 동시 실행. - - require_base_return은 여기서 지원하지 않는다 — base 회전/salvage_doomed 같은 - hard-mask 안전장치가 rollout_with_pairings()에만 있고 이 배치 경로엔 없어서, - 그냥 통과시키면 base 미복귀 pairing이 조용히 섞여 나온다(2026-07-29). 지금은 - 아무 호출부도 이 조합을 안 쓰지만, 나중에 실수로 쓰면 바로 터지게 막아둔다. - """ - if constraint.get("require_base_return"): - raise NotImplementedError( - "rollout_batch()/collect_pool()/collect_pool_multibase()는 " - "require_base_return을 지원하지 않습니다 — hard mask가 필요하면 " - "rollout_with_pairings() 기반 경로(예: evaluation/evaluate_ip.py의 collect_pool_full)를 쓰세요." + """CPP legality가 검증된 single rollout을 B회 실행해 동일한 반환 형식을 제공함.""" + # 벡터화보다 correctness를 우선하며, 이후 동일 lifecycle을 보존한 최적화로 교체 가능함. + return [ + rollout_with_pairings( + flights, constraint, encoder, decoder, encoded, + greedy=greedy, device=device, ) - dev = device or torch.device("cpu") - n_flights = len(flights) - episode_base = constraint.get("base_airport", 0) - flight_by_id = {f["id"]: f for f in flights} - - assigned = [{f["id"]: False for f in flights} for _ in range(B)] - states = [None] * B - cur_legs = [[] for _ in range(B)] - pair_dep = [None] * B - pair_fly = [0.0] * B - pair_arr = [0.0] * B - pair_rest = [0.0] * B - pair_duties = [1] * B # number of duties in the current pairing - pairings = [[] for _ in range(B)] - done = [False] * B - - def flush_env(i, forced=False): - if not cur_legs[i] or pair_dep[i] is None: - return - elapsed = pair_arr[i] - pair_dep[i] - fly = pair_fly[i] - n_legs = len(cur_legs[i]) - dead = max(elapsed - fly - pair_rest[i], 0.0) - cost = (dead - - config.IP_LEG_BONUS * max(n_legs - 1, 0) - + (config.IP_DEADHEAD_PENALTY if forced else 0.0) - + config.IP_PAIRING_FIXED_COST) - ends_at_base = (flight_by_id[cur_legs[i][0]]["origin"] == episode_base - and flight_by_id[cur_legs[i][-1]]["dest"] == episode_base) - pairings[i].append({"legs": list(cur_legs[i]), "fly": fly, "elapsed": elapsed, - "dead_time": dead, "cost": cost, "is_deadhead": forced, - "n_legs": n_legs, "n_duties": pair_duties[i], - "ends_at_base": ends_at_base}) - - def start_env(i, f): - assigned[i][f["id"]] = True - cur_legs[i] = [f["id"]] - pair_dep[i] = f["dep_time"] - pair_fly[i] = f["arr_time"] - f["dep_time"] - pair_arr[i] = f["arr_time"] - pair_rest[i] = 0.0 - pair_duties[i] = 1 - states[i] = { - "current_airport": f["dest"], - "current_time": f["arr_time"], - "duty_time": f["arr_time"] - f["dep_time"], - "duty_start_time": f["dep_time"], - "legs": 1, - "remaining": sum(1 for v in assigned[i].values() if not v), - "pairing_start": False, - "duty_period": 0, - "pairing_start_time": f["dep_time"], - "is_resting": False, - "rest_end_time": None, - "base_airport": episode_base, - } - - base_fs = [f for f in flights if f["origin"] == episode_base] - first = sorted(base_fs or flights, key=lambda f: f["dep_time"])[0] - for i in range(B): - start_env(i, first) - - for _ in range(n_flights * 6): - active = [i for i in range(B) if not done[i]] - if not active: - break - - normal, zero_mask = [], [] - for i in active: - ml = get_mask(states[i], flights, assigned[i], constraint) - if sum(ml[:-2]) == 0 and ml[-2] == 0 and ml[-1] == 0: - zero_mask.append(i) - else: - normal.append((i, ml)) - - for i in zero_mask: - unassigned = [f for f in flights if not assigned[i][f["id"]]] - if not unassigned: - flush_env(i) - done[i] = True - continue - flush_env(i, forced=True) - bf = [f for f in unassigned if f["origin"] == episode_base] - start_env(i, sorted(bf or unassigned, key=lambda f: f["dep_time"])[0]) - - if not normal: - continue - - idxs = [i for i, _ in normal] - masks_t = torch.stack([ - torch.tensor(ml, dtype=torch.float32) for _, ml in normal - ]).to(dev) - _incl_total = decoder.state_mlp[0].weight.shape[1] > 78 - svecs_t = torch.stack([ - state_to_vec(states[i], encoder, constraint, device=dev, include_total_legs=_incl_total) for i in idxs - ]).to(dev) - gap_bias_t = flight_gap_bias_batch([states[i] for i in idxs], flights, constraint, device=dev) - - probs = decoder(encoded, svecs_t, masks_t, gap_bias=gap_bias_t) - if greedy: - actions = probs.argmax(dim=-1).cpu().tolist() - else: - actions = Categorical(probs).sample().cpu().tolist() - - for action, i in zip(actions, idxs): - if action == n_flights: # EndDuty - pair_rest[i] += constraint.get("min_rest", 10.0) - pair_duties[i] += 1 - states[i], _, _ = step(states[i], action, flights, assigned[i], constraint) - - elif action == n_flights + 1: # EndPairing - flush_env(i) - unassigned = [f for f in flights if not assigned[i][f["id"]]] - if not unassigned: - done[i] = True - continue - bf = [f for f in unassigned if f["origin"] == episode_base] - start_env(i, sorted(bf or unassigned, key=lambda f: f["dep_time"])[0]) - - else: # select a flight leg - f = flights[action] - cur_legs[i].append(f["id"]) - pair_fly[i] += f["arr_time"] - f["dep_time"] - pair_arr[i] = f["arr_time"] - states[i], _, done_flag = step(states[i], action, flights, assigned[i], constraint) - if done_flag: - flush_env(i) - done[i] = True - - return pairings + for _ in range(B) + ] def collect_pool(flights, constraint, encoder, decoder, encoded, diff --git a/RL/turkish/constraints_turkish.py b/RL/turkish/constraints_turkish.py index 4c5eae8..08a9495 100644 --- a/RL/turkish/constraints_turkish.py +++ b/RL/turkish/constraints_turkish.py @@ -1,11 +1,5 @@ -# constraints_turkish.py -- Turkish-specific constraint definitions (allows asymmetric HB1/HB2 termination) -# -# Why this file exists instead of using RL/constraints.py's get_turkish_constraints() directly: -# HB1/HB2 are both in the same city (Istanbul) and are effectively the same home, so a pairing -# that starts at one and ends at the other should incur no penalty (see field 1 description in -# RL/data/timetables/ttfields.txt). For delta and others the bases are in different cities, so -# this logic must not apply there -- hence the original RL/constraints.py is left untouched, and -# the base_ids (set-based) logic is kept separate here for turkish only. +# constraints_turkish.py -- Turkish-specific constraint definitions +# HB1/HB2는 상호 대체 가능한 Turkish home-base 집합으로 처리함. from airline_constraints.turkish import TURKISH_CONSTRAINTS @@ -14,14 +8,14 @@ def get_turkish_constraints(base_airport: int, base_ids=None): """Return the Turkish Airlines (THY) constraint dict. base_airport: the base airport ID where the pairing starts for this episode (either HB1 or HB2) - base_ids: full list of HB1/HB2 IDs. When given, environment_turkish.py accepts any base in - base_ids for pairing end/restart (allows HB1<->HB2 asymmetry). - If None, only base_airport itself is treated as a valid base, as before. + base_ids: pairing이 시작하거나 종료할 수 있는 HB1/HB2 home-base ID 목록. + HB1에서 시작해 HB2로 복귀하거나 그 반대인 pairing도 유효함. base_airport/base_ids are excluded from the FiLM input (categorical -- not in FILM_CONSTRAINT_KEYS) """ c = {**TURKISH_CONSTRAINTS, "base_airport": base_airport} if base_ids is not None: c["base_ids"] = list(base_ids) + c["allow_cross_base_return"] = True return c diff --git a/RL/turkish/environment_turkish.py b/RL/turkish/environment_turkish.py index 36777b8..282ce9b 100644 --- a/RL/turkish/environment_turkish.py +++ b/RL/turkish/environment_turkish.py @@ -1,6 +1,6 @@ import numpy as np import config -from base_reach import can_reach_base +from base_reach import can_reach_any_base # flight dict keys: "origin", "dest", "dep_time", "arr_time", "id" @@ -40,31 +40,16 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): duty_start_time = state.get("duty_start_time", state["current_time"]) pairing_start_time = state.get("pairing_start_time", state["current_time"]) - # First leg of a pairing: force departure from a base if unassigned base-origin flights - # remain. Once base-origin flights are exhausted, lift the origin restriction to prevent - # deadhead loops. - # HB1/HB2 asymmetry: if base_ids is given, departure from any base in it is accepted. - # base_remaining does not depend on the candidate flight f (loop-invariant), so it is - # computed once outside the loop -- recomputing it inside the loop would be O(N^2), which - # slows episodes on low-connectivity bases (HB2) to tens of seconds each (found in the - # log/0704 turkish smoke test). - base_ap = c.get("base_airport", config.DEFAULT_CONSTRAINTS["base_airport"]) + # 첫 flight는 episode base에서 시작하되 HB1/HB2 중 어느 home base로든 복귀 가능함. + base_ap = c["base_airport"] base_id_set = set(c.get("base_ids") or [base_ap]) - # Base-return hard mask (only when require_base_return is set) -- same principle as - # environment.py. rollout.py's constraint_for() computes _base_reach relative to - # episode_base (=base_ap), so this only enforces a single return to base_ap (the base - # this pairing actually departed from) -- cross HB1<->HB2 returns are not supported under - # the hard mask (a stricter subset). - require_return = c.get("require_base_return", False) - base_reach = c.get("_base_reach") if require_return else None + base_reaches = c.get("_base_reaches") + if base_reaches is None and c.get("_base_reach") is not None: + base_reaches = {base_ap: c["_base_reach"]} + if not base_reaches: + raise ValueError("Turkish CPP constraint에는 _base_reaches가 필요합니다.") max_pd = c.get("max_pairing_days", config.DEFAULT_CONSTRAINTS["max_pairing_days"]) max_duty_periods = c.get("max_duty_periods", config.DEFAULT_CONSTRAINTS["max_duty_periods"]) - if pairing_start: - base_remaining = any( - not assigned[fl["id"]] and fl["origin"] in base_id_set - for fl in flights - ) - for i, f in enumerate(flights): if assigned[f["id"]]: continue @@ -73,7 +58,7 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # 1. Airport connectivity check if pairing_start: - if base_remaining and f["origin"] not in base_id_set: + if f["origin"] != base_ap: valid = False elif f["origin"] != state["current_airport"]: valid = False @@ -108,11 +93,11 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): if elapsed_days > c.get("max_pairing_days", config.DEFAULT_CONSTRAINTS["max_pairing_days"]): valid = False - # 5. Base-return feasibility (only when require_base_return is set) - if valid and base_reach is not None: + # 5. Base 복귀 가능성 + if valid: ps_time = f["dep_time"] if pairing_start else pairing_start_time - if not can_reach_base( - base_reach, f, ps_time, max_pd, + if not can_reach_any_base( + base_reaches, f, ps_time, max_pd, duty_period=duty_period, max_duty_periods=max_duty_periods, ): valid = False @@ -139,10 +124,8 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): state.get("total_legs", 0) >= min_pairing_legs and pairing_elapsed_days <= c.get("max_pairing_days", config.DEFAULT_CONSTRAINTS["max_pairing_days"]) ) - # When base is not returned to, BASE_PENALTY is handled as a reward in step() (hard mask - # removed -> soft penalty). If require_base_return is set, revert to a hard mask -- cannot - # end anywhere other than the base (base_ap, the base this pairing departed from). - if require_return and state["current_airport"] != base_ap: + # Turkish pairing은 HB1/HB2 중 어느 home base에서도 종료 가능함. + if state["current_airport"] not in base_id_set: can_end_pairing = False if can_end_pairing: mask[config.END_PAIRING] = 1 @@ -161,9 +144,13 @@ def step(state, action, flights, assigned, constraint=None): """ c = constraint if constraint else config.DEFAULT_CONSTRAINTS N = len(flights) + if action < 0 or action >= N + 2: + raise IndexError("action이 허용 범위를 벗어났습니다.") # END_DUTY -> enter rest, pairing continues if action == N: + if not get_mask(state, flights, assigned, c)[config.END_DUTY]: + raise ValueError("현재 상태에서는 END_DUTY를 선택할 수 없습니다.") min_rest = c.get("min_rest", config.DEFAULT_CONSTRAINTS["min_rest"]) next_state = { **state, @@ -188,11 +175,11 @@ def step(state, action, flights, assigned, constraint=None): # END_PAIRING -> charge the pairing cost, then start a new pairing (or end the episode) if action == N + 1: + if not get_mask(state, flights, assigned, c)[config.END_PAIRING]: + raise ValueError("CPP 제약을 만족하지 않은 pairing은 종료할 수 없습니다.") p_cost = c.get("pairing_cost", config.DEFAULT_CONSTRAINTS["pairing_cost"]) - base_penalty = c.get("base_penalty", config.DEFAULT_CONSTRAINTS["base_penalty"]) # constraint["base_airport"] is injected per episode - base = c.get("base_airport", config.DEFAULT_CONSTRAINTS["base_airport"]) - # HB1/HB2 asymmetry: if base_ids is given, returning to any base in it incurs no penalty + base = c["base_airport"] base_id_set = set(c.get("base_ids") or [base]) total_legs = state.get("total_legs", 0) @@ -200,17 +187,13 @@ def step(state, action, flights, assigned, constraint=None): if total_legs < config.MIN_LEGS_FOR_PAIRING: reward += config.MIN_LEGS_PENALTY - if state["current_airport"] not in base_id_set: - reward -= base_penalty unassigned = [f for f in flights if not assigned[f["id"]]] if not unassigned: # All flights covered -> end the episode return state, reward, True # Unassigned flights remain -> start a new pairing - # If the just-arrived location is one of base_ids, start there; otherwise (base not - # returned to) relocate to the nearest base and start the new pairing there -- - # equivalent to prior behavior (teleport to the single base) when base_ids is not given + # 도착한 Turkish home base에서 다음 pairing을 시작함. restart_base = state["current_airport"] if state["current_airport"] in base_id_set else base base_unassigned = [f for f in unassigned if f["origin"] == restart_base] next_time = min(f["dep_time"] for f in base_unassigned) if base_unassigned else min(f["dep_time"] for f in unassigned) @@ -230,7 +213,9 @@ def step(state, action, flights, assigned, constraint=None): } return next_state, reward, False - # Select a flight + # flight action도 직접 호출 시 hard mask legality를 다시 확인함. + if not get_mask(state, flights, assigned, c)[action]: + raise ValueError("CPP 제약을 위반한 flight는 선택할 수 없습니다.") f = flights[action] assigned[f["id"]] = True flight_time = f["arr_time"] - f["dep_time"] diff --git a/evaluation/evaluate_ip.py b/evaluation/evaluate_ip.py index 360c574..147a59c 100644 --- a/evaluation/evaluate_ip.py +++ b/evaluation/evaluate_ip.py @@ -48,13 +48,13 @@ "delta": get_delta_constraints, "alaska": get_alaska_constraints, "jetblue": get_jetblue_constraints, - "turkish": get_turkish_constraints_hb, # allows asymmetric HB1/HB2 termination + "turkish": get_turkish_constraints_hb, # HB1/HB2 cross-base return contract } from model import FlightEncoder, PointerDecoder from evaluation.set_partition import solve_set_covering, solve_lp_relaxation from utils import constraint_to_tensor, flights_to_tensors from rollout import rollout_with_pairings, set_environment -from base_reach import build_base_reach +from base_reach import build_base_reaches import config @@ -262,8 +262,7 @@ def collect_pool_full(windows, base_ids, constraint, encoder, decoder, n_rollouts_per_chunk=5, subset_size=config.EPISODE_MAX_FLIGHTS, connected_sampler=sample_connected_subnet_std, - airline="delta", - require_base_return=False): + airline="delta"): """Roll out over all windows to build the global-ID-keyed candidate pool Cθ. Paper Sec. "Scalable Inference and Global Selection": "For each chunk, we @@ -276,12 +275,8 @@ def collect_pool_full(windows, base_ids, constraint, encoder, decoder, included in at least one rollout (guaranteeing 100% coverage opportunity) while preserving the same connectivity density seen during training. - airline="turkish" allows the two bases HB1/HB2 to substitute for each - other (environment_turkish.py) -- rollout.py's p["ends_at_base"] only - checks same-base return against the single base assigned to that - rollout, so it would incorrectly reject a valid HB1->HB2 cross-return. - For turkish only, validity is instead determined by checking whether the - actual first/last leg's origin/dest lie in base_id_set (all of HB1, HB2). + Turkish는 선택된 HB1/HB2 중 하나에서 시작하고 두 home base 중 어느 쪽으로든 복귀 가능함. + 일반 항공사는 pairing이 출발한 동일 base로 복귀함. """ pool = {} covered_global = set() @@ -324,33 +319,23 @@ def collect_pool_full(windows, base_ids, constraint, encoder, decoder, def _pairing_valid(p, _chunk_by_gid=chunk_by_gid): if airline != "turkish": return p["ends_at_base"] - # turkish: HB1->HB2 and HB2->HB1 are also valid -- rollout.py's - # ends_at_base (single-episode_base same-base check) would - # reject this cross-return, so re-derive validity by - # comparing the actual first/last leg origin/dest against the - # full base_id_set. + # Turkish는 HB1→HB2와 HB2→HB1 교차 home-base 복귀도 유효함. first = _chunk_by_gid.get(p["legs"][0]) - last = _chunk_by_gid.get(p["legs"][-1]) - return (first is not None and last is not None - and first["origin"] in base_id_set and last["dest"] in base_id_set) + last = _chunk_by_gid.get(p["legs"][-1]) + return ( + first is not None and last is not None + and first["origin"] in base_id_set + and last["dest"] in base_id_set + ) base_id = random.choice(base_ids) c_b = {**constraint, "base_airport": base_id} - if require_base_return: - # rollout.py supports base rotation (switch to another base - # once the current base's departing legs are exhausted) and - # salvage (on a dead end, keep only the prefix that ends at - # base and return the rest); pass base_ids/strict_base_start - # to activate that path. - c_b["base_ids"] = base_ids - c_b["strict_base_start"] = True - c_b["require_base_return"] = True - # rollout_subset_global remaps flight["id"] to local_id before - # rolling out (mask/step indexing breaks under global IDs), so - # reachability must also be computed against local_id to match - # the IDs actually looked up during rollout. - _local_flights = [{**f, "id": f["local_id"]} for f in chunk] - c_b["_base_reach"] = build_base_reach(_local_flights, base_id, c_b) + c_b["base_ids"] = base_ids + # local ID 기준 reachability를 모든 CPP rollout에 필수로 구성함. + _local_flights = [{**f, "id": f["local_id"]} for f in chunk] + return_bases = base_ids if c_b.get("allow_cross_base_return") else [base_id] + c_b["_base_reaches"] = build_base_reaches(_local_flights, return_bases, c_b) + c_b["_base_reach"] = c_b["_base_reaches"][base_id] for _ in range(n_rollouts_per_chunk): try: @@ -431,7 +416,6 @@ def evaluate_full( wandb_project="ASCP-2026-paper", compute_gap=False, seed=None, - require_base_return=False, ): """Full flight-coverage evaluation. Uses config.AIRLINE_DATA[airline] if data_path is unset. @@ -533,14 +517,9 @@ def evaluate_full( connected_sampler = sample_connected_subnet_turkish if airline == "turkish" else sample_connected_subnet_std - _hard_mask = require_base_return - if _hard_mask: - print("\n[base-return] decode-time hard mask ON (includes reachability pruning)", flush=True) - if airline == "turkish": - print(" [note] For turkish, HB1<->HB2 cross-return is not enforced; the hard " - "mask only enforces single-base return to whichever base the pairing " - "actually departed from (a stricter subset).", - flush=True) + print("\n[base-return] CPP hard constraint ON (includes reachability pruning)", flush=True) + if airline == "turkish": + print(" [note] Turkish는 HB1/HB2 교차 home-base 복귀를 허용합니다.", flush=True) print(f"\nCollecting pool (rollouts/chunk={n_rollouts_per_chunk}, subset={subset_size})...", flush=True) with torch.no_grad(): @@ -550,7 +529,6 @@ def evaluate_full( subset_size=subset_size, connected_sampler=connected_sampler, airline=airline, - require_base_return=_hard_mask, ) print(f"\nSolving IP (n_flights={n_total}, pool={len(pool)}, time_limit={ip_time_limit}s, lambda_dh={lambda_dh})...", flush=True) @@ -566,6 +544,13 @@ def evaluate_full( else: print(" [warn] LP relaxation failed to solve -- cannot compute Gap%") + if result["uncoverable"] > 0 or result["coverage"] < 1.0: + raise RuntimeError( + "CPP 해를 구성하지 못했습니다: coverage={:.3f}, uncoverable={}".format( + result["coverage"], result["uncoverable"] + ) + ) + sel = result["selected"] fly_total = sum(p["fly"] for p in sel) if sel else 0.0 raw_dead_total = sum(p.get("dead_time", p["cost"]) for p in sel) if sel else 0.0 @@ -669,9 +654,6 @@ def evaluate_full( help="Fix the random/torch RNG -- set this to run a paired comparison of " "multiple checkpoints against the same evaluation instance (e.g. the " "same seed for every ON/OFF checkpoint)") - parser.add_argument("--require-base-return", action="store_true", - help="Enable the decode-time hard mask -- masks any leg that would make " - "base return infeasible during rollout, and forbids EndPairing away from the base.") args = parser.parse_args() ckpt = args.checkpoint @@ -696,5 +678,4 @@ def evaluate_full( wandb_project=args.wandb_project, compute_gap=args.compute_gap, seed=args.seed, - require_base_return=args.require_base_return, ) diff --git a/experiments/train.py b/experiments/train.py index dbcc702..98b000a 100644 --- a/experiments/train.py +++ b/experiments/train.py @@ -23,8 +23,7 @@ def _select_environment(airline): - """airline에 맞는 get_mask/step/final_reward 구현으로 전환 (turkish는 HB1/HB2 비대칭 - 종료 허용). run_episode 등 이 모듈의 get_mask/step/final_reward를 + """airline에 맞는 get_mask/step/final_reward 구현으로 전환. run_episode 등 이 모듈의 get_mask/step/final_reward를 참조하는 모든 호출부에 즉시 반영됨 (모듈 전역 rebind).""" global get_mask, step, final_reward if airline == "turkish": @@ -37,9 +36,10 @@ def _select_environment(airline): "delta": get_delta_constraints, "alaska": get_alaska_constraints, "jetblue": get_jetblue_constraints, - "turkish": get_turkish_constraints_hb, # HB1/HB2 비대칭 종료 허용 (base_ids는 train()에서 주입) + "turkish": get_turkish_constraints_hb, # Turkish 규정값 및 HB1/HB2 교차 복귀 유지 } from state import init_state +from base_reach import build_base_reaches, can_reach_any_base from utils import flights_to_tensors, constraint_to_tensor, state_to_vec, flight_gap_bias, set_skip_decoder_constraint import config @@ -52,12 +52,29 @@ def _set_device(device_str: str): DEVICE = torch.device(device_str) +def _prepare_cpp_constraint(flights, constraint): + """일반 base 또는 Turkish HB1/HB2 집합에 대한 reachability를 구성함.""" + c = dict(constraint) + base = c["base_airport"] + return_bases = list(c.get("base_ids") or [base]) \ + if c.get("allow_cross_base_return") else [base] + cache_key = tuple(return_bases) + if c.get("_base_reaches") is not None and c.get("_base_reach_bases") == cache_key: + return c + c["_base_reaches"] = build_base_reaches(flights, return_bases, c) + c["_base_reach"] = c["_base_reaches"][base] + c["_base_reach_base"] = base + c["_base_reach_bases"] = cache_key + return c + + def run_episode(flights, constraint, encoder, decoder, encoded, greedy=False): """ Returns: total_reward, log_probs, entropies, metrics dict metrics: {n_pairings, n_deadheads, n_uncovered, coverage_pct} """ + constraint = _prepare_cpp_constraint(flights, constraint) assigned = {f["id"]: False for f in flights} state = init_state(flights, constraint) @@ -68,6 +85,7 @@ def run_episode(flights, constraint, encoder, decoder, encoded, greedy=False): n_deadheads = 0 # 강제 시작된 pairing 수 (connection 못 찾아서) n_end_duties = 0 total_legs_sum = 0 + n_zero_mask = 0 max_steps = len(flights) * 20 # 무한루프 방지 (flight당 최대 20 step) step_count = 0 @@ -78,45 +96,17 @@ def run_episode(flights, constraint, encoder, decoder, encoded, greedy=False): mask_list = get_mask(state, flights, assigned, constraint) mask = torch.tensor(mask_list, dtype=torch.float32).to(DEVICE) - # flight도 없고 END_DUTY/END_PAIRING도 불가 → 강제로 새 pairing 시작 (deadhead) + # 합법 action이 없으면 임의 위치 이동 없이 미커버 상태로 episode를 종료함. no_flight = sum(mask_list[:-2]) == 0 no_end_duty = mask_list[-2] == 0 no_end_pairing = mask_list[-1] == 0 if no_flight and no_end_duty and no_end_pairing: unassigned = [f for f in flights if not assigned[f["id"]]] - if len(unassigned) == 0: + if not unassigned: break - - # base 출발 편 우선, 없으면 가장 이른 편으로 강제 이동 (deadhead) - base = constraint["base_airport"] - base_unassigned = [f for f in unassigned if f["origin"] == base] - earliest = sorted(base_unassigned or unassigned, key=lambda x: x["dep_time"])[0] - - if not state.get("pairing_start", False): - total_legs_sum += state.get("total_legs", 0) - n_pairings += 1 - n_deadheads += 1 - # BASE_PENALTY, PAIRING_COST는 environment step()과 중복되지 않도록 - # deadhead 강제이동 시에만 직접 차감 - total_reward -= config.DEFAULT_CONSTRAINTS["pairing_cost"] - if state["current_airport"] != base: - total_reward -= constraint.get("base_penalty", config.DEFAULT_CONSTRAINTS["base_penalty"]) - - state = { - "current_airport": earliest["origin"], - "current_time": earliest["dep_time"], - "duty_time": 0.0, - "duty_start_time": earliest["dep_time"], - "legs": 0, - "total_legs": 0, - "remaining": sum(1 for v in assigned.values() if not v), - "pairing_start": True, - "duty_period": 0, - "pairing_start_time": earliest["dep_time"], - "is_resting": False, - "rest_end_time": None, - } - continue + # CPP에서 합법 action이 없으면 relocation하지 않고 미커버 상태로 종료함. + n_zero_mask += 1 + break # decoder state_vec = state_to_vec(state, encoder, constraint, device=DEVICE) @@ -170,6 +160,7 @@ def run_episode(flights, constraint, encoder, decoder, encoded, greedy=False): "coverage_pct": coverage_pct, "avg_legs": total_legs_sum / n_pairings if n_pairings > 0 else 0.0, "avg_overnight": n_end_duties / n_pairings if n_pairings > 0 else 0.0, + "n_zero_mask": n_zero_mask, } return total_reward, log_probs, entropies, metrics @@ -181,6 +172,7 @@ def run_episode(flights, constraint, encoder, decoder, encoded, greedy=False): def _rollout_with_pairings(flights, constraint, encoder, decoder, encoded, greedy=False): + constraint = _prepare_cpp_constraint(flights, constraint) assigned = {f["id"]: False for f in flights} flight_by_id = {f["id"]: f for f in flights} pairings = [] @@ -195,6 +187,17 @@ def flush_pairing(is_forced=False): if len(current_legs) < 1 or pairing_dep is None: return elapsed = pairing_last_arr - pairing_dep + n_legs = len(current_legs) + # 일반 항공사는 동일 base, Turkish는 HB1/HB2 home-base 집합 복귀를 허용함. + allowed_returns = set(constraint.get("base_ids") or [episode_base]) \ + if constraint.get("allow_cross_base_return") else {episode_base} + if flight_by_id[current_legs[0]]["origin"] != episode_base \ + or flight_by_id[current_legs[-1]]["dest"] not in allowed_returns: + raise ValueError("허용 home base로 복귀하지 않은 pairing은 dual pool에 저장할 수 없습니다.") + if n_legs < constraint["min_pairing_legs"]: + raise ValueError("최소 leg 수를 충족하지 않은 pairing은 dual pool에 저장할 수 없습니다.") + if elapsed / 24.0 > constraint["max_pairing_days"]: + raise ValueError("최대 pairing 기간을 초과한 pairing은 dual pool에 저장할 수 없습니다.") dead_time = max(elapsed - pairing_fly - pairing_rest, 0.0) cost = (dead_time - _LEG_BONUS_IP * max(len(current_legs) - 1, 0) @@ -203,8 +206,7 @@ def flush_pairing(is_forced=False): # A pairing must start and end at the base to be a valid column for # the LP-dual pool (Eq. 2 requires x_p in Omega(c), which excludes # pairings that never return to base); same check as RL/rollout.py. - ends_at_base = (flight_by_id[current_legs[0]]["origin"] == episode_base - and flight_by_id[current_legs[-1]]["dest"] == episode_base) + ends_at_base = True pairings.append({"legs": list(current_legs), "fly": pairing_fly, "elapsed": elapsed, "cost": cost, "ends_at_base": ends_at_base}) @@ -218,11 +220,22 @@ def start_new(f): pairing_last_arr = f["arr_time"] pairing_rest = 0.0 - episode_base = constraint.get("base_airport", 0) + episode_base = constraint["base_airport"] + + def base_start_candidates(candidates): + base_flights = [f for f in candidates if f["origin"] == episode_base] + # 수동 시작 flight도 decoder와 같은 복귀 가능성 검사를 통과해야 함. + return [f for f in base_flights if can_reach_any_base( + constraint["_base_reaches"], f, f["dep_time"], + constraint["max_pairing_days"], duty_period=0, + max_duty_periods=constraint["max_duty_periods"], + )] # Manually start the first flight -- prefer a base-departing leg unassigned = [f for f in flights if not assigned[f["id"]]] - base_flights = [f for f in unassigned if f["origin"] == episode_base] + base_flights = base_start_candidates(unassigned) + if not base_flights: + return pairings first = sorted(base_flights or unassigned, key=lambda f: f["dep_time"])[0] assigned[first["id"]] = True start_new(first) @@ -247,37 +260,14 @@ def start_new(f): while True: step_count += 1 if step_count > max_steps: - flush_pairing(is_forced=False) break mask_list = get_mask(state, flights, assigned, constraint) mask = torch.tensor(mask_list, dtype=torch.float32).to(DEVICE) if sum(mask_list[:-2]) == 0 and mask_list[-2] == 0 and mask_list[-1] == 0: - unassigned = [f for f in flights if not assigned[f["id"]]] - if not unassigned: - flush_pairing(is_forced=False) - break - flush_pairing(is_forced=True) - base_flights = [f for f in unassigned if f["origin"] == episode_base] - nxt = sorted(base_flights or unassigned, key=lambda f: f["dep_time"])[0] - assigned[nxt["id"]] = True - start_new(nxt) - state = { - "current_airport": nxt["dest"], - "current_time": nxt["arr_time"], - "duty_time": nxt["arr_time"] - nxt["dep_time"], - "duty_start_time": nxt["dep_time"], - "legs": 1, - "total_legs": 1, - "remaining": sum(1 for v in assigned.values() if not v), - "pairing_start": False, - "duty_period": 0, - "pairing_start_time": nxt["dep_time"], - "is_resting": False, - "rest_end_time": None, - } - continue + # 미복귀 partial pairing은 CPP column으로 저장하지 않음. + break state_vec = state_to_vec(state, encoder, constraint, device=DEVICE) gap_bias = flight_gap_bias(state, flights, constraint, device=DEVICE) @@ -294,7 +284,9 @@ def start_new(f): unassigned = [f for f in flights if not assigned[f["id"]]] if not unassigned: break - base_flights = [f for f in unassigned if f["origin"] == episode_base] + base_flights = base_start_candidates(unassigned) + if not base_flights: + break nxt = sorted(base_flights or unassigned, key=lambda f: f["dep_time"])[0] assigned[nxt["id"]] = True start_new(nxt) @@ -327,6 +319,7 @@ def start_new(f): def _collect_pool(flights, constraint, encoder, decoder, encoded, n_rollouts): + constraint = _prepare_cpp_constraint(flights, constraint) # Exclude pairings that do not return to base -- the restricted LP of # Eq. (2) is defined over Omega(c), and its duals mu^cov/nu^exc (Eq. 9) # should not be computed from infeasible columns. @@ -357,6 +350,7 @@ def run_episode_with_dual(flights, constraint, encoder, decoder, encoded, dual_v restricted-master LP solve (Algorithm 1, line 6); dual_weight is w_dual(e), ramped up externally by run_phase2() (Algorithm 1, line 8-9). """ + constraint = _prepare_cpp_constraint(flights, constraint) assigned = {f["id"]: False for f in flights} state = init_state(flights, constraint) @@ -367,6 +361,7 @@ def run_episode_with_dual(flights, constraint, encoder, decoder, encoded, dual_v n_deadheads = 0 n_end_duties = 0 total_legs_sum = 0 + n_zero_mask = 0 base = constraint["base_airport"] max_steps = len(flights) * 20 @@ -387,30 +382,9 @@ def run_episode_with_dual(flights, constraint, encoder, decoder, encoded, dual_v unassigned = [f for f in flights if not assigned[f["id"]]] if not unassigned: break - base_unassigned = [f for f in unassigned if f["origin"] == base] - earliest = sorted(base_unassigned or unassigned, key=lambda x: x["dep_time"])[0] - if not state.get("pairing_start", False): - total_legs_sum += state.get("total_legs", 0) # include this pairing's legs in avg_legs numerator even on a forced deadhead flush - n_pairings += 1 - n_deadheads += 1 - total_reward -= config.DEFAULT_CONSTRAINTS["pairing_cost"] - if state["current_airport"] != base: - total_reward -= constraint.get("base_penalty", config.DEFAULT_CONSTRAINTS["base_penalty"]) - state = { - "current_airport": earliest["origin"], - "current_time": earliest["dep_time"], - "duty_time": 0.0, - "duty_start_time": earliest["dep_time"], - "legs": 0, - "total_legs": 0, - "remaining": sum(1 for v in assigned.values() if not v), - "pairing_start": True, - "duty_period": 0, - "pairing_start_time": earliest["dep_time"], - "is_resting": False, - "rest_end_time": None, - } - continue + # dual 학습도 동일한 CPP action space에서 미커버 상태로 종료함. + n_zero_mask += 1 + break state_vec = state_to_vec(state, encoder, constraint, device=DEVICE) gap_bias = flight_gap_bias(state, flights, constraint, device=DEVICE) @@ -460,6 +434,7 @@ def run_episode_with_dual(flights, constraint, encoder, decoder, encoded, dual_v "coverage_pct": coverage_pct, "avg_legs": total_legs_sum / n_pairings if n_pairings > 0 else 0.0, "avg_overnight": n_end_duties / n_pairings if n_pairings > 0 else 0.0, + "n_zero_mask": n_zero_mask, } @@ -494,6 +469,7 @@ def run_phase2(encoder, decoder, optimizer, n_episodes, constraint, save_dir, fl base_c = constraint_sampler() if constraint_sampler else constraint c = {**base_c, "base_airport": base_airport} + c = _prepare_cpp_constraint(flights, c) c_tensor = constraint_to_tensor(c, device=DEVICE) with torch.no_grad(): @@ -629,6 +605,7 @@ def run_curriculum_stage( c = constraint_sampler() if constraint_sampler else constraint_override c = {**c, "base_airport": base_airport} # 에피소드별 base 주입 + c = _prepare_cpp_constraint(flights, c) # 선택된 복원/샘플링 제약조건 사전(c)을 기반으로 정확히 텐서를 빌드하여 FiLM 정렬 유지 c_tensor = constraint_to_tensor(c, device=DEVICE) @@ -744,7 +721,7 @@ def train(phase2_only=False, multi_airline=False, skip_film=False, skip_decoder_ n_airports = len(airport_map) print(f"airports: {n_airports}개, airline: {config.AIRLINE}, bases: {airline_bases}") if config.AIRLINE == "turkish": - # HB1/HB2 비대칭 종료 허용 — base_ids를 클로저로 캡처해 get_turkish_constraints_hb에 주입 + # 두 Istanbul base 중 하나에서 시작하고 HB1/HB2 어느 쪽으로든 복귀함 _CONSTRAINT_FN["turkish"] = lambda b, _hb=base_ids: get_turkish_constraints_hb(b, base_ids=_hb) encoder = FlightEncoder( diff --git a/result/v1_strict_hardmask/.gitkeep b/result/v1_strict_hardmask/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/result/v1_strict_hardmask/all_tests.log b/result/v1_strict_hardmask/all_tests.log new file mode 100644 index 0000000..10811a4 --- /dev/null +++ b/result/v1_strict_hardmask/all_tests.log @@ -0,0 +1,31 @@ +test_collect_pool_has_no_base_return_opt_out (test_evaluation_contract.CppEvaluationContractTest) ... ok +test_evaluate_full_has_no_base_return_opt_out (test_evaluation_contract.CppEvaluationContractTest) ... ok +test_incomplete_coverage_fails_instead_of_reporting_cpp_solution (test_evaluation_contract.CppEvaluationContractTest) ... ok +test_turkish_cross_base_pairing_exception_is_preserved (test_evaluation_contract.CppEvaluationContractTest) ... ok +test_cpp_end_pairing_requires_base_return (test_mask_contract.StrictMaskContractTest) ... ok +test_cpp_requires_reachability (test_mask_contract.StrictMaskContractTest) ... ok +test_cpp_start_never_uses_non_base_origin (test_mask_contract.StrictMaskContractTest) ... ok +test_direct_end_pairing_cannot_bypass_mask (test_mask_contract.StrictMaskContractTest) ... ok +test_direct_flight_action_cannot_bypass_mask (test_mask_contract.StrictMaskContractTest) ... ok +test_legacy_flags_cannot_disable_cpp_contract (test_mask_contract.StrictMaskContractTest) ... ok +test_missing_base_airport_is_configuration_error (test_mask_contract.StrictMaskContractTest) ... ok +test_out_of_range_action_fails_before_state_mutation (test_mask_contract.StrictMaskContractTest) ... ok +test_turkish_cpp_start_is_bound_to_episode_base (test_mask_contract.StrictMaskContractTest) ... ok +test_turkish_cross_base_return_is_legal (test_mask_contract.StrictMaskContractTest) ... ok +test_unreachable_flight_is_masked_before_selection (test_mask_contract.StrictMaskContractTest) ... ok +test_all_zero_at_base_does_not_emit_short_pairing (test_rollout_contract.StrictRolloutTest) ... ok +test_batch_rollout_preserves_cpp_contract (test_rollout_contract.StrictRolloutTest) ... ok +test_single_rollout_returns_only_base_to_base_pairing (test_rollout_contract.StrictRolloutTest) ... ok +test_turkish_rollout_allows_cross_base_return (test_rollout_contract.StrictRolloutTest) ... ok +test_dual_episode_uses_same_strict_stop (test_training_contract.StrictTrainingTest) ... ok +test_legacy_flag_cannot_disable_cpp_training (test_training_contract.StrictTrainingTest) ... ok +test_phase2_pool_drops_doomed_partial_pairing (test_training_contract.StrictTrainingTest) ... ok +test_prepared_constraint_reuses_reachability (test_training_contract.StrictTrainingTest) ... ok +test_stage_episode_stops_instead_of_arbitrary_restart (test_training_contract.StrictTrainingTest) ... ok +test_training_constraint_always_builds_cpp_reachability (test_training_contract.StrictTrainingTest) ... ok +test_turkish_constraint_builds_reachability_for_both_home_bases (test_training_contract.StrictTrainingTest) ... ok + +---------------------------------------------------------------------- +Ran 26 tests in 0.014s + +OK diff --git a/result/v1_strict_hardmask/evaluation_contract.log b/result/v1_strict_hardmask/evaluation_contract.log new file mode 100644 index 0000000..31c86ef --- /dev/null +++ b/result/v1_strict_hardmask/evaluation_contract.log @@ -0,0 +1,5 @@ +.... +---------------------------------------------------------------------- +Ran 4 tests in 0.006s + +OK diff --git a/result/v1_strict_hardmask/mask_contract.log b/result/v1_strict_hardmask/mask_contract.log new file mode 100644 index 0000000..8e864c6 --- /dev/null +++ b/result/v1_strict_hardmask/mask_contract.log @@ -0,0 +1,5 @@ +........... +---------------------------------------------------------------------- +Ran 11 tests in 0.001s + +OK diff --git a/result/v1_strict_hardmask/rollout_contract.log b/result/v1_strict_hardmask/rollout_contract.log new file mode 100644 index 0000000..4c87e5a --- /dev/null +++ b/result/v1_strict_hardmask/rollout_contract.log @@ -0,0 +1,5 @@ +.... +---------------------------------------------------------------------- +Ran 4 tests in 0.004s + +OK diff --git a/result/v1_strict_hardmask/training_contract.log b/result/v1_strict_hardmask/training_contract.log new file mode 100644 index 0000000..de2dca9 --- /dev/null +++ b/result/v1_strict_hardmask/training_contract.log @@ -0,0 +1,5 @@ +....... +---------------------------------------------------------------------- +Ran 7 tests in 0.002s + +OK diff --git a/test/v1_strict_hardmask/run_all.py b/test/v1_strict_hardmask/run_all.py new file mode 100644 index 0000000..decc08c --- /dev/null +++ b/test/v1_strict_hardmask/run_all.py @@ -0,0 +1,10 @@ +import sys +import unittest +from pathlib import Path + +TEST_DIR = Path(__file__).resolve().parent + +# V1 전용 테스트만 탐색해 저장소의 다른 legacy 테스트와 분리 실행함. +suite = unittest.defaultTestLoader.discover(str(TEST_DIR), pattern="test_*.py") +result = unittest.TextTestRunner(verbosity=2).run(suite) +sys.exit(0 if result.wasSuccessful() else 1) diff --git a/test/v1_strict_hardmask/test_evaluation_contract.py b/test/v1_strict_hardmask/test_evaluation_contract.py new file mode 100644 index 0000000..dd1c30b --- /dev/null +++ b/test/v1_strict_hardmask/test_evaluation_contract.py @@ -0,0 +1,34 @@ +import inspect +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "RL")) + +from evaluation import evaluate_ip + + +class CppEvaluationContractTest(unittest.TestCase): + def test_evaluate_full_has_no_base_return_opt_out(self): + params = inspect.signature(evaluate_ip.evaluate_full).parameters + self.assertNotIn("require_base_return", params) + + def test_collect_pool_has_no_base_return_opt_out(self): + params = inspect.signature(evaluate_ip.collect_pool_full).parameters + self.assertNotIn("require_base_return", params) + + + def test_turkish_cross_base_pairing_exception_is_preserved(self): + source = inspect.getsource(evaluate_ip.collect_pool_full) + self.assertIn("HB1→HB2", source) + self.assertIn("last[\"dest\"] in base_id_set", source) + + def test_incomplete_coverage_fails_instead_of_reporting_cpp_solution(self): + source = inspect.getsource(evaluate_ip.evaluate_full) + self.assertIn("result[\"uncoverable\"] > 0", source) + self.assertIn("CPP 해를 구성하지 못했습니다", source) + +if __name__ == "__main__": + unittest.main() diff --git a/test/v1_strict_hardmask/test_mask_contract.py b/test/v1_strict_hardmask/test_mask_contract.py new file mode 100644 index 0000000..532db98 --- /dev/null +++ b/test/v1_strict_hardmask/test_mask_contract.py @@ -0,0 +1,157 @@ +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "RL")) + +import environment +from base_reach import build_base_reach +from turkish import environment_turkish + + +def make_state(**updates): + value = { + "current_airport": 0, "current_time": 0.0, "duty_start_time": 0.0, + "legs": 0, "total_legs": 0, "pairing_start": True, + "pairing_start_time": 0.0, "is_resting": False, + "rest_end_time": None, "duty_period": 0, + } + value.update(updates) + return value + + +def make_constraint(**updates): + value = { + "base_airport": 0, "min_conn": 0.5, "max_conn": 4.0, + "min_rest": 8.0, "max_duty": 14.0, "max_legs": 4, + "max_duty_periods": 2, "max_pairing_days": 2, + "min_pairing_legs": 2, + } + value.update(updates) + return value + + +class StrictMaskContractTest(unittest.TestCase): + def test_cpp_requires_reachability(self): + flights = [{"id": 0, "origin": 0, "dest": 1, "dep_time": 1.0, "arr_time": 2.0}] + with self.assertRaisesRegex(ValueError, "_base_reach"): + environment.get_mask(make_state(), flights, {0: False}, make_constraint()) + + def test_cpp_start_never_uses_non_base_origin(self): + flights = [ + {"id": 0, "origin": 1, "dest": 0, "dep_time": 1.0, "arr_time": 2.0}, + {"id": 1, "origin": 0, "dest": 1, "dep_time": 3.0, "arr_time": 4.0}, + ] + rule = make_constraint() + rule["_base_reach"] = build_base_reach(flights, 0, rule) + mask = environment.get_mask(make_state(), flights, {0: False, 1: True}, rule) + self.assertEqual(mask[0], 0) + + def test_cpp_end_pairing_requires_base_return(self): + flights = [{"id": 0, "origin": 0, "dest": 1, "dep_time": 1.0, "arr_time": 2.0}] + rule = make_constraint() + rule["_base_reach"] = build_base_reach(flights, 0, rule) + mask = environment.get_mask( + make_state(current_airport=1, current_time=2.0, legs=2, + total_legs=2, pairing_start=False), + flights, {0: True}, rule, + ) + self.assertEqual(mask[-1], 0) + + def test_unreachable_flight_is_masked_before_selection(self): + flights = [ + {"id": 0, "origin": 0, "dest": 1, "dep_time": 1.0, "arr_time": 2.0}, + {"id": 1, "origin": 0, "dest": 2, "dep_time": 1.0, "arr_time": 2.0}, + {"id": 2, "origin": 1, "dest": 0, "dep_time": 3.0, "arr_time": 4.0}, + ] + rule = make_constraint() + rule["_base_reach"] = build_base_reach(flights, 0, rule) + mask = environment.get_mask(make_state(), flights, {0: False, 1: False, 2: False}, rule) + self.assertEqual(mask[0], 1) + self.assertEqual(mask[1], 0) + + def test_legacy_flags_cannot_disable_cpp_contract(self): + flights = [ + {"id": 0, "origin": 1, "dest": 0, "dep_time": 1.0, "arr_time": 2.0}, + ] + rule = make_constraint(require_base_return=False, strict_base_start=False) + rule["_base_reach"] = build_base_reach(flights, 0, rule) + mask = environment.get_mask(make_state(), flights, {0: False}, rule) + self.assertEqual(mask[0], 0) + + def test_turkish_cpp_start_is_bound_to_episode_base(self): + flights = [ + {"id": 0, "origin": 1, "dest": 0, "dep_time": 1.0, "arr_time": 2.0}, + {"id": 1, "origin": 0, "dest": 1, "dep_time": 3.0, "arr_time": 4.0}, + ] + rule = make_constraint(base_ids=[0, 1]) + rule["_base_reach"] = build_base_reach(flights, 0, rule) + mask = environment_turkish.get_mask(make_state(), flights, {0: False, 1: True}, rule) + self.assertEqual(mask[0], 0) + + + def test_direct_end_pairing_cannot_bypass_mask(self): + flights = [{"id": 0, "origin": 0, "dest": 1, "dep_time": 1.0, "arr_time": 2.0}] + rule = make_constraint() + rule["_base_reach"] = build_base_reach(flights, 0, rule) + state = make_state(current_airport=1, current_time=2.0, legs=2, + total_legs=2, pairing_start=False) + with self.assertRaisesRegex(ValueError, "pairing"): + environment.step(state, len(flights) + 1, flights, {0: True}, rule) + with self.assertRaisesRegex(ValueError, "pairing"): + environment_turkish.step(state, len(flights) + 1, flights, {0: True}, rule) + + def test_missing_base_airport_is_configuration_error(self): + flights = [] + rule = make_constraint() + del rule["base_airport"] + rule["_base_reach"] = {} + with self.assertRaises(KeyError): + environment.get_mask(make_state(), flights, {}, rule) + + def test_direct_flight_action_cannot_bypass_mask(self): + flights = [ + {"id": 0, "origin": 1, "dest": 0, "dep_time": 1.0, "arr_time": 2.0}, + ] + rule = make_constraint() + rule["_base_reach"] = build_base_reach(flights, 0, rule) + for module in (environment, environment_turkish): + with self.assertRaisesRegex(ValueError, "flight"): + module.step(make_state(), 0, flights, {0: False}, rule) + + def test_out_of_range_action_fails_before_state_mutation(self): + rule = make_constraint() + rule["_base_reach"] = {} + with self.assertRaises(IndexError): + environment.step(make_state(), 2, [], {}, rule) + + def test_turkish_cross_base_return_is_legal(self): + flights = [ + {"id": 0, "origin": 0, "dest": 2, "dep_time": 1.0, "arr_time": 2.0}, + {"id": 1, "origin": 2, "dest": 1, "dep_time": 3.0, "arr_time": 4.0}, + ] + rule = make_constraint( + base_airport=0, base_ids=[0, 1], allow_cross_base_return=True + ) + rule["_base_reaches"] = { + base: build_base_reach(flights, base, rule) for base in rule["base_ids"] + } + rule["_base_reach"] = rule["_base_reaches"][0] + + start_mask = environment_turkish.get_mask( + make_state(), flights, {0: False, 1: False}, rule + ) + self.assertEqual(start_mask[0], 1) + + end_mask = environment_turkish.get_mask( + make_state( + current_airport=1, current_time=4.0, legs=2, + total_legs=2, pairing_start=False + ), + flights, {0: True, 1: True}, rule, + ) + self.assertEqual(end_mask[-1], 1) + +if __name__ == "__main__": + unittest.main() diff --git a/test/v1_strict_hardmask/test_rollout_contract.py b/test/v1_strict_hardmask/test_rollout_contract.py new file mode 100644 index 0000000..b476a68 --- /dev/null +++ b/test/v1_strict_hardmask/test_rollout_contract.py @@ -0,0 +1,143 @@ +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +import torch + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "RL")) + +import rollout +from base_reach import build_base_reach + + +class DummyLayer: + weight = torch.zeros((1, 78)) + + +class GreedyLegalDecoder: + state_mlp = [DummyLayer()] + + def __call__(self, encoded, state_vec, mask, gap_bias=None): + probs = torch.zeros_like(mask) + flight_count = len(mask) - 2 + feasible_flights = torch.nonzero(mask[:flight_count], as_tuple=False) + if len(feasible_flights): + probs[feasible_flights[0].item()] = 1.0 + elif mask[-1] > 0: + probs[-1] = 1.0 + else: + probs[-2] = 1.0 + return probs + + +def strict_fixture(): + flights = [ + {"id": 0, "origin": 0, "dest": 1, "dep_time": 1.0, "arr_time": 2.0}, + {"id": 1, "origin": 1, "dest": 0, "dep_time": 3.0, "arr_time": 4.0}, + ] + rule = { + "base_airport": 0, "base_ids": [0], + "min_conn": 0.5, "max_conn": 4.0, "min_rest": 8.0, + "max_duty": 14.0, "max_legs": 4, "max_duty_periods": 2, + "max_pairing_days": 2, "min_pairing_legs": 2, + } + rule["_base_reach"] = build_base_reach(flights, 0, rule) + return flights, rule + + +class StrictRolloutTest(unittest.TestCase): + def test_single_rollout_returns_only_base_to_base_pairing(self): + flights, rule = strict_fixture() + old_state_to_vec = rollout.state_to_vec + old_gap_bias = rollout.flight_gap_bias + rollout.state_to_vec = lambda *args, **kwargs: torch.zeros(78) + rollout.flight_gap_bias = lambda *args, **kwargs: torch.zeros(len(flights) + 2) + try: + with patch.object(rollout, "build_base_reach", side_effect=AssertionError("cache miss")): + pairings = rollout.rollout_with_pairings( + flights, rule, None, GreedyLegalDecoder(), None, greedy=True + ) + finally: + rollout.state_to_vec = old_state_to_vec + rollout.flight_gap_bias = old_gap_bias + + self.assertEqual(len(pairings), 1) + self.assertEqual(pairings[0]["legs"], [0, 1]) + self.assertTrue(pairings[0]["ends_at_base"]) + self.assertEqual(pairings[0]["true_start_airport"], 0) + + def test_batch_rollout_preserves_cpp_contract(self): + flights, rule = strict_fixture() + old_state_to_vec = rollout.state_to_vec + old_gap_bias = rollout.flight_gap_bias + rollout.state_to_vec = lambda *args, **kwargs: torch.zeros(78) + rollout.flight_gap_bias = lambda *args, **kwargs: torch.zeros(len(flights) + 2) + try: + results = rollout.rollout_batch( + flights, rule, None, GreedyLegalDecoder(), None, B=2, greedy=True + ) + finally: + rollout.state_to_vec = old_state_to_vec + rollout.flight_gap_bias = old_gap_bias + self.assertEqual(len(results), 2) + self.assertTrue(all(len(items) == 1 for items in results)) + self.assertTrue(all(items[0]["ends_at_base"] for items in results)) + + + def test_all_zero_at_base_does_not_emit_short_pairing(self): + flights = [ + {"id": 0, "origin": 0, "dest": 0, "dep_time": 1.0, "arr_time": 2.0}, + ] + rule = { + "base_airport": 0, "base_ids": [0], + "min_conn": 0.5, "max_conn": 4.0, "min_rest": 8.0, + "max_duty": 14.0, "max_legs": 4, "max_duty_periods": 0, + "max_pairing_days": 2, "min_pairing_legs": 2, + } + rule["_base_reach"] = build_base_reach(flights, 0, rule) + pairings = rollout.rollout_with_pairings( + flights, rule, None, GreedyLegalDecoder(), None, greedy=True + ) + self.assertEqual(pairings, []) + + def test_turkish_rollout_allows_cross_base_return(self): + flights = [ + {"id": 0, "origin": 0, "dest": 2, "dep_time": 1.0, "arr_time": 2.0}, + {"id": 1, "origin": 2, "dest": 1, "dep_time": 3.0, "arr_time": 4.0}, + ] + rule = { + "base_airport": 0, "base_ids": [0, 1], + "allow_cross_base_return": True, + "min_conn": 0.5, "max_conn": 4.0, "min_rest": 8.0, + "max_duty": 14.0, "max_legs": 4, "max_duty_periods": 2, + "max_pairing_days": 2, "min_pairing_legs": 2, + } + rule["_base_reaches"] = { + base: build_base_reach(flights, base, rule) for base in rule["base_ids"] + } + rule["_base_reach"] = rule["_base_reaches"][0] + + old_state_to_vec = rollout.state_to_vec + old_gap_bias = rollout.flight_gap_bias + rollout.state_to_vec = lambda *args, **kwargs: torch.zeros(78) + rollout.flight_gap_bias = lambda *args, **kwargs: torch.zeros(len(flights) + 2) + rollout.set_environment("turkish") + try: + pairings = rollout.rollout_with_pairings( + flights, rule, None, GreedyLegalDecoder(), None, greedy=True + ) + finally: + rollout.set_environment("delta") + rollout.state_to_vec = old_state_to_vec + rollout.flight_gap_bias = old_gap_bias + + self.assertEqual(len(pairings), 1) + self.assertEqual(pairings[0]["legs"], [0, 1]) + self.assertEqual(pairings[0]["true_start_airport"], 0) + self.assertEqual(pairings[0]["true_end_airport"], 1) + self.assertTrue(pairings[0]["ends_at_base"]) + +if __name__ == "__main__": + unittest.main() diff --git a/test/v1_strict_hardmask/test_training_contract.py b/test/v1_strict_hardmask/test_training_contract.py new file mode 100644 index 0000000..537c457 --- /dev/null +++ b/test/v1_strict_hardmask/test_training_contract.py @@ -0,0 +1,95 @@ +import sys +import unittest +from unittest.mock import patch +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "RL")) + +from experiments import train + + +def rule(**updates): + value = { + "base_airport": 0, "min_conn": 0.5, "max_conn": 4.0, + "min_rest": 8.0, "max_duty": 14.0, "max_legs": 4, + "max_duty_periods": 2, "max_pairing_days": 2, + "min_pairing_legs": 2, "pairing_cost": 5.0, + "base_penalty": 500.0, "uncovered_penalty": 10.0, + } + value.update(updates) + return value + + +class NeverCalledDecoder: + def __call__(self, *args, **kwargs): + raise AssertionError("all-zero strict 상태에서 decoder가 호출되면 안 됨") + + +class StrictTrainingTest(unittest.TestCase): + def setUp(self): + self.flights = [ + {"id": 0, "origin": 0, "dest": 1, "dep_time": 1.0, "arr_time": 2.0} + ] + + def test_training_constraint_always_builds_cpp_reachability(self): + prepared = train._prepare_cpp_constraint(self.flights, rule()) + self.assertIn("_base_reach", prepared) + self.assertEqual(prepared["_base_reach_base"], 0) + + def test_prepared_constraint_reuses_reachability(self): + prepared = train._prepare_cpp_constraint(self.flights, rule()) + with patch.object(train, "build_base_reaches", side_effect=AssertionError("rebuild")): + reused = train._prepare_cpp_constraint(self.flights, prepared) + self.assertIs(reused["_base_reach"], prepared["_base_reach"]) + + def test_legacy_flag_cannot_disable_cpp_training(self): + legacy_flag = rule(require_base_return=False, strict_base_start=False) + prepared = train._prepare_cpp_constraint(self.flights, legacy_flag) + self.assertIn("_base_reach", prepared) + _, _, _, metrics = train.run_episode( + self.flights, legacy_flag, None, NeverCalledDecoder(), None, greedy=True + ) + self.assertEqual(metrics["n_zero_mask"], 1) + self.assertEqual(metrics["n_uncovered"], 1) + + def test_stage_episode_stops_instead_of_arbitrary_restart(self): + _, _, _, metrics = train.run_episode( + self.flights, rule(), None, NeverCalledDecoder(), None, greedy=True + ) + self.assertEqual(metrics["n_zero_mask"], 1) + self.assertEqual(metrics["n_uncovered"], 1) + self.assertEqual(metrics["n_deadheads"], 0) + + def test_dual_episode_uses_same_strict_stop(self): + _, _, _, metrics = train.run_episode_with_dual( + self.flights, rule(), None, NeverCalledDecoder(), None, {}, greedy=True + ) + self.assertEqual(metrics["n_zero_mask"], 1) + self.assertEqual(metrics["n_uncovered"], 1) + + def test_phase2_pool_drops_doomed_partial_pairing(self): + pairings = train._rollout_with_pairings( + self.flights, rule(), None, NeverCalledDecoder(), None, greedy=True + ) + self.assertEqual(pairings, []) + + + def test_turkish_constraint_builds_reachability_for_both_home_bases(self): + flights = [ + {"id": 0, "origin": 0, "dest": 2, "dep_time": 1.0, "arr_time": 2.0}, + {"id": 1, "origin": 2, "dest": 1, "dep_time": 3.0, "arr_time": 4.0}, + ] + prepared = train._prepare_cpp_constraint( + flights, + rule( + base_airport=0, base_ids=[0, 1], + allow_cross_base_return=True, + ), + ) + self.assertEqual(set(prepared["_base_reaches"]), {0, 1}) + self.assertIs(prepared["_base_reach"], prepared["_base_reaches"][0]) + +if __name__ == "__main__": + unittest.main()