From 5b7e8581bc06c6df2f35fd23ff6e2918835fc88c Mon Sep 17 00:00:00 2001 From: pkhyrn268 Date: Tue, 18 Aug 2026 11:13:24 +0000 Subject: [PATCH 01/14] =?UTF-8?q?fix(mask):=20strict=20base=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=EA=B3=BC=20=EB=B3=B5=EA=B7=80=20=EA=B3=84=EC=95=BD=20?= =?UTF-8?q?=EA=B0=95=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RL/environment.py | 7 +- RL/turkish/environment_turkish.py | 7 +- result/v1_strict_hardmask/.gitkeep | 0 result/v1_strict_hardmask/mask_contract.log | 5 ++ test/v1_strict_hardmask/test_mask_contract.py | 87 +++++++++++++++++++ 5 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 result/v1_strict_hardmask/.gitkeep create mode 100644 result/v1_strict_hardmask/mask_contract.log create mode 100644 test/v1_strict_hardmask/test_mask_contract.py diff --git a/RL/environment.py b/RL/environment.py index 9584482..09d3471 100644 --- a/RL/environment.py +++ b/RL/environment.py @@ -64,6 +64,9 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # 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 + if require_return and base_reach is None: + # strict 모드가 복귀 가능성 검사를 빠뜨린 채 완화되지 않도록 즉시 중단함. + raise ValueError("require_base_return=True이면 _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 +84,9 @@ 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 c.get("strict_base_start", False) and f["origin"] != base_ap: + valid = False + elif base_remaining and f["origin"] != base_ap: valid = False elif f["origin"] != state["current_airport"]: valid = False diff --git a/RL/turkish/environment_turkish.py b/RL/turkish/environment_turkish.py index 36777b8..76a69e7 100644 --- a/RL/turkish/environment_turkish.py +++ b/RL/turkish/environment_turkish.py @@ -57,6 +57,9 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # 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 + if require_return and base_reach is None: + # strict 모드에서 Turkish 복귀 검사가 누락되면 즉시 실패시킴. + raise ValueError("require_base_return=True이면 _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"]) if pairing_start: @@ -73,7 +76,9 @@ 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 c.get("strict_base_start", False) and f["origin"] != base_ap: + valid = False + elif base_remaining and f["origin"] not in base_id_set: valid = False elif f["origin"] != state["current_airport"]: valid = False 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/mask_contract.log b/result/v1_strict_hardmask/mask_contract.log new file mode 100644 index 0000000..949db44 --- /dev/null +++ b/result/v1_strict_hardmask/mask_contract.log @@ -0,0 +1,5 @@ +..... +---------------------------------------------------------------------- +Ran 5 tests in 0.001s + +OK 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..717cfde --- /dev/null +++ b/test/v1_strict_hardmask/test_mask_contract.py @@ -0,0 +1,87 @@ +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, "require_base_return": True, + "strict_base_start": True, + } + value.update(updates) + return value + + +class StrictMaskContractTest(unittest.TestCase): + def test_strict_mode_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_strict_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_strict_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_turkish_strict_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) + + +if __name__ == "__main__": + unittest.main() From 0b77d1c34e7bd5fa0edf546a61ff81b2a1b73527 Mon Sep 17 00:00:00 2001 From: pkhyrn268 Date: Tue, 18 Aug 2026 11:24:09 +0000 Subject: [PATCH 02/14] =?UTF-8?q?fix(rollout):=20strict=20base=20=EB=B3=B5?= =?UTF-8?q?=EA=B7=80=20lifecycle=20=EC=A0=95=ED=95=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RL/rollout.py | 16 +++- result/v1_strict_hardmask/mask_contract.log | 2 +- .../v1_strict_hardmask/rollout_contract.log | 5 ++ .../test_rollout_contract.py | 79 +++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 result/v1_strict_hardmask/rollout_contract.log create mode 100644 test/v1_strict_hardmask/test_rollout_contract.py diff --git a/RL/rollout.py b/RL/rollout.py index 61d4ee1..234df18 100644 --- a/RL/rollout.py +++ b/RL/rollout.py @@ -59,6 +59,9 @@ def rollout_with_pairings(flights, constraint, encoder, decoder, encoded, min_pairing_legs = constraint.get("min_pairing_legs", 2) _reach_cache = {} + if require_return and constraint.get("_base_reach") is not None: + # 호출부가 계산한 현재 base의 reachability를 재사용해 rollout별 중복 계산을 막음. + _reach_cache[constraint.get("base_airport", 0)] = constraint["_base_reach"] def constraint_for(base): c = {**constraint, "base_airport": base} @@ -151,7 +154,8 @@ def emit_prefix(recs, end_ap, start_ap): "n_duties": n_rest + 1, "intra_duty_gap": intra, "inter_duty_excess": inter, - "ends_at_base": True, + # salvage 결과도 실제 마지막 도착지가 목표 base인지 다시 확인함. + "ends_at_base": recs[-1]["dest"] == end_ap, "true_start_airport": start_ap, "is_truncated": True, }) @@ -216,7 +220,9 @@ def pick_start(): return None, None if not startable: return None, None - return episode_base, min(startable, key=lambda f: f["dep_time"]) + f = min(startable, key=lambda f: f["dep_time"]) + # legacy 재시작에서도 constraint 기준 base와 실제 출발 공항을 일치시킴. + return f["origin"], f def begin_pairing(): nonlocal state, episode_base, cur_c @@ -316,6 +322,12 @@ def begin_pairing(): def rollout_batch(flights, constraint, encoder, decoder, encoded, B=50, greedy=False, device=None): """Run B rollouts concurrently, using one batched decoder call per step.""" + if constraint.get("require_base_return"): + # 배치 경로는 base 회전과 salvage를 지원하지 않아 strict 요청을 명시적으로 차단함. + raise NotImplementedError( + "rollout_batch는 strict hard mask를 지원하지 않습니다. " + "rollout_with_pairings 기반 경로를 사용하세요." + ) dev = device or torch.device("cpu") n_flights = len(flights) episode_base = constraint.get("base_airport", 0) diff --git a/result/v1_strict_hardmask/mask_contract.log b/result/v1_strict_hardmask/mask_contract.log index 949db44..c6996b4 100644 --- a/result/v1_strict_hardmask/mask_contract.log +++ b/result/v1_strict_hardmask/mask_contract.log @@ -1,5 +1,5 @@ ..... ---------------------------------------------------------------------- -Ran 5 tests in 0.001s +Ran 5 tests in 0.000s 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..75063c1 --- /dev/null +++ b/result/v1_strict_hardmask/rollout_contract.log @@ -0,0 +1,5 @@ +.. +---------------------------------------------------------------------- +Ran 2 tests in 0.009s + +OK 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..244664d --- /dev/null +++ b/test/v1_strict_hardmask/test_rollout_contract.py @@ -0,0 +1,79 @@ +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, + "require_base_return": True, "strict_base_start": True, + } + 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_rejects_strict_mode(self): + _, rule = strict_fixture() + with self.assertRaisesRegex(NotImplementedError, "strict hard mask"): + rollout.rollout_batch([], rule, None, None, None) + + +if __name__ == "__main__": + unittest.main() From 170c0cdbb8bfdda2abb8255d3ee943d42eade33f Mon Sep 17 00:00:00 2001 From: pkhyrn268 Date: Wed, 19 Aug 2026 03:40:13 +0000 Subject: [PATCH 03/14] =?UTF-8?q?fix(train):=20=ED=95=99=EC=8A=B5=EA=B3=BC?= =?UTF-8?q?=20dual=20=EA=B2=BD=EB=A1=9C=EC=97=90=20strict=20hard=20mask=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- experiments/train.py | 57 +++++++++++++-- result/v1_strict_hardmask/mask_contract.log | 2 +- .../v1_strict_hardmask/rollout_contract.log | 2 +- .../v1_strict_hardmask/training_contract.log | 5 ++ .../test_training_contract.py | 71 +++++++++++++++++++ 5 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 result/v1_strict_hardmask/training_contract.log create mode 100644 test/v1_strict_hardmask/test_training_contract.py diff --git a/experiments/train.py b/experiments/train.py index f436c9a..88efa4d 100644 --- a/experiments/train.py +++ b/experiments/train.py @@ -40,6 +40,7 @@ def _select_environment(airline): "turkish": get_turkish_constraints_hb, # HB1/HB2 비대칭 종료 허용 (base_ids는 train()에서 주입) } from state import init_state +from base_reach import build_base_reach, can_reach_base from utils import flights_to_tensors, constraint_to_tensor, state_to_vec, flight_gap_bias import config @@ -52,12 +53,25 @@ def _set_device(device_str: str): DEVICE = torch.device(device_str) +def _prepare_training_constraint(flights, constraint): + """명시적으로 끄지 않은 학습 episode에 strict base 복귀 조건을 구성함.""" + c = dict(constraint) + if not c.get("require_base_return", True): + return c + c["require_base_return"] = True + c["strict_base_start"] = True + base = c.get("base_airport", 0) + c["_base_reach"] = build_base_reach(flights, base, c) + 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_training_constraint(flights, constraint) assigned = {f["id"]: False for f in flights} state = init_state(flights, constraint) @@ -68,6 +82,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 @@ -86,6 +101,10 @@ def run_episode(flights, constraint, encoder, decoder, encoded, greedy=False): unassigned = [f for f in flights if not assigned[f["id"]]] if len(unassigned) == 0: break + if constraint.get("require_base_return"): + # strict 모드에서는 불법 pairing을 끊고 임의 공항에서 재시작하지 않음. + n_zero_mask += 1 + break # base 출발 편 우선, 없으면 가장 이른 편으로 강제 이동 (deadhead) base = constraint["base_airport"] @@ -170,6 +189,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 +201,7 @@ def run_episode(flights, constraint, encoder, decoder, encoded, greedy=False): def _rollout_with_pairings(flights, constraint, encoder, decoder, encoded, greedy=False): + constraint = _prepare_training_constraint(flights, constraint) assigned = {f["id"]: False for f in flights} flight_by_id = {f["id"]: f for f in flights} pairings = [] @@ -220,9 +241,22 @@ def start_new(f): episode_base = constraint.get("base_airport", 0) + def base_start_candidates(candidates): + base_flights = [f for f in candidates if f["origin"] == episode_base] + if not constraint.get("require_base_return"): + return base_flights + # 수동 시작 flight도 decoder와 같은 복귀 가능성 검사를 통과해야 함. + return [f for f in base_flights if can_reach_base( + constraint["_base_reach"], 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 constraint.get("require_base_return") and 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,7 +281,8 @@ def start_new(f): while True: step_count += 1 if step_count > max_steps: - flush_pairing(is_forced=False) + if not constraint.get("require_base_return"): + flush_pairing(is_forced=False) break mask_list = get_mask(state, flights, assigned, constraint) @@ -255,11 +290,16 @@ def start_new(f): 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 constraint.get("require_base_return"): + # strict pool에는 막다른 미복귀 pairing을 후보로 저장하지 않음. + break 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] + base_flights = base_start_candidates(unassigned) + if constraint.get("require_base_return") and not base_flights: + break nxt = sorted(base_flights or unassigned, key=lambda f: f["dep_time"])[0] assigned[nxt["id"]] = True start_new(nxt) @@ -294,7 +334,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 constraint.get("require_base_return") and not base_flights: + break nxt = sorted(base_flights or unassigned, key=lambda f: f["dep_time"])[0] assigned[nxt["id"]] = True start_new(nxt) @@ -357,6 +399,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_training_constraint(flights, constraint) assigned = {f["id"]: False for f in flights} state = init_state(flights, constraint) @@ -367,6 +410,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,6 +431,10 @@ 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 + if constraint.get("require_base_return"): + # dual 학습도 동일한 strict 행동 공간을 사용하고 임의 재시작을 금지함. + n_zero_mask += 1 + 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): @@ -460,6 +508,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, } diff --git a/result/v1_strict_hardmask/mask_contract.log b/result/v1_strict_hardmask/mask_contract.log index c6996b4..949db44 100644 --- a/result/v1_strict_hardmask/mask_contract.log +++ b/result/v1_strict_hardmask/mask_contract.log @@ -1,5 +1,5 @@ ..... ---------------------------------------------------------------------- -Ran 5 tests in 0.000s +Ran 5 tests in 0.001s OK diff --git a/result/v1_strict_hardmask/rollout_contract.log b/result/v1_strict_hardmask/rollout_contract.log index 75063c1..9be4110 100644 --- a/result/v1_strict_hardmask/rollout_contract.log +++ b/result/v1_strict_hardmask/rollout_contract.log @@ -1,5 +1,5 @@ .. ---------------------------------------------------------------------- -Ran 2 tests in 0.009s +Ran 2 tests in 0.006s 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..790f4d6 --- /dev/null +++ b/result/v1_strict_hardmask/training_contract.log @@ -0,0 +1,5 @@ +..... +---------------------------------------------------------------------- +Ran 5 tests in 0.002s + +OK 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..7d86e93 --- /dev/null +++ b/test/v1_strict_hardmask/test_training_contract.py @@ -0,0 +1,71 @@ +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 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_enables_strict_by_default(self): + prepared = train._prepare_training_constraint(self.flights, rule()) + self.assertTrue(prepared["require_base_return"]) + self.assertTrue(prepared["strict_base_start"]) + self.assertIn("_base_reach", prepared) + + def test_explicit_legacy_mode_is_preserved(self): + prepared = train._prepare_training_constraint( + self.flights, rule(require_base_return=False) + ) + self.assertFalse(prepared["require_base_return"]) + self.assertNotIn("_base_reach", prepared) + + 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, []) + + +if __name__ == "__main__": + unittest.main() From daab307c300fd21519b2264637dd21e0fb912860 Mon Sep 17 00:00:00 2001 From: pkhyrn268 Date: Wed, 19 Aug 2026 03:40:47 +0000 Subject: [PATCH 04/14] =?UTF-8?q?test(v1):=20strict=20hard=20mask=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20runner=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- result/v1_strict_hardmask/all_tests.log | 17 +++++++++++++++++ test/v1_strict_hardmask/run_all.py | 10 ++++++++++ 2 files changed, 27 insertions(+) create mode 100644 result/v1_strict_hardmask/all_tests.log create mode 100644 test/v1_strict_hardmask/run_all.py diff --git a/result/v1_strict_hardmask/all_tests.log b/result/v1_strict_hardmask/all_tests.log new file mode 100644 index 0000000..3e45b42 --- /dev/null +++ b/result/v1_strict_hardmask/all_tests.log @@ -0,0 +1,17 @@ +test_strict_end_pairing_requires_base_return (test_mask_contract.StrictMaskContractTest) ... ok +test_strict_mode_requires_reachability (test_mask_contract.StrictMaskContractTest) ... ok +test_strict_start_never_uses_non_base_origin (test_mask_contract.StrictMaskContractTest) ... ok +test_turkish_strict_start_is_bound_to_episode_base (test_mask_contract.StrictMaskContractTest) ... ok +test_unreachable_flight_is_masked_before_selection (test_mask_contract.StrictMaskContractTest) ... ok +test_batch_rollout_rejects_strict_mode (test_rollout_contract.StrictRolloutTest) ... ok +test_single_rollout_returns_only_base_to_base_pairing (test_rollout_contract.StrictRolloutTest) ... ok +test_dual_episode_uses_same_strict_stop (test_training_contract.StrictTrainingTest) ... ok +test_explicit_legacy_mode_is_preserved (test_training_contract.StrictTrainingTest) ... ok +test_phase2_pool_drops_doomed_partial_pairing (test_training_contract.StrictTrainingTest) ... ok +test_stage_episode_stops_instead_of_arbitrary_restart (test_training_contract.StrictTrainingTest) ... ok +test_training_constraint_enables_strict_by_default (test_training_contract.StrictTrainingTest) ... ok + +---------------------------------------------------------------------- +Ran 12 tests in 0.012s + +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) From 7f8adc022169ae6e355ff801a4bb7298908b3fb2 Mon Sep 17 00:00:00 2001 From: pkhyrn268 Date: Wed, 19 Aug 2026 03:55:44 +0000 Subject: [PATCH 05/14] =?UTF-8?q?perf(train):=20strict=20reachability=20ep?= =?UTF-8?q?isode=20=EB=8B=A8=EC=9C=84=20=EC=9E=AC=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- experiments/train.py | 7 +++++++ result/v1_strict_hardmask/all_tests.log | 3 ++- test/v1_strict_hardmask/test_training_contract.py | 7 +++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/experiments/train.py b/experiments/train.py index 88efa4d..4111a38 100644 --- a/experiments/train.py +++ b/experiments/train.py @@ -61,7 +61,11 @@ def _prepare_training_constraint(flights, constraint): c["require_base_return"] = True c["strict_base_start"] = True base = c.get("base_airport", 0) + if c.get("_base_reach") is not None and c.get("_base_reach_base") == base: + return c + # 같은 episode와 base에서 계산한 reachability는 sample/greedy rollout이 공유함. c["_base_reach"] = build_base_reach(flights, base, c) + c["_base_reach_base"] = base return c @@ -369,6 +373,7 @@ def base_start_candidates(candidates): def _collect_pool(flights, constraint, encoder, decoder, encoded, n_rollouts): + constraint = _prepare_training_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. @@ -548,6 +553,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_training_constraint(flights, c) c_tensor = constraint_to_tensor(c, device=DEVICE) with torch.no_grad(): @@ -680,6 +686,7 @@ def run_curriculum_stage( c = constraint_sampler() if constraint_sampler else constraint_override c = {**c, "base_airport": base_airport} # 에피소드별 base 주입 + c = _prepare_training_constraint(flights, c) # 선택된 복원/샘플링 제약조건 사전(c)을 기반으로 정확히 텐서를 빌드하여 FiLM 정렬 유지 c_tensor = constraint_to_tensor(c, device=DEVICE) diff --git a/result/v1_strict_hardmask/all_tests.log b/result/v1_strict_hardmask/all_tests.log index 3e45b42..9718b6b 100644 --- a/result/v1_strict_hardmask/all_tests.log +++ b/result/v1_strict_hardmask/all_tests.log @@ -8,10 +8,11 @@ test_single_rollout_returns_only_base_to_base_pairing (test_rollout_contract.Str test_dual_episode_uses_same_strict_stop (test_training_contract.StrictTrainingTest) ... ok test_explicit_legacy_mode_is_preserved (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_enables_strict_by_default (test_training_contract.StrictTrainingTest) ... ok ---------------------------------------------------------------------- -Ran 12 tests in 0.012s +Ran 13 tests in 0.015s OK diff --git a/test/v1_strict_hardmask/test_training_contract.py b/test/v1_strict_hardmask/test_training_contract.py index 7d86e93..c007398 100644 --- a/test/v1_strict_hardmask/test_training_contract.py +++ b/test/v1_strict_hardmask/test_training_contract.py @@ -1,5 +1,6 @@ import sys import unittest +from unittest.mock import patch from pathlib import Path ROOT = Path(__file__).resolve().parents[2] @@ -38,6 +39,12 @@ def test_training_constraint_enables_strict_by_default(self): self.assertTrue(prepared["strict_base_start"]) self.assertIn("_base_reach", prepared) + def test_prepared_constraint_reuses_reachability(self): + prepared = train._prepare_training_constraint(self.flights, rule()) + with patch.object(train, "build_base_reach", side_effect=AssertionError("rebuild")): + reused = train._prepare_training_constraint(self.flights, prepared) + self.assertIs(reused["_base_reach"], prepared["_base_reach"]) + def test_explicit_legacy_mode_is_preserved(self): prepared = train._prepare_training_constraint( self.flights, rule(require_base_return=False) From 49fb2de6dcb35bb74d741962a1b607a00527d6f1 Mon Sep 17 00:00:00 2001 From: pkhyrn268 Date: Wed, 19 Aug 2026 04:25:17 +0000 Subject: [PATCH 06/14] =?UTF-8?q?refactor(mask):=20base=20=EB=B3=B5?= =?UTF-8?q?=EA=B7=80=EB=A5=BC=20CPP=20=EB=B6=88=EB=B3=80=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RL/environment.py | 17 +++++++--------- RL/turkish/environment_turkish.py | 16 +++++++-------- result/v1_strict_hardmask/mask_contract.log | 4 ++-- test/v1_strict_hardmask/test_mask_contract.py | 20 +++++++++++++------ 4 files changed, 30 insertions(+), 27 deletions(-) diff --git a/RL/environment.py b/RL/environment.py index 09d3471..3c89f72 100644 --- a/RL/environment.py +++ b/RL/environment.py @@ -62,11 +62,10 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # 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 - if require_return and base_reach is None: - # strict 모드가 복귀 가능성 검사를 빠뜨린 채 완화되지 않도록 즉시 중단함. - raise ValueError("require_base_return=True이면 _base_reach가 필요합니다.") + 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"]) @@ -84,9 +83,7 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # 1. Airport-continuity check if pairing_start: - if c.get("strict_base_start", False) and f["origin"] != base_ap: - valid = False - elif base_remaining and f["origin"] != base_ap: + if f["origin"] != base_ap: valid = False elif f["origin"] != state["current_airport"]: valid = False @@ -126,7 +123,7 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # 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, @@ -159,7 +156,7 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # 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: + if state["current_airport"] != base_ap: can_end_pairing = False if can_end_pairing: mask[config.END_PAIRING] = 1 diff --git a/RL/turkish/environment_turkish.py b/RL/turkish/environment_turkish.py index 76a69e7..e9951e4 100644 --- a/RL/turkish/environment_turkish.py +++ b/RL/turkish/environment_turkish.py @@ -55,11 +55,10 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # 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 - if require_return and base_reach is None: - # strict 모드에서 Turkish 복귀 검사가 누락되면 즉시 실패시킴. - raise ValueError("require_base_return=True이면 _base_reach가 필요합니다.") + 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"]) if pairing_start: @@ -76,9 +75,8 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # 1. Airport connectivity check if pairing_start: - if c.get("strict_base_start", False) and f["origin"] != base_ap: + if f["origin"] != base_ap: valid = False - elif base_remaining and f["origin"] not in base_id_set: valid = False elif f["origin"] != state["current_airport"]: valid = False @@ -114,7 +112,7 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): valid = False # 5. Base-return feasibility (only when require_base_return is set) - 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, @@ -147,7 +145,7 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # 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: + if state["current_airport"] != base_ap: can_end_pairing = False if can_end_pairing: mask[config.END_PAIRING] = 1 diff --git a/result/v1_strict_hardmask/mask_contract.log b/result/v1_strict_hardmask/mask_contract.log index 949db44..c27459a 100644 --- a/result/v1_strict_hardmask/mask_contract.log +++ b/result/v1_strict_hardmask/mask_contract.log @@ -1,5 +1,5 @@ -..... +...... ---------------------------------------------------------------------- -Ran 5 tests in 0.001s +Ran 6 tests in 0.002s OK diff --git a/test/v1_strict_hardmask/test_mask_contract.py b/test/v1_strict_hardmask/test_mask_contract.py index 717cfde..5490884 100644 --- a/test/v1_strict_hardmask/test_mask_contract.py +++ b/test/v1_strict_hardmask/test_mask_contract.py @@ -26,20 +26,19 @@ def make_constraint(**updates): "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, "require_base_return": True, - "strict_base_start": True, + "min_pairing_legs": 2, } value.update(updates) return value class StrictMaskContractTest(unittest.TestCase): - def test_strict_mode_requires_reachability(self): + 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_strict_start_never_uses_non_base_origin(self): + 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}, @@ -49,7 +48,7 @@ def test_strict_start_never_uses_non_base_origin(self): mask = environment.get_mask(make_state(), flights, {0: False, 1: True}, rule) self.assertEqual(mask[0], 0) - def test_strict_end_pairing_requires_base_return(self): + 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) @@ -72,7 +71,16 @@ def test_unreachable_flight_is_masked_before_selection(self): self.assertEqual(mask[0], 1) self.assertEqual(mask[1], 0) - def test_turkish_strict_start_is_bound_to_episode_base(self): + 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}, From b0d92f5595436d2e3ac44b442bc830a1364f11f4 Mon Sep 17 00:00:00 2001 From: pkhyrn268 Date: Wed, 19 Aug 2026 04:26:46 +0000 Subject: [PATCH 07/14] =?UTF-8?q?refactor(rollout):=20=EB=AA=A8=EB=93=A0?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EA=B2=BD=EB=A1=9C=EC=97=90=20CPP=20leg?= =?UTF-8?q?ality=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RL/rollout.py | 183 +++--------------- .../v1_strict_hardmask/rollout_contract.log | 2 +- .../test_rollout_contract.py | 21 +- 3 files changed, 39 insertions(+), 167 deletions(-) diff --git a/RL/rollout.py b/RL/rollout.py index 234df18..97a22eb 100644 --- a/RL/rollout.py +++ b/RL/rollout.py @@ -13,9 +13,9 @@ import config import environment as _env_default -from base_reach import build_base_reach +from base_reach import build_base_reach, can_reach_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 @@ -45,30 +45,21 @@ 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). + # 모든 pairing은 허용 base에서 시작하고 해당 pairing의 base로 복귀해야 함. 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)) min_rest = constraint.get("min_rest", 10.0) min_pairing_legs = constraint.get("min_pairing_legs", 2) _reach_cache = {} - if require_return and constraint.get("_base_reach") is not None: + if constraint.get("_base_reach") is not None: # 호출부가 계산한 현재 base의 reachability를 재사용해 rollout별 중복 계산을 막음. _reach_cache[constraint.get("base_airport", 0)] = 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] + if base not in _reach_cache: + _reach_cache[base] = build_base_reach(flights, base, c) + c["_base_reach"] = _reach_cache[base] return c bad_starters = set() @@ -206,7 +197,11 @@ def pick_start(): 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_base( + c_b["_base_reach"], 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"]) @@ -216,13 +211,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 - f = min(startable, key=lambda f: f["dep_time"]) - # legacy 재시작에서도 constraint 기준 base와 실제 출발 공항을 일치시킴. - return f["origin"], f + return None, None def begin_pairing(): nonlocal state, episode_base, cur_c @@ -265,7 +254,7 @@ 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: + if state["current_airport"] != episode_base: salvage_doomed() else: flush_pairing(is_forced=any(not v for v in assigned.values())) @@ -321,143 +310,15 @@ def begin_pairing(): def rollout_batch(flights, constraint, encoder, decoder, encoded, B=50, greedy=False, device=None): - """Run B rollouts concurrently, using one batched decoder call per step.""" - if constraint.get("require_base_return"): - # 배치 경로는 base 회전과 salvage를 지원하지 않아 strict 요청을 명시적으로 차단함. - raise NotImplementedError( - "rollout_batch는 strict hard mask를 지원하지 않습니다. " - "rollout_with_pairings 기반 경로를 사용하세요." + """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/result/v1_strict_hardmask/rollout_contract.log b/result/v1_strict_hardmask/rollout_contract.log index 9be4110..75063c1 100644 --- a/result/v1_strict_hardmask/rollout_contract.log +++ b/result/v1_strict_hardmask/rollout_contract.log @@ -1,5 +1,5 @@ .. ---------------------------------------------------------------------- -Ran 2 tests in 0.006s +Ran 2 tests in 0.009s OK diff --git a/test/v1_strict_hardmask/test_rollout_contract.py b/test/v1_strict_hardmask/test_rollout_contract.py index 244664d..1982e29 100644 --- a/test/v1_strict_hardmask/test_rollout_contract.py +++ b/test/v1_strict_hardmask/test_rollout_contract.py @@ -42,7 +42,6 @@ def strict_fixture(): "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, - "require_base_return": True, "strict_base_start": True, } rule["_base_reach"] = build_base_reach(flights, 0, rule) return flights, rule @@ -69,10 +68,22 @@ def test_single_rollout_returns_only_base_to_base_pairing(self): self.assertTrue(pairings[0]["ends_at_base"]) self.assertEqual(pairings[0]["true_start_airport"], 0) - def test_batch_rollout_rejects_strict_mode(self): - _, rule = strict_fixture() - with self.assertRaisesRegex(NotImplementedError, "strict hard mask"): - rollout.rollout_batch([], rule, None, None, None) + 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)) if __name__ == "__main__": From 591fdf79883c4c2f3300fe75b20d094e94667512 Mon Sep 17 00:00:00 2001 From: pkhyrn268 Date: Wed, 19 Aug 2026 04:29:29 +0000 Subject: [PATCH 08/14] =?UTF-8?q?refactor(train):=20CPP=20base=20=EB=B3=B5?= =?UTF-8?q?=EA=B7=80=20=EC=A1=B0=EA=B1=B4=EC=9D=84=20=ED=95=AD=EC=83=81=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- experiments/train.py | 130 +++--------------- .../v1_strict_hardmask/training_contract.log | 4 +- .../test_training_contract.py | 24 ++-- 3 files changed, 34 insertions(+), 124 deletions(-) diff --git a/experiments/train.py b/experiments/train.py index 4111a38..751afc7 100644 --- a/experiments/train.py +++ b/experiments/train.py @@ -53,13 +53,9 @@ def _set_device(device_str: str): DEVICE = torch.device(device_str) -def _prepare_training_constraint(flights, constraint): - """명시적으로 끄지 않은 학습 episode에 strict base 복귀 조건을 구성함.""" +def _prepare_cpp_constraint(flights, constraint): + """모든 학습 episode에 CPP base 복귀 조건과 reachability를 구성함.""" c = dict(constraint) - if not c.get("require_base_return", True): - return c - c["require_base_return"] = True - c["strict_base_start"] = True base = c.get("base_airport", 0) if c.get("_base_reach") is not None and c.get("_base_reach_base") == base: return c @@ -75,7 +71,7 @@ def run_episode(flights, constraint, encoder, decoder, encoded, greedy=False): total_reward, log_probs, entropies, metrics dict metrics: {n_pairings, n_deadheads, n_uncovered, coverage_pct} """ - constraint = _prepare_training_constraint(flights, constraint) + constraint = _prepare_cpp_constraint(flights, constraint) assigned = {f["id"]: False for f in flights} state = init_state(flights, constraint) @@ -103,43 +99,11 @@ def run_episode(flights, constraint, encoder, decoder, encoded, greedy=False): 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: - break - if constraint.get("require_base_return"): - # strict 모드에서는 불법 pairing을 끊고 임의 공항에서 재시작하지 않음. - n_zero_mask += 1 + 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 -= 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) @@ -205,7 +169,7 @@ def run_episode(flights, constraint, encoder, decoder, encoded, greedy=False): def _rollout_with_pairings(flights, constraint, encoder, decoder, encoded, greedy=False): - constraint = _prepare_training_constraint(flights, constraint) + 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 = [] @@ -247,8 +211,6 @@ def start_new(f): def base_start_candidates(candidates): base_flights = [f for f in candidates if f["origin"] == episode_base] - if not constraint.get("require_base_return"): - return base_flights # 수동 시작 flight도 decoder와 같은 복귀 가능성 검사를 통과해야 함. return [f for f in base_flights if can_reach_base( constraint["_base_reach"], f, f["dep_time"], @@ -259,7 +221,7 @@ def base_start_candidates(candidates): # Manually start the first flight -- prefer a base-departing leg unassigned = [f for f in flights if not assigned[f["id"]]] base_flights = base_start_candidates(unassigned) - if constraint.get("require_base_return") and not base_flights: + if not base_flights: return pairings first = sorted(base_flights or unassigned, key=lambda f: f["dep_time"])[0] assigned[first["id"]] = True @@ -285,43 +247,14 @@ def base_start_candidates(candidates): while True: step_count += 1 if step_count > max_steps: - if not constraint.get("require_base_return"): - 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 constraint.get("require_base_return"): - # strict pool에는 막다른 미복귀 pairing을 후보로 저장하지 않음. - break - if not unassigned: - flush_pairing(is_forced=False) - break - flush_pairing(is_forced=True) - base_flights = base_start_candidates(unassigned) - if constraint.get("require_base_return") and not base_flights: - break - 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) @@ -339,7 +272,7 @@ def base_start_candidates(candidates): if not unassigned: break base_flights = base_start_candidates(unassigned) - if constraint.get("require_base_return") and not base_flights: + if not base_flights: break nxt = sorted(base_flights or unassigned, key=lambda f: f["dep_time"])[0] assigned[nxt["id"]] = True @@ -373,7 +306,7 @@ def base_start_candidates(candidates): def _collect_pool(flights, constraint, encoder, decoder, encoded, n_rollouts): - constraint = _prepare_training_constraint(flights, constraint) + 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. @@ -404,7 +337,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_training_constraint(flights, constraint) + constraint = _prepare_cpp_constraint(flights, constraint) assigned = {f["id"]: False for f in flights} state = init_state(flights, constraint) @@ -436,34 +369,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 - if constraint.get("require_base_return"): - # dual 학습도 동일한 strict 행동 공간을 사용하고 임의 재시작을 금지함. - n_zero_mask += 1 - 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 -= 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) @@ -553,7 +461,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_training_constraint(flights, c) + c = _prepare_cpp_constraint(flights, c) c_tensor = constraint_to_tensor(c, device=DEVICE) with torch.no_grad(): @@ -686,7 +594,7 @@ def run_curriculum_stage( c = constraint_sampler() if constraint_sampler else constraint_override c = {**c, "base_airport": base_airport} # 에피소드별 base 주입 - c = _prepare_training_constraint(flights, c) + c = _prepare_cpp_constraint(flights, c) # 선택된 복원/샘플링 제약조건 사전(c)을 기반으로 정확히 텐서를 빌드하여 FiLM 정렬 유지 c_tensor = constraint_to_tensor(c, device=DEVICE) diff --git a/result/v1_strict_hardmask/training_contract.log b/result/v1_strict_hardmask/training_contract.log index 790f4d6..36e4c23 100644 --- a/result/v1_strict_hardmask/training_contract.log +++ b/result/v1_strict_hardmask/training_contract.log @@ -1,5 +1,5 @@ -..... +...... ---------------------------------------------------------------------- -Ran 5 tests in 0.002s +Ran 6 tests in 0.004s OK diff --git a/test/v1_strict_hardmask/test_training_contract.py b/test/v1_strict_hardmask/test_training_contract.py index c007398..c39ce9e 100644 --- a/test/v1_strict_hardmask/test_training_contract.py +++ b/test/v1_strict_hardmask/test_training_contract.py @@ -33,24 +33,26 @@ def setUp(self): {"id": 0, "origin": 0, "dest": 1, "dep_time": 1.0, "arr_time": 2.0} ] - def test_training_constraint_enables_strict_by_default(self): - prepared = train._prepare_training_constraint(self.flights, rule()) - self.assertTrue(prepared["require_base_return"]) - self.assertTrue(prepared["strict_base_start"]) + 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_training_constraint(self.flights, rule()) + prepared = train._prepare_cpp_constraint(self.flights, rule()) with patch.object(train, "build_base_reach", side_effect=AssertionError("rebuild")): - reused = train._prepare_training_constraint(self.flights, prepared) + reused = train._prepare_cpp_constraint(self.flights, prepared) self.assertIs(reused["_base_reach"], prepared["_base_reach"]) - def test_explicit_legacy_mode_is_preserved(self): - prepared = train._prepare_training_constraint( - self.flights, rule(require_base_return=False) + 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.assertFalse(prepared["require_base_return"]) - self.assertNotIn("_base_reach", prepared) + 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( From 14fb5ec6e15752c202d90078822ec0ff1ee2d3dc Mon Sep 17 00:00:00 2001 From: pkhyrn268 Date: Wed, 19 Aug 2026 04:33:21 +0000 Subject: [PATCH 09/14] =?UTF-8?q?refactor(eval):=20CPP=20base=20=EB=B3=B5?= =?UTF-8?q?=EA=B7=80=20opt-out=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RL/environment.py | 14 ++----- RL/rollout.py | 5 +-- RL/turkish/environment_turkish.py | 12 ++---- evaluate_ip.py | 40 +++++-------------- result/v1_strict_hardmask/all_tests.log | 19 +++++---- .../evaluation_contract.log | 5 +++ .../test_evaluation_contract.py | 24 +++++++++++ 7 files changed, 56 insertions(+), 63 deletions(-) create mode 100644 result/v1_strict_hardmask/evaluation_contract.log create mode 100644 test/v1_strict_hardmask/test_evaluation_contract.py diff --git a/RL/environment.py b/RL/environment.py index 3c89f72..6f801ab 100644 --- a/RL/environment.py +++ b/RL/environment.py @@ -55,13 +55,7 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # 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. + # CPP pairing이 base 복귀 가능성을 잃는 action을 항상 제거함. base_reach = c.get("_base_reach") if base_reach is None: # CPP 실행에는 base 복귀 가능성 자료가 필수이며 누락은 구성 오류로 처리함. @@ -118,7 +112,7 @@ 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 @@ -153,9 +147,7 @@ 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). + # CPP pairing은 base에 도착한 상태에서만 종료 가능함. if state["current_airport"] != base_ap: can_end_pairing = False if can_end_pairing: diff --git a/RL/rollout.py b/RL/rollout.py index 97a22eb..a038b3b 100644 --- a/RL/rollout.py +++ b/RL/rollout.py @@ -187,10 +187,7 @@ 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 diff --git a/RL/turkish/environment_turkish.py b/RL/turkish/environment_turkish.py index e9951e4..7fd2119 100644 --- a/RL/turkish/environment_turkish.py +++ b/RL/turkish/environment_turkish.py @@ -50,11 +50,7 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # log/0704 turkish smoke test). base_ap = c.get("base_airport", config.DEFAULT_CONSTRAINTS["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). + # Turkish CPP도 episode의 출발 base로 복귀 가능한 action만 허용함. base_reach = c.get("_base_reach") if base_reach is None: # CPP 실행에는 base 복귀 가능성 자료가 필수이며 누락은 구성 오류로 처리함. @@ -111,7 +107,7 @@ 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) + # 5. Base 복귀 가능성 if valid: ps_time = f["dep_time"] if pairing_start else pairing_start_time if not can_reach_base( @@ -142,9 +138,7 @@ 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). + # CPP pairing은 episode의 출발 base에서만 종료 가능함. if state["current_airport"] != base_ap: can_end_pairing = False if can_end_pairing: diff --git a/evaluate_ip.py b/evaluate_ip.py index c8e822b..1de7e54 100644 --- a/evaluate_ip.py +++ b/evaluate_ip.py @@ -263,8 +263,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 @@ -337,21 +336,10 @@ def _pairing_valid(p, _chunk_by_gid=chunk_by_gid): 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] + c_b["_base_reach"] = build_base_reach(_local_flights, base_id, c_b) for _ in range(n_rollouts_per_chunk): try: @@ -432,7 +420,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. @@ -534,14 +521,10 @@ 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도 pairing별 출발 base로 복귀하는 same-base 조건을 적용합니다.", + flush=True) print(f"\nCollecting pool (rollouts/chunk={n_rollouts_per_chunk}, subset={subset_size})...", flush=True) with torch.no_grad(): @@ -551,7 +534,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) @@ -670,9 +652,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 @@ -697,5 +676,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/result/v1_strict_hardmask/all_tests.log b/result/v1_strict_hardmask/all_tests.log index 9718b6b..3834d24 100644 --- a/result/v1_strict_hardmask/all_tests.log +++ b/result/v1_strict_hardmask/all_tests.log @@ -1,18 +1,21 @@ -test_strict_end_pairing_requires_base_return (test_mask_contract.StrictMaskContractTest) ... ok -test_strict_mode_requires_reachability (test_mask_contract.StrictMaskContractTest) ... ok -test_strict_start_never_uses_non_base_origin (test_mask_contract.StrictMaskContractTest) ... ok -test_turkish_strict_start_is_bound_to_episode_base (test_mask_contract.StrictMaskContractTest) ... ok +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_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_legacy_flags_cannot_disable_cpp_contract (test_mask_contract.StrictMaskContractTest) ... ok +test_turkish_cpp_start_is_bound_to_episode_base (test_mask_contract.StrictMaskContractTest) ... ok test_unreachable_flight_is_masked_before_selection (test_mask_contract.StrictMaskContractTest) ... ok -test_batch_rollout_rejects_strict_mode (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_dual_episode_uses_same_strict_stop (test_training_contract.StrictTrainingTest) ... ok -test_explicit_legacy_mode_is_preserved (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_enables_strict_by_default (test_training_contract.StrictTrainingTest) ... ok +test_training_constraint_always_builds_cpp_reachability (test_training_contract.StrictTrainingTest) ... ok ---------------------------------------------------------------------- -Ran 13 tests in 0.015s +Ran 16 tests in 0.011s 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..bbf64a9 --- /dev/null +++ b/result/v1_strict_hardmask/evaluation_contract.log @@ -0,0 +1,5 @@ +.. +---------------------------------------------------------------------- +Ran 2 tests in 0.001s + +OK 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..249b6cc --- /dev/null +++ b/test/v1_strict_hardmask/test_evaluation_contract.py @@ -0,0 +1,24 @@ +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")) + +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) + + +if __name__ == "__main__": + unittest.main() From faef9e2f539da53b9551c5f810aaa7f618068621 Mon Sep 17 00:00:00 2001 From: pkhyrn268 Date: Wed, 19 Aug 2026 04:43:15 +0000 Subject: [PATCH 10/14] =?UTF-8?q?fix(cpp):=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EC=9D=98=20pairing=20=EB=B6=88=EB=B3=80?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EA=B0=95=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RL/environment.py | 11 ++++---- RL/rollout.py | 28 ++++++++++++------- RL/turkish/environment_turkish.py | 21 ++++++-------- experiments/train.py | 4 +-- test/v1_strict_hardmask/test_mask_contract.py | 19 +++++++++++++ .../test_rollout_contract.py | 16 +++++++++++ 6 files changed, 70 insertions(+), 29 deletions(-) diff --git a/RL/environment.py b/RL/environment.py index 6f801ab..0c547a8 100644 --- a/RL/environment.py +++ b/RL/environment.py @@ -53,7 +53,7 @@ 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"]) + base_ap = c["base_airport"] # CPP pairing이 base 복귀 가능성을 잃는 action을 항상 제거함. base_reach = c.get("_base_reach") @@ -176,6 +176,8 @@ def step(state, action, flights, assigned, constraint=None): # 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, @@ -199,18 +201,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: diff --git a/RL/rollout.py b/RL/rollout.py index a038b3b..bc29fc0 100644 --- a/RL/rollout.py +++ b/RL/rollout.py @@ -46,14 +46,14 @@ def rollout_with_pairings(flights, constraint, encoder, decoder, encoded, pairings = [] # 모든 pairing은 허용 base에서 시작하고 해당 pairing의 base로 복귀해야 함. - all_bases = list(constraint.get("base_ids") or [constraint.get("base_airport", 0)]) + 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 = {} if constraint.get("_base_reach") is not None: # 호출부가 계산한 현재 base의 reachability를 재사용해 rollout별 중복 계산을 막음. - _reach_cache[constraint.get("base_airport", 0)] = constraint["_base_reach"] + _reach_cache[constraint["base_airport"]] = constraint["_base_reach"] def constraint_for(base): c = {**constraint, "base_airport": base} @@ -84,6 +84,13 @@ def flush_pairing(is_forced=False): elapsed = pairing_last_arr - pairing_dep fly = pairing_fly n_legs = len(current_legs) + # CPP column은 동일 base 복귀·최소 leg·최대 기간을 모두 만족할 때만 저장함. + if pairing_start_ap != flight_by_id[current_legs[-1]]["dest"]: + raise ValueError("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) @@ -94,7 +101,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, @@ -157,9 +164,12 @@ def salvage_doomed(): return the remaining tail legs to unassigned so other pairings can reuse them.""" k = 0 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"] == episode_base + and i + 1 >= min_pairing_legs + and elapsed_days <= cur_c["max_pairing_days"]): k = i + 1 - if k >= min_pairing_legs: + if k > 0: emit_prefix(leg_recs[:k], episode_base, pairing_start_ap) tail = leg_recs[k:] else: @@ -240,7 +250,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(): @@ -251,10 +261,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 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 diff --git a/RL/turkish/environment_turkish.py b/RL/turkish/environment_turkish.py index 7fd2119..e4c9061 100644 --- a/RL/turkish/environment_turkish.py +++ b/RL/turkish/environment_turkish.py @@ -48,7 +48,7 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # 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"]) + base_ap = c["base_airport"] base_id_set = set(c.get("base_ids") or [base_ap]) # Turkish CPP도 episode의 출발 base로 복귀 가능한 action만 허용함. base_reach = c.get("_base_reach") @@ -161,6 +161,8 @@ def step(state, action, flights, assigned, constraint=None): # 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, @@ -185,31 +187,26 @@ 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_id_set = set(c.get("base_ids") or [base]) + 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"] 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 - 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] + # 다음 pairing도 현재 episode에 지정된 동일 base에서 시작함. + restart_base = base + base_unassigned = [f for f in unassigned if f["origin"] == base] next_time = min(f["dep_time"] for f in base_unassigned) if base_unassigned else min(f["dep_time"] for f in unassigned) next_state = { **state, diff --git a/experiments/train.py b/experiments/train.py index 751afc7..83ca1d4 100644 --- a/experiments/train.py +++ b/experiments/train.py @@ -93,7 +93,7 @@ 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 @@ -207,7 +207,7 @@ 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] diff --git a/test/v1_strict_hardmask/test_mask_contract.py b/test/v1_strict_hardmask/test_mask_contract.py index 5490884..326fd1d 100644 --- a/test/v1_strict_hardmask/test_mask_contract.py +++ b/test/v1_strict_hardmask/test_mask_contract.py @@ -91,5 +91,24 @@ def test_turkish_cpp_start_is_bound_to_episode_base(self): 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) + 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 index 1982e29..42e166f 100644 --- a/test/v1_strict_hardmask/test_rollout_contract.py +++ b/test/v1_strict_hardmask/test_rollout_contract.py @@ -86,5 +86,21 @@ def test_batch_rollout_preserves_cpp_contract(self): 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, []) + if __name__ == "__main__": unittest.main() From 4c89037f6a44ad043aecbb78a983534ce04e0442 Mon Sep 17 00:00:00 2001 From: pkhyrn268 Date: Wed, 19 Aug 2026 04:46:26 +0000 Subject: [PATCH 11/14] =?UTF-8?q?fix(eval):=20CPP=20column=EA=B3=BC=20?= =?UTF-8?q?=EC=99=84=EC=A0=84=20coverage=20=EA=B3=84=EC=95=BD=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RL/rollout.py | 2 +- RL/turkish/constraints_turkish.py | 15 +++----- RL/turkish/environment_turkish.py | 17 +--------- evaluate_ip.py | 34 +++++++------------ experiments/train.py | 21 ++++++++---- .../test_evaluation_contract.py | 10 ++++++ 6 files changed, 43 insertions(+), 56 deletions(-) diff --git a/RL/rollout.py b/RL/rollout.py index bc29fc0..984927d 100644 --- a/RL/rollout.py +++ b/RL/rollout.py @@ -22,7 +22,7 @@ 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에도 동일 base 복귀 계약 적용). 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 diff --git a/RL/turkish/constraints_turkish.py b/RL/turkish/constraints_turkish.py index 4c5eae8..7910c44 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 +# base_ids는 episode base 후보 집합이며 각 pairing은 선택된 동일 base로 복귀함. from airline_constraints.turkish import TURKISH_CONSTRAINTS @@ -14,9 +8,8 @@ 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: episode별 base 선택에 사용하는 HB1/HB2 후보 ID 목록. + pairing 시작과 종료는 선택된 base_airport로 고정됨. base_airport/base_ids are excluded from the FiLM input (categorical -- not in FILM_CONSTRAINT_KEYS) """ c = {**TURKISH_CONSTRAINTS, "base_airport": base_airport} diff --git a/RL/turkish/environment_turkish.py b/RL/turkish/environment_turkish.py index e4c9061..1bf603a 100644 --- a/RL/turkish/environment_turkish.py +++ b/RL/turkish/environment_turkish.py @@ -40,16 +40,8 @@ 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). + # Turkish pairing도 첫 flight를 episode에 지정된 base에서 시작함. base_ap = c["base_airport"] - base_id_set = set(c.get("base_ids") or [base_ap]) # Turkish CPP도 episode의 출발 base로 복귀 가능한 action만 허용함. base_reach = c.get("_base_reach") if base_reach is None: @@ -57,12 +49,6 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): 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"]) - 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 +59,6 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): if pairing_start: if f["origin"] != base_ap: valid = False - valid = False elif f["origin"] != state["current_airport"]: valid = False diff --git a/evaluate_ip.py b/evaluate_ip.py index 1de7e54..41c0145 100644 --- a/evaluate_ip.py +++ b/evaluate_ip.py @@ -49,7 +49,7 @@ "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, # same-base CPP contract } from model import FlightEncoder, PointerDecoder from set_partition import solve_set_covering, solve_lp_relaxation @@ -276,12 +276,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도 rollout마다 선택된 episode base에서 시작해 동일 base로 복귀함. + base_ids는 chunk 구성과 episode base 선택 후보로만 사용함. """ pool = {} covered_global = set() @@ -319,20 +315,9 @@ def collect_pool_full(windows, base_ids, constraint, encoder, decoder, for c_idx, chunk in enumerate(chunks): for local_id, f in enumerate(chunk): f["local_id"] = local_id - chunk_by_gid = {f["global_id"]: f for f in chunk} - - 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. - 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) + def _pairing_valid(p): + # 항공사와 무관하게 출발한 동일 base로 복귀한 pairing만 사용함. + return p["ends_at_base"] base_id = random.choice(base_ids) c_b = {**constraint, "base_airport": base_id} @@ -549,6 +534,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 diff --git a/experiments/train.py b/experiments/train.py index 83ca1d4..63fa56d 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,7 +36,7 @@ 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 규정값 사용, CPP 동일 base 복귀 계약 유지 } from state import init_state from base_reach import build_base_reach, can_reach_base @@ -56,7 +55,7 @@ def _set_device(device_str: str): def _prepare_cpp_constraint(flights, constraint): """모든 학습 episode에 CPP base 복귀 조건과 reachability를 구성함.""" c = dict(constraint) - base = c.get("base_airport", 0) + base = c["base_airport"] if c.get("_base_reach") is not None and c.get("_base_reach_base") == base: return c # 같은 episode와 base에서 계산한 reachability는 sample/greedy rollout이 공유함. @@ -184,6 +183,15 @@ 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) + # dual pool에도 완결된 CPP pairing만 column으로 저장함. + if flight_by_id[current_legs[0]]["origin"] != episode_base \ + or flight_by_id[current_legs[-1]]["dest"] != episode_base: + raise ValueError("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) @@ -192,8 +200,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}) @@ -704,7 +711,7 @@ def train(phase2_only=False, multi_airline=False, skip_film=False, ckpt_dir=None 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 중 episode base를 선택하되 pairing은 동일 base로 복귀함 _CONSTRAINT_FN["turkish"] = lambda b, _hb=base_ids: get_turkish_constraints_hb(b, base_ids=_hb) encoder = FlightEncoder( diff --git a/test/v1_strict_hardmask/test_evaluation_contract.py b/test/v1_strict_hardmask/test_evaluation_contract.py index 249b6cc..e536e9d 100644 --- a/test/v1_strict_hardmask/test_evaluation_contract.py +++ b/test/v1_strict_hardmask/test_evaluation_contract.py @@ -20,5 +20,15 @@ def test_collect_pool_has_no_base_return_opt_out(self): self.assertNotIn("require_base_return", params) + def test_turkish_has_no_cross_base_pairing_exception(self): + source = inspect.getsource(evaluate_ip.collect_pool_full) + self.assertNotIn("HB1->HB2", source) + self.assertIn("return p[\"ends_at_base\"]", 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() From f6b58f5d929a4946f417b90a5abb2acb7f96fe1f Mon Sep 17 00:00:00 2001 From: pkhyrn268 Date: Wed, 19 Aug 2026 04:49:03 +0000 Subject: [PATCH 12/14] =?UTF-8?q?fix(env):=20=EC=A7=81=EC=A0=91=20flight?= =?UTF-8?q?=20action=EC=9D=98=20mask=20=EC=9A=B0=ED=9A=8C=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RL/environment.py | 6 +++++- RL/turkish/environment_turkish.py | 6 +++++- test/v1_strict_hardmask/test_mask_contract.py | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/RL/environment.py b/RL/environment.py index 0c547a8..d8a3b7e 100644 --- a/RL/environment.py +++ b/RL/environment.py @@ -173,6 +173,8 @@ 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: @@ -237,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/turkish/environment_turkish.py b/RL/turkish/environment_turkish.py index 1bf603a..372dacb 100644 --- a/RL/turkish/environment_turkish.py +++ b/RL/turkish/environment_turkish.py @@ -143,6 +143,8 @@ 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: @@ -209,7 +211,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/test/v1_strict_hardmask/test_mask_contract.py b/test/v1_strict_hardmask/test_mask_contract.py index 326fd1d..ff84d0d 100644 --- a/test/v1_strict_hardmask/test_mask_contract.py +++ b/test/v1_strict_hardmask/test_mask_contract.py @@ -110,5 +110,21 @@ def test_missing_base_airport_is_configuration_error(self): 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) + if __name__ == "__main__": unittest.main() From 7ef7fe3c04c4df80844365d6439b2f045fe5451c Mon Sep 17 00:00:00 2001 From: pkhyrn268 Date: Wed, 19 Aug 2026 04:51:55 +0000 Subject: [PATCH 13/14] =?UTF-8?q?test(v1):=20CPP=20correctness=2023?= =?UTF-8?q?=EA=B0=9C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EA=B7=B8=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- result/v1_strict_hardmask/all_tests.log | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/result/v1_strict_hardmask/all_tests.log b/result/v1_strict_hardmask/all_tests.log index 3834d24..0134610 100644 --- a/result/v1_strict_hardmask/all_tests.log +++ b/result/v1_strict_hardmask/all_tests.log @@ -1,11 +1,18 @@ 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_has_no_cross_base_pairing_exception (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_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_dual_episode_uses_same_strict_stop (test_training_contract.StrictTrainingTest) ... ok @@ -16,6 +23,6 @@ test_stage_episode_stops_instead_of_arbitrary_restart (test_training_contract.St test_training_constraint_always_builds_cpp_reachability (test_training_contract.StrictTrainingTest) ... ok ---------------------------------------------------------------------- -Ran 16 tests in 0.011s +Ran 23 tests in 0.033s OK From 08052c6388a720dd0747f2329511b6b623010ae0 Mon Sep 17 00:00:00 2001 From: pkhyrn268 Date: Thu, 20 Aug 2026 23:26:44 +0000 Subject: [PATCH 14/14] =?UTF-8?q?fix(turkish):=20HB1=20HB2=20=EA=B5=90?= =?UTF-8?q?=EC=B0=A8=20home-base=20=EB=B3=B5=EA=B7=80=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RL/base_reach.py | 22 +++++++++++ RL/rollout.py | 32 ++++++++++------ RL/turkish/constraints_turkish.py | 7 ++-- RL/turkish/environment_turkish.py | 30 ++++++++------- evaluation/evaluate_ip.py | 31 +++++++++++----- experiments/train.py | 30 +++++++++------ result/v1_strict_hardmask/all_tests.log | 7 +++- .../evaluation_contract.log | 4 +- result/v1_strict_hardmask/mask_contract.log | 4 +- .../v1_strict_hardmask/rollout_contract.log | 4 +- .../v1_strict_hardmask/training_contract.log | 4 +- .../test_evaluation_contract.py | 6 +-- test/v1_strict_hardmask/test_mask_contract.py | 27 ++++++++++++++ .../test_rollout_contract.py | 37 +++++++++++++++++++ .../test_training_contract.py | 17 ++++++++- 15 files changed, 198 insertions(+), 64 deletions(-) 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/rollout.py b/RL/rollout.py index 0689d68..d729fc6 100644 --- a/RL/rollout.py +++ b/RL/rollout.py @@ -13,7 +13,7 @@ import config import environment as _env_default -from base_reach import build_base_reach, can_reach_base +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 @@ -22,7 +22,7 @@ def set_environment(airline): """Switch to the get_mask/step implementation for the given airline - (Turkish에도 동일 base 복귀 계약 적용). 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 @@ -57,9 +57,12 @@ def rollout_with_pairings(flights, constraint, encoder, decoder, encoded, def constraint_for(base): c = {**constraint, "base_airport": base} - if base not in _reach_cache: - _reach_cache[base] = build_base_reach(flights, base, c) + 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() @@ -84,9 +87,11 @@ def flush_pairing(is_forced=False): elapsed = pairing_last_arr - pairing_dep fly = pairing_fly n_legs = len(current_legs) - # CPP column은 동일 base 복귀·최소 leg·최대 기간을 모두 만족할 때만 저장함. - if pairing_start_ap != flight_by_id[current_legs[-1]]["dest"]: - raise ValueError("base로 복귀하지 않은 pairing은 저장할 수 없습니다.") + # 일반 항공사는 동일 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"]: @@ -115,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): @@ -163,14 +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): elapsed_days = (r["arr"] - leg_recs[0]["dep"]) / 24.0 - if (r["dest"] == episode_base + if (r["dest"] in allowed_returns and i + 1 >= min_pairing_legs and elapsed_days <= cur_c["max_pairing_days"]): k = i + 1 + prefix_end_ap = r["dest"] if k > 0: - emit_prefix(leg_recs[:k], episode_base, pairing_start_ap) + emit_prefix(leg_recs[:k], prefix_end_ap, pairing_start_ap) tail = leg_recs[k:] else: tail = list(leg_recs) @@ -205,8 +215,8 @@ def pick_start(): best = None for b in [episode_base] + [x for x in all_bases if x != episode_base]: c_b = constraint_for(b) - cands = [f for f in startable if f["origin"] == b and can_reach_base( - c_b["_base_reach"], f, f["dep_time"], c_b["max_pairing_days"], + 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: diff --git a/RL/turkish/constraints_turkish.py b/RL/turkish/constraints_turkish.py index 7910c44..08a9495 100644 --- a/RL/turkish/constraints_turkish.py +++ b/RL/turkish/constraints_turkish.py @@ -1,5 +1,5 @@ # constraints_turkish.py -- Turkish-specific constraint definitions -# base_ids는 episode base 후보 집합이며 각 pairing은 선택된 동일 base로 복귀함. +# HB1/HB2는 상호 대체 가능한 Turkish home-base 집합으로 처리함. from airline_constraints.turkish import TURKISH_CONSTRAINTS @@ -8,13 +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: episode별 base 선택에 사용하는 HB1/HB2 후보 ID 목록. - pairing 시작과 종료는 선택된 base_airport로 고정됨. + 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 372dacb..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,13 +40,14 @@ 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"]) - # Turkish pairing도 첫 flight를 episode에 지정된 base에서 시작함. + # 첫 flight는 episode base에서 시작하되 HB1/HB2 중 어느 home base로든 복귀 가능함. base_ap = c["base_airport"] - # Turkish CPP도 episode의 출발 base로 복귀 가능한 action만 허용함. - base_reach = c.get("_base_reach") - if base_reach is None: - # CPP 실행에는 base 복귀 가능성 자료가 필수이며 누락은 구성 오류로 처리함. - raise ValueError("CPP constraint에는 _base_reach가 필요합니다.") + base_id_set = set(c.get("base_ids") or [base_ap]) + 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"]) for i, f in enumerate(flights): @@ -95,8 +96,8 @@ def get_mask(state, flights, assigned, constraint=None, stage=3): # 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 @@ -123,8 +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"]) ) - # CPP pairing은 episode의 출발 base에서만 종료 가능함. - if 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 @@ -179,6 +180,7 @@ def step(state, action, flights, assigned, constraint=None): p_cost = c.get("pairing_cost", config.DEFAULT_CONSTRAINTS["pairing_cost"]) # constraint["base_airport"] is injected per episode base = c["base_airport"] + base_id_set = set(c.get("base_ids") or [base]) total_legs = state.get("total_legs", 0) reward = -p_cost + total_legs * config.LEG_PER_PAIRING_BONUS @@ -191,9 +193,9 @@ def step(state, action, flights, assigned, constraint=None): # All flights covered -> end the episode return state, reward, True # Unassigned flights remain -> start a new pairing - # 다음 pairing도 현재 episode에 지정된 동일 base에서 시작함. - restart_base = base - base_unassigned = [f for f in unassigned if f["origin"] == base] + # 도착한 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) next_state = { **state, diff --git a/evaluation/evaluate_ip.py b/evaluation/evaluate_ip.py index 518fb1c..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, # same-base CPP contract + "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 @@ -275,8 +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. - Turkish도 rollout마다 선택된 episode base에서 시작해 동일 base로 복귀함. - base_ids는 chunk 구성과 episode base 선택 후보로만 사용함. + Turkish는 선택된 HB1/HB2 중 하나에서 시작하고 두 home base 중 어느 쪽으로든 복귀 가능함. + 일반 항공사는 pairing이 출발한 동일 base로 복귀함. """ pool = {} covered_global = set() @@ -314,16 +314,28 @@ def collect_pool_full(windows, base_ids, constraint, encoder, decoder, for c_idx, chunk in enumerate(chunks): for local_id, f in enumerate(chunk): f["local_id"] = local_id - def _pairing_valid(p): - # 항공사와 무관하게 출발한 동일 base로 복귀한 pairing만 사용함. - return p["ends_at_base"] + chunk_by_gid = {f["global_id"]: f for f in chunk} + + def _pairing_valid(p, _chunk_by_gid=chunk_by_gid): + if airline != "turkish": + return p["ends_at_base"] + # 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 + ) base_id = random.choice(base_ids) c_b = {**constraint, "base_airport": base_id} c_b["base_ids"] = base_ids # local ID 기준 reachability를 모든 CPP 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) + 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: @@ -507,8 +519,7 @@ def evaluate_full( print("\n[base-return] CPP hard constraint ON (includes reachability pruning)", flush=True) if airline == "turkish": - print(" [note] Turkish도 pairing별 출발 base로 복귀하는 same-base 조건을 적용합니다.", - flush=True) + 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(): diff --git a/experiments/train.py b/experiments/train.py index f836a13..98b000a 100644 --- a/experiments/train.py +++ b/experiments/train.py @@ -36,10 +36,10 @@ def _select_environment(airline): "delta": get_delta_constraints, "alaska": get_alaska_constraints, "jetblue": get_jetblue_constraints, - "turkish": get_turkish_constraints_hb, # Turkish 규정값 사용, CPP 동일 base 복귀 계약 유지 + "turkish": get_turkish_constraints_hb, # Turkish 규정값 및 HB1/HB2 교차 복귀 유지 } from state import init_state -from base_reach import build_base_reach, can_reach_base +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 @@ -53,14 +53,18 @@ def _set_device(device_str: str): def _prepare_cpp_constraint(flights, constraint): - """모든 학습 episode에 CPP base 복귀 조건과 reachability를 구성함.""" + """일반 base 또는 Turkish HB1/HB2 집합에 대한 reachability를 구성함.""" c = dict(constraint) base = c["base_airport"] - if c.get("_base_reach") is not None and c.get("_base_reach_base") == base: + 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 - # 같은 episode와 base에서 계산한 reachability는 sample/greedy rollout이 공유함. - c["_base_reach"] = build_base_reach(flights, base, 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 @@ -184,10 +188,12 @@ def flush_pairing(is_forced=False): return elapsed = pairing_last_arr - pairing_dep n_legs = len(current_legs) - # dual pool에도 완결된 CPP pairing만 column으로 저장함. + # 일반 항공사는 동일 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"] != episode_base: - raise ValueError("base로 복귀하지 않은 pairing은 dual pool에 저장할 수 없습니다.") + 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"]: @@ -219,8 +225,8 @@ def start_new(f): 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_base( - constraint["_base_reach"], f, f["dep_time"], + 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"], )] @@ -715,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": - # 두 Istanbul base 중 episode base를 선택하되 pairing은 동일 base로 복귀함 + # 두 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/all_tests.log b/result/v1_strict_hardmask/all_tests.log index ce5d8b5..10811a4 100644 --- a/result/v1_strict_hardmask/all_tests.log +++ b/result/v1_strict_hardmask/all_tests.log @@ -1,7 +1,7 @@ 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_has_no_cross_base_pairing_exception (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 @@ -11,18 +11,21 @@ test_legacy_flags_cannot_disable_cpp_contract (test_mask_contract.StrictMaskCont 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 23 tests in 0.011s +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 index bbf64a9..31c86ef 100644 --- a/result/v1_strict_hardmask/evaluation_contract.log +++ b/result/v1_strict_hardmask/evaluation_contract.log @@ -1,5 +1,5 @@ -.. +.... ---------------------------------------------------------------------- -Ran 2 tests in 0.001s +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 index c27459a..8e864c6 100644 --- a/result/v1_strict_hardmask/mask_contract.log +++ b/result/v1_strict_hardmask/mask_contract.log @@ -1,5 +1,5 @@ -...... +........... ---------------------------------------------------------------------- -Ran 6 tests in 0.002s +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 index 75063c1..4c87e5a 100644 --- a/result/v1_strict_hardmask/rollout_contract.log +++ b/result/v1_strict_hardmask/rollout_contract.log @@ -1,5 +1,5 @@ -.. +.... ---------------------------------------------------------------------- -Ran 2 tests in 0.009s +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 index 36e4c23..de2dca9 100644 --- a/result/v1_strict_hardmask/training_contract.log +++ b/result/v1_strict_hardmask/training_contract.log @@ -1,5 +1,5 @@ -...... +....... ---------------------------------------------------------------------- -Ran 6 tests in 0.004s +Ran 7 tests in 0.002s OK diff --git a/test/v1_strict_hardmask/test_evaluation_contract.py b/test/v1_strict_hardmask/test_evaluation_contract.py index 7814ca6..dd1c30b 100644 --- a/test/v1_strict_hardmask/test_evaluation_contract.py +++ b/test/v1_strict_hardmask/test_evaluation_contract.py @@ -20,10 +20,10 @@ def test_collect_pool_has_no_base_return_opt_out(self): self.assertNotIn("require_base_return", params) - def test_turkish_has_no_cross_base_pairing_exception(self): + def test_turkish_cross_base_pairing_exception_is_preserved(self): source = inspect.getsource(evaluate_ip.collect_pool_full) - self.assertNotIn("HB1->HB2", source) - self.assertIn("return p[\"ends_at_base\"]", source) + 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) diff --git a/test/v1_strict_hardmask/test_mask_contract.py b/test/v1_strict_hardmask/test_mask_contract.py index ff84d0d..532db98 100644 --- a/test/v1_strict_hardmask/test_mask_contract.py +++ b/test/v1_strict_hardmask/test_mask_contract.py @@ -126,5 +126,32 @@ def test_out_of_range_action_fails_before_state_mutation(self): 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 index 42e166f..b476a68 100644 --- a/test/v1_strict_hardmask/test_rollout_contract.py +++ b/test/v1_strict_hardmask/test_rollout_contract.py @@ -102,5 +102,42 @@ def test_all_zero_at_base_does_not_emit_short_pairing(self): ) 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 index c39ce9e..537c457 100644 --- a/test/v1_strict_hardmask/test_training_contract.py +++ b/test/v1_strict_hardmask/test_training_contract.py @@ -40,7 +40,7 @@ def test_training_constraint_always_builds_cpp_reachability(self): def test_prepared_constraint_reuses_reachability(self): prepared = train._prepare_cpp_constraint(self.flights, rule()) - with patch.object(train, "build_base_reach", side_effect=AssertionError("rebuild")): + 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"]) @@ -76,5 +76,20 @@ def test_phase2_pool_drops_doomed_partial_pairing(self): 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()