From abe4316340e30126bee52734d632601444299495 Mon Sep 17 00:00:00 2001 From: LsMin124 Date: Tue, 23 Jun 2026 17:17:37 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(v2):=20authoring=20=EC=A0=95=ED=95=A9?= =?UTF-8?q?=EC=84=B1=20=EC=88=98=EC=84=A0=20=E2=80=94=20P1=20=EC=B6=9C?= =?UTF-8?q?=ED=95=98=EC=9C=A8=201/9=E2=86=926/9=20(=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EC=A7=84=EC=8B=A4=EC=9B=90=EC=B2=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 생성 노드들이 같은 사실을 독립 재진술해 경계에서 모순(QA reject)이 나던 것을, 사실을 io_schema 에서 코드로 투영해 구조적으로 제거. ① 입력생성 fix (references/cols): - IOFieldSpec.references — 정점/원소 참조 스칼라를 참조 대상 collection 의 실제 생성 크기 [1,V] 로 2-pass 바인딩(value_range·tier 무시). s,t 가 V 와 무관하게 [1,2](trivial)·V 초과(범위밖 RTE)로 생성되던 결함 해소. - IOFieldSpec.cols_range — int_matrix 열 수 고정(행 수=size_range 분리). 행별 속성 수가 흔들려 정해 IndexError 나던 sort 계열 해소. ② 정합성 fix (constraints 코드파생 + self-loop 단일진실): - render_constraints(io_schema) — constraints 코드 파생(LLM 저작 폐기). graph V 를 E 로 오라벨/V 누락/[1,2] 리터럴 하던 것 차단. - 샘플·그래프 empty bias 가 size_range.min 존중(V≥2 스키마에 V=1 입력 금지). - self-loop 를 serializer 진실에 단일 정렬: formalizer(invariant 정의 금지)+ narrative(서술 금지)+generator_designer(self_loop 카테고리 금지). 실측: P1 출하율 1/9(11%) → input-fix 4/9 → consistency 6/9(67%). seed별 dijkstra 0→2/3 · sort 0→3/3 · bfs 1/3. fail_synthesis 6→2. 게이트 779 passed(+16) / mypy 92 strict / ruff green. --- ipe/v1/schema/blueprint.py | 21 ++- ipe/v2/generation/input_gen.py | 237 +++++++++++++++++++++++---- ipe/v2/nodes/formalizer.py | 23 ++- ipe/v2/nodes/generator_designer.py | 20 +-- ipe/v2/nodes/narrative.py | 5 + ipe/v2/nodes/spec_bridge.py | 27 ++-- tests/v2/test_input_gen.py | 249 +++++++++++++++++++++++++++++ 7 files changed, 528 insertions(+), 54 deletions(-) diff --git a/ipe/v1/schema/blueprint.py b/ipe/v1/schema/blueprint.py index 44c806a..f87aa70 100644 --- a/ipe/v1/schema/blueprint.py +++ b/ipe/v1/schema/blueprint.py @@ -47,11 +47,30 @@ class IOFieldSpec(BaseModel): name: str = Field(..., min_length=1, description="필드 이름 (예: 'N', 'edges')") type: IOFieldType size_range: ConstraintRange | None = Field( - default=None, description="배열/행렬/그래프의 원소·정점 수 범위" + default=None, description="배열/행렬/그래프의 원소·정점·행 수 범위" ) value_range: ConstraintRange | None = Field( default=None, description="수치 값 범위 (가중치/원소값 등)" ) + references: str | None = Field( + default=None, + description=( + "정점/원소/행을 가리키는 **스칼라 int 참조** — 가리키는 collection 필드 " + "이름. 설정 시 입력 생성기가 그 필드의 **실제 생성 크기**에 맞춰 " + "[1, 실제크기] 1-indexed 로 생성한다(value_range·tier 무시). 정점 질의 " + "s/t 가 V 와 무관하게 [1,2](trivial)·V 초과(범위밖 RTE)로 생성되던 결함의 " + "구조적 해소 — 차원이 데이터 의존이라 정적 range 로 표현 불가하기 때문." + ), + ) + cols_range: ConstraintRange | None = Field( + default=None, + description=( + "int_matrix/grid 의 **열 수(C) 범위** — size_range 는 행 수(N). 레코드가 " + "고정 K개 속성을 가지면 [K,K] 로 고정한다. None 이면 열 수도 size_range " + "에서(현행). 열 수가 무작위라 행별 속성 수가 흔들려 정해가 IndexError 로 " + "깨지던 결함의 해소." + ), + ) description: str = "" diff --git a/ipe/v2/generation/input_gen.py b/ipe/v2/generation/input_gen.py index f5fa42f..ce20bca 100644 --- a/ipe/v2/generation/input_gen.py +++ b/ipe/v2/generation/input_gen.py @@ -19,13 +19,19 @@ - 여러 필드는 io_schema 순서로 줄 join. graph 필드는 **self-contained** (V/E 헤더 포함) — V:int 필드를 따로 두는 분리 모델링 -은 중복/모순을 낳으므로 formalizer prompt 가 단일 graph 필드로 유도한다. 정점 참조 -스칼라(s/t 등)의 value_range ↔ V 결합은 formalizer 책임(size 하한 이내). 규약↔골든 -파서 정합은 assembled 비율 anchor 로 실측(known item). +은 중복/모순을 낳으므로 formalizer prompt 가 단일 graph 필드로 유도한다. + +정점/원소 참조 스칼라(s/t/질의 index)는 ``IOFieldSpec.references`` 로 가리키는 +collection 필드명을 선언하면, 2-pass 직렬화가 그 필드의 **실제 생성 크기**에 맞춰 +``[1, 실제크기]`` 1-indexed 로 생성한다(value_range·tier 무시). 정적 ConstraintRange +로는 데이터 의존 차원을 표현할 수 없어 s 가 V 와 무관하게 ``[1,2]``(trivial)·V 초과 +(범위밖 RTE)로 생성되던 결함의 구조적 해소. int_matrix/grid 의 열 수는 ``cols_range`` +로 행 수(size_range)와 분리 고정(레코드 고정 K 속성). 규약↔골든 파서 정합은 assembled +비율 anchor 로 실측(known item). tier 적용: ScaleFamily.field_bounds(이름=필드명)는 스칼라의 **값**, sized 타입의 **크기**(graph 는 정점 수 V)를 그 tier 로 좁힌다. 원소/가중치 값은 io_schema 의 -value_range. +value_range. 참조 스칼라는 tier 를 보지 않는다(실제 크기에 바인딩). """ from __future__ import annotations @@ -34,11 +40,10 @@ import random from typing import TYPE_CHECKING, Literal -from ipe.v1.schema import GeneratedTestCase +from ipe.v1.schema import ConstraintRange, GeneratedTestCase if TYPE_CHECKING: from ipe.v1.schema import ( - ConstraintRange, GeneratorContract, IOFieldSpec, IOSchema, @@ -90,15 +95,137 @@ def seed_from_run_id(run_id: str) -> int: def _render_field(field: IOFieldSpec) -> str: + if _is_reference(field): + # 참조 스칼라 — 가리키는 collection 의 1-indexed 원소/정점 번호 (1 이상 그 크기 이하). + return ( + f"{field.name}: 한 줄에 정수 하나 — {field.references} 의 원소/정점을 가리키는 " + f"1-indexed 번호 (1 이상 {field.references} 의 크기 이하)." + ) if field.type == "tree_edges": edge_line = "'u v w'(간선과 정수 가중치)" if field.value_range else "'u v'" return ( f"{field.name}: 첫 줄에 정점 수 V, 이어서 V-1 줄에 {edge_line}. " "정점 번호는 1..V (1-indexed), 트리(연결·무사이클) 보장." ) + if field.type in ("int_matrix", "grid") and field.cols_range is not None: + cr = field.cols_range + cols = ( + f"열 수 C={cr.min_value} 고정" + if cr.min_value == cr.max_value + else f"열 수 C∈[{cr.min_value}..{cr.max_value}]" + ) + return ( + f"{field.name}: 첫 줄에 'R C'(행 수, {cols}), 이어서 R 줄에 각 C 개의 " + "공백구분 정수." + ) return f"{field.name}: {_FORMAT_TEXT[field.type]}" +def describe_io_field(field: IOFieldSpec) -> str: + """io_schema 한 필드의 설계용 요약 (design 프롬프트 공용) — name:type + 범위/참조/열수. + + spec_bridge·generator_designer 가 동일 포맷으로 LLM 에 필드를 기술하게 한다(DRY). + 참조 스칼라는 ``→refs X(1..|X|)`` 로, 고정 열 행렬은 ``cols[K..K]`` 로 노출해 + constraint/field_bounds 저작이 trivial [1,2] 대신 honest 한 관계를 쓰게 한다. + """ + head = f"{field.name}:{field.type}" + if _is_reference(field): + return f"{head} →refs {field.references}(1..|{field.references}|)" + rng = "" + if field.size_range is not None: + rng += f" size[{field.size_range.min_value}..{field.size_range.max_value}]" + if field.cols_range is not None: + rng += f" cols[{field.cols_range.min_value}..{field.cols_range.max_value}]" + if field.value_range is not None: + rng += f" val[{field.value_range.min_value}..{field.value_range.max_value}]" + return head + rng + + +# 컬렉션 size 차원의 관용 기호 + 한국어 라벨 (constraints 코드 파생용). +_SIZE_SYMBOL: dict[str, tuple[str, str]] = { + "weighted_edges": ("V", "정점 수"), + "tree_edges": ("V", "정점 수"), + "int_array": ("N", "원소 개수"), + "int_matrix": ("R", "행 수"), + "grid": ("R", "행 수"), +} + + +def render_constraints(io_schema: IOSchema) -> list[ConstraintRange]: + """io_schema → constraints 코드 파생 (LLM 저작 대체 — 단일 진실원천 투영). + + spec_bridge LLM 이 constraints 를 손저작하면 graph 의 V 를 E 로 오라벨하거나 V 범위 + 자체를 누락하고, 참조 스칼라를 ``[1,2]`` 같은 리터럴로 적어 QA ambiguity 로 reject + 됐다(N=18 실측). io_schema 에서 코드로 파생하면 io_contract·parser·생성기와 **같은 + 규약**을 보므로 드리프트가 사라진다. 참조 스칼라는 가리키는 컬렉션 크기에 묶고 + (``1 ≤ s ≤ V``), 행렬 고정 열은 ``C=K`` 로, 컬렉션은 size(V/N/R)+value 를 명시한다. + """ + sized = {f.name: f for f in io_schema.inputs} + out: list[ConstraintRange] = [] + for f in io_schema.inputs: + if _is_reference(f): + ref = sized.get(f.references) if f.references is not None else None + hi = ( + ref.size_range.max_value + if ref is not None and ref.size_range is not None + else 1 + ) + out.append( + ConstraintRange( + name=f.name, + min_value=1, + max_value=hi, + description=( + f"{f.references} 의 1-indexed 번호 " + f"(1 이상 {f.references} 의 크기 이하)" + ), + ) + ) + continue + if f.type in ("int", "bool", "float"): + if f.value_range is not None: + out.append( + ConstraintRange( + name=f.name, + min_value=f.value_range.min_value, + max_value=f.value_range.max_value, + description=f"{f.name} 값", + ) + ) + continue + # collection (array/matrix/graph) + if f.size_range is not None and f.type in _SIZE_SYMBOL: + sym, label = _SIZE_SYMBOL[f.type] + out.append( + ConstraintRange( + name=sym, + min_value=f.size_range.min_value, + max_value=f.size_range.max_value, + description=f"{f.name} 의 {label}", + ) + ) + if f.cols_range is not None: + out.append( + ConstraintRange( + name="C", + min_value=f.cols_range.min_value, + max_value=f.cols_range.max_value, + description=f"{f.name} 의 열(속성) 수", + ) + ) + if f.value_range is not None: + is_graph = f.type in ("weighted_edges", "tree_edges") + out.append( + ConstraintRange( + name="w" if is_graph else f"{f.name}_값", + min_value=f.value_range.min_value, + max_value=f.value_range.max_value, + description="간선 가중치" if is_graph else f"{f.name} 원소 값", + ) + ) + return out + + def render_input_format(io_schema: IOSchema) -> str: """io_schema → 입력 형식 명세 prose — ``generate_inputs`` 직렬화와 동일 규약. @@ -137,6 +264,11 @@ def generate_inputs( return tuple(cases) +def _is_reference(field: IOFieldSpec) -> bool: + """참조 스칼라 여부 — int 타입 + references 지정 (방어: 비-int references 는 무시).""" + return field.type == "int" and field.references is not None + + def _serialize_inputs( io_schema: IOSchema, tier_bounds: dict[str, ConstraintRange], @@ -144,11 +276,24 @@ def _serialize_inputs( *, bias: _Bias, ) -> str: - parts = [ - _serialize_field(f, tier_bounds.get(f.name), rng, bias=bias) - for f in io_schema.inputs - ] - return "\n".join(parts) + """2-pass — 비참조 필드 먼저(실제 addressable 크기 기록) → 참조 스칼라를 그 크기에 + 바인딩. 참조 없는 schema 는 1-pass 와 동일 rng 순서(기존 출력 보존). 선언 순서로 join. + """ + texts: dict[str, str] = {} + sizes: dict[str, int] = {} + deferred: list[IOFieldSpec] = [] + for f in io_schema.inputs: + if _is_reference(f): + deferred.append(f) # pass 2 — 참조 대상 크기 확정 후 생성 + continue + text, size = _serialize_field(f, tier_bounds.get(f.name), rng, bias=bias) + texts[f.name] = text + if size is not None: + sizes[f.name] = size + 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) + return "\n".join(texts[f.name] for f in io_schema.inputs) def _serialize_field( @@ -157,19 +302,22 @@ def _serialize_field( rng: random.Random, *, bias: _Bias, -) -> str: +) -> tuple[str, int | None]: + """필드 직렬화 → (text, addressable 크기). 크기는 참조 스칼라가 바인딩할 대상 + (배열 N · 행렬 R · 그래프 V); 순수 스칼라(int/bool/float)는 None. + """ t = field.type if t == "int": - return str(_pick_value(_value_bounds(field, tier_bound), bias, rng)) + return str(_pick_value(_value_bounds(field, tier_bound), bias, rng)), None if t == "bool": - return "1" if _bool_value(bias, rng) else "0" + return ("1" if _bool_value(bias, rng) else "0"), None if t == "float": lo, hi = _value_bounds(field, tier_bound) - return f"{_pick_float(lo, hi, bias, rng):.4f}" + 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) # 길이 캡 - return "".join(rng.choice(_ALPHABET) for _ in range(n)) + return "".join(rng.choice(_ALPHABET) for _ in range(n)), 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 규약 @@ -182,20 +330,33 @@ def _serialize_field( raise NotImplementedError(msg) +def _serialize_reference( + ref_size: int | None, bias: _Bias, rng: random.Random +) -> str: + """참조 스칼라 값 — 참조 대상의 **실제 크기**에 1-indexed 바인딩 [1, size]. + + value_range·tier 를 보지 않는다 (정적 range 로 표현 불가한 데이터 의존 차원). dangling + /빈 컬렉션이면 size=1 로 안전 default(crash 회피). bias 는 경계 선택(min/empty→1, + max→size). + """ + size = max(ref_size if ref_size is not None else 1, 1) + return str(_pick_value((1, size), bias, rng)) + + def _serialize_int_array( field: IOFieldSpec, tier_bound: ConstraintRange | None, rng: random.Random, *, bias: _Bias, -) -> str: +) -> tuple[str, int]: n = _pick_size(_size_bounds(field, tier_bound), bias, rng) if n <= 0: - return "0" + return "0", 0 n = min(n, _MAX_ELEMENTS) # 원소 총량 캡 (패키지 비대화/생성 OOM 차단) lo, hi = _element_bounds(field) vals = " ".join(str(rng.randint(lo, hi)) for _ in range(n)) - return f"{n}\n{vals}" + return f"{n}\n{vals}", n # 크기 = 원소 개수 N (참조 바인딩 대상) def _cap_matrix(r: int, c: int) -> tuple[int, int]: @@ -213,18 +374,20 @@ def _serialize_int_matrix( rng: random.Random, *, bias: _Bias, -) -> str: +) -> tuple[str, int]: size = _size_bounds(field, tier_bound) r = _pick_size(size, bias, rng) - c = _pick_size(size, bias, rng) + # 열 수: cols_range 가 있으면 그 범위(레코드 고정 K 속성), 없으면 size 와 동일(현행). + cols = _range_or(field.cols_range, size) + c = _pick_size(cols, bias, rng) if r <= 0 or c <= 0: - return f"{max(r, 0)} {max(c, 0)}" + return f"{max(r, 0)} {max(c, 0)}", max(r, 0) r, c = _cap_matrix(r, c) # R*C 총량 캡 lo, hi = _element_bounds(field) rows = "\n".join( " ".join(str(rng.randint(lo, hi)) for _ in range(c)) for _ in range(r) ) - return f"{r} {c}\n{rows}" + return f"{r} {c}\n{rows}", r # 크기 = 행 수 R (참조 바인딩 대상) # ---------- graph serialization (step3b) ---------- @@ -255,10 +418,16 @@ def _serialize_weighted_edges( rng: random.Random, *, bias: _Bias, -) -> str: - """``V E`` + E 줄 ``u v w`` (1-indexed). backbone 연결 + bias 별 밀도/구조.""" +) -> tuple[str, int]: + """``V E`` + E 줄 ``u v w`` (1-indexed). backbone 연결 + bias 별 밀도/구조. + + 반환 크기 = 정점 수 V (참조 스칼라 s/t 가 [1,V] 로 바인딩할 대상). + """ if bias == "empty": - return "1 0" # 단일 정점, 간선 0 (퇴화 최소 그래프) + # 퇴화 최소 그래프 — 단, size_range.min 을 존중(V≥2 스키마면 '2 0'). + # 하한 무시하고 V=1 을 내면 constraints(V≥min)와 모순돼 QA reject(N=18 실측). + vmin = max(_size_bounds(field, tier_bound)[0], 1) + return f"{vmin} 0", vmin # V_min 정점, 간선 0 v = _graph_vertex_count(field, tier_bound, rng, bias) v = min(v, _MAX_ELEMENTS // 2) # E ≤ 2V → 간선 총량 캡 if bias == "disconnected": @@ -277,7 +446,7 @@ def _serialize_weighted_edges( edges.append((u, t)) lo, hi = _element_bounds(field) # value_range = 가중치 lines = [f"{u} {t} {rng.randint(lo, hi)}" for u, t in edges] - return "\n".join([f"{v} {len(edges)}", *lines]) + return "\n".join([f"{v} {len(edges)}", *lines]), v def _serialize_tree_edges( @@ -286,22 +455,26 @@ def _serialize_tree_edges( rng: random.Random, *, bias: _Bias, -) -> str: +) -> tuple[str, int]: """``V`` + (V-1) 줄 ``u v`` (value_range 있으면 ``u v w``). 랜덤 부착 트리. - 트리는 정의상 연결 — disconnected bias 는 크기 random 으로만 작용. + 트리는 정의상 연결 — disconnected bias 는 크기 random 으로만 작용. 반환 크기 = V. """ if bias == "empty": - return "1" # 단일 정점 트리 - v = _graph_vertex_count(field, tier_bound, rng, bias) + # 최소 트리 — size_range.min 존중(V≥2 스키마면 단일 정점이 아니라 V_min 트리). + 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 간선 + if v <= 1: + return "1", 1 # 단일 정점 트리 edges = _backbone(1, v, 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] else: lines = [f"{u} {t}" for u, t in edges] - return "\n".join([str(v), *lines]) + return "\n".join([str(v), *lines]), v # ---------- bounds resolution ---------- diff --git a/ipe/v2/nodes/formalizer.py b/ipe/v2/nodes/formalizer.py index 61907c3..4c013fc 100644 --- a/ipe/v2/nodes/formalizer.py +++ b/ipe/v2/nodes/formalizer.py @@ -42,8 +42,14 @@ - graph 입력은 **self-contained 단일 필드** 로 모델링한다 (weighted_edges/tree_edges 는 V·E 헤더를 자체 포함 — 정점 수 V 를 별도 int 필드로 분리하지 말 것: 입력 생성기 와 중복/모순을 만든다). 정점 수 범위는 그 graph 필드의 size_range 로 표현한다. -- 정점을 참조하는 스칼라 필드(출발/도착 s, t 등)의 value_range 는 graph 필드 - size_range 의 **하한 이내** 로 잡는다 (V 가 하한일 때도 유효한 정점이도록). +- 정점/원소/행을 가리키는 **스칼라 int 참조 필드**(출발/도착 s, t, 질의 인덱스 등)는 + value_range 를 **직접 잡지 말고** ``references`` 에 가리키는 collection 필드 이름을 + 지정한다 (예: s.type=int, s.references="grid"). 입력 생성기가 그 필드의 **실제 생성 + 크기**에 맞춰 ``[1, 실제크기]`` 1-indexed 로 생성한다. value_range 로는 데이터 의존 + 차원(V 는 1~수십만 가변)을 표현할 수 없다 — 정적 ``[1,2]`` 로 잡으면 질의가 1·2 뿐인 + **trivial 퇴화**(QA difficulty reject), ``[1,V상한]`` 으로 잡으면 작은 그래프에서 + **V 초과 범위밖 입력**(정해 IndexError → fail_synthesis)이 된다. ``references`` 가 + 둘 다 구조적으로 차단한다 (정점 질의는 거의 항상 이 방식). - **중복 카운트 금지** (위 graph 규율을 모든 collection 으로 일반화): collection 필드(int_array/int_matrix/grid/weighted_edges/tree_edges)는 canonical 직렬화에 **자기 크기 헤더**(원소 개수 N / 행·열 R C / 정점·간선 V E)를 **자기접두**로 자체 @@ -52,6 +58,12 @@ N, 그리고 배열 자체 헤더 → `5\\n5\\n3 1 4 1 5`) solver 가 입력 줄 수를 확정 못 하는 모호 입력이 되어 QA ambiguity 게이트에서 reject 된다. collection 의 크기는 그 필드의 size_range 로만 표현한다. +- int_matrix/grid 에서 각 레코드(행)가 **고정 개수 K개 속성**을 가지면 (예: '각 거래는 + [시각, 금액] 2개 값', '각 점은 [x, y, z] 좌표') ``cols_range=[K,K]`` 로 열 수를 + 고정하고 ``size_range`` 는 행 수 N 으로 둔다. 고정하지 않으면 생성기가 열 수도 + 무작위로 잡아 행마다 속성 수가 흔들리고, 정해가 ``row[2]`` 같은 고정 인덱스에서 + IndexError 로 깨진다(sort 계열 fail_synthesis 실측). 열 수가 진짜 가변이어야 하는 + 문제(가변 길이 행)만 ``cols_range`` 를 비운다. - io_schema 는 필드 집합이 **자기완결적 의미 정합**이어야 한다: 임계값/예산/필터 같은 비교용 스칼라 필드는 그 **비교 대상**이 되는 per-element 데이터가 io_schema 안에 실제로 존재할 때만 추가한다 (예: capacity_threshold 를 두려면 간선별 capacity @@ -67,6 +79,13 @@ **다속성**을 요구하는 설계(예: 손실+내경 두 값)는 금지. 필터/임계값류 합성은 그 단일 w 에 대한 조건으로 설계한다 (예: 'w ≤ t 인 간선만 사용 가능할 때의 최단 경로' — 임계값은 별도 스칼라 필드). +- **graph canonical 구조 사실(불변·이것만 진실)**: weighted_edges/tree_edges 입력에는 + ① **self-loop(자기 자신으로의 간선)이 절대 없다** ② weighted_edges 는 다중 간선 + 허용·연결 비보장(분리 컴포넌트 가능), tree_edges 는 트리(연결·무사이클) 보장. + edge_case_semantics 는 **실제 발생 가능한** 구조에 대해서만 정의한다 — 다중 간선, + 분리(도달 불가), 시작==끝, V 하한. **self-loop 의 처리 의미를 정의하지 말 것** + (입력에 없으므로). self-loop 동작을 invariant 에 적으면 narrative 가 그걸 서술하고 + 형식 계약('self-loop 없음')·채점셋과 3중 모순을 일으켜 QA 가 reject 한다(N=18 실측). - io_schema 가 허용하는 **퇴화/경계 입력**의 출력 의미를 output_invariants 에 명시적으로 **결정**해 둔다 (kind 예: edge_case_semantics): 시작==끝 같은 동일 지점 케이스의 출력값, **도달 불가**·해 없음 케이스의 출력값(예: -1)과 그것이 diff --git a/ipe/v2/nodes/generator_designer.py b/ipe/v2/nodes/generator_designer.py index cef3194..fc14384 100644 --- a/ipe/v2/nodes/generator_designer.py +++ b/ipe/v2/nodes/generator_designer.py @@ -20,6 +20,7 @@ from ipe.v1.schema import GeneratorContract +from ..generation.input_gen import describe_io_field from ..state import V2State GENERATOR_DESIGNER_MODEL = "claude-opus-4-8" @@ -38,11 +39,19 @@ stress 는 성능·정확성 경계용 더 많이. - field_bounds: 이 tier 의 per-field 크기/값 범위 (ConstraintRange list). **io_schema 의 field 이름을 그대로** 쓰고, io_schema 의 전체 범위를 이 tier 로 **좁힌다** - (절대 io_schema 상한을 넘기지 말 것). 비우면 io_schema 기본 범위. + (절대 io_schema 상한을 넘기지 말 것). 비우면 io_schema 기본 범위. 참조 스칼라 + (``→refs X`` 표시)는 생성기가 참조 대상 X 의 실제 크기에 자동 바인딩하므로 + **field_bounds 를 주지 말 것**(줘도 무시됨). 컬렉션 X 의 size 만 tier 로 좁히면 된다. - description: 이 tier 가 무엇을 노리는지 한 줄. - edge_cases: 반드시 포함할 경계/퇴화 입력들 (EdgeCaseSpec name + description). 이 알고리즘에서 자주 틀리는 케이스 (예: 'empty'/'single'/'all_equal'/'disconnected'/ 'max_size'/'negative_zero_weights' 등 — reduction_core 에 맞게). + **실현 가능한 종류만** 쓸 것 — 생성기는 크기/밀도 경계만 만든다: empty·single· + min/small(하한), max/large/stress(상한), disconnected/unreachable(분리, 그래프). + 생성기가 **만들 수 없는 구조를 카테고리로 만들지 말 것** — 특히 ``self_loop`` + (canonical 그래프엔 self-loop 가 없다)·specific 위상은 금지. 이런 카테고리는 + 채점셋 이름으로 남아 형식 계약과 모순돼 QA 가 reject 한다(N=18 실측). 카테고리 + 이름은 **실제 생성되는 입력**을 반영해야 한다. - determinism_seed: 보통 비워둔다 (생성기가 선택). - notes: 생성 시 주의점 (선택). @@ -60,14 +69,7 @@ def _build_user_prompt(state: V2State) -> str: if bp is None: msg = "generator_designer requires state.blueprint — formalizer must run first" raise ValueError(msg) - fields = [] - for f in bp.io_schema.inputs: - rng = "" - if f.size_range is not None: - rng += f" size[{f.size_range.min_value}..{f.size_range.max_value}]" - if f.value_range is not None: - rng += f" val[{f.value_range.min_value}..{f.value_range.max_value}]" - fields.append(f"{f.name}:{f.type}{rng}") + fields = [describe_io_field(f) for f in bp.io_schema.inputs] invariants = [f"{iv.kind}: {iv.description}" for iv in bp.output_invariants] return "\n".join( [ diff --git a/ipe/v2/nodes/narrative.py b/ipe/v2/nodes/narrative.py index 17aa80a..7be1aa0 100644 --- a/ipe/v2/nodes/narrative.py +++ b/ipe/v2/nodes/narrative.py @@ -65,6 +65,11 @@ 금지된 '형식' 서술이 아니라 **출력 의미의 일부**다 (예: "출발지와 목적지가 같으면 답은 0 이다", "도달할 수 없는 경우 -1 을 출력한다"). 퇴화 케이스 동작이 지문에 없으면 solver 가 해석을 강요받는 모호 문제가 되어 QA 에서 reject 된다. + 단 **output_invariants 에 실제로 있는 케이스만** 서술한다 — 없는 동작을 지어내지 말 + 것. 특히 그래프 입력에는 **self-loop(자기 자신으로의 간선)이 존재하지 않으므로** + '자기 루프가 있을 수 있다/처리한다' 류를 **절대 쓰지 말 것** (형식 계약은 'self-loop + 없음'으로 고정 — 서술하면 3중 모순으로 QA reject). 발생 가능한 그래프 퇴화는 다중 + 간선·분리(도달 불가)·시작==끝뿐이다. - output_invariants 의 **답 유일성/동률 해소**(answer_uniqueness)도 퇴화 의미와 마찬가지로 지문에 **의미 수준으로 서술**한다(형식 서술 아님 — 출력 의미의 일부): 답이 유일하게 정해진다는 점과, 동률이 생길 수 있는 경우(같은 정렬 키, 복수 최적해, diff --git a/ipe/v2/nodes/spec_bridge.py b/ipe/v2/nodes/spec_bridge.py index bbde44c..ae1fd19 100644 --- a/ipe/v2/nodes/spec_bridge.py +++ b/ipe/v2/nodes/spec_bridge.py @@ -38,7 +38,9 @@ ) from ..generation.input_gen import ( + describe_io_field, generate_inputs, + render_constraints, render_input_format, seed_from_run_id, ) @@ -92,7 +94,13 @@ def _generate_sample_inputs(io_schema: IOSchema, run_id: str) -> list[str]: """ schema = _sample_io_schema(io_schema) bounds = tuple( - ConstraintRange(name=f.name, min_value=1, max_value=_SAMPLE_SIZE_MAX) + ConstraintRange( + name=f.name, + # size_range.min 존중(상한 sample max 로 클램프) — min=1 강제 시 V≥2 스키마에 + # V=1 샘플('1 0')이 생성돼 코드파생 constraints(V≥2)와 모순돼 QA reject(실측). + min_value=min(f.size_range.min_value, _SAMPLE_SIZE_MAX), + max_value=_SAMPLE_SIZE_MAX, + ) for f in schema.inputs if f.size_range is not None ) @@ -118,7 +126,9 @@ def _generate_sample_inputs(io_schema: IOSchema, run_id: str) -> list[str]: - description: 짧은 placeholder (node 가 narrative 로 대체하니 1줄이면 충분). - io_contract: user 메시지의 '입력 형식 (동결)' 텍스트를 input_format 에, io_schema. output_format 을 output_format 에 그대로 (node 가 어차피 canonical 로 강제한다). -- constraints: io_schema 의 size_range/value_range 를 ConstraintRange list 로. +- constraints: **비워둔다(빈 list)** — node 가 io_schema 에서 코드로 파생해 강제 + 주입한다 (V/N/R 크기·참조 ``1≤s≤V``·고정 열·값 범위). LLM 손저작은 graph V 를 E 로 + 오라벨하거나 V 를 누락해 QA reject 하던 원인이라 제거했다. - sample_testcases: 3~5개. **input_text 만 작성하고 expected_output 은 빈 문자열("")** 로 둔다 — 정답은 하류에서 검증된 golden 실행으로 자동 채운다 (직접 계산 금지: LLM 손계산은 토큰 낭비이자 오답[sample_mismatch]의 원인). @@ -145,14 +155,7 @@ def _build_user_prompt(state: V2State) -> str: if bp is None or nar is None: msg = "spec_bridge requires state.blueprint and state.narrative" raise ValueError(msg) - fields = [] - for f in bp.io_schema.inputs: - rng = "" - if f.size_range is not None: - rng += f" size[{f.size_range.min_value}..{f.size_range.max_value}]" - if f.value_range is not None: - rng += f" val[{f.value_range.min_value}..{f.value_range.max_value}]" - fields.append(f"{f.name}:{f.type}{rng}") + fields = [describe_io_field(f) for f in bp.io_schema.inputs] invariants = [f"{iv.kind}: {iv.description}" for iv in bp.output_invariants] return "\n".join( [ @@ -237,6 +240,10 @@ def node(state: V2State) -> V2State: update={ "target_algorithm": bp.reduction_core, "description": nar.scenario, + # constraints 도 코드 파생(freeze) — LLM 손저작이 graph V 를 E 로 오라벨/ + # V 누락/참조를 [1,2] 리터럴로 적어 QA reject 하던 것을 io_schema 투영으로 + # 차단(io_contract/parser/생성기와 같은 단일 규약). + "constraints": render_constraints(bp.io_schema), # step6: 형식 계약은 코드가 정한다 — 입력 생성기와 동일 규약 렌더 "io_contract": IOContract( input_format=render_input_format(bp.io_schema), diff --git a/tests/v2/test_input_gen.py b/tests/v2/test_input_gen.py index 113aaab..0b0d304 100644 --- a/tests/v2/test_input_gen.py +++ b/tests/v2/test_input_gen.py @@ -17,7 +17,9 @@ ) from ipe.v2.generation.input_gen import ( _MAX_ELEMENTS, + describe_io_field, generate_inputs, + render_constraints, render_input_format, seed_from_run_id, ) @@ -406,6 +408,253 @@ def test_size_cap_preserves_inputs_under_limit() -> None: assert v == 5 # 캡 미만 → 상한 그대로 +# ---------- references: 정점/원소 참조 스칼라 (#1 graph trivial/범위밖 해소) ---------- + + +def _graph_and_query_schema(v_lo: int, v_hi: int) -> IOSchema: + """[weighted_edges grid, int s→grid, int t→grid] — dijkstra 형상.""" + return IOSchema( + inputs=( + IOFieldSpec( + name="grid", + type="weighted_edges", + size_range=ConstraintRange(name="grid", min_value=v_lo, max_value=v_hi), + value_range=ConstraintRange(name="w", min_value=1, max_value=9), + ), + IOFieldSpec(name="s", type="int", references="grid"), + IOFieldSpec(name="t", type="int", references="grid"), + ), + output_type="int", + output_format="x", + ) + + +def _query_value(text: str, idx: int) -> int: + """flat 토큰에서 graph(V E + E triples) 뒤의 idx 번째 스칼라.""" + lines = text.split("\n") + v, e = (int(x) for x in lines[0].split()) + return int(lines[1 + e + idx]) + + +def test_reference_scalar_stays_within_actual_vertex_count() -> None: + schema = _graph_and_query_schema(5, 5) # V 고정 5 + contract = GeneratorContract(scale_families=(ScaleFamily(name="s", case_count=20),)) + for c in generate_inputs(contract, schema, seed=11): + v = int(c.input_text.split("\n")[0].split()[0]) + s, t = _query_value(c.input_text, 0), _query_value(c.input_text, 1) + assert 1 <= s <= v and 1 <= t <= v # 실제 V 이내 (범위밖 RTE 소멸) + + +def test_reference_scalar_not_trivially_pinned_to_two() -> None: + """[1,2] trivial 퇴화 회귀 — 큰 V 에서 질의가 2 초과 값을 실제로 가진다.""" + schema = _graph_and_query_schema(50, 50) + contract = GeneratorContract(scale_families=(ScaleFamily(name="s", case_count=30),)) + seen = { + _query_value(c.input_text, 0) + for c in generate_inputs(contract, schema, seed=3) + } + assert max(seen) > 2 # [1,2] 고정이 아니라 전 범위 분산 + + +def test_reference_scalar_valid_even_for_degenerate_single_vertex() -> None: + """empty bias → V=1 그래프('1 0')라도 s=1 (s>V IndexError '1 0 2 1' 회귀).""" + schema = _graph_and_query_schema(1, 9) + contract = GeneratorContract( + scale_families=(ScaleFamily(name="s", case_count=1),), + edge_cases=(EdgeCaseSpec(name="empty"),), + ) + empty = next( + c for c in generate_inputs(contract, schema, seed=0) if c.category == "empty" + ) + lines = empty.input_text.split("\n") + assert lines[0] == "1 0" # V=1, E=0 + assert lines[1] == "1" and lines[2] == "1" # s=t=1 (범위밖 아님) + + +def test_reference_into_int_array_bound_to_element_count() -> None: + schema = IOSchema( + inputs=( + IOFieldSpec( + name="arr", + type="int_array", + size_range=ConstraintRange(name="arr", min_value=6, max_value=6), + value_range=ConstraintRange(name="v", min_value=0, max_value=9), + ), + IOFieldSpec(name="k", type="int", references="arr"), + ), + output_type="int", + output_format="x", + ) + contract = GeneratorContract(scale_families=(ScaleFamily(name="s", case_count=15),)) + for c in generate_inputs(contract, schema, seed=2): + k = int(c.input_text.split("\n")[-1]) + assert 1 <= k <= 6 # 원소 개수 이내 1-indexed + + +def test_reference_resolves_regardless_of_field_order() -> None: + """참조 스칼라가 collection 보다 **앞**에 선언돼도 실제 크기에 바인딩.""" + schema = IOSchema( + inputs=( + IOFieldSpec(name="s", type="int", references="grid"), + IOFieldSpec( + name="grid", + type="weighted_edges", + size_range=ConstraintRange(name="grid", min_value=4, max_value=4), + value_range=ConstraintRange(name="w", min_value=1, max_value=9), + ), + ), + output_type="int", + output_format="x", + ) + contract = GeneratorContract(scale_families=(ScaleFamily(name="s", case_count=12),)) + for c in generate_inputs(contract, schema, seed=8): + lines = c.input_text.split("\n") + s = int(lines[0]) # s 가 첫 줄 (선언 순서 유지) + v = int(lines[1].split()[0]) # grid 헤더 + assert v == 4 and 1 <= s <= 4 + + +def test_reference_generation_is_deterministic() -> None: + schema = _graph_and_query_schema(2, 12) + contract = GeneratorContract(scale_families=(ScaleFamily(name="s", case_count=6),)) + a = generate_inputs(contract, schema, seed=5) + b = generate_inputs(contract, schema, seed=5) + assert [c.input_text for c in a] == [c.input_text for c in b] + + +def test_dangling_reference_defaults_safely() -> None: + """존재하지 않는 필드 참조(LLM 오타)도 crash 없이 안전값(1).""" + schema = IOSchema( + inputs=(IOFieldSpec(name="s", type="int", references="nope"),), + output_type="int", + output_format="x", + ) + contract = GeneratorContract(scale_families=(ScaleFamily(name="s", case_count=3),)) + for c in generate_inputs(contract, schema, seed=1): + assert c.input_text == "1" + + +# ---------- cols_range: int_matrix 열 수 고정 (#2 sort IndexError 해소) ---------- + + +def test_matrix_cols_range_fixes_column_count() -> None: + """레코드 고정 K 속성 — 행 수는 변해도 열 수는 K 고정 (행별 속성 흔들림 소멸).""" + field = IOFieldSpec( + name="records", + type="int_matrix", + size_range=ConstraintRange(name="records", min_value=1, max_value=8), + value_range=ConstraintRange(name="v", min_value=0, max_value=9), + cols_range=ConstraintRange(name="cols", min_value=3, max_value=3), + ) + contract = GeneratorContract(scale_families=(ScaleFamily(name="s", case_count=20),)) + for c in generate_inputs(contract, _io_schema(field), seed=7): + lines = c.input_text.split("\n") + r, cols = (int(x) for x in lines[0].split()) + assert cols == 3 # 열 수 고정 + for row in lines[1:]: + assert len(row.split()) == 3 # 모든 행이 정확히 3 속성 + + +def test_matrix_without_cols_range_unchanged() -> None: + """cols_range None 이면 현행 동작(열 수도 size_range 에서) — 회귀 안전.""" + field = IOFieldSpec( + name="m", + type="int_matrix", + size_range=ConstraintRange(name="m", min_value=2, max_value=2), + value_range=ConstraintRange(name="v", min_value=0, max_value=0), + ) + contract = GeneratorContract(scale_families=(ScaleFamily(name="s", case_count=4),)) + for c in generate_inputs(contract, _io_schema(field), seed=6): + lines = c.input_text.split("\n") + r, cols = (int(x) for x in lines[0].split()) + assert r == 2 and cols == 2 # size_range 가 행·열 모두 (현행) + + +def test_reference_render_states_relationship() -> None: + schema = _graph_and_query_schema(1, 100) + text = render_input_format(schema) + assert "grid" in text # 참조 대상 명시 + assert "1-indexed" in text or "1 이상" in text # 참조 규약 노출 + + +def test_empty_graph_bias_respects_size_min() -> None: + """V≥2 스키마면 empty 엣지케이스도 '2 0'(V_min) — '1 0'(V=1)은 제약과 모순.""" + schema = _io_schema(_weighted_edges_field(3, 50)) # V≥3 + contract = GeneratorContract( + scale_families=(ScaleFamily(name="s", case_count=1),), + edge_cases=(EdgeCaseSpec(name="empty"),), + ) + empty = next( + c for c in generate_inputs(contract, schema, seed=0) if c.category == "empty" + ) + assert empty.input_text == "3 0" # V_min=3, 간선 0 (V=1 아님) + + +def test_empty_tree_bias_respects_size_min() -> None: + field = IOFieldSpec( + name="tree", + type="tree_edges", + size_range=ConstraintRange(name="tree", min_value=4, max_value=20), + ) + contract = GeneratorContract( + scale_families=(ScaleFamily(name="s", case_count=1),), + edge_cases=(EdgeCaseSpec(name="empty"),), + ) + empty = next( + c + for c in generate_inputs(contract, _io_schema(field), seed=0) + if c.category == "empty" + ) + lines = empty.input_text.split("\n") + assert int(lines[0]) == 4 and len(lines) == 4 # V_min=4 트리 (V-1=3 간선) + + +# ---------- render_constraints: 코드 파생 제약 (#1 E/V·V누락 해소) ---------- + + +def test_render_constraints_includes_vertex_count_and_weight() -> None: + schema = _graph_and_query_schema(2, 100000) + cons = {c.name: c for c in render_constraints(schema)} + assert "V" in cons and (cons["V"].min_value, cons["V"].max_value) == (2, 100000) + assert "w" in cons # 가중치 누락 안 함 + + +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)} + # 참조 스칼라 s/t 는 [1, V_max] 로 (리터럴 [1,2] 아님) + 의존 설명 + for q in ("s", "t"): + assert cons[q].min_value == 1 and cons[q].max_value == 5000 + assert "크기 이하" in cons[q].description + + +def test_render_constraints_states_fixed_matrix_columns() -> None: + field = IOFieldSpec( + name="records", + type="int_matrix", + size_range=ConstraintRange(name="records", min_value=1, max_value=2000), + value_range=ConstraintRange(name="v", min_value=0, max_value=1000), + cols_range=ConstraintRange(name="cols", min_value=3, max_value=3), + ) + cons = {c.name: c for c in render_constraints(_io_schema(field))} + assert "R" in cons and (cons["R"].min_value, cons["R"].max_value) == (1, 2000) + assert "C" in cons and (cons["C"].min_value, cons["C"].max_value) == (3, 3) + + +def test_describe_io_field_surfaces_reference_and_cols() -> None: + ref = describe_io_field(IOFieldSpec(name="s", type="int", references="grid")) + assert "→refs grid" in ref and "1..|grid|" in ref # 참조 관계 노출 + mtx = describe_io_field( + IOFieldSpec( + name="m", + type="int_matrix", + size_range=ConstraintRange(name="m", min_value=1, max_value=9), + cols_range=ConstraintRange(name="c", min_value=2, max_value=2), + ) + ) + assert "size[1..9]" in mtx and "cols[2..2]" in mtx # 행수+고정열수 분리 노출 + + # ---------- seed helper ---------- From 965476b9095e3f32ec99f7c89eb1c521e6f67245 Mon Sep 17 00:00:00 2001 From: LsMin124 Date: Tue, 23 Jun 2026 17:17:52 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs(rfc):=20=EB=8B=A8=EC=9D=BC=20IR=20?= =?UTF-8?q?=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20=E2=80=94=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EB=AA=A8=EC=88=9C=20=EB=B6=95=EA=B4=B4=20O(N=C2=B2)=E2=86=92O(?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 각 노드가 같은 사실을 독립 재진술해 경계 모순이 끝없이 나오던 구조 문제의 근본 설계. 단일 ProblemIR + 순수 코드 투영 + 검증된 LLM 슬롯 2개(narrative· golden), spec_bridge·generator_designer 는 공짜 투영으로 강등, IR validator 로 P2 ill-posed 조기 기각. 사실 21종 인벤토리·단일소스 표·enriched IR 스키마· 6단계 마이그레이션·F8(directedness 미정의) 잠재버그 포함. 이번 수선이 Phase 0. --- .../2026-06-23_single-ir-architecture-rfc.md | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 docs/improvements/2026-06-23_single-ir-architecture-rfc.md diff --git a/docs/improvements/2026-06-23_single-ir-architecture-rfc.md b/docs/improvements/2026-06-23_single-ir-architecture-rfc.md new file mode 100644 index 0000000..2b548bb --- /dev/null +++ b/docs/improvements/2026-06-23_single-ir-architecture-rfc.md @@ -0,0 +1,244 @@ +# RFC: Single Canonical IR + Pure Projections + Two Verified Creative Slots + +**Status:** Draft for review · **Scope:** `ipe/v2/` generation pipeline + `ipe/v1/schema` data model +**Author role:** architecture · **Date:** 2026-06-23 + +## 0. Thesis + +The pipeline's correctness problem is not in any node — every node is locally correct and the QA gate is correctly rejecting. The problem is **topological**: the same problem-fact is independently re-expressed by ~8 artifacts, and their mutual consistency is held together by accumulated prompt RULES rather than by construction. The contradiction surface is O(N²) in the number of independently-authored representations. The recent "freeze-by-code" patches (which lifted P1 from 11% to 67%) are each an instance of one principle — *project the fact from a single source instead of re-authoring it* — applied reactively, one fact at a time. + +This RFC makes that principle the explicit architecture: **one rich frozen `ProblemIR`; every correctness artifact a pure function of it; exactly two LLM slots that EXPRESS the problem (narrative prose, golden code), each verified against the IR.** Operating invariant: *every fact has exactly one authored source; if a fact appears in two independently-authored artifacts, that is a latent contradiction by definition.* + +The codebase is already ~60% of the way there. This RFC names the remaining gaps precisely and sequences the rest. + +--- + +## 1. Fact inventory + +For a representative graph problem the canonical fact bundle is: *"directed/undirected graph, V vertices indexed 1..V, no self-loops, multi-edges allowed, connectivity not guaranteed, weights w∈[lo,hi]; s,t are query vertices in [1,V]; output is the shortest s→t distance, −1 if unreachable, 0 if s==t; ties don't change the answer."* Here is where each sub-fact is authored and re-stated today. + +| # | Fact | Authored at (single intended source) | Re-stated / consumed at | Status | +|---|------|--------------------------------------|--------------------------|--------| +| F1 | field name + type | `make_formalizer_node` → `IOSchema.inputs` (`ipe/v1/schema/blueprint.py:42`) | every consumer below | single source ✓ | +| F2 | collection size range | formalizer → `IOFieldSpec.size_range` | `render_constraints`, `render_input_format`, `render_input_parser`, `generate_inputs`, sample-gen | **projected ✓** (Phase 0) | +| F3 | element/weight value range | formalizer → `IOFieldSpec.value_range` | same as F2 | **projected ✓** | +| F4 | scalar pointer (s,t → graph) | formalizer → `IOFieldSpec.references` | `_serialize_reference`, `render_constraints` (`1≤s≤V`), `_render_field` | **projected ✓** (the template) | +| F5 | fixed columns (K attrs/row) | formalizer → `IOFieldSpec.cols_range` | `_serialize_int_matrix`, `render_constraints`, `_render_field` | **projected ✓** | +| F6 | **self-loop policy** | *nowhere as data* — hardcoded in `_serialize_weighted_edges`/`_backbone` (`ipe/v2/generation/input_gen.py`) | prose rule in formalizer prompt, narrative prompt, `_FORMAT_TEXT`, parser docstring | **redundant prose ✗** | +| F7 | **multi-edge / connectivity policy** | *nowhere as data* — hardcoded biases in `_serialize_weighted_edges`, `_backbone`, `_serialize_tree_edges` | same prose sites as F6 | **redundant prose ✗** | +| F8 | **directedness** | *undecided anywhere* — serializer emits `u v w`; `DijkstraVerifier._bfs_reachable_from_source` assumes directed (`ipe/v1/verifiers/dijkstra.py:104`); golden chooses freely; narrative may say "two-way roads" arbitrarily | latent | **missing ✗ (latent contradiction)** | +| F9 | **1-indexing** | *nowhere as data* — hardcoded in `_backbone` and `_serialize_reference` | prose in formalizer/narrative, `_FORMAT_TEXT`, parser | **redundant prose ✗** | +| F10 | output type + format | formalizer → `IOSchema.output_type/output_format` | `io_contract.output_format` carry-over (spec_bridge), narrative told NOT to restate | single source ✓ | +| F11 | **edge-case semantics** (s==t→0, unreachable→−1, multi-edge/0-budget handling) | formalizer → `output_invariants` as **prose** (`OutputInvariant.kind/description`) | narrative must echo, QA ambiguity reviewer checks, golden implements operationally | **redundant prose ✗** (4-way) | +| F12 | **answer uniqueness / tie-break** | formalizer → prose `output_invariants` | narrative echoes, QA ambiguity checks, reconcile enforces operationally | **redundant prose ✗** | +| F13 | constraints table | `render_constraints(io_schema)` (`ipe/v2/generation/input_gen.py`) | spec_bridge injects, QA reads | **projected ✓** | +| F14 | input_format prose | `render_input_format(io_schema)` | spec_bridge → `io_contract.input_format`, golden/brute coder | **projected ✓** | +| F15 | stdin parser preamble | `render_input_parser(io_schema)` (`ipe/v2/generation/input_parser.py`) | injected to golden/brute via `parse_discipline` (`ipe/v1/nodes/coder.py`) | **projected ✓** | +| F16 | sample inputs | `_generate_sample_inputs` (`ipe/v2/nodes/spec_bridge.py`); expected by `make_sample_filler_node` (golden) | — | **projected ✓** | +| F17 | **scale tiers** (field_bounds) | `make_generator_designer_node` LLM → `ScaleFamily.field_bounds` | `generate_inputs`; must mirror io_schema names+ranges (prompt rule) | **redundant LLM ✗** | +| F18 | **edge cases to generate** | generator_designer LLM → `EdgeCaseSpec.name`; collapsed to 5 biases by `_edge_bias` | `generate_inputs`; prompt forbids unrealizable names | **redundant LLM ✗** (pure risk — see §4) | +| F19 | target algorithm + composition + domain | strategist → `StrategySeed`; carried structurally into blueprint and spec | verifier dispatch, Tier-B switch (`ipe/v2/graph.py`), narrative | single source ✓ | +| F20 | description | `narrative.scenario`; carried into spec | QA reads | single source ✓ | +| F21 | title (+ time/mem limits) | **spec_bridge LLM** — the only surviving authored output | spec | **vestigial LLM ✗** | + +**Reading of the inventory.** Phase-0 patches already collapsed F2–F5, F13–F16 to projections. The remaining redundancy is three clusters: + +- **Structural facts F6–F9** live *only* as code constants inside the `input_gen.py` serializer and are re-stated as prose rules in three prompts. They are not fields anywhere. F8 (directedness) is not even decided — a genuine latent contradiction. +- **Output semantics F11–F12** are authored as prose by formalizer, must be echoed by narrative, checked by QA, and implemented by golden — a 4-way surface held together entirely by prompt rules. +- **Generator contract F17–F18** is LLM-authored but adds no information the IR doesn't already determine; F18 adds only *risk* (the LLM can name an edge kind the serializer cannot produce, which survives as a category and contradicts the format → QA reject). F21 (title) is a full Opus call producing one line. + +--- + +## 2. Single-source assignment table + +Target state: each fact has exactly one authored home in the enriched IR (or is a strategist seed absorbed into it); every consumer is a pure function. + +| Fact | Canonical home (enriched IR) | Projected to (pure code, cannot contradict) | +|------|------------------------------|---------------------------------------------| +| F1–F5 | `IOFieldSpec` (unchanged) | constraints, format, parser, generator, samples | +| F6 self-loops | **`GraphShape.self_loops: bool`** (new) | serializers read it; `render_structural_facts` (new) emits it as machine fact; narrative receives as DATA; faithfulness + validator check it | +| F7 multi-edge + connectivity | **`GraphShape.multi_edges: bool`, `GraphShape.connectivity`** (new) | same consumers as F6 | +| F8 directedness | **`GraphShape.directed: bool`** (new) | serializer comment, format prose, narrative DATA, golden contract, verifier | +| F9 indexing | **`IOSchema.indexing: Literal[0,1]=1`** (new) | `_backbone`, `_serialize_reference`, constraints, format, parser | +| F10 output type/format | `IOSchema` (unchanged) | io_contract carry-over | +| F11 edge-case semantics | **`ResolvedEdgeCase[]`** (new): inputs derived from IR realizable-degeneracy set; **outputs golden-filled** | narrative DESCRIBES observed; QA checks narrative↔resolved; validator checks coverage | +| F12 answer uniqueness | **`IOSchema.answer_uniqueness: Literal["tie_invariant","tie_broken","unverified"]`** (new) + reconcile proof | validator; narrative | +| F13–F16 | already projected from `IOSchema` | unchanged | +| F17 scale tiers | **derived** from `size_range`/`value_range` (deterministic policy) | `generate_inputs` | +| F18 edge cases | **derived** from realizable-degeneracy set (function of `GraphShape`/types) | `generate_inputs` | +| F19 algo/composition/domain | `ProblemBlueprint` (strategist seed, carried) | verifier, Tier-B switch, narrative | +| F20 description | `narrative.scenario` | spec | +| F21 title | **fold into `NarrativeDraft.title`** (creative slot 1) or derive | spec | + +After this assignment, **the only artifacts a human/LLM authors are: the strategist seed (F19), the IOSchema+GraphShape (F1–F10, formalizer), the narrative prose (F20–F21), and the golden code (F11 outputs).** Everything else is `f(IR)`. + +--- + +## 3. Enriched `ProblemIR` schema + +Concrete additions to `ipe/v1/schema/blueprint.py`. All keep `frozen=True, extra="forbid"`. + +### 3.1 `GraphShape` — lift the implicit serializer constants (F6–F9) + +```python +class GraphShape(BaseModel): + """Structural facts for graph-typed fields. Today these are HARD-CODED in + input_gen._serialize_weighted_edges/_backbone and only RE-STATED as prose + rules in formalizer/narrative prompts. Making them IR fields means the + serializer READS them (one truth) and narrative/QA/faithfulness check + against them by machine instead of by prompt rule.""" + model_config = ConfigDict(frozen=True, extra="forbid") + directed: bool # F8: today UNDECIDED — must be pinned + self_loops: bool = False # F6: serializer constant today + multi_edges: bool = True # F7: weighted_edges constant today + connectivity: Literal["connected", "maybe_disconnected"] = "maybe_disconnected" +``` + +`IOFieldSpec` gains `graph_shape: GraphShape | None = None` (required by validator when `type in {weighted_edges, tree_edges}`; for `tree_edges` the validator forces `connectivity="connected", multi_edges=False, self_loops=False` — the tree invariant becomes a *checked* fact, not a prose promise). + +**Behavior preservation:** the field defaults equal today's serializer constants, so Phase 1 is a no-op on generated bytes until formalizer starts varying them. The win is that `_serialize_weighted_edges` reads `field.graph_shape.*` instead of hardcoding, and a new pure projection emits the structural facts for narrative/QA to consume: + +```python +def render_structural_facts(io_schema: IOSchema) -> list[str]: + """IR → machine-derived structural statements (one source). Replaces the + self-loop/multi-edge/indexing prose RULES in formalizer & narrative prompts.""" +``` + +### 3.2 `IOSchema` — indexing + uniqueness (F9, F12) + +```python +class IOSchema(BaseModel): + ... + indexing: Literal[0, 1] = 1 # F9: hardcoded in _backbone today + answer_uniqueness: Literal[ # F12: today prose in output_invariants + "tie_invariant", # output value identical for all optimal solutions (preferred) + "tie_broken", # tie-break rule fully specified by the IR + "unverified", # validator must reject for P2 + ] = "unverified" +``` + +### 3.3 Edge-case semantics → golden-defined (F11) + +Replace the prose `output_invariants(kind="edge_case_semantics")` pattern with a *machine* representation whose **inputs are derived from the IR** and whose **outputs are filled by the verified golden** — exactly mirroring how `sample_filler` already bootstraps sample expected outputs (`ipe/v2/nodes/sample_filler.py`). + +```python +class ResolvedEdgeCase(BaseModel): + """A realizable degenerate input + its golden-defined output. + input_text is DERIVED deterministically from the IR's realizable-degeneracy + set (function of types + GraphShape); expected_output is filled post-reconcile + by running the canonical golden (operational definition). rationale is the + human description that narrative must match and QA checks against.""" + model_config = ConfigDict(frozen=True, extra="forbid") + name: str # "source_equals_target" | "unreachable" | "empty" | ... + input_text: str + expected_output: str | None = None # golden-filled (None = pending), like GeneratedTestCase + rationale: str = "" +``` + +**How edge semantics become golden-defined instead of prose-formalized.** The realizable-degeneracy set is *derivable*: a graph with `connectivity="maybe_disconnected"` admits an `unreachable` case; `references` self-pointing admits `source_equals_target`; any sized field admits `empty`/`min`. A new pure function `derive_edge_inputs(io_schema)` enumerates these inputs (it already exists in spirit as `_edge_bias` + the `bias` machinery in `input_gen.py`). Crucially, **these edge inputs are added to the reconcile differential set** (today `reconcile` only diffs sample inputs — `ipe/v1/nodes/reconciler.py`). Then: + +- golden×K agreement on an edge input ⇒ that edge's semantics are **uniquely determined** (well-posed) AND **operationally defined** by the agreed output. Disagreement ⇒ the IR is ill-posed *on that edge* → reject early with a pointer to the exact input (the P2 lever, §6). +- the agreed output is stored as `ResolvedEdgeCase.expected_output`. +- narrative DESCRIBES the observed `rationale`; faithfulness/QA check description ↔ resolved pairs by machine. + +This removes formalizer's prose edge-case authoring entirely. The IR declares *which* edges exist (derived); the golden defines *what they do* (verified unique by reconcile); the prose merely describes. + +### 3.4 `ProblemBlueprint` + +No new top-level fields beyond the nested ones above. `output_invariants` is retained only for *symbolic* invariants consumed by `ipe/v1/verifiers/*` (e.g. `non_negative`, `triangle_inequality`); the `edge_case_semantics`/`answer_uniqueness` *kinds* migrate out to §3.2/§3.3 where they are machine-usable. + +--- + +## 4. Node → role mapping + +Three roles only: **IR authors** (write the single source), **verified creative slots** (express the problem, checked against the IR), **pure projections** (f(IR), no LLM), plus **validators** (check a relationship). + +| Node (file) | Today | Target role | Change | +|-------------|-------|-------------|--------| +| `strategist` | LLM seed | **seed author** (creative, upstream) | unchanged — emits `StrategySeed` (F19), absorbed into IR by formalizer | +| `formalizer` | LLM | **THE IR author** (critical single source) | enrich output schema (GraphShape/indexing/uniqueness); **drop** the structural prose RULES (§3.1) since they become checked fields | +| `narrative` | LLM | **verified creative slot 1** | unchanged role; receives structural + resolved-edge facts as DATA to describe, not rules to avoid | +| `faithfulness` | LLM | **verifier: narrative ↔ IR** | augment with machine checks against `GraphShape`/`ResolvedEdgeCase` | +| `golden_i` / `brute` | LLM | **verified creative slot 2** | unchanged; reconcile differential set extended to edge inputs (§3.3) | +| `reconciler` | code | **verifier: golden ↔ IR + uniqueness** | diff over sample **+ derived edge** inputs | +| `spec_bridge` | LLM (Opus) | **PURE PROJECTION** | **drop the LLM call.** Today it authors only `title`; everything else is already carried/projected. Split into `spec_projection` (parser/constraints/io_contract/samples — pure `f(IR)`) + late attach of `description`/`title` | +| `generator_designer` | LLM (Opus) | **PURE PROJECTION** | **drop the LLM call.** `field_bounds` (F17) derive from `size/value_range` by deterministic tier policy; `edge_cases` (F18) derive from the realizable set. The LLM's only freedom is collapsed to 5 biases by `_edge_bias` anyway | +| `input_generator`, `sample_filler`, `suite_assembler` | code | **pure projections** | unchanged (already LLM-free) | +| **`validator`** (NEW) | — | **verifier: IR ↔ itself (well-definedness)** | new node after formalizer (§6) | +| `qa_*` reviewers | LLM (Sonnet) | **independent audit** (final) | unchanged role; ambiguity reviewer's edge/tie checks become partially redundant with validator (defense in depth) | + +**Net:** LLM calls in the *correctness path* drop from 6 (strategist, formalizer, narrative, spec_bridge, generator_designer, golden×K/brute) to the **two creative slots + formalizer (IR author) + strategist (seed)**. spec_bridge and generator_designer — two full Opus calls per run — become free code. The contradiction surface collapses to exactly two edges: **narrative ↔ IR** (faithfulness) and **golden ↔ IR** (reconcile/executor). Every projection is `f(IR)` and therefore cannot disagree with the IR or with another projection. + +--- + +## 5. Migration plan (incremental, each shippable + measurable) + +Measurement protocol matches the existing one (`project_ship_rate_analysis`): fixed batch, N≥18 per mode, P1/P2 ship-rate before/after, plus per-failure-class counts from `V2FinalStatus`. + +**Phase 0 — DONE (reframed).** The freeze patches (`render_constraints`/`render_input_format`/`render_input_parser`/`_generate_sample_inputs`, `references`, `cols_range`, self-loop prose alignment) each lifted one fact (F2–F5, F13–F16) from redundant authoring to projection. Result: P1 11%→67%. Those were the first applications of the §0 invariant; Phases 1–5 finish it. + +**Phase 1 — Structural IR fields (F6–F9).** Add `GraphShape` + `IOSchema.indexing`; make `input_gen.py` serializers READ them (defaults = current constants ⇒ byte-identical output, zero regression risk); have formalizer EMIT them (structured) and DROP the structural prose rules; add `render_structural_facts` projection feeding narrative/QA. *Leverage:* eliminates the self-loop/multi-edge/**directedness** contradiction class (F8 is currently un-pinned). *Measure:* `fail_qa` ambiguity/format-contradiction count; P1/P2 ship-rate. + +**Phase 2 — IR validator, pure-code tier (P2 lever, §6).** New `validator` node after formalizer: completeness + realizability + coverage checks, all pure code, plus `composition` non-empty for P2 and `answer_uniqueness != "unverified"` for P2. Add an **ill-posed back-route** to the strategist/formalizer (mirror the existing QA back-route topology, `ipe/v2/router.py`, `graph.py:_wire_qa`). *Leverage:* highest for P2 — rejects orphan-field/empty-composition/unrealizable-edge/uncovered-degeneracy IRs *before* spending golden×K + brute + suite + 4 reviewers, and makes the rejection *repairable*. *Measure:* P2 cost-per-ship; fraction of P2 failures caught pre-synthesis. + +**Phase 3 — `generator_designer` → projection (F17–F18).** Replace the LLM with `derive_scale_families(io_schema)` (log-spaced tiers within declared ranges) + `derive_edge_cases(io_schema)` (one `EdgeCaseSpec` per realizable bias). Delete the prompt section fighting unrealizable kinds. *Leverage:* removes the F18 reject class entirely and one Opus call. Depends on Phase 1. *Measure:* unrealizable-category `fail_qa` count (expect →0); cost/run. + +**Phase 4 — `spec_bridge` → projection (F21).** Split into pure `spec_projection` + fold `title` into `NarrativeDraft`. Delete the Opus call and its structured-output failure mode (`fail_spec_authoring`). *Leverage:* removes a full Opus call and an entire failure class. *Measure:* cost/run; `fail_spec_authoring` →0. + +**Phase 5 — Golden-defined edge semantics (F11) + reorder.** Add `ResolvedEdgeCase` derivation; extend reconcile differential to edge inputs; add an edge-filler node (clone `sample_filler`); move narrative to *describe observed* behavior (after synthesis — consistent with the existing `narrative_revise` back-route). *Leverage:* deepest; fully realizes "golden operationally defines semantics, prose describes." Largest reorder, so last. *Measure:* edge-semantics `fail_qa` count; faithfulness false-reject rate. + +Ordering rationale: Phase 1 unblocks 2 and 3 (the realizable set needs `GraphShape`); Phase 2 is the highest-leverage P2 fix and only needs Phase 1; Phases 3–4 are pure simplification; Phase 5 is the deepest and is sequenced last because it requires reordering the DAG. + +--- + +## 6. IR validator + early well-definedness (the P2 lever) + +P2 ships 0% because the *composition is often ill-posed* (the rules don't uniquely determine the answer), and **no downstream gate can repair an ill-posed problem**. Today the only thing that detects non-uniqueness is `reconcile` (golden×K diverge → `fail_synthesis_rejected`) — but that fires *after* full synthesis, reports as "synthesis rejected" (not "ill-posed IR"), and has **no back-route**: the run just dies. The validator turns this into a cheap, diagnostic, repairable front gate. + +**Tier A — pure-code structural checks (free, always on, before synthesis):** + +- **Completeness:** every collection field has a `size_range`; every `references` resolves to an existing collection; `output_type` consistent with `output_format`. +- **Orphan-field detection** (today a formalizer prose rule): any comparison/threshold scalar must have a per-element data field it compares against; otherwise the problem is unsolvable from its inputs → reject. +- **Realizability** (today a `generator_designer` prose rule): the realizable-degeneracy set is derivable; any declared edge category outside `{empty, min, max, disconnected, source_equals_target}` is rejected at the IR, not discovered as a QA category mismatch. +- **Coverage:** every *realizable* degeneracy (e.g. `connectivity="maybe_disconnected"` ⇒ `unreachable` exists) must have a `ResolvedEdgeCase` slot, so semantics can't be silently undefined. +- **P2 well-formedness:** `composition` non-empty and `answer_uniqueness != "unverified"`. + +**Tier B — uniqueness, the part pure code can't decide:** + +1. **Operational (preferred, reuses the moat):** include the derived edge inputs in the reconcile differential set (§3.3). Independent goldens agreeing ⇒ unique; diverging ⇒ ill-posed, with the *exact* witnessing input. This needs the goldens to run, but the back-route makes it repairable. +2. **Cheap pre-synthesis probe (optional, P2 only):** a single Haiku/Sonnet "well-posedness auditor" that reads the **IR** (not the narrative) and answers "does this io_schema + output definition + invariants determine a single answer for every input in range? list ambiguities." This is the faithfulness pattern turned inward (IR ↔ itself: *is this a total, single-valued function spec?*). One cheap call vs. burning the whole expensive tail. + +**Back-route.** On validator reject (or Tier-B divergence), route to strategist/formalizer with the witnessing diagnostic, bounded by a budget exactly like `max_qa_routebacks` / `max_iterations` (`ipe/v2/state.py`, `router.py:route_after_qa`). This is the missing repair path: an ill-posed P2 composition gets one or two attempts to re-pick the composition before failing, instead of dying on the first reconcile divergence. + +The three gates now map cleanly onto the three relationships: **validator** = IR ↔ itself (well-defined function), **faithfulness** = narrative ↔ IR, **reconcile/executor** = golden ↔ IR. The validator is the cheap front gate that's currently missing. + +--- + +## 7. Risks and trade-offs + +- **The IR author becomes the single point of failure (by design).** Today consistency is smeared across prompts so no single node failure is fatal; concentrating truth in formalizer means a formalizer error propagates to *every* projection. Mitigation: that is exactly what the **validator** (§6) guards — a rich IR is *checkable*, a smeared one is not. We trade many weak prompt-rule guards for one strong machine guard. Net safer, but the formalizer prompt + IR schema become the highest-value review surface. + +- **Schema/serializer churn touches the hot path.** `IOFieldSpec`/`IOSchema` are imported by 14 types in `V2State` and consumed by every projection + the v1 verifiers. Mitigation: all new fields are optional with defaults equal to today's constants (Phase 1 is byte-identical until formalizer varies them); round-trip tests already guard serializer↔parser drift and must be extended to the new fields. + +- **Some "creativity" is genuinely lost — and that's correct here.** Collapsing `generator_designer` removes an LLM's choice of test strategy. But that choice is already collapsed to 5 biases by `_edge_bias`; the LLM contributed no realizable information, only the risk of unrealizable categories. If per-algorithm stress strategy later proves to add real signal, it can return as a *projection input* (a small typed policy table keyed by `reduction_core`), not a free-text LLM artifact. + +- **Edge semantics that resist derivation.** Some output semantics aren't reducible to a small realizable set (e.g. complex multi-condition tie-breaks). For those, F11/F12 stay partly prose in `output_invariants`, and the golden-defined `ResolvedEdgeCase` covers only the enumerable degeneracies. This is a graceful boundary, not a cliff: the validator still forces `answer_uniqueness != "unverified"` for P2, pushing authors toward tie-invariant outputs. + +- **Phase 5 reorder risk.** Moving narrative after synthesis is the one change that alters control flow materially (recursion budgets in `config.py`, the back-route wiring in `_wire_qa`). It is sequenced last and is independently revertible; Phases 1–4 deliver most of the contradiction collapse without it. + +- **What could regress.** (a) If new `GraphShape` defaults are set wrong, every graph problem's generated bytes shift at once — mitigated by default-equals-constant + round-trip tests. (b) A too-strict validator could over-reject well-posed-but-unusual IRs — mitigated by measuring validator reject reasons against the fixed batch before enabling the back-route as a hard gate. (c) Folding `title` into narrative couples two concerns in one creative slot — acceptable since title is cosmetic. + +--- + +## Key files this RFC touches + +- Data model (enrich): `ipe/v1/schema/blueprint.py`, `ipe/v1/schema/problem_spec.py`, `ipe/v1/schema/test_suite.py` +- Projections (read new fields; absorb dropped LLM nodes): `ipe/v2/generation/input_gen.py`, `ipe/v2/generation/input_parser.py` +- Nodes → projections: `ipe/v2/nodes/spec_bridge.py`, `ipe/v2/nodes/generator_designer.py` +- IR authors / creative slots (prompt changes): `ipe/v2/nodes/formalizer.py`, `ipe/v2/nodes/narrative.py`, `ipe/v2/nodes/faithfulness.py` +- Verification (extend differential to edge inputs): `ipe/v1/nodes/reconciler.py`, `ipe/v2/nodes/sample_filler.py` +- New validator + wiring + back-route: `ipe/v2/graph.py`, `ipe/v2/router.py`, `ipe/v2/state.py` + +## Immediate bug flagged (independent of full refactor) + +**F8 (directedness) is unspecified anywhere** — the serializer emits `u v w`, `DijkstraVerifier._bfs_reachable_from_source` (`ipe/v1/verifiers/dijkstra.py:104`) assumes directed, and the narrative can describe edges as bidirectional with nothing to catch the mismatch. Pinning `GraphShape.directed` (Phase 1) closes a contradiction that no current prompt rule even mentions. From 95770901be72988dbab9bf7a89342f37ba0411ab Mon Sep 17 00:00:00 2001 From: LsMin124 Date: Wed, 24 Jun 2026 09:11:25 +0900 Subject: [PATCH 3/3] =?UTF-8?q?style(v2):=20graph.py=20=ED=9D=90=EB=A6=84?= =?UTF-8?q?=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=A0=95=EB=A0=AC=20(=EA=B3=B5=EB=B0=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ipe/v2/graph.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ipe/v2/graph.py b/ipe/v2/graph.py index 703799f..57c6be5 100644 --- a/ipe/v2/graph.py +++ b/ipe/v2/graph.py @@ -12,9 +12,9 @@ 기본 흐름 (always):: START → strategist → formalizer → narrative → faithfulness → route ─┬─ regen→narrative - (시드) (FREEZE) (렌더) (round-trip) │ (faithful=False) - ▲ │ - └──────────────────────────────┘ + (시드) (FREEZE) (렌더) (round-trip) │ (faithful=False) + ▲ │ + └─────────────────────────────┘ route(budget 소진) ── end_faithfulness faithfulness ─(faithful)→ spec_bridge → designer → dispatch ─┬→ golden_0..K ─┐ └→ brute ───────┴→ reconciler