diff --git a/.gitignore b/.gitignore index 45a02e5..b46726e 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,8 @@ data/ /paper_experiments/ /paper_runs/ /legacy/ + +# Local-only working directories (not for GitHub) +/baseline/ +/test/ +/analysis/dataset_summary_all.py diff --git a/evaluation/llm_adapter.py b/evaluation/llm_adapter.py new file mode 100644 index 0000000..a6d7990 --- /dev/null +++ b/evaluation/llm_adapter.py @@ -0,0 +1,90 @@ +""" +evaluation/llm_adapter.py -- LLM(GPT/Claude 등) 원시 출력을 validator가 먹을 수 있는 +pairing_record로 변환하는 adapter (F1/V1 C4) + +기존 eval_llm.py의 parser/metric 계산 로직을 그대로 갖다 쓰지 않는다 -- 그쪽의 +"valid only" 판정이 우리 strict validator보다 느슨한 걸 이미 확인했기 때문에(base +복귀·duty 간 연속성을 안 걸러냄, duplicate는 기록만 함). 그래서 여기서는 파싱만 +독립적으로 다시 구현하고, validity 판정은 전부 evaluation/validator.py를 거치게 한다. + +LLM 출력 형식 (기존 eval_llm.py와 동일하게 파싱): + Pairing 1 (base=ATL): [1, 23, 45] + Pairing 2 (base=SLC): [7, 88] + ... + Uncovered: [12, 34, ...] +""" + +import re +from typing import Dict, List, Tuple + +_PAIRING_PATTERN = re.compile( + r"Pairing\s+\d+\s*(?:\(base=\w+\))?\s*:\s*\[([^\]]*)\]", + re.IGNORECASE, +) +_UNCOVERED_PATTERN = re.compile(r"Uncovered\s*:\s*\[([^\]]*)\]", re.IGNORECASE) + + +def parse_llm_output(text: str) -> Tuple[List[List[int]], List[int]]: + """LLM 원시 텍스트에서 pairing별 flight ID 리스트와 uncovered 리스트를 뽑아냄. + + 파싱 형식은 기존 eval_llm.py와 동일 -- 다만 validity 판정은 여기서 전혀 안 함, + 순수 파싱만. 판정은 evaluation/validator.py::validate_pairing()에 맡긴다. + """ + pairings: List[List[int]] = [] + for m in _PAIRING_PATTERN.finditer(text): + ids_str = m.group(1).strip() + if not ids_str: + continue + try: + ids = [int(x.strip()) for x in ids_str.split(",") if x.strip()] + except ValueError: + continue + if ids: + pairings.append(ids) + + uncovered: List[int] = [] + m = _UNCOVERED_PATTERN.search(text) + if m: + ids_str = m.group(1).strip() + if ids_str: + try: + uncovered = [int(x.strip()) for x in ids_str.split(",") if x.strip()] + except ValueError: + pass + + return pairings, uncovered + + +def to_pairing_records(pairings: List[List[int]]) -> List[Dict]: + """LLM이 직접 제시한 pairing들을 policy_direct와 동등하게 취급 -- LLM이 "자기가 + 직접 고른 것"이라는 점에서 우리 모델의 policy 출력과 같은 역할이므로 + source_type="policy"로 태깅해서 aggregate_by_source()의 policy_direct + bucket에 들어가게 한다. + """ + return [{"legs": legs, "source_type": "policy"} for legs in pairings] + + +def forced_singleton_records(uncovered_flight_ids: List[int]) -> List[Dict]: + """LLM이 못 커버한 flight마다 1-leg "forced" pairing을 만듦(기존 baseline의 + "forced 100" 완성 방식과 동일 구성). source_type="forced"로 명시 태깅해서 + legal direct coverage에 섞이지 않게 한다(v1.md C4 "forced 100은 source_type= + forced로만 기록하고 legal direct coverage에 포함 금지"). 이 pairing들은 대부분 + base 미복귀라 validate_pairing()으로 검증하면 invalid로 나올 텐데, 그건 의도된 + 결과다 -- 실제 legal한 pairing이 아니라 "억지로 채운 것"이므로. + """ + return [{"legs": [fid], "source_type": "forced"} for fid in uncovered_flight_ids] + + +def llm_output_to_pairing_records(text: str, include_forced_completion: bool = False) -> List[Dict]: + """LLM 원시 텍스트 -> pairing_record 리스트 (validator/validation_report에 바로 + 넣을 수 있는 형태). + + include_forced_completion=True면 uncovered flight의 forced singleton도 같이 + 포함(source_type="forced"로 구분되니 aggregate_by_source()에서 자동으로 policy_direct + 와 분리됨). + """ + pairings, uncovered = parse_llm_output(text) + records = to_pairing_records(pairings) + if include_forced_completion: + records += forced_singleton_records(uncovered) + return records diff --git a/evaluation/validation_report.py b/evaluation/validation_report.py new file mode 100644 index 0000000..1fe015d --- /dev/null +++ b/evaluation/validation_report.py @@ -0,0 +1,185 @@ +""" +evaluation/validation_report.py -- source_type별 결과 분리 집계 (F1/V1 C2) + +policy가 정상 생성한 pairing과 salvage/repair/forced로 보완된 pairing을 섞어서 보고하면 +"generator가 진짜로 얼마나 잘 만들었는지"가 왜곡된다 -- 이 모듈은 pairing_record의 +source_type(policy|salvage|repair|forced, v1.md §2 스키마)별로 나눠서 각각 따로 집계한다. +`policy_direct`만 실제 generator direct coverage로 쓰고, 나머지는 별도 completion +결과로만 쓴다. + +Deadhead/ManDays/FTC는 pairing_record가 생성 쪽(RL/rollout.py)에서 채워주는 +cost/dead_time 값을 그대로 믿지 않고, flights 데이터로부터 독립적으로 다시 계산한다 +-- C1의 "생성 코드와 독립적으로 검증한다" 원칙을 집계 지표에도 동일하게 적용함. +""" + +from typing import Dict, List, Optional, Tuple + +from validator import ( + validate_pairing, + find_cross_pairing_duplicates, + _split_into_duties, + VALIDATOR_VERSION, +) + + +# pairing_record.source_type 값 -> 이 report의 집계 bucket 이름 +_SOURCE_TO_BUCKET = { + "policy": "policy_direct", + "salvage": "salvage", + "repair": "repair", + "forced": "forced", +} + + +def _pairing_time_metrics(legs: List[int], flights: Dict[int, Dict], min_rest: float): + """flying_time, dead_time(휴식 제외, duty별 elapsed-flying 합), pairing_days를 + flights 데이터로부터 독립 계산 (pairing_record의 cost/dead_time 필드는 안 씀). + """ + duties = _split_into_duties(legs, flights, min_rest) + total_fly = 0.0 + total_dead = 0.0 + for duty in duties: + fly = sum(flights[fid]["arr_time"] - flights[fid]["dep_time"] for fid in duty) + elapsed = flights[duty[-1]]["arr_time"] - flights[duty[0]]["dep_time"] + total_fly += fly + total_dead += max(elapsed - fly, 0.0) + pairing_days = (flights[legs[-1]]["arr_time"] - flights[legs[0]]["dep_time"]) / 24.0 + return total_fly, total_dead, pairing_days + + +def _aggregate( + pairing_constraint_pairs: List[Tuple[Dict, Optional[Dict]]], + flights: Dict[int, Dict], + n_total_flights: Optional[int], + min_rest: float, +) -> Dict[str, Dict]: + """실제 집계 로직 -- (pairing_record, 그 pairing을 검증할 constraint) 쌍의 리스트를 + 받는다. constraint가 pairing마다 달라도(chunk별 base_airport 등) 각자 자기 + constraint로 검증되므로 정확함. aggregate_by_source()/aggregate_by_source_per_chunk() + 둘 다 이 함수를 감싼 얇은 wrapper임. + """ + buckets: Dict[str, List[Tuple[Dict, Optional[Dict]]]] = {name: [] for name in _SOURCE_TO_BUCKET.values()} + for p, c in pairing_constraint_pairs: + source = p.get("source_type", "policy") + bucket_name = _SOURCE_TO_BUCKET.get(source, source) + buckets.setdefault(bucket_name, []).append((p, c)) + + denom = n_total_flights if n_total_flights is not None else len(flights) + + report = {} + for bucket_name, bucket_pairs in buckets.items(): + covered = set() + invalid_count = 0 + any_constraint_given = False + deadhead_count = 0 + total_fly = 0.0 + total_dead = 0.0 + total_man_days = 0.0 + bucket_pairings_only = [p for p, _ in bucket_pairs] + + for p, c in bucket_pairs: + legs = p.get("legs", []) + covered.update(legs) + + if c is not None: + any_constraint_given = True + result = validate_pairing(p, flights, c) + if not result["is_valid"]: + invalid_count += 1 + + if p.get("is_deadhead"): + deadhead_count += 1 + + if legs and all(fid in flights for fid in legs): + fly, dead, days = _pairing_time_metrics(legs, flights, min_rest) + total_fly += fly + total_dead += dead + total_man_days += days + # legs가 비어있거나 unknown flight를 포함하면(이미 invalid로 잡힘) + # 시간 지표 계산은 건너뜀 -- flights[fid] 접근이 안전하지 않으므로. + + ftc_pct = (total_dead / total_fly * 100) if total_fly > 0 else None + + report[bucket_name] = { + "pairing_count": len(bucket_pairs), + "covered_flights": len(covered), + "invalid_count": invalid_count if any_constraint_given else None, + "internal_duplicate_flight_ids": find_cross_pairing_duplicates(bucket_pairings_only), + "direct_coverage_ratio": (len(covered) / denom) if denom else 0.0, + "deadhead_count": deadhead_count, + "total_flying_time": total_fly, + "total_dead_time": total_dead, + "man_days": total_man_days, + "ftc_pct": ftc_pct, + } + + all_pairings = [p for p, _ in pairing_constraint_pairs] + # 최종 selection 기준(모든 bucket 합산) duplicate -- policy가 커버한 flight를 + # salvage/repair/forced가 또 커버한 경우도 여기서 잡힘. + report["cross_bucket_duplicate_flight_ids"] = find_cross_pairing_duplicates(all_pairings) + # policy_direct만 진짜 generator coverage로 쓴다는 원칙을 결과에도 명시. + report["_direct_coverage_source"] = "policy_direct" + # C3 provenance 요구사항 -- 이 report가 어느 validator 버전으로 만들어졌는지. + # (constraint_hash는 pairing마다 다를 수 있어 여기(전체 report)엔 안 두고, + # 필요하면 validate_pairing() 개별 호출 결과의 constraint_hash를 참고.) + report["_validator_version"] = VALIDATOR_VERSION + return report + + +def aggregate_by_source( + pairings: List[Dict], + flights: Dict[int, Dict], + constraint: Optional[Dict] = None, + n_total_flights: Optional[int] = None, + min_rest: float = 10.0, +) -> Dict[str, Dict]: + """pairing들을 source_type별로 나눠서 각각 집계 -- 배치 전체가 같은 constraint + 하나를 쓸 때 사용(예: 단일 chunk, 또는 constraint가 정말 동일한 경우). + + 여러 chunk(서로 다른 base_airport 등)를 합쳐서 봐야 하면 + aggregate_by_source_per_chunk()를 쓸 것 -- evaluation/evaluate_ip.py가 chunk마다 + base_id = random.choice(base_ids)로 constraint를 다시 뽑는 걸 확인했으므로, 여러 + chunk의 pairing을 이 함수 하나에 몰아넣으면 일부 pairing이 자기 생성 시점과 다른 + constraint로 검증될 수 있음. + + duplicate flights는 두 층위로 나눠서 본다 (스펙에 bucket별인지 전체인지 명시가 + 없어서, 둘 다 보여주고 어느 걸 "duplicate flights"로 볼지는 사용하는 쪽에서 고르게 함): + - bucket별 "internal_duplicate_flight_ids": 그 bucket 안에서만 중복 + - 최상위 "cross_bucket_duplicate_flight_ids": 전체 선택(모든 bucket 합산) + 기준 중복 -- 최종 solution의 진짜 duplicate assignment는 이쪽이 맞음. + + 반환: { + "policy_direct": {...}, "salvage": {...}, "repair": {...}, "forced": {...}, + "cross_bucket_duplicate_flight_ids": [...], + "_direct_coverage_source": "policy_direct", + } + 각 bucket: pairing_count, covered_flights, invalid_count, + internal_duplicate_flight_ids, direct_coverage_ratio, + deadhead_count, total_flying_time, total_dead_time, man_days, ftc_pct + """ + pairs = [(p, constraint) for p in pairings] + return _aggregate(pairs, flights, n_total_flights, min_rest) + + +def aggregate_by_source_per_chunk( + chunks: List[Tuple[List[Dict], Optional[Dict]]], + flights: Dict[int, Dict], + n_total_flights: Optional[int] = None, + min_rest: float = 10.0, +) -> Dict[str, Dict]: + """여러 chunk(각자 자기 constraint를 가짐)의 pairing들을 하나의 report로 합쳐서 집계. + + chunks: [(이 chunk의 pairing_record 리스트, 이 chunk에서 쓰인 constraint), ...] + -- evaluate_ip.py가 chunk마다 base_id를 다시 뽑는 것과 맞춰, pairing마다 자기가 + 생성될 때 쓰인 constraint로 검증되도록 함(§3 TODO에서 언급한 옵션 1). + + # TODO(확인 필요, 대안): 지금은 "호출하는 쪽이 chunk별로 pairing과 constraint를 + # 짝지어 넘겨준다"고 가정함 -- 만약 나중에 pairing_record 자체가 자기 constraint(최소 + # base_airport)를 필드로 들고 있는 형태로 바뀐다면, 이 함수 대신 pairing_record에서 + # 직접 constraint를 꺼내 쓰는 방식으로 더 간단해질 수 있음. 실제 evaluate_ip.py 연결 + # (C3)할 때 어느 쪽이 더 자연스러운지 다시 판단. + """ + pairs: List[Tuple[Dict, Optional[Dict]]] = [] + for chunk_pairings, chunk_constraint in chunks: + pairs.extend((p, chunk_constraint) for p in chunk_pairings) + return _aggregate(pairs, flights, n_total_flights, min_rest) diff --git a/evaluation/validator.py b/evaluation/validator.py new file mode 100644 index 0000000..1dc69a0 --- /dev/null +++ b/evaluation/validator.py @@ -0,0 +1,270 @@ +""" +evaluation/validator.py -- independent pairing legality validator + +이 모듈은 RL/environment.py::get_mask()/step()을 재사용하지 않는다 -- +-> 생성 쪽(mask)과 같은 구현을 쓰면 같은 버그를 검출할 수 없기 때문임 +여기서 각 제약을 완전히 새로 계산해서, policy가 만든 pairing이 실제로 +legal한지 독립적으로 재확인한다. + +flight dict 포맷은 RL/loader.py와 동일함: {"id", "origin", "dest", "dep_time", "arr_time"} +(origin/dest는 정수 airport ID, dep_time/arr_time은 시간(hour) 단위 절대값) +constraint dict 포맷은 RL/config.py::DEFAULT_CONSTRAINTS와 동일한 키를 씀 + +# TODO(추후 코드 확인 필요): violation code enum을 evaluation/validator.py 안에 두는 걸로 +# 우선 진행함 -- RL/ 쪽(mask)에서도 이 코드가 필요해지면 공통 모듈로 옮기는 게 나을 수 있음 +""" + +import hashlib +import json +from typing import Dict, List, Optional + +import config as _rl_config # RL/ 이 sys.path에 있다고 가정 (evaluate_ip.py와 동일 관례) + + +# C3 "ASCP 결과 JSON/CSV에 validator version과 constraint hash 기록", +# 공통 column schema의 validator_version/constraint_hash와 이름 맞춤. +# 검증 로직(violation code 종류나 판정 기준)이 바뀌면 이 값을 올려서, 과거에 +# 저장된 결과가 어느 버전 로직으로 검증됐는지 구분할 수 있게 한다. +VALIDATOR_VERSION = "0.1.0" + + +def constraint_hash(constraint: Optional[Dict]) -> Optional[str]: + """constraint dict 내용 기반 짧은 해시 -- "이 결과가 어떤 constraint로 검증됐는지" + provenance를 남기기 위함(v1.md C3, v2.md column schema). set 같은 + JSON-직렬화 안 되는 값(예: allowed_return_bases)은 정렬된 리스트로 바꿔서 + 항상 같은 constraint에 대해 같은 해시가 나오게 한다. + """ + if constraint is None: + return None + + def _normalize(v): + if isinstance(v, (set, frozenset)): + return sorted(v) + if isinstance(v, dict): + return {k: _normalize(vv) for k, vv in sorted(v.items())} + return v + + blob = json.dumps(_normalize(constraint), sort_keys=True, default=str) + return hashlib.sha256(blob.encode()).hexdigest()[:12] + + +# ── Violation codes (공통 violation code 14개 그대로) ────────── +UNKNOWN_FLIGHT = "UNKNOWN_FLIGHT" +DUPLICATE_FLIGHT = "DUPLICATE_FLIGHT" +INVALID_BASE_START = "INVALID_BASE_START" +BASE_RETURN_FAILURE = "BASE_RETURN_FAILURE" +AIRPORT_DISCONTINUITY = "AIRPORT_DISCONTINUITY" +MIN_CONNECTION_FAILURE = "MIN_CONNECTION_FAILURE" +MAX_CONNECTION_FAILURE = "MAX_CONNECTION_FAILURE" +MIN_REST_FAILURE = "MIN_REST_FAILURE" +MAX_DUTY_FAILURE = "MAX_DUTY_FAILURE" +MAX_LEGS_FAILURE = "MAX_LEGS_FAILURE" +MAX_DUTIES_FAILURE = "MAX_DUTIES_FAILURE" +MAX_PAIRING_DAYS_FAILURE = "MAX_PAIRING_DAYS_FAILURE" +MIN_PAIRING_LEGS_FAILURE = "MIN_PAIRING_LEGS_FAILURE" +TIME_ORDER_FAILURE = "TIME_ORDER_FAILURE" + + +def _split_into_duties(legs: List[int], flights: Dict[int, Dict], min_rest: float): + """gap >= min_rest인 지점을 duty 경계(overnight rest)로 보고 분리 + + min_conn <= gap <= max_conn 이면 같은 duty 안의 connection, gap >= min_rest면 + 새 duty 시작 -- 그 사이(min_rest 미만이지만 max_conn 초과)는 어느 쪽으로도 + 유효하지 않은 "dead zone"이며, 호출부(_check_connections_and_rest)에서 + 별도로 위반 처리한다. 여기서는 min_rest 기준으로만 1차 분리 + """ + if not legs: + return [] + duties = [[legs[0]]] + for i in range(1, len(legs)): + prev = flights[legs[i - 1]] + curr = flights[legs[i]] + gap = curr["dep_time"] - prev["arr_time"] + if gap >= min_rest: + duties.append([curr["id"]]) + else: + duties[-1].append(curr["id"]) + return duties + + +def _check_time_order_and_unknown(legs, flights, violations): + """UNKNOWN_FLIGHT, TIME_ORDER_FAILURE. 이후 체크가 의존하는 전제 조건이라 가장 먼저 실행.""" + for fid in legs: + if fid not in flights: + violations.append(UNKNOWN_FLIGHT) + if any(v == UNKNOWN_FLIGHT for v in violations): + return False # 이후 체크는 flights[fid] 접근이 안전하지 않으므로 중단 + for i in range(1, len(legs)): + if flights[legs[i]]["dep_time"] < flights[legs[i - 1]]["arr_time"]: + violations.append(TIME_ORDER_FAILURE) + return True + + +def _check_duplicate_within(legs, violations): + if len(set(legs)) != len(legs): + violations.append(DUPLICATE_FLIGHT) + + +def _check_base(legs, flights, constraint, violations): + # 기본 규칙(Delta/Alaska/JetBlue): 출발 base == 도착 base == 배정된 base_airport + # 두 체크를 서로 독립적으로 본다 -- first==last(출발지로 그대로 복귀)만 보면, 애초에 + # 엉뚱한 곳에서 출발한 pairing(INVALID_BASE_START)이 그 엉뚱한 곳으로 되돌아왔을 때 + # BASE_RETURN_FAILURE를 놓친다. + # + # 예외: Turkish는 HB1->HB2, HB2->HB1처럼 서로 다른 base로 돌아와도 유효함 + # (evaluation/evaluate_ip.py의 _pairing_valid()가 airline=="turkish"일 때 별도로 이렇게 + # 처리하는 걸 확인함). 이 검증기에서는 constraint에 명시적으로 + # `allowed_return_bases`(이 base들 중 아무거나로 복귀해도 됨, 서로 같을 필요 없음)가 + # 주어졌을 때만 이 예외를 적용한다 -- base_ids만으로는(Delta도 여러 base를 갖고 + # 있어서) 항공사를 구분할 수 없으므로 반드시 별도 필드로 명시적으로 opt-in해야 함 + # + # "Turkish HB1/HB2를 allowed-return-base 규칙으로 표현"하라고 이미 + # 합의돼 있음 -- 이 접근 자체는 확정. TODO로 남는 건 두 가지: + # (1) 실제 필드명이 `allowed_return_bases`가 맞는지, (2) Turkish constraint + # 모듈(RL/turkish/constraints_turkish.py 등)이 실제로 이 필드를 채워줄지. + base = constraint.get("base_airport") + allowed_return_bases = constraint.get("allowed_return_bases") + first, last = flights[legs[0]], flights[legs[-1]] + + if base is not None and first["origin"] != base: + violations.append(INVALID_BASE_START) + + if allowed_return_bases: + if last["dest"] not in allowed_return_bases: + violations.append(BASE_RETURN_FAILURE) + elif base is not None: + if last["dest"] != base: + violations.append(BASE_RETURN_FAILURE) + elif first["origin"] != last["dest"]: + # base_airport 자체가 안 주어진 경우(예외적) -- 최소한 출발==도착이라도 확인 + violations.append(BASE_RETURN_FAILURE) + + +def _check_connections_and_rest(duties, flights, constraint, violations): + min_conn = constraint.get("min_conn", _rl_config.DEFAULT_CONSTRAINTS["min_conn"]) + max_conn = constraint.get("max_conn", _rl_config.DEFAULT_CONSTRAINTS["max_conn"]) + min_rest = constraint.get("min_rest", _rl_config.DEFAULT_CONSTRAINTS["min_rest"]) + + for duty in duties: + # duty 내부 공항 연속성 + connection 시간 + for i in range(1, len(duty)): + prev, curr = flights[duty[i - 1]], flights[duty[i]] + if prev["dest"] != curr["origin"]: + violations.append(AIRPORT_DISCONTINUITY) + gap = curr["dep_time"] - prev["arr_time"] + if gap < min_conn: + violations.append(MIN_CONNECTION_FAILURE) + if gap > max_conn: + violations.append(MAX_CONNECTION_FAILURE) + + # duty 간 (overnight) 공항 연속성 + rest 시간 + # + # TODO(확인 필요): 지금 구조에서 MIN_REST_FAILURE는 사실상 발생할 수 없는 죽은 + # 코드임 -- _split_into_duties()가 "gap >= min_rest"인 지점에서만 duty를 나누기 + # 때문에 여기서 구한 duty 간 rest는 나누는 조건 자체가 이미 min_rest 이상이라서 + # 항상 min_rest를 만족함 max_conn을 넘었지만 min_rest에는 못 미치는 "dead zone" + # gap은 같은 duty로 묶여서 MAX_CONNECTION_FAILURE로만 잡힌다(의미상 맞을 수도, 아닐 + # 수도 있음). flat leg 목록만으로는 "duty 내 connection이었는지 실패한 rest였는지"를 + # 구조적으로 구분할 방법이 없어서 -- pairing_record가 duty 경계를 명시적으로 주는 + # 형태가 되면 그때 구현 지금은 TODO로 남기고 fixture도 안 만듦. + for i in range(1, len(duties)): + prev_last = flights[duties[i - 1][-1]] + curr_first = flights[duties[i][0]] + if prev_last["dest"] != curr_first["origin"]: + violations.append(AIRPORT_DISCONTINUITY) + rest = curr_first["dep_time"] - prev_last["arr_time"] + if rest < min_rest: + violations.append(MIN_REST_FAILURE) + + +def _check_duty_and_pairing_limits(legs, duties, flights, constraint, violations): + max_duty = constraint.get("max_duty", _rl_config.DEFAULT_CONSTRAINTS["max_duty"]) + max_legs = constraint.get("max_legs", _rl_config.DEFAULT_CONSTRAINTS["max_legs"]) + max_duty_periods = constraint.get("max_duty_periods", _rl_config.DEFAULT_CONSTRAINTS["max_duty_periods"]) + max_pairing_days = constraint.get("max_pairing_days", _rl_config.DEFAULT_CONSTRAINTS["max_pairing_days"]) + min_pairing_legs = constraint.get("min_pairing_legs", _rl_config.DEFAULT_CONSTRAINTS["min_pairing_legs"]) + + for duty in duties: + if len(duty) > max_legs: + violations.append(MAX_LEGS_FAILURE) + elapsed = flights[duty[-1]]["arr_time"] - flights[duty[0]]["dep_time"] + if elapsed > max_duty: + violations.append(MAX_DUTY_FAILURE) + + # max_duty_periods는 "duty 수"가 아니라 "overnight 횟수" 기준 (RL/environment.py의 + # duty_period < max_duty_periods 게이트와 동일 의미) -- duties가 n개면 overnight은 n-1개 + n_overnights = len(duties) - 1 + if n_overnights > max_duty_periods: + violations.append(MAX_DUTIES_FAILURE) + + pairing_days = (flights[legs[-1]]["arr_time"] - flights[legs[0]]["dep_time"]) / 24.0 + if pairing_days > max_pairing_days: + violations.append(MAX_PAIRING_DAYS_FAILURE) + + if len(legs) < min_pairing_legs: + violations.append(MIN_PAIRING_LEGS_FAILURE) + + +def validate_pairing(pairing_record: Dict, flights: Dict[int, Dict], constraint: Dict) -> Dict: + """pairing_record(최소 {"legs": [...]})를 완전히 독립적으로 재검증 + + 반환: {"is_valid", "violation_codes", "invalid_flight_ids", "duplicate_flight_ids", + "start_base", "end_airport", "n_duties"} (v1.md §2 "Validator 결과 최소 필드") + + "validator_version", "constraint_hash" (v1.md C3 provenance 요구사항) + """ + legs = pairing_record.get("legs", []) + violations: List[str] = [] + c_hash = constraint_hash(constraint) + + if not legs: + return { + "is_valid": False, "violation_codes": [UNKNOWN_FLIGHT], + "invalid_flight_ids": [], "duplicate_flight_ids": [], + "start_base": None, "end_airport": None, "n_duties": 0, + "validator_version": VALIDATOR_VERSION, "constraint_hash": c_hash, + } + + ok = _check_time_order_and_unknown(legs, flights, violations) + invalid_flight_ids = [fid for fid in legs if fid not in flights] + duplicate_flight_ids = [fid for fid in set(legs) if legs.count(fid) > 1] + _check_duplicate_within(legs, violations) + + if ok: # flights[fid] 접근이 안전할 때만 나머지 체크 진행 + _check_base(legs, flights, constraint, violations) + min_rest = constraint.get("min_rest", _rl_config.DEFAULT_CONSTRAINTS["min_rest"]) + duties = _split_into_duties(legs, flights, min_rest) + _check_connections_and_rest(duties, flights, constraint, violations) + _check_duty_and_pairing_limits(legs, duties, flights, constraint, violations) + start_base = flights[legs[0]]["origin"] + end_airport = flights[legs[-1]]["dest"] + n_duties = len(duties) + else: + start_base, end_airport, n_duties = None, None, 0 + + return { + "is_valid": len(violations) == 0, + "violation_codes": violations, + "invalid_flight_ids": invalid_flight_ids, + "duplicate_flight_ids": duplicate_flight_ids, + "start_base": start_base, + "end_airport": end_airport, + "n_duties": n_duties, + "validator_version": VALIDATOR_VERSION, + "constraint_hash": c_hash, + } + + +def find_cross_pairing_duplicates(pairings: List[Dict]) -> List[int]: + """Selected solution 전체에서 같은 flight가 2개 이상 pairing에 중복 배정됐는지 확인 + (v1.md C1 "Selected solution 전체 duplicate conflict"). 개별 pairing 내부 중복은 + validate_pairing()의 duplicate_flight_ids가 이미 잡음 -- 이건 pairing 간 중복 전용 + """ + seen: Dict[int, int] = {} + dupes = [] + for p in pairings: + for fid in p.get("legs", []): + seen[fid] = seen.get(fid, 0) + 1 + for fid, count in seen.items(): + if count > 1: + dupes.append(fid) + return dupes diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_llm_adapter.py b/tests/test_llm_adapter.py new file mode 100644 index 0000000..ed66e19 --- /dev/null +++ b/tests/test_llm_adapter.py @@ -0,0 +1,100 @@ +""" +tests/test_llm_adapter.py -- evaluation/llm_adapter.py 테스트 +""" + +import os +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO_ROOT) +sys.path.insert(0, os.path.join(REPO_ROOT, "RL")) +sys.path.insert(0, os.path.join(REPO_ROOT, "evaluation")) + +from llm_adapter import ( # noqa: E402 + parse_llm_output, + to_pairing_records, + forced_singleton_records, + llm_output_to_pairing_records, +) +from validation_report import aggregate_by_source # noqa: E402 + + +SAMPLE_LLM_OUTPUT = """ +Here is my crew pairing solution: + +Pairing 1 (base=ATL): [1, 23, 45] +Pairing 2 (base=SLC): [7, 88] +Pairing 3: [99] + +Uncovered: [12, 34, 56] +""" + + +def test_parse_llm_output_extracts_pairings_and_uncovered(): + pairings, uncovered = parse_llm_output(SAMPLE_LLM_OUTPUT) + assert pairings == [[1, 23, 45], [7, 88], [99]] + assert uncovered == [12, 34, 56] + + +def test_parse_llm_output_handles_empty_text(): + pairings, uncovered = parse_llm_output("no pairings here") + assert pairings == [] + assert uncovered == [] + + +def test_to_pairing_records_tags_as_policy(): + records = to_pairing_records([[1, 23, 45], [7, 88]]) + assert records == [ + {"legs": [1, 23, 45], "source_type": "policy"}, + {"legs": [7, 88], "source_type": "policy"}, + ] + + +def test_forced_singleton_records_tags_as_forced(): + records = forced_singleton_records([12, 34]) + assert records == [ + {"legs": [12], "source_type": "forced"}, + {"legs": [34], "source_type": "forced"}, + ] + + +def test_llm_output_to_pairing_records_without_forced_completion(): + records = llm_output_to_pairing_records(SAMPLE_LLM_OUTPUT) + assert len(records) == 3 + assert all(r["source_type"] == "policy" for r in records) + + +def test_llm_output_to_pairing_records_with_forced_completion(): + records = llm_output_to_pairing_records(SAMPLE_LLM_OUTPUT, include_forced_completion=True) + assert len(records) == 3 + 3 # pairing 3개 + uncovered 3개 + forced = [r for r in records if r["source_type"] == "forced"] + assert len(forced) == 3 + assert {r["legs"][0] for r in forced} == {12, 34, 56} + + +def test_forced_completion_does_not_count_as_policy_direct_coverage(): + # aggregate_by_source()에 넣었을 때 forced가 policy_direct 커버리지에 안 섞이는지 확인. + # constraint를 안 넘겨서 validate_pairing은 안 타지만, 시간 지표 계산은 항상 도니까 + # dep_time/arr_time은 채워줘야 함. + flights = { + fid: {"id": fid, "origin": 0, "dest": 0, "dep_time": float(fid), "arr_time": float(fid) + 1.0} + for fid in range(1, 100) + } + records = llm_output_to_pairing_records(SAMPLE_LLM_OUTPUT, include_forced_completion=True) + report = aggregate_by_source(records, flights, n_total_flights=99) + + assert report["policy_direct"]["pairing_count"] == 3 + assert report["policy_direct"]["covered_flights"] == 6 # 1,23,45,7,88,99 + assert report["forced"]["pairing_count"] == 3 + assert report["forced"]["covered_flights"] == 3 # 12,34,56 + + +if __name__ == "__main__": + test_parse_llm_output_extracts_pairings_and_uncovered() + test_parse_llm_output_handles_empty_text() + test_to_pairing_records_tags_as_policy() + test_forced_singleton_records_tags_as_forced() + test_llm_output_to_pairing_records_without_forced_completion() + test_llm_output_to_pairing_records_with_forced_completion() + test_forced_completion_does_not_count_as_policy_direct_coverage() + print("OK: 7개 테스트 통과") diff --git a/tests/test_validation_report.py b/tests/test_validation_report.py new file mode 100644 index 0000000..ec36c90 --- /dev/null +++ b/tests/test_validation_report.py @@ -0,0 +1,116 @@ +""" +tests/test_validation_report.py -- evaluation/validation_report.py::aggregate_by_source 스모크 테스트 +""" + +import os +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO_ROOT) +sys.path.insert(0, os.path.join(REPO_ROOT, "RL")) +sys.path.insert(0, os.path.join(REPO_ROOT, "evaluation")) + +from validation_report import aggregate_by_source, aggregate_by_source_per_chunk # noqa: E402 + + +BASE, OTHER, THIRD = 0, 1, 2 + +# flight 0->1: 2h 비행. flight 1(다시 base로)은 3h 뒤 출발, 2h 비행 -> 그 사이 1h는 dead time. +FLIGHTS = { + 0: {"id": 0, "origin": BASE, "dest": OTHER, "dep_time": 0.0, "arr_time": 2.0}, + 1: {"id": 1, "origin": OTHER, "dest": BASE, "dep_time": 3.0, "arr_time": 5.0}, + 2: {"id": 2, "origin": BASE, "dest": THIRD, "dep_time": 0.0, "arr_time": 1.0}, +} +CONSTRAINT = { + "base_airport": BASE, + "min_conn": 0.5, "max_conn": 9.0, "min_rest": 10.0, + "max_duty": 13.0, "max_legs": 8, + "max_duty_periods": 2, "max_pairing_days": 5, + "min_pairing_legs": 2, +} + + +def test_buckets_split_by_source_type_and_time_metrics(): + pairings = [ + {"legs": [0, 1], "source_type": "policy"}, + {"legs": [2], "source_type": "salvage", "is_truncated": True}, # base 미복귀 -> invalid + ] + report = aggregate_by_source(pairings, FLIGHTS, constraint=CONSTRAINT, n_total_flights=3) + + pd = report["policy_direct"] + assert pd["pairing_count"] == 1 + assert pd["covered_flights"] == 2 + assert pd["invalid_count"] == 0 + # flying = (2-0) + (5-3) = 4h, elapsed = 5-0 = 5h, dead = 5-4 = 1h + assert pd["total_flying_time"] == 4.0 + assert pd["total_dead_time"] == 1.0 + assert pd["ftc_pct"] == 25.0 # 1/4 * 100 + assert pd["man_days"] == 5.0 / 24.0 + + sv = report["salvage"] + assert sv["pairing_count"] == 1 + assert sv["covered_flights"] == 1 + assert sv["invalid_count"] == 1 # THIRD(2)로 끝나서 base 미복귀 + + assert report["repair"]["pairing_count"] == 0 + assert report["forced"]["pairing_count"] == 0 + assert report["_direct_coverage_source"] == "policy_direct" + assert report["cross_bucket_duplicate_flight_ids"] == [] + + +def test_deadhead_count_and_cross_bucket_duplicate(): + pairings = [ + {"legs": [0], "source_type": "policy"}, + {"legs": [0], "source_type": "forced", "is_deadhead": True}, # flight 0을 policy와 중복 커버 + ] + report = aggregate_by_source(pairings, FLIGHTS, n_total_flights=3) + + assert report["forced"]["deadhead_count"] == 1 + assert report["policy_direct"]["deadhead_count"] == 0 + # bucket 내부에는 중복 없음(각 bucket에 pairing 1개씩) + assert report["policy_direct"]["internal_duplicate_flight_ids"] == [] + # 근데 전체(policy+forced)로 보면 flight 0이 두 번 커버됨 + assert report["cross_bucket_duplicate_flight_ids"] == [0] + + +def test_per_chunk_uses_each_chunks_own_constraint(): + # chunk1은 base=BASE, chunk2는 base=OTHER -- evaluate_ip.py가 chunk마다 base_id를 + # 다시 뽑는 것과 동일한 상황. 각 pairing이 "자기" chunk의 base 기준으로는 정상이어야 함. + flights = { + **FLIGHTS, + 3: {"id": 3, "origin": OTHER, "dest": THIRD, "dep_time": 0.0, "arr_time": 1.0}, + 4: {"id": 4, "origin": THIRD, "dest": OTHER, "dep_time": 2.0, "arr_time": 3.0}, + } + constraint_other_base = {**CONSTRAINT, "base_airport": OTHER} + + chunks = [ + ([{"legs": [0, 1], "source_type": "policy"}], CONSTRAINT), # base=BASE + ([{"legs": [3, 4], "source_type": "policy"}], constraint_other_base), # base=OTHER + ] + report = aggregate_by_source_per_chunk(chunks, flights, n_total_flights=5) + + pd = report["policy_direct"] + assert pd["pairing_count"] == 2 + assert pd["covered_flights"] == 4 # 0,1,3,4 -- 겹치는 flight 없이 정확히 합산됨 + assert pd["invalid_count"] == 0 # 각자 자기 chunk의 base 기준으로는 둘 다 valid + + # 대조: 이걸 chunk 구분 없이 CONSTRAINT(base=BASE) 하나로만 검증했다면 chunk2 + # pairing(3,4)은 base=OTHER라서 잘못 invalid로 잡혔을 것 -- per-chunk가 왜 필요한지 확인. + wrong = aggregate_by_source([{"legs": [3, 4], "source_type": "policy"}], flights, + constraint=CONSTRAINT, n_total_flights=5) + assert wrong["policy_direct"]["invalid_count"] == 1 + + +def test_unknown_source_type_falls_back_to_own_name(): + pairings = [{"legs": [0, 1], "source_type": "weird"}] + report = aggregate_by_source(pairings, FLIGHTS, n_total_flights=3) + assert report["weird"]["pairing_count"] == 1 + assert report["weird"]["invalid_count"] is None # constraint 안 줬으니 검증 안 함 + + +if __name__ == "__main__": + test_buckets_split_by_source_type_and_time_metrics() + test_deadhead_count_and_cross_bucket_duplicate() + test_per_chunk_uses_each_chunks_own_constraint() + test_unknown_source_type_falls_back_to_own_name() + print("OK: 4개 테스트 통과") diff --git a/tests/test_validator.py b/tests/test_validator.py new file mode 100644 index 0000000..50c13dc --- /dev/null +++ b/tests/test_validator.py @@ -0,0 +1,248 @@ +""" +tests/test_validator.py -- evaluation/validator.py 스모크 테스트 (첫 골격 단계) + +C5(항목별 violation fixture 전부)는 별도 커밋에서 채운다 -- 여기서는 validator가 +정상 pairing/base-미복귀 pairing을 각각 올바르게 판정하는지만 우선 확인 +""" + +import os +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO_ROOT) +sys.path.insert(0, os.path.join(REPO_ROOT, "RL")) +sys.path.insert(0, os.path.join(REPO_ROOT, "evaluation")) + +from validator import ( # noqa: E402 + validate_pairing, + constraint_hash, + VALIDATOR_VERSION, + UNKNOWN_FLIGHT, + DUPLICATE_FLIGHT, + INVALID_BASE_START, + BASE_RETURN_FAILURE, + AIRPORT_DISCONTINUITY, + MIN_CONNECTION_FAILURE, + MAX_CONNECTION_FAILURE, + MAX_DUTY_FAILURE, + MAX_LEGS_FAILURE, + MAX_DUTIES_FAILURE, + MAX_PAIRING_DAYS_FAILURE, + MIN_PAIRING_LEGS_FAILURE, + TIME_ORDER_FAILURE, +) + + +BASE = 0 +OTHER = 1 +THIRD = 2 + +# base(0) -> other(1) -> base(0), 정상적인 connection/휴식 없이 한 duty로 끝나는 pairing +FLIGHTS_VALID = { + 0: {"id": 0, "origin": BASE, "dest": OTHER, "dep_time": 0.0, "arr_time": 2.0}, + 1: {"id": 1, "origin": OTHER, "dest": BASE, "dep_time": 3.0, "arr_time": 5.0}, +} +CONSTRAINT = { + "base_airport": BASE, + "min_conn": 0.5, "max_conn": 9.0, + "min_rest": 10.0, + "max_duty": 13.0, "max_legs": 8, + "max_duty_periods": 2, "max_pairing_days": 5, + "min_pairing_legs": 2, +} + + +def test_valid_pairing_passes(): + result = validate_pairing({"legs": [0, 1]}, FLIGHTS_VALID, CONSTRAINT) + assert result["is_valid"], result["violation_codes"] + assert result["violation_codes"] == [] + assert result["start_base"] == BASE + assert result["end_airport"] == BASE + assert result["n_duties"] == 1 + + +def test_provenance_fields_present_and_stable(): + result = validate_pairing({"legs": [0, 1]}, FLIGHTS_VALID, CONSTRAINT) + assert result["validator_version"] == VALIDATOR_VERSION + assert result["constraint_hash"] == constraint_hash(CONSTRAINT) + + # 같은 내용의 constraint면(딕셔너리 순서가 달라도) 같은 해시가 나와야 함 + reordered = {"min_pairing_legs": 2, **CONSTRAINT} + assert constraint_hash(CONSTRAINT) == constraint_hash(reordered) + + # constraint가 다르면 해시도 달라야 함 + different = {**CONSTRAINT, "max_duty": 10.0} + assert constraint_hash(CONSTRAINT) != constraint_hash(different) + + # constraint에 set(allowed_return_bases)이 섞여 있어도 안정적으로 해시돼야 함 + with_set = {**CONSTRAINT, "allowed_return_bases": {BASE, OTHER}} + assert constraint_hash(with_set) == constraint_hash({**CONSTRAINT, "allowed_return_bases": {OTHER, BASE}}) + + +def test_non_base_return_is_caught(): + # 두 번째 leg를 지워서 base로 안 돌아오는(도착지가 OTHER인) pairing으로 만듦 + result = validate_pairing({"legs": [0]}, FLIGHTS_VALID, CONSTRAINT) + assert not result["is_valid"] + assert BASE_RETURN_FAILURE in result["violation_codes"] + + +# Turkish HB1/HB2 비대칭 복귀: HB1(base=BASE)에서 출발해서 HB2(=OTHER)로 끝나는 pairing. +FLIGHTS_CROSS_BASE = { + 0: {"id": 0, "origin": BASE, "dest": OTHER, "dep_time": 0.0, "arr_time": 2.0}, +} + + +def test_cross_base_return_fails_by_default(): + # allowed_return_bases 없이(Delta/Alaska/JetBlue 기본 규칙)는 HB1->HB2도 + # 그냥 base 미복귀로 잡혀야 함. + result = validate_pairing({"legs": [0]}, FLIGHTS_CROSS_BASE, CONSTRAINT) + assert not result["is_valid"] + assert BASE_RETURN_FAILURE in result["violation_codes"] + + +def test_cross_base_return_allowed_when_opted_in(): + # allowed_return_bases = {BASE, OTHER}로 명시하면(Turkish HB1/HB2 케이스) 서로 + # 다른 base로 끝나도 BASE_RETURN_FAILURE가 나면 안 됨. + turkish_constraint = {**CONSTRAINT, "allowed_return_bases": {BASE, OTHER}} + result = validate_pairing({"legs": [0]}, FLIGHTS_CROSS_BASE, turkish_constraint) + assert BASE_RETURN_FAILURE not in result["violation_codes"], result["violation_codes"] + + +def test_unknown_flight_is_caught(): + result = validate_pairing({"legs": [999]}, {}, CONSTRAINT) + assert not result["is_valid"] + assert UNKNOWN_FLIGHT in result["violation_codes"] + + +def test_duplicate_within_pairing_is_caught(): + # 같은 flight(0)를 두 번 넣음 + result = validate_pairing({"legs": [0, 0]}, FLIGHTS_VALID, CONSTRAINT) + assert not result["is_valid"] + assert DUPLICATE_FLIGHT in result["violation_codes"] + assert result["duplicate_flight_ids"] == [0] + + +def test_invalid_base_start_is_caught(): + # OTHER(1)에서 출발해서 BASE(0)로 끝남 -- 도착은 base라 BASE_RETURN_FAILURE는 안 나야 함 + flights = {0: {"id": 0, "origin": OTHER, "dest": BASE, "dep_time": 0.0, "arr_time": 2.0}} + result = validate_pairing({"legs": [0]}, flights, CONSTRAINT) + assert not result["is_valid"] + assert INVALID_BASE_START in result["violation_codes"] + assert BASE_RETURN_FAILURE not in result["violation_codes"] + + +def test_airport_discontinuity_is_caught(): + # leg0 도착지(1)와 leg1 출발지(THIRD=2)가 안 맞음 -- 같은 duty(연결시간 정상 범위) + flights = { + 0: {"id": 0, "origin": BASE, "dest": OTHER, "dep_time": 0.0, "arr_time": 2.0}, + 1: {"id": 1, "origin": THIRD, "dest": BASE, "dep_time": 3.0, "arr_time": 5.0}, + } + result = validate_pairing({"legs": [0, 1]}, flights, CONSTRAINT) + assert not result["is_valid"] + assert AIRPORT_DISCONTINUITY in result["violation_codes"] + + +def test_min_connection_failure_is_caught(): + # gap = 0.1h < min_conn(0.5h) + flights = { + 0: {"id": 0, "origin": BASE, "dest": OTHER, "dep_time": 0.0, "arr_time": 2.0}, + 1: {"id": 1, "origin": OTHER, "dest": BASE, "dep_time": 2.1, "arr_time": 4.0}, + } + result = validate_pairing({"legs": [0, 1]}, flights, CONSTRAINT) + assert not result["is_valid"] + assert MIN_CONNECTION_FAILURE in result["violation_codes"] + + +def test_max_connection_failure_is_caught(): + # gap = 9.1h: max_conn(9.0h) 초과, min_rest(10h) 미만이라 duty는 안 나뉨 -- "dead zone" + flights = { + 0: {"id": 0, "origin": BASE, "dest": OTHER, "dep_time": 0.0, "arr_time": 0.5}, + 1: {"id": 1, "origin": OTHER, "dest": BASE, "dep_time": 9.6, "arr_time": 10.1}, + } + result = validate_pairing({"legs": [0, 1]}, flights, CONSTRAINT) + assert not result["is_valid"] + assert MAX_CONNECTION_FAILURE in result["violation_codes"] + + +def test_max_duty_failure_is_caught(): + # duty 경과시간 14.1h > max_duty(13h), connection은 정상 범위(0.6h) + flights = { + 0: {"id": 0, "origin": BASE, "dest": OTHER, "dep_time": 0.0, "arr_time": 7.0}, + 1: {"id": 1, "origin": OTHER, "dest": BASE, "dep_time": 7.6, "arr_time": 14.1}, + } + result = validate_pairing({"legs": [0, 1]}, flights, CONSTRAINT) + assert not result["is_valid"] + assert MAX_DUTY_FAILURE in result["violation_codes"] + + +def test_max_legs_failure_is_caught(): + # max_legs를 1로 낮춰서, 정상 2-leg 1-duty pairing도 leg 수 초과로 잡히는지 확인 + constraint = {**CONSTRAINT, "max_legs": 1} + result = validate_pairing({"legs": [0, 1]}, FLIGHTS_VALID, constraint) + assert not result["is_valid"] + assert MAX_LEGS_FAILURE in result["violation_codes"] + + +def test_max_duties_failure_is_caught(): + # max_duty_periods를 0(overnight 0번, 즉 duty 1개만 허용)으로 낮춘 뒤 2-duty pairing 검증 + flights = { + 0: {"id": 0, "origin": BASE, "dest": OTHER, "dep_time": 0.0, "arr_time": 2.0}, + 1: {"id": 1, "origin": OTHER, "dest": BASE, "dep_time": 15.0, "arr_time": 17.0}, # gap 13h >= min_rest + } + constraint = {**CONSTRAINT, "max_duty_periods": 0} + result = validate_pairing({"legs": [0, 1]}, flights, constraint) + assert not result["is_valid"] + assert MAX_DUTIES_FAILURE in result["violation_codes"] + assert result["n_duties"] == 2 + + +def test_max_pairing_days_failure_is_caught(): + # 기존 정상 pairing(약 0.2일)에 max_pairing_days만 아주 작게(0.05일) 낮춰서 위반 유도 + constraint = {**CONSTRAINT, "max_pairing_days": 0.05} + result = validate_pairing({"legs": [0, 1]}, FLIGHTS_VALID, constraint) + assert not result["is_valid"] + assert MAX_PAIRING_DAYS_FAILURE in result["violation_codes"] + + +def test_min_pairing_legs_failure_is_caught(): + # 기존 정상 2-leg pairing에 min_pairing_legs만 3으로 올려서 위반 유도 + constraint = {**CONSTRAINT, "min_pairing_legs": 3} + result = validate_pairing({"legs": [0, 1]}, FLIGHTS_VALID, constraint) + assert not result["is_valid"] + assert MIN_PAIRING_LEGS_FAILURE in result["violation_codes"] + + +def test_time_order_failure_is_caught(): + # leg1의 출발(3.0)이 leg0의 도착(7.0)보다 이름 -- 시간 역순 + flights = { + 0: {"id": 0, "origin": BASE, "dest": OTHER, "dep_time": 5.0, "arr_time": 7.0}, + 1: {"id": 1, "origin": OTHER, "dest": BASE, "dep_time": 3.0, "arr_time": 9.0}, + } + result = validate_pairing({"legs": [0, 1]}, flights, CONSTRAINT) + assert not result["is_valid"] + assert TIME_ORDER_FAILURE in result["violation_codes"] + + +if __name__ == "__main__": + test_fns = [ + test_valid_pairing_passes, + test_provenance_fields_present_and_stable, + test_non_base_return_is_caught, + test_cross_base_return_fails_by_default, + test_cross_base_return_allowed_when_opted_in, + test_unknown_flight_is_caught, + test_duplicate_within_pairing_is_caught, + test_invalid_base_start_is_caught, + test_airport_discontinuity_is_caught, + test_min_connection_failure_is_caught, + test_max_connection_failure_is_caught, + test_max_duty_failure_is_caught, + test_max_legs_failure_is_caught, + test_max_duties_failure_is_caught, + test_max_pairing_days_failure_is_caught, + test_min_pairing_legs_failure_is_caught, + test_time_order_failure_is_caught, + ] + for fn in test_fns: + fn() + print(f"OK: {len(test_fns)}개 테스트 통과 (MIN_REST_FAILURE는 TODO -- validator.py 참고)")