diff --git a/CHANGES.md b/CHANGES.md index 64b8cc0..5c1ce77 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4903,3 +4903,100 @@ CLI flag 추가 — `ipe.v1.main_v1`: | `CHANGES.md` §69 | 본 entry | --- + +## 70. v2 품질 개선 3-wave — BOJ 상용 수준 문제 품질 (2026-07-13) + +### 70.1 동기 + +생성 문제의 체감 품질이 상용 저지(BOJ) 대비 미달. 4개 서브시스템 병렬 감사로 +갭을 도출하고 3-wave 병렬 구현으로 해소: +(1) 지문 작문 품질 무기준, (2) 채점 데이터가 random+경계 위주(적대적/최대규모 +케이스 부재, _MAX_ELEMENTS=10000 전역 클램프로 지문 제약↔실데이터 괴리), +(3) 난이도 anchor 20개 편중(Gold 9/Platinum 1/Diamond 0), (4) brute 검증자가 +golden 과 동일 프롬프트·동일 AlgorithmDesign 공유 → Tier B differential 이 +사실상 자기승인, (5) 예제 설명 채널(SampleTestCase.description) 0% 사용, +(6) QA 리뷰어가 샘플 2개+분포 요약만 열람, 작문 품질 관점 부재. + +### 70.2 Wave 1 — 병렬 4 워크스트림 + +- **난이도 calibration**: anchors.json 20→44 (Bronze 8/Silver 10/Gold 12/ + Platinum 10/Diamond 4, 오라벨 bj_1761 교체·리뷰어 전수검증 통과). + difficulty.py 프롬프트에 티어 rubric(절대 프레임) + 최소 3-anchor 삼각측량 + + 복잡도×n_max 교차확인 + 애매 시 보수 하향 + "rubric↔anchor 어긋나면 anchor + 우선" 규율 추가. (사후 annotation 지위는 RFC R4 대로 유지 — 게이트 아님.) +- **지문 작문 기준**: narrative.py 두 프롬프트에 '작문 품질 (BOJ 상용 수준)' + 섹션 ADD-only (구성 2~4문단·문어체·번역투 금지·용어 일관·300~800자·제목 + 2~6어절). strategist.py _DOMAIN_POOL 21→42 (팔레트 크기·sha256 회전 불변). +- **적대적 채점 데이터**: input_gen.py _Bias 에 graph 7종(path_chain/star/ + dense/cycle_heavy/duplicate_edges/equal_weights/extreme_weights) + sequence + 6종(sorted_asc/desc/all_equal/alternating/single_element/extreme_values) + + string 2종(all_same_char/periodic) 아키타입. shape 핀(sortedness/alphabet/ + multi_edges)과 모순되는 아키타입은 미방출(F18 정합). max_size+max_stress 로 + 상한 타격 2개 보장. _ADVERSARIAL_MIN_SIZE=10 규모 게이트. 대형 graph 스키마 + 기준 suite 9→17 케이스. +- **QA 게이트 5종화**: QAReviewerKind 에 "presentation"(지문 품질) 신설 — + P1 4종/P2 5종. blocker 는 작문 붕괴 수준만(해석 유일성은 ambiguity 전담), + 초급 완화 charter(_PRESENTATION_CHARTER_EASY) 병설. ambiguity charter 에 + 동률/중복/퇴화 결정성 체크리스트, fairness 에 전문지식 전제 점검 추가. + 리뷰어 열람 확대: 샘플 2→3개(300자 절단) + '[채점셋 상세]'(최대 크기·대형 + 케이스 수). + +### 70.3 Wave 2 — brute 독립화 + 채점셋 견고화 + +- **brute 검증자 독립화 (correctness)**: coder.py 에 _BRUTE_SYSTEM_PROMPT + (검산자 — AlgorithmDesign 비열람·정공법/완전탐색·효율 무시) + brute_mode + 플래그(user prompt 에서 design 4개 라인 제외, _PARSE_DISCIPLINE·preamble + compliance 는 유지). main_v2/api(+batch 는 팩토리 재사용) brute 배선에 + brute_mode=True. Tier B differential 의 독립성 전제(§7.4) 복원. +- **채점셋 견고화**: assembler.py 에 _MIN_SURVIVAL_RATIO=0.7 생존율 게이트 + (카테고리별 drop 진단 포함 fail — 조용한 약체 suite 출하 차단). + suite_assembler 가 golden 검증된 resolved_edges 를 "edge_resolved:*" + 카테고리로 채점셋에 병합(재실행 없음·중복 skip). spec_bridge 는 + edge_case_semantics 선언 시 샘플 1개를 퇴화 입력으로 교체(지문의 경계 + 의미를 샘플이 시연 — BOJ 표준). + +### 70.4 Wave 3 — 예제 설명 + 스트레스 상한 분리 + +- **sample_explainer 노드 신설 (N: 예제 설명)**: sample_filler 뒤 배선 + (with_sample_explanations 플래그 — 기본 off, api/batch/CLI 프로덕션 on). + Sonnet 4.6, 샘플별 1~3문장 한국어 설명을 SampleTestCase.description 에 + 기입 — 인스턴스 사실만(해법 처방·형식 재서술 금지, 은닉 규율), 퇴화 샘플 + 의미 명시. LLM 예외 시 state 무변경(장식 채널 — 파이프라인 신규 실패 클래스 + 0). RECURSION_PAD_SYNTHESIS 12→13, RECURSION_LIMIT_API 90→91. +- **스트레스 상한 분리**: _MAX_STRESS_ELEMENTS=50_000 — bias="max"(max_size/ + max_stress)에만 적용, 나머지는 _MAX_ELEMENTS=10000 유지(패키지/실행 비용 + 보호). graph max 케이스 V≤25,000·E≈2V. formalizer 프롬프트에 '제약 크기 + 설계' 규율 ADD(생성 상한 5×10^4 정합 — 복잡도별 권장 상한, 10^5+ 선언 금지 + → 지문 제약↔실데이터 괴리로 naive 오답이 통과하는 결함 차단). + _ARCHETYPE_MIN_SIZE=2 하한 클램프(duplicate_edges V=1·periodic 길이 1 등 + 카테고리명↔입력 불일치 해소). real-llm difficulty e2e 에 API 키 skip 가드. + +### 70.5 검증 + +- 3-wave 각각 병렬 구현 → 통합 CI → 적대적 리뷰(2관점) → blocker 수정 루프. +- 최종: ruff clean / mypy --strict 102 files clean / **pytest 964 passed, + 10 skipped** (baseline 892 → +72, 전 wave 신규 테스트 포함, 무회귀). +- 한계: 로컬에 ANTHROPIC_API_KEY 부재로 real-LLM e2e·실생성 스모크런 미수행 + — 프롬프트 개선의 실효(작문 품질·설명 품질·calibration 정확도)는 키 있는 + 환경에서 P1/P2 배치 + `python -m ipe.v2.difficulty --force` 재측정 권장. + +### 70.6 변경 파일 (38 tracked +2067/-133 + 신규 4) + +| 영역 | 파일 | +|---|---| +| 난이도 | `ipe/calibration/anchors.json` `ipe/calibration/__init__.py` `ipe/v2/difficulty.py` | +| 지문 | `ipe/v2/nodes/narrative.py` `ipe/v2/nodes/strategist.py` | +| 채점 데이터 | `ipe/v2/generation/input_gen.py` `ipe/v2/nodes/generator_designer.py` `ipe/v2/nodes/formalizer.py` | +| QA | `ipe/v1/schema/qa.py` `ipe/v2/nodes/qa_reviewer.py` `ipe/v2/config.py` `ipe/v2/graph.py` | +| brute 독립화 | `ipe/v1/nodes/coder.py` `ipe/v2/main_v2.py` `ipe/v2/api.py` | +| 채점셋 견고화 | `ipe/v2/generation/assembler.py` `ipe/v2/nodes/suite_assembler.py` `ipe/v2/nodes/spec_bridge.py` | +| 예제 설명 | `ipe/v1/schema/sample_explanation.py`(신규) `ipe/v2/nodes/sample_explainer.py`(신규) + graph/api/main_v2/config 배선 | +| 테스트 | tests/ 전반 +72 (신규 파일: `tests/v2/nodes/test_sample_explainer.py` `tests/v2/test_v2_sample_explainer_graph.py` 등) | + +### 70.7 후속 과제 (이번 범위 외) + +- in-graph 난이도 타겟팅(target_difficulty 노브 + 측정-목표 불일치 라우팅, RFC R4 확장) +- special judge / float 허용오차 비교기 (toposort·max_flow 류 다답 문제) +- leakage 리뷰어의 실코퍼스 grounding (RFC §11 Q2 재논의 트리거 경과) +- QA back-route 의 구조 결함(스키마/채점셋 기인) 수선 경로 확장 +- samples_engaged=0 (symbolic verifier silent skip) 신호의 게이트/진단 승격 diff --git a/ipe/calibration/__init__.py b/ipe/calibration/__init__.py index f97d30f..877bcd3 100644 --- a/ipe/calibration/__init__.py +++ b/ipe/calibration/__init__.py @@ -2,7 +2,8 @@ 스펙: PROJECT_SPEC.md §4.6 (The Evaluator), IMPLEMENTATION_ROADMAP §1 P9.1 -``anchors.json`` 은 4~8개의 anchor를 포함: +``anchors.json`` 은 Bronze~Diamond 티어를 균형 있게 덮는 40여 개 anchor를 포함 +(RFC R4 확장: Bronze 8 / Silver 10 / Gold 12 / Platinum 10 / Diamond 4). 각 anchor: - ``id``: 시스템 식별자 (예: ``bj_1753_gold5``) - ``label``: 사람이 읽는 라벨 (예: ``Gold V``) - ``summary``: 한 줄 요약 diff --git a/ipe/calibration/anchors.json b/ipe/calibration/anchors.json index fcc4d9a..ee55cad 100644 --- a/ipe/calibration/anchors.json +++ b/ipe/calibration/anchors.json @@ -10,6 +10,28 @@ "data_structures": [] } }, + { + "id": "bj_2557_bronze5", + "label": "Bronze V", + "summary": "Hello World! 고정 문자열 한 줄 출력 (출력문)", + "factors": { + "algorithm": "implementation", + "n_max": null, + "complexity": "O(1)", + "data_structures": [] + } + }, + { + "id": "bj_2753_bronze5", + "label": "Bronze V", + "summary": "윤년 판정 — 4/100/400 배수 조건 분기 (조건문)", + "factors": { + "algorithm": "implementation", + "n_max": null, + "complexity": "O(1)", + "data_structures": [] + } + }, { "id": "bj_2562_bronze3", "label": "Bronze III", @@ -43,6 +65,28 @@ "data_structures": ["array"] } }, + { + "id": "bj_1546_bronze1", + "label": "Bronze I", + "summary": "최댓값 기준으로 점수를 조작한 새 평균 계산 (배열 순회 + 산술)", + "factors": { + "algorithm": "implementation", + "n_max": 1000, + "complexity": "O(N)", + "data_structures": ["array"] + } + }, + { + "id": "bj_2609_bronze1", + "label": "Bronze I", + "summary": "두 수의 최대공약수·최소공배수 — 유클리드 호제법", + "factors": { + "algorithm": "euclidean_gcd", + "n_max": 10000, + "complexity": "O(log N)", + "data_structures": [] + } + }, { "id": "bj_2751_silver5", "label": "Silver V", @@ -65,6 +109,17 @@ "data_structures": [] } }, + { + "id": "bj_2839_silver4", + "label": "Silver IV", + "summary": "설탕 N kg 을 3/5kg 봉지 최소 개수로 배달 — 그리디/완전탐색 (N ≤ 5000)", + "factors": { + "algorithm": "greedy", + "n_max": 5000, + "complexity": "O(N)", + "data_structures": [] + } + }, { "id": "bj_1003_silver3", "label": "Silver III", @@ -87,6 +142,17 @@ "data_structures": ["array"] } }, + { + "id": "bj_11726_silver3", + "label": "Silver III", + "summary": "2×n 타일링 경우의 수 — 피보나치형 점화 DP (N ≤ 1000, mod 10007)", + "factors": { + "algorithm": "dp", + "n_max": 1000, + "complexity": "O(N)", + "data_structures": ["array"] + } + }, { "id": "bj_1012_silver2", "label": "Silver II", @@ -109,6 +175,28 @@ "data_structures": ["grid", "queue"] } }, + { + "id": "bj_1697_silver1", + "label": "Silver I", + "summary": "수직선 숨바꼭질 최소 시간 — 상태공간 BFS (N, K ≤ 100,000)", + "factors": { + "algorithm": "bfs", + "n_max": 100000, + "complexity": "O(N)", + "data_structures": ["queue", "visited_array"] + } + }, + { + "id": "bj_1149_silver1", + "label": "Silver I", + "summary": "RGB거리 — 인접 집과 색이 다르게 칠하는 최소 비용 DP (N ≤ 1000)", + "factors": { + "algorithm": "dp", + "n_max": 1000, + "complexity": "O(N)", + "data_structures": ["array"] + } + }, { "id": "bj_1916_gold5", "label": "Gold V", @@ -131,6 +219,28 @@ "data_structures": ["array"] } }, + { + "id": "bj_7576_gold5", + "label": "Gold V", + "summary": "토마토 전체가 익는 최소 일수 — 다중 시작점 BFS (격자 ≤ 1000×1000)", + "factors": { + "algorithm": "multi_source_bfs", + "n_max": 1000000, + "complexity": "O(N*M)", + "data_structures": ["grid", "queue"] + } + }, + { + "id": "bj_1717_gold5", + "label": "Gold V", + "summary": "합집합·같은 집합 판별 쿼리 — 유니온파인드 경로압축 (N ≤ 1,000,000, M ≤ 100,000)", + "factors": { + "algorithm": "union_find", + "n_max": 1000000, + "complexity": "O(α(N)) per query", + "data_structures": ["union_find"] + } + }, { "id": "bj_1753_gold4", "label": "Gold IV", @@ -186,6 +296,17 @@ "data_structures": [] } }, + { + "id": "bj_12015_gold2", + "label": "Gold II", + "summary": "가장 긴 증가하는 부분 수열 길이 — 이분탐색 LIS (N ≤ 1,000,000)", + "factors": { + "algorithm": "lis_binary_search", + "n_max": 1000000, + "complexity": "O(N log N)", + "data_structures": ["array"] + } + }, { "id": "bj_2042_gold1", "label": "Gold I", @@ -209,14 +330,157 @@ } }, { - "id": "bj_1761_platinum5", + "id": "bj_11438_platinum5", "label": "Platinum V", - "summary": "트리 두 정점 거리 쿼리 — LCA 희소 배열 (N ≤ 40000, M ≤ 10000)", + "summary": "두 정점의 최소 공통 조상(LCA) 다수 쿼리 — 희소 배열 이진 리프팅 (N ≤ 100,000, M ≤ 100,000)", "factors": { "algorithm": "lca_sparse_table", - "n_max": 40000, + "n_max": 100000, "complexity": "O((N+M) log N)", "data_structures": ["sparse_table", "tree"] } + }, + { + "id": "bj_1786_platinum5", + "label": "Platinum V", + "summary": "텍스트 내 패턴 등장 위치 전부 탐색 — KMP 실패함수 (|T| ≤ 1,000,000)", + "factors": { + "algorithm": "kmp", + "n_max": 1000000, + "complexity": "O(N+M)", + "data_structures": ["failure_function"] + } + }, + { + "id": "bj_2150_platinum5", + "label": "Platinum V", + "summary": "방향그래프 강한연결요소(SCC) 분해 — 타잔/코사라주 (V ≤ 10000, E ≤ 100000)", + "factors": { + "algorithm": "scc", + "n_max": 10000, + "complexity": "O(V+E)", + "data_structures": ["stack", "adjacency_list"] + } + }, + { + "id": "bj_5719_platinum5", + "label": "Platinum V", + "summary": "최단경로 간선을 모두 제거한 '거의 최단 경로' — 다익스트라 + 역추적 간선 삭제 반복", + "factors": { + "algorithm": "dijkstra_edge_removal", + "n_max": 500, + "complexity": "O(E log V)", + "data_structures": ["priority_queue", "adjacency_list"] + } + }, + { + "id": "bj_11280_platinum4", + "label": "Platinum IV", + "summary": "2-SAT 충족 가능성 판정 — 함의 그래프 + SCC (N ≤ 10000, M ≤ 100,000)", + "factors": { + "algorithm": "two_sat", + "n_max": 10000, + "complexity": "O(V+E)", + "data_structures": ["implication_graph", "scc"] + } + }, + { + "id": "bj_11375_platinum4", + "label": "Platinum IV", + "summary": "직원-작업 최대 이분매칭 개수 — 증가 경로 탐색 (N, M ≤ 1000)", + "factors": { + "algorithm": "bipartite_matching", + "n_max": 1000, + "complexity": "O(V*E)", + "data_structures": ["adjacency_list", "match_array"] + } + }, + { + "id": "bj_6086_platinum4", + "label": "Platinum IV", + "summary": "파이프 네트워크 A→Z 최대 유량 — 에드몬드-카프 (노드 ≤ 52)", + "factors": { + "algorithm": "max_flow", + "n_max": 52, + "complexity": "O(V*E^2)", + "data_structures": ["capacity_matrix", "queue"] + } + }, + { + "id": "bj_10999_platinum4", + "label": "Platinum IV", + "summary": "구간 덧셈 갱신 + 구간 합 쿼리 — 레이지 프로퍼게이션 세그먼트 트리 (N ≤ 1,000,000)", + "factors": { + "algorithm": "lazy_segment_tree", + "n_max": 1000000, + "complexity": "O(log N) per query", + "data_structures": ["lazy_segment_tree"] + } + }, + { + "id": "bj_2243_platinum4", + "label": "Platinum IV", + "summary": "사탕상자 — k번째 원소 꺼내기 + 수량 갱신, 펜윅/세그트리 k-th 검색 (맛 등급 ≤ 1,000,000)", + "factors": { + "algorithm": "fenwick_kth_search", + "n_max": 1000000, + "complexity": "O(log N) per query", + "data_structures": ["fenwick_tree"] + } + }, + { + "id": "bj_9250_platinum3", + "label": "Platinum III", + "summary": "패턴 집합 중 하나라도 포함하는지 다수 문자열 판별 — 아호코라식 오토마톤", + "factors": { + "algorithm": "aho_corasick", + "n_max": 10000, + "complexity": "O(sum|S| + sum|Q|)", + "data_structures": ["trie", "failure_link"] + } + }, + { + "id": "bj_17131_diamond5", + "label": "Diamond V", + "summary": "V자(여우 모양)를 이루는 별 세 개 조합 개수 — 좌표 스위핑 + 세그먼트 트리 조합 카운팅 (N ≤ 200,000)", + "factors": { + "algorithm": "sweep_segment_tree_counting", + "n_max": 200000, + "complexity": "O(N log N)", + "data_structures": ["segment_tree", "coordinate_compression"] + } + }, + { + "id": "bj_14898_diamond5", + "label": "Diamond V", + "summary": "온라인 구간 내 서로 다른 수 개수 쿼리 — 퍼시스턴트 세그먼트 트리 (N ≤ 1,000,000)", + "factors": { + "algorithm": "persistent_segment_tree", + "n_max": 1000000, + "complexity": "O(log N) per query", + "data_structures": ["persistent_segment_tree"] + } + }, + { + "id": "bj_13519_diamond4", + "label": "Diamond IV", + "summary": "트리 경로의 최대 연속 부분합 쿼리 + 정점 가중치 갱신 — 링크컷/HLD + 병합형 세그트리 노드 (N ≤ 100,000)", + "factors": { + "algorithm": "link_cut_tree_path_query", + "n_max": 100000, + "complexity": "O(log^2 N) per query", + "data_structures": ["link_cut_tree", "segment_tree"] + } + }, + { + "id": "bj_13539_diamond3", + "label": "Diamond III", + "summary": "동적 트리 link/cut + 경로 가중치 갱신·합 쿼리 — 레이지 링크컷 트리 (N, Q ≤ 100,000)", + "factors": { + "algorithm": "link_cut_tree_lazy", + "n_max": 100000, + "complexity": "O(log N) amortized per query", + "data_structures": ["link_cut_tree"] + } } ] diff --git a/ipe/v1/nodes/coder.py b/ipe/v1/nodes/coder.py index 9bd7805..f5f845f 100644 --- a/ipe/v1/nodes/coder.py +++ b/ipe/v1/nodes/coder.py @@ -42,6 +42,33 @@ """ +# brute(검산자) 전용 system prompt — golden 과의 **프롬프트 격리**가 목적 (W2A). +# P2(합성·은닉)는 symbolic verifier off → golden×K vs brute reconcile 합의가 유일한 +# 정답성 게이트인데, brute 가 golden 과 동일 prompt + 동일 AlgorithmDesign.pseudocode +# 를 소비하면 설계 오해 시 전 후보가 같은 오답에 합의('검증됨' 오출하). 따라서 brute 는 +# 지문(ProblemSpec)만으로 독립 작성하고 설계를 읽지 않는다 — 독립 differential 복원. +# 요구사항(code/language/iteration/lessons 형식)은 _SYSTEM_PROMPT 와 동일하게 유지. +_BRUTE_SYSTEM_PROMPT = """\ +당신은 신중한 검산자(reference checker)다. 주어진 문제 지문(ProblemSpec)만으로 +**가장 단순하고 명백히 옳은** 정공법 풀이를 독립 작성해 typed SolutionAttempt +(구조화된 tool call) 를 반환한다. 효율은 무시하라 — 완전탐색/직접 시뮬레이션/O(N²) +이상도 좋다 (작은 검증 입력에만 실행된다). 제공되는 AlgorithmDesign(의사코드/복잡도)은 +**읽지 말고 따르지도 말 것** — 당신의 역할은 그 설계와 독립적인 교차검증이다. + +요구사항: +- code: 완전한 python solution (stdin 으로 input 읽고 stdout 으로 output 출력). +- language: "python" (Phase 1 기본). +- iteration: 입력으로 받은 iteration 값. +- lessons: 이 시도에서 학습한 lessons 의 list (signature + content + from_iter). + prev IterationContext 의 lessons 와 중복되지 않게 (signature dedup 의무). + +prev verification.feedback 가 있으면: +- failure_mode + invariant_violations + actionable_hint 를 정확히 반영해 새 code. +- 같은 blocking_signature 반복 회피 (oscillation 방지). +- accumulated_lessons 와 failed_strategies 를 참고해 같은 실수 회피. +""" + + # v2 synthesis 전용 입력 파싱 규율 (opt-in). 골든/brute 코더가 같은 입력을 같게 # 읽어야 reconcile 합의 → 출하. 배치 진단상 array/value 시드 출하의 1위 병목이 # "양쪽 비-reference 코더 동시 IndexError"(파서 불일치)였다. literal 중괄호 금지 @@ -57,15 +84,21 @@ """ -def _coder_system_prompt(parse_discipline: bool) -> str: +def _coder_system_prompt( + parse_discipline: bool, *, brute_mode: bool = False +) -> str: """coder system prompt — ``parse_discipline`` 시 입력 파싱 규율을 append. v1 ``make_coder_node`` 는 기본 off 로 ``_SYSTEM_PROMPT`` 동결 유지(91.2% anchor 자산); v2 synthesis(골든/brute)는 on 으로 파서 불일치 RTE(IndexError) 거부를 줄인다. + ``brute_mode`` 는 base 를 ``_BRUTE_SYSTEM_PROMPT``(검산자 — 설계 비공유 독립 + 교차검증)로 교체하되, ``_PARSE_DISCIPLINE`` append 는 동일 적용 — preamble 파싱 + 규율은 reconcile 합의의 전제라 brute 에서도 절대 제거하지 않는다. """ + base = _BRUTE_SYSTEM_PROMPT if brute_mode else _SYSTEM_PROMPT if parse_discipline: - return _SYSTEM_PROMPT + _PARSE_DISCIPLINE - return _SYSTEM_PROMPT + return base + _PARSE_DISCIPLINE + return base # RTE 레버 — canonical 파서 기계적 보장 (fail_synthesis 1위 병목 54% 진단 후속, #2). @@ -179,7 +212,11 @@ def _render_prev_attempt(state: V1State) -> str: ) -def _build_user_prompt(state: V1State) -> str: +def _build_user_prompt(state: V1State, *, brute_mode: bool = False) -> str: + """coder user prompt 렌더. ``brute_mode`` 시 design 관련 라인(algorithm/ + complexity_target/pseudocode/required invariants)을 **제외** — brute 검산자가 + 골든의 설계 오해를 그대로 재생산하지 않는 독립 differential 의 프롬프트 격리. + 나머지(spec/샘플/preamble/lessons/verification)는 동일 렌더.""" spec = state.spec design = state.design if spec is None or design is None: @@ -195,12 +232,17 @@ def _build_user_prompt(state: V1State) -> str: f"io_contract.input_format: {spec.io_contract.input_format}", f"io_contract.output_format: {spec.io_contract.output_format}", f"time_limit_ms: {spec.time_limit_ms}, memory_limit_mb: {spec.memory_limit_mb}", - "", - f"algorithm: {design.algorithm_name}", - f"complexity_target: time={design.complexity_target.time_big_o}, " - f"space={design.complexity_target.space_big_o}", - f"pseudocode: {design.pseudocode}", - f"required invariants: {[i.kind for i in design.invariants]}", + ] + if not brute_mode: + parts += [ + "", + f"algorithm: {design.algorithm_name}", + f"complexity_target: time={design.complexity_target.time_big_o}, " + f"space={design.complexity_target.space_big_o}", + f"pseudocode: {design.pseudocode}", + f"required invariants: {[i.kind for i in design.invariants]}", + ] + parts += [ "", "sample testcases (input → expected):", ] @@ -237,19 +279,30 @@ class AnthropicCoderLLM: """production impl — Opus + structured output.""" def __init__( - self, model: str = CODER_MODEL, *, parse_discipline: bool = False + self, + model: str = CODER_MODEL, + *, + parse_discipline: bool = False, + brute_mode: bool = False, ) -> None: from langchain_anthropic import ChatAnthropic from langchain_core.prompts import ChatPromptTemplate llm = ChatAnthropic(model_name=model, timeout=60, stop=None) prompt = ChatPromptTemplate.from_messages( - [("system", _coder_system_prompt(parse_discipline)), ("user", "{user}")] + [ + ( + "system", + _coder_system_prompt(parse_discipline, brute_mode=brute_mode), + ), + ("user", "{user}"), + ] ) self._chain = ( prompt | llm.with_structured_output(SolutionAttempt) ).with_retry(stop_after_attempt=5, wait_exponential_jitter=True) self._parse_discipline = parse_discipline + self._brute_mode = brute_mode def generate(self, state: V1State) -> SolutionAttempt: preamble = "" @@ -257,7 +310,7 @@ def generate(self, state: V1State) -> SolutionAttempt: preamble = state.spec.input_parser_code def _once(corrective: str | None) -> SolutionAttempt: - user = _build_user_prompt(state) + user = _build_user_prompt(state, brute_mode=self._brute_mode) if corrective: user = f"{user}\n\n{corrective}" result = self._chain.invoke({"user": user}) diff --git a/ipe/v1/schema/__init__.py b/ipe/v1/schema/__init__.py index f0d7970..3973443 100644 --- a/ipe/v1/schema/__init__.py +++ b/ipe/v1/schema/__init__.py @@ -32,6 +32,7 @@ is_basic, ) from .qa import QAFinding, QAReport, QAReview, QAReviewerKind, QASeverity +from .sample_explanation import SampleExplanations from .solution_attempt import Lesson, SolutionAttempt from .synthesis import ReconciliationResult, SolutionCandidate from .test_suite import ( @@ -88,6 +89,7 @@ "QASeverity", "ReconciliationResult", "ResolvedEdgeCase", + "SampleExplanations", "SampleResult", "SampleTestCase", "ScaleFamily", diff --git a/ipe/v1/schema/qa.py b/ipe/v1/schema/qa.py index d5e5ded..1b91db6 100644 --- a/ipe/v1/schema/qa.py +++ b/ipe/v1/schema/qa.py @@ -1,7 +1,7 @@ """QA/Critic 스테이지 아티팩트 (Phase 3 M5 — RFC N10/N11). -suite 까지 완성된 문제 패키지(narrative+spec+test_suite)를 4 관점의 병렬 QA 리뷰어 -(N10a-d: 모호성/공정성/유출/난이도)가 검토하고, deterministic aggregator(N11)가 +suite 까지 완성된 문제 패키지(narrative+spec+test_suite)를 5 관점의 병렬 QA 리뷰어 +(N10a-e: 모호성/공정성/유출/난이도/지문품질)가 검토하고, deterministic aggregator(N11)가 집계해 출하 게이트를 친다. artifacts: @@ -22,7 +22,10 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator -QAReviewerKind = Literal["ambiguity", "fairness", "leakage", "difficulty"] +# presentation(지문 품질 — 문체·구성)은 상용 저지 수준 작문 게이트 (N10e, additive). +QAReviewerKind = Literal[ + "ambiguity", "fairness", "leakage", "difficulty", "presentation" +] QASeverity = Literal["info", "warning", "blocker"] @@ -36,7 +39,7 @@ class QAFinding(BaseModel): class QAReview(BaseModel): - """QA 리뷰어 1종(N10a-d)의 판정. + """QA 리뷰어 1종(N10a-e)의 판정. ``passed=True`` 와 blocker finding 의 공존은 모순 — LLM 이 산출한 판정의 내적 일관성을 schema 가 강제한다 (왜곡된 구조화 출력 조기 reject). diff --git a/ipe/v1/schema/sample_explanation.py b/ipe/v1/schema/sample_explanation.py new file mode 100644 index 0000000..782cb7e --- /dev/null +++ b/ipe/v1/schema/sample_explanation.py @@ -0,0 +1,29 @@ +"""SampleExplanations — 예제 설명(sample explanation) LLM 의 structured output. + +BOJ 표준 '예제 설명' 채널의 typed 계약. ``SampleTestCase.description`` 은 스키마에 +있으나 어떤 노드도 채우지 않아 출하 문제의 예제 설명이 영구 빈 문자열이었다 — +v2 ``sample_explainer`` 노드가 sample_filler(golden 실행으로 expected 확정) 직후 +이 모델로 설명을 받아 각 sample.description 에 스탬프한다. 설명은 '이 입력에서 +왜 이 출력이 나오는지' **인스턴스 수준** 사실만(해법 은닉 규율은 노드 프롬프트가 +집행), 순서는 샘플 순서 그대로. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class SampleExplanations(BaseModel): + """sample_explainer LLM 출력 — 샘플 순서 그대로의 예제 설명 텍스트 목록. + + 개수는 샘플 개수와 정확히 같아야 한다(프롬프트 규율). 불일치 시 노드가 min + 길이까지만 채우고 나머지 sample 은 원본 유지 — 장식적 품질 채널이라 어떤 + 경우에도 파이프라인을 죽이지 않는다(방어적 완화, 노드 docstring 참조). + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + explanations: list[str] = Field( + ..., + description="샘플 순서 그대로의 예제 설명 (샘플당 1~3문장 한국어, 인스턴스 수준)", + ) diff --git a/ipe/v2/api.py b/ipe/v2/api.py index d7bc255..8159f8a 100644 --- a/ipe/v2/api.py +++ b/ipe/v2/api.py @@ -54,8 +54,9 @@ class GenerateRequest(BaseModel): """계약 §2.1 요청 본문. - ``mode`` (Phase 4 — P1/P2 수렴): ``"p1"``=단일·공개·QA 3종 / ``"p2"``=합성·은닉· - QA 4종. 모드 노브(hidden/composition_mode/qa_kinds)는 ``config.mode_knobs`` 가 결정. + ``mode`` (Phase 4 — P1/P2 수렴): ``"p1"``=단일·공개·QA 4종(presentation 포함) / + ``"p2"``=합성·은닉·QA 5종(+leakage). 모드 노브(hidden/composition_mode/qa_kinds)는 + ``config.mode_knobs`` 가 결정. """ model_config = ConfigDict(frozen=True, extra="forbid") @@ -84,10 +85,13 @@ class _Job: def _production_graph_factory(req: GenerateRequest) -> Any: """CLI 와 동일 모델 구성의 full 그래프. 모드 노브(hidden/composition/qa_kinds)는 - ``config.mode_knobs`` (P1=단일·공개·QA3 / P2=합성·은닉·QA4).""" + ``config.mode_knobs`` (P1=단일·공개·QA4 / P2=합성·은닉·QA5). 예제 설명 + (sample_explainer, W2B)은 production 항상 켬 — LLM 예외 시 무변경 통과라 + 실패 클래스가 늘지 않는다.""" from ipe.v1.nodes import AnthropicCoderLLM from .graph import build_v2_graph + from .nodes import AnthropicSampleExplainerLLM hidden, composition_mode, qa_kinds = config.mode_knobs(req.mode) return build_v2_graph( @@ -96,11 +100,15 @@ def _production_graph_factory(req: GenerateRequest) -> Any: golden_llms=[ AnthropicCoderLLM(m, parse_discipline=True) for m in _GOLDEN_MODELS ], - brute_llm=AnthropicCoderLLM(_BRUTE_MODEL, parse_discipline=True), + brute_llm=AnthropicCoderLLM( + _BRUTE_MODEL, parse_discipline=True, brute_mode=True + ), golden_origins=_GOLDEN_MODELS, with_test_suite=True, with_qa=req.with_qa, qa_kinds=qa_kinds, + sample_explainer_llm=AnthropicSampleExplainerLLM(), + with_sample_explanations=True, ) diff --git a/ipe/v2/config.py b/ipe/v2/config.py index 92f19e1..a3d56b0 100644 --- a/ipe/v2/config.py +++ b/ipe/v2/config.py @@ -19,18 +19,25 @@ # --------------------------------------------------------------------------- # # Phase 4 — P1/P2 생성 파이프라인 모드 (정확히 2개로 수렴) # -# P1: 타겟 고정·단일(합성X)·공개(비은닉)·QA 3종(leakage 제외 — 타겟 공개라 # +# P1: 타겟 고정·단일(합성X)·공개(비은닉)·QA 4종(leakage 제외 — 타겟 공개라 # # 유명문제 동형 게이트 부적합). # -# P2: 타겟 힌트·2+ 합성·은닉·QA 4종(leakage 포함). # +# P2: 타겟 힌트·2+ 합성·은닉·QA 5종(leakage 포함). # +# presentation(지문 품질, N10e)은 두 모드 공통 — 상용 저지 수준 작문 게이트. # # --------------------------------------------------------------------------- # PipelineMode = Literal["p1", "p2"] -QA_KINDS_P1: tuple[QAReviewerKind, ...] = ("ambiguity", "fairness", "difficulty") +QA_KINDS_P1: tuple[QAReviewerKind, ...] = ( + "ambiguity", + "fairness", + "difficulty", + "presentation", +) QA_KINDS_P2: tuple[QAReviewerKind, ...] = ( "ambiguity", "fairness", "leakage", "difficulty", + "presentation", ) @@ -60,7 +67,7 @@ def mode_knobs( # 모델명 (golden / brute) — 교체 시 여기 한 곳만 # # --------------------------------------------------------------------------- # GOLDEN_MODELS: tuple[str, ...] = ("claude-opus-4-8", "claude-sonnet-4-6") -BRUTE_MODEL = "claude-sonnet-4-6" # golden 과 distinct → 독립 differential +BRUTE_MODEL = "claude-sonnet-4-6" # == GOLDEN_MODELS[1] — 독립성은 brute_mode 프롬프트 격리로 확보 GOLDEN_MODELS_CLI_DEFAULT = ",".join(GOLDEN_MODELS) # argparse comma-sep default # 난이도 calibration 모델 (RFC R4 — 사후 난이도 판별). QA Sonnet 승급과 동일 논리: @@ -79,10 +86,15 @@ def mode_knobs( # API: 고정 상한 # # --------------------------------------------------------------------------- # RECURSION_PAD_BASE = 15 -RECURSION_PAD_SYNTHESIS = 12 +# synthesis tail — sample_explainer(W2B 예제 설명, 활성 시 sample_filler 뒤 +1 step) +# 포함 13 (기존 12 + 1). +RECURSION_PAD_SYNTHESIS = 13 RECURSION_PAD_SUITE = 6 +# QA 리뷰어 fan-out 은 병렬 1 superstep — 리뷰어 5종으로 늘어도 step 수 불변이라 +# pad 는 fan-out 크기에 민감하지 않다 (routeback 라운드 수에만 비례). RECURSION_PAD_QA = 14 -RECURSION_LIMIT_API = 90 +# API 고정 상한 — sample_explainer 활성(+1 step) 반영 91 (기존 90 + 1). +RECURSION_LIMIT_API = 91 # --------------------------------------------------------------------------- # # 토큰 단가 (USD per 1M tokens; input, output) — 비용 실측 정정(계약 §5, 5fb370f) # diff --git a/ipe/v2/difficulty.py b/ipe/v2/difficulty.py index 55d20e5..62d237b 100644 --- a/ipe/v2/difficulty.py +++ b/ipe/v2/difficulty.py @@ -45,7 +45,23 @@ - factors: 지배 알고리즘 / 시간복잡도 / 입력규모(n_max) / 핵심 자료구조. - calibration_anchors: 비교한 anchor id 목록 — **제공된 anchor id 중에서만**. -절대 척도를 환각하지 말고 anchor 대비 **상대 위치**로 판단하라. +티어 rubric (절대 프레임 — anchor 상대 비교와 **교차 적용**): +- Bronze: 단순 구현·사칙연산·조건문·반복문 수준. +- Silver: 기본 자료구조·정렬·완전탐색·기초 BFS/DFS·기초 수학(소수/GCD). +- Gold: 다익스트라·중급 DP(배낭/비트마스크)·이분탐색 응용·유니온파인드·세그먼트 트리 기초. +- Platinum: 고급 자료구조(레이지 세그, 머지소트 트리)·최대 유량·이분매칭·SCC·2-SAT·HLD. +- Diamond: 고도의 기법 조합(퍼시스턴트/링크컷 트리 등 여러 고급 기법의 비자명한 결합). + +추가 규율: +- reasoning 에는 **최소 3개**의 anchor id 를 인용해 상대 위치를 삼각측량하라 + (상회 anchor / 하회 anchor / 동급 anchor 형태가 이상적). +- **복잡도 × n_max 교차확인**: 예) N ≤ 1e5 인데 정해가 O(N^2) 이면 실제 요구 + 수준이 낮다는 신호 → 티어 하향 조정. +- 인접한 두 티어 사이에서 애매하면 **보수적으로 낮은 쪽** 티어를 선택하라. + +위 rubric 은 좌표계(사전 프레임)로만 쓰고, 최종 판정은 anchor 대비 **상대 위치**로 +내린다 — rubric 직관과 anchor 비교가 어긋나면 **anchor 쪽을 우선**하라 (절대 척도 +환각 방지). """ diff --git a/ipe/v2/generation/assembler.py b/ipe/v2/generation/assembler.py index 8461e6f..50dd5b3 100644 --- a/ipe/v2/generation/assembler.py +++ b/ipe/v2/generation/assembler.py @@ -8,6 +8,10 @@ golden 이 실행 못하는 입력(crash/timeout)은 **drop** — 입력 직렬화↔골든 파서 불일치 또는 골든 버그의 신호다. assembled 케이스 수 / pending 수 = 규약 정합 비율(step5 anchor). 전부 실패면 ValueError (형식 불일치 가능 — known item). + +**최소 생존율 게이트**: 생존율(filled/pending)이 ``_MIN_SURVIVAL_RATIO`` 미만이면 +카테고리별 drop 집계를 담아 ValueError — 특정 tier 만 조용히 전멸한 약체 채점셋이 +출하되는 것을 차단한다 (전멸(0개)만 잡던 기존 게이트의 강화, additive). """ from __future__ import annotations @@ -25,6 +29,11 @@ from ipe.sandbox.runner import RunResult from ipe.v1.verification._exec import CodeRunner +# 최소 생존율(filled/pending) — 미만이면 카테고리별 drop 집계와 함께 ValueError. +# 전멸(0개)만 잡던 게이트로는 특정 tier 전멸(예: large 만 전부 timeout)이 조용히 +# 통과해 약체 채점셋이 출하됐다 — 그 침묵 경로를 차단하는 임계값. +_MIN_SURVIVAL_RATIO = 0.7 + def assemble_suite( pending: TestSuite, @@ -37,10 +46,12 @@ def assemble_suite( ) -> TestSuite: """golden 을 각 입력에 실행 → expected 채운 assembled TestSuite. - 실행 OK 인 케이스만 expected 채워 포함(나머지 drop). 전부 실패면 ValueError. + 실행 OK 인 케이스만 expected 채워 포함(나머지 drop). 전부 실패면 ValueError, + 생존율 < ``_MIN_SURVIVAL_RATIO`` 면 카테고리별 drop 집계를 담은 ValueError. ``golden_origin`` 은 provenance(어느 golden 이 정답을 만들었나). """ filled: list[GeneratedTestCase] = [] + dropped_by_category: dict[str, int] = {} first_failure: RunResult | None = None first_failed_input: str | None = None for case in pending.cases: @@ -61,21 +72,46 @@ def assemble_suite( } ) ) - elif first_failure is None: - first_failure = result - first_failed_input = case.input_text - if not filled: - detail = "" - if first_failure is not None and first_failed_input is not None: - # 전부실패는 규약 불일치 신호 — 원인 분석 가능하게 첫 실패 증거 포함 - detail = ( - f" — 첫 실패: status={first_failure.status}" - f" stderr={first_failure.stderr[:200]!r}" - f" input_head={first_failed_input[:80]!r}" + else: + # (a) 카테고리별 drop 집계 — 어느 tier/edge 가 죽는지 진단·게이트 근거. + dropped_by_category[case.category] = ( + dropped_by_category.get(case.category, 0) + 1 ) + if first_failure is None: + first_failure = result + first_failed_input = case.input_text + # 실패 증거(첫 실패 status/stderr/입력 head) — 두 게이트 메시지 공용 진단. + detail = _first_failure_detail(first_failure, first_failed_input) + if not filled: + # 전부실패는 규약 불일치 신호 — 원인 분석 가능하게 첫 실패 증거 포함 msg = ( "assemble_suite: golden 이 생성 입력을 하나도 실행하지 못함 " f"(입력 직렬화↔골든 파서 불일치 가능){detail}" ) raise ValueError(msg) + survival = len(filled) / len(pending.cases) + if survival < _MIN_SURVIVAL_RATIO: + # (b) 최소 생존율 게이트 — 어떤 카테고리가 몇 개 죽었는지 담아 fail. + drops = ", ".join( + f"{cat}={n}" for cat, n in sorted(dropped_by_category.items()) + ) + msg = ( + f"assemble_suite: 생존율 {survival:.2f} < {_MIN_SURVIVAL_RATIO}" + f" ({len(filled)}/{len(pending.cases)}) — 카테고리별 drop: {drops}" + f" (조용한 약체 채점셋 출하 차단){detail}" + ) + raise ValueError(msg) return TestSuite(cases=tuple(filled), golden_origin=golden_origin) + + +def _first_failure_detail( + first_failure: RunResult | None, first_failed_input: str | None +) -> str: + """첫 실패 증거(status/stderr/입력 head) 문자열 — 게이트 ValueError 진단 공용.""" + if first_failure is None or first_failed_input is None: + return "" + return ( + f" — 첫 실패: status={first_failure.status}" + f" stderr={first_failure.stderr[:200]!r}" + f" input_head={first_failed_input[:80]!r}" + ) diff --git a/ipe/v2/generation/input_gen.py b/ipe/v2/generation/input_gen.py index 23761d0..19946e5 100644 --- a/ipe/v2/generation/input_gen.py +++ b/ipe/v2/generation/input_gen.py @@ -34,6 +34,31 @@ tier 적용: ScaleFamily.field_bounds(이름=필드명)는 스칼라의 **값**, sized 타입의 **크기**(graph 는 정점 수 V)를 그 tier 로 좁힌다. 원소/가중치 값은 io_schema 의 value_range. 참조 스칼라는 tier 를 보지 않는다(실제 크기에 바인딩). + +adversarial 아키타입 (상용 저지 수준 케이스 강도): edge bias 를 확장해 boundary/퇴화 외 +**구조 스트레스** 입력을 같은 결정론 메커니즘으로 생성한다 — +- graph: ``path_chain``(선형 사슬, 지름 최대) / ``star``(허브 차수 집중) / ``dense``· + ``cycle_heavy``(간선 상한 근접·사슬+추가 간선=사이클 다수) / ``duplicate_edges``(다중 + 간선 극단 — graph_shape.multi_edges 허용 시만) / ``equal_weights``·``extreme_weights`` + (모든 가중치 동일=tie 스트레스·상하한 혼재). +- sequence(int_array): ``sorted_asc``/``sorted_desc``/``all_equal``/``alternating``/ + ``single_element``/``extreme_values``. **sequence_shape 핀이 항상 우선** — 핀과 + 모순되는 아키타입(예: strictly_increasing 에 all_equal)은 derive 가 방출하지 않고, + 직렬화기도 방어적으로 핀을 위반하지 않는다(핀 승리). +- string: ``all_same_char``/``periodic``(짧은 주기 반복 — KMP류 스트레스). alphabet 핀 내. +방출 게이트 = ``_ADVERSARIAL_MIN_SIZE``: size 상한이 임계 미만인 소규모 스키마는 기존 +edge 집합만 유지(소형 입력은 small tier random 이 구조 공간을 사실상 전부 덮고 naive TLE +유도 불가 — 케이스 수 바운드 겸 기존 소형 suite 정책 보존). ``max_stress`` 는 max_size +와 별도의 상한 타격 케이스 1개를 더해 진짜 상한(size hi·최대 밀도) 케이스를 suite 에 +최소 2개 보장한다(naive TLE 유도). + +원소 총량 상한 이원화: max 계열(bias="max" — max_size/max_stress)만 +``_MAX_STRESS_ELEMENTS``(5×10^4), 그 외 전 케이스는 ``_MAX_ELEMENTS``(10^4) — 전 케이스 +1만 클램프는 O(N²) 오답이 시간 내 통과하는 complexity 분리 실패를 낳았고(지문 N≤10^5 +선언 vs 실데이터 1만), 반대로 전 케이스 5만은 패키지 크기·golden 실행 비용을 폭증시킨다. +아키타입 크기 하한: 최소 크기 전제가 있는 아키타입(duplicate_edges/periodic/alternating/ +extreme_values/all_equal/graph 구조 전부)은 크기 random 추첨이 1 을 뽑아도 size 상한이 +허용하면 하한 2 로 클램프 — 카테고리명↔입력 불일치(F18 동형) 구조 차단. """ from __future__ import annotations @@ -81,8 +106,93 @@ # 패키지/전송 실용성의 타협(≈수백 KB/입력); 조정은 이 상수만. _MAX_ELEMENTS = 10000 -# "disconnected" 는 graph 타입 전용 의미(두 컴포넌트) — 비-graph 필드에선 random 취급 -_Bias = Literal["random", "empty", "min", "max", "disconnected"] +# max 계열(bias="max" — max_size·max_stress) 전용 스트레스 상한 (기본 상한과 분리). +# 전 케이스 10^4 클램프는 지문이 N≤10^5 를 선언해도 실제 최대 테스트가 원소 1만 개라 +# O(N²) 오답이 시간 내 통과하는 complexity 분리 실패를 낳았다 — 상한 타격 케이스만 +# 5×10^4 로 올려 naive TLE 를 실측 유도한다(파이썬 정해가 2초 내 처리 가능한 수준·실측). +# max 계열은 suite 당 1~3개뿐이라 패키지 크기·golden 실행 비용 증가는 바운드됨. 나머지 +# 케이스(random tier·기타 아키타입)는 _MAX_ELEMENTS 유지 (패키지/실행 비용 보호). +_MAX_STRESS_ELEMENTS = 50_000 + +# "disconnected" 는 graph 타입 전용 의미(두 컴포넌트) — 비-graph 필드에선 random 취급. +# adversarial 아키타입 bias 들도 동형: 해당 타입 필드에서만 구조로 실현, 그 외 필드에선 +# random 취급(경계/범위 위반 불가 — _pick_* fallthrough). +_Bias = Literal[ + "random", + "empty", + "min", + "max", + "disconnected", + # graph 구조 아키타입 (weighted_edges/tree_edges) + "path_chain", + "star", + "dense", + "cycle_heavy", + "duplicate_edges", + "equal_weights", + "extreme_weights", + # sequence 아키타입 (int_array — sequence_shape 핀 우선) + "sorted_asc", + "sorted_desc", + "all_equal", + "alternating", + "single_element", + "extreme_values", + # string 아키타입 (alphabet 핀 내) + "all_same_char", + "periodic", +] + +# 아키타입 그룹 — _edge_bias 의 exact-name 매핑 + 직렬화기 dispatch 가 READ. +_GRAPH_ARCHETYPES: tuple[_Bias, ...] = ( + "path_chain", + "star", + "dense", + "cycle_heavy", + "duplicate_edges", + "equal_weights", + "extreme_weights", +) +_SEQUENCE_ARCHETYPES: tuple[_Bias, ...] = ( + "sorted_asc", + "sorted_desc", + "all_equal", + "alternating", + "single_element", + "extreme_values", +) +_STRING_ARCHETYPES: tuple[_Bias, ...] = ("all_same_char", "periodic") + +# adversarial 아키타입 방출 게이트 — size 상한이 이 값 미만인 소규모 스키마는 기존 edge +# 집합만 유지: 소형 입력은 small tier random 이 구조 공간을 사실상 전부 덮고 naive TLE +# 유도도 불가능해 아키타입이 이름만 늘린다(케이스 수 바운드 + 기존 소형 suite 정책 보존). +_ADVERSARIAL_MIN_SIZE = 10 + +# 크기 2 미만에서 카테고리명이 약속하는 구조가 **거짓**이 되는 아키타입 — duplicate_edges +# (V=1 은 다중 간선 불가)·periodic(길이 1 은 주기 없음)·alternating(교대 최소 2)· +# extreme_values(하한/상한 혼재)·all_equal(tie 는 원소 2+) + graph 구조 전부(V=1 은 간선 +# 0 — dense/cycle_heavy/사슬/스타/가중치 아키타입이 전부 공허). 크기 random 추첨이 1 을 +# 뽑으면 기본 경로 fallback 으로 카테고리명↔입력 불일치(F18 동형)가 났다 — size 상한이 +# 허용하면(≥2) 하한을 2 로 클램프해 **항상** 실현을 보장한다(상한 1 스키마는 클램프 불가 +# — derive 게이트(_ADVERSARIAL_MIN_SIZE)가 애초 방출하지 않으므로 방어 fallback 만 유지). +_ARCHETYPE_MIN_SIZE = 2 + + +def _element_cap(bias: _Bias) -> int: + """케이스별 유효 원소 총량 상한 — max 계열(bias=="max": max_size·max_stress 등 상한 + 타격 케이스)만 ``_MAX_STRESS_ELEMENTS``, 그 외 전부 ``_MAX_ELEMENTS`` (분리 원칙). + 상한 선택은 rng 를 소비하지 않는다 — 같은 seed → 같은 출력 (결정론 보존).""" + return _MAX_STRESS_ELEMENTS if bias == "max" else _MAX_ELEMENTS + + +def _archetype_size_floor(n: int, size_hi: int) -> int: + """아키타입 크기 하한 클램프 — 상한이 허용할 때만 ``_ARCHETYPE_MIN_SIZE`` 로 올린다. + + 호출측이 해당 타입에서 최소 크기 전제가 있는 bias 인지 판단해 부른다(비대상 필드의 + random 취급 불변). 클램프는 rng 를 소비하지 않아 결정론 보존.""" + if size_hi >= _ARCHETYPE_MIN_SIZE: + return max(n, _ARCHETYPE_MIN_SIZE) + return n def seed_from_run_id(run_id: str) -> int: @@ -336,6 +446,21 @@ def render_constraints(io_schema: IOSchema) -> list[ConstraintRange]: description=f"{f.name} 의 {label}", ) ) + if f.type == "weighted_edges": + # E(간선 수) 상한 — 생성기 실상한에서 코드 파생: backbone(V-1) + + # extra(_extra_edge_count ≤ V) = 2V-1 < 2V (duplicate_edges 포함 전 + # bias). E 상한이 constraints 에 없으면 solver 가 복잡도 설계를 못 해 + # QA ambiguity blocker (run v2-b4fd4625 실측). tree_edges 는 E=V-1 + # 이 input_format 트리 서술로 자명해 별도 행을 만들지 않는다. + out.append( + ConstraintRange( + name="E", + min_value=0, + max_value=2 * f.size_range.max_value, + symbolic_max="2V", + description=f"{f.name} 의 간선 수", + ) + ) if f.cols_range is not None: out.append( ConstraintRange( @@ -504,7 +629,9 @@ def derive_edge_cases(io_schema: IOSchema) -> tuple[EdgeCaseSpec, ...]: 각 이름은 ``_edge_bias`` 가 인식하는 bias 로 매핑되고 직렬화기가 그 입력을 실제로 만든다 — LLM 이 만들 수 없는 카테고리(self_loop·특정 위상 등)를 채점셋 이름에 남겨 형식 계약과 모순시키던 F18 reject 클래스를 구조적으로 제거(N=18 실측). 집합 = - {min_size/max_size 크기 경계, empty(안전시), disconnected(분리가능 그래프)}. + {min_size/max_size 크기 경계, empty(안전시), disconnected(분리가능 그래프)} + + adversarial 아키타입(``_derive_adversarial_edges`` — 규모 게이트·shape 핀 존중, + 상용 저지 케이스 강도). """ sized = [f for f in io_schema.inputs if _is_sized(f)] edges: list[EdgeCaseSpec] = [] @@ -523,9 +650,140 @@ def derive_edge_cases(io_schema: IOSchema) -> tuple[EdgeCaseSpec, ...]: edges.append( EdgeCaseSpec(name="disconnected", description="분리: 비연결 그래프(도달 불가)") ) + edges.extend(_derive_adversarial_edges(io_schema)) return tuple(edges) +# ---------- adversarial 아키타입 파생 (상용 저지 케이스 강도 — 순수 투영 확장) ---------- +# boundary/퇴화(위 집합) 위에 구조 스트레스 아키타입을 얹는다. F18 교훈 동일 적용: 각 +# 이름은 직렬화기가 **실제로 실현**하는 입력에만 붙는다 — shape 핀(sortedness/duplicates/ +# multi_edges)과 모순되는 아키타입은 그 스키마에 방출하지 않는다(카테고리명↔입력 정합). +# 비용: 추가 대형(상한) 케이스는 max_stress 1개뿐, 나머지는 random 크기(저렴). + + +def _adversarial_scale(field: IOFieldSpec) -> bool: + """adversarial 아키타입을 방출할 규모인가 — sized + size 상한 ≥ 임계.""" + return _is_sized(field) and _effective_size(field)[1] >= _ADVERSARIAL_MIN_SIZE + + +def _multi_edges_allowed(field: IOFieldSpec) -> bool: + """duplicate_edges 실현 가능 여부 — graph_shape 미핀(레거시 상수 True) 또는 허용 핀.""" + return field.graph_shape is None or field.graph_shape.multi_edges + + +def _value_spread(field: IOFieldSpec) -> bool: + """원소/가중치 값 범위가 실제로 벌어져 있는가 (equal/extreme 아키타입이 genuine).""" + lo, hi = _element_bounds(field) + return lo < hi + + +def _seq_unsorted(field: IOFieldSpec) -> bool: + """정렬 아키타입(asc/desc/alternating) 실현 가능 — 정렬 미핀(무정렬)일 때만. + 정렬 핀 배열엔 재배치 아키타입이 핀과 중복(asc)이거나 모순(desc/zigzag).""" + shape = field.sequence_shape + return shape is None or shape.sortedness == "unsorted" + + +def _seq_dups_allowed(field: IOFieldSpec) -> bool: + """중복값 아키타입(all_equal/extreme_values) 실현 가능 — distinct 핀이 없을 때만.""" + shape = field.sequence_shape + if shape is None: + return True + return shape.duplicates_allowed and shape.sortedness != "strictly_increasing" + + +def _graph_archetype_edges(graphs: list[IOFieldSpec]) -> list[EdgeCaseSpec]: + """graph 구조 아키타입 — 트리엔 chain/star 만(사이클·추가 간선 불가), weighted 엔 + 밀도/사이클/다중간선, 가중치 범위가 벌어진 필드가 있으면 tie/극단 가중치.""" + if not graphs: + return [] + out = [ + EdgeCaseSpec(name="path_chain", description="선형 사슬 — 지름 최대(깊이/전파 스트레스)"), + EdgeCaseSpec(name="star", description="스타 — 허브 정점 차수 집중"), + ] + weighted = [f for f in graphs if f.type == "weighted_edges"] + if weighted: + out.append(EdgeCaseSpec(name="dense", description="조밀 — 간선 수 상한 근접")) + out.append( + EdgeCaseSpec(name="cycle_heavy", description="사이클 다수 — 사슬 + 추가 간선") + ) + if any(_multi_edges_allowed(f) for f in weighted): + out.append( + EdgeCaseSpec(name="duplicate_edges", description="다중 간선 극단 — 같은 쌍 반복") + ) + if any(f.value_range is not None and _value_spread(f) for f in graphs): + out.append( + EdgeCaseSpec(name="equal_weights", description="모든 가중치 동일 — tie 스트레스") + ) + out.append( + EdgeCaseSpec(name="extreme_weights", description="가중치 하한/상한 혼재 — 극단값") + ) + return out + + +def _sequence_archetype_edges(arrays: list[IOFieldSpec]) -> list[EdgeCaseSpec]: + """sequence 아키타입 — sequence_shape 핀을 존중해 실현 가능한 것만 방출.""" + if not arrays: + return [] + out: list[EdgeCaseSpec] = [] + if any(_seq_unsorted(f) for f in arrays): + out.append(EdgeCaseSpec(name="sorted_asc", description="오름차순 정렬 배열 — 편향 정렬")) + out.append( + EdgeCaseSpec(name="sorted_desc", description="내림차순 정렬 배열 — 역정렬(최악 비교)") + ) + out.append( + EdgeCaseSpec( + name="alternating", description="지그재그(저-고 교대) — 인접 비교 스트레스" + ) + ) + if any(_seq_dups_allowed(f) for f in arrays): + out.append( + EdgeCaseSpec(name="all_equal", description="전 원소 동일 — tie/중복 스트레스") + ) + if any(_seq_dups_allowed(f) and _value_spread(f) for f in arrays): + out.append( + EdgeCaseSpec(name="extreme_values", description="값 하한/상한 혼재 — 극단값") + ) + if any(_effective_size(f)[0] <= 1 for f in arrays): + out.append( + EdgeCaseSpec(name="single_element", description="원소 1개 — 최소 비퇴화 입력") + ) + return out + + +def _string_archetype_edges(strings: list[IOFieldSpec]) -> list[EdgeCaseSpec]: + """string 아키타입 — alphabet 핀 내에서 항상 실현 가능.""" + if not strings: + return [] + return [ + EdgeCaseSpec(name="all_same_char", description="단일 문자 반복 — 최악 중복"), + EdgeCaseSpec(name="periodic", description="짧은 주기 반복 문자열 — 주기성(KMP류) 스트레스"), + ] + + +def _derive_adversarial_edges(io_schema: IOSchema) -> list[EdgeCaseSpec]: + """io_schema → adversarial 아키타입 edge 들 (게이트: _ADVERSARIAL_MIN_SIZE). + + max_stress 는 max_size 와 별도의 상한 타격 케이스(bias=max, 다른 rng 스트림) — + 진짜 상한(size hi·최대 밀도) 케이스를 suite 에 최소 2개 보장한다(naive TLE 유도). + 각 아키타입은 exact-name 으로 ``_edge_bias`` 에 매핑돼 직렬화기가 실현한다. + """ + adv = [f for f in io_schema.inputs if _adversarial_scale(f)] + if not adv: + return [] + out = [ + EdgeCaseSpec( + name="max_stress", description="상한 재타격: 최대 규모 2번째 케이스 (naive TLE 유도)" + ) + ] + out += _graph_archetype_edges( + [f for f in adv if f.type in ("weighted_edges", "tree_edges")] + ) + out += _sequence_archetype_edges([f for f in adv if f.type == "int_array"]) + out += _string_archetype_edges([f for f in adv if f.type == "string"]) + return out + + def derive_generator_contract(io_schema: IOSchema) -> GeneratorContract: """io_schema → GeneratorContract (scale tiers + 실현가능 edge), 순수 투영. @@ -592,21 +850,41 @@ def generate_inputs( io_schema: IOSchema, *, seed: int, + distinct_index_refs: bool = False, ) -> tuple[GeneratedTestCase, ...]: """contract + io_schema → 결정론 입력들 (expected=None pending). scale_families 각 tier 에서 ``case_count`` 개 + edge_cases 각 1 개. category 는 출처(tier/edge name). 같은 seed → 같은 결과. + + ``distinct_index_refs=True`` 면 같은 collection 을 가리키는 **index 참조 스칼라** + 들(s·t 류)이 대상 크기 ≥2 일 때 서로 다른 값을 갖도록 산술 회피한다 — spec_bridge + **샘플 전용** (작은 샘플 클램프에서 s==t 충돌이 잦아 '핵심 로직을 시연하는 샘플이 + 없다'는 QA blocker, run v2-54d68df4 실측). rng 추가 소비 없음 — 기본 False 경로는 + 채점셋 결정론을 byte-identical 로 보존한다 (채점셋은 s==t 퇴화 커버리지가 필요해 + 이 플래그를 쓰지 않는다). """ rng = random.Random(seed) cases: list[GeneratedTestCase] = [] for family in contract.scale_families: tier_bounds = {cr.name: cr for cr in family.field_bounds} for _ in range(family.case_count): - text = _serialize_inputs(io_schema, tier_bounds, rng, bias="random") + text = _serialize_inputs( + io_schema, + tier_bounds, + rng, + bias="random", + distinct_index_refs=distinct_index_refs, + ) cases.append(GeneratedTestCase(input_text=text, category=family.name)) for edge in contract.edge_cases: - text = _serialize_inputs(io_schema, {}, rng, bias=_edge_bias(edge.name)) + text = _serialize_inputs( + io_schema, + {}, + rng, + bias=_edge_bias(edge.name), + distinct_index_refs=distinct_index_refs, + ) cases.append(GeneratedTestCase(input_text=text, category=edge.name)) return tuple(cases) @@ -622,9 +900,13 @@ def _serialize_inputs( rng: random.Random, *, bias: _Bias, + distinct_index_refs: bool = False, ) -> str: """2-pass — 비참조 필드 먼저(실제 addressable 크기 기록) → 참조 스칼라를 그 크기에 바인딩. 참조 없는 schema 는 1-pass 와 동일 rng 순서(기존 출력 보존). 선언 순서로 join. + + ``distinct_index_refs`` 는 같은 collection 대상 index 참조들의 값 충돌을 **rng 추가 + 소비 없이** 산술 오프셋으로 회피한다 (``generate_inputs`` docstring 참조 — 샘플 전용). """ indexing = io_schema.indexing # F9 단일 진실원천 (정점/원소 참조 인덱싱 base) texts: dict[str, str] = {} @@ -640,9 +922,29 @@ def _serialize_inputs( texts[f.name] = text if size is not None: sizes[f.name] = size + used_refs: dict[str, set[int]] = {} for f in deferred: ref_size = sizes.get(f.references) if f.references is not None else None - texts[f.name] = _serialize_reference(ref_size, bias, rng, indexing=indexing) + text = _serialize_reference(ref_size, bias, rng, indexing=indexing) + if ( + distinct_index_refs + and f.references is not None + and f.reference_kind != "cardinality" + and ref_size is not None + and ref_size >= 2 + ): + used = used_refs.setdefault(f.references, set()) + val = int(text) + if val in used: + # 충돌 — 같은 [base, base+size-1] 범위 안에서 순환 오프셋 (rng 미소비). + lo, hi = indexing, indexing + ref_size - 1 + for _ in range(ref_size): + val = val + 1 if val < hi else lo + if val not in used: + break + text = str(val) + used.add(val) + texts[f.name] = text return "\n".join(texts[f.name] for f in io_schema.inputs) @@ -668,9 +970,11 @@ def _serialize_field( return f"{_pick_float(lo, hi, bias, rng):.4f}", None if t == "string": n = max(_pick_size(_size_bounds(field, tier_bound), bias, rng), _STRING_MIN_LEN) - n = min(n, _MAX_ELEMENTS) # 길이 캡 + if bias == "periodic": # 길이 1 은 주기 불가 — 하한 2 보장 (카테고리명↔입력 정합) + n = _archetype_size_floor(n, _size_bounds(field, tier_bound)[1]) + n = min(n, _element_cap(bias)) # 길이 캡 (max 계열은 스트레스 상한) pool = _string_alphabet(field.string_shape) - return "".join(rng.choice(pool) for _ in range(n)), n + return _string_text(n, pool, bias, rng), n if t == "int_array": return _serialize_int_array(field, tier_bound, rng, bias=bias) if t in ("int_matrix", "grid"): # grid = int_matrix 와 동일 canonical 규약 @@ -705,11 +1009,14 @@ def _serialize_int_array( bias: _Bias, ) -> tuple[str, int]: n = _pick_size(_size_bounds(field, tier_bound), bias, rng) + if bias in ("alternating", "extreme_values", "all_equal"): + # 교대/혼재/tie 는 원소 2+ 필요 — 크기 1 추첨 시 카테고리명이 거짓 (하한 2 보장). + n = _archetype_size_floor(n, _size_bounds(field, tier_bound)[1]) if n <= 0: return "0", 0 - n = min(n, _MAX_ELEMENTS) # 원소 총량 캡 (패키지 비대화/생성 OOM 차단) + n = min(n, _element_cap(bias)) # 원소 총량 캡 (max 계열은 스트레스 상한) lo, hi = _element_bounds(field) - values = _sequence_values(field.sequence_shape, n, lo, hi, rng) + values = _sequence_values(field.sequence_shape, n, lo, hi, rng, bias=bias) vals = " ".join(str(v) for v in values) return f"{len(values)}\n{vals}", len(values) # 크기 = 원소 개수 N (참조 바인딩 대상) @@ -720,6 +1027,8 @@ def _sequence_values( lo: int, hi: int, rng: random.Random, + *, + bias: _Bias = "random", ) -> list[int]: """int_array 원소값 — ``sequence_shape`` 의 sortedness/duplicates 를 honor (G1a). @@ -729,10 +1038,13 @@ def _sequence_values( 표본추출. 범위가 n 보다 좁으면 범위 크기로 캡 — 실현가능성(같은 값 없이 n 개를 못 담으면 담을 수 있는 만큼만). - 정렬 요구(non_decreasing/strictly_increasing): 추출 후 오름차순 정렬. + - adversarial 아키타입 bias 는 ``_archetype_sequence_values`` 로 분기 — 핀 승리. 정렬/distinct 가 켜진 경로는 byte-identical 대상이 아니므로(핀된 의도적 변주) rng 추출 방식이 달라도 무방 — 고정 seed 면 여전히 결정론. """ + if bias in _SEQUENCE_ARCHETYPES: + return _archetype_sequence_values(shape, n, lo, hi, rng, bias) if shape is None or (shape.sortedness == "unsorted" and shape.duplicates_allowed): return [rng.randint(lo, hi) for _ in range(n)] # byte-identical 경로 distinct = shape.sortedness == "strictly_increasing" or not shape.duplicates_allowed @@ -746,6 +1058,80 @@ def _sequence_values( return values +def _archetype_sequence_values( + shape: SequenceShape | None, + n: int, + lo: int, + hi: int, + rng: random.Random, + bias: _Bias, +) -> list[int]: + """sequence 아키타입 값 — **shape 핀이 항상 우선** (핀 위반 입력 생성 불가). + + - all_equal: 단일 값 반복 (tie/중복 스트레스). distinct 핀이면 기본 추출로 fallback + (방어 — derive 게이트가 애초에 방출 안 함). + - extreme_values: 하한/상한 혼재 (극단값 스트레스). distinct 핀·단일값 범위면 fallback. + - sorted_asc/sorted_desc/alternating(+위 fallback): 기본 추출(distinct 존중) 후 + **배열 순서만** 아키타입으로 재배치. 정렬 핀이면 재배치 없이 핀 정렬 유지(핀 승리 + — 예: non_decreasing 핀에 sorted_desc bias 가 와도 오름차순 방출). + - single_element: 크기는 ``_pick_size`` 가 1 로 고정 — 값은 기본 추출. + """ + distinct = shape is not None and ( + shape.sortedness == "strictly_increasing" or not shape.duplicates_allowed + ) + sortedness = shape.sortedness if shape is not None else "unsorted" + if bias == "all_equal" and not distinct: + return [rng.randint(lo, hi)] * n # 정렬 핀과도 정합 (동일값 = 비내림차) + if bias == "extreme_values" and not distinct and lo < hi: + values = [lo if rng.randint(0, 1) == 0 else hi for _ in range(n)] + if sortedness != "unsorted": + values.sort() + return values + if distinct: + span = hi - lo + 1 + values = sorted(rng.sample(range(lo, hi + 1), min(n, span))) + else: + values = sorted(rng.randint(lo, hi) for _ in range(n)) + if sortedness in ("non_decreasing", "strictly_increasing"): + return values # 정렬 핀 승리 — 재배치 금지 + if bias == "sorted_desc": + return values[::-1] + if bias == "alternating": + return _zigzag(values) + return values # sorted_asc + 나머지 fallback (오름차순 정렬 배열) + + +def _zigzag(values: list[int]) -> list[int]: + """정렬값 → 지그재그(저-고 교대) 재배치 — 인접 비교/단조 스택류 스트레스. + + distinct 입력이면 distinct 보존(재배치만). 다중집합 불변.""" + out: list[int] = [] + i, j = 0, len(values) - 1 + take_low = True + while i <= j: + if take_low: + out.append(values[i]) + i += 1 + else: + out.append(values[j]) + j -= 1 + take_low = not take_low + return out + + +def _string_text(n: int, pool: str, bias: _Bias, rng: random.Random) -> str: + """string 아키타입 — all_same_char(단일 문자 반복, 최악 중복)/periodic(주기 2~3 반복, + KMP류 주기성 스트레스). 그 외 bias 는 현행 uniform 추출(byte-identical). 문자는 항상 + ``pool``(string_shape.alphabet 핀) 안에서만 — 핀 위반 불가.""" + if bias == "all_same_char": + return rng.choice(pool) * n + if bias == "periodic" and n >= 2: + period = rng.randint(2, min(3, n)) + unit = "".join(rng.choice(pool) for _ in range(period)) + return (unit * (n // period + 1))[:n] + return "".join(rng.choice(pool) for _ in range(n)) + + def _string_alphabet(shape: StringShape | None) -> str: """string 필드 문자 풀 — ``string_shape.alphabet`` 을 honor (G2). None(미핀) 또는 lowercase 면 현 상수 ``_ALPHABET``(a-z) → byte-identical(같은 풀에서 같은 rng.choice). @@ -755,12 +1141,13 @@ def _string_alphabet(shape: StringShape | None) -> str: return _ALPHABETS[shape.alphabet] -def _cap_matrix(r: int, c: int) -> tuple[int, int]: - """행렬 원소 총량 R*C 를 _MAX_ELEMENTS 로 캡 (결정론, 큰 차원부터 축소).""" - r = min(r, _MAX_ELEMENTS) - c = min(c, _MAX_ELEMENTS) - if r * c > _MAX_ELEMENTS: - c = max(1, _MAX_ELEMENTS // r) +def _cap_matrix(r: int, c: int, cap: int) -> tuple[int, int]: + """행렬 원소 총량 R*C 를 ``cap`` 으로 캡 (결정론, 큰 차원부터 축소) — cap 은 + 케이스별 유효 상한(``_element_cap``: max 계열=스트레스 상한, 그 외=기본 상한).""" + r = min(r, cap) + c = min(c, cap) + if r * c > cap: + c = max(1, cap // r) return r, c @@ -778,7 +1165,7 @@ def _serialize_int_matrix( c = _pick_size(cols, bias, rng) if r <= 0 or c <= 0: return f"{max(r, 0)} {max(c, 0)}", max(r, 0) - r, c = _cap_matrix(r, c) # R*C 총량 캡 + r, c = _cap_matrix(r, c, _element_cap(bias)) # R*C 총량 캡 (max 계열은 스트레스 상한) lo, hi = _element_bounds(field) rows = "\n".join( " ".join(str(rng.randint(lo, hi)) for _ in range(c)) for _ in range(r) @@ -802,6 +1189,39 @@ def _edge_key(u: int, t: int, *, directed: bool) -> tuple[int, int]: return (u, t) if directed else (min(u, t), max(u, t)) +def _chain_edges(base: int, v: int) -> list[tuple[int, int]]: + """path_chain — 선형 사슬 (지름 최대·연결·단순·무사이클). v<=1 → 빈 목록.""" + return [(base + i, base + i + 1) for i in range(v - 1)] + + +def _star_edges(base: int, v: int) -> list[tuple[int, int]]: + """star — 허브(base) 집중 (차수 편중·연결·단순·무사이클). v<=1 → 빈 목록.""" + return [(base, base + i) for i in range(1, v)] + + +def _extra_edge_count(bias: _Bias, v: int, rng: random.Random) -> int: + """backbone 외 추가 간선 수 — min=0(트리 밀도), max/dense/cycle_heavy=v(조밀 상한), + 그 외=random (현행 byte-identical).""" + if bias == "min": + return 0 + if bias in ("max", "dense", "cycle_heavy"): + return v + return rng.randint(0, v) + + +def _edge_weight_values( + count: int, lo: int, hi: int, bias: _Bias, rng: random.Random +) -> list[int]: + """간선 가중치 — equal_weights=단일 값 반복(tie 스트레스), extreme_weights=하한/상한 + 혼재(극단값·우선순위 스트레스), 그 외=uniform (현행과 동일 rng 호출 순서 → + byte-identical). 값은 항상 value_range 안.""" + if bias == "equal_weights": + return [rng.randint(lo, hi)] * count + if bias == "extreme_weights": + return [lo if rng.randint(0, 1) == 0 else hi for _ in range(count)] + return [rng.randint(lo, hi) for _ in range(count)] + + def _graph_vertex_count( field: IOFieldSpec, tier_bound: ConstraintRange | None, @@ -841,16 +1261,36 @@ def _serialize_weighted_edges( vmin = max(_size_bounds(field, tier_bound)[0], 1) return f"{vmin} 0", vmin # V_min 정점, 간선 0 (헤더는 카운트라 indexing 무관) v = _graph_vertex_count(field, tier_bound, rng, bias) - v = min(v, _MAX_ELEMENTS // 2) # E ≤ 2V → 간선 총량 캡 + v = min(v, _element_cap(bias) // 2) # E ≤ 2V → 간선 총량 캡 (max 계열은 스트레스 상한) + if bias in _GRAPH_ARCHETYPES: + # 구조 아키타입은 간선 1+ 필요 (V=1 은 사슬/스타/조밀/다중간선/가중치 전부 공허) + # — 크기 1 추첨 시 카테고리명↔입력 불일치. size 상한이 허용하면 하한 2 보장. + v = _archetype_size_floor(v, _size_bounds(field, tier_bound)[1]) if bias == "disconnected" and not connected: v = max(v, 2) # 두 컴포넌트가 가능한 최소 half = (v + 1) // 2 edges = _backbone(base, base + half - 1, rng) + _backbone( base + half, base + v - 1, rng ) - else: # connected 면 disconnected bias 도 단일 컴포넌트(구조 사실 우선) + elif bias == "path_chain": + edges = _chain_edges(base, v) # 선형 사슬 — 지름 최대 (연결·단순) + elif bias == "star": + edges = _star_edges(base, v) # 허브 집중 — 차수 편중 (연결·단순) + elif bias == "duplicate_edges" and multi_edges and v >= 2: + # 다중 간선 극단 — backbone 간선을 v 회 재방출 (같은 쌍 반복). multi_edges + # 미허용이면 아래 기본 경로로 fallback (dedupe 가 중복을 걸러 단순 유지). + # v>=2 는 위 _archetype_size_floor 가 보장(size 상한≥2 시) — 이 가드는 상한 1 + # 스키마 방어 fallback 전용. edges = _backbone(base, base + v - 1, rng) - extra = 0 if bias == "min" else (v if bias == "max" else rng.randint(0, v)) + edges += [edges[rng.randrange(len(edges))] for _ in range(v)] + else: # connected 면 disconnected bias 도 단일 컴포넌트(구조 사실 우선) + # cycle_heavy 는 사슬 backbone — 추가 간선 하나마다 사이클 1개 이상 생긴다. + edges = ( + _chain_edges(base, v) + if bias == "cycle_heavy" + else _backbone(base, base + v - 1, rng) + ) + extra = _extra_edge_count(bias, v, rng) if v >= 2: seen = ( {_edge_key(u, t, directed=directed) for u, t in edges} @@ -872,7 +1312,8 @@ def _serialize_weighted_edges( seen.add(key) edges.append((u, t)) lo, hi = _element_bounds(field) # value_range = 가중치 - lines = [f"{u} {t} {rng.randint(lo, hi)}" for u, t in edges] + weights = _edge_weight_values(len(edges), lo, hi, bias, rng) + lines = [f"{u} {t} {w}" for (u, t), w in zip(edges, weights, strict=True)] return "\n".join([f"{v} {len(edges)}", *lines]), v @@ -888,7 +1329,8 @@ def _serialize_tree_edges( 트리는 정의상 연결·무사이클·단순 → graph_shape 의 connectivity/multi_edges/ self_loops 는 구조적으로 고정(직렬화기가 보장). 정점 번호 base = ``indexing``. - disconnected bias 는 크기 random 으로만 작용. 반환 크기 = V. + disconnected bias 는 크기 random 으로만 작용. path_chain/star 아키타입은 트리의 + 특수형(사슬 트리·스타 트리 — 깊이 최대/허브 집중)이라 그대로 실현. 반환 크기 = V. """ base = indexing if bias == "empty": @@ -896,13 +1338,22 @@ def _serialize_tree_edges( v = max(_size_bounds(field, tier_bound)[0], 1) else: v = _graph_vertex_count(field, tier_bound, rng, bias) - v = min(v, _MAX_ELEMENTS) # V-1 간선 + v = min(v, _element_cap(bias)) # V-1 간선 (max 계열은 스트레스 상한) + if bias in _GRAPH_ARCHETYPES: + # 사슬/스타 트리는 간선 1+ 필요 — 크기 1 추첨 시 카테고리명↔입력 불일치 방지. + v = _archetype_size_floor(v, _size_bounds(field, tier_bound)[1]) if v <= 1: return "1", 1 # 단일 정점 트리 (헤더는 카운트라 indexing 무관) - edges = _backbone(base, base + v - 1, rng) + if bias == "path_chain": + edges = _chain_edges(base, v) # 사슬 트리 — 깊이 최대 (재귀/전파 스트레스) + elif bias == "star": + edges = _star_edges(base, v) # 스타 트리 — 허브 차수 집중 + else: + edges = _backbone(base, base + v - 1, rng) if field.value_range is not None: lo, hi = _element_bounds(field) - lines = [f"{u} {t} {rng.randint(lo, hi)}" for u, t in edges] + weights = _edge_weight_values(len(edges), lo, hi, bias, rng) + lines = [f"{u} {t} {w}" for (u, t), w in zip(edges, weights, strict=True)] else: lines = [f"{u} {t}" for u, t in edges] return "\n".join([str(v), *lines]), v @@ -964,7 +1415,9 @@ def _pick_size(bounds: tuple[int, int], bias: _Bias, rng: random.Random) -> int: return lo if bias == "max": return hi - return rng.randint(lo, hi) + if bias == "single_element": + return min(max(lo, 1), hi) # 크기 1 — size_range 밖이면 경계로 클램프 (위반 불가) + return rng.randint(lo, hi) # 구조 아키타입 포함 — 크기는 전 범위 random def _bool_value(bias: _Bias, rng: random.Random) -> bool: @@ -976,8 +1429,16 @@ def _bool_value(bias: _Bias, rng: random.Random) -> bool: def _edge_bias(name: str) -> _Bias: - """edge 케이스 이름 → boundary 전략 (generic keyword 해석).""" + """edge 케이스 이름 → boundary 전략 (generic keyword 해석). + + adversarial 아키타입은 **exact-name 우선 매칭** — keyword 스캔보다 먼저 봐야 + 'single_element' 가 'single'→min 으로 오분류되지 않는다. derive 가 방출하는 + 이름과 1:1 (카테고리명↔입력 정합). max_stress 는 keyword 'stress'→max (상한 타격). + """ low = name.lower() + for archetype in (*_GRAPH_ARCHETYPES, *_SEQUENCE_ARCHETYPES, *_STRING_ARCHETYPES): + if low == archetype: + return archetype # graph 전용 의미가 가장 구체적 — 먼저 매칭 ('disconnected_large' 등 복합어 보호) if any(k in low for k in ("disconnect", "unreachable", "isolated")): return "disconnected" diff --git a/ipe/v2/graph.py b/ipe/v2/graph.py index 654abf9..eec892a 100644 --- a/ipe/v2/graph.py +++ b/ipe/v2/graph.py @@ -6,7 +6,8 @@ 노브 P1 (단일·공개) P2 (합성·은닉) hidden False True composition_mode "single" (composition 빈값) "composed" (≥1, 총 2개+) - qa_kinds (ambiguity,fairness,difficulty) +leakage (4종) + qa_kinds (ambiguity,fairness,difficulty, +leakage (5종) + presentation — 4종) seed_algorithm 고정 공개 타겟 힌트 기본 흐름 (always):: @@ -21,6 +22,8 @@ reconciler ─(채택)→ synth_bridge → sample_filler → edge_filler → executor ─(pass)→ suite/qa (sample+퇴화엣지 diff) (golden→expected 채움) └(fail)→ end_verification reconciler ─(reject)→ end_synthesis_rejected + ``with_sample_explanations=True`` 면 sample_filler → **sample_explainer**(BOJ 예제 + 설명 저작, Sonnet) → edge_filler 로 배선 (False 면 기존 경로 불변) ``with_test_suite=True`` (M4 풀 채점셋 — verification 통과 후):: @@ -74,6 +77,7 @@ FormalizerLLM, NarrativeLLM, QAReviewerLLM, + SampleExplainerLLM, StrategistLLM, make_edge_filler_node, make_faithfulness_node, @@ -83,6 +87,7 @@ make_narrative_node, make_qa_aggregator_node, make_qa_reviewer_node, + make_sample_explainer_node, make_sample_filler_node, make_spec_bridge_node, make_spec_patch_node, @@ -236,7 +241,10 @@ def build_v2_graph( "fairness", "leakage", "difficulty", + "presentation", ), + sample_explainer_llm: SampleExplainerLLM | None = None, + with_sample_explanations: bool = False, ) -> CompiledStateGraph: # type: ignore[type-arg] """v2 그래프 빌드. None dependency 는 production default(Anthropic/sandbox/verifier). @@ -246,8 +254,11 @@ def build_v2_graph( =True``(M4) 면 verification 통과 후 generator_designer→input_generator→ suite_assembler 로 풀 채점셋까지. ``with_qa=True``(M5) 면 suite 완성 후 ``qa_kinds`` 리뷰어 병렬 게이트 — 완성 패키지를 검토하므로 ``with_test_suite=True`` 필수. - ``qa_kinds`` = 돌릴 QA 관점(P1=ambiguity/fairness/difficulty 3종 / P2=+leakage 4종). + ``qa_kinds`` = 돌릴 QA 관점(P1=ambiguity/fairness/difficulty/presentation 4종 / + P2=+leakage 5종). ``qa_reviewer_llms`` 는 kind→LLM, 누락 kind 는 production Haiku. test 는 LLM mock 주입. + ``with_sample_explanations=True`` 면 sample_filler 뒤에 sample_explainer(BOJ 예제 + 설명 저작, Sonnet)를 배선 — ``sample_explainer_llm`` None 은 production Sonnet. """ if with_qa and not with_test_suite: msg = "with_qa=True 는 with_test_suite=True 필수 (완성 패키지를 검토)" @@ -342,6 +353,8 @@ def build_v2_graph( narrative_llm=narrative_llm, faithfulness_llm=faithfulness_llm, hidden=hidden, + sample_explainer_llm=sample_explainer_llm, + with_sample_explanations=with_sample_explanations, ) return builder.compile() @@ -365,10 +378,13 @@ def _wire_synthesis( "fairness", "leakage", "difficulty", + "presentation", ), narrative_llm: NarrativeLLM | None = None, faithfulness_llm: FaithfulnessLLM | None = None, hidden: bool = True, + sample_explainer_llm: SampleExplainerLLM | None = None, + with_sample_explanations: bool = False, ) -> None: """synthesis 서브그래프 — spec_bridge→designer→fan-out→reconcile→bridge→executor. @@ -376,6 +392,8 @@ def _wire_synthesis( ``with_test_suite=True`` 면 executor 통과 후 M4 채점셋 3 노드를 거쳐 end_success, ``with_qa=True`` 면 그 뒤 ``qa_kinds`` 병렬 게이트(M5)까지. ``narrative_llm``/ ``faithfulness_llm``/``hidden`` 은 QA back-route(B)의 revise 경로용 passthrough. + ``with_sample_explanations=True`` 면 sample_filler→sample_explainer→edge_filler + (BOJ 예제 설명 저작, synthesis 경로 +1 step), False 면 기존 경로 불변. """ if not golden_llms or brute_llm is None: msg = "synthesis 배선은 golden_llms(>=1) + brute_llm 필수" @@ -475,7 +493,19 @@ def _wire_synthesis( ), ) builder.add_edge("synth_bridge", "sample_filler") - builder.add_edge("sample_filler", "edge_filler") + if with_sample_explanations: + # sample_explainer — 완성 샘플(golden 확정 expected)에 BOJ 표준 '예제 설명' + # 저작 (W2B). sample_filler(expected 확정) 직후가 유일한 안전 저작 지점. + # LLM 예외 시 무변경 통과(장식적 품질 채널 — 노드 docstring) → 배선해도 + # 파이프라인 실패 클래스는 늘지 않는다. + builder.add_node( + "sample_explainer", + cast(Any, make_sample_explainer_node(sample_explainer_llm)), + ) + builder.add_edge("sample_filler", "sample_explainer") + builder.add_edge("sample_explainer", "edge_filler") + else: + builder.add_edge("sample_filler", "edge_filler") builder.add_edge("edge_filler", "executor") # 검증 통과 시: 채점셋 생성(M4) 또는 즉시 success pass_target = "generator_designer" if with_test_suite else "end_success" diff --git a/ipe/v2/main_v2.py b/ipe/v2/main_v2.py index afc2bd0..5eb1230 100644 --- a/ipe/v2/main_v2.py +++ b/ipe/v2/main_v2.py @@ -9,9 +9,10 @@ 두 모드 다 **full 파이프라인**(synthesis+verification+풀 채점셋+QA)을 태운다 — 차이는 모드 노브뿐(``config.mode_knobs``): -- ``--mode p1`` (단일·공개): composition 빈값·hidden=False·QA 3종(leakage 제외). - 타겟 알고리즘 고정 공개(토픽 드릴). -- ``--mode p2`` (합성·은닉, 기본): composition≥1·hidden=True·QA 4종. 타겟은 힌트(은닉). +- ``--mode p1`` (단일·공개): composition 빈값·hidden=False·QA 4종(presentation 포함, + leakage 제외). 타겟 알고리즘 고정 공개(토픽 드릴). +- ``--mode p2`` (합성·은닉, 기본): composition≥1·hidden=True·QA 5종(+leakage). + 타겟은 힌트(은닉). 흐름: strategist→formalizer→narrative→faithfulness → spec_bridge → designer → golden×K/brute fan-out → reconcile → executor → 풀 채점셋 → QA 게이트. golden 은 @@ -41,7 +42,8 @@ # recursion budget pad — 값은 config 단일 소스. 주석은 각 pad 의 근거(스테이지 tail). # 모델링 루프 1회 = narrative+faithfulness+regen(3 step). _RECURSION_PAD = config.RECURSION_PAD_BASE -# synthesis tail(spec_bridge→designer→fan-out→reconcile→bridge→executor) 단발 step. +# synthesis tail(spec_bridge→designer→fan-out→reconcile→bridge→sample_filler→ +# [sample_explainer]→edge_filler→executor) 단발 step — 예제 설명 활성 시 +1 포함. _SYNTHESIS_RECURSION_PAD = config.RECURSION_PAD_SYNTHESIS # suite tail(generator_designer→input_generator→suite_assembler) 단발 step. _SUITE_RECURSION_PAD = config.RECURSION_PAD_SUITE @@ -91,11 +93,16 @@ def _build_parser() -> argparse.ArgumentParser: "--mode", choices=["p1", "p2"], default="p2", - help="생성 모드 (p1=단일·공개·QA3 / p2=합성·은닉·QA4, default: p2)", + help="생성 모드 (p1=단일·공개·QA4 / p2=합성·은닉·QA5, default: p2)", ) parser.add_argument( "--verbose", action="store_true", help="blueprint/narrative 전체 출력" ) + parser.add_argument( + "--no-sample-explanations", + action="store_true", + help="예제 설명(sample_explainer, BOJ '예제 설명') 저작 끄기 (기본: 켜짐)", + ) parser.add_argument( "--golden-models", default=DEFAULT_GOLDEN_MODELS, @@ -225,11 +232,16 @@ def _build_default_graph(args: argparse.Namespace) -> Any: golden_llms=[ AnthropicCoderLLM(m, parse_discipline=True) for m in golden_models ], - brute_llm=AnthropicCoderLLM(args.brute_model, parse_discipline=True), + brute_llm=AnthropicCoderLLM( + args.brute_model, parse_discipline=True, brute_mode=True + ), golden_origins=golden_models, with_test_suite=True, with_qa=True, qa_kinds=qa_kinds, + # 예제 설명(W2B) — 기본 켜짐(--no-sample-explanations 로 옵트아웃). LLM None + # 은 graph→node factory 의 production Sonnet 배선. + with_sample_explanations=not args.no_sample_explanations, # suite/qa 노드 LLM 은 None → graph 의 production default(Opus/Haiku) 배선 ) diff --git a/ipe/v2/nodes/__init__.py b/ipe/v2/nodes/__init__.py index 9b2749c..c75c5b7 100644 --- a/ipe/v2/nodes/__init__.py +++ b/ipe/v2/nodes/__init__.py @@ -33,6 +33,11 @@ make_qa_reviewer_node, ) from .reconciler import make_v2_reconciler_node +from .sample_explainer import ( + AnthropicSampleExplainerLLM, + SampleExplainerLLM, + make_sample_explainer_node, +) from .sample_filler import make_sample_filler_node from .spec_bridge import make_spec_bridge_node from .spec_patch import make_spec_patch_node @@ -49,11 +54,13 @@ "AnthropicFormalizerLLM", "AnthropicNarrativeLLM", "AnthropicQAReviewerLLM", + "AnthropicSampleExplainerLLM", "AnthropicStrategistLLM", "FaithfulnessLLM", "FormalizerLLM", "NarrativeLLM", "QAReviewerLLM", + "SampleExplainerLLM", "StrategistLLM", "make_edge_filler_node", "make_faithfulness_node", @@ -63,6 +70,7 @@ "make_narrative_node", "make_qa_aggregator_node", "make_qa_reviewer_node", + "make_sample_explainer_node", "make_sample_filler_node", "make_spec_bridge_node", "make_spec_patch_node", diff --git a/ipe/v2/nodes/faithfulness.py b/ipe/v2/nodes/faithfulness.py index 8f762c3..0390f44 100644 --- a/ipe/v2/nodes/faithfulness.py +++ b/ipe/v2/nodes/faithfulness.py @@ -57,6 +57,13 @@ directed=단방향인데 지문이 '양방향으로 오갈 수 있다'; self-loop 없음인데 '자기 자신으로 가는 길'을 서술). 구조 사실은 형식 계약의 일부 — 누락(은닉)은 OK 이나 **모순 서술은 reject**. 구조 사실 섹션이 없으면(비-graph) 해당 없음. +- **계약에 없는 입력 '사용 규칙'을 창작하는 것도 distortion 이다**: 지문이 주어진 + 입력의 일부만 유효하다는 필터/자격 조건을 지어내면 왜곡이다 (예: '정점 번호가 + 소인수 관계인 전환만 허용', '위상 순서와 일치할 때만 사용 가능' — io_schema/ + 불변식 어디에도 없는 규칙). 그런 규칙은 solver 가 입력만으로 검증·재현할 수 없어 + 계약의 출력 의미와 지문이 갈라진다 (run v2-54d68df4 실측: QA 5관점 전멸의 근원). + 도메인 서사로 입력의 *의미를 장식*하는 것은 은닉(OK)이나, 입력 데이터의 **유효성 + 자체를 바꾸는 규칙 창작**은 reject. - 모호하지만 모순은 아닌 경우(은닉으로 인한 일반화)는 faithful=true. """ diff --git a/ipe/v2/nodes/formalizer.py b/ipe/v2/nodes/formalizer.py index 09ec36e..64d7f5f 100644 --- a/ipe/v2/nodes/formalizer.py +++ b/ipe/v2/nodes/formalizer.py @@ -136,7 +136,12 @@ 다른 실패 사유(예: 예산 초과)와 같은 값인지 구분되는지, 임계값·예산이 0 또는 범위 하한일 때의 해석, **다중 간선**(같은 쌍에 여러 간선)·중복 값의 허용 여부와 처리 방식. 이런 케이스의 출력이 미정의면 solver 가 해석을 강요받는 모호 문제가 - 되어 QA ambiguity 게이트에서 reject 된다. + 되어 QA ambiguity 게이트에서 reject 된다. **복수의 퇴화 규칙이 한 입력에서 겹칠 + 수 있으면 우선순위까지 결정**한다 — 예: '시작==끝' 이면서 동시에 '도달 불가/전부 + 차단' 인 입력이 constraints 상 가능하면, 어느 규칙이 우선 적용되는지(예: "시작==끝 + 이면 다른 조건과 무관하게 0")를 edge_case_semantics 에 명시한다. 우선순위가 + 미정의면 겹침 입력에서 구현마다 답이 갈려 QA ambiguity 게이트에서 reject 된다 + (run v2-b4fd4625 실측). - **답 유일성(tie-break) 결정**: 출력이 선택·정렬·최적화를 거치는 문제는, 동률(tie — 같은 정렬 키를 가진 원소, 복수의 최적해, 동일 max/min 값을 주는 서로 다른 후보)이 존재할 때도 **답이 입력으로부터 유일하게 결정**되어야 한다. 두 방법 중 하나로 @@ -146,6 +151,14 @@ answer_uniqueness — 정렬 키 동률 시 2차/3차 기준, 동률 최적해 중 무엇을 답으로 할지). 답이 유일하게 결정되지 않으면 solver 가 해석을 강요받아 QA ambiguity 게이트에서 reject 된다. +- **제약 크기 설계 (채점 데이터 생성 상한 정합)**: size_range 상한은 실제 채점 데이터 + 생성 상한 **이내**에서 의도한 복잡도 분리가 성립하게 잡는다. 생성기의 단일 입력 원소 + 총량(배열 원소 N·그래프 간선 E·행렬 R×C·문자열 길이) 상한은 최대 5×10^4 (스트레스 + 케이스 생성 상한) — 이보다 큰 상한을 선언해도 실데이터는 그 크기로 만들어지지 않는다. + 의도 복잡도별 권장 상한: O(N²) 정해 의도면 N ≤ 2,000 수준, O(N log N)/O(N) 정해 + 의도면 N ≤ 50,000 수준, graph 는 V ≤ 25,000·E ≤ 50,000 수준. **10^5 이상을 선언하지 + 말 것** — 생성기가 그 크기를 만들 수 없어 지문 제약과 실제 채점 데이터가 어긋나고, + 지문이 과장한 만큼 naive 오답이 시간 내 통과해 문제의 변별력(품질)이 무너진다. """ diff --git a/ipe/v2/nodes/generator_designer.py b/ipe/v2/nodes/generator_designer.py index 50488d8..6f77f0b 100644 --- a/ipe/v2/nodes/generator_designer.py +++ b/ipe/v2/nodes/generator_designer.py @@ -8,6 +8,17 @@ ``derive_generator_contract`` 가 sized 필드 size_range 를 log-spaced scale tier 로, 실현가능 퇴화를 edge case 로 결정론 파생한다. 효과 = Opus 호출 1 삭제 + unrealizable fail_qa → 0. 계약이 io_schema 의 함수이므로 다른 투영과 모순 불가(consistency-by-construction). + +suite 구성 계획 (상용 저지 케이스 강도): boundary/퇴화(min_size/max_size/empty/ +disconnected — 기존 유지) 위에 adversarial 아키타입을 각 1개 이상 얹는다 — graph: +path_chain/star/dense/cycle_heavy/duplicate_edges/equal_weights/extreme_weights, +sequence: sorted_asc/sorted_desc/all_equal/alternating/single_element/extreme_values, +string: all_same_char/periodic. ``max_stress`` 가 max_size 와 별도로 상한을 한 번 더 +때려 진짜 상한(naive TLE 유도) 케이스를 최소 2개 보장한다. 전부 shape 핀 +(sortedness/duplicates/multi_edges/connectivity) 존중 + 규모 게이트 +(``input_gen._ADVERSARIAL_MIN_SIZE`` — 소규모 스키마는 기존 집합 그대로, 대형 케이스 +추가 비용은 max_stress 1개뿐). 파생 로직은 ``input_gen._derive_adversarial_edges`` +단일 소스 (이 노드는 여전히 순수 위임 — F18 카테고리명↔입력 정합 불변). """ from __future__ import annotations diff --git a/ipe/v2/nodes/narrative.py b/ipe/v2/nodes/narrative.py index a81c57f..b591ad5 100644 --- a/ipe/v2/nodes/narrative.py +++ b/ipe/v2/nodes/narrative.py @@ -88,6 +88,25 @@ 길이 자체이므로 어떤 체인을 고르든 같다", "정렬 기준이 같은 거래는 접수 순서를 따른다"). 동률 처리가 지문에 없으면 solver 가 해석을 강요받아 QA ambiguity 게이트에서 reject 된다. + +작문 품질 (BOJ 상용 수준) — 위 규율을 전부 지킨 위에서, 상용 저지(BOJ 등)에 그대로 +출제해도 손색없는 완성도를 요구한다: +- 구성: scenario 는 2~4문단으로 짜임새 있게. ① 상황과 등장 주체를 도입하는 + 1~3문장(구체적이되 간결 — 배경 설명으로 늘어지지 말 것), ② 주어지는 데이터와 + 규칙이 그 상황에서 무엇을 **의미**하는지 서술, ③ 무엇을 구해야 하는지 명확히 묻는 + 마무리 — 어떤 값을 계산/출력해야 하는지 **의미 수준으로** 분명하게 끝맺는다 + (입출력 '형식' 서술 금지 규율은 그대로 — 줄 구성·순서·인덱싱은 여전히 쓰지 않는다). +- 문체: 자연스러운 한국어 문어체(…한다/…이다)로 쓴다. 번역투·기계 문체 금지 — + '당신은 ~할 필요가 있다', '~하는 것이 요구된다' 류. 같은 대상은 처음 붙인 명칭 + 하나로 끝까지 일관되게 부른다(용어 드리프트 금지 — '창고'로 시작했으면 중간에 + '물류 거점'으로 바꿔 부르지 않는다). 불필요한 수식어·감탄·과장은 쓰지 않는다. +- 분량: 대략 300~800자. 정보 없는 잡설로 늘리지도, 전보문처럼 압축해 의미 서술을 + 누락하지도 말 것 — 모든 문장이 상황·데이터 의미·요구 중 하나에 기여해야 한다. +- 몰입: 도메인의 구체적 상황(작은 이야기)이 데이터의 의미와 자연스럽게 맞물리게 + 한다 — 도메인이 장식이 아니라, 입력·규칙·출력이 그 상황에서 당연하게 읽혀야 한다. + 단 도메인 전문지식 없이 이해 가능해야 한다(일반 상식 수준의 용어만 사용). +- 제목: 간결한 명사구(2~6어절)로 시나리오의 핵심 소재를 담는다. 해법·알고리즘 힌트 + 금지는 기존 규율 그대로 — 상황을 정확히 가리키는 좋은 제목이 위장의 일부다. """ @@ -111,7 +130,13 @@ - **풀이 방법('어떻게 푸는지') 서술 금지**: 무엇을 구하는지만 쓰고 알고리즘·자료구조·전략은 쓰지 않는다. - output_invariants 의 경계/퇴화 케이스 의미(있으면)는 서술한다 — 없는 케이스는 지어내지 - 말 것(초급은 대개 경계 의미가 단순하거나 없다).""" + 말 것(초급은 대개 경계 의미가 단순하거나 없다). + +작문 품질 (입문 상용 수준): 간결·정확하며 모호함이 없어야 한다. 1~2문단으로, 초등 +교과서처럼 평이하고 짧은 문장을 쓴다 — 한 문장에 하나의 사실만. 자연스러운 한국어 +문어체(…한다/…구하라)로 쓰고 번역투('당신은 ~할 필요가 있다' 류)는 금지. 같은 대상은 +같은 명칭으로 일관되게 부르고, 불필요한 수식어 없이 무엇이 주어지고 무엇을 구하는지만 +정확히 서술한다.""" # back-route(B) 재진입 시 QA findings 렌더 바운드 — 지적 해소 방향 재작성을 유도하되 diff --git a/ipe/v2/nodes/qa_reviewer.py b/ipe/v2/nodes/qa_reviewer.py index cb56eed..b427d6e 100644 --- a/ipe/v2/nodes/qa_reviewer.py +++ b/ipe/v2/nodes/qa_reviewer.py @@ -1,8 +1,9 @@ -"""qa_reviewer 노드 — 문제 패키지 4관점 병렬 QA (M5 step2, RFC N10a-d). +"""qa_reviewer 노드 — 문제 패키지 5관점 병렬 QA (M5 step2, RFC N10a-e). -suite 까지 완성된 패키지(narrative+spec+test_suite)를 모호성/공정성/유출/난이도 -4 관점의 리뷰어가 **병렬 fan-out** 으로 검토한다. 공용 factory 1개 + kind 별 -관점 헌장(charter) — 모델은 전부 Haiku (RFC 비용 관찰: QA=저가 tier). +suite 까지 완성된 패키지(narrative+spec+test_suite)를 모호성/공정성/유출/난이도/ +지문품질(presentation) 5 관점의 리뷰어가 **병렬 fan-out** 으로 검토한다. 공용 +factory 1개 + kind 별 관점 헌장(charter) — 모델은 전부 Haiku (RFC 비용 관찰: +QA=저가 tier). - 병렬 규율(M0/M2): 노드는 **partial dict** ``{"qa_reviews": [review]}`` 반환 — ``qa_reviews`` reducer 채널에 누적 (dedup 멱등). @@ -34,12 +35,16 @@ "ambiguity": ( "모호성 — 지문과 입출력 형식이 **유일하게 해석**되는가. 미정의 동작, " "모호한 경계 조건(빈 입력/동률/중복), 불명확한 출력 형식(공백·순서·반올림)을 " - "찾는다." + "찾는다. 체크리스트: (1) 동률(tie)·중복 원소·퇴화 입력의 출력 의미가 지문에 " + "**결정**돼 있는가, (2) 출력 형식의 해석이 유일한가(구분자·순서·정밀도), " + "(3) 제약(constraints)과 지문 본문의 수치가 서로 모순되지 않는가." ), "fairness": ( "공정성 — solver 가 지문과 형식 계약만으로 풀 수 있는가. 지문에 없는 숨은 " "전제, 도메인 사전지식 요구, 불공정한 함정을 찾는다. 단 **알고리즘 은닉 " - "자체는 의도된 설계** — 결함으로 지적하지 말 것." + "자체는 의도된 설계** — 결함으로 지적하지 말 것. 추가 점검: 지문의 시나리오가 " + "**도메인 전문지식·문화 특수 지식**(특정 업계 관행, 지역·문화권 한정 상식 등)을 " + "지문 설명 없이 전제하면 결함이다." ), "leakage": ( "유출 — 이 문제가 유명 문제(온라인 저지/교재의 고전 인스턴스)와 사실상 " @@ -53,6 +58,18 @@ "(예: 입력 무관 상수 출력), 명세상 불가능한 요구. 난이도 측정/calibration 은 " "범위 밖." ), + # N10e presentation — 상용 저지(BOJ) 수준 작문 게이트. 다른 kind 가 문체를 못 + # 보게 막는 규율(시스템 프롬프트)의 보완 — 문체·구성은 이 관점의 전담이다. + "presentation": ( + "지문 품질 — 상용 저지(BOJ) 수준의 작문인가. 자연스러운 한국어 문어체인지, " + "번역투/기계문체는 없는지, 용어 일관성(같은 대상을 다른 명칭으로 부르지 " + "않는지), 문단 구성(도입→규칙→질문)이 서 있는지, 시나리오와 질문이 논리적으로 " + "연결되는지, 정보 없는 잡설이나 과도한 압축은 없는지를 본다. blocker 는 " + "**작문 자체가 무너져 전달이 실패하는 수준**(성립하지 않는 문장, 용어 붕괴, " + "구성 파탄)만 — '해석이 여러 가지로 갈리는가'(중의성)는 ambiguity 관점의 " + "전담이므로 이 관점에서 blocker 로 다루지 않는다. 문체 개선 여지는 " + "warning/info 로 남긴다." + ), } # 초급(is_basic) 문제 전용 완화 difficulty charter — 난이도-agnostic 원칙을 코드화. @@ -66,6 +83,27 @@ "통과시킨다. 난이도 측정/calibration 은 범위 밖." ) +# 초급(is_basic) 문제 전용 완화 presentation charter — narrative 의 초급 트랙은 +# **의도적으로** 1~2문단 교과서체(입문 상용 수준)를 쓰고, abstract 도메인 센티널이면 +# 시나리오 없이 맨 수식/IO 로 직접 서술한다. 표준 charter 의 '문단 구성(도입→규칙→질문)'· +# '시나리오-질문 연결'·'과도한 압축' 요구가 이 의도된 형식을 결함으로 오탈락시키는 것을 +# 차단 (_DIFFICULTY_CHARTER_EASY 와 같은 false-reject 방지 규율의 presentation 판). +_PRESENTATION_CHARTER_EASY = ( + "지문 품질 (초급 문제) — 이 문제는 **입문자용 기초 문제**로, 짧은 1~2문단·시나리오 " + "없는 맨 수식/IO 직접 서술이 **의도된 형식**이다. 간결함·시나리오 부재·도입 문단 " + "부재는 **결함이 아니다** — 그 이유로 막지 말 것. 자연스러운 한국어 문어체인지, " + "번역투/기계문체는 없는지, 용어 일관성(같은 대상을 다른 명칭으로 부르지 않는지)만 " + "본다. **의미 전달·해석을 해치는 결함만 blocker**, 문체 개선 여지는 warning/info 로 " + "남긴다." +) + +# 초급(is_basic) 완화 charter 를 갖는 kind 의 dispatch 테이블 — 여기 없는 kind 는 +# 표준 charter 그대로. 완화는 false-reject 방지 목적에 한정 (표준 charter 불변). +_EASY_CHARTERS: dict[QAReviewerKind, str] = { + "difficulty": _DIFFICULTY_CHARTER_EASY, + "presentation": _PRESENTATION_CHARTER_EASY, +} + _SYSTEM_PROMPT_TEMPLATE = """\ 당신은 코딩테스트 문제 패키지의 QA 리뷰어다. 당신의 관점: {charter} @@ -82,6 +120,20 @@ """ +# 프롬프트 크기 바운드 상수 — 샘플/채점셋 노출은 리뷰 정밀도를 올리지만 (특히 +# ambiguity 의 동률·중복 판단), 대형 케이스를 그대로 넣으면 토큰 폭발 → 절단. +_MAX_SAMPLES = 3 # 노출 샘플 수 상한 (기존 2 → 3: 경계 케이스 판독 근거 확대) +_SAMPLE_TEXT_LIMIT = 300 # 샘플 in/expected 필드당 문자 상한 (초과분 절단 표기) +_LARGE_CASE_RATIO = 0.5 # '대형 케이스' 판정: 최대 입력 크기 대비 비율 + + +def _clip(text: str, limit: int = _SAMPLE_TEXT_LIMIT) -> str: + """프롬프트 노출용 절단 — 초과분은 '…(+N자)' 로 표기해 크기만 전달.""" + if len(text) <= limit: + return text + return f"{text[:limit]}…(+{len(text) - limit}자)" + + def _build_user_prompt(state: V2State) -> str: bp = state.blueprint spec = state.spec @@ -92,9 +144,15 @@ def _build_user_prompt(state: V2State) -> str: raise ValueError(msg) categories = Counter(c.category for c in suite.cases) samples = "\n".join( - f" in={tc.input_text!r} expected={tc.expected_output!r}" - for tc in spec.sample_testcases[:2] + f" in={_clip(tc.input_text)!r} expected={_clip(tc.expected_output)!r}" + for tc in spec.sample_testcases[:_MAX_SAMPLES] ) + # 최대 크기 케이스 요약 (입력 원문은 미노출 — 크기 신호만): 채점셋이 제약 상한 + # 근처를 실제로 커버하는지(스트레스 케이스 존재 여부/개수) 리뷰 근거 제공. + sizes = [len(c.input_text) for c in suite.cases] + max_size = max(sizes) + biggest = suite.cases[sizes.index(max_size)] + large_count = sum(1 for s in sizes if s >= max_size * _LARGE_CASE_RATIO) hidden = ( [ f"reduction_core (숨은 알고리즘): {bp.reduction_core.value}", @@ -119,10 +177,15 @@ def _build_user_prompt(state: V2State) -> str: ", ".join(format_constraint(c) for c in spec.constraints) or "(미명시)" ), - f"samples (앞 2개):\n{samples}", + f"samples (앞 {_MAX_SAMPLES}개):\n{samples}", "", - "[채점셋 요약]", + "[채점셋 상세]", f"케이스 {len(suite.cases)}개, 카테고리 분포: {dict(categories)}", + ( + f"최대 입력 크기: {max_size}자 (category={biggest.category}), " + f"대형 케이스(최대의 {int(_LARGE_CASE_RATIO * 100)}%+ 크기) " + f"{large_count}개" + ), ] ) @@ -151,11 +214,10 @@ def __init__( self._chain = (prompt | llm.with_structured_output(QAReview)).with_retry( stop_after_attempt=5, wait_exponential_jitter=True ) - # difficulty kind 전용 초급 완화 charter 체인 — review 시 is_basic 이면 사용. - # 다른 kind 는 표준 charter 그대로(동일 체인, 미사용) → byte-identical. - easy_charter = ( - _DIFFICULTY_CHARTER_EASY if kind == "difficulty" else _CHARTERS[kind] - ) + # 완화 charter 보유 kind(difficulty/presentation) 전용 초급 체인 — review 시 + # is_basic 이면 사용. 그 외 kind 는 표준 charter 그대로(동일 체인, 미사용) + # → byte-identical. + easy_charter = _EASY_CHARTERS.get(kind, _CHARTERS[kind]) easy_system = _SYSTEM_PROMPT_TEMPLATE.format(charter=easy_charter, kind=kind) easy_prompt = ChatPromptTemplate.from_messages( [("system", easy_system), ("user", "{user}")] @@ -165,10 +227,11 @@ def __init__( ).with_retry(stop_after_attempt=5, wait_exponential_jitter=True) def review(self, state: V2State, *, kind: QAReviewerKind) -> QAReview: - # 초급(is_basic) + difficulty kind 만 완화 charter — '쉽다'고 막지 않는다. + # 초급(is_basic) + 완화 charter 보유 kind 만 완화 체인 — difficulty 는 + # '쉽다'고, presentation 은 '간결/시나리오 없다'고 막지 않는다. chain = ( self._chain_easy - if self._kind == "difficulty" and is_basic(state.seed_algorithm) + if self._kind in _EASY_CHARTERS and is_basic(state.seed_algorithm) else self._chain ) result = chain.invoke({"user": _build_user_prompt(state)}) @@ -186,7 +249,7 @@ def make_qa_reviewer_node( *, kind: QAReviewerKind, ) -> Callable[[V2State], dict[str, Any]]: - """factory — kind 관점 QA 리뷰어 노드 (N10a-d 공용). test 는 mock 주입. + """factory — kind 관점 QA 리뷰어 노드 (N10a-e 공용). test 는 mock 주입. 병렬 fan-out 노드라 partial dict 만 반환 (reducer 채널 누적). review.kind 는 node 의 kind 로 강제 스탬프 — LLM 이 관점 라벨을 못 바꾼다. diff --git a/ipe/v2/nodes/sample_explainer.py b/ipe/v2/nodes/sample_explainer.py new file mode 100644 index 0000000..f381cd1 --- /dev/null +++ b/ipe/v2/nodes/sample_explainer.py @@ -0,0 +1,166 @@ +"""sample_explainer 노드 — 완성 샘플(입력/정답)에 BOJ 표준 '예제 설명' 저작. + +LLM: Sonnet 4.6, temperature 0.2 (사실 서술 — 창작 발산 금지). ``SampleTestCase. +description`` 은 스키마에 있으나 채우는 노드가 없어 영구 빈 문자열이던 채널 — +sample_filler 가 canonical golden 실행으로 expected 를 채운 **직후**가 유일한 안전 +저작 지점이다(그 전엔 정답이 없어 설명이 지어낸 사실이 된다). 각 sample 의 +``description`` 을 model_copy 로 채운다 (input/expected 불변). + +은닉 규율(narrative 와 동일 계열): 설명은 '이 입력에서 왜 이 출력이 나오는지' +**인스턴스 수준** 사실만 — 알고리즘/자료구조/해법 전략 언급 금지. + +**의도적 완화 설계**: 예제 설명은 장식적 품질 채널이라, LLM 예외 시 raise 하는 +기존 노드들(narrative/formalizer 등)과 달리 **state 무변경 반환**한다 — 검증까지 +통과한 패키지를 설명 저작 실패로 죽이면 안 된다. 개수 불일치도 min 길이까지만 +채우고 나머지 sample 은 원본 유지(방어적 — 파이프라인 생존 우선). +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Protocol + +from ipe.v1.schema import SampleExplanations + +from ..state import V2State + +SAMPLE_EXPLAINER_MODEL = "claude-sonnet-4-6" +SAMPLE_EXPLAINER_TEMPERATURE = 0.2 # 인스턴스 사실 서술 (발산 금지, Faithfulness 와 동급) + +# user prompt 필드별 절단 바운드 — 대형 scenario/sample 로 프롬프트 폭주 방지. +_FIELD_HEAD = 500 + + +_SYSTEM_PROMPT = """\ +당신은 코딩 문제의 '예제 설명' author 다. 완성된 샘플(입력/정답)마다 1~3문장의 +한국어 설명을 쓴다 — 이 입력에서 왜 이 출력이 나오는지 **그 인스턴스 수준으로만** +설명한다 (BOJ 예제 설명 표준). + +typed SampleExplanations (구조화된 tool call) 로 반환 — explanations 는 샘플 순서 +그대로, **개수는 샘플 개수와 정확히 같게**. + +규율: +- **알고리즘/자료구조/일반 해법 전략 언급 절대 금지** (은닉 규율): '최단 경로를 + 다익스트라로 구한다' 같은 해법 처방을 쓰지 말 것 — '1→3→4 경로의 총 비용 7이 + 가장 작다'처럼 **그 인스턴스의 사실만** 서술한다. 일반화된 풀이 절차·기법 + 이름·자료구조는 어떤 형태로도 드러내지 않는다. +- **입력 형식 재서술 금지**: 줄 구성/순서/개수('첫째 줄의', '두 번째 수는'), + 인덱싱 규약 언급 금지 — 값의 **의미**만 쓴다 (형식의 진실원천은 입력 형식 + 섹션이다). +- 지문(scenario)의 도메인 용어를 **그대로** 사용해 일관성을 유지한다 — 지문이 + '창고'라 부르면 설명도 '창고'(용어 드리프트 금지). +- 퇴화 샘플(도달 불가/시작==끝/해 없음 등)은 그 **의미**를 명시한다 + (예: '도달할 수 없으므로 -1 이다', '출발지와 목적지가 같아 비용은 0 이다'). +- **확실하지 않은 사실을 지어내지 말 것** — 왜 그 출력이 나오는지 확신이 없으면 + 해당 샘플 설명은 결과 서술만으로 짧게 끝낸다 (틀린 근거가 빈 근거보다 나쁘다). +""" + + +def _head(text: str, limit: int = _FIELD_HEAD) -> str: + """prompt 렌더용 절단 — 바운드 초과분은 말줄임 (필드별 폭주 방지).""" + return text if len(text) <= limit else text[:limit] + "…" + + +def _build_user_prompt(state: V2State) -> str: + spec = state.spec + narrative = state.narrative + if spec is None or narrative is None: + msg = "sample_explainer requires state.spec + state.narrative" + raise ValueError(msg) + parts = [ + "[지문 scenario — 도메인 용어 일관성 참고용 컨텍스트]", + _head(narrative.scenario), + ] + bp = state.blueprint + if bp is not None and bp.output_invariants: + invariants = [f"{iv.kind}: {iv.description}" for iv in bp.output_invariants] + parts.extend(["", f"output_invariants: {invariants}"]) + parts.extend( + [ + "", + f"샘플 {len(spec.sample_testcases)}개 — explanations 도 정확히 이 개수로.", + ] + ) + for i, sample in enumerate(spec.sample_testcases): + parts.extend( + [ + "", + f"[샘플 {i}]", + f"input_text: {_head(sample.input_text)}", + f"expected_output: {_head(sample.expected_output)}", + ] + ) + return "\n".join(parts) + + +class SampleExplainerLLM(Protocol): + """sample_explainer 의 LLM dependency. test 가 mock 주입.""" + + def explain(self, state: V2State) -> SampleExplanations: ... + + +class AnthropicSampleExplainerLLM: + """production impl — Sonnet + structured output. lazy import (test 는 mock).""" + + def __init__(self, model: str = SAMPLE_EXPLAINER_MODEL) -> None: + from langchain_anthropic import ChatAnthropic + from langchain_core.prompts import ChatPromptTemplate + + llm = ChatAnthropic(model_name=model, timeout=60, stop=None) + prompt = ChatPromptTemplate.from_messages( + [("system", _SYSTEM_PROMPT), ("user", "{user}")] + ) + self._chain = ( + prompt | llm.with_structured_output(SampleExplanations) + ).with_retry(stop_after_attempt=5, wait_exponential_jitter=True) + + def explain(self, state: V2State) -> SampleExplanations: + result = self._chain.invoke({"user": _build_user_prompt(state)}) + if not isinstance(result, SampleExplanations): + msg = ( + f"with_structured_output 가 {type(result).__name__} 반환 — " + "SampleExplanations 기대" + ) + raise TypeError(msg) + return result + + +def make_sample_explainer_node( + llm: SampleExplainerLLM | None = None, +) -> Callable[[V2State], V2State]: + """factory — 완성 샘플에 예제 설명 저작, sample.description 을 model_copy 로 채움. + + sample_filler(expected 확정) 후·edge_filler 전 배선 전제. spec/narrative 없으면 + 방어적 no-op. **LLM 예외는 광범위 캐치 후 state 무변경 반환** — 예제 설명은 + 장식적 품질 채널이라 파이프라인을 죽이지 않는다(기존 노드들의 raise 정책과 + 다른 의도적 선택, 모듈 docstring 참조). 개수 불일치 시 min 길이까지만 채움. + """ + resolved_llm: SampleExplainerLLM = ( + llm if llm is not None else AnthropicSampleExplainerLLM() + ) + + def node(state: V2State) -> V2State: + spec = state.spec + if spec is None or state.narrative is None: + return state # 상류 미완(방어적) — no-op + try: + result = resolved_llm.explain(state) + except Exception: # noqa: BLE001 — 의도적 광범위 캐치 (아래 이유) + # 예제 설명은 장식적 품질 채널: 여기까지 온 state 는 golden 합의 + + # 검증 경로를 통과한 자산이라, 설명 저작 실패(LLM 장애/파싱 실패/ + # retry 소진)로 전체 파이프라인을 죽이는 것이 설명 없는 출하보다 + # 훨씬 나쁘다. state 무변경 반환 — description 은 빈 문자열 유지. + return state + # 개수 불일치 시 min 길이까지만 채움 (zip 이 짧은 쪽에서 멈춤) — 나머지 + # sample 은 원본 유지(길이 보존, ProblemSpec min 3 제약 안전). + filled = [ + sample.model_copy(update={"description": explanation}) + for sample, explanation in zip( + spec.sample_testcases, result.explanations, strict=False + ) + ] + filled.extend(spec.sample_testcases[len(filled) :]) + new_spec = spec.model_copy(update={"sample_testcases": filled}) + return state.model_copy(update={"spec": new_spec}) + + return node diff --git a/ipe/v2/nodes/spec_bridge.py b/ipe/v2/nodes/spec_bridge.py index 1803f87..9e3028c 100644 --- a/ipe/v2/nodes/spec_bridge.py +++ b/ipe/v2/nodes/spec_bridge.py @@ -40,12 +40,14 @@ GeneratorContract, IOContract, IOSchema, + OutputInvariant, ProblemSpec, SampleTestCase, ScaleFamily, ) from ..generation.input_gen import ( + derive_degenerate_inputs, generate_inputs, render_constraints, render_input_format, @@ -62,6 +64,15 @@ _SAMPLE_SIZE_MAX = 5 _SAMPLE_VALUE_MAX = 20 +# 퇴화 의미(도달 불가→-1 등)를 결정해 둔 invariant kind 의 표식 — formalizer 가 +# "edge_case_semantics" 류로 저작한다(LLM 저작 kind 라 접두 변형 허용, substring 매칭). +_EDGE_INVARIANT_MARKER = "edge_case" + + +def _has_edge_semantics(invariants: tuple[OutputInvariant, ...]) -> bool: + """output_invariants 에 edge_case_semantics 류 invariant 가 있나 (순수 판정).""" + return any(_EDGE_INVARIANT_MARKER in inv.kind for inv in invariants) + def _clamp_value_range(cr: ConstraintRange | None) -> ConstraintRange | None: """value_range 를 작은 창으로 clamp (부호 보존). 빈/단일값이면 그대로 유지.""" @@ -88,7 +99,12 @@ def _sample_io_schema(io_schema: IOSchema) -> IOSchema: return io_schema.model_copy(update={"inputs": clamped_fields}) -def _generate_sample_inputs(io_schema: IOSchema, run_id: str) -> list[str]: +def _generate_sample_inputs( + io_schema: IOSchema, + run_id: str, + *, + output_invariants: tuple[OutputInvariant, ...] = (), +) -> list[str]: """io_schema 에서 결정적·형식정합·소규모 샘플 입력 생성 (A: 샘플-too-short 해소). composition 으로 io_schema 필드가 늘면 LLM 저작 input 은 토큰이 모자라 골든 파서가 @@ -96,6 +112,13 @@ def _generate_sample_inputs(io_schema: IOSchema, run_id: str) -> list[str]: input_gen 직렬화(=골든이 받는 파서 #2 의 짝)로 만들어 필드 수·헤더가 항상 일치하게 한다. 크기·값 모두 작게 clamp(가독성+큰 값 골든 IndexError 방지). expected 는 하류 sample_filler 가 golden 실행으로 채운다. + + **경계 샘플 1개 보장** (BOJ 표준 — 지문의 퇴화 의미를 샘플이 시연): + ``output_invariants`` 에 edge_case_semantics 류 invariant 가 있고 backbone 파생 + 퇴화 입력(``derive_degenerate_inputs``)이 비어있지 않으면 **마지막 샘플 1개**를 그 + 첫 퇴화 입력으로 교체한다(개수 불변). '도달 불가 → -1' 같은 지문 처방이 샘플로 + 실증돼 solver 가 퇴화 해석을 추측하지 않는다. 순수성(LLM 0) 유지 — 둘 다 IR 의 + 결정론 함수. """ schema = _sample_io_schema(io_schema) bounds = tuple( @@ -114,8 +137,20 @@ def _generate_sample_inputs(io_schema: IOSchema, run_id: str) -> list[str]: ScaleFamily(name="sample", case_count=_SAMPLE_COUNT, field_bounds=bounds), ) ) - cases = generate_inputs(contract, schema, seed=seed_from_run_id(run_id)) - return [c.input_text for c in cases] + # distinct_index_refs — 샘플은 s==t 충돌을 회피해 핵심 로직(경로 탐색 등)을 실제로 + # 시연한다 (전 샘플 s==t 로 '항상 0 출력' trivial 해석이 가능하던 QA blocker, + # run v2-54d68df4 실측). 퇴화 의미 시연은 아래 degenerate 교체 샘플이 전담. + cases = generate_inputs( + contract, schema, seed=seed_from_run_id(run_id), distinct_index_refs=True + ) + texts = [c.input_text for c in cases] + if _has_edge_semantics(output_invariants): + # 원본 io_schema 로 파생 — 퇴화 입력은 이미 최소 규모(min/unreachable)라 clamp + # 불필요 + reconcile/edge_filler 가 쓰는 것과 동일 입력(고정 seed, IR 함수). + degenerate = derive_degenerate_inputs(io_schema) + if degenerate: + texts[-1] = degenerate[0][1] # (name, input_text, rationale) 의 input_text + return texts def make_spec_bridge_node() -> Callable[[V2State], V2State]: @@ -151,7 +186,13 @@ def node(state: V2State) -> V2State: # sample_filler 가 canonical golden 실행으로 채운다(LLM 손계산 expected 차단). sample_testcases=[ SampleTestCase(input_text=text, expected_output="") - for text in _generate_sample_inputs(bp.io_schema, state.run_id) + for text in _generate_sample_inputs( + bp.io_schema, + state.run_id, + # 경계 샘플 1개 보장 — edge_case_semantics 류 invariant 가 있으면 + # 마지막 샘플을 backbone 파생 퇴화 입력으로 교체 (BOJ 표준). + output_invariants=bp.output_invariants, + ) ], ) return state.model_copy(update={"spec": spec}) diff --git a/ipe/v2/nodes/strategist.py b/ipe/v2/nodes/strategist.py index 337d826..bf320a6 100644 --- a/ipe/v2/nodes/strategist.py +++ b/ipe/v2/nodes/strategist.py @@ -68,6 +68,9 @@ def _composition_palette(run_id: str, reduction_core: TargetAlgorithm) -> list[s # 위장 도메인 후보 pool — 알고리즘과 무관하게 시나리오를 호스팅할 수 있는 distinct 한 # 현실 도메인들. 'finance' 도 한 후보로 포함(banishing 아닌 분산 — over-correction 회피). +# 21→42 확장(지문 품질): 스토리 친화적(작은 이야기가 데이터 의미와 자연스럽게 맞물리는) +# 도메인만 추가 — pool 이 넓을수록 run 간 도메인 반복이 줄어 고전 동형 인식(leakage)과 +# 지문 매너리즘이 함께 분산된다. 팔레트 크기(_DOMAIN_PALETTE_SIZE)·sha256 회전은 불변. _DOMAIN_POOL: tuple[str, ...] = ( "logistics", "healthcare", @@ -90,6 +93,27 @@ def _composition_palette(run_id: str, reduction_core: TargetAlgorithm) -> list[s "media-streaming", "disaster-response", "finance", + "archaeology", + "sports-analytics", + "music-production", + "library-science", + "meteorology", + "aviation", + "marine-biology", + "film-production", + "culinary", + "textile", + "mining", + "water-management", + "postal-service", + "museum-curation", + "theme-park", + "publishing", + "brewing", + "beekeeping", + "waste-management", + "event-planning", + "wildlife-conservation", ) diff --git a/ipe/v2/nodes/suite_assembler.py b/ipe/v2/nodes/suite_assembler.py index 2ee7766..c0c6c99 100644 --- a/ipe/v2/nodes/suite_assembler.py +++ b/ipe/v2/nodes/suite_assembler.py @@ -5,6 +5,12 @@ 각 입력에 golden 실행 → expected 채운 assembled TestSuite 로 교체. golden_origin 은 reconciliation.adopted_origin(provenance). +**resolved_edges 편입** (Phase 5a 후속): edge_filler 가 canonical golden 실행으로 +expected 를 이미 채운 ``state.resolved_edges``(min/unreachable 등 검증된 퇴화 입력)를 +``category="edge_resolved:"`` 케이스로 채점셋에 병합한다 — meta 로만 나가고 +채점셋에 빠지던 정답-검증 퇴화 케이스의 편입. 동일 ``input_text`` 가 이미 있으면 +skip(중복 없음), expected 미채움(pending) 엣지는 편입하지 않는다(진단 보존). + runner None 이면 production sandbox(pick_runner) — executor 와 동일. """ @@ -13,10 +19,13 @@ from collections.abc import Callable from typing import TYPE_CHECKING +from ipe.v1.schema import GeneratedTestCase, TestSuite + from ..generation.assembler import assemble_suite from ..state import V2State if TYPE_CHECKING: + from ipe.v1.schema import ResolvedEdgeCase from ipe.v1.verification._exec import CodeRunner @@ -42,11 +51,41 @@ def node(state: V2State) -> V2State: assembled = assemble_suite( suite, attempt.code, runner=resolved_runner, golden_origin=origin ) - return state.model_copy(update={"test_suite": assembled}) + merged = _merge_resolved_edges(assembled, state.resolved_edges) + return state.model_copy(update={"test_suite": merged}) return node +def _merge_resolved_edges( + suite: TestSuite, resolved_edges: tuple[ResolvedEdgeCase, ...] +) -> TestSuite: + """golden-검증된 resolved_edges 를 채점셋에 중복 없이 병합 (순수 함수). + + expected_output 이 채워진(=edge_filler 가 canonical golden 으로 정의한) 엣지만 + ``category="edge_resolved:"`` 케이스로 덧붙인다. 동일 ``input_text`` 가 suite + 에(또는 앞선 엣지에) 이미 있으면 skip — 같은 퇴화 입력이 contract edge_cases 로도 + 생성될 수 있어 이중 채점을 막는다. 편입분 golden_elapsed_ms 는 None(퇴화 입력은 + 소규모라 TL 산정 max 에 기여하지 않음 — api 집계는 None 필터). + """ + seen = {c.input_text for c in suite.cases} + extra: list[GeneratedTestCase] = [] + for edge in resolved_edges: + if edge.expected_output is None or edge.input_text in seen: + continue # pending(미정의) 엣지/중복 입력은 편입하지 않음 + seen.add(edge.input_text) + extra.append( + GeneratedTestCase( + input_text=edge.input_text, + category=f"edge_resolved:{edge.name}", + expected_output=edge.expected_output, + ) + ) + if not extra: + return suite + return suite.model_copy(update={"cases": suite.cases + tuple(extra)}) + + def _default_runner() -> CodeRunner: from ipe.sandbox.selector import pick_runner diff --git a/tests/test_calibration.py b/tests/test_calibration.py index c272d81..5ebe7d8 100644 --- a/tests/test_calibration.py +++ b/tests/test_calibration.py @@ -7,15 +7,19 @@ from __future__ import annotations import json +import re from pathlib import Path from ipe.calibration import ANCHORS_PATH, load_anchors +# BOJ 티어 라벨 규격 — DifficultyReport.label 검증과 동일 어휘. +_LABEL_RE = re.compile(r"^(Bronze|Silver|Gold|Platinum|Diamond|Ruby) (I|II|III|IV|V)$") + def test_default_anchors_load() -> None: - """프로젝트 default anchors.json은 충분한 수(현재 20개, Bronze~Platinum)의 anchor를 포함.""" + """프로젝트 default anchors.json은 충분한 수(현재 44개, Bronze~Diamond)의 anchor를 포함.""" anchors = load_anchors() - assert len(anchors) >= 12 # 확장된 calibration set (RFC R4) — truncation 가드 + assert len(anchors) >= 40 # 확장된 calibration set (RFC R4) — truncation 가드 # 모든 entry가 (id, label, summary, factors) 4개 필드를 보유 for a in anchors: assert "id" in a and isinstance(a["id"], str) @@ -24,6 +28,50 @@ def test_default_anchors_load() -> None: assert "factors" in a and isinstance(a["factors"], dict) +def test_default_anchor_ids_unique_and_well_formed() -> None: + """anchor id 는 전역 유일 + ``bj_<번호>_<등급>`` 규격 (인용 필터의 전제).""" + anchors = load_anchors() + ids = [a["id"] for a in anchors] + assert len(ids) == len(set(ids)) # 중복 id → 인용 교집합 필터 왜곡 + pattern = re.compile(r"bj_\d+_(bronze|silver|gold|platinum|diamond|ruby)[1-5]") + for aid in ids: + assert pattern.fullmatch(aid), aid + + +def test_default_anchor_labels_valid_tier_format() -> None: + """label 은 반드시 '<티어> <로마숫자>' 형식 — LLM 출력 label 과 동일 어휘.""" + for a in load_anchors(): + assert _LABEL_RE.fullmatch(a["label"]), a["label"] + + +def test_default_anchor_factors_shape() -> None: + """factors 는 {algorithm, n_max, complexity, data_structures} 고정 스키마.""" + for a in load_anchors(): + f = a["factors"] + assert isinstance(f["algorithm"], str) and f["algorithm"] + assert f["n_max"] is None or isinstance(f["n_max"], int) + assert isinstance(f["complexity"], str) and f["complexity"].startswith("O(") + assert isinstance(f["data_structures"], list) + assert all(isinstance(d, str) for d in f["data_structures"]) + + +def test_default_anchor_tier_coverage_balanced() -> None: + """티어 분포가 균형(Bronze~Diamond 전 티어 커버) — 편중 anchor set 회귀 가드. + + 기존 20개 set 는 Bronze 4 / Silver 6 / Gold 9 / Platinum 1 / Diamond 0 으로 + 상위 티어 판별이 불가능했다. 확장 set 의 티어별 최소 하한을 고정한다. + """ + counts: dict[str, int] = {} + for a in load_anchors(): + tier = a["label"].split(" ")[0] + counts[tier] = counts.get(tier, 0) + 1 + assert counts.get("Bronze", 0) >= 6 + assert counts.get("Silver", 0) >= 8 + assert counts.get("Gold", 0) >= 10 + assert counts.get("Platinum", 0) >= 8 + assert counts.get("Diamond", 0) >= 3 + + def test_default_anchors_path_exists() -> None: """기본 anchors.json 경로는 패키지 안에 존재.""" assert ANCHORS_PATH.exists() diff --git a/tests/v1/nodes/test_coder.py b/tests/v1/nodes/test_coder.py index 09604d3..53ce7b6 100644 --- a/tests/v1/nodes/test_coder.py +++ b/tests/v1/nodes/test_coder.py @@ -5,6 +5,8 @@ import pytest from ipe.v1.nodes.coder import ( + _BRUTE_SYSTEM_PROMPT, + _PARSE_DISCIPLINE, _SYSTEM_PROMPT, _build_user_prompt, _coder_system_prompt, @@ -242,6 +244,71 @@ def gen(corrective: str | None) -> SolutionAttempt: assert calls[1] is not None # 2차에 교정지시 전달 +# ---------- W2A: brute 검증자 독립화 — brute_mode 프롬프트 격리 ---------- +# P2(합성·은닉)는 symbolic verifier off → reconcile 합의가 유일한 정답성 게이트. +# brute 가 golden 과 동일 prompt + 동일 pseudocode 를 소비하면 설계 오해 시 전 후보가 +# 같은 오답에 합의(오출하). brute_mode 는 design 라인을 프롬프트에서 제외해 독립 +# differential 을 복원하되, spec/샘플/preamble(파싱 규율)은 동일 유지한다. + + +def test_brute_mode_prompt_excludes_design_lines() -> None: + """brute_mode=True 는 algorithm_name/complexity_target/pseudocode/invariants 제외.""" + prompt = _build_user_prompt(_state_with_spec_design(), brute_mode=True) + assert "algorithm: Dijkstra" not in prompt # design.algorithm_name + assert "pseudocode" not in prompt + assert "dist[s]=0; pq; relax." not in prompt # design.pseudocode 본문 + assert "complexity_target" not in prompt + assert "required invariants" not in prompt + assert "non_negative_distance" not in prompt # invariant kind + + +def test_brute_mode_prompt_keeps_spec_samples_and_preamble() -> None: + """brute_mode 도 spec/샘플/preamble 은 동일 렌더 — reconcile 합의(동일 입력 소비) 전제.""" + spec = _spec().model_copy( + update={"input_parser_code": "PARSER_PREAMBLE_MARKER_xyz"} + ) + state = _state_with_spec_design().model_copy(update={"spec": spec}) + prompt = _build_user_prompt(state, brute_mode=True) + assert "problem title: t" in prompt + assert "io_contract.input_format: V E s t..." in prompt + assert "sample testcases" in prompt + assert "2 1 0 1" in prompt # 샘플 입력 렌더 + assert "PARSER_PREAMBLE_MARKER_xyz" in prompt + assert "입력 파싱 preamble (필수" in prompt + + +def test_brute_mode_default_off_renders_design_unchanged() -> None: + """기본 off — 골든 경로 프롬프트 무회귀 (design 라인 그대로).""" + state = _state_with_spec_design() + assert _build_user_prompt(state) == _build_user_prompt(state, brute_mode=False) + prompt = _build_user_prompt(state) + assert "algorithm: Dijkstra" in prompt + assert "pseudocode: dist[s]=0; pq; relax." in prompt + assert "required invariants" in prompt + + +def test_brute_system_prompt_isolated_but_keeps_parse_discipline() -> None: + """brute_mode system prompt = 검산자 base + _PARSE_DISCIPLINE append. + + preamble 파싱 규율은 reconcile 합의의 전제 — brute 에서도 제거 금지.""" + brute = _coder_system_prompt(True, brute_mode=True) + assert brute == _BRUTE_SYSTEM_PROMPT + _PARSE_DISCIPLINE + assert "검산자" in brute + assert "읽지 말고 따르지도 말 것" in brute + assert "단일 진실원천" in brute # _PARSE_DISCIPLINE 유지 + assert not brute.startswith(_SYSTEM_PROMPT) + # discipline off 시에도 base 는 검산자 prompt + assert _coder_system_prompt(False, brute_mode=True) == _BRUTE_SYSTEM_PROMPT + + +def test_brute_system_prompt_keeps_output_contract() -> None: + """기존 요구사항(code/language/iteration/lessons 형식) 유지 — 구조화 출력 무회귀.""" + assert "code: 완전한 python solution" in _BRUTE_SYSTEM_PROMPT + assert '- language: "python"' in _BRUTE_SYSTEM_PROMPT + assert "iteration: 입력으로 받은 iteration 값" in _BRUTE_SYSTEM_PROMPT + assert "signature dedup 의무" in _BRUTE_SYSTEM_PROMPT + + def test_coerce_compliance_best_effort_after_max() -> None: """끝까지 미준수면 마지막 출력을 best-effort 로 반환 (현행 동작=무회귀).""" calls: list[str | None] = [] diff --git a/tests/v2/nodes/test_generator_designer.py b/tests/v2/nodes/test_generator_designer.py index 59abbc1..7766ae3 100644 --- a/tests/v2/nodes/test_generator_designer.py +++ b/tests/v2/nodes/test_generator_designer.py @@ -77,7 +77,8 @@ def test_generator_designer_projects_scalar_schema() -> None: def test_generator_designer_projects_graph_schema() -> None: - """graph schema → small/large scale tier + 실현가능 edge(min/max/empty/disconnected).""" + """대규모 graph schema → small/large scale tier + 실현가능 edge(min/max/empty/ + disconnected) + adversarial 아키타입(max_stress·구조·가중치 — 상용 저지 케이스 강도).""" out = make_generator_designer_node()(_state(_graph_schema())) contract = out.generator_contract @@ -88,7 +89,17 @@ def test_generator_designer_projects_graph_schema() -> None: "max_size", "empty", "disconnected", + "max_stress", + "path_chain", + "star", + "dense", + "cycle_heavy", + "duplicate_edges", + "equal_weights", + "extreme_weights", } + # boundary/퇴화 4 + max_stress 1 + 아키타입 7 = edge 12, tier 5 → 17 케이스 계획 + assert contract.total_planned_cases == 17 def test_generator_designer_requires_blueprint() -> None: diff --git a/tests/v2/nodes/test_modeling_nodes.py b/tests/v2/nodes/test_modeling_nodes.py index 12bad3a..1ce3024 100644 --- a/tests/v2/nodes/test_modeling_nodes.py +++ b/tests/v2/nodes/test_modeling_nodes.py @@ -369,6 +369,16 @@ def test_strategist_single_user_prompt_omits_composition_palette() -> None: assert "도메인 팔레트" in single # 도메인은 양 모드 공통 +def test_domain_pool_expanded_and_distinct() -> None: + """지문 품질: _DOMAIN_POOL 21→40+ 확장 — pool 이 넓을수록 run 간 도메인 반복이 + 줄어 지문 매너리즘·고전 동형 인식이 함께 분산된다. 중복 없음 + 팔레트 크기 불변.""" + from ipe.v2.nodes.strategist import _DOMAIN_PALETTE_SIZE, _DOMAIN_POOL + + assert len(_DOMAIN_POOL) >= 40 # 확장 회귀 방지 + assert len(set(_DOMAIN_POOL)) == len(_DOMAIN_POOL) # distinct + assert _DOMAIN_PALETTE_SIZE == 7 # 회전 메커니즘 불변 + + def test_strategist_prompt_encourages_domain_diversity() -> None: """domain 다양성 규율 — DB 16문제 실측 banking 100% 단일화(composition fenwick 68% 보다 심함, domain 은 strategist 자유선택). 해법: 매 run 회전하는 결정적 diff --git a/tests/v2/nodes/test_narrative_node.py b/tests/v2/nodes/test_narrative_node.py index 6ebbcb0..2de9992 100644 --- a/tests/v2/nodes/test_narrative_node.py +++ b/tests/v2/nodes/test_narrative_node.py @@ -225,3 +225,25 @@ def test_narrative_abstract_prompt_drops_domain_story() -> None: assert "지어내지 말 것" in _ABSTRACT_SYSTEM_PROMPT # 현실 상황 날조 금지 assert "A 와 B" in _ABSTRACT_SYSTEM_PROMPT # 변수 직접 서술 예시 assert _ABSTRACT_SYSTEM_PROMPT != _SYSTEM_PROMPT # 도메인 시나리오 prompt 와 다름 + + +def test_narrative_prompt_has_craft_bar() -> None: + """지문 품질: 규율(음성 제약)만으로는 밋밋한 지문 — BOJ 상용 수준의 양성 작문 + 기준(구성/문체/분량/몰입/제목) 섹션이 규율 뒤에 존재해야 한다. 드리프트 방지.""" + from ipe.v2.nodes.narrative import _SYSTEM_PROMPT + + assert "작문 품질 (BOJ 상용 수준)" in _SYSTEM_PROMPT # 섹션 라벨 + assert _SYSTEM_PROMPT.index("규율:") < _SYSTEM_PROMPT.index("작문 품질") # 규율 뒤 + assert "2~4문단" in _SYSTEM_PROMPT # 구성 + assert "번역투" in _SYSTEM_PROMPT # 문체 — 기계 문체 금지 + assert "용어 드리프트 금지" in _SYSTEM_PROMPT # 명칭 일관 + assert "300~800자" in _SYSTEM_PROMPT # 분량 + + +def test_narrative_abstract_prompt_has_craft_bar() -> None: + """초급 abstract 도 작문 품질 섹션 보유 — 1~2문단, 교과서처럼 평이, 모호함 zero.""" + from ipe.v2.nodes.narrative import _ABSTRACT_SYSTEM_PROMPT + + assert "작문 품질" in _ABSTRACT_SYSTEM_PROMPT + assert "1~2문단" in _ABSTRACT_SYSTEM_PROMPT + assert "번역투" in _ABSTRACT_SYSTEM_PROMPT diff --git a/tests/v2/nodes/test_prompt_template_integrity.py b/tests/v2/nodes/test_prompt_template_integrity.py index 355eece..5aa5a78 100644 --- a/tests/v2/nodes/test_prompt_template_integrity.py +++ b/tests/v2/nodes/test_prompt_template_integrity.py @@ -66,6 +66,23 @@ def test_coder_parse_discipline_prompt_has_no_stray_variables() -> None: )) +def test_coder_brute_mode_prompt_has_no_stray_variables() -> None: + """W2A: brute 검산자 prompt(_BRUTE_SYSTEM_PROMPT ± 파싱 규율)도 동일 무결성 — + brute_mode 조합 전부에서 {'user'} 외 템플릿 변수가 없어야 한다.""" + from ipe.v1.nodes.coder import _coder_system_prompt + + for flag in (True, False): + template = ChatPromptTemplate.from_messages( + [ + ("system", _coder_system_prompt(flag, brute_mode=True)), + ("user", "{user}"), + ] + ) + assert set(template.input_variables) == {"user"}, (flag, sorted( + template.input_variables + )) + + def test_qa_reviewer_rendered_prompts_have_no_stray_variables() -> None: """qa_reviewer 는 kind 별 charter 를 Python .format 으로 선렌더 — 렌더 결과가 템플릿을 통과할 때도 동일 무결성이 성립해야 한다.""" diff --git a/tests/v2/nodes/test_qa_reviewer.py b/tests/v2/nodes/test_qa_reviewer.py index d8e3753..036f5a2 100644 --- a/tests/v2/nodes/test_qa_reviewer.py +++ b/tests/v2/nodes/test_qa_reviewer.py @@ -1,6 +1,6 @@ """QA 리뷰어 노드 단위 테스트 (Phase 3 M5 step2). -make_qa_reviewer_node(llm, kind=...) — 4종(N10a-d) 공용 factory: +make_qa_reviewer_node(llm, kind=...) — 5종(N10a-e) 공용 factory: - 병렬 fan-out 규율: **partial dict** ``{"qa_reviews": [review]}`` 반환 (M0/M2). - freeze 규율: review.kind 는 node 의 kind 로 강제 스탬프 (LLM 이 못 바꿈). - guard: 검토 대상 패키지(narrative+spec+test_suite) 없으면 ValueError. @@ -110,7 +110,8 @@ def test_reviewer_requires_package() -> None: node(bare) -def test_factory_builds_all_four_kinds() -> None: +def test_factory_builds_all_kinds() -> None: + assert "presentation" in ALL_KINDS # N10e 지문품질 kind 포함 (5종) for kind in ALL_KINDS: node = make_qa_reviewer_node( _FixedQALLM(QAReview(kind=kind, passed=True)), kind=kind @@ -119,6 +120,60 @@ def test_factory_builds_all_four_kinds() -> None: assert out["qa_reviews"][0].kind == kind +def test_presentation_reviewer_stamps_kind() -> None: + """N10e presentation — LLM 이 다른 kind 를 반환해도 presentation 으로 강제 스탬프.""" + node = make_qa_reviewer_node( + _FixedQALLM(QAReview(kind="ambiguity", passed=True)), kind="presentation" + ) + out = node(_package_state()) + assert out["qa_reviews"][0].kind == "presentation" + + +def test_presentation_charter_scopes_blocker_to_meaning_defects() -> None: + """presentation charter — 상용 저지 수준 작문 게이트: 의미 전달을 해치는 결함만 + blocker, 문체 개선 여지는 warning/info (오탈락 방지 규율이 charter 에 명시).""" + from ipe.v2.nodes.qa_reviewer import _CHARTERS + + charter = _CHARTERS["presentation"] + assert "번역투" in charter # 기계문체 점검 + assert "용어 일관성" in charter # 같은 대상 다른 명칭 점검 + assert "blocker" in charter and "warning/info" in charter # 심각도 규율 + + +def test_user_prompt_shows_three_samples_and_suite_detail() -> None: + """프롬프트 강화 — 샘플 3개 노출 + [채점셋 상세](분포+최대 크기 케이스 요약).""" + from ipe.v2.nodes.qa_reviewer import _MAX_SAMPLES, _build_user_prompt + + prompt = _build_user_prompt(_package_state()) + assert _MAX_SAMPLES == 3 + assert "samples (앞 3개):" in prompt + assert prompt.count("expected=") == 3 # 샘플 3개 전부 노출 (기존 2) + assert "[채점셋 상세]" in prompt + assert "카테고리 분포" in prompt + assert "최대 입력 크기" in prompt # 스트레스 케이스 존재 신호 + assert "대형 케이스" in prompt + + +def test_user_prompt_truncates_oversized_sample_text() -> None: + """토큰 바운드 — 샘플 필드가 상한 초과면 절단 + '…(+N자)' 크기 표기만 남는다.""" + from ipe.v2.nodes.qa_reviewer import _SAMPLE_TEXT_LIMIT, _build_user_prompt + + big = "9" * (_SAMPLE_TEXT_LIMIT + 50) + state = _package_state() + spec = state.spec + assert spec is not None + fat_spec = spec.model_copy( + update={ + "sample_testcases": [ + SampleTestCase(input_text=big, expected_output="1") + ] + } + ) + prompt = _build_user_prompt(state.model_copy(update={"spec": fat_spec})) + assert big not in prompt # 원문 통짜 미노출 + assert "…(+50자)" in prompt # 절단 표기 + + def test_easy_difficulty_charter_does_not_block_simplicity() -> None: """초급(is_basic) 완화 difficulty charter — '쉽다'는 이유로 막지 않고 진짜 퇴화만 blocker. RFC 난이도-agnostic 원칙을 코드화. 표준 charter 와 구분(완화 적용).""" @@ -128,3 +183,23 @@ def test_easy_difficulty_charter_does_not_block_simplicity() -> None: assert "막지 말 것" in _DIFFICULTY_CHARTER_EASY # '쉽다'고 막지 않음 assert "상수" in _DIFFICULTY_CHARTER_EASY # 진짜 퇴화(상수출력)는 여전히 차단 assert _CHARTERS["difficulty"] != _DIFFICULTY_CHARTER_EASY # 표준과 다름 + + +def test_easy_presentation_charter_does_not_block_terseness() -> None: + """초급(is_basic) 완화 presentation charter — narrative 초급 트랙의 의도된 + 형식(1~2문단 교과서체, abstract 센티널의 시나리오 없는 맨 서술)을 '간결하다/ + 시나리오가 없다'는 이유로 막지 않는다. 표준 charter 와 구분(완화 적용).""" + from ipe.v2.nodes.qa_reviewer import ( + _CHARTERS, + _EASY_CHARTERS, + _PRESENTATION_CHARTER_EASY, + ) + + assert "결함이 아니다" in _PRESENTATION_CHARTER_EASY # 간결·시나리오 부재는 결함 아님 + assert "시나리오 부재" in _PRESENTATION_CHARTER_EASY # abstract 맨 서술 허용 + assert "번역투" in _PRESENTATION_CHARTER_EASY # 문체 결함 점검은 유지 + assert "blocker" in _PRESENTATION_CHARTER_EASY # 의미 훼손만 blocker 규율 유지 + assert _CHARTERS["presentation"] != _PRESENTATION_CHARTER_EASY # 표준과 다름 + # dispatch 테이블 — difficulty/presentation 두 kind 만 완화 charter 를 갖는다. + assert set(_EASY_CHARTERS) == {"difficulty", "presentation"} + assert _EASY_CHARTERS["presentation"] is _PRESENTATION_CHARTER_EASY diff --git a/tests/v2/nodes/test_sample_explainer.py b/tests/v2/nodes/test_sample_explainer.py new file mode 100644 index 0000000..dd8708b --- /dev/null +++ b/tests/v2/nodes/test_sample_explainer.py @@ -0,0 +1,146 @@ +"""sample_explainer 노드 단위 테스트 (W2B) — 완성 샘플에 BOJ '예제 설명' 저작. + +mock LLM 주입으로 결정론 검증 (sample_filler 테스트 패턴 미러): ① 설명이 샘플 +순서대로 description 에 채워지고 input/expected 는 불변, ② 개수 불일치면 min +길이까지만 채우고 나머지 원본 유지(길이 보존), ③ **LLM 예외 시 state 무변경** +(장식적 품질 채널 — 파이프라인 생존 우선, 기존 노드들의 raise 정책과 다른 의도적 +선택), ④ spec/narrative 부재 시 방어적 no-op (LLM 미호출). +""" + +from __future__ import annotations + +from ipe.v1.schema import ( + IOContract, + Narrative, + ProblemSpec, + SampleExplanations, + SampleTestCase, + TargetAlgorithm, +) +from ipe.v2.nodes import make_sample_explainer_node +from ipe.v2.state import V2State, initial_v2_state + + +class _FixedExplainerLLM: + """고정 explanations 반환 + 호출 기록 (no-op 경로의 미호출 검증용).""" + + def __init__(self, explanations: list[str]) -> None: + self._explanations = explanations + self.calls = 0 + + def explain(self, state: V2State) -> SampleExplanations: + self.calls += 1 + return SampleExplanations(explanations=self._explanations) + + +class _RaisingExplainerLLM: + """LLM 장애 시뮬레이션 — 예외 시 무변경 통과 검증용.""" + + def explain(self, state: V2State) -> SampleExplanations: + msg = "LLM boom" + raise RuntimeError(msg) + + +def _spec() -> ProblemSpec: + return ProblemSpec( + target_algorithm=TargetAlgorithm.DIJKSTRA, + title="창고 배송 비용", + description="은닉 지문", + io_contract=IOContract(input_format="N", output_format="정수"), + sample_testcases=[ + SampleTestCase(input_text="3", expected_output="7"), + SampleTestCase(input_text="5", expected_output="-1"), + SampleTestCase(input_text="7", expected_output="0"), + ], + ) + + +def _state( + *, spec: ProblemSpec | None, with_narrative: bool = True +) -> V2State: + base = initial_v2_state("run", TargetAlgorithm.DIJKSTRA) + update: dict[str, object] = {} + if spec is not None: + update["spec"] = spec + if with_narrative: + update["narrative"] = Narrative( + title="창고 배송 비용", + scenario="창고에서 상점으로 물품을 배송한다.", + hidden=True, + domain="logistics", + ) + return base.model_copy(update=update) + + +def test_fills_descriptions_in_sample_order() -> None: + """설명이 샘플 순서 그대로 description 에 채워진다 — input/expected 불변.""" + llm = _FixedExplainerLLM(["비용 7이 최소다.", "도달 불가라 -1.", "같은 지점이라 0."]) + out = make_sample_explainer_node(llm)(_state(spec=_spec())) + + filled = out.spec + assert filled is not None + assert [s.description for s in filled.sample_testcases] == [ + "비용 7이 최소다.", + "도달 불가라 -1.", + "같은 지점이라 0.", + ] + # 설명 외 필드는 불변 + assert [s.input_text for s in filled.sample_testcases] == ["3", "5", "7"] + assert [s.expected_output for s in filled.sample_testcases] == ["7", "-1", "0"] + + +def test_count_mismatch_fills_min_and_keeps_rest() -> None: + """explanations 가 샘플보다 적으면 min 길이까지만 채우고 나머지 원본 유지 — + 길이 보존 (ProblemSpec min 3 제약 안전).""" + llm = _FixedExplainerLLM(["첫 설명.", "둘째 설명."]) + out = make_sample_explainer_node(llm)(_state(spec=_spec())) + + filled = out.spec + assert filled is not None + assert len(filled.sample_testcases) == 3 # 길이 보존 + assert [s.description for s in filled.sample_testcases] == [ + "첫 설명.", + "둘째 설명.", + "", # 미채움 — 원본 유지 + ] + + +def test_count_overflow_ignores_extras() -> None: + """explanations 가 샘플보다 많으면 샘플 개수까지만 사용 (초과분 무시).""" + llm = _FixedExplainerLLM(["a", "b", "c", "잉여 설명"]) + out = make_sample_explainer_node(llm)(_state(spec=_spec())) + + filled = out.spec + assert filled is not None + assert [s.description for s in filled.sample_testcases] == ["a", "b", "c"] + + +def test_llm_exception_returns_state_unchanged() -> None: + """LLM 예외 시 state 무변경 반환 — 예제 설명은 장식적 품질 채널이라 + 파이프라인을 죽이지 않는다 (raise 하는 기존 노드들과 다른 의도적 선택).""" + state = _state(spec=_spec()) + out = make_sample_explainer_node(_RaisingExplainerLLM())(state) + + assert out is state # 무변경 (같은 객체) + assert out.spec is not None + assert all(s.description == "" for s in out.spec.sample_testcases) + + +def test_noop_without_spec() -> None: + """spec 부재(상류 미완) — 방어적 no-op, LLM 미호출.""" + llm = _FixedExplainerLLM(["x"]) + state = _state(spec=None) + out = make_sample_explainer_node(llm)(state) + + assert out is state + assert llm.calls == 0 + + +def test_noop_without_narrative() -> None: + """narrative 부재 — 방어적 no-op (도메인 용어 컨텍스트 없이 저작하지 않는다).""" + llm = _FixedExplainerLLM(["x", "y", "z"]) + state = _state(spec=_spec(), with_narrative=False) + out = make_sample_explainer_node(llm)(state) + + assert out is state + assert llm.calls == 0 diff --git a/tests/v2/nodes/test_spec_bridge.py b/tests/v2/nodes/test_spec_bridge.py index 4225aa4..e237720 100644 --- a/tests/v2/nodes/test_spec_bridge.py +++ b/tests/v2/nodes/test_spec_bridge.py @@ -11,14 +11,19 @@ import pytest from ipe.v1.schema import ( + ConstraintRange, IOFieldSpec, IOSchema, Narrative, + OutputInvariant, ProblemBlueprint, ProblemSpec, TargetAlgorithm, ) -from ipe.v2.generation.input_gen import render_input_format +from ipe.v2.generation.input_gen import ( + derive_degenerate_inputs, + render_input_format, +) from ipe.v2.generation.input_parser import render_input_parser from ipe.v2.nodes import make_spec_bridge_node from ipe.v2.state import V2State, initial_v2_state @@ -135,6 +140,92 @@ def test_spec_bridge_requires_narrative() -> None: node(_state(with_narrative=False)) +# ---------- 경계 샘플 1개 보장 (edge_case_semantics invariant → 마지막 샘플 교체) ---------- + + +def _sized_io_schema() -> IOSchema: + """sized 필드 포함 io_schema — derive_degenerate_inputs 가 'min' 퇴화를 파생.""" + return IOSchema( + inputs=( + IOFieldSpec( + name="A", + type="int_array", + size_range=ConstraintRange(name="N", min_value=1, max_value=10), + value_range=ConstraintRange(name="A_i", min_value=1, max_value=100), + ), + ), + output_type="int", + output_format="단일 정수", + ) + + +def _edge_invariant() -> OutputInvariant: + return OutputInvariant( + kind="edge_case_semantics", description="해 없음이면 -1 출력" + ) + + +def test_generate_sample_inputs_replaces_last_with_first_degenerate() -> None: + """edge_case_semantics 류 invariant + 퇴화 파생 가능 schema → 마지막 샘플이 + derive_degenerate_inputs 의 첫 퇴화 입력으로 교체 (개수 불변, BOJ 표준).""" + from ipe.v2.nodes.spec_bridge import _SAMPLE_COUNT, _generate_sample_inputs + + schema = _sized_io_schema() + texts = _generate_sample_inputs( + schema, "run-v2", output_invariants=(_edge_invariant(),) + ) + degenerate = derive_degenerate_inputs(schema) + assert degenerate # 전제: sized 필드로 'min' 퇴화 파생 + assert len(texts) == _SAMPLE_COUNT # 개수 불변 + assert texts[-1] == degenerate[0][1] # 마지막 샘플 = 첫 퇴화 입력 + + +def test_generate_sample_inputs_unchanged_without_edge_invariant() -> None: + """invariant 없으면 기존 동작 그대로 (bias=random 3개, 회귀 가드).""" + from ipe.v2.nodes.spec_bridge import _generate_sample_inputs + + schema = _sized_io_schema() + plain = _generate_sample_inputs(schema, "run-v2") + explicit = _generate_sample_inputs(schema, "run-v2", output_invariants=()) + assert plain == explicit + degenerate = derive_degenerate_inputs(schema) + assert plain[-1] != degenerate[0][1] # 교체 미발생 (seed 상 구분되는 입력) + + +def test_generate_sample_inputs_unchanged_when_no_degenerate() -> None: + """invariant 가 있어도 퇴화 파생이 비면(스칼라 only) 교체 없음 — 순수 no-op.""" + from ipe.v2.nodes.spec_bridge import _generate_sample_inputs + + scalar_schema = _blueprint().io_schema # N int only → 퇴화 파생 빈 튜플 + assert derive_degenerate_inputs(scalar_schema) == () + with_inv = _generate_sample_inputs( + scalar_schema, "run-v2", output_invariants=(_edge_invariant(),) + ) + without = _generate_sample_inputs(scalar_schema, "run-v2") + assert with_inv == without + + +def test_spec_bridge_node_sample_includes_degenerate_case() -> None: + """node 레벨: blueprint.output_invariants 의 edge_case_semantics 가 샘플에 + 반영 — 지문의 퇴화 처방('도달 불가 → -1' 류)을 샘플이 실제 시연.""" + bp = ProblemBlueprint( + reduction_core=TargetAlgorithm.SORT, + domain="logistics", + io_schema=_sized_io_schema(), + output_invariants=(_edge_invariant(),), + ) + state = _state().model_copy(update={"blueprint": bp}) + out = make_spec_bridge_node()(state) + + spec = out.spec + assert isinstance(spec, ProblemSpec) + assert len(spec.sample_testcases) == 3 # 개수 불변 + degenerate = derive_degenerate_inputs(bp.io_schema) + assert spec.sample_testcases[-1].input_text == degenerate[0][1] + # expected 는 여전히 비움 — sample_filler 가 golden 실행으로 채움 (순수성 유지) + assert all(s.expected_output == "" for s in spec.sample_testcases) + + def test_spec_bridge_preserves_original_state() -> None: state = _state() out = make_spec_bridge_node()(state) diff --git a/tests/v2/nodes/test_suite_assembler.py b/tests/v2/nodes/test_suite_assembler.py index bd650ca..a99e17a 100644 --- a/tests/v2/nodes/test_suite_assembler.py +++ b/tests/v2/nodes/test_suite_assembler.py @@ -2,6 +2,8 @@ - ``make_suite_assembler_node`` (LLM 없음): pending test_suite + verified golden(attempt) → assembled(expected 채움). golden_origin=reconciliation.adopted_origin. +- resolved_edges 편입: edge_filler 가 golden 으로 expected 채운 퇴화 엣지를 + ``edge_resolved:`` 케이스로 중복 없이 병합 (pending 엣지/동일 input_text skip). """ from __future__ import annotations @@ -12,6 +14,7 @@ from ipe.v1.schema import ( GeneratedTestCase, ReconciliationResult, + ResolvedEdgeCase, SolutionAttempt, TargetAlgorithm, TestSuite, @@ -41,10 +44,14 @@ def _pending() -> TestSuite: def _state( - *, with_suite: bool = True, with_attempt: bool = True, origin: str = "opus" + *, + with_suite: bool = True, + with_attempt: bool = True, + origin: str = "opus", + resolved_edges: tuple[ResolvedEdgeCase, ...] = (), ) -> V2State: base = initial_v2_state("run-v2", TargetAlgorithm.SORT) - update: dict[str, object] = {} + update: dict[str, object] = {"resolved_edges": resolved_edges} if with_suite: update["test_suite"] = _pending() if with_attempt: @@ -89,3 +96,54 @@ def test_assembler_node_preserves_pending_original() -> None: out = make_suite_assembler_node(runner=_EchoRunner())(state) assert state.test_suite is not None and state.test_suite.is_assembled is False assert out.test_suite is not None and out.test_suite.is_assembled is True + + +def test_assembler_merges_verified_resolved_edges() -> None: + """edge_filler 가 golden 으로 expected 채운 resolved_edges 를 채점셋에 + ``edge_resolved:`` 케이스로 편입 — golden 검증 expected 를 재실행 없이 + 보존(runner 출력 'out:...' 이 아니라 edge_filler 채움값 그대로).""" + edges = ( + ResolvedEdgeCase(name="min", input_text="9", expected_output="0"), + ResolvedEdgeCase(name="unreachable", input_text="8", expected_output="-1"), + ) + out = make_suite_assembler_node(runner=_EchoRunner())( + _state(resolved_edges=edges) + ) + suite = out.test_suite + assert suite is not None + assert len(suite.cases) == 4 # pending 2 + 편입 2 + merged = {c.category: c for c in suite.cases[2:]} + assert merged["edge_resolved:min"].input_text == "9" + assert merged["edge_resolved:min"].expected_output == "0" # 재실행 아님 + assert merged["edge_resolved:unreachable"].expected_output == "-1" + assert merged["edge_resolved:min"].golden_elapsed_ms is None # TL 산정 무기여 + assert suite.is_assembled is True + + +def test_assembler_skips_pending_and_duplicate_resolved_edges() -> None: + """expected 미채움(pending) 엣지·suite 에 이미 있는 input_text 는 편입 skip + (중복 채점·미정의 정답 유입 차단).""" + edges = ( + # "1" 은 pending suite 의 케이스와 동일 input_text → skip + ResolvedEdgeCase(name="min", input_text="1", expected_output="0"), + # expected=None (golden 실행 실패 pending) → skip + ResolvedEdgeCase(name="unreachable", input_text="7", expected_output=None), + # 앞선 편입 엣지와 동일 input_text → skip (엣지 간 중복도 차단) + ResolvedEdgeCase(name="min2", input_text="9", expected_output="0"), + ResolvedEdgeCase(name="min3", input_text="9", expected_output="0"), + ) + out = make_suite_assembler_node(runner=_EchoRunner())( + _state(resolved_edges=edges) + ) + suite = out.test_suite + assert suite is not None + assert len(suite.cases) == 3 # pending 2 + 편입은 min2 하나만 + assert suite.cases[2].category == "edge_resolved:min2" + + +def test_assembler_without_resolved_edges_is_unchanged() -> None: + """resolved_edges 빈(비-graph 등) 경로 — 편입 없이 기존 assembled 그대로.""" + out = make_suite_assembler_node(runner=_EchoRunner())(_state()) + suite = out.test_suite + assert suite is not None + assert [c.category for c in suite.cases] == ["small", "small"] diff --git a/tests/v2/test_assembler.py b/tests/v2/test_assembler.py index 72a3303..5d3ddcd 100644 --- a/tests/v2/test_assembler.py +++ b/tests/v2/test_assembler.py @@ -1,7 +1,9 @@ """suite assembler 엔진 단위 테스트 (Phase 3 M4 step4). assemble_suite(pending, golden_code, runner, origin): golden 실행으로 expected 채움 + -실행 실패 케이스 drop + 전부 실패 시 ValueError. mock runner 로 sandbox 없이 결정론. +실행 실패 케이스 drop + 전부 실패 시 ValueError + 생존율 < _MIN_SURVIVAL_RATIO 면 +카테고리별 drop 집계 담은 ValueError(약체 채점셋 출하 차단). mock runner 로 sandbox +없이 결정론. """ from __future__ import annotations @@ -48,12 +50,13 @@ def test_assemble_fills_expected_from_golden_stdout() -> None: def test_assemble_drops_cases_golden_cannot_run() -> None: + # 4개 중 1개 실패(생존율 0.75 ≥ 0.7) — drop 만 하고 통과하는 경로. suite = assemble_suite( - _pending("1", "BAD", "3"), "# g", runner=_EchoRunner(), golden_origin="g" + _pending("1", "BAD", "3", "4"), "# g", runner=_EchoRunner(), golden_origin="g" ) - assert len(suite.cases) == 2 # BAD drop + assert len(suite.cases) == 3 # BAD drop assert all(c.expected_output is not None for c in suite.cases) - assert [c.input_text for c in suite.cases] == ["1", "3"] + assert [c.input_text for c in suite.cases] == ["1", "3", "4"] def test_assemble_raises_when_all_cases_fail() -> None: @@ -65,3 +68,43 @@ def test_assemble_raises_when_all_cases_fail() -> None: assert "RTE" in msg # 첫 실패 status 진단 (e2e all-fail 원인 분석용) assert "boom" in msg # 첫 실패 stderr assert "BAD1" in msg # 첫 실패 입력 head + + +def _pending_by_category(**inputs_by_category: tuple[str, ...]) -> TestSuite: + """카테고리별 입력으로 pending suite 구성 — 생존율 게이트의 drop 집계 검증용.""" + return TestSuite( + cases=tuple( + GeneratedTestCase(input_text=i, category=cat) + for cat, inputs in inputs_by_category.items() + for i in inputs + ) + ) + + +def test_assemble_raises_below_min_survival_with_category_drops() -> None: + """생존율(4/10=0.4) < 0.7 → 어떤 카테고리가 몇 개 죽었는지 담은 ValueError + (특정 tier 만 조용히 전멸한 약체 채점셋 출하 차단).""" + pending = _pending_by_category( + small=("1", "2", "3", "4"), + large=("BAD-l1", "BAD-l2", "BAD-l3"), + stress=("BAD-s1", "BAD-s2", "BAD-s3"), + ) + with pytest.raises(ValueError, match="생존율") as exc_info: + assemble_suite(pending, "# g", runner=_EchoRunner(), golden_origin="g") + msg = str(exc_info.value) + assert "4/10" in msg # filled/pending + assert "large=3" in msg # 카테고리별 drop 집계 + assert "stress=3" in msg + assert "small=" not in msg # 생존 tier 는 drop 집계에 미포함 + assert "RTE" in msg # 첫 실패 증거(원인 분석용) 동봉 + + +def test_assemble_allows_drops_at_or_above_min_survival() -> None: + """생존율 7/10=0.7(임계 이상) — drop 이 있어도 통과 (경계 포함 확인).""" + pending = _pending_by_category( + small=("1", "2", "3", "4", "5", "6", "7"), + large=("BAD1", "BAD2", "BAD3"), + ) + suite = assemble_suite(pending, "# g", runner=_EchoRunner(), golden_origin="g") + assert len(suite.cases) == 7 + assert suite.is_assembled is True diff --git a/tests/v2/test_difficulty.py b/tests/v2/test_difficulty.py index fb75459..b6b4b32 100644 --- a/tests/v2/test_difficulty.py +++ b/tests/v2/test_difficulty.py @@ -181,6 +181,23 @@ def test_user_prompt_includes_solution_and_anchor_ids() -> None: assert "최단경로" in text # 지문 +def test_system_prompt_has_tier_rubric_and_calibration_rules() -> None: + """system prompt 는 절대 티어 rubric + 강화 규율(3-anchor 인용, 복잡도×n_max + 교차확인, 보수적 하향)을 포함 — anchor 상대 비교의 보정 프레임 회귀 가드.""" + from ipe.v2.difficulty import _SYSTEM_PROMPT + + # 티어 rubric — Bronze~Diamond 절대 프레임 + for tier in ("Bronze", "Silver", "Gold", "Platinum", "Diamond"): + assert f"- {tier}:" in _SYSTEM_PROMPT + # 강화 규율 3종 + assert "최소 3개" in _SYSTEM_PROMPT # anchor 인용 하한 + assert "복잡도 × n_max 교차확인" in _SYSTEM_PROMPT # 복잡도-규모 정합 체크 + assert "보수적으로 낮은 쪽" in _SYSTEM_PROMPT # 애매하면 하향 + # 기존 규율 보존 (약화 금지) + assert "상대 위치" in _SYSTEM_PROMPT + assert "제공된 anchor id 중에서만" in _SYSTEM_PROMPT + + # --------------------------------------------------------------------------- # # DB 적재 — meta.difficulty.label → problems.difficulty 1급 컬럼 # # --------------------------------------------------------------------------- # diff --git a/tests/v2/test_e2e_v2_difficulty_real_llm.py b/tests/v2/test_e2e_v2_difficulty_real_llm.py index 67e11b0..82e1c2d 100644 --- a/tests/v2/test_e2e_v2_difficulty_real_llm.py +++ b/tests/v2/test_e2e_v2_difficulty_real_llm.py @@ -11,6 +11,8 @@ from __future__ import annotations +import os + import pytest from ipe.v1.schema.difficulty import _TIERS @@ -50,6 +52,10 @@ @pytest.mark.e2e +@pytest.mark.skipif( + not os.environ.get("ANTHROPIC_API_KEY"), + reason="ANTHROPIC_API_KEY missing — real LLM e2e skipped", +) def test_real_llm_calibrates_dijkstra_to_valid_tier() -> None: from ipe.v2.difficulty import AnthropicDifficultyLLM, evaluate_difficulty diff --git a/tests/v2/test_e2e_v2_full_pipeline_real_llm.py b/tests/v2/test_e2e_v2_full_pipeline_real_llm.py index c348aae..bf91cbb 100644 --- a/tests/v2/test_e2e_v2_full_pipeline_real_llm.py +++ b/tests/v2/test_e2e_v2_full_pipeline_real_llm.py @@ -66,7 +66,9 @@ def test_v2_full_pipeline_single_run_real_llm() -> None: golden_llms=[ AnthropicCoderLLM(m, parse_discipline=True) for m in _GOLDEN_MODELS ], - brute_llm=AnthropicCoderLLM(_BRUTE_MODEL, parse_discipline=True), + brute_llm=AnthropicCoderLLM( + _BRUTE_MODEL, parse_discipline=True, brute_mode=True + ), golden_origins=_GOLDEN_MODELS, ) raw = graph.invoke( diff --git a/tests/v2/test_e2e_v2_qa_real_llm.py b/tests/v2/test_e2e_v2_qa_real_llm.py index b0cd683..7cd24c5 100644 --- a/tests/v2/test_e2e_v2_qa_real_llm.py +++ b/tests/v2/test_e2e_v2_qa_real_llm.py @@ -68,7 +68,9 @@ def test_v2_pipeline_single_run_real_llm(mode: str) -> None: golden_llms=[ AnthropicCoderLLM(m, parse_discipline=True) for m in _GOLDEN_MODELS ], - brute_llm=AnthropicCoderLLM(_BRUTE_MODEL, parse_discipline=True), + brute_llm=AnthropicCoderLLM( + _BRUTE_MODEL, parse_discipline=True, brute_mode=True + ), golden_origins=_GOLDEN_MODELS, with_test_suite=True, with_qa=True, diff --git a/tests/v2/test_e2e_v2_suite_real_llm.py b/tests/v2/test_e2e_v2_suite_real_llm.py index 823bf8b..f8825f8 100644 --- a/tests/v2/test_e2e_v2_suite_real_llm.py +++ b/tests/v2/test_e2e_v2_suite_real_llm.py @@ -66,7 +66,9 @@ def test_v2_suite_pipeline_single_run_real_llm() -> None: golden_llms=[ AnthropicCoderLLM(m, parse_discipline=True) for m in _GOLDEN_MODELS ], - brute_llm=AnthropicCoderLLM(_BRUTE_MODEL, parse_discipline=True), + brute_llm=AnthropicCoderLLM( + _BRUTE_MODEL, parse_discipline=True, brute_mode=True + ), golden_origins=_GOLDEN_MODELS, with_test_suite=True, ) diff --git a/tests/v2/test_input_gen.py b/tests/v2/test_input_gen.py index 84ca176..9252a37 100644 --- a/tests/v2/test_input_gen.py +++ b/tests/v2/test_input_gen.py @@ -20,6 +20,7 @@ ) from ipe.v2.generation.input_gen import ( _MAX_ELEMENTS, + _MAX_STRESS_ELEMENTS, derive_degenerate_inputs, derive_edge_cases, derive_generator_contract, @@ -485,7 +486,7 @@ def test_grid_matches_int_matrix_canonical() -> None: def test_max_scale_graph_input_is_element_capped() -> None: - """V=200000=7.8MB 입력 실측 회귀 — bias=max 그래프도 _MAX_ELEMENTS 로 바운드.""" + """V=200000=7.8MB 입력 실측 회귀 — bias=max 그래프는 스트레스 상한으로 바운드.""" schema = _io_schema(_weighted_edges_field(2, 50000)) # 본래 대형 그래프 contract = GeneratorContract( scale_families=(ScaleFamily(name="s", case_count=1),), @@ -494,13 +495,15 @@ def test_max_scale_graph_input_is_element_capped() -> None: edge = next( c for c in generate_inputs(contract, schema, seed=7) if c.category == "max_stress" ) - _, e, _ = _parse_graph(edge.input_text) - assert e <= _MAX_ELEMENTS # 총 간선 캡 + v, e, _ = _parse_graph(edge.input_text) + assert v <= _MAX_STRESS_ELEMENTS // 2 # 정점 캡 (E ≤ 2V 산식 동일 원칙) + assert e <= _MAX_STRESS_ELEMENTS # 총 간선 캡 (max 계열 스트레스 상한) + assert e > _MAX_ELEMENTS # 기본 상한을 실제로 초과 활용 (complexity 분리) assert len(edge.input_text) < 1_500_000 # 수MB → 수백KB def test_max_scale_array_input_is_element_capped() -> None: - """대형 배열(OOM 유발) bias=max 도 N ≤ _MAX_ELEMENTS.""" + """대형 배열(OOM 유발) bias=max 도 N ≤ 스트레스 상한 (기본 상한은 초과 활용).""" field = IOFieldSpec( name="arr", type="int_array", @@ -516,15 +519,17 @@ def test_max_scale_array_input_is_element_capped() -> None: for c in generate_inputs(contract, _io_schema(field), seed=3) if c.category == "max_size" ) - assert int(edge.input_text.split("\n")[0]) <= _MAX_ELEMENTS # N + n = int(edge.input_text.split("\n")[0]) + assert n <= _MAX_STRESS_ELEMENTS # N ≤ 스트레스 상한 + assert n > _MAX_ELEMENTS # 기본 상한을 실제로 초과 활용 (complexity 분리) def test_max_scale_matrix_total_elements_capped() -> None: - """행렬 R*C 원소 총량도 _MAX_ELEMENTS 로 캡 (R*C 폭주 방지).""" + """행렬 R*C 원소 총량도 max 계열은 스트레스 상한으로 캡 (R*C 폭주 방지).""" field = IOFieldSpec( name="m", type="int_matrix", - size_range=ConstraintRange(name="m", min_value=1, max_value=200), + size_range=ConstraintRange(name="m", min_value=1, max_value=400), value_range=ConstraintRange(name="v", min_value=0, max_value=9), ) contract = GeneratorContract( @@ -537,7 +542,53 @@ def test_max_scale_matrix_total_elements_capped() -> None: if c.category == "max_grid" ) r, c = (int(x) for x in edge.input_text.split("\n")[0].split()) - assert r * c <= _MAX_ELEMENTS + assert r * c <= _MAX_STRESS_ELEMENTS # 400*400=160000 → 캡 실동작 + assert r * c > _MAX_ELEMENTS # 스트레스 상한 활용 + + +def test_non_max_cases_keep_default_element_cap() -> None: + """비-max 케이스(random tier·구조 아키타입)는 기본 상한(1만) 유지 — 패키지/실행 비용 보호.""" + field = IOFieldSpec( + name="arr", + type="int_array", + size_range=ConstraintRange(name="arr", min_value=150000, max_value=200000), + value_range=ConstraintRange(name="v", min_value=0, max_value=9), + ) + contract = GeneratorContract( + scale_families=(ScaleFamily(name="large", case_count=3),), + edge_cases=(EdgeCaseSpec(name="sorted_desc"),), # 구조 아키타입 (크기 random) + ) + for case in generate_inputs(contract, _io_schema(field), seed=11): + assert int(case.input_text.split("\n")[0]) <= _MAX_ELEMENTS # N ≤ 기본 상한 + + +def test_non_max_graph_archetypes_keep_default_cap() -> None: + """graph 구조 아키타입(dense 등)도 기본 상한 — 스트레스 상한은 max 계열 전용.""" + schema = _io_schema(_weighted_edges_field(2, 200000)) + contract = GeneratorContract( + scale_families=(ScaleFamily(name="s", case_count=1),), + edge_cases=(EdgeCaseSpec(name="dense"), EdgeCaseSpec(name="cycle_heavy")), + ) + for case in generate_inputs(contract, schema, seed=4): + _, e, _ = _parse_graph(case.input_text) + assert e <= _MAX_ELEMENTS # V ≤ 기본상한//2 → E ≤ 2V ≤ 기본 상한 + + +def test_stress_cap_cases_remain_deterministic() -> None: + """스트레스 상한 경로 포함 — 같은 seed → 같은 출력 (결정론 보존).""" + field = IOFieldSpec( + name="arr", + type="int_array", + size_range=ConstraintRange(name="arr", min_value=1, max_value=200000), + value_range=ConstraintRange(name="v", min_value=0, max_value=9), + ) + contract = GeneratorContract( + scale_families=(ScaleFamily(name="s", case_count=1),), + edge_cases=(EdgeCaseSpec(name="max_size"), EdgeCaseSpec(name="max_stress")), + ) + a = generate_inputs(contract, _io_schema(field), seed=21) + b = generate_inputs(contract, _io_schema(field), seed=21) + assert [c.input_text for c in a] == [c.input_text for c in b] def test_size_cap_preserves_inputs_under_limit() -> None: @@ -834,6 +885,56 @@ def test_render_constraints_includes_vertex_count_and_weight() -> None: assert "w" in cons # 가중치 누락 안 함 +def test_distinct_index_refs_avoids_sample_collision() -> None: + """``distinct_index_refs=True`` — 같은 collection 을 가리키는 index 참조(s·t)가 + 크기 ≥2 에서 서로 다른 값을 갖는다 (전 샘플 s==t 로 핵심 로직 시연 샘플이 없던 + QA blocker, run v2-54d68df4 실측). 기본 False 는 기존 출력 byte-identical.""" + schema = _graph_and_query_schema(2, 3) # V∈[2,3] — 충돌 확률 최대 구간 + contract = derive_generator_contract(schema) + for seed in range(20): + for case in generate_inputs( + contract, schema, seed=seed, distinct_index_refs=True + ): + s = _query_value(case.input_text, 0) + t = _query_value(case.input_text, 1) + assert s != t, f"seed={seed} category={case.category}: s==t=={s}" + # 기본(False) 경로 무회귀 — 같은 seed 에서 flag 유무와 무관하게 동일 출력. + base = [c.input_text for c in generate_inputs(contract, schema, seed=7)] + off = [ + c.input_text + for c in generate_inputs(contract, schema, seed=7, distinct_index_refs=False) + ] + assert base == off + + +def test_render_constraints_emits_edge_count_bound_for_weighted_edges() -> None: + """weighted_edges 는 E(간선 수) 상한을 렌더 — 생성기 실상한 backbone(V-1)+ + extra(≤V)=2V-1 < 2V 의 코드 파생. E 상한이 constraints 에 없으면 solver 가 + 복잡도 설계를 못 해 QA ambiguity blocker (run v2-b4fd4625 실측). tree_edges 는 + E=V-1 이 자명해 별도 행 없음.""" + schema = _graph_and_query_schema(2, 5000) + cons = {c.name: c for c in render_constraints(schema)} + assert "E" in cons + assert cons["E"].min_value == 0 + assert cons["E"].max_value == 10000 # numeric fallback = 2 × V상한 + assert format_constraint(cons["E"]) == "E ∈ [0, 2V]" # 기호 렌더 + tree = IOSchema( + inputs=( + IOFieldSpec( + name="hierarchy", + type="tree_edges", + size_range=ConstraintRange( + name="hierarchy", min_value=2, max_value=100 + ), + value_range=ConstraintRange(name="w", min_value=1, max_value=9), + ), + ), + output_type="int", + output_format="x", + ) + assert "E" not in {c.name for c in render_constraints(tree)} + + def test_render_constraints_binds_reference_to_collection_max() -> None: schema = _graph_and_query_schema(2, 5000) cons = {c.name: c for c in render_constraints(schema)} @@ -1218,7 +1319,8 @@ def test_derive_scalar_schema_single_nominal_family() -> None: def test_derive_graph_schema_tiers_and_edges() -> None: - """weighted_edges(shape=None 레거시) — small/large tier + 4 실현가능 edge.""" + """weighted_edges(shape=None 레거시, 대규모) — small/large tier + 4 실현가능 edge + + adversarial 아키타입(max_stress·구조·가중치 — 상용 저지 케이스 강도).""" schema = _io_schema(_derive_edges_field()) assert {f.name for f in derive_scale_families(schema)} == {"small", "large"} assert {e.name for e in derive_edge_cases(schema)} == { @@ -1226,11 +1328,20 @@ def test_derive_graph_schema_tiers_and_edges() -> None: "max_size", "empty", "disconnected", + "max_stress", + "path_chain", + "star", + "dense", + "cycle_heavy", + "duplicate_edges", + "equal_weights", + "extreme_weights", } def test_derive_int_array_min_one_excludes_empty() -> None: - """크기 하한 1 배열 — empty(크기 0)는 제약(크기≥1) 위반이라 미방출(realizability gate).""" + """크기 하한 1 배열 — empty(크기 0)는 제약(크기≥1) 위반이라 미방출(realizability gate). + 미핀 대규모 배열이라 sequence adversarial 아키타입은 전부 방출.""" schema = _io_schema( IOFieldSpec( name="arr", @@ -1238,7 +1349,17 @@ def test_derive_int_array_min_one_excludes_empty() -> None: size_range=ConstraintRange(name="arr", min_value=1, max_value=50), ) ) - assert {e.name for e in derive_edge_cases(schema)} == {"min_size", "max_size"} + assert {e.name for e in derive_edge_cases(schema)} == { + "min_size", + "max_size", + "max_stress", + "sorted_asc", + "sorted_desc", + "alternating", + "all_equal", + "extreme_values", + "single_element", + } def test_derive_int_array_min_zero_includes_empty() -> None: @@ -1265,7 +1386,9 @@ def test_derive_tree_edges_no_disconnected() -> None: ) names = {e.name for e in derive_edge_cases(schema)} assert "disconnected" not in names - assert names == {"min_size", "max_size"} # V_min=2 트리는 간선 1개 — empty 거짓 + # V_min=2 트리는 간선 1개 — empty 거짓. 트리엔 chain/star 만(사이클·다중간선·밀도 + # 불가), 무가중(value_range=None)이라 가중치 아키타입도 미방출. + assert names == {"min_size", "max_size", "max_stress", "path_chain", "star"} def test_derive_tree_edges_empty_only_when_single_vertex() -> None: @@ -1388,3 +1511,338 @@ def test_derive_degenerate_inputs_deterministic() -> None: # 고정 seed — reconcile 가 diff 한 입력 == edge_filler 가 채우는 입력 보장 schema = _graph_and_query_schema(3, 12) assert derive_degenerate_inputs(schema) == derive_degenerate_inputs(schema) + + +# ---------- adversarial 아키타입 (상용 저지 케이스 강도) ---------- + + +def _edge_case_text(schema: IOSchema, name: str, *, seed: int = 0) -> str: + """edge case 하나만 담은 contract 로 해당 카테고리 입력 텍스트를 얻는다.""" + contract = GeneratorContract( + scale_families=(ScaleFamily(name="s", case_count=1),), + edge_cases=(EdgeCaseSpec(name=name),), + ) + return next( + c.input_text + for c in generate_inputs(contract, schema, seed=seed) + if c.category == name + ) + + +def _plain_array(n: int) -> IOFieldSpec: + """크기 n 고정, 값 [-5,5], shape 미핀 int_array.""" + return IOFieldSpec( + name="arr", + type="int_array", + size_range=ConstraintRange(name="arr", min_value=n, max_value=n), + value_range=ConstraintRange(name="v", min_value=-5, max_value=5), + ) + + +def test_path_chain_is_linear_chain() -> None: + schema = _io_schema(_weighted_edges_field(12, 12)) + v, e, edges = _parse_graph(_edge_case_text(schema, "path_chain")) + assert v == 12 and e == v - 1 + assert [(u, t) for u, t, _ in edges] == [(i, i + 1) for i in range(1, 12)] # 사슬 + assert all(2 <= w <= 9 for _, _, w in edges) # value_range 존중 + + +def test_star_concentrates_on_hub() -> None: + schema = _io_schema(_weighted_edges_field(12, 12)) + v, e, edges = _parse_graph(_edge_case_text(schema, "star")) + assert v == 12 and e == v - 1 + assert all(u == 1 for u, _, _ in edges) # 허브(1-indexed base) 집중 + assert sorted(t for _, t, _ in edges) == list(range(2, 13)) # 나머지 전 정점 + + +def test_dense_hits_edge_count_upper() -> None: + schema = _io_schema(_weighted_edges_field(12, 12)) + v, e, edges = _parse_graph(_edge_case_text(schema, "dense")) + assert e == 2 * v - 1 # backbone(V-1) + extra(V) — 간선 수 상한 근접 + comps, _ = _uf_components(v, [(u, t) for u, t, _ in edges]) + assert comps == 1 # 여전히 연결 + + +def test_cycle_heavy_is_chain_plus_cycles() -> None: + schema = _io_schema(_weighted_edges_field(12, 12)) + v, e, edges = _parse_graph(_edge_case_text(schema, "cycle_heavy")) + pairs = [(u, t) for u, t, _ in edges] + assert pairs[: v - 1] == [(i, i + 1) for i in range(1, v)] # 사슬 backbone + assert e == 2 * v - 1 # 추가 간선 v 개 — 간선당 사이클 1+ + comps, has_cycle = _uf_components(v, pairs) + assert comps == 1 and has_cycle + + +def test_duplicate_edges_repeats_pairs_when_multi_allowed() -> None: + schema = _io_schema(_weighted_edges_field(12, 12)) # shape=None → 다중간선 허용(현 상수) + v, e, edges = _parse_graph(_edge_case_text(schema, "duplicate_edges")) + pairs = [(u, t) for u, t, _ in edges] + assert e == 2 * v - 1 # backbone + v 개 중복 재방출 + assert len(set(pairs)) < len(pairs) # 같은 쌍 반복 실재 + + +def test_duplicate_edges_bias_respects_simple_graph_pin() -> None: + """multi_edges=False 핀 — 아키타입 bias 가 와도 단순 그래프 유지 (핀 승리, 방어).""" + field = _shaped_edges(GraphShape(directed=True, multi_edges=False), 12, 12) + _, _, edges = _parse_graph(_edge_case_text(_io_schema(field), "duplicate_edges")) + pairs = [(u, t) for u, t, _ in edges] + assert len(pairs) == len(set(pairs)) # 중복 없음 + + +def test_equal_weights_all_weights_identical() -> None: + schema = _io_schema(_weighted_edges_field(12, 12)) + _, _, edges = _parse_graph(_edge_case_text(schema, "equal_weights")) + weights = {w for _, _, w in edges} + assert len(weights) == 1 # 전 간선 동일 가중치 (tie 스트레스) + assert all(2 <= w <= 9 for w in weights) + + +def test_extreme_weights_mixes_bounds_only() -> None: + schema = _io_schema(_weighted_edges_field(30, 30)) + _, _, edges = _parse_graph(_edge_case_text(schema, "extreme_weights", seed=1)) + weights = {w for _, _, w in edges} + assert weights == {2, 9} # 하한/상한만 혼재 (seed 고정 → 결정론) + + +def test_tree_path_chain_and_star_shapes() -> None: + field = IOFieldSpec( + name="tree", + type="tree_edges", + size_range=ConstraintRange(name="tree", min_value=10, max_value=10), + ) + chain_lines = _edge_case_text(_io_schema(field), "path_chain").split("\n") + assert chain_lines[0] == "10" + chain = [tuple(int(x) for x in ln.split()) for ln in chain_lines[1:]] + assert chain == [(i, i + 1) for i in range(1, 10)] # 사슬 트리 (깊이 최대) + star_lines = _edge_case_text(_io_schema(field), "star").split("\n") + star = [tuple(int(x) for x in ln.split()) for ln in star_lines[1:]] + assert len(star) == 9 and all(u == 1 for u, _ in star) # 스타 트리 (허브 집중) + + +def test_sorted_asc_bias_emits_ascending() -> None: + vals = _array_values(_edge_case_text(_io_schema(_plain_array(10)), "sorted_asc", seed=2)) + assert len(vals) == 10 and vals == sorted(vals) + assert all(-5 <= v <= 5 for v in vals) + + +def test_sorted_desc_bias_emits_descending() -> None: + vals = _array_values(_edge_case_text(_io_schema(_plain_array(10)), "sorted_desc", seed=2)) + assert len(vals) == 10 and vals == sorted(vals, reverse=True) + + +def test_all_equal_bias_emits_identical_values() -> None: + vals = _array_values(_edge_case_text(_io_schema(_plain_array(10)), "all_equal", seed=2)) + assert len(vals) == 10 and len(set(vals)) == 1 + assert -5 <= vals[0] <= 5 + + +def test_alternating_bias_zigzags() -> None: + from ipe.v2.generation.input_gen import _zigzag + + vals = _array_values(_edge_case_text(_io_schema(_plain_array(11)), "alternating", seed=3)) + assert len(vals) == 11 + assert vals == _zigzag(sorted(vals)) # 저-고 교대 재배치 (다중집합 불변) + assert vals[0] == min(vals) and vals[1] == max(vals) + + +def test_single_element_bias_yields_n_one() -> None: + field = IOFieldSpec( + name="arr", + type="int_array", + size_range=ConstraintRange(name="arr", min_value=1, max_value=50), + value_range=ConstraintRange(name="v", min_value=-5, max_value=5), + ) + text = _edge_case_text(_io_schema(field), "single_element", seed=4) + lines = text.split("\n") + assert lines[0] == "1" and len(lines[1].split()) == 1 # N=1 + + +def test_extreme_values_bias_uses_bounds_only() -> None: + vals = _array_values( + _edge_case_text(_io_schema(_plain_array(20)), "extreme_values", seed=5) + ) + assert set(vals) == {-5, 5} # 하한/상한 혼재 (seed 고정 → 결정론) + + +def test_sorted_desc_bias_defers_to_sort_pin() -> None: + """non_decreasing 핀 배열에 sorted_desc bias — 핀 승리(오름차순 유지, 위반 불가).""" + field = _shaped_array_field( + SequenceShape(sortedness="non_decreasing"), size_lo=10, size_hi=10 + ) + vals = _array_values(_edge_case_text(_io_schema(field), "sorted_desc", seed=4)) + assert len(vals) == 10 and vals == sorted(vals) + + +def test_all_equal_bias_defers_to_distinct_pin() -> None: + """strictly_increasing 핀 배열에 all_equal bias — 핀 승리(순증가 distinct 유지).""" + field = _shaped_array_field( + SequenceShape(sortedness="strictly_increasing"), size_lo=10, size_hi=10 + ) + vals = _array_values(_edge_case_text(_io_schema(field), "all_equal", seed=4)) + assert all(vals[i] < vals[i + 1] for i in range(len(vals) - 1)) + + +def test_all_same_char_bias_single_char_within_alphabet() -> None: + field = _shaped_string_field(StringShape(alphabet="dna"), size_lo=12, size_hi=12) + text = _edge_case_text(_io_schema(field), "all_same_char") + assert len(text) == 12 and len(set(text)) == 1 + assert set(text) <= set("ACGT") # alphabet 핀 존중 + + +def test_periodic_bias_repeats_short_period() -> None: + field = _shaped_string_field(StringShape(alphabet="lowercase"), size_lo=12, size_hi=12) + text = _edge_case_text(_io_schema(field), "periodic") + assert len(text) == 12 + assert any( + all(text[i] == text[i % p] for i in range(len(text))) for p in (2, 3) + ) # 주기 2~3 반복 + + +def test_max_stress_guarantees_two_upper_bound_cases() -> None: + """max_size + max_stress — 진짜 상한(V=hi·최대 밀도) 케이스 2개 보장 (naive TLE 유도).""" + schema = _io_schema(_weighted_edges_field(2, 60)) + contract = derive_generator_contract(schema) + cases = generate_inputs(contract, schema, seed=9) + upper = [c for c in cases if c.category in ("max_size", "max_stress")] + assert len(upper) == 2 + for c in upper: + v, e, _ = _parse_graph(c.input_text) + assert v == 60 # size 상한 실타격 + assert e == 2 * v - 1 # 최대 밀도 + assert upper[0].input_text != upper[1].input_text # 서로 다른 rng 스트림(중복 케이스 아님) + + +def test_derive_small_schema_keeps_legacy_edge_set() -> None: + """size 상한 < _ADVERSARIAL_MIN_SIZE — 아키타입 미방출(기존 소형 suite 정책 보존).""" + schema = _io_schema(_weighted_edges_field(3, 8)) + assert {e.name for e in derive_edge_cases(schema)} == { + "min_size", + "max_size", + "empty", + "disconnected", + } + + +def test_derive_pinned_sequence_gates_conflicting_archetypes() -> None: + """strictly_increasing 핀 — 모순/중복 아키타입 미방출(카테고리명↔입력 정합, F18 동형).""" + field = _shaped_array_field( + SequenceShape(sortedness="strictly_increasing"), size_lo=1, size_hi=100 + ) + names = {e.name for e in derive_edge_cases(_io_schema(field))} + for banned in ("sorted_asc", "sorted_desc", "alternating", "all_equal", "extreme_values"): + assert banned not in names + assert {"max_stress", "single_element"} <= names # 핀과 무관한 아키타입은 방출 + + +def test_derive_non_decreasing_allows_tie_archetypes_only() -> None: + """non_decreasing(중복 허용) 핀 — 재배치류 미방출, tie/극단값 아키타입은 방출.""" + field = _shaped_array_field( + SequenceShape(sortedness="non_decreasing"), size_lo=1, size_hi=100 + ) + names = {e.name for e in derive_edge_cases(_io_schema(field))} + for banned in ("sorted_asc", "sorted_desc", "alternating"): + assert banned not in names + assert {"all_equal", "extreme_values"} <= names + + +def test_derive_simple_graph_pin_gates_duplicate_edges() -> None: + """multi_edges=False 핀 — duplicate_edges 미방출, 나머지 구조 아키타입은 방출.""" + shape = GraphShape(directed=True, multi_edges=False) + schema = _io_schema(_shaped_edges(shape, 2, 100)) + names = {e.name for e in derive_edge_cases(schema)} + assert "duplicate_edges" not in names + assert {"path_chain", "star", "dense", "cycle_heavy"} <= names + + +def test_derive_string_schema_emits_string_archetypes() -> None: + field = _shaped_string_field(StringShape(alphabet="binary"), size_lo=1, size_hi=100) + names = {e.name for e in derive_edge_cases(_io_schema(field))} + assert {"max_stress", "all_same_char", "periodic"} <= names + + +def test_adversarial_contract_generation_deterministic_and_in_bounds() -> None: + """파생 adversarial contract 전체 — 같은 seed 재현 + 모든 케이스가 제약 내(V/가중치/참조).""" + schema = _graph_and_query_schema(2, 40) + contract = derive_generator_contract(schema) + a = generate_inputs(contract, schema, seed=13) + b = generate_inputs(contract, schema, seed=13) + assert [c.input_text for c in a] == [c.input_text for c in b] # 결정론 + assert len(a) == contract.total_planned_cases + for c in a: + lines = c.input_text.split("\n") + v, e = (int(x) for x in lines[0].split()) + assert 2 <= v <= 40 # size_range 존중 + for ln in lines[1 : 1 + e]: + u, t, w = (int(x) for x in ln.split()) + assert 1 <= u <= v and 1 <= t <= v and u != t # 1-indexed·self-loop 없음 + assert 1 <= w <= 9 # value_range 존중 + s, t2 = _query_value(c.input_text, 0), _query_value(c.input_text, 1) + assert 1 <= s <= v and 1 <= t2 <= v # 참조 스칼라 실제 V 바인딩 + + +# ---------- 아키타입 최소 크기 보장 (카테고리명↔입력 정합 — 크기 1 추첨 fallback 수선) ---------- + + +def test_duplicate_edges_guaranteed_with_size_lower_bound_one() -> None: + """크기 하한 1 스키마 — V=1 추첨이 나와도 하한 2 클램프로 다중 간선 **항상** 실재.""" + schema = _io_schema(_weighted_edges_field(1, 12)) + for seed in range(12): + v, _, edges = _parse_graph(_edge_case_text(schema, "duplicate_edges", seed=seed)) + pairs = [(u, t) for u, t, _ in edges] + assert v >= 2 # V=1 fallback 불가 (하한 2 클램프) + assert len(set(pairs)) < len(pairs) # 같은 쌍 반복이 모든 seed 에서 실재 + + +def test_periodic_guaranteed_min_length_two() -> None: + """크기 하한 1 문자열 — 길이 1 추첨이 나와도 하한 2 클램프로 주기 반복 항상 실재.""" + field = _shaped_string_field(StringShape(alphabet="lowercase"), size_lo=1, size_hi=12) + for seed in range(12): + text = _edge_case_text(_io_schema(field), "periodic", seed=seed) + n = len(text) + assert n >= 2 # 길이 1 fallback 불가 + assert any( + all(text[i] == text[i % p] for i in range(n)) for p in (2, 3) + ) # 주기 2~3 반복이 모든 seed 에서 실재 + + +def test_pairwise_sequence_archetypes_guarantee_min_two_elements() -> None: + """교대/혼재/tie 아키타입 — 원소 2+ 전제라 크기 하한 1 스키마에서도 N≥2 보장.""" + field = IOFieldSpec( + name="arr", + type="int_array", + size_range=ConstraintRange(name="arr", min_value=1, max_value=12), + value_range=ConstraintRange(name="v", min_value=-5, max_value=5), + ) + for name in ("alternating", "extreme_values", "all_equal"): + for seed in range(8): + vals = _array_values(_edge_case_text(_io_schema(field), name, seed=seed)) + assert len(vals) >= 2, (name, seed) + + +def test_graph_archetypes_guarantee_min_two_vertices() -> None: + """graph 구조 아키타입 전부 — V=1 은 간선 0(공허)이라 하한 2 클램프로 간선 1+ 보장.""" + schema = _io_schema(_weighted_edges_field(1, 12)) + for name in ("path_chain", "star", "dense", "cycle_heavy", "equal_weights"): + for seed in range(6): + v, e, _ = _parse_graph(_edge_case_text(schema, name, seed=seed)) + assert v >= 2 and e >= 1, (name, seed) + + +def test_tree_archetypes_guarantee_min_two_vertices() -> None: + """사슬/스타 트리 — 크기 하한 1 스키마에서도 V≥2 (단일 정점 '1' fallback 불가).""" + field = IOFieldSpec( + name="tree", + type="tree_edges", + size_range=ConstraintRange(name="V", min_value=1, max_value=12), + ) + for name in ("path_chain", "star"): + for seed in range(6): + lines = _edge_case_text(_io_schema(field), name, seed=seed).split("\n") + assert int(lines[0]) >= 2 and len(lines) >= 2, (name, seed) + + +def test_archetype_floor_skipped_when_size_upper_is_one() -> None: + """size 상한 1 스키마 — 클램프 불가(범위 위반 금지) → 기본 경로 fallback 유지 (방어).""" + schema = _io_schema(_weighted_edges_field(1, 1)) + v, e, _ = _parse_graph(_edge_case_text(schema, "duplicate_edges", seed=0)) + assert v == 1 and e == 0 # size_range 위반 없이 fallback diff --git a/tests/v2/test_main_v2.py b/tests/v2/test_main_v2.py index cb34e18..9ffdfe3 100644 --- a/tests/v2/test_main_v2.py +++ b/tests/v2/test_main_v2.py @@ -178,8 +178,10 @@ def test_main_p1_mode_prints_single_public( assert "mode=p1" in out assert "hidden=False" in out # P1 = 공개 assert "composition=single" in out - # P1 qa_kinds 는 leakage 제외 3종 - assert "qa_kinds=['ambiguity', 'fairness', 'difficulty']" in out + # P1 qa_kinds 는 leakage 제외 4종 (presentation 포함) + assert ( + "qa_kinds=['ambiguity', 'fairness', 'difficulty', 'presentation']" in out + ) def test_main_default_mode_is_p2(capsys: pytest.CaptureFixture[str]) -> None: diff --git a/tests/v2/test_v2_qa_graph.py b/tests/v2/test_v2_qa_graph.py index 4bacba2..c06fc67 100644 --- a/tests/v2/test_v2_qa_graph.py +++ b/tests/v2/test_v2_qa_graph.py @@ -1,8 +1,8 @@ """v2 QA 스테이지 통합테스트 — with_qa=True (Phase 3 M5 step3). -suite_assembler 후 QA 리뷰어 4종 병렬 fan-out → aggregator(N11) → 게이트 배선을 +suite_assembler 후 QA 리뷰어 5종 병렬 fan-out → aggregator(N11) → 게이트 배선을 mock LLM + scripted runner 로 검증: -1. 전원 통과: qa_report.overall_pass → end_success, qa_reviews 4종(dedup). +1. 전원 통과: qa_report.overall_pass → end_success, qa_reviews 5종(dedup). 2. 일부 실패: failed_kinds 기록 + final_status='fail_qa' (단발 게이트 — back-route 루프는 후속 step). 3. build guard: with_qa=True 는 with_test_suite=True 필수. @@ -102,7 +102,7 @@ def run(self, spec: RunSpec) -> RunResult: class _QAReviewerLLM: - """kind 별 pass/fail 스크립트 — 병렬 4종 mock.""" + """kind 별 pass/fail 스크립트 — 병렬 전종(5종) mock.""" def __init__(self, passed: bool, kind: QAReviewerKind) -> None: self._passed = passed @@ -186,7 +186,7 @@ def test_qa_pipeline_all_pass() -> None: assert final.final_status == "success" assert final.test_suite is not None and final.test_suite.is_assembled - assert len(final.qa_reviews) == 4 # 4 병렬 리뷰어, dedup 멱등 + assert len(final.qa_reviews) == len(ALL_KINDS) # 병렬 리뷰어 전종, dedup 멱등 assert {r.kind for r in final.qa_reviews} == set(ALL_KINDS) assert final.qa_report is not None assert final.qa_report.overall_pass is True diff --git a/tests/v2/test_v2_sample_explainer_graph.py b/tests/v2/test_v2_sample_explainer_graph.py new file mode 100644 index 0000000..648c204 --- /dev/null +++ b/tests/v2/test_v2_sample_explainer_graph.py @@ -0,0 +1,207 @@ +"""sample_explainer 그래프 배선 통합테스트 (W2B) — 예제 설명 opt-in 플래그. + +full 파이프라인(mock LLM + scripted runner, test_v2_synthesis_graph 패턴 미러)에서: +1. ``with_sample_explanations=True`` + mock explainer 주입 → 최종 state 의 spec + sample description 이 채워지고 expected(sample_filler 산출)는 보존 — 즉 + sample_filler → sample_explainer → edge_filler 순서 배선이 실효. +2. 기본(False) → sample_explainer 미배선, description 은 기존처럼 빈 문자열 + (기존 경로 무회귀). +3. explainer LLM 예외 → 무변경 통과로 파이프라인은 여전히 success (장식적 품질 + 채널 — 실패 클래스 불증가). +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from ipe.sandbox.runner import RunResult, RunSpec +from ipe.v1.schema import ( + AlgorithmDesign, + BlueprintFormalization, + ComplexityBound, + Invariant, + IOFieldSpec, + IOSchema, + NarrativeDraft, + NarrativeFaithfulnessReport, + SampleExplanations, + SolutionAttempt, + StrategySeed, + TargetAlgorithm, +) +from ipe.v2.graph import build_v2_graph +from ipe.v2.state import V2State, initial_v2_state + +# ---------- modeling / synthesis mocks (test_v2_synthesis_graph 미러) ---------- + + +class _FixedStrategistLLM: + def seed(self, state: Any) -> StrategySeed: + return StrategySeed(reduction_core=TargetAlgorithm.DIJKSTRA, domain="logistics") + + +class _FixedFormalizerLLM: + def formalize(self, state: Any) -> BlueprintFormalization: + return BlueprintFormalization( + io_schema=IOSchema( + inputs=(IOFieldSpec(name="N", type="int"),), + output_type="int", + output_format="단일 정수", + ) + ) + + +class _FixedNarrativeLLM: + def render(self, state: Any, *, hidden: bool) -> NarrativeDraft: + return NarrativeDraft(title="물류 경로", scenario="물류 시나리오") + + +class _FaithfulLLM: + def assess(self, state: Any) -> NarrativeFaithfulnessReport: + return NarrativeFaithfulnessReport(faithful=True) + + +class _DesignerLLM: + def generate(self, state: Any) -> AlgorithmDesign: + return AlgorithmDesign( + algorithm_name="dijkstra", + complexity_target=ComplexityBound( + time_big_o="O(E log V)", space_big_o="O(V)" + ), + pseudocode="relax edges.", + invariants=[Invariant(kind="non_negative", description="x")], + ) + + +class _CoderLLM: + def __init__(self, code: str) -> None: + self._code = code + + def generate(self, state: Any) -> SolutionAttempt: + return SolutionAttempt(code=self._code, iteration=0) + + +class _MarkerRunner: + def __init__(self, fn: Callable[[str, str], tuple[str, str]]) -> None: + self._fn = fn + + def run(self, spec: RunSpec) -> RunResult: + py = sorted(Path(spec.cwd).glob("*.py")) + code = py[0].read_text(encoding="utf-8") if py else "" + status, stdout = self._fn(code, spec.stdin) + return RunResult( + status=status, # type: ignore[arg-type] + returncode=0 if status == "OK" else 1, + stdout=stdout, + stderr="" if status == "OK" else "boom", + elapsed_ms=1, + ) + + +def _agreeing(code: str, stdin: str) -> tuple[str, str]: + return ("OK", f"ans-{stdin}") + + +# ---------- explainer mocks ---------- + + +class _StateAwareExplainerLLM: + """샘플 개수에 맞춰 결정론 설명 생성 — spec_bridge 가 만드는 샘플 수에 무관.""" + + def explain(self, state: V2State) -> SampleExplanations: + spec = state.spec + assert spec is not None + return SampleExplanations( + explanations=[ + f"설명-{i}" for i in range(len(spec.sample_testcases)) + ] + ) + + +class _RaisingExplainerLLM: + def explain(self, state: V2State) -> SampleExplanations: + msg = "LLM boom" + raise RuntimeError(msg) + + +# ---------- helpers ---------- + + +def _final(raw: Any) -> V2State: + return raw if isinstance(raw, V2State) else V2State.model_validate(raw) + + +def _graph(**kwargs: Any) -> Any: + return build_v2_graph( + composition_mode="single", + strategist_llm=_FixedStrategistLLM(), + formalizer_llm=_FixedFormalizerLLM(), + narrative_llm=_FixedNarrativeLLM(), + faithfulness_llm=_FaithfulLLM(), + designer_llm=_DesignerLLM(), + golden_llms=[_CoderLLM("# G0"), _CoderLLM("# G1")], + brute_llm=_CoderLLM("# B"), + golden_origins=["opus", "sonnet"], + runner=_MarkerRunner(_agreeing), + verifier_getter=lambda _a: None, + **kwargs, + ) + + +def _run(graph: Any, run_id: str) -> V2State: + return _final( + graph.invoke( + initial_v2_state(run_id, TargetAlgorithm.DIJKSTRA), + config={"recursion_limit": 50}, + ) + ) + + +# ---------- 1. 활성 배선: description 이 최종 state 에 반영 ---------- + + +def test_with_sample_explanations_fills_descriptions() -> None: + graph = _graph( + with_sample_explanations=True, + sample_explainer_llm=_StateAwareExplainerLLM(), + ) + final = _run(graph, "run-expl-on") + + assert final.final_status == "success" + assert final.spec is not None + samples = final.spec.sample_testcases + # sample_explainer 가 sample_filler 뒤에 실행 — description 채움 + expected 보존 + assert [s.description for s in samples] == [ + f"설명-{i}" for i in range(len(samples)) + ] + assert all(s.expected_output for s in samples) # sample_filler 산출 보존 + + +# ---------- 2. 기본(off): 기존 경로 무회귀 ---------- + + +def test_default_off_keeps_descriptions_empty() -> None: + graph = _graph() # with_sample_explanations 기본 False + final = _run(graph, "run-expl-off") + + assert final.final_status == "success" + assert final.spec is not None + assert all(s.description == "" for s in final.spec.sample_testcases) + assert all(s.expected_output for s in final.spec.sample_testcases) + + +# ---------- 3. explainer 예외: 파이프라인 생존 (실패 클래스 불증가) ---------- + + +def test_explainer_failure_does_not_break_pipeline() -> None: + graph = _graph( + with_sample_explanations=True, + sample_explainer_llm=_RaisingExplainerLLM(), + ) + final = _run(graph, "run-expl-boom") + + assert final.final_status == "success" # 무변경 통과 — 검증 경로 계속 + assert final.spec is not None + assert all(s.description == "" for s in final.spec.sample_testcases)