From 3821cb64ad1bb8ec9172058004debeb6ccbfe515 Mon Sep 17 00:00:00 2001 From: LsMin124 Date: Thu, 25 Jun 2026 15:49:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(v2):=20=EC=B4=88=EA=B8=89=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=8A=B8=EB=9E=99=20=E2=80=94=20=EB=82=9C=EC=9D=B4?= =?UTF-8?q?=EB=8F=84=20seed-=ED=8C=8C=EC=83=9D=20+=20is=5Fbasic-aware=20di?= =?UTF-8?q?fficulty=20=EC=99=84=ED=99=94=20(E1+E2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기본 입출력·산술/논리·조건·반복 누적 등 초급 문제 생성. 별도 파이프라인 0 — 단일 엔진에 is_basic(seed) 파생 난이도 노브로 분기(P1/P2 modes-as-knobs 동형). - TargetAlgorithm + basic_io/arithmetic/conditional/loop_accumulate + is_basic() 분류(단일소스). 난이도=seed 파생(모순 불가·플러밍 0, request/state/CLI 무수정). - strategist: is_basic → 은닉/위장 대신 명확·직접 저작 + composition 팔레트 basic 제외. - formalizer: is_basic → 작은 입력·단순 구조·입력의존, 무거운 graph/sequence/edge 머신 배제. - qa_reviewer: is_basic-aware difficulty charter — '쉽다'고 막지 않고 진짜 퇴화만 (난이도-agnostic 원칙 코드화). 다른 QA kind·알고리즘 문제 무영향. 측정 P1 N=3: difficulty 완화 16%→75%, 스칼라(arithmetic/basic_io/conditional) 9/9(100%). 배열(loop_accumulate) 0/3 = N=0↔constraints 모순(sequence write-side, 후속 B). 비-basic seed byte-identical. 게이트 890 passed/mypy --strict 100/ruff green. --- .../2026-06-25_easy-problem-track-rfc.md | 188 ++++++++++++++++++ ipe/v1/schema/__init__.py | 2 + ipe/v1/schema/problem_spec.py | 29 +++ ipe/v2/nodes/formalizer.py | 45 ++++- ipe/v2/nodes/qa_reviewer.py | 34 +++- ipe/v2/nodes/strategist.py | 63 +++++- tests/v1/schema/test_problem_spec.py | 44 ++++ tests/v2/nodes/test_modeling_nodes.py | 49 ++++- tests/v2/nodes/test_qa_reviewer.py | 11 + 9 files changed, 454 insertions(+), 11 deletions(-) create mode 100644 docs/improvements/2026-06-25_easy-problem-track-rfc.md diff --git a/docs/improvements/2026-06-25_easy-problem-track-rfc.md b/docs/improvements/2026-06-25_easy-problem-track-rfc.md new file mode 100644 index 0000000..ab91331 --- /dev/null +++ b/docs/improvements/2026-06-25_easy-problem-track-rfc.md @@ -0,0 +1,188 @@ +# RFC: Easy Problem Track — 단일 엔진 + 난이도 노브 (no pipeline fork) + +작성 2026-06-25 · 상태: 제안(승인 대기) · 관련 [[single-ir-architecture-rfc]] · [[backbone-generalization-rfc]] + +## 0. Thesis + +초급 문제(기본 입출력 · 산술/논리 · 큐/스택 기초)는 **별도 파이프라인이 필요 없다.** +v2 엔진은 이미 trivial 문제를 end-to-end 로 처리한다(스칼라→NullBackbone, sample-only +reconcile, difficulty-agnostic QA). 막힌 곳은 엔진이 아니라 둘뿐이다: + +1. **타깃 어휘** — "무엇을 만들지"에 초급 카테고리가 없다. +2. **난이도 입력** — authoring 을 작고 명확한 문제로 *조종할* 손잡이가 없다. + +둘 다 **하나의 엔진에 노브로** 추가한다 — P1/P2 가 쓰는 "one engine, modes as knobs" +패턴 그대로. **포크되는 경로가 없다.** 3세대→2 파이프라인 수렴과 단일-IR 의 통합 노력을 +되돌리지 않는 것이 본 RFC 의 제1 제약이다. + +## 1. Current state (grounded) + +### 1.1 엔진은 trivial 을 이미 처리한다 (변경 0 영역) +- **Backbone**: 스칼라/단순 필드 → `resolve_backbone` 가 first-owns-wins, else `NullBackbone` + (`ipe/v2/backbone/__init__.py:24-38`). NullBackbone 은 **안전 no-op** — `structural_facts()→[]`, + `derive_edge_inputs()→()` (`ipe/v2/backbone/base.py:93-108`). 실패 아님, 폴백. +- **Reconcile**: 엣지 0 → samples-only differential. brute==golden 합의도 **유효 채택** + (`ipe/v1/verification/reconcile.py:129-136`, `ipe/v2/nodes/reconciler.py:57-60`). + A+B 처럼 brute 가 golden 과 동일 코드여도 reject 아님. +- **edge_filler**: `resolved_edges` 빈 경우 no-op (`ipe/v2/nodes/edge_filler.py:47-48`). +- **input_gen**: sized 필드 없으면 단일 `nominal` family 4 cases (`ipe/v2/generation/input_gen.py:430-465`), + int 스칼라는 `value_range`+tier 로 단일 정수 직렬화 (`input_gen.py:629-630`). + +### 1.2 QA 는 쉬운 문제를 막지 않는다 +- difficulty 리뷰어 charter: **"명백한 모순만" 본다 — 입력 무관 상수 출력 / 명세상 불가능. + 난이도 측정·calibration 은 범위 밖** (`ipe/v2/nodes/qa_reviewer.py:51-55`). +- 파이프라인은 설계상 **난이도-agnostic** (`ipe/v2/difficulty.py:3`). calibration 은 사후 메타, + 출하 게이트 아님. +- P1 QA = `("ambiguity","fairness","difficulty")` (`ipe/v2/config.py:28`). 최소 난이도 floor + 없음 (`ipe/v2/router.py:60-75`). +- Bronze 앵커 **이미 존재**: `ipe/calibration/anchors.json` `bj_1000_bronze5`(A+B, O(1), "implementation"). + +### 1.3 갭 (본 RFC 가 메우는 것) +- **G1 어휘**: `TargetAlgorithm` 은 평면 19종, 메타 0 (`ipe/v1/schema/problem_spec.py:19-45`). + 전부 중급+. 초급 카테고리 없음. seed 가 유일 주입점(`ipe/v2/main_v2.py`, `batch.py:_parse_seeds`). +- **G2 난이도 입력 없음**: strategist/formalizer 에 "쉽게/작게" 지시가 **전무** + (`strategist.py:139-169`, `formalizer.py:23-133`). 난이도는 순수 사후(`difficulty.py`). +- **G3 큐/스택 표현**: "N개 연산 처리(push/pop)"용 `operation_sequence` IOFieldType 부재 + (`ipe/v1/schema/blueprint.py:27-37`). int_array 우회는 가능하나 비-pedagogical. + +## 2. Design: one engine, two orthogonal knobs + +기존 mode 노브와 **직교**하는 난이도 노브를 추가한다. mode 는 그대로 둔다. + +| 노브 | 값 | 의미축 | 상태 | +|---|---|---|---| +| `mode` | `p1` / `p2` | hidden·composition·qa_kinds (= 은닉/합성으로 어렵게) | 기존(`config.py:mode_knobs`) | +| `difficulty_target` | `standard` / `easy` | authoring scale·clarity (= 작고 명확하게) | **신규** | + +- **초급 문제 = `mode=p1` + `difficulty_target=easy`.** (단일·공개·QA3 그대로.) +- **`difficulty_target=standard` = 기본값 = 현 동작 byte-identical**(easy 지시 미주입). + → E1 은 단일-IR Phase 1a 처럼 "기본값=현 상수 ⇒ 무회귀" 로 검증. +- `p2`(합성·은닉)는 본질적으로 non-easy → validator 가 `p2 + easy` 조합 reject(§4). +- 난이도는 별도 *mode 값*(p0 등) 이 아니다 — easy 의 (hidden,composition,qa) 는 정확히 P1 과 + 같으므로, 난이도는 mode 와 **독립 축**이지 mode 의 세 번째 값이 아니다. + +### 2.1 단일소스 표 (every fact ← 정확히 1 저작 소스) +단일-IR 운영 불변식("모든 사실은 저작 소스 정확히 1개")을 초급 축에도 적용: + +| 사실 | 단일 저작 소스 | 소비처 | +|---|---|---| +| 난이도 타깃 | request `difficulty_target` (입력) | state → strategist/formalizer 프롬프트 | +| easy 전략 지시 | `strategist._DIFFICULTY_DIRECTIVE_EASY` 상수 1개 | strategist user prompt(조건부) | +| easy 형식 지시 | `formalizer` easy 프롬프트 섹션 1개 | io_schema 저작 | +| 크기 경계 | formalizer io_schema(`size_range`/`value_range`) — 기존 단일소스 | render_*/input_gen 순수 투영 | +| 카테고리 family | `target_family(t)` 분류 맵 1개 (§3) | strategist/formalizer 조건부 | +| 입력 의존성 요구 | formalizer easy 지시 1줄 + QA difficulty 백스톱 | 저작 + 게이트 | + +## 3. Vocabulary modeling — ⚠️ 유일한 결정 포인트 + +초급 카테고리를 어디에 둘지. **두 안 모두 파이프라인은 하나**(그래프 무포크) — 차이는 타입 위생뿐. + +### Option A (권장) — `TargetAlgorithm` 확장 + 분류-as-data +- enum 에 초급 카테고리 추가: `BASIC_IO`, `ARITHMETIC`, `CONDITIONAL`, `LOOP_ACCUMULATE`. +- **메타는 코드 분류 맵으로** (enum 오염 최소): `target_family: dict[TargetAlgorithm, Family]` + + `is_basic(t) -> bool` (단일소스). Family = graph/sequence/string/dp/number_theory/**basic**. +- seed 필드 타입 **불변** → main_v2/batch/state/strategist 플러밍 **무수정**. verifier 는 + None-dispatch(초급=symbolic verifier 없음, golden/brute 가 검증 — 기존 v2 dormant 패턴). +- 비용: "TargetAlgorithm" 이 비-알고리즘 보유(네이밍 smell). **선례**: anchors.json 가 A+B 를 + `"algorithm":"implementation"` 로 이미 분류 — 난이도층은 이미 "implementation" 을 멤버로 취급. + +### Option B — 형제 `SkillTarget` enum + union seed 타입 +- 의미 깔끔(`TargetAlgorithm` 순수 유지). 비용: seed 타입 `TargetAlgorithm | SkillTarget` + union → 타입 표면 ~6곳 수정(`GenerateRequest.seed_algorithm`, `V2State`, `_parse_seeds`, + `_parse_target_algorithm`, strategist, batch). 그래프는 여전히 무포크. + +**권장 = A** (사용자 일관성 제약에 가장 부합·플러밍 0·분류는 data). 네이밍 caveat 는 분류 맵이 +의미를 명시하므로 수용 가능. 첫 카테고리 셋: `basic_io · arithmetic · conditional · loop_accumulate` +(스칼라/배열만 — 엔진 검증 완료 영역). 큐/스택은 §5 E3. + +## 4. Node → role mapping + +| 노드/모듈 | 변경 | 비고 | +|---|---|---| +| `graph.py`(배선)·synthesis·reconciler·executor·QA wiring·calibration·backbones | **무수정** | 초급은 동일 경로 통과(§1.1) | +| `strategist.py` | + `_DIFFICULTY_DIRECTIVE_EASY` 조건부 섹션 | easy: composition 빈값 강제·camouflage 끔·명확 직접 서술. `_COMPOSITION_DIRECTIVE_*` 와 동형 | +| `formalizer.py` | + easy 프롬프트 섹션 | 작은 `size_range`/`value_range`·단순 io·출력 단순·**입력 의존 필수**(상수출력 금지) | +| request/state | + `difficulty_target: Literal["standard","easy"]="standard"` | 기본=standard ⇒ byte-identical | +| `config.py` | + `difficulty_knobs(target)` (또는 mode_knobs 형제) | 단일소스 노브 파생 | +| validator(Phase 2) | (선택) `p2+easy` reject + `easy ⇒ size_range ≤ cap` assert | construction-enforced 난이도(§6 R3) | + +## 5. Migration plan (each shippable + measurable) + +- **E0** — 본 RFC + §3 결정 확정. (코드 0) +- **E1 — 난이도 노브 (키스톤)**: `difficulty_target` 필드 + state 스레딩 + strategist/formalizer + 조건부 지시 + 기본 standard. **측정**: 기존 시드 standard 로 재생성 → **byte-identical**(무회귀 게이트). +- **E2 — 초급 어휘**: §3 카테고리 + `target_family`/`is_basic` 분류 + None-dispatch. + **측정**: 초급 P1 출하율 N≥3(가설: graph 대비 **높음** — 모순 표면 소멸) + calibration(가설: Bronze 안착). +- **E3 — 큐/스택**: 표현 결정 = `operation_sequence` IOFieldType + `OperationBackbone` + (SequenceBackbone 미러) **vs** int_array 우회. **측정**: 동일. +- **E4 — 은행 적재 + Bronze 서브티어**: 초급 출하분 prod 적재 + (필요시) Bronze IV~I 앵커 보강 + (현 Bronze 앵커 희소 → 서브티어 해상도). + +## 6. Risks & trade-offs + +- **R1 상수출력 충돌(지배)**: difficulty 게이트가 유일하게 막는 게 "입력 무관 상수 출력". + 순수 "Hello World 출력"형 기본-I/O 는 **reject**. → formalizer easy 지시에 **입력 의존 필수** + 규칙(echo/format/연산) + QA 백스톱. consistency-by-construction(프롬프트) + 게이트(backstop). +- **R2 enum 네이밍(Option A)**: cosmetic. 분류 맵이 의미 명시로 상쇄. +- **R3 LLM 이 small-size 지시 무시**: 측정으로 관측 → 드리프트 시 validator 에 `easy ⇒ size cap` + assert 로 construction-enforced 승격(prompt-enforced → 코드강제). +- **R4 기존 BOJ trivial 중복**(A+B=BOJ 1000): domain/framing 변주 + 은행 de-dup. 초급은 본질상 + 유사도 높음 — 다양성은 domain palette 회전으로 일부 완화. +- **R5 brute==golden 약한 교차검증**: trivial 은 golden==brute 흔함 → 검증 신호 감소(RFC §7.4 + '원점 라벨' 독립성은 유지되나 코드 동일). 수용 — 대신 input_gen nominal 4 cases 가 커버. + +## 7. Key files this RFC touches +- `ipe/v1/schema/problem_spec.py` (TargetAlgorithm + 분류 맵, Option A) +- `ipe/v2/nodes/strategist.py` (easy directive), `ipe/v2/nodes/formalizer.py` (easy io 지시) +- `ipe/v2/config.py` (difficulty_knobs), request/`V2State` (difficulty_target) +- (선택) `ipe/v2/nodes/validator` (p2+easy reject, size cap) +- (E3) `ipe/v1/schema/blueprint.py` (operation_sequence), `ipe/v2/backbone/operation.py` +- **무수정 보존**: `ipe/v2/graph.py`, synthesis/reconciler/executor, QA wiring, `ipe/v2/difficulty.py`, 기존 backbones + +## 부록: 왜 별도 구성이 아닌가 (사용자 우려 직답) +"초급용 별도 구성 = 일관 파이프라인 깨짐" 은 정확한 우려다. 본 설계는 별도 구성을 **거부**한다: +graph/synthesis/QA/verification/calibration 전부 동일 경로. 추가되는 것은 (a) 입력 노브 하나, +(b) 그 노브가 켜는 프롬프트 조건부 섹션(=`composition_mode` 가 이미 하는 것과 동형), (c) 코드 +분류 맵. 새 그래프·새 노드·새 검증경로 **0**. "one engine, knobs" 패턴의 직접 연장이다. + +--- + +## 구현 노트 (E1+E2 완료, 2026-06-25) + +설계를 구현하며 두 가지가 정제·추가됐다. + +### 정제 1 — 난이도는 입력이 아니라 seed 에서 파생 +§2 의 `difficulty_target` 입력 노브 대신 **`is_basic(seed)` 파생**으로 단순화. 난이도를 +별도 입력으로 받으면 (a) request/state/CLI 스레딩 추가 (b) `easy+dijkstra` 같은 모순 상태 +가능. seed 가 곧 난이도를 말하면(basic 카테고리=easy) **단일소스·모순 불가·플러밍 0**. +`ipe/v1/schema/problem_spec.py` 에 `is_basic()`+`_BASIC_TARGETS`(단일소스), strategist/ +formalizer/qa_reviewer 가 `is_basic(state.seed_algorithm)` 로 분기(비-basic byte-identical). + +### 정제 2 — is_basic-aware difficulty charter (키스톤 레버) +원 RFC R1 은 "상수출력만 difficulty 게이트가 막는다"고 봤으나 **실측 반증**: 표준 charter 의 +"trivial 하게 풀리거나" 가 입문 문제를 *쉽다는 이유로* reject(단순 곱셈·분기 하나). → +`qa_reviewer._DIFFICULTY_CHARTER_EASY` 추가: is_basic 문제는 단순함을 통과시키고 **진짜 +퇴화(상수출력·모순)만** blocker. RFC 의 "난이도-agnostic" 의도를 코드화. 다른 QA kind· +알고리즘 문제 무영향. + +### 측정 (P1 N=3, 완화 전후) +| 카테고리 | 완화 전 | 완화 후 | +|---|---|---| +| arithmetic | 1/3 | **3/3** | +| basic_io | 0/3 | **3/3** | +| conditional | 1/3 | **3/3** | +| loop_accumulate(배열) | 0/3 | 0/3 | +| **전체** | 2/12 (16%) | **9/12 (75%)** | + +**스칼라 초급 = 9/9 (100%)**. 출하분 전부 깔끔한 Bronze(계좌 잔액·합격판정·성적표 등). + +### A↔B 결합 (확증) +`loop_accumulate`(배열) 0/3 은 전부 **"N=0 ↔ constraints N∈[1,100]" 모순** = binary_search/ +lis 를 깨뜨린 sequence write-side 갭. formalizer easy 프롬프트("N=0 지어내지 마")로도 안 +막힘 → 모순이 하류(narrative/input_format 렌더)에서 발생. **배열 기반 초급은 B(N=0 +단일소스화) 선행 필수.** 스칼라 초급은 A 로 완결, 배열은 B 후 자연 출하. + +### 게이트 / 미완 +- **게이트**: 890 passed / mypy --strict 100 / ruff green. +- **E3** 큐/스택(operation_sequence/int_array) — B 선행 권장(배열 N=0 공유). +- **E4** 난이도 calibration — 출하분 Bronze 안착 확인(적재 시 backfill). diff --git a/ipe/v1/schema/__init__.py b/ipe/v1/schema/__init__.py index 31c39aa..f0d7970 100644 --- a/ipe/v1/schema/__init__.py +++ b/ipe/v1/schema/__init__.py @@ -29,6 +29,7 @@ ProblemSpec, SampleTestCase, TargetAlgorithm, + is_basic, ) from .qa import QAFinding, QAReport, QAReview, QAReviewerKind, QASeverity from .solution_attempt import Lesson, SolutionAttempt @@ -98,6 +99,7 @@ "StructuredFeedback", "TargetAlgorithm", "TargetNode", + "is_basic", "TestSuite", "VerificationResult", ] diff --git a/ipe/v1/schema/problem_spec.py b/ipe/v1/schema/problem_spec.py index 4077376..79de50b 100644 --- a/ipe/v1/schema/problem_spec.py +++ b/ipe/v1/schema/problem_spec.py @@ -43,6 +43,35 @@ class TargetAlgorithm(StrEnum): HEAP = "heap" FENWICK = "fenwick" COIN_CHANGE = "coin_change" + # 초급 카테고리 (easy track) — 알고리즘이 아니라 기초 스킬. is_basic() 으로 분류, + # strategist/formalizer 가 은닉/위장 대신 명확·직접 저작으로 분기. 난이도는 별도 + # 입력이 아니라 seed 에서 파생(단일소스). symbolic verifier 는 None-dispatch + # (golden/brute reconcile 가 검증). 입력은 스칼라/소형 배열이라 NullBackbone. + BASIC_IO = "basic_io" + ARITHMETIC = "arithmetic" + CONDITIONAL = "conditional" + LOOP_ACCUMULATE = "loop_accumulate" + + +# 초급 카테고리 단일소스 — easy 저작 분기의 진실. 별도 difficulty 입력 없이 seed 가 +# 난이도를 말한다(모순 불가: "easy dijkstra"/"hard basic_io" 상태가 안 생김). +_BASIC_TARGETS: frozenset[TargetAlgorithm] = frozenset( + { + TargetAlgorithm.BASIC_IO, + TargetAlgorithm.ARITHMETIC, + TargetAlgorithm.CONDITIONAL, + TargetAlgorithm.LOOP_ACCUMULATE, + } +) + + +def is_basic(target: TargetAlgorithm) -> bool: + """초급 카테고리(기초 스킬)인가 — easy 저작 분기 신호. + + True 면 strategist/formalizer 가 은닉/위장 대신 명확·직접 서술 + 작은 입력으로 + 저작한다. 난이도를 별도 입력으로 받지 않고 seed 에서 파생(단일소스·모순 불가). + """ + return target in _BASIC_TARGETS class ConstraintRange(BaseModel): diff --git a/ipe/v2/nodes/formalizer.py b/ipe/v2/nodes/formalizer.py index 052bfe9..b30d305 100644 --- a/ipe/v2/nodes/formalizer.py +++ b/ipe/v2/nodes/formalizer.py @@ -12,7 +12,7 @@ from collections.abc import Callable from typing import Protocol -from ipe.v1.schema import BlueprintFormalization, ProblemBlueprint +from ipe.v1.schema import BlueprintFormalization, ProblemBlueprint, is_basic from ..state import V2State @@ -133,6 +133,37 @@ """ +# 초급(easy track) formalizer system prompt — is_basic(seed) 일 때. 무거운 graph_shape/ +# sequence_shape/edge_case_semantics/tie-break 머신을 **뺀** 단순 계약: 작은 입력·스칼라 +# 또는 단순 배열·단순 출력·입력 의존. (알고리즘 경로의 그 머신이 binary_search/lis 의 +# 'N=0↔constraints'·sortedness 모순을 만든 표면 — 초급엔 불필요하고 위험.) +_EASY_SYSTEM_PROMPT = """\ +당신은 입문자용 코딩 문제 formalizer 다. 기초 카테고리(기본 입출력·산술/논리·조건 분기· +반복 누적)의 전략 시드를 받아, **간단하고 명확한** 입출력 형식 계약을 동결한다. + +typed BlueprintFormalization (구조화된 tool call) 로 반환 — 형식 면만: +- io_schema: + - inputs: 각 필드의 IOFieldSpec. type 은 대개 int 또는 int_array (필요시 string). + 크기·값 범위를 ConstraintRange 로 명시하되 **작게** 잡는다(입문 난이도): 스칼라 int 는 + 보통 [1, 1000], 산술은 [-1000000000, 1000000000], 배열 size 는 [1, 100] 수준. + 거대한 N(수만~수십만)을 쓰지 말 것 — 입문은 작은 입력이다. + - output_type: int / int_array / bool / string / yes_no 중 **가장 단순한** 것. + - output_format: 출력 인쇄 형식 (한 줄 설명). +- output_invariants: 출력이 항상 만족하는 간단한 관계 0~1개면 충분(예: non_negative). + +규율 (입문 평이함 — 알고리즘 구조 배제): +- 알고리즘/도메인을 재결정하지 말 것 — 시드의 reduction_core/domain 은 그대로 유지. +- **작고 단순하게**: 입력 필드 1~3개, 구조는 최소(스칼라 또는 단순 1차원 배열). graph/ + matrix/grid 같은 복잡 구조와 graph_shape/sequence_shape/string_shape 핀을 **쓰지 말 것** + — 기초 입출력·산술·조건·반복은 그런 구조가 필요 없다. +- **출력은 반드시 입력에 의존**한다 — 입력과 무관한 상수 출력 금지(퇴화 → difficulty reject). +- 입력을 읽어 간단히 계산(합·차·비교·카운트·누적)해 출력하는 수준. 정렬·이분탐색·그래프 + 같은 알고리즘을 끌어들이지 말 것(끌어들이면 입문이 아니다). +- 퇴화/경계 입력의 특별 처리(빈 입력·N=0 등)를 **지어내지 말 것** — constraints 가 허용하는 + 범위만 다루면 충분하다(없는 케이스를 서술하면 constraints 와 모순돼 reject 된다). +- collection 필드(int_array)는 자기 크기 헤더를 자체 포함한다(별도 개수 스칼라 추가 금지).""" + + def _build_user_prompt(state: V2State) -> str: strategy = state.strategy if strategy is None: @@ -171,15 +202,25 @@ def __init__(self, model: str = FORMALIZER_MODEL) -> None: from langchain_core.prompts import ChatPromptTemplate llm = ChatAnthropic(model_name=model, timeout=60, stop=None) + # 알고리즘(정밀 구조) 체인 — 비-basic seed. 기존과 동일(byte-identical). prompt = ChatPromptTemplate.from_messages( [("system", _SYSTEM_PROMPT), ("user", "{user}")] ) self._chain = ( prompt | llm.with_structured_output(BlueprintFormalization) ).with_retry(stop_after_attempt=5, wait_exponential_jitter=True) + # 초급(단순) 체인 — is_basic seed. 무거운 구조 머신 배제(N=0/sortedness 모순 표면 제거). + easy_prompt = ChatPromptTemplate.from_messages( + [("system", _EASY_SYSTEM_PROMPT), ("user", "{user}")] + ) + self._chain_easy = ( + easy_prompt | llm.with_structured_output(BlueprintFormalization) + ).with_retry(stop_after_attempt=5, wait_exponential_jitter=True) def formalize(self, state: V2State) -> BlueprintFormalization: - result = self._chain.invoke({"user": _build_user_prompt(state)}) + # 난이도는 seed 에서 파생(단일소스) — is_basic 이면 단순 형식 계약 경로. + chain = self._chain_easy if is_basic(state.seed_algorithm) else self._chain + result = chain.invoke({"user": _build_user_prompt(state)}) if not isinstance(result, BlueprintFormalization): msg = ( f"with_structured_output 가 {type(result).__name__} 반환 — " diff --git a/ipe/v2/nodes/qa_reviewer.py b/ipe/v2/nodes/qa_reviewer.py index 0077eb1..cb56eed 100644 --- a/ipe/v2/nodes/qa_reviewer.py +++ b/ipe/v2/nodes/qa_reviewer.py @@ -18,7 +18,7 @@ from collections.abc import Callable from typing import Any, Protocol -from ipe.v1.schema import QAReview, QAReviewerKind +from ipe.v1.schema import QAReview, QAReviewerKind, is_basic from ..generation.input_gen import format_constraint from ..state import V2State @@ -55,6 +55,17 @@ ), } +# 초급(is_basic) 문제 전용 완화 difficulty charter — 난이도-agnostic 원칙을 코드화. +# 표준 charter 의 'trivial 하게 풀리거나' 가 입문 문제를 '쉽다'는 이유로 reject 하던 것 +# (단순 곱셈·분기 하나·엣지 적음)을 차단. **진짜 퇴화(상수출력/모순)만** blocker. +_DIFFICULTY_CHARTER_EASY = ( + "난이도 일관성 (초급 문제) — 이 문제는 **입문자용 기초 문제**다. 단순하고 쉬운 것은 " + "**결함이 아니다**(의도된 난이도). '너무 쉽다/단순하다/한 연산으로 풀린다/엣지 케이스가 " + "적다'는 이유로 막지 말 것. **오직 진짜 퇴화만** blocker 로 본다: 입력과 무관한 상수 " + "출력(어떤 입력이든 같은 답), 명세상 불가능하거나 자기모순인 요구. 그 외 단순함은 " + "통과시킨다. 난이도 측정/calibration 은 범위 밖." +) + _SYSTEM_PROMPT_TEMPLATE = """\ 당신은 코딩테스트 문제 패키지의 QA 리뷰어다. 당신의 관점: {charter} @@ -131,6 +142,7 @@ def __init__( from langchain_anthropic import ChatAnthropic from langchain_core.prompts import ChatPromptTemplate + self._kind = kind llm = ChatAnthropic(model_name=model, timeout=60, stop=None) system = _SYSTEM_PROMPT_TEMPLATE.format(charter=_CHARTERS[kind], kind=kind) prompt = ChatPromptTemplate.from_messages( @@ -139,9 +151,27 @@ 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] + ) + easy_system = _SYSTEM_PROMPT_TEMPLATE.format(charter=easy_charter, kind=kind) + easy_prompt = ChatPromptTemplate.from_messages( + [("system", easy_system), ("user", "{user}")] + ) + self._chain_easy = ( + easy_prompt | llm.with_structured_output(QAReview) + ).with_retry(stop_after_attempt=5, wait_exponential_jitter=True) def review(self, state: V2State, *, kind: QAReviewerKind) -> QAReview: - result = self._chain.invoke({"user": _build_user_prompt(state)}) + # 초급(is_basic) + difficulty kind 만 완화 charter — '쉽다'고 막지 않는다. + chain = ( + self._chain_easy + if self._kind == "difficulty" and is_basic(state.seed_algorithm) + else self._chain + ) + result = chain.invoke({"user": _build_user_prompt(state)}) if not isinstance(result, QAReview): msg = ( f"with_structured_output 가 {type(result).__name__} 반환 — " diff --git a/ipe/v2/nodes/strategist.py b/ipe/v2/nodes/strategist.py index 84d5f43..9d4ce5e 100644 --- a/ipe/v2/nodes/strategist.py +++ b/ipe/v2/nodes/strategist.py @@ -21,7 +21,7 @@ from collections.abc import Callable from typing import Literal, Protocol -from ipe.v1.schema import StrategySeed, TargetAlgorithm +from ipe.v1.schema import StrategySeed, TargetAlgorithm, is_basic from ..state import V2State @@ -49,7 +49,11 @@ def _composition_palette(run_id: str, reduction_core: TargetAlgorithm) -> list[s """ digest = hashlib.sha256(run_id.encode("utf-8")).hexdigest() rng = random.Random(int(digest[:16], 16)) - candidates = [a.value for a in TargetAlgorithm if a != reduction_core] + candidates = [ + a.value + for a in TargetAlgorithm + if a != reduction_core and not is_basic(a) + ] rng.shuffle(candidates) return sorted(candidates[:_COMPOSITION_PALETTE_SIZE]) @@ -126,6 +130,29 @@ def _domain_palette(run_id: str) -> list[str]: 쓰지 않는다.""" +# 초급(easy track) system prompt — is_basic(seed) 일 때 사용. 알고리즘 은닉/위장이 +# 아니라 **명확·직접 서술**이 목표. composition 항상 빈값(단일 기초 스킬). 입력 의존 +# 출력 강제(상수 출력=퇴화 → difficulty 게이트 reject). 난이도=seed 파생(단일소스). +_EASY_SYSTEM_PROMPT = """\ +당신은 입문자용 코딩 문제 strategist 다. 주어진 기초 카테고리(기본 입출력·산술/논리· +조건 분기·반복 누적)를 받아, **명확하고 직접적인** 입문 문제의 전략 시드를 설계한다. +**은닉·위장이 목표가 아니다** — 풀이자가 무엇을 해야 하는지 지문에서 바로 알 수 있어야 한다. + +typed StrategySeed (구조화된 tool call) 로 반환: +- reduction_core: 주어진 기초 카테고리를 **그대로** 둔다 (숨기거나 다른 것으로 환원 금지). +- composition: **반드시 빈 list**. 입문 문제는 단일 기초 스킬만 다룬다 (합성 금지). +- domain: 입출력이 자연스럽게 읽히도록 돕는 **가벼운 현실 소재** (위장이 아니라 명확성 보조). + **반드시 user 메시지의 '도메인 팔레트(이번 run)' 안에서** 고른다. +- rationale: 이 소재가 기초 스킬을 어떻게 명확히 드러내는지 한 줄. + +핵심 목표 (명확성·평이함): 작은 입력, 한눈에 드러나는 요구. 알고리즘 지식이 필요 없고 +입력을 읽어 간단한 산술/조건/반복으로 계산해 출력하는 수준이어야 한다. 출력은 반드시 +**입력에 의존**해야 한다 — 입력과 무관한 상수 출력은 금지(퇴화 문제가 되어 reject 된다). +입력을 **그대로 되출력**(가공 없는 단순 echo)하는 것도 너무 자명해 reject 되니, 기초 +입출력이라도 **최소한의 계산·판정·변환**(합·차·비교·개수·간단한 포맷 변환 등)을 반드시 +하나는 포함해 풀이자가 '읽고 무언가 한 가지를 계산'하게 만든다.""" + + def _system_prompt(composition_mode: CompositionMode) -> str: """composition_mode 별 system prompt — single=합성 금지 / composed=합성 필수. @@ -191,6 +218,20 @@ def _build_user_prompt(state: V2State, composition_mode: CompositionMode) -> str return "\n".join(lines) +def _easy_user_prompt(state: V2State) -> str: + """초급(easy) user prompt — 기초 카테고리 + 도메인 팔레트 (합성 팔레트 없음).""" + dom_palette = _domain_palette(state.run_id) + return "\n".join( + [ + f"기초 카테고리: {state.seed_algorithm.value}", + f"run_id: {state.run_id}", + "", + "도메인 팔레트 (이번 run — domain 은 이 안에서 고른다):", + ", ".join(dom_palette), + ] + ) + + class StrategistLLM(Protocol): """Strategist 의 LLM dependency. test 가 mock 주입.""" @@ -215,17 +256,29 @@ def __init__( self._composition_mode = composition_mode llm = ChatAnthropic(model_name=model, timeout=60, stop=None) + # 알고리즘(은닉/합성) 체인 — 비-basic seed. 기존과 동일(byte-identical). prompt = ChatPromptTemplate.from_messages( [("system", _system_prompt(composition_mode)), ("user", "{user}")] ) self._chain = (prompt | llm.with_structured_output(StrategySeed)).with_retry( stop_after_attempt=5, wait_exponential_jitter=True ) + # 초급(명확) 체인 — is_basic seed. 은닉 대신 명확·직접 저작. + easy_prompt = ChatPromptTemplate.from_messages( + [("system", _EASY_SYSTEM_PROMPT), ("user", "{user}")] + ) + self._chain_easy = ( + easy_prompt | llm.with_structured_output(StrategySeed) + ).with_retry(stop_after_attempt=5, wait_exponential_jitter=True) def seed(self, state: V2State) -> StrategySeed: - result = self._chain.invoke( - {"user": _build_user_prompt(state, self._composition_mode)} - ) + # 난이도는 seed 에서 파생(단일소스) — is_basic 이면 초급 명확 저작 경로. + if is_basic(state.seed_algorithm): + result = self._chain_easy.invoke({"user": _easy_user_prompt(state)}) + else: + result = self._chain.invoke( + {"user": _build_user_prompt(state, self._composition_mode)} + ) if not isinstance(result, StrategySeed): msg = ( f"with_structured_output 가 {type(result).__name__} 반환 — " diff --git a/tests/v1/schema/test_problem_spec.py b/tests/v1/schema/test_problem_spec.py index 8b02e32..2577b6b 100644 --- a/tests/v1/schema/test_problem_spec.py +++ b/tests/v1/schema/test_problem_spec.py @@ -11,6 +11,7 @@ ProblemSpec, SampleTestCase, TargetAlgorithm, + is_basic, ) @@ -127,3 +128,46 @@ def test_problem_spec_target_algorithm_accepts_enum_value_str() -> None: base["target_algorithm"] = "dijkstra" spec = ProblemSpec.model_validate(base) assert spec.target_algorithm is TargetAlgorithm.DIJKSTRA + + +# --- 초급 카테고리 분류 (easy track E2a) -------------------------------------- + + +def test_basic_targets_are_valid_target_algorithm_members() -> None: + """초급 카테고리도 TargetAlgorithm StrEnum 멤버 — seed 어휘 단일 유지(Option A).""" + assert TargetAlgorithm("basic_io") is TargetAlgorithm.BASIC_IO + assert TargetAlgorithm.ARITHMETIC.value == "arithmetic" + assert TargetAlgorithm.CONDITIONAL.value == "conditional" + assert TargetAlgorithm.LOOP_ACCUMULATE.value == "loop_accumulate" + + +def test_is_basic_true_for_beginner_categories() -> None: + """초급 카테고리는 is_basic True (easy 저작 분기 신호 — 단일소스).""" + for target in ( + TargetAlgorithm.BASIC_IO, + TargetAlgorithm.ARITHMETIC, + TargetAlgorithm.CONDITIONAL, + TargetAlgorithm.LOOP_ACCUMULATE, + ): + assert is_basic(target) is True + + +def test_is_basic_false_for_algorithm_categories() -> None: + """알고리즘 카테고리는 is_basic False — standard 저작(byte-identical).""" + for target in ( + TargetAlgorithm.DIJKSTRA, + TargetAlgorithm.SORT, + TargetAlgorithm.BINARY_SEARCH, + TargetAlgorithm.SEGTREE, + TargetAlgorithm.STRING_MATCH, + TargetAlgorithm.KNAPSACK, + ): + assert is_basic(target) is False + + +def test_problem_spec_constructs_with_basic_target() -> None: + """초급 카테고리도 ProblemSpec 의 valid target_algorithm (파이프라인 단일 어휘).""" + base = _valid_spec().model_dump() + base["target_algorithm"] = "basic_io" + spec = ProblemSpec.model_validate(base) + assert spec.target_algorithm is TargetAlgorithm.BASIC_IO diff --git a/tests/v2/nodes/test_modeling_nodes.py b/tests/v2/nodes/test_modeling_nodes.py index 9a1ed56..7f3f7ff 100644 --- a/tests/v2/nodes/test_modeling_nodes.py +++ b/tests/v2/nodes/test_modeling_nodes.py @@ -20,6 +20,7 @@ ProblemBlueprint, StrategySeed, TargetAlgorithm, + is_basic, ) from ipe.v2.nodes import make_formalizer_node, make_strategist_node from ipe.v2.state import V2State, initial_v2_state @@ -222,13 +223,57 @@ def test_composition_palette_rotates_and_spreads_vocabulary() -> None: counter[v] += 1 # 다른 run_id 는 다른 팔레트를 만든다(고정 아님) assert _composition_palette("run-0", core) != _composition_palette("run-1", core) - # core(dijkstra) 외 18종 전부 제안됨 (사각지대 없음) - assert len(counter) == len(TargetAlgorithm) - 1 + # core(dijkstra) 외 non-basic 전부 제안됨 (basic 카테고리는 합성 후보서 제외) + non_basic = [a for a in TargetAlgorithm if not is_basic(a)] + assert len(counter) == len(non_basic) - 1 # 어느 기법도 과점하지 않음 — 균등 기대 ~7/18=38.9%, 상한 55% 로 여유 가드 top_share = counter.most_common(1)[0][1] / runs assert top_share < 0.55 +def test_composition_palette_excludes_basic_categories() -> None: + """easy 카테고리(basic_io 등)는 합성 후보 팔레트에서 제외 — P2 합성 문제가 + 기초 스킬과 합성하는 무의미한 조합 차단(어휘는 단일이되 합성 공간은 알고리즘만).""" + from ipe.v2.nodes.strategist import _composition_palette + + for i in range(60): + for v in _composition_palette(f"run-{i}", TargetAlgorithm.DIJKSTRA): + assert not is_basic(TargetAlgorithm(v)) + + +def test_strategist_easy_system_prompt_is_clarity_framed() -> None: + """is_basic seed 용 easy system prompt — 은닉이 아니라 명확·직접·단일·입력의존.""" + from ipe.v2.nodes.strategist import _EASY_SYSTEM_PROMPT + + assert "은닉·위장이 목표가 아니다" in _EASY_SYSTEM_PROMPT + assert "명확" in _EASY_SYSTEM_PROMPT + assert "빈 list" in _EASY_SYSTEM_PROMPT # composition 강제 빈값(단일) + assert "상수 출력은 금지" in _EASY_SYSTEM_PROMPT # 입력의존(퇴화 방어, R1) + assert "최소한의 계산" in _EASY_SYSTEM_PROMPT # 순수 echo 금지(triviality 바닥) + + +def test_strategist_easy_user_prompt_injects_domain_only() -> None: + """easy user prompt — 기초 카테고리 + 도메인 팔레트만(합성 팔레트 없음).""" + from ipe.v2.nodes.strategist import _easy_user_prompt + + state = initial_v2_state("run-easy", TargetAlgorithm.BASIC_IO) + prompt = _easy_user_prompt(state) + assert "기초 카테고리: basic_io" in prompt + assert "도메인 팔레트" in prompt + assert "합성 후보 팔레트" not in prompt # 단일 — 합성 없음 + + +def test_formalizer_easy_system_prompt_drops_heavy_structure() -> None: + """is_basic seed 용 formalizer easy prompt — 작은 입력·단순 구조·입력의존, + graph_shape/sequence_shape 같은 무거운 머신 배제(N=0/sortedness 모순 표면 제거).""" + from ipe.v2.nodes.formalizer import _EASY_SYSTEM_PROMPT + + assert "작게" in _EASY_SYSTEM_PROMPT # 작은 입력 범위 + assert "쓰지 말 것" in _EASY_SYSTEM_PROMPT # graph_shape/sequence_shape 배제 + assert "상수 출력 금지" in _EASY_SYSTEM_PROMPT # 입력의존(R1) + assert "지어내지 말 것" in _EASY_SYSTEM_PROMPT # 없는 N=0 케이스 날조 금지 + + def test_strategist_user_prompt_injects_palette() -> None: """node 가 user 프롬프트에 이번 run 팔레트를 주입 — LLM 이 그 안에서만 고름.""" from ipe.v2.nodes.strategist import _build_user_prompt, _composition_palette diff --git a/tests/v2/nodes/test_qa_reviewer.py b/tests/v2/nodes/test_qa_reviewer.py index 28ecb04..d8e3753 100644 --- a/tests/v2/nodes/test_qa_reviewer.py +++ b/tests/v2/nodes/test_qa_reviewer.py @@ -117,3 +117,14 @@ def test_factory_builds_all_four_kinds() -> None: ) out = node(_package_state()) assert out["qa_reviews"][0].kind == kind + + +def test_easy_difficulty_charter_does_not_block_simplicity() -> None: + """초급(is_basic) 완화 difficulty charter — '쉽다'는 이유로 막지 않고 진짜 퇴화만 + blocker. RFC 난이도-agnostic 원칙을 코드화. 표준 charter 와 구분(완화 적용).""" + from ipe.v2.nodes.qa_reviewer import _CHARTERS, _DIFFICULTY_CHARTER_EASY + + assert "결함이 아니다" in _DIFFICULTY_CHARTER_EASY # 단순함은 결함 아님 + assert "막지 말 것" in _DIFFICULTY_CHARTER_EASY # '쉽다'고 막지 않음 + assert "상수" in _DIFFICULTY_CHARTER_EASY # 진짜 퇴화(상수출력)는 여전히 차단 + assert _CHARTERS["difficulty"] != _DIFFICULTY_CHARTER_EASY # 표준과 다름 From 5788619f0a1a85ebab4e0ff2189491a2972e576b Mon Sep 17 00:00:00 2001 From: LsMin124 Date: Thu, 25 Jun 2026 16:18:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(v2):=20=EC=B4=88=EA=B8=89=20abstract/d?= =?UTF-8?q?omain=20orthogonal=20=EC=84=A0=ED=83=9D=20=E2=80=94=20=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=97=86=EB=8A=94=20=EB=A7=A8=20=EC=84=9C?= =?UTF-8?q?=EC=88=A0=20(E2.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 초급 문제의 스토리/도메인을 난이도처럼 orthogonal 축으로 분리. A+B 수준에 도메인 스토리는 군더더기이고 ambiguity(반올림·할인 부호 등)·domain 클러스터링의 원인이었다. - config: ABSTRACT_DOMAIN 센티넬(단일소스 — strategist set, formalizer/narrative read). - strategist: is_basic 에서 _easy_abstract(run_id) run-seeded 선택(abstract 선호 ~2/3), abstract 면 domain 스탬프. domained 경로 byte-identical. - formalizer: domain==abstract 면 추상 필드명(A,B,N,P)·수학적 output_format 지령 (narrative 와 io_schema 정합 — quantity↔N 불일치 QA reject 방지). - narrative: domain==abstract 면 스토리 없이 변수로 맨 서술(_ABSTRACT_SYSTEM_PROMPT). 실 LLM smoke: abstract 3/3 출하('정수 A,B가 주어진다. A×B를 구하라' 류, 서술·형식 변수 일치). domained 도 출하. 게이트 893 passed/mypy --strict 100/ruff. 비-abstract byte-identical. --- ipe/v2/config.py | 9 ++++++ ipe/v2/nodes/formalizer.py | 10 +++++++ ipe/v2/nodes/narrative.py | 41 ++++++++++++++++++++++++++- ipe/v2/nodes/strategist.py | 15 ++++++++++ tests/v2/nodes/test_modeling_nodes.py | 25 ++++++++++++++++ tests/v2/nodes/test_narrative_node.py | 11 +++++++ 6 files changed, 110 insertions(+), 1 deletion(-) diff --git a/ipe/v2/config.py b/ipe/v2/config.py index eebc861..92f19e1 100644 --- a/ipe/v2/config.py +++ b/ipe/v2/config.py @@ -47,6 +47,15 @@ def mode_knobs( return (True, "composed", QA_KINDS_P2) +# --------------------------------------------------------------------------- # +# 초급(easy track) abstract 도메인 센티넬 — 스토리/도메인 orthogonal 선택. # +# strategist 가 is_basic 문제에서 run_id-seeded 로 abstract 를 고르면 domain 을 # +# 이 값으로 스탬프 → narrative 가 도메인 스토리 대신 맨 수식/IO 로 직접 서술한다. # +# 단일 진실원천: strategist 가 set, narrative 가 read (난이도 seed-파생과 동형). # +# --------------------------------------------------------------------------- # +ABSTRACT_DOMAIN = "abstract" + + # --------------------------------------------------------------------------- # # 모델명 (golden / brute) — 교체 시 여기 한 곳만 # # --------------------------------------------------------------------------- # diff --git a/ipe/v2/nodes/formalizer.py b/ipe/v2/nodes/formalizer.py index b30d305..0ee07eb 100644 --- a/ipe/v2/nodes/formalizer.py +++ b/ipe/v2/nodes/formalizer.py @@ -14,6 +14,7 @@ from ipe.v1.schema import BlueprintFormalization, ProblemBlueprint, is_basic +from ..config import ABSTRACT_DOMAIN from ..state import V2State FORMALIZER_MODEL = "claude-opus-4-8" @@ -176,6 +177,15 @@ def _build_user_prompt(state: V2State) -> str: ] if strategy.rationale: parts.append(f"rationale: {strategy.rationale}") + if strategy.domain == ABSTRACT_DOMAIN: + # 초급 abstract(orthogonal) — narrative 가 변수로 맨 서술하므로 io_schema 도 + # 추상 필드명·출력형식으로 정합시킨다(quantity↔N 같은 불일치=QA reject 방지). + parts.append("") + parts.append( + "[추상 모드 — 도메인 스토리 없음]: io_schema 필드명을 추상 변수(A, B, N, P, " + "arr 등)로 두고, output_format 도 도메인 용어 없이 수학적으로 서술하라 " + "(quantity/price/balance 같은 도메인 명칭 금지). narrative 가 같은 변수로 서술한다." + ) # back-route 재진입 — 직전 IR 검증 위반을 수선 지시로 (첫 pass 는 validation=None # 이라 빈 추가 = 메인 경로 prompt 불변). narrative 의 QA 피드백 패턴과 동일. validation = state.validation diff --git a/ipe/v2/nodes/narrative.py b/ipe/v2/nodes/narrative.py index 734524d..a81c57f 100644 --- a/ipe/v2/nodes/narrative.py +++ b/ipe/v2/nodes/narrative.py @@ -19,6 +19,7 @@ from ipe.v1.schema import Narrative, NarrativeDraft from ..backbone import resolve_backbone +from ..config import ABSTRACT_DOMAIN from ..state import V2State NARRATIVE_MODEL = "claude-sonnet-4-6" @@ -90,6 +91,29 @@ """ +# 초급(easy track) abstract system prompt — domain==ABSTRACT_DOMAIN 일 때. 현실 도메인 +# 스토리 없이 변수(A,B,N)로 직접 서술. is_basic·P1(hidden=False) 전용이라 은닉 규율 불요. +_ABSTRACT_SYSTEM_PROMPT = """\ +당신은 입문자용 코딩 문제 author 다. 동결된 ProblemBlueprint(입출력 형식+불변식)를 받아, +**도메인 스토리 없이** 문제를 직접·추상적으로 서술한다 (입문 추상 모드). + +typed NarrativeDraft (구조화된 tool call) 로 반환: +- title: 무엇을 구하는지 짧고 직접적으로 (예: '두 정수의 합', '최댓값 찾기', '조건을 + 만족하는 개수'). 현실 도메인 스토리 없이 연산 자체를 가리킨다. +- scenario: 변수(A, B, N, 배열 원소 등)로 문제를 직접 서술한다. 현실 상황(은행·학교·물류 + 등)을 **지어내지 말 것** — 군더더기 없이 '무엇이 주어지고 무엇을 구하는지'만. 예: + "정수 A 와 B 가 주어진다. A 와 B 의 합을 구하라." + +규율: +- **입출력 '형식' 서술 금지**: 입력의 줄 구성/순서/개수, 인덱싱 규약, 변수 나열식 형식 + 정의, 구체 입력/출력 예시 블록을 쓰지 말 것. 형식의 단일 진실원천은 이후 단계의 '입력 + 형식' 섹션·샘플 테스트케이스다. scenario 는 무엇을 구하는지 의미만(io_schema 는 참고용). +- **풀이 방법('어떻게 푸는지') 서술 금지**: 무엇을 구하는지만 쓰고 알고리즘·자료구조·전략은 + 쓰지 않는다. +- output_invariants 의 경계/퇴화 케이스 의미(있으면)는 서술한다 — 없는 케이스는 지어내지 + 말 것(초급은 대개 경계 의미가 단순하거나 없다).""" + + # back-route(B) 재진입 시 QA findings 렌더 바운드 — 지적 해소 방향 재작성을 유도하되 # 프롬프트 폭주 방지 (finding 은 심각도순 일부, 본문 truncate). _QA_FEEDBACK_MAX_FINDINGS = 6 @@ -165,15 +189,30 @@ def __init__(self, model: str = NARRATIVE_MODEL) -> None: from langchain_core.prompts import ChatPromptTemplate llm = ChatAnthropic(model_name=model, timeout=60, stop=None) + # 도메인 시나리오 체인 — 기존(byte-identical). prompt = ChatPromptTemplate.from_messages( [("system", _SYSTEM_PROMPT), ("user", "{user}")] ) self._chain = (prompt | llm.with_structured_output(NarrativeDraft)).with_retry( stop_after_attempt=5, wait_exponential_jitter=True ) + # 초급 abstract 체인 — domain==ABSTRACT_DOMAIN 일 때 스토리 없이 맨 서술. + abstract_prompt = ChatPromptTemplate.from_messages( + [("system", _ABSTRACT_SYSTEM_PROMPT), ("user", "{user}")] + ) + self._chain_abstract = ( + abstract_prompt | llm.with_structured_output(NarrativeDraft) + ).with_retry(stop_after_attempt=5, wait_exponential_jitter=True) def render(self, state: V2State, *, hidden: bool) -> NarrativeDraft: - result = self._chain.invoke({"user": _build_user_prompt(state, hidden=hidden)}) + # 초급 abstract(domain 센티넬) → 맨 서술 체인, 그 외 → 도메인 시나리오(불변). + bp = state.blueprint + chain = ( + self._chain_abstract + if bp is not None and bp.domain == ABSTRACT_DOMAIN + else self._chain + ) + result = chain.invoke({"user": _build_user_prompt(state, hidden=hidden)}) if not isinstance(result, NarrativeDraft): msg = ( f"with_structured_output 가 {type(result).__name__} 반환 — " diff --git a/ipe/v2/nodes/strategist.py b/ipe/v2/nodes/strategist.py index 9d4ce5e..337d826 100644 --- a/ipe/v2/nodes/strategist.py +++ b/ipe/v2/nodes/strategist.py @@ -23,6 +23,7 @@ from ipe.v1.schema import StrategySeed, TargetAlgorithm, is_basic +from ..config import ABSTRACT_DOMAIN from ..state import V2State CompositionMode = Literal["single", "composed"] @@ -107,6 +108,17 @@ def _domain_palette(run_id: str) -> list[str]: return sorted(pool[:_DOMAIN_PALETTE_SIZE]) +def _easy_abstract(run_id: str) -> bool: + """초급 문제의 abstract(무스토리) vs domained 를 run_id 로 결정 — orthogonal 선택. + + 스토리/도메인은 초급에 군더더기라 **abstract 선호**(약 2/3). 도메인 팔레트와 동형의 + sha256 안정 seed(재현·run 분산). 비율은 여기 한 곳에서 조정. domained 면 기존 도메인 + 경로 그대로(byte-identical). + """ + digest = hashlib.sha256(f"abstract:{run_id}".encode()).hexdigest() + return int(digest[:8], 16) % 3 != 0 # 2/3 abstract + + # composition 모드별 지령 (Phase 4 — P1/P2 수렴). single=합성 금지 / composed=합성 필수. _COMPOSITION_DIRECTIVE_SINGLE = """\ composition (단일 모드 — 합성 금지): @@ -285,6 +297,9 @@ def seed(self, state: V2State) -> StrategySeed: "StrategySeed 기대" ) raise TypeError(msg) + # 초급 abstract 선택(orthogonal) — domain 을 센티넬로 스탬프(narrative 가 맨 서술). + if is_basic(state.seed_algorithm) and _easy_abstract(state.run_id): + result = result.model_copy(update={"domain": ABSTRACT_DOMAIN}) return result diff --git a/tests/v2/nodes/test_modeling_nodes.py b/tests/v2/nodes/test_modeling_nodes.py index 7f3f7ff..12bad3a 100644 --- a/tests/v2/nodes/test_modeling_nodes.py +++ b/tests/v2/nodes/test_modeling_nodes.py @@ -274,6 +274,31 @@ def test_formalizer_easy_system_prompt_drops_heavy_structure() -> None: assert "지어내지 말 것" in _EASY_SYSTEM_PROMPT # 없는 N=0 케이스 날조 금지 +def test_easy_abstract_is_deterministic_and_mixes() -> None: + """초급 abstract/domained orthogonal 선택 — run_id 결정적 + 다수 run 에 둘 다 출현 + (abstract 선호하되 도메인도 일부). 도메인 팔레트와 동형(sha256 안정 seed).""" + from ipe.v2.nodes.strategist import _easy_abstract + + assert _easy_abstract("run-xyz") == _easy_abstract("run-xyz") # 결정적 + flags = [_easy_abstract(f"run-{i}") for i in range(120)] + assert any(flags) and not all(flags) # 둘 다 출현(orthogonal mix) + assert sum(flags) / len(flags) > 0.5 # abstract 선호(~2/3) + + +def test_formalizer_user_prompt_abstract_directive() -> None: + """domain==ABSTRACT_DOMAIN 이면 formalizer user prompt 에 추상 필드명 지령 주입 — + narrative abstract(N/P 변수)와 io_schema(필드명/출력형식) 정합. 불일치=QA reject 방지.""" + from ipe.v2.config import ABSTRACT_DOMAIN + from ipe.v2.nodes.formalizer import _build_user_prompt + + state = initial_v2_state("r", TargetAlgorithm.BASIC_IO).model_copy( + update={"strategy": _seed().model_copy(update={"domain": ABSTRACT_DOMAIN})} + ) + prompt = _build_user_prompt(state) + assert "추상 모드" in prompt + assert "도메인 명칭 금지" in prompt + + def test_strategist_user_prompt_injects_palette() -> None: """node 가 user 프롬프트에 이번 run 팔레트를 주입 — LLM 이 그 안에서만 고름.""" from ipe.v2.nodes.strategist import _build_user_prompt, _composition_palette diff --git a/tests/v2/nodes/test_narrative_node.py b/tests/v2/nodes/test_narrative_node.py index f771c4f..6ebbcb0 100644 --- a/tests/v2/nodes/test_narrative_node.py +++ b/tests/v2/nodes/test_narrative_node.py @@ -214,3 +214,14 @@ def test_narrative_user_prompt_includes_qa_feedback_on_routeback() -> None: } ) assert "QA" not in _build_user_prompt(ok, hidden=True) # 통과면 미포함 + + +def test_narrative_abstract_prompt_drops_domain_story() -> None: + """domain==ABSTRACT_DOMAIN 용 abstract system prompt — 스토리 없이 변수로 맨 서술 + (초급 orthogonal abstract 선택, 스토리 군더더기 제거). 도메인 시나리오 prompt 와 구분.""" + from ipe.v2.nodes.narrative import _ABSTRACT_SYSTEM_PROMPT, _SYSTEM_PROMPT + + assert "도메인 스토리 없이" in _ABSTRACT_SYSTEM_PROMPT + assert "지어내지 말 것" in _ABSTRACT_SYSTEM_PROMPT # 현실 상황 날조 금지 + assert "A 와 B" in _ABSTRACT_SYSTEM_PROMPT # 변수 직접 서술 예시 + assert _ABSTRACT_SYSTEM_PROMPT != _SYSTEM_PROMPT # 도메인 시나리오 prompt 와 다름