diff --git a/README.md b/README.md
index d30aa1271be38bd81eb3c6c1be496f35b0fb3885..a3f39b079bc3b357d3c5ed0a07ef31f239b5b4f8 100644
--- a/README.md
+++ b/README.md
@@ -10,19 +10,14 @@ pinned: false
# 조선일보 본문 교열 데모
-Upstage **solar-mini4** 기반 교열 파이프라인 데모입니다. 평가 하네스와 **같은 엔진 코드**를
-실행합니다 — 데모가 보여주는 결과는 평가 run 과 같은 경로에서 나옵니다.
+FT 교열 + Opus 기사 검토 구성(`pipeline_exp_261021_ft_hybrid`) 데모입니다. 평가 하네스와 같은 엔진 코드를 실행합니다.
```
-[규칙] 사전 치환
-[LLM] FT 교열 (ft:solar-news-correction)
-[LLM] 판정 — FT 결과를 keep/revert
-[LLM] 문맥 교정 · 어휘
-[LLM] 문맥 교정 · 문법
-[규칙] 사전 재적용 · 복합어 복원 · 율/률 규칙 · 후처리 · 조사 교정 · 오탐 되돌림
+[규칙] 용어 사전 치환
+[LLM] FT 교열 (ft:solar-news-correction, 문단 묶음별 동시 호출)
+[LLM] 기사 전체 검토 1회 (Claude Opus): FT 오교정 되돌림 + 사실·숫자·문맥 교정 목록
+[규칙] 되돌림·교정 적용 · 따옴표 방향 · 원문 따옴표 보존 · 율/률 · 후처리
```
-LLM 호출 4회, 요청당 중앙 3.2초(단락 기준). 긴 기사는 문서 단위로 분할해 병렬 처리합니다.
-
-**실행 전 필요**: Space Settings → Secrets 에 `UPSTAGE_API_KEY` 를 넣으세요
-(없으면 사이드바 입력을 사용합니다).
+**실행 전 필요**: Space Settings → Secrets 에 `UPSTAGE_API_KEY` 와 `TIMELY_API_KEY` 를 넣으세요
+(없으면 사이드바 입력을 사용합니다). 기사 검토 단계는 Timely 라우터를 거쳐 Claude Opus 를 부릅니다.
diff --git a/__pycache__/logbook.cpython-312.pyc b/__pycache__/logbook.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..3c3277e614923e196d67bac2a576012231c8ba18
Binary files /dev/null and b/__pycache__/logbook.cpython-312.pyc differ
diff --git a/__pycache__/runner.cpython-312.pyc b/__pycache__/runner.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..e0a6452ca1c791dd8f0ad3819af042b5b3318b1c
Binary files /dev/null and b/__pycache__/runner.cpython-312.pyc differ
diff --git a/app.py b/app.py
index cefee7eb5cf52e0a730591ac589ebb3617b246b1..b73f17023fde1f85398526cea9a27271f768df09 100644
--- a/app.py
+++ b/app.py
@@ -1,12 +1,9 @@
"""조선일보 본문 교열 데모 (Streamlit / HF Space).
-solar-mini4 교열 파이프라인을 solar-eval 엔진 그대로 실행한다 — FT 교열 →
-판정형 step1 → 문맥 교정 어휘/문법 2스텝 → 결정론 규칙 6층. 구성 정의와 실행은
-전부 runner.py 이고 이 파일은 UI 만 담당한다. 사용자 입력과 결과는 logbook.py 가
-Supabase 에 남긴다 (시크릿 없으면 조용히 건너뜀).
-
-모델 셀렉터는 두지 않는다 — 파이프라인·프롬프트·모델은 한 묶음이고, 모델만
-갈아끼우면 다른 구성이 된다 (runner.CONFIGS 주석 참조).
+FT 교열 + Opus 기사 검토 구성을 solar-eval 엔진 그대로 실행한다 - FT 교열 모델이 문단 묶음마다 1차 교열하고,
+Claude Opus 가 기사 전체를 한 번 보고 FT 오교정을 되돌리며 사실·숫자·문맥 오류를 고친 뒤 결정론 규칙을 건다.
+구성 정의와 실행은 전부 runner.py 이고 이 파일은 UI 만 담당한다. 사용자 입력과 결과는 logbook.py 가
+Supabase 에 남긴다(시크릿이 없으면 조용히 건너뜀).
"""
from __future__ import annotations
@@ -24,33 +21,29 @@ PANE_STYLE = (
"padding:14px 16px;line-height:1.9;font-size:1.02rem;min-height:120px"
)
-# 합성 예문 — 평가셋 gold 는 쓰지 않는다(유출 방지). 각 층의 표적을 일부러 심어 둔 문장이다:
-# 비률/갱신률/연체률(사전·율률 규칙) · WSJ은·160㎞으로(조사 교정) · ▶ 와 곡선 따옴표(오탐 되돌림 가드).
+# 합성 예문 - 평가셋 gold 는 쓰지 않는다(유출 방지). 각 층의 표적을 일부러 심어 둔 문장이다:
+# 비률/갱신률/연체률(사전·율률 규칙) · 의문형 어미 · 160㎞으로(조사) · 곧은 따옴표(원문 따옴표 보존).
SAMPLE_TEXT = (
"▶정부의 강력한 부동산 규제에도 불구하고 수도권 아파트 매매 거래 비률은 "
"오히려 증가세를 보였다. 시중 은행의 주택 담보 대출 금리가 낮아지면서 "
"실수요자의 구매 심리가 되살아났기 때문이라는 분석이 나온다. "
"국토부 관계자는 “대출 갱신률과 연체률을 함께 봐야 한다”며 “당분간 이런 "
- "흐름이 이어질 것”이라고 말했다. WSJ은 한국의 주택 시장이 수도권 중심으로 "
- "재편되고 있다고 보도했다. 신규 광역철도는 최고 시속 160㎞으로 설계됐다."
+ "흐름이 이어질 것”이라고 말했다. 업계에선 'K-주거' 모델이 해외에서도 통할지 "
+ "주목하고 있다. 신규 광역철도는 최고 시속 160㎞으로 설계됐다."
)
-# 스텝 이름 → 사용자에게 보여줄 라벨. 세 구성의 스텝을 모두 담는다 (모르는 스텝은 이름 그대로).
-# basic_correction 의 라벨이 구성마다 다르다 — v44 에서는 생성이 아니라 FT 결과 판정이다.
+# 스텝 이름 → 사용자에게 보여줄 라벨 (모르는 스텝은 이름 그대로)
STEP_LABELS = {
"vocabulary_sub_pre": "용어 사전 치환",
- "proofread": "1차 교열 (교열 전용 FT 모델)",
- "basic_correction": "기초 교정 / FT 결과 판정",
- "context_correction": "문맥 교정",
- "context_vocab": "문맥 교정 · 어휘",
- "context_grammar": "문맥 교정 · 문법",
- "style_correction": "문체 교정",
- "vocabulary_reapply": "용어 사전 재적용",
- "compound_whitelist_restore": "복합어 화이트리스트 복원",
+ "proofread": "1차 교열 (교열 전용 FT 모델, 문단별)",
+ "ft_hunks": "FT 수정 목록 정리",
+ "review": "기사 전체 검토 (Claude Opus 1회)",
+ "revert_ft": "FT 오교정 되돌림",
+ "apply_review": "사실·숫자·문맥 교정 적용",
+ "quote_direction": "따옴표 방향 정리",
+ "quote_preserve": "원문 따옴표 보존",
"yul_ryul_normalize": "율/률 규칙 적용",
"post_process": "후처리 (단위·통화·쉼표)",
- "josa_fix": "조사 교정 (기호·숫자·단위 뒤)",
- "diff_revert": "오탐 되돌림 (글리프·구조문자·쉼표)",
}
@@ -76,64 +69,32 @@ def diff_panes(src: str, dst: str) -> tuple[str, str, int]:
class ProgressView:
- """스텝 진행을 st.status 안에 그린다 — 단계별 출력은 보여주지 않고 '어디까지 왔나' 만.
-
- 문서 단위 chunk 모드에서는 chunk 들이 **병렬**로 돌아 이벤트가 섞여 들어오므로, chunk 마다 줄을 하나씩
- 두고(몇 단계까지 왔나) 전체 진행 바는 (끝난 단계 수) / (chunk 수 × 단계 수) 로 잡는다.
- """
+ """스텝 진행을 st.status 안에 그린다 - 단계별 출력은 보여주지 않고 '어디까지 왔나' 만."""
def __init__(self, names: list[str]) -> None:
self.names = names
- self.total_steps = len(names)
- self.n_chunks = 1
- self.done: dict[int, int] = {1: 0} # chunk 번호 → 끝난 단계 수
- self.current: dict[int, str] = {} # chunk 번호 → 진행 중 단계 라벨
+ self.done = 0
self.status = st.status("교열 중입니다…", expanded=True)
- self.bar = self.status.progress(0.0, text=f"0 / {self.total_steps} 단계")
+ self.bar = self.status.progress(0.0, text=f"0 / {len(names)} 단계")
self.lines = self.status.empty()
self._render()
- def on_step(self, event: str, index: int, total: int, name: str, doc_chunk=None) -> None:
- ci = doc_chunk[0] if doc_chunk else 1
- if doc_chunk:
- self.n_chunks = doc_chunk[1]
- self.done.setdefault(ci, 0)
- label = STEP_LABELS.get(name, name)
- if event == "start":
- self.current[ci] = label
- elif event == "chunk":
- self.current[ci] = f"{label} · 조각 {index}/{total}"
- elif event == "done":
- self.done[ci] = index
- self.current.pop(ci, None)
- finished = sum(self.done.values())
- grand = self.n_chunks * self.total_steps
- unit = "단계" if self.n_chunks == 1 else f"단계 (문서 조각 {self.n_chunks}개 병렬)"
- self.bar.progress(min(finished / grand, 1.0), text=f"{finished} / {grand} {unit}")
+ def on_step(self, event: str, index: int, total: int, name: str) -> None:
+ if event == "done":
+ self.done = index
+ self.bar.progress(min(self.done / total, 1.0), text=f"{self.done} / {total} 단계")
self._render()
def _render(self) -> None:
- if self.n_chunks == 1:
- done = self.done.get(1, 0)
- rows = []
- for i, name in enumerate(self.names, 1):
- label = STEP_LABELS.get(name, name)
- if i <= done:
- rows.append(f"✅ {label}")
- elif i == done + 1:
- rows.append(f"⏳ **{self.current.get(1, label)}**")
- else:
- rows.append(f"▫️ {label}")
- else:
- rows = []
- for ci in range(1, self.n_chunks + 1):
- done = self.done.get(ci, 0)
- if done >= self.total_steps:
- rows.append(f"✅ 조각 {ci}: 완료")
- elif ci in self.current:
- rows.append(f"⏳ 조각 {ci}: **{self.current[ci]}** ({done}/{self.total_steps})")
- else:
- rows.append(f"▫️ 조각 {ci}: 대기")
+ rows = []
+ for i, name in enumerate(self.names, 1):
+ label = STEP_LABELS.get(name, name)
+ if i <= self.done:
+ rows.append(f"✅ {label}")
+ elif i == self.done + 1:
+ rows.append(f"⏳ **{label}**")
+ else:
+ rows.append(f"▫️ {label}")
self.lines.markdown(" \n".join(rows))
def finish(self, elapsed_s: float) -> None:
@@ -144,49 +105,47 @@ class ProgressView:
@st.cache_resource(show_spinner="파이프라인 준비 중…")
-def get_pipeline(label: str):
- """구성 라벨로 캐시한다 — 구성이 늘면 그 구성의 파이프라인이 따로 조립된다."""
- return runner.build_pipeline(label)
+def get_pipeline():
+ return runner.build_pipeline()
st.set_page_config(page_title="조선일보 본문 교열 데모", page_icon="📰", layout="wide")
st.title("📰 조선일보 본문 교열 데모")
-st.caption("solar-mini4 파이프라인 · 평가 하네스와 같은 엔진 코드")
+st.caption(f"{runner.CONFIG.label} · 평가 하네스와 같은 엔진 코드")
with st.sidebar:
st.subheader("설정")
- if not os.environ.get("UPSTAGE_API_KEY"):
- key = st.text_input(
- "UPSTAGE_API_KEY",
- type="password",
- help="HF Space 에서는 Settings → Secrets 로 설정하세요.",
- )
- if key:
- os.environ["UPSTAGE_API_KEY"] = key
- label = runner.DEFAULT_LABEL
- cfg = runner.BY_LABEL[label]
+ for env_key in runner.REQUIRED_KEYS:
+ if not os.environ.get(env_key):
+ value = st.text_input(
+ env_key, type="password", help="HF Space 에서는 Settings → Secrets 로 설정하세요."
+ )
+ if value:
+ os.environ[env_key] = value
+ cfg = runner.CONFIG
st.markdown(f"**{cfg.label}**")
st.caption(cfg.note)
- st.markdown(f"- 모델 `{cfg.model}`\n- 파이프라인 `{cfg.pipeline}`\n- 프롬프트 `{cfg.prompt}`")
+ st.markdown(f"- 파이프라인 `{cfg.pipeline}`\n- 프롬프트 `{cfg.prompt}`")
-text = st.text_area("기사 본문", value=SAMPLE_TEXT, height=320, max_chars=4000)
+text = st.text_area("기사 본문", value=SAMPLE_TEXT, height=320, max_chars=12000)
if st.button("교열 실행", type="primary", disabled=not text.strip()):
- if not os.environ.get("UPSTAGE_API_KEY"):
+ missing = [k for k in runner.REQUIRED_KEYS if not os.environ.get(k)]
+ if missing:
st.error(
- "UPSTAGE_API_KEY 가 필요합니다 — 사이드바에 입력하거나 Space Secrets 에 설정하세요."
+ f"{', '.join(missing)} 가 필요합니다 - 사이드바에 입력하거나 Space Secrets 에 설정하세요."
)
st.stop()
source = text.strip()
- pipeline = get_pipeline(label)
- # 입력은 실행 전에 남긴다 — 실행이 실패해도 무엇이 들어왔는지는 알 수 있게
+ pipeline = get_pipeline()
+ # 입력은 실행 전에 남긴다 - 실행이 실패해도 무엇이 들어왔는지는 알 수 있게
article_id = logbook.save_article(source)
progress = ProgressView(runner.step_names(pipeline))
try:
- result = runner.run_proofread(pipeline, source, label=label, on_step=progress.on_step)
- except Exception as e: # noqa: BLE001 — 데모 표면에서는 원인 요약만 보여준다
+ result = runner.run_proofread(pipeline, source, on_step=progress.on_step)
+ except Exception as e: # noqa: BLE001 - 데모 표면에서는 원인 요약만 보여준다
progress.fail()
st.error(f"실행 실패: {type(e).__name__}: {e}")
st.stop()
@@ -204,7 +163,6 @@ if st.button("교열 실행", type="primary", disabled=not text.strip()):
st.caption(f"기록 실패 (결과에는 영향 없음): {logbook.last_error()}")
left, right, _n_changed = diff_panes(source, result["output"])
-
c1, c2 = st.columns(2, gap="medium")
with c1:
st.subheader("원문")
diff --git a/assets/pipelines/pipeline_dev_v40.yaml b/assets/pipelines/pipeline_dev_v40.yaml
deleted file mode 100644
index 81a86d5c0c2fbac2c5e665debc227b8025e012fb..0000000000000000000000000000000000000000
--- a/assets/pipelines/pipeline_dev_v40.yaml
+++ /dev/null
@@ -1,71 +0,0 @@
-name: pipeline_dev_v40
-description: "v38(C1) + 규칙 층 3종 (2026-09-18 라운드): ① vocabulary_v2.csv — 표준어/사이시옷/외래어 혼동 71쌍 등재 (C3 실패로 소거된 'LLM 기초교열 추가'의 규칙 대체), ② josa_fix — 기호·숫자·단위 뒤 조사 결정론 교정 (풀셋 gold 15건 중 모델 적중 1건이던 클래스), ③ diff_revert — 글리프 변환·쉼표 삽입·금지 치환·격식 전환 오탐 되돌림 (paragraph 오탐 410건 중 ~100건이 이 클래스). 풀셋 재생(replay) 실측: paragraph +4.71 / ci-v0 +3.67 / ci-v1 +1.78, rep-small 3반복 재생 +2.47. 스텝 추가라 extends 불가, 전체 파일."
-text_split_size: 500
-steps:
- - name: vocabulary_sub_pre
- type: rule
- rule_type: vocabulary_sub
- config:
- vocab_dataset: resources/vocabulary_v2.csv
-
- - name: proofread
- type: llm
- model: ft:solar-news-correction
- prompt_key: proofread
- max_tokens: 2000
-
- - name: basic_correction
- type: llm
- prompt_key: step1
- user_template: |
- <원문>
- {steps.vocabulary_sub_pre}
- 원문>
-
- <교열_모델_수정결과>
- {current}
- 교열_모델_수정결과>
-
- - name: context_vocab
- type: llm
- prompt_key: step2a
-
- - name: context_grammar
- type: llm
- prompt_key: step2b
-
- - name: vocabulary_reapply
- type: rule
- config:
- vocab_dataset: resources/vocabulary_v2.csv
-
- - name: compound_whitelist_restore
- type: rule
- config:
- whitelist_path: resources/compound_whitelist.txt
-
- - name: yul_ryul_normalize
- type: rule
- config: {}
-
- - name: post_process
- type: rule
- config:
- pronoun_replacements:
- 이재명 대표: 이재명 대통령
- currency_compact: true
- comma_removal: true
- unit_unicode: true
-
- - name: josa_fix
- type: rule
- config: {}
-
- - name: diff_revert
- type: rule
- config:
- glyph_restore: true
- comma_insertion_revert: true
- forbidden_insertions:
- - 청와대
- register_guard: true
diff --git a/assets/pipelines/pipeline_dev_v44.yaml b/assets/pipelines/pipeline_dev_v44.yaml
deleted file mode 100644
index 7ccb7b4a050fbe3de9668b7e99d3d74da76c9c0e..0000000000000000000000000000000000000000
--- a/assets/pipelines/pipeline_dev_v44.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-name: pipeline_dev_v44
-description: "v41 + 문서 단위 chunk — 데모(HF Space) 표면용. 내용상 `pipeline_dev_v41` 과 동일하고 `document_chunking: true` 하나만 다르다. **base 가 v40 인 이유**: 상속은 1단계로 제한돼 있어(core/pipeline_compose.py) v41(= v40 + punct 플래그)을 다시 extends 할 수 없다 — 그래서 v41 이 얹는 플래그를 여기서 함께 선언한다. 두 파일은 diff_revert 설정이 갈라지면 안 되므로 v41 을 고칠 때 이 파일도 같이 본다. chunk 근거: v35(= v34 + chunk)가 long-article 매칭 ΔF1 −0.9 → +3.5 (2026-09-10, `#260910_long_article_regression_set`). 단 v41 계열에서의 긴 기사 검증은 아직 없다(long-article 셋 미확보)."
-extends: pipeline_dev_v40
-overrides:
- steps.diff_revert.config.punct_deletion_revert: true
- document_chunking: true
diff --git a/assets/pipelines/pipeline_exp_261021_ft_hybrid.yaml b/assets/pipelines/pipeline_exp_261021_ft_hybrid.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..a01fede73030f19f5ee4d55a34a0a858cfa07c33
--- /dev/null
+++ b/assets/pipelines/pipeline_exp_261021_ft_hybrid.yaml
@@ -0,0 +1,66 @@
+name: pipeline_exp_261021_ft_hybrid
+description: 'FT 문단 층 + 기사 층 1회 (2026-10-21) - 표기 사전 치환 뒤 ft:solar-news-correction 을 학습 시스템 프롬프트(ft_system)로 줄바꿈 500자 묶음마다 동시에 부르고(묶음 동시 8), 원문 대비 변경 목록(사전 치환 포함, 띄어쓰기만 다른 것은 뺌)과 FT 교정본 전문을 기사 층 1회(review: FT·사전 사고 되돌림 번호 + FT 가 못 보는 사실·숫자·문맥 낱말 교정 목록)에 준다. 결정론 되돌림·적용 뒤 따옴표 방향·율/률·후처리 규칙. 기본 기사 층은 Opus, Solar 만이면 --set steps.review.model=solar-pro4 --set steps.review.prompt_key=review_sp4. 측정·변이 기록은 insights/261021_ft_hybrid_paragraph_article.md.'
+text_split_size: 500
+text_split_mode: lines
+steps:
+ - name: vocabulary_sub_pre
+ type: rule
+ rule_type: vocabulary_sub
+ config:
+ vocab_dataset: resources/vocabulary.csv
+ - name: proofread
+ type: llm
+ model: ft:solar-news-correction
+ prompt_key: ft_system
+ max_tokens: 2000
+ temperature: 0.0
+ bulk_concurrency: 8
+ - name: ft_hunks
+ type: diff_hunks
+ config:
+ key: ft_diff
+ context: 20
+ max_hunks: 80
+ skip_spacing: true
+ - name: review
+ type: llm
+ model: timely:claude-opus-5-5
+ prompt_key: review
+ output_mode: json
+ max_tokens: 4000
+ user_template: |
+
+ {steps.ft_diff}
+
+
+ <본문>
+ {current}
+ 본문>
+ - name: revert_ft
+ type: revert_hunks
+ config:
+ hunks_from: ft_hunks
+ from_step: review
+ - name: apply_review
+ type: apply_corrections
+ config:
+ from_step: review
+ max_items: 12
+ max_shrink: 0.6
+ - name: quote_direction
+ type: rule
+ config: {}
+ - name: quote_preserve
+ type: rule
+ config: {}
+ - name: yul_ryul_normalize
+ type: rule
+ config: {}
+ - name: post_process
+ type: rule
+ config:
+ pronoun_replacements:
+ 이재명 대표: 이재명 대통령
+ currency_compact: true
+ comma_removal: true
+ unit_unicode: true
diff --git a/assets/prompts/prompt_dev_260917_vsplit/step1.txt b/assets/prompts/prompt_dev_260917_vsplit/step1.txt
deleted file mode 100644
index 2de7af4e2da08ad666ba39bc118fd4ab6cd79fce..0000000000000000000000000000000000000000
--- a/assets/prompts/prompt_dev_260917_vsplit/step1.txt
+++ /dev/null
@@ -1,85 +0,0 @@
-🚨🚨🚨 **최상위 핵심 지시사항 (PRIME DIRECTIVE)** 🚨🚨🚨
-**사실 보존이 최우선이다.** `<원문>` 이 가리키는 인물·기관·직책·숫자·날짜·사건은 너의 지식과 다르더라도 절대 다른 대상으로 바꾸지 않는다. 이를 위반하는 것이 가장 중대한 오류다.
-
-## Task
-너는 한국어 뉴스 교열의 **검증자(verifier)** 다. 두 텍스트를 받는다:
-- `<원문>`: 교열되지 않은 원본. **모든 사실 정보(이름, 직책, 숫자 등)의 기준.**
-- `<교열_모델_수정결과>`: 1차 교열 모델이 수정한 결과. 올바른 교정과 잘못된 수정이 섞여 있을 수 있다.
-
-너의 임무는 **새로 교정하는 것이 아니라, 1차 교열의 수정을 하나씩 판정하는 것**이다.
-다음 3단계를 순서대로 수행하라:
-
-1. **차이 나열**: `<원문>` 과 `<교열_모델_수정결과>` 를 어절 단위로 대조해, 1차 교열이 바꾼 지점을 **빠짐없이** 나열한다. 형식: `'원문 표현' -> '수정 표현'`
-2. **판정**: 각 차이에 대해 `keep`(올바른 교정, 유지) 또는 `revert`(잘못된 수정, 원문 복원) 를 판정하고 근거를 한 줄로 적는다.
-3. **최종 조립**: `keep` 판정만 반영하고 `revert` 판정은 원문 표현으로 되돌린 최종 텍스트를 만든다.
-
-**새 교정을 추가하는 것은 너의 임무가 아니다.** 1차 교열이 놓친 오류는 이후 단계가 처리한다. 차이가 하나도 없으면 입력을 그대로 출력한다.
-
-## 판정 기준
-
-**keep (올바른 교정 — 유지):**
-- 맞춤법·오탈자 교정 (예: `권의주의` -> `권위주의`, `기렷다` -> `기렸다`)
-- 사이시옷 교정 (예: `코수염` -> `콧수염`, `댓가` -> `대가` — 넣을 곳과 뺄 곳 모두)
-- 조사·어미 교정 (예: `1를` -> `1을`, `~했다고 했다` 의 호응 교정)
-- 명백한 띄어쓰기 오류 교정
-- 같은 대상의 표기 오류 교정 (예: `오니카와` -> `오키나와`, `쉥겐` -> `솅겐` — 대상이 같고 표기만 틀렸을 때)
-
-**revert (잘못된 수정 — 원문 복원):**
-- **대상 교체**: 인물·기관·직책·사건을 다른 대상으로 바꾼 것 (예: `이재명 대통령` -> `윤석열 대통령`, `기시다 총리` -> `다카이치 총리`). 너의 지식상 원문이 틀려 보여도 **무조건 revert**.
-- **숫자·날짜·단위 값 변경**: 수치 자체를 바꾼 것 (표기 정규화가 아니라 값이 달라진 경우)
-- **동의어 치환·윤문**: 원문 표현도 표준어이고 뜻이 통하는데 다른 표현으로 바꾼 것 (예: `재차` -> `다시`)
-- **문장 압축·내용 삭제·내용 추가**: 원문에 있던 내용이 사라지거나 없던 내용(괄호 부연 포함)이 생긴 것
-- **허용되는 표기를 바꾼 것**: 붙여 씀/띄어 씀이 모두 허용되는 복합어의 재조정
-- **문단·문장 구조 변경**: `` 나 줄바꿈의 추가·삭제·이동
-
-판단이 확실하지 않으면 **revert** 한다 — 원문 유지가 기본값이다.
-
-## 출력 형식
-
-먼저 판정 목록을 자유 텍스트로 적은 뒤, **마지막에 JSON 하나**를 출력한다.
-- `output`: 최종 텍스트 전체. `` 와 줄바꿈 구조는 원문과 100% 동일하게 유지 (`[IMG:~~]` 태그 포함).
-- `explanation`: revert 한 항목 요약. 없으면 `"모든 수정 유지"`. 줄바꿈은 `\n` 으로 표기.
-
-## 예시
-
-**Input:**
-```xml
-<원문>
-윤석열 대통령은 이날 점오에 모여 회의를 기렷다.
-원문>
-<교열_모델_수정결과>
-이재명 대통령은 이날 정오에 모여 회의를 기렸다.
-교열_모델_수정결과>
-```
-
-**CORRECT EXAMPLE:**
-차이 판정:
-1. '윤석열 대통령은' -> '이재명 대통령은' : revert (대상 교체 — 원문의 인물을 다른 인물로 변경)
-2. '점오에' -> '정오에' : keep (오탈자 교정)
-3. '기렷다' -> '기렸다' : keep (맞춤법 교정)
-
-{"output": "윤석열 대통령은 이날 정오에 모여 회의를 기렸다.", "explanation": "'이재명 대통령은' -> '윤석열 대통령은' (대상 교체 복원)"}
-
-**Input:**
-```xml
-<원문>
-그는 젊은 시절 권위주의에 맞서 재차 투쟁했다.
-원문>
-<교열_모델_수정결과>
-그는 젊은 시절 권위주의에 맞서 다시 투쟁했다.
-교열_모델_수정결과>
-```
-
-**CORRECT EXAMPLE:**
-차이 판정:
-1. '재차' -> '다시' : revert (동의어 치환 — 원문 표현도 표준어)
-
-{"output": "그는 젊은 시절 권위주의에 맞서 재차 투쟁했다.", "explanation": "'다시' -> '재차' (동의어 치환 복원)"}
-
-------------------------------------
-
-⚠️ 최종 확인:
-- 판정 목록 다음, 마지막에 JSON 한 개만 출력한다 (JSON 뒤에 다른 텍스트 금지)
-- `output` 의 `` 개수·위치·줄바꿈은 원문과 100% 동일
-- 차이가 없으면: `{"output": "[입력 그대로]", "explanation": "수정된 내용이 없습니다."}`
-- 새 교정 추가 금지 — 나열·판정·조립만 한다
diff --git a/assets/prompts/prompt_dev_260917_vsplit/step2a.txt b/assets/prompts/prompt_dev_260917_vsplit/step2a.txt
deleted file mode 100644
index 220f11e76a3ff635002b4b91cee902910e2278f1..0000000000000000000000000000000000000000
--- a/assets/prompts/prompt_dev_260917_vsplit/step2a.txt
+++ /dev/null
@@ -1,36 +0,0 @@
-🚨🚨🚨 **최상위 핵심 지시사항 (PRIME DIRECTIVE)** 🚨🚨🚨
-1. **사실 정보 수정 절대 금지**: 인물, 직책, 숫자, 고유명사는 너의 지식과 다르더라도 절대 바꾸지 않는다. (입력 `이재명 대통령` → 출력 `이재명 대통령`)
-2. **구조 절대 유지**: 문단 수·문장 순서·줄바꿈·``·`[IMG:~~]` 태그를 그대로 유지한다. 내용 추가(괄호 부연 포함)·삭제 금지.
-3. **수정하지 않는 것이 기본값이다**: 동의어 치환, 문장 압축, 띄어쓰기 재조정, 표현 다듬기는 전부 금지. 아래 임무에 해당하는 교정만 한다.
-
-# 역할
-당신은 뉴스 기사 교열가 중 **어휘 오식(誤植) 전담**입니다. 기초 교열이 끝난 텍스트에서 **단 한 가지**만 찾습니다: **문맥에 뜻이 통하지 않는 단어**.
-
-신문 제작 과정에서는 발음·표기가 비슷한 다른 단어로 잘못 찍히는 오식이 잦습니다. 문장을 읽으며 뜻이 어긋나는 단어를 발견하면, 발음이 비슷하고(대개 한두 글자 차이) 뜻이 명백히 통하는 단어로 교정하세요. **현재 단어로도 뜻이 통하면 절대 바꾸지 않습니다** — 이 작업은 오식 복원이지 어휘 선호 교체가 아닙니다.
-
-# 교정 예시 (오식 복원)
-- 곤혹을 치르다 → 곤욕을 치르다 (치르는 것은 '곤욕')
-- 겉잡을 수 없이 → 걷잡을 수 없이
-- 정답을 맞추다 → 정답을 맞히다 (적중은 '맞히다')
-- 수출량이 급등하다 → 수출량이 급증하다 (수량은 급증/급감, 가격·지수는 급등/급락)
-- 실수가 번복되다 → 실수가 반복되다 (되풀이는 '반복', 결정 뒤집기는 '번복')
-- 신문에 개재된 기사 → 신문에 게재된 기사
-- 유래없는 폭염 → 유례없는 폭염 (비슷한 사례는 '유례')
-- 승전보를 울리다 → 승전고를 울리다
-- 각출하다 → 갹출하다 (여럿이 나누어 낼 때)
-- 매여 → 메어 (어깨에 걸 때는 '메다')
-
-# 바꾸면 안 되는 예 (뜻이 통함 — 유지)
-- '재차 강조했다' → 그대로 유지 ('재차'도 표준어다. '다시'로 바꾸지 마라)
-- '구별하다' → 문맥상 뜻이 통하면 그대로 유지
-- 율/률 표기, 띄어쓰기, 사이시옷 → 이 단계의 대상이 아니다. 손대지 마라
-
-# 출력 형식
-JSON 하나만 출력한다 (마크다운 코드 블록·설명문 금지):
-{"output": "[교정 완료된 전체 텍스트 - 수정 없어도 입력 전체 포함, ·줄바꿈은 입력과 100% 동일]", "explanation": "['기존' -> '수정' (이유)' 형식, 여러 개는 \\n 구분, 없으면 '수정된 내용이 없습니다.']"}
-
-**Input:** 사고 여파가 겉잡을 수 없이 커지자 시는 곤혹을 치렀다.
-**CORRECT:** {"output": "사고 여파가 걷잡을 수 없이 커지자 시는 곤욕을 치렀다.", "explanation": "'겉잡을' -> '걷잡을' (오식 복원)\n'곤혹을' -> '곤욕을' (치르는 것은 '곤욕')"}
-
-**Input:** K팝 그룹이 컴백했다.
-**CORRECT:** {"output": "K팝 그룹이 컴백했다.", "explanation": "수정된 내용이 없습니다."}
diff --git a/assets/prompts/prompt_dev_260917_vsplit/step2b.txt b/assets/prompts/prompt_dev_260917_vsplit/step2b.txt
deleted file mode 100644
index e28e3858baec07426dc034c94f5e86828a425b1b..0000000000000000000000000000000000000000
--- a/assets/prompts/prompt_dev_260917_vsplit/step2b.txt
+++ /dev/null
@@ -1,33 +0,0 @@
-🚨🚨🚨 **최상위 핵심 지시사항 (PRIME DIRECTIVE)** 🚨🚨🚨
-1. **사실 정보 수정 절대 금지**: 인물, 직책, 숫자, 고유명사는 너의 지식과 다르더라도 절대 바꾸지 않는다. (입력 `이재명 대통령` → 출력 `이재명 대통령`)
-2. **구조 절대 유지**: 문단 수·문장 순서·줄바꿈·``·`[IMG:~~]` 태그를 그대로 유지한다. 내용 추가(괄호 부연 포함)·삭제 금지.
-3. **수정하지 않는 것이 기본값이다**: 동의어 치환, 문장 압축, 어순 재배치, 표현 다듬기, 띄어쓰기 재조정은 전부 금지. 아래 임무에 해당하는 교정만 한다.
-
-# 역할
-당신은 뉴스 기사 교열가 중 **문법 호응 전담**입니다. 기초 교열이 끝난 텍스트에서 다음 네 가지만 찾습니다:
-
-1. **조사 오류·누락**: 받침에 맞지 않는 조사, 문맥에 안 맞는 조사, 빠진 조사
- - 예: `1를` → `1을` / `하르그섬이 원유 수출 심장부` → `하르그섬의 원유 수출 심장부` (소유 관계)
-2. **어미·용언 활용 오류**: 잘못된 활용형, 겹친 어미
- - 예: `가겠다"며며` → `가겠다"며` / `슬프자` → `슬퍼서` / `모자라안 하다` → `모자라지 않다`
-3. **문장 성분 호응 오류**: 주어-서술어, 목적어-서술어가 어긋나는 경우 — **어긋난 성분만 최소 수정**
- - 예: `어떻게 관리하는가 중요하다` → `어떻게 관리하는지가 중요하다`
-4. **단위 누락**: 수치 뒤에 단위가 빠진 경우 — 문맥에 명시된 단위만 보충
- - 예: `시장 규모는 1조7000억이다` → `시장 규모는 1조7000억원이다` (앞 문맥이 금액일 때)
-
-**사동/피동 혼용**은 명백히 문법이 틀린 경우만 고칩니다 (예: `보여지다` → `보이다`). 둘 다 허용되는 표현은 그대로 둡니다.
-
-# 바꾸면 안 되는 예 (유지)
-- 뜻이 통하는 문장의 어순 → 그대로 유지 (재배치는 너의 임무가 아니다)
-- '~에도 불구하고', 번역투로 보이는 표현 → 문법이 틀리지 않았으면 유지
-- 띄어쓰기·사이시옷·맞춤법 → 이 단계의 대상이 아니다. 손대지 마라
-
-# 출력 형식
-JSON 하나만 출력한다 (마크다운 코드 블록·설명문 금지):
-{"output": "[교정 완료된 전체 텍스트 - 수정 없어도 입력 전체 포함, ·줄바꿈은 입력과 100% 동일]", "explanation": "['기존' -> '수정' (이유)' 형식, 여러 개는 \\n 구분, 없으면 '수정된 내용이 없습니다.']"}
-
-**Input:** 법원은 혐의 11개에 대해 유죄 판결을 내렸고 조국은 "가시밭길을 가겠다"며며 항소했다.
-**CORRECT:** {"output": "법원은 혐의 11개에 대해 유죄 판결을 내렸고 조국은 \"가시밭길을 가겠다\"며 항소했다.", "explanation": "'가겠다\"며며' -> '가겠다\"며' (겹친 어미 교정)"}
-
-**Input:** K팝 그룹이 컴백했다.
-**CORRECT:** {"output": "K팝 그룹이 컴백했다.", "explanation": "수정된 내용이 없습니다."}
diff --git a/assets/prompts/prompt_exp_261021_ft_hybrid/ft_system.txt b/assets/prompts/prompt_exp_261021_ft_hybrid/ft_system.txt
new file mode 100644
index 0000000000000000000000000000000000000000..355f067e35a03851fd8896ef584a4a9d3d012720
--- /dev/null
+++ b/assets/prompts/prompt_exp_261021_ft_hybrid/ft_system.txt
@@ -0,0 +1 @@
+입력된 문서에 대한 교열 결과를 생성해 주세요.
diff --git a/assets/prompts/prompt_exp_261021_ft_hybrid/review.txt b/assets/prompts/prompt_exp_261021_ft_hybrid/review.txt
new file mode 100644
index 0000000000000000000000000000000000000000..03c5cc3a5a1a3b961ae7ca31a41bc8f5d15ce252
--- /dev/null
+++ b/assets/prompts/prompt_exp_261021_ft_hybrid/review.txt
@@ -0,0 +1,41 @@
+너는 한국 신문사 어문팀의 **최종 교열 데스크**다. 1차 교열 모델(FT)이 기사를 문단(500자)씩 나눠 이미 고쳤다. 너는 기사 전체를 한 번에 보고 두 가지 일을 한다.
+(A) FT 가 고친 자리 가운데 **FT 가 잘못 고친 것만** 골라 되돌린다. (B) FT 가 구조적으로 **못 보는 오류**를 찾아 짧은 교정 항목으로 낸다. 본문을 다시 쓰지 않는다.
+
+## FT 를 알고 시작한다
+- FT 는 규범 교열을 잘한다: 맞춤법·오탈자, 조사, 어미·활용, 주술 호응·비문, 사이시옷, 띄어쓰기, 겹친 말 삭제, 단위·문장부호 표기. 조선일보 관례(`N개국`→`N국`, `슈퍼`→`수퍼`, 곧은 따옴표→둥근 따옴표)도 FT 가 따른다. **이 종류의 FT 변경은 되돌리지 않는다.** 네가 보기에 다른 표현도 가능하더라도 그대로 둔다.
+- 단, 위 규범 교정은 **인용문 밖, 일반 낱말**에 대한 것이다. FT 는 따옴표 안 말, 고유명사 철자, 짧은 관형어·조사 하나에 담긴 뜻을 규범 교정인 척 바꾸는 사고를 낸다. 문단 밖을 못 보고 기사 작성 시점도 모른다. (A)에서 되돌릴 것은 아래 사고뿐이다.
+- FT 는 기사 전체의 사실·숫자·이름 관계와 문맥상 낱말 혼동을 거의 고치지 못한다 — (B)에서 네가 맡는다.
+
+## (A) 되돌릴 FT 변경 — `` 의 번호 중 아래에 해당하는 것
+판정 순서: ① 바뀐 자리가 따옴표 안인가 → 3번. ② 고유명사인가 → 2번. ③ 지워지거나 더해진 말이 뜻을 갖는가 → 4·5번. ④ 남은 것이 맞춤법·조사·어미·호응 정리면 둔다.
+1. **숫자 바꿈**: 연도·날짜·금액·비율·순위·인원 등의 숫자(0~9)가 다른 숫자로 바뀜. 예: `2026`→`2023`, `20곳`→`18곳`. (값은 같고 표기만 바뀐 것, `30001명`→`3001명`처럼 자릿수가 겹친 오타, **기사 안 다른 숫자로 검산하면 FT 쪽 값이 맞는 것**(합·차·비율), 기사 다른 곳 숫자와 맞춘 것은 둔다.)
+2. **고유명사 바꿈**: 인명·지명·기관명·회사명·작품명·직함이 다른 이름이 되거나 한 글자라도 바뀜, 순서 이동, 정식 명칭의 일부를 잘라냄(`경성제국대학`→`경성제대`). **상호·브랜드·제품명·시설명·행사 이름은 외래어 표기법과 달라도 그 이름의 표기가 정답이다**: `뚜레쥬르`→`뚜레주르`, `K-팝 페스티벌`의 하이픈 삭제 같은 표기 '교정'은 되돌린다. 외국 인명·지명의 한 글자 바꿈도 되돌린다. (기사 안 다른 곳 표기와 맞춘 것, 없는 글자 조합 같은 명백한 오타 교정은 둔다.)
+3. **인용 훼손**: 큰따옴표·작은따옴표 안 변경은 **원문 그대로 화자가 그렇게 말했을 법한가**로 가른다.
+ - 되돌림 — 말투 정리: 원문이 구어로 자연스럽고 뜻이 통하는데 다듬은 것. 축약형 풀기(`“그건 아니다”`→`“그것은 아니다”`, `“안 했다”`→`“하지 않았다”`), 복수 `들` 삭제(`“선수들이”`→`“선수가”`), 구어 조사 바꾸기(`“동생한테”`→`“동생에게”`), 약어·줄임말 풀기, 낱말·어순 취향, 글자를 넣고 뺌 — 규범상 더 낫더라도 되돌린다. 인용문·인용 뒤 절 삭제, `”라며`·`”라고`의 `라` 삭제도 되돌린다.
+ - 둔다 — 오류 교정: 원문 그대로는 문맥에 안 맞는 명백한 오류를 고친 것. 사전에 없는 오탈자, 한 글자가 달라 뜻이 엉뚱해진 낱말(`“의견을 수렵하겠다”`→`“수렴하겠다”`), 인용 끝 어미가 문장과 안 맞는 것(`“늘려야 하자”고`→`“늘려야 한다”고`).
+4. **뜻 있는 말 삭제**: 어절·구·절·문장·괄호 설명·사진 설명이 사라짐(`삼성전자가`→`∅`). 짧은 말도 뜻이 있으면 되돌린다: 시점 관형어(`오는`·`지난`·`약`), 복수 `들`(`이들 사안`→`이 사안`), 기간 `간`(`5년간은`→`5년은`), 날짜 범위의 `일`(`28일~다음 달 2일`), `시·군` 같은 가운뎃점(`시군`으로 붙어 뜻이 흐려짐), `와의`·`인`처럼 지우면 비문이 되는 말, 숫자 천 단위 쉼표(`152,000원`), 나열 사이 쉼표. `개`를 지워 `12군`·`20항`처럼 서수·부대명으로 읽히게 된 것도 되돌린다(`개국`→`국`은 관례라 둔다). (바로 앞뒤에 똑같은 말이 겹쳐 하나를 지운 것은 둔다.)
+5. **추가·뜻 바꿈**: 원문에 없던 정보·문장·`(사진)`이 붙음, 마침표 없는 중간제목 줄에 `이다.` 같은 서술어를 덧붙임, 맞는 낱말을 뜻이 다른 낱말로 바꿈(`연구개발비를`→`R&D 비용을`, `이달`→`다음 달`, `방문 계기`→`방문차`처럼 계기가 목적으로 바뀜). 조사 교체도 원문이 맞고 뜻이 바뀌면 되돌린다: 첨가의 `도`→`는`, 사람·그룹 뒤 `에게`→`에`, 주어 `이`→목적어 `을`로 서술어 호응이 깨짐. (원문 조사가 틀린 것을 고친 것 — `정부에게`→`정부에`, `학생들이 책이`→`책을` — 은 둔다.)
+6. **외래어 역교정**: 표준 외래어 표기를 비표준으로 바꿈. 예: `로봇`→`로보트`, `디지털`→`디지탈`, `리더십`→`리더쉽`, `센서`→`쎈서`(된소리), `패널`→`패넬`. (비표준을 표준으로 고친 것, 조선일보 사전 치환은 둔다.)
+7. **이동**: 같은 말이 한 곳에서 지워지고 다른 곳에 생긴 쌍 — 두 번호를 함께 낸다.
+8. **표기 사전 치환의 문맥 오적용**: 목록에는 조선일보 표기 사전의 기계 치환(예: `대통령실`→`청와대`, `환경부`→`기후에너지환경부`, `슈퍼`→`수퍼`)도 섞여 있다. 사전 치환은 조선일보 표기 규칙이므로 두되, **과거 시점의 당시 이름**(예: 윤석열 정부 '대통령실', 문재인 정부 '환경부 블랙리스트'), **이미 치환된 이름에 또 붙은 것**(`기후에너지기후에너지환경부`), **방송·작품·상품의 고유 제목**(예: '슈퍼맨이 돌아왔다') 안이면 되돌린다.
+인용문 밖 일반 낱말의 맞춤법·조사·어미 교정이 애매하면 두어라. 따옴표 안 말투 정리·고유명사·뜻 있는 말 삭제가 애매하면 원문 쪽(되돌림)을 택한다. 되돌릴 번호는 보통 목록의 15% 이하이고 0개도 흔하다.
+
+## (B) 기사 전체에서 새로 고칠 것 — `<본문>`(FT 교정본)을 처음부터 끝까지 훑으며 아래 표적을 하나씩 확인한다
+**FT 가 고친 자리는 다시 고치지 않는다**: `` 의 「바뀐 뒤」 구간(되돌리지 않은 번호)은 FT 가 이미 판단한 자리다. 그 구간을 `original` 에 넣지 않는다. 그 자리가 틀렸다고 보면 (A)에서 되돌릴지만 정한다.
+1. **기사 안 불일치**(inconsistency): 같은 인물·기관·사건의 이름·직함·숫자·날짜가 기사 안 다른 곳(제목·사진 설명 포함)과 어긋남. 더 여러 번 나오거나 풀네임·정식 명칭이 적힌 쪽에 맞춘다.
+2. **이름·명칭·사실**(fact): 고유명사가 나올 때마다 글자를 확인한다.
+ - 외국 지명·인명·단체·제도의 표준 한글 표기에서 글자가 틀림. 예: `스칸디니비아`→`스칸디나비아`, `베네주엘라`→`베네수엘라`.
+ - 행사·시상식·작품·법률·국제기구의 공식 명칭에서 글자가 빠지거나 바뀜. 예: `국제통화기구(IMF)`→`국제통화기금(IMF)`.
+ - 약어와 풀이가 서로 바뀌었거나 짝이 어긋남. 예: `국민총소득(GDP)`→`국민총소득(GNI)`.
+ - 널리 알려진 인물 이름의 글자 순서·한 글자 오기, 역사적 사건의 연도. 예: `한국전쟁이 일어난 1953년`→`1950년`.
+ 단, 회사·상점·시설·상품의 상호는 외래어 표기법과 달라도 공식 표기일 수 있으니 둔다. 최근 인사·직책·수치는 네 지식이 낡았을 수 있으니 기사 안 근거가 없으면 둔다.
+3. **검산**(calc): 기사 안 숫자끼리 이어지는 값을 실제로 계산한다 — 비율·증감률·합계·차이, 두 비율의 차(%포인트), 금액·단위 환산의 자릿수(10배·100배 어긋남), 날짜와 요일, 기사 안 두 시점으로 정해지는 기간·나이. `A에서 B로 늘었다`처럼 비교하는 두 값의 단위가 다르면(명↔%) 기사 안의 맞는 값으로 맞춘다. 계산은 두 번 해서 같을 때만 고치고, 기사 밖 기준 시점(작성일·'올해')에 기대는 값은 고치지 않는다.
+4. **문맥상 틀린 낱말**(word): 동음이의어·비슷한 소리·한자어·동사 혼동으로 문맥에 안 맞는 낱말. 문장마다 서술어가 주어·목적어와 뜻이 맞는지 본다. 예: `결재`↔`결제`, `한참`↔`한창`, `지양`↔`지향`, `정답을 맞추다`→`맞히다`, `대체제`→`대체재`.
+5. **남은 규범 오류**(grammar): FT 가 놓친 조사·어미·호응·비문·맞춤법 오류 가운데 **틀렸다고 단정할 수 있는 것**. 숫자·영문 뒤 조사는 읽는 소리에 맞춘다(예: `코드 9을`→`9를`). 한두 글자 오타로 보이면 문장을 다시 쓰지 말고 뜻에 가장 가까운 형태로 최소한만 고친다.
+고치지 말 것: 띄어쓰기만의 차이, 따옴표 방향(뒤 규칙이 맡는다), 문체·어순·표현 취향, 인용문 안 말투, 문단·줄바꿈, **`evidence` 에 기사 안 다른 곳·계산·확실한 상식 중 하나로 근거를 댈 수 없는 숫자·이름·낱말 변경**.
+
+## 출력 — JSON 한 객체만, 다른 말·코드펜스 없이
+{"revert_why": {"<번호>": "숫자|고유명사|인용|삭제|추가|외래어|이동|사전"}, "revert": [<번호>...], "corrections": [{"original": "<본문에 글자 그대로 있는 2~30자 구간>", "corrected": "<그 구간을 고친 것>", "category": "inconsistency|fact|calc|word|grammar", "evidence": "<근거 한 줄>"}]}
+- `revert` 의 번호는 `revert_why` 에 사유를 적은 번호와 같다. `` 에 있는 번호만 쓴다.
+- `original` 은 `<본문>` 에서 그대로 복사하고, 본문에서 한 번만 나오도록 앞뒤 글자를 넣는다. `original` 과 `corrected` 는 달라야 한다. 최대 12개.
+- 둘 다 없으면 {"revert_why": {}, "revert": [], "corrections": []}.
diff --git a/assets/prompts/prompt_exp_261021_ft_hybrid/review_sp4.txt b/assets/prompts/prompt_exp_261021_ft_hybrid/review_sp4.txt
new file mode 100644
index 0000000000000000000000000000000000000000..5abc849c6937b4b58415a2a5151733c3e4d30076
--- /dev/null
+++ b/assets/prompts/prompt_exp_261021_ft_hybrid/review_sp4.txt
@@ -0,0 +1,38 @@
+너는 신문 교열 데스크다. 1차 교열 모델(FT)이 기사를 이미 고쳤다. FT 는 맞춤법·오탈자·조사·어미·비문 교정을 아주 잘한다. 너는 아래 두 일만 한다. 본문을 다시 쓰지 않는다.
+(A) `` 에서 FT 가 사고를 낸 번호만 골라 되돌린다. **FT 변경은 기본으로 모두 유지한다.**
+(B) `<본문>` 에서 기사 안에 근거가 글자로 있는 오류만 찾아 고친다.
+
+## (A) 되돌림 절차 — 목록의 각 번호마다 1→5 순서로 확인한다
+목록 한 줄은 `#번호 …앞문맥「바뀌기 전」→「바뀐 뒤」뒷문맥…` 이다(∅=빈 문자열, ⏎=줄바꿈).
+1. 줄바꿈(⏎)·문장부호·따옴표 모양·띄어쓰기만 바뀌었으면 → 유지. 다음 번호로.
+2. 숫자 확인: 「바뀌기 전」과 「바뀐 뒤」의 아라비아 숫자를 차례로 적어 비교한다. 숫자가 다르면(예: `2025년`→`2022년`, `30곳`→`28곳`, `7번째`→`6번째`) → 되돌림(숫자). 단 다음은 유지: 숫자가 같고 단위 글자(만·억·조·원)만 정리됨, `19987년`→`1987년`처럼 겹친 자릿수 오타 정리, 비율 값이 바뀌었는데 기사 안 두 숫자로 직접 나눠 보니 「바뀐 뒤」 값이 나옴.
+3. 이름 확인: 사람·기관·지명·회사·작품 이름이 바뀌었으면, `<본문>` 을 검색한다.
+ - 「바뀌기 전」 이름이 본문 다른 곳에 그대로 나오면 → 되돌림(고유명사). 예: 본문에 '파월'이 여러 번 있는데 한 곳이 `파월`→`파울`.
+ - 완전히 다른 이름(두 글자 이상 다름)이나 다른 직함으로 바뀌었거나, 이름·직함 순서가 바뀌었으면 → 되돌림(고유명사).
+ - 그 밖(한 글자 오타 정리, 외래어 표기 정리 `도날드`→`도널드`류, 「바뀐 뒤」 이름이 본문 다른 곳에 있음) → 유지.
+4. 오탈자 확인: 「바뀌기 전」이 틀린 말이면 → **유지**. 틀린 말이란 사전에 없는 말·글자 빠짐·글자 겹침·틀린 조사·틀린 어미다. 예: `역활`→`역할`, `회사을`→`회사를`, `되요`→`돼요`, `하였읍니다`→`하였습니다`, `알맞는`→`알맞은`, 문맥상 다른 낱말 오타 `결재 대금`→`결제 대금`. 이런 것은 인용문 안이든 이름처럼 보이든 되돌리지 않는다.
+5. 「바뀌기 전」이 맞는 말인데 FT 가 바꾼 경우만 아래를 본다. 하나에 **분명히** 맞을 때만 되돌린다.
+ - 인용: “ ” 나 ‘ ’ 안의 맞는 말을 다른 말·어미로 바꾸거나 지움. 예: “제가 한 걸로 압니다”의 `걸로`→`것으로`, “사람들이 많았죠”의 `들이`→`이`. → 되돌림(인용)
+ - 삭제: 뜻 있는 어절·구·문장·괄호 설명이 ∅ 이 됨. 예: `오는 15일`의 `오는`→∅, `현대차가`→∅. 바로 옆에 같은 말이 겹쳐 하나를 지운 것은 유지. → 되돌림(삭제)
+ - 추가·바꿔 쓰기: 원문에 없던 정보가 붙음, 또는 맞는 낱말이 뜻이 다른 낱말로 바뀜. 예: `지난주`→`이번 주`, `수출액이`→`수입액이`, `인건비를`→`HR 비용을`. 같은 뜻의 다른 표현(`이외`→`이 밖에`, `되어`→`돼`)은 유지. → 되돌림(추가)
+ - 이동: 같은 말이 한 번호에서 지워지고 다른 번호에서 생김 → 두 번호 모두 되돌림(이동).
+ - 사전: 표기 사전 기계 치환(`대통령실`→`청와대`, `환경부`→`기후에너지환경부`, `슈퍼`→`수퍼` 등)은 조선일보 규칙이라 유지한다. 단 과거 정부·과거 사건의 당시 이름(예: 지난 정부 '대통령실' 인사, 옛 부처 시절 사건명), 이미 치환된 이름에 또 붙음(`기후에너지기후에너지환경부`), 방송·노래·상품 고유 제목 안이면 → 되돌림(사전).
+판단이 애매하면 유지한다. 되돌릴 번호는 기사 하나에 보통 0~2개다. 목록의 대부분은 4번(오탈자 정리)에 해당하니 유지해야 한다.
+
+## (B) 새 교정 — 아래 네 가지만, 근거가 `<본문>` 안에 글자로 있을 때만
+1. inconsistency(기사 안 불일치): 같은 인물·기관·사건의 이름·직함·숫자·날짜가 본문 두 곳에서 다르게 적힘. 더 여러 번 나오거나 풀네임이 적힌 쪽에 맞춰 적은 쪽을 고친다. evidence 에 두 곳의 글자를 모두 쓴다.
+2. calc(검산): 본문의 두 숫자로 계산되는 비율·증감률·합계·차이가 틀림. 계산을 두 번 해서 같을 때만 고치고, evidence 에 식을 쓴다(예: "42÷350=12.0%"). 괄호 안 환산액, 'N년 만'처럼 오늘 날짜가 필요한 값은 고치지 않는다.
+3. word(문맥 낱말): 비슷한 소리·한자어 혼동으로 문맥에 안 맞는 낱말. 예: 돈 내는 문맥의 `결재`→`결제`, `지양해야 할 목표`→`지향해야 할 목표`, `체제비`→`체재비`.
+4. grammar(남은 오탈자): FT 가 놓친 오탈자·틀린 조사·틀린 어미 가운데 틀렸다고 단정할 수 있는 것. 예: `정부은`→`정부는`, `하겠읍니다`→`하겠습니다`.
+하지 말 것: 네 지식만으로 이름·직함·숫자·연도·날짜를 바꾸기(기사 안 근거가 없으면 맞다고 본다), 기관·단체 이름 목록을 늘리기, 띄어쓰기, 문체·어순, 인용문 말투, FT 변경을 되돌리는 교정(되돌림은 (A)로만).
+
+### corrections 항목 쓰는 법
+- original: `<본문>` 에서 글자를 **그대로 복사**한다. `` 의 글자나 네가 기억하는 글자를 쓰지 않는다. 고칠 낱말과 앞뒤 한두 어절, 4~25자. 문장 전체를 넣지 않는다. 본문에서 한 번만 나오도록 한다.
+- corrected: original 에서 틀린 글자만 바꾼 것. 나머지 글자는 그대로 둔다.
+- 다 쓰고 확인: original 과 corrected 가 같으면 그 항목을 지운다. original 이 본문에 없으면 지운다. 최대 6개. 없으면 빈 배열이 정상이다.
+
+## 출력 — JSON 한 객체만, 다른 말·코드펜스 없이
+{"revert_why": {"<번호>": "숫자|고유명사|인용|삭제|추가|이동|사전"}, "revert": [<번호>...], "corrections": [{"original": "<본문 복사 구간>", "corrected": "<고친 구간>", "category": "inconsistency|calc|word|grammar", "evidence": "<근거 한 줄>"}]}
+- `revert` 의 번호와 `revert_why` 의 번호는 같다. 목록에 있는 번호만 쓴다.
+- 예: {"revert_why": {"3": "숫자"}, "revert": [3], "corrections": [{"original": "김민수 대표는 이날", "corrected": "김민준 대표는 이날", "category": "inconsistency", "evidence": "본문 다른 두 곳은 '김민준 대표'"}]}
+- 둘 다 없으면 {"revert_why": {}, "revert": [], "corrections": []}.
diff --git a/assets/resources/compound_whitelist.txt b/assets/resources/compound_whitelist.txt
deleted file mode 100644
index 2bbb7988eeb87e08648054a999a64acd756e000d..0000000000000000000000000000000000000000
--- a/assets/resources/compound_whitelist.txt
+++ /dev/null
@@ -1,3136 +0,0 @@
-삼성전자 34
-상설특검 25
-때문이다 20
-국민의힘 19
-기록했다 15
-시중은행 15
-현대차그룹 14
-계획이다 14
-알려졌다 13
-나타났다 13
-흡혈박쥐 13
-우리나라 12
-싱가포르 12
-과정에서 12
-대상으로 11
-예정이다 11
-인터넷은행 11
-프로그램 10
-시작했다 10
-조국혁신당 10
-상황에서 9
-주장했다 8
-더불어민주당 8
-것이라고 7
-허리케인 7
-집계됐다 7
-알레르기 7
-증가했다 7
-국정감사 7
-분석이다 7
-상황이다 7
-상대적으로 7
-대통령실 7
-위험운전치사상 7
-노벨문학상 7
-문학평론가 7
-수수료율 7
-서울에서 6
-스마트폰 6
-투자자들 6
-중심으로 6
-만들었다 6
-발표했다 6
-연금저축 6
-미디어텍 6
-적극적으로 6
-여론조사 6
-토스증권 6
-레지던스 6
-파드리스 6
-캔자스시티 6
-인도법인 6
-영업이익 5
-지적했다 5
-만들어진 5
-사이에서 5
-전망이다 5
-시내버스 5
-플레이오프 5
-지금까지 5
-원천징수 5
-일각에선 5
-와일드카드 5
-포스트시즌 5
-파리바게뜨 5
-어포스트로피 5
-한성도서본 5
-이어졌다 4
-문씨에게 4
-버튼으로 4
-보도했다 4
-이해하지 4
-상태에서 4
-하이브리드 4
-기준으로 4
-설명했다 4
-대회에서 4
-내년부터 4
-진행했다 4
-콜레오스 4
-방식이다 4
-골프클럽 4
-조사됐다 4
-허사비스 4
-시가총액 4
-이끌었다 4
-추진하고 4
-유튜버들 4
-국내에서 4
-늘어나고 4
-신에너지 4
-난양이공대 4
-전문가들 4
-알츠하이머병 4
-차지했다 4
-공동주택 4
-사전점검 4
-영광군수 4
-커뮤니티 4
-영등포로터리 4
-디자이너 4
-넷플릭스 4
-내셔널리그 4
-아메리칸리그 4
-타이거스 4
-네트워크 4
-카카오게임즈 4
-배드민턴 4
-알테오젠 4
-무너졌다 4
-비트코인 4
-옥스퍼드대 4
-서울대병원 4
-부산대병원 4
-시범경기 4
-레이커스 4
-무엇보다 3
-마감했다 3
-모건스탠리 3
-이태원동 3
-가능하다 3
-업계에서 3
-터치스크린 3
-제공하고 3
-실리콘밸리 3
-스타트업 3
-플로리다주 3
-기자회견 3
-중요하다고 3
-이스라엘 3
-디스커버리 3
-검토한다 3
-방식으로 3
-입장에서 3
-프로축구 3
-경기에서 3
-벗어나지 3
-르노코리아 3
-설문조사 3
-공개했다 3
-판단했다 3
-홈페이지 3
-활용하고 3
-예상된다 3
-주식시장 3
-한국에서 3
-딥마인드 3
-창업했다 3
-알파폴드 3
-청약통장 3
-시작으로 3
-들어봤다 3
-전해졌다 3
-징검다리 3
-유통업계 3
-전국에서 3
-검토하고 3
-미국에서 3
-그러면서 3
-적용된다 3
-제기했다 3
-수사요구안 3
-정치권에선 3
-원내대표 3
-필요하다고 3
-규정하고 3
-내부에서 3
-아이디어 3
-집행유예 3
-많을수록 3
-직장인들 3
-처음이다 3
-계속해서 3
-순이었다 3
-영광에서 3
-호우특보 3
-늘어났다 3
-명태균씨 3
-공개하지 3
-이어지고 3
-문제였다 3
-손해배상 3
-마이너스 3
-현대건설 3
-샌디에이고 3
-야마모토 3
-요시노부 3
-에르난데스 3
-선발투수 3
-무실점으로 3
-역전승했다 3
-클리블랜드 3
-가디언스 3
-디트로이트 3
-이승만대통령기념관 3
-시정명령 3
-집행정지 3
-블룸버그 3
-시장에서 3
-시장으로 3
-수준이다 3
-최고인민회의 3
-주식투자자금 3
-원점으로 3
-한국시리즈 3
-날카로운 3
-모습으로 3
-한국문학번역원 3
-문학평론 3
-시중은행들 3
-인터넷은행들 3
-그렇다고 3
-라이프치히 3
-오리올스 3
-과학자들 3
-기요사키 3
-것이었다 3
-영어식으로 3
-만리장성 3
-장편소설 3
-확인됐다 3
-중앙서림본 3
-대학생들 3
-스티로폼 3
-방침이다 2
-개발하고 2
-배터리팩 2
-지속적으로 2
-발전시켜 2
-처음으로 2
-조정했다 2
-공시했다 2
-목표주가 2
-유진투자증권 2
-이어지며 2
-생각하지 2
-음주운전 2
-파악됐다 2
-체납으로 2
-차량으로 2
-추가됐다 2
-특징이다 2
-가전제품 2
-제품에서 2
-폴크스바겐 2
-내놓으며 2
-유지하기 2
-일반적으로 2
-도입된다 2
-도로에서 2
-조사에서 2
-정도라고 2
-걱정해야 2
-못지않게 2
-플로리다 2
-힐즈버러 2
-유세에서 2
-주민에게 2
-끈질기게 2
-주도하고 2
-브렌트유 2
-과도하다 2
-분석했다 2
-출시했다 2
-버스중앙차로 2
-지나치게 2
-대상이다 2
-이상으로 2
-떨어졌다 2
-포르투갈 2
-투입했다 2
-뽑아내며 2
-뛰어올랐다 2
-올잉글랜드 2
-팬들에게 2
-드러나기 2
-방향으로 2
-선보였다 2
-구매하고 2
-분석하고 2
-헌법재판관 2
-진행되지 2
-공개됐다 2
-화이트큐브 2
-하루하루 2
-뉴욕에서 2
-전국적으로 2
-시작하던 2
-필요하다 2
-예년보다 2
-많았는데 2
-자본시장 2
-폐지까지 2
-유예한다고 2
-게임에서 2
-태어났다 2
-등극했다 2
-캠브리지 2
-스튜디오 2
-당첨되기 2
-문화체육관광부 2
-성희롱으로 2
-노벨의학상 2
-노벨위원회 2
-결정적인 2
-연구자들 2
-밝혀졌다 2
-조선일보 2
-스마트인컴 2
-공개된다 2
-업로드된다 2
-연금수령 2
-필명으로 2
-기준금리 2
-진입했다 2
-스냅드래곤 2
-채택했다 2
-홍명보호 2
-터뜨렸다 2
-에레디비시 2
-이어지면서 2
-경쟁에서 2
-보이면서 2
-대형마트 2
-북적였다 2
-소비자들 2
-분위기다 2
-본격적으로 2
-접어들자 2
-프랜차이즈 2
-급증했다 2
-주력하고 2
-과세당국 2
-적용하면 2
-해외에서 2
-기획재정부 2
-이용자들 2
-성냥갑들 2
-때문이라 2
-자원순환형 2
-분야에서 2
-순위에서 2
-진행한다 2
-플라스틱 2
-수소전기차 2
-공동으로 2
-투자하고 2
-증시에서 2
-사모펀드 2
-제출했다 2
-결정했다 2
-보도자료 2
-보관하던 2
-규명하자며 2
-특검법안 2
-증언감정법 2
-상설특검으로 2
-수사하게 2
-재발의할 2
-확보하려 2
-처리한다 2
-무력화하 2
-이끌어낼 2
-장담하기 2
-상황에선 2
-노림수로 2
-사법리스크 2
-잡히면서 2
-제기되고 2
-민주당으로선 2
-결집하고 2
-끌어올려야 2
-반복되며 2
-상설특검법 2
-판단하거나 2
-발의했다 2
-법원행정처 2
-대한변협회장 2
-추천하도록 2
-발의했던 2
-명시했던 2
-거론된다 2
-맞닥뜨릴 2
-유지하고 2
-연구개발 2
-사건에서 2
-연구위원 2
-전략으로 2
-강조했다 2
-기반으로 2
-지원하고 2
-치열해지면서 2
-급증하고 2
-엔지니어 2
-구축하고 2
-기록했는데 2
-해당한다 2
-터바인홀 2
-프로젝트 2
-품질관리단 2
-원칙적으로 2
-대행업체 2
-건설사들 2
-못하도록 2
-정상회의 2
-세계에서 2
-국제사회 2
-총선에서 2
-리얼미터 2
-선거에서 2
-영광으로 2
-돌아다니며 2
-사이에선 2
-당선되든 2
-졸업했다 2
-집중호우 2
-폭발적으로 2
-쏟아내기 2
-늘어나지 2
-내다봤다 2
-개혁신당 2
-과시하고 2
-국회의원 2
-보궐선거 2
-입장이다 2
-정치인들 2
-들어갔다 2
-만들어졌다 2
-않았다고 2
-시도하거나 2
-두부김치 2
-교통사고 2
-고가차도 2
-제품으로 2
-인테리어 2
-국내에선 2
-중요하게 2
-여러모로 2
-전화번호 2
-압도적인 2
-명씨에게 2
-차근차근 2
-했습니다 2
-리더라면 2
-법원에서 2
-예정이라고 2
-파라시스 2
-하락했다 2
-한국거래소 2
-중소형주 2
-프로배구 2
-벗어났다 2
-득점으로 2
-바뀌었다 2
-시즌부터 2
-정규리그 2
-페어플레 2
-메이저리그 2
-안방에서 2
-제압했다 2
-순간이었다 2
-뒤집었다 2
-다르빗슈 2
-필라델피아 2
-브루어스 2
-대한민국 2
-엔씨소프트 2
-기록하며 2
-이어갔다 2
-알고리즘 2
-업계에선 2
-지난해엔 2
-논의하기 2
-예고했다 2
-국무위원장 2
-공개적으로 2
-증권투자자금 2
-남편이나 2
-휴대전화 2
-프로야구 2
-수원에서 2
-적시타로 2
-엘리에이저 2
-아시아나항공 2
-가리킨다 2
-집중했다 2
-대한배드민턴협회 2
-천위페이 2
-작품이라 2
-평가했다 2
-수준으로 2
-대중문화 2
-신용대출 2
-주택담보대출 2
-천연가스 2
-분데스리가 2
-민주주의 2
-시작된다 2
-미세전류 2
-챔피언십 2
-월드시리즈 2
-카를로스 2
-에스테베즈 2
-레드삭스 2
-터뜨리며 2
-관점에서 2
-돌아섰다 2
-드라큘라 2
-인터내셔널 2
-맨부커상 2
-메디치상 2
-말레이시아 2
-브루나이 2
-세계적인 2
-투자해야 2
-증가한다 2
-높아지고 2
-친아버지 2
-공부해야 2
-하이트진로 2
-뿌리쳤다 2
-우승하고 2
-드라이브샷 2
-지키느냐 2
-해결했다 2
-잡아당겨 2
-평균자책점 2
-가져왔다 2
-가르시아 2
-샌프란시스코 2
-정규시즌 2
-애틀랜타 2
-독일어정서법위원회 2
-흔들도록 2
-시달렸다 2
-니코바르 2
-유능하게 2
-설치했다 2
-당선되며 2
-이상문학상 2
-광주민주화운동 2
-문장으로 2
-트라우마 2
-비서실장 2
-부산에서 2
-행동강령 2
-성공했다 2
-실망스러운 2
-비교하면 2
-윤전식으로 2
-국가유산으로 2
-북한연구소 2
-탈북민들 2
-총참모부 2
-플랫폼들 2
-쿠팡이츠 2
-아미노산들 2
-아미노산 2
-개발했다 2
-이산화탄소 2
-혁신기업 2
-평균기온 2
-떨어지지 2
-최고기온 2
-집행위원장 2
-확대하고 1
-안심점검 1
-무상보증기간 1
-법인차로 1
-전기차엔 1
-통보되지 1
-등록하면 1
-진입하기 1
-발생하면 1
-투입된다 1
-소방청에선 1
-시작하고 1
-특수구조대 1
-기증한다 1
-수압으로 1
-안심하고 1
-대표주인 1
-신한투자증권 1
-해당되지 1
-소외되고 1
-순매도했지 1
-순매수했다 1
-고대역폭 1
-그레이드 1
-증권사들 1
-집계됐다고 1
-하회했다 1
-현대차증권 1
-발표에서 1
-지켜지지 1
-비메모리 1
-비용으로 1
-연말까지 1
-근접하며 1
-하회하고 1
-동기보다 1
-상승하도록 1
-초입이라 1
-맞추면서 1
-빡빡해질 1
-예상했지 1
-스마트폰용 1
-기대하기 1
-어려워졌다고 1
-추격이다 1
-성공하든 1
-실패하든 1
-엔비디아 1
-통과하면 1
-유의미한 1
-떨어지게 1
-상태였던 1
-모델이다 1
-개인적으로 1
-사용하기 1
-자동차등록원부 1
-소유였던 1
-위반으로 1
-제주에서 1
-양도했다 1
-명의였다 1
-이전됐고 1
-문씨에서 1
-대통령으로 1
-이전됐다 1
-이면도로 1
-돌아왔다 1
-해밀톤호텔 1
-운전하던 1
-변경하다 1
-뒤따라오던 1
-피해자인 1
-혈중알코올농도 1
-수준이었다 1
-카메라용 1
-쓸어주면 1
-추가했는데 1
-사라졌던 1
-돌아오고 1
-되돌아가고 1
-애플마저 1
-가져오고 1
-기사에서 1
-쓸어넘기던 1
-디스플레이 1
-장착했던 1
-등장하고 1
-주목받고 1
-도입하겠다고 1
-차종에서 1
-회귀했다 1
-올리거나 1
-헤리티지 1
-브레이크 1
-추가되고 1
-선보이며 1
-추가했다 1
-저장해둘 1
-스톱워치 1
-단축키로 1
-장점이다 1
-넘기게끔 1
-감지되고 1
-주시해야 1
-화면에서 1
-프로그램인 1
-받으려면 1
-책임자인 1
-에이버리 1
-더타임스와 1
-제조업체 1
-터치스크린으로 1
-산만으로 1
-마찬가지다 1
-가스레인지 1
-동일하게 1
-흘러넘치면 1
-작동하지 1
-트렌드와 1
-맥락이다 1
-태블릿용 1
-진동으로 1
-탑재하고 1
-가르치며 1
-천수답이어서 1
-생소했기 1
-설명하자 1
-그제서야 1
-끄덕였다 1
-지적됐다 1
-못한다고 1
-코미디같 1
-벌어진다 1
-설문에서 1
-대답했다 1
-걱정하기 1
-질문이나 1
-이해할까 1
-지경이라고 1
-파악한다 1
-알아듣고 1
-끄덕인다 1
-한자어이기 1
-이해력인 1
-멀리하고 1
-읽으라고 1
-추상적인 1
-모르고선 1
-언어철학자인 1
-넓어진다 1
-세상으로 1
-사망했다고 1
-저기압으로 1
-약화됐지 1
-밀턴으로 1
-늘었다고 1
-나뭇가지 1
-추정된다 1
-구출했다 1
-탬파에서 1
-구조단체 1
-구조했다 1
-구조대원 1
-떨어졌고 1
-약화됐다 1
-지역에서 1
-대서양으로 1
-빠져나갔다 1
-담화에서 1
-불안하게 1
-무모하고 1
-무책임하며 1
-비판했다 1
-퍼뜨리고 1
-비난하면서 1
-뉴스레터 1
-구독하기 1
-나오면서 1
-급등했다 1
-서부텍사스산 1
-거래일보다 1
-전날보다 1
-보복으로 1
-나서면서 1
-부추겼다 1
-지오바니 1
-스타우노보 1
-애널리스트 1
-정리하면서 1
-산유국인 1
-재규어랜드로버코리아 1
-인제니움 1
-표방한다 1
-리클라인 1
-엇비슷하지 1
-활용한다 1
-수납공간 1
-오프로드 1
-강점이다 1
-서스펜션 1
-편안하게 1
-노면에서 1
-수심까지 1
-안전하게 1
-판매된다 1
-개편한다 1
-준공영제 1
-도입하면서 1
-재설계해 1
-서울시버스운송사업조합 1
-노선체계 1
-발주했다 1
-시행한다 1
-신설됐는데 1
-그대로라 1
-위례신사선 1
-개통하고 1
-연장된다 1
-이곳저곳 1
-통과하다 1
-구불구불한 1
-노선으로 1
-고덕강일지구 1
-그었는데 1
-편리해질 1
-예상한다 1
-서울시내 1
-바뀌었는데 1
-반영하지 1
-백지에서 1
-효율적이라고 1
-강등됐다 1
-영입한다 1
-코린치안스 1
-출신으로 1
-리그에서 1
-끌어올렸다 1
-구단이라 1
-넉넉하지 1
-스트라이커 1
-득점포로 1
-코리아컵 1
-올려놓았다 1
-대구에서 1
-레전드다 1
-어렵겠지 1
-세징야에겐 1
-튀르키예 1
-전전하다 1
-활약하고 1
-여전하다 1
-무대였다 1
-허용하면서 1
-절체절명 1
-활용해보겠다 1
-파고들어 1
-정확하고 1
-재역전승 1
-만끽했다 1
-자동으로 1
-떨어지고 1
-어떻게든 1
-매진시키며 1
-우리에게 1
-행복하게 1
-결승전이란 1
-대회부터 1
-시행한다고 1
-카메라로 1
-입체적으로 1
-재구성하 1
-중반부터 1
-익숙하다 1
-극대화할 1
-대회까지 1
-고수했다 1
-제한적으로 1
-활용했다 1
-예선부터 1
-결승까지 1
-대신한다 1
-심판협회 1
-언제까지 1
-사용됐다 1
-오픈에선 1
-운영했다 1
-코트에서 1
-설치되어 1
-참고용으로 1
-진화하고 1
-리그에선 1
-시스템으로 1
-오프사이드 1
-잡아내고 1
-리플레이 1
-홈런이나 1
-판독에서 1
-올해부터 1
-서비스센터 1
-개최하고 1
-만족했다고 1
-응답했고 1
-응답했다 1
-차량에서 1
-만족스러운 1
-기능으로 1
-조성하기 1
-탑재했다 1
-감지하고 1
-뛰어나고 1
-우수하다 1
-수준이고 1
-구간에서 1
-고속도로나 1
-주말마다 1
-방송통신위원장 1
-마무리하면서 1
-헌재소장 1
-선출해야 1
-대립으로 1
-출석해야 1
-열겠다고 1
-밝히면서 1
-헌법재판소법 1
-알아봐야 1
-선출하지 1
-검토해보라 1
-출신이다 1
-작업했던 1
-마르세유 1
-흩뿌리고 1
-작품이다 1
-시리즈로 1
-생각으로 1
-전시된다 1
-발행된다 1
-오브리스트 1
-서펜타인 1
-예술감독 1
-진행했던 1
-차별이라고 1
-제한하지 1
-권고했다 1
-아내에게 1
-사주려고 1
-남성에게 1
-거부했다고 1
-제기했고 1
-가입자격 1
-명시하고 1
-탓이라고 1
-제한한다 1
-여성용으로 1
-부족하다고 1
-설립됐던 1
-다수였음 1
-고려하더라 1
-추진되면 1
-가정주부 1
-따가움으로 1
-알레르기성 1
-주범으로 1
-분포하며 1
-주변이나 1
-산책하면 1
-심해진다 1
-시즌마다 1
-환자이기 1
-긴장하며 1
-시달리고 1
-하순까지 1
-삼성서울병원 1
-알레르기내 1
-건조해지고 1
-선선해지기 1
-건강보험심사평가원 1
-추산된다 1
-아열대성 1
-변하면서 1
-선선하고 1
-과민반응으로 1
-재채기나 1
-건조해지기 1
-지속되면서 1
-요인으로 1
-작용하면서 1
-많아졌다 1
-예상되니 1
-질병관리청 1
-질병이나 1
-쯔쯔가무시병 1
-길어지므로 1
-가을까지 1
-풀밭이나 1
-서식한다 1
-생겼으면 1
-고려대구로병원 1
-쌀쌀해지면서 1
-밀접해지면서 1
-유행하는데 1
-유행하던 1
-만성질환자들 1
-금융투자소득세 1
-유예해야 1
-것이었지 1
-고려하다 1
-유예까지 1
-시행하자 1
-유예하고 1
-선진지수 1
-편입이라든지 1
-판단하자 1
-유예하자 1
-정상화될 1
-않는다면 1
-마찬가지라 1
-일각에서 1
-갑론을박하기보다 1
-폐지하자 1
-유예한다면 1
-집권해서 1
-시행해야 1
-예정이던 1
-미뤄졌고 1
-의총에서 1
-위임하기로 1
-기울어졌는데 1
-무너뜨릴 1
-신중해야 1
-부담스럽다 1
-키프로스계 1
-그리스인 1
-싱가포르인 1
-시작했고 1
-보드게임 1
-뛰어나다 1
-상금으로 1
-구입하고 1
-프로그래밍 1
-대학에서 1
-컴퓨터과학 1
-유니버시티 1
-신경과학 1
-박사학위 1
-기억상실증 1
-사이언스지 1
-두뇌게임 1
-올림피아드인 1
-올림피아드 1
-런던에서 1
-무스타파 1
-슐레이만 1
-스타트업인 1
-인수했지 1
-위험처럼 1
-무주택으로 1
-강남권에선 1
-커트라인 1
-가점보다 1
-리얼투데이 1
-만점이며 1
-부양가족 1
-산정된다 1
-웃돌았다 1
-공공기관 1
-기관장에게 1
-근절되지 1
-한국도박문제예방치유원 1
-드러났다 1
-문화체육관광위원회 1
-해임됐다 1
-명목으로 1
-법률자문 1
-국감에서 1
-카지노공기업인 1
-지적되었음 1
-가능하더라 1
-해임되었는데 1
-세포에서 1
-돌아갔다 1
-이뤄졌다 1
-카롤린스카 1
-생리의학상 1
-수상자로 1
-생물학자인 1
-매사추세츠 1
-비밀열쇠로 1
-발견하여 1
-기여했기 1
-수상하게 1
-수상자인 1
-앰브로스 1
-미생물인 1
-예쁜꼬마선충 1
-발견했다 1
-비정상적으로 1
-작동되면 1
-자가면역 1
-발견으로 1
-이해하게 1
-조절이라 1
-물리학상 1
-발표되며 1
-경제학상으로 1
-전업투자자다 1
-강연에선 1
-불합리한 1
-주식으로 1
-무엇인지 1
-들어보자 1
-화요일인 1
-퇴직급여 1
-연금으로 1
-소득세법상 1
-연금수령액 1
-퇴직소득세율 1
-과세하고 1
-넘어가면서부터 1
-과세한다 1
-유리한지 1
-미래에셋 1
-투자와연금센터 1
-부와지식의배움터 1
-영상이다 1
-내다보면서 1
-움직여서 1
-프리즘투자자문 1
-단행하면서 1
-본격적인 1
-자산시장 1
-유망할지 1
-국민연금공단 1
-기금운용본부 1
-투자운용팀장 1
-국민은행 1
-수석이코노미스트 1
-전문가다 1
-신공정으로 1
-모바일용 1
-출시되고 1
-아이폰용 1
-끌어올린 1
-탑재되면서 1
-저전력으로 1
-중요해졌다 1
-강조하고 1
-전력효율 1
-향상됐다 1
-약점으로 1
-거론되던 1
-강화됐다 1
-전망된다 1
-제외하고 1
-안드로이드 1
-스마트폰들 1
-향상되면서 1
-떨어지면 1
-고객사들 1
-들어가면서 1
-좋아지고 1
-고부가가치 1
-집중하면서 1
-월가에서 1
-동갑내기로 1
-국가대표팀 1
-네덜란드 1
-세르비아 1
-즈베즈다 1
-페예노르트로 1
-챔피언스리그 1
-미드필더로 1
-맹활약하며 1
-분데스리 1
-프랑크푸르트 1
-측면에서 1
-오른발로 1
-마무리해 1
-주니오르 1
-허용하며 1
-미드필더 1
-부상으로 1
-빠지면서 1
-소속팀으로 1
-파트너로 1
-수비수로 1
-이뤘는데 1
-대표팀엔 1
-포함됐다 1
-입성했다 1
-중동에서 1
-요르단으로 1
-합류한다 1
-개천절인 1
-만석이었다 1
-손님으로 1
-근처에서 1
-쇼핑객들로 1
-신선식품 1
-계산대마다 1
-기다렸다 1
-비수기로 1
-공휴일로 1
-지정되고 1
-대형마트마다 1
-북적였고 1
-여행지로 1
-매진됐다 1
-살아나지 1
-진작하기 1
-임시공휴일 1
-소비시장 1
-회복될지 1
-유통업체에선 1
-품목에서 1
-떨어지면서 1
-장만하려 1
-롯데백화점 1
-해외여행 1
-덕분이라 1
-신세계백화점 1
-늘어났다고 1
-즉석식품 1
-돼지고기 1
-여행객들로 1
-신제주점 1
-서귀포점 1
-기간보다 1
-불꽃축제 1
-몰리면서 1
-서울세계불꽃축제 1
-한강공원 1
-놀이공원 1
-할인행사 1
-매장에서 1
-진행하고 1
-롯데마트 1
-샤인머스캣 1
-저렴하게 1
-먹거리들 1
-개최한다 1
-내야하니 1
-유튜브로 1
-발라냈다 1
-제출하지 1
-경고까지 1
-보냈다고 1
-시청자들 1
-수익에서 1
-기여분으로 1
-금액만큼 1
-입장에선 1
-세무당국 1
-법인이라 1
-징수하기 1
-실질적으로 1
-한국재무관리학회 1
-동등하게 1
-과세해야 1
-국세까지 1
-논의조차 1
-원천징수하기 1
-개정하면서 1
-정책협력 1
-발생하고 1
-납부하게 1
-조세조약 1
-따져보겠다 1
-입장이었다 1
-들어줬다 1
-시청자들로 1
-가능해야 1
-구글코리아 1
-도널드슨 1
-전무하다 1
-소득세법 1
-규정하지 1
-규정하더라 1
-원천소득 1
-이행하지 1
-가속화되면서 1
-국세청으로 1
-종합소득세 1
-벌어들인 1
-시청시간 1
-다방이나 1
-가져가게 1
-가져오다 1
-빼곡하게 1
-습관이었다 1
-취미생활이라고 1
-유행이었어요 1
-수집했다 1
-자연스럽게 1
-됐습니다 1
-이후라고 1
-기억했다 1
-다채로운 1
-떠들썩하게 1
-마시거나 1
-고형필씨 1
-사용하거나 1
-사람에게 1
-싸움까지 1
-회고했다 1
-식당이나 1
-열광하고 1
-아날로그 1
-현대자동차그룹 1
-난양대학 1
-정부까지 1
-비즈니스 1
-체결했다고 1
-공과대학 1
-연구중심 1
-대학이다 1
-발전사업 1
-쓰레기나 1
-모빌리티 1
-밸류체인 1
-설립하기로 1
-통상산업부 1
-조직으로 1
-연구기관 1
-로보틱스 1
-혁신제조 1
-국가연구펀드 1
-다가왔지 1
-불투명해 1
-최하위권 1
-금투세란 1
-금융상품 1
-양도소득 1
-도입되면 1
-어려워질 1
-빠져나가면서 1
-한국기업거버넌스포럼 1
-결과적으로 1
-빠져나갈 1
-관측하고 1
-부자감세 1
-투자자들에게 1
-희망이자 1
-금투세로 1
-금융소득종합과세로 1
-시행으로 1
-낮아진다 1
-법제사법위원회 1
-증인으로 1
-아트센터 1
-동아시아문화센터 1
-요구하기로 1
-법사위원장인 1
-소재파악 1
-동행명령장 1
-방안까지 1
-아내이자 1
-떠올랐다 1
-약속어음 1
-제시하며 1
-인정된다면서 1
-부인하고 1
-국면으로 1
-양상이다 1
-농협중앙회 1
-적발되자 1
-주식거래 1
-적발됐지 1
-이뤄지지 1
-살펴보고 1
-발의하자 1
-처리했지 1
-가로막혔다 1
-끌어올리려 1
-공직선거법 1
-다가오면서 1
-압박하기 1
-재발의했던 1
-것들이다 1
-의결하면 1
-제정하지 1
-소속되거나 1
-소속됐던 1
-국회규칙 1
-구성해야 1
-입맛대로 1
-임명하기 1
-바꾸겠다 1
-느슨해진 1
-결과라고 1
-내부적으로 1
-심화했다 1
-공공연히 1
-자랑이었지 1
-옅어지고 1
-유일하게 1
-우선순위고 1
-제조업인 1
-청년에게 1
-학과까지 1
-전자공학 1
-전공자들 1
-학교에서 1
-동결되면서 1
-따라잡겠다고 1
-존재하지 1
-최상층부 1
-장악하고 1
-투자보다 1
-바뀌었고 1
-임직원들 1
-해결할지 1
-쏟았는데 1
-잘못인지 1
-색출해내서 1
-이기주의 1
-털어놨다 1
-삼성그룹 1
-출석하고 1
-요구했고 1
-부랴부랴 1
-시스템반도체 1
-경쟁사인 1
-자극했고 1
-느슨해졌다 1
-과도하게 1
-무임승차하며 1
-경쟁자인 1
-시가총액으로 1
-넘어섰다 1
-창업자인 1
-친분으로 1
-파운드리 1
-전념했다 1
-빼닮았다 1
-재계에서 1
-정신으로 1
-추월하고 1
-중요하다 1
-한림대학교 1
-동탄성심병원 1
-대표적인 1
-평가하며 1
-인지기능과 1
-인지기능 1
-맥락정보 1
-혈관질환 1
-신체활동 1
-집단보다 1
-유전자인 1
-벌어졌다 1
-한림대동탄성심병원 1
-정신건강의학 1
-촉진하고 1
-긍정적인 1
-에디스코완대학 1
-사람보다 1
-아밀로이드 1
-목적에서 1
-응답자들 1
-연령별로 1
-다가올수록 1
-기혼자들보다 1
-대비해야 1
-결과에선 1
-직장인보다 1
-저축하고 1
-체계적인 1
-미친다고 1
-단기적인 1
-집중해서 1
-지적이다 1
-납입한다 1
-세액공제 1
-중국으로 1
-확보하기 1
-중요합니다 1
-기술적인 1
-오픈소스 1
-본사에서 1
-부사장으로 1
-근무했고 1
-마이크로소프트 1
-과학자로 1
-선도하고 1
-오픈소스로 1
-혁신적인 1
-프랑스뿐 1
-출신들로 1
-전쟁이다 1
-충분하지 1
-조직에게 1
-치열해지고 1
-효율적인 1
-아키텍처 1
-자원으로 1
-개발해야 1
-제공자들과 1
-파트너십 1
-유럽으로 1
-확대해나간다 1
-경제에서 1
-매출에서 1
-차지한다 1
-수출에서 1
-두드러진다 1
-반도체고 1
-생활가전 1
-살림살이 1
-좌우한다 1
-납부했다 1
-유지해왔다 1
-공중에서 1
-나풀거리고 1
-회전한다 1
-공간으로 1
-재탄생했다 1
-현대미술관 1
-작가이자 1
-부르주아 1
-올라푸르 1
-엘리아손 1
-아이웨이웨 1
-내로라하 1
-현대미술 1
-거쳐갔다 1
-현대자동차 1
-지원으로 1
-느껴지지 1
-않았느냐 1
-질문인데 1
-무서워하기 1
-아름다움 1
-공존한다 1
-바꾸면서 1
-떨어뜨린다 1
-발전소였던 1
-주목했다 1
-사용하지 1
-재가동했고 1
-현장으로 1
-탈바꿈시켰다 1
-재가동해 1
-장소였다 1
-소개했다 1
-끌어왔다 1
-조각으로 1
-매달리게 1
-추가되면서 1
-들어가기 1
-횡포라고 1
-상처에서 1
-무기력함 1
-아름답다 1
-감동한다 1
-움직인다 1
-역경이나 1
-아름답다고 1
-들어서며 1
-공간이다 1
-큐레이터 1
-부드러움 1
-감각적으로 1
-경험하게 1
-예방하기 1
-손질해야 1
-국토교통부 1
-하자심사분쟁조정위원회 1
-육박했다 1
-증가하면서 1
-처리해야 1
-실시해야 1
-전문가로 1
-품질점검단 1
-점검해야 1
-작년부터 1
-입주예정자 1
-동행하지 1
-금지했다 1
-사전방문 1
-명시하기로 1
-담합한다 1
-제기됐다 1
-진행하면 1
-입주예정자들 1
-실시하는데 1
-변경했다 1
-사전방문일 1
-주간별로 1
-균등하게 1
-전산시스템 1
-일방적으로 1
-개선해야 1
-지자체마다 1
-천차만별이었다 1
-강화해야 1
-방문하고 1
-참석하기 1
-출국했다 1
-방문국인 1
-참배하고 1
-맺어왔으며 1
-최초이자 1
-자유대한민국 1
-순방에서 1
-용인하지 1
-겨냥하기 1
-모니터링 1
-형성되어 1
-양상으로 1
-흘러가고 1
-예상됐지 1
-여론조사상 1
-나타나면서 1
-정가에서 1
-예측하기 1
-결정으로 1
-통합진보당 1
-후신으로 1
-위성정당 1
-확보했다 1
-네거티브 1
-야권에선 1
-진보당식 1
-남도일보 1
-지지한다 1
-조사에선 1
-일주일여 1
-네거티브전 1
-벌이면서 1
-난타전식 1
-진보당으로 1
-학도호국단 1
-총학생장 1
-공격했고 1
-집회시위법 1
-부각되지 1
-밀어내고 1
-당선됐던 1
-자극했는데 1
-조직에서 1
-당선무효형 1
-확정되자 1
-읍내에선 1
-들어주고 1
-부축해주 1
-영광군농민회 1
-시절부터 1
-집집마다 1
-논밭까지 1
-요구하며 1
-포퓰리즘 1
-경쟁으로 1
-영광사랑지원금 1
-공약으로 1
-판박이로 1
-영광군노인복지관 1
-얻어놓고 1
-내려갔고 1
-상임대표 1
-최연소다 1
-주주총회 1
-대표이사 1
-선임했다 1
-제품총괄로 1
-카네기멜론대 1
-엔비욘드 1
-이베이코리아 1
-비금융권 1
-회사에서 1
-해외주식 1
-추진하며 1
-플랫폼으로서 1
-대표이사로 1
-물러났다 1
-기간으로 1
-한정하면 1
-가속화할수록 1
-시간동안 1
-비구름대 1
-수증기량 1
-토해내듯 1
-속출했다 1
-장마기간 1
-많아지며 1
-편성하고 1
-운영하고 1
-복기조로 1
-운영된다 1
-이어지겠다 1
-쌀쌀하고 1
-보이겠다 1
-이어지겠다고 1
-경남지역 1
-브로커로 1
-술렁이고 1
-비대위원장 1
-얘기하면서 1
-정치자금법 1
-창원지검 1
-구속하면 1
-하야하고 1
-감당되겠느냐고 1
-검사에게 1
-응하면서 1
-곤혹스러워하 1
-정치브로커 1
-국민께서 1
-한심하게 1
-생각하실 1
-수사기관 1
-엄정하고 1
-신속하게 1
-수사해야 1
-자택으로 1
-조언으로 1
-텔레그램 1
-나타나고 1
-부적절한 1
-부정확한 1
-설명했는데 1
-당사자들 1
-반박했다 1
-만나자고 1
-페이스북 1
-빅테크들 1
-파악해야 1
-유럽연합 1
-대항하기 1
-공개하도록 1
-회계연도 1
-종료되면 1
-공개해야 1
-당국끼리 1
-공유하던 1
-국제협약인 1
-이행하기 1
-국가별보고서 1
-도입했고 1
-정보교환 1
-교환하고 1
-빅테크로 1
-가공해서 1
-국가에서 1
-유럽처럼 1
-추진되고 1
-공개하기 1
-선행돼야 1
-새벽까지 1
-서울경찰청 1
-현장에서 1
-없었으며 1
-거부하지 1
-부축하려 1
-포착되기 1
-들여다보고 1
-불가능한 1
-이전까지 1
-차선에서 1
-난폭운전 1
-접수하고 1
-주문했다 1
-꾸벅꾸벅 1
-철거된다 1
-개통했다 1
-교차로로 1
-도로교통공단 1
-불가피할 1
-철거하고 1
-조성한다 1
-연결한다 1
-여의도나 1
-도심으로 1
-출퇴근하 1
-반도체뿐 1
-밀어내기 1
-지지부진한 1
-선두주자 1
-실적으로 1
-감소했는데 1
-영업이익으로 1
-제외하면 1
-적자였다 1
-영향이다 1
-수조원대 1
-체결했다 1
-따내면서 1
-포트폴리오 1
-다변화로 1
-편광필름 1
-양도했고 1
-구조조정 1
-최대치였지 1
-해운에서 1
-영향으로 1
-분석됐다 1
-길어지고 1
-비상경영 1
-돌입했지 1
-미나토구 1
-펜트하우스이자 1
-일본에서 1
-레지던스라 1
-들어섰는데 1
-화룡점정 1
-디자인했다 1
-스카닝으로 1
-가구업체 1
-꾸몄다고 1
-옷장까지 1
-디자이너들 1
-고심해서 1
-소개하고 1
-갤러리스트 1
-컨설턴트 1
-건축가나 1
-이어진다 1
-갤러리에선 1
-건설업체 1
-투자회사들 1
-현상까지 1
-벌어지고 1
-조연이었다 1
-먼저였고 1
-이때부터 1
-시작됐다 1
-간결하면서 1
-초부터다 1
-대중적으로 1
-디자이너로 1
-앤트워프 1
-관능적으로 1
-윈트레베르트 1
-유기적인 1
-각광받고 1
-어퍼하우스 1
-르페이드 1
-나인원한남 1
-실내가구 1
-배치하느냐 1
-달라진다 1
-조사기관 1
-할리팩스 1
-걸렸는데 1
-구조만큼 1
-뉴욕이나 1
-레지던스일수록 1
-지역민보단 1
-외국인들 1
-귀띔했다 1
-아자다부힐스 1
-중동이나 1
-유럽에서 1
-외국인들이고 1
-고객이라 1
-핏플레이스 1
-만족시킬 1
-차별적인 1
-필요하고 1
-디자인한 1
-설명이다 1
-퍼니처로 1
-레지던스라고 1
-하이엔드 1
-오피스텔 1
-자금난으로 1
-가라앉지 1
-여전하기 1
-신중하게 1
-따져보고 1
-유출됐다 1
-착수한다고 1
-인터뷰하면서 1
-비상대책위원장 1
-실시했다면서 1
-책임당원 1
-미래한국연구소 1
-비교했고 1
-가상대결 1
-실시했으며 1
-사무총장 1
-최고위원 1
-회의에서 1
-만들어서 1
-적법하게 1
-흘러갔는지 1
-넘어갔는지 1
-조사하겠다 1
-안심번호 1
-캠프에서 1
-관련해서 1
-뚜렷해야 1
-시간가량 1
-결론입니다 1
-서바이벌 1
-미션에서 1
-승리하며 1
-끌었습니다 1
-해산물로 1
-싹쓸이하며 1
-셰프에게 1
-물었더니 1
-방송에서 1
-익숙하면서 1
-만들어야겠다 1
-주저하지 1
-뚜렷했기 1
-리더에게 1
-다양합니다 1
-들었습니다 1
-아름다웠다면 1
-중요하지 1
-좋습니다 1
-염두해둔 1
-안타깝지 1
-아닐까요 1
-도로교통법상 1
-특정범죄가중처벌법상 1
-혐의까지 1
-처하도록 1
-용산경찰서 1
-운전하다 1
-판단되면 1
-가능하다고 1
-식당에서 1
-지속됐기 1
-적용되면 1
-붉어지고 1
-비틀거리며 1
-주행하다 1
-오토바이 1
-발생했다 1
-위험치사상 1
-기소했고 1
-선고했다 1
-제조사인 1
-메르세데스벤츠 1
-수입사인 1
-메르세데스벤츠코리아 1
-한성자동차 1
-벤츠파이낸셜서비스코리아 1
-서울중앙지법 1
-제출했다고 1
-공정거래위원회 1
-포함되지 1
-소유자들 1
-주장하고 1
-탑재됐지 1
-크리스토프 1
-스타진스키 1
-장착된다 1
-알았다면 1
-부담해야 1
-손해보다 1
-배상하게 1
-소외됐다 1
-단적으로 1
-엠에프엠코리아 1
-현대사료 1
-퀄리타스반도체 1
-큐라티스 1
-삼성증권 1
-반도체주 1
-이어가고 1
-통영체육관 1
-맞붙었다 1
-후위에서 1
-스파이크 1
-바깥으로 1
-벤치에서 1
-뛰어올랐던 1
-나갔다고 1
-운용하고 1
-인정하고 1
-선수에게 1
-주어진다 1
-불필요한 1
-경기에선 1
-여자부에선 1
-조별리그 1
-페퍼저축은행 1
-주인공이었다 1
-네이션스 1
-도입했다 1
-지급한다 1
-보상으로 1
-페어플레이 1
-유도하자 1
-페어플레이상 1
-반영한다 1
-반영하기로 1
-금전적으로 1
-유인으로 1
-작용하지 1
-홍보팀장 1
-정착하고 1
-홈런포로 1
-앞장섰다 1
-기회에서 1
-내야플라 1
-가라앉았던 1
-받아쳤다 1
-홈런이었다 1
-에이드리언 1
-부러졌지 1
-테오스카 1
-미쳤으나 1
-불펜투수 1
-집어삼킬 1
-끌려가다 1
-무안타로 1
-침묵했다 1
-기부하기로 1
-약정했다고 1
-출생으로 1
-졸업하고 1
-미국으로 1
-상장시켰다 1
-한미상호방위조약 1
-대국으로 1
-마련했기 1
-참여했고 1
-조성됐다고 1
-출시했다고 1
-오세아니아 1
-실시했다 1
-야심작으로 1
-출시됐다 1
-크래프톤 1
-진행한다고 1
-기부그라운드 1
-배틀그라운드 1
-즐기면서 1
-공정거래위원회로 1
-서울고법 1
-공정거래법 1
-부과했다 1
-조작으로 1
-부당하게 1
-소송했고 1
-회복하기 1
-기각했다 1
-성격이기 1
-정당한지 1
-최종적인 1
-재판에서 1
-내려진다 1
-유력하다 1
-기업공개 1
-마무리될 1
-인도증권거래위원회 1
-예상대로 1
-상장하면 1
-판매했다 1
-장부가액 1
-삭제하면서 1
-최고인민회 1
-시정연설 1
-지시했었다 1
-파기하고 1
-개성공단 1
-남북기본합의서 1
-잠정적으로 1
-깎아내렸는데 1
-제기된다 1
-해상국경선 1
-수역화하려 1
-관측한다 1
-구체적으로 1
-명시하기보다 1
-모호하게 1
-종료됐다 1
-노동신문 1
-한국은행 1
-불확실성 1
-지정학적 1
-미쳤다고 1
-확대되고 1
-중장기채권 1
-계속되면서 1
-순유입됐다 1
-순유출로 1
-선고받았다 1
-인천지법 1
-정보통신망 1
-이용촉진 1
-정보보호 1
-선고했다고 1
-사회봉사 1
-명령했다 1
-남자친구 1
-알려주고 1
-넘겨졌다 1
-제안으로 1
-모집하면 1
-관리했다 1
-애플리케이션 1
-개인정보 1
-입력하면 1
-들렀는지 1
-친구에게 1
-고소하기 1
-봉인했다 1
-연발하며 1
-자멸했다 1
-설욕하면서 1
-수비에서 1
-돋보였다 1
-호수비로 1
-같아졌다 1
-뼈아팠다 1
-이어가지 1
-활발하게 1
-공격했다 1
-더블스틸로 1
-성공하면서 1
-실패하면서 1
-상황이면 1
-역전했다 1
-희생번트 1
-때렸는데 1
-서두르던 1
-빠뜨렸다 1
-밟으면서 1
-기울어지 1
-마무리투수 1
-내야안타 1
-몰렸으나 1
-뛰어오르면서 1
-대등했다 1
-성공했고 1
-희생플라이로 1
-수비에선 1
-부진했다 1
-구원투수로 1
-인상한다 1
-부담하지 1
-비행시간 1
-인상된다 1
-오르면서 1
-대한항공 1
-제주항공 1
-노선별로 1
-인상했다 1
-파리올림픽 1
-금메달리스트 1
-복귀했다 1
-쏟아지자 1
-밀양에서 1
-전국체육대회 1
-예선에서 1
-부산광역시 1
-인천광역시 1
-모습이었다 1
-많았다고 1
-진정시킨 1
-사랑해준 1
-감사하다 1
-불참하고 1
-연호하며 1
-유도하며 1
-마무리했다 1
-관중에게 1
-화답했다 1
-감사했고 1
-그들에게 1
-조금이라 1
-표현하고 1
-기다렸던 1
-만족스럽다 1
-병행하며 1
-좋아졌다 1
-국제대회 1
-보여드리겠다 1
-이뤘으니 1
-비판하면서 1
-이뤄지고 1
-이뤄지는지 1
-않았었고 1
-발표하고 1
-출전하지 1
-본모습으로 1
-않을까라고 1
-생각하고 1
-덧붙였다 1
-머물렀던 1
-늦게나마 1
-아쉬웠는데 1
-지역적인 1
-못했다면 1
-인식하게 1
-음식까지 1
-한국으로 1
-쏠리면서 1
-주목하지 1
-소설인데 1
-섬세하고 1
-겪어왔던 1
-정치적인 1
-형상화해온 1
-근원적인 1
-작가로서 1
-억눌려왔던 1
-번역가들 1
-차원에서 1
-제안했다 1
-보내느냐 1
-이야기해왔지 1
-한몫했다 1
-축적돼온 1
-양성하고 1
-국제무대 1
-세계문학 1
-되어가고 1
-재평가되며 1
-역사까지 1
-심층적으로 1
-들여다보며 1
-나라인지 1
-인터넷전문은행보다 1
-취급하고 1
-전업주부 1
-분류된다 1
-포용하지 1
-못한다면 1
-거둬들이고 1
-포용하자 1
-내세우지 1
-인터넷은행에 1
-떠넘겨버린다 1
-인터넷전문은행 1
-자산으로 1
-인터넷은행보다 1
-가계대출 1
-인터넷은행에선 1
-인터넷은행으로 1
-달려가고 1
-팍팍해진 1
-중저신용자들 1
-중저신용대출 1
-취약계층 1
-밀려나고 1
-방증이다 1
-넘어서기 1
-금융당국 1
-관리하고 1
-인터넷은행만 1
-이자이익 1
-당기순이익 1
-고민해야 1
-마무리할 1
-산업통상자원부 1
-개발권인 1
-부여하고 1
-특별수당 1
-해저광물자원 1
-입법예고한다고 1
-개정하기로 1
-생산하면 1
-조광료로 1
-거두도록 1
-제도에서 1
-늘어날수록 1
-급증하면 1
-신설된다 1
-공식적으로 1
-광구에서 1
-발견하면 1
-계약에서 1
-넘어서면 1
-관리자로 1
-복귀한다 1
-관장하고 1
-전략적인 1
-잉글랜드 1
-감독으로 1
-물러나자 1
-관장하며 1
-바이에른 1
-소유하고 1
-바뀌겠지 1
-유망주들 1
-발전시키고 1
-반발하고 1
-쏟아부어 1
-팬들에겐 1
-분데스리가에선 1
-말이라고 1
-강조하며 1
-몸담았던 1
-도르트문트 1
-비아냥거리고 1
-끌어내려야 1
-강화군수 1
-심각하다면 1
-도중에라 1
-끌어내리 1
-민주주의고 1
-발언이었지 1
-만들겠다고 1
-권좌에서 1
-구성하고 1
-의원회관 1
-정면으로 1
-반헌법적 1
-반발했다 1
-아니었다고 1
-피감기관 1
-예고하고 1
-키즈카페 1
-생각나서 1
-찾아보니 1
-씁쓸했다 1
-어르신들 1
-경로당엔 1
-장기요양인정등급 1
-어린이집 1
-감소했다 1
-서울시교육청 1
-실버타운 1
-실버타운으로 1
-셔틀버스로 1
-데려오고 1
-데려다준다 1
-비슷하다 1
-유치원장 1
-노치원으로 1
-편이라고 1
-시도하고 1
-학습지로 1
-배달하고 1
-단지에서 1
-기부채납 1
-제시하자 1
-소유주들 1
-접어들어 1
-분명하다 1
-선진국에선 1
-시설이기 1
-늘려가야 1
-최근부터 1
-내려가면서 1
-심해지면 1
-멜라토닌 1
-빌려보고 1
-사용해봤다 1
-의료기기 1
-삼성메디슨 1
-연구소장 1
-제품이다 1
-슬리피솔 1
-거슬리지 1
-이곳으로 1
-전달한다 1
-스트레스 1
-비약물적 1
-치료법이라고 1
-서울대학교 1
-분당병원 1
-사용했더니 1
-호전됐다고 1
-답했다고 1
-설치하고 1
-블루투스로 1
-기본이다 1
-스마트기기 1
-분석해주고 1
-적응하기 1
-어려웠으나 1
-시티필드 1
-지목됐던 1
-프란시스코 1
-투입하자 1
-직선으로 1
-넘겨버렸다 1
-올라가야 1
-감격했다 1
-뛰어들었다 1
-볼티모어 1
-팀이었다 1
-막판까지 1
-기사회생한 1
-모양새다 1
-극적으로 1
-확정하더니 1
-원정경기로 1
-가려진다 1
-달아났다 1
-홈런으로 1
-유린했다 1
-예고됐다 1
-외나무다리 1
-승부에서 1
-등판한다 1
-가까스로 1
-가라앉아 1
-고민이다 1
-신약개발업체인 1
-알테오젠인 1
-거래되던 1
-면역항암제 1
-적용됐다 1
-작용했다 1
-견제하기 1
-통과되면 1
-메리츠증권 1
-아모레퍼시픽 1
-한화에어로스페이스 1
-신한지주 1
-삼성생명 1
-대장주인 1
-순매도하면서 1
-순매도로 1
-하락하면서 1
-국제회의 1
-열었어요 1
-회의에선 1
-기후변화로 1
-악마화되고 1
-포유동물 1
-종류뿐이고 1
-부풀려졌다 1
-아담해요 1
-아르헨티나 1
-분포하죠 1
-중남미로 1
-발견하고 1
-떠올려서 1
-펄럭이며 1
-내려앉아 1
-모습이요 1
-야행성이긴 1
-다르답니다 1
-저공비행 1
-점프하거나 1
-올라가요 1
-박쥐보다 1
-능숙하게 1
-흘러나오 1
-혓바닥으로 1
-오목하게 1
-들이마시기 1
-구조예요 1
-무게까지 1
-있답니다 1
-빵빵해진 1
-널브러져 1
-발견되죠 1
-만들어내 1
-자기에게 1
-일어났는지 1
-잔답니다 1
-물렸다고 1
-이리저리 1
-다니면서 1
-만들어요 1
-구성원들끼리 1
-게워주기 1
-있거든요 1
-먹인답니다 1
-영토에서 1
-가와바타 1
-배출했고 1
-배출했다 1
-등장했지 1
-국제사회에서 1
-중론이었다 1
-수상하면서 1
-성장하고 1
-글로벌하게 1
-공동수상한 1
-문학은커녕 1
-선진국인 1
-통화에서 1
-확실하다 1
-페인트칠 1
-탐미주의자 1
-효과적으로 1
-이탈리아 1
-말라파르테 1
-한국문학 1
-접근하고 1
-작동하고 1
-간과돼서 1
-진출한다 1
-이에따라 1
-늘어나게 1
-맺었다고 1
-베이커리 1
-거점으로 1
-증가하고 1
-계약으로 1
-동남아시아에서 1
-진출하게 1
-발언으로 1
-일으킨다 1
-집중시키기 1
-송도에서 1
-시장이다 1
-파산이다 1
-실버게이트은행 1
-위기에서 1
-인수되며 1
-크레디트스위스 1
-위험하기 1
-생각한다 1
-고민하자 1
-하락한다 1
-지날수록 1
-올라간다 1
-메트갈프 1
-법칙이란 1
-메트칼프 1
-직선적으로 1
-기하급수적으로 1
-법칙이다 1
-가속화될수록 1
-화폐보다 1
-암호화폐 1
-트럼프나 1
-스탠퍼드대 1
-노스웨스턴 1
-안정적인 1
-윤택해진다고 1
-시작하면서 1
-일반적인 1
-봉급으로 1
-아버지인 1
-월급으로 1
-투자하기 1
-키워나간 1
-붕괴였다 1
-이전보다 1
-두려워할 1
-나타난다 1
-부족하다 1
-레이스란 1
-벗어나려면 1
-사회에서 1
-기다리던 1
-페어웨이 1
-작성했다 1
-블루헤런 1
-잡아내며 1
-허용했으나 1
-한국여자오픈 1
-문턱까지 1
-모르겠다 1
-우승하고자 1
-임하는데 1
-싶었는데 1
-지나가길 1
-장타자이면서 1
-아이언샷 1
-빼어나다 1
-좋아하지 1
-어려워서 1
-조건이니 1
-있었다고 1
-초반부터 1
-훈련해오면서 1
-노력했다 1
-흔들리지 1
-단단하게 1
-올라섰다 1
-제주삼다수 1
-마스터스 1
-번뿐이지 1
-상금에서 1
-모습이다 1
-자리했다 1
-따라왔다 1
-합작했고 1
-야구보다 1
-생각했는데 1
-들어맞으면서 1
-통과하고 1
-장소에서 1
-쿠에바스 1
-선발투수로 1
-슬라이더 1
-신고했다 1
-부응했다 1
-시도했다 1
-기대했던 1
-정규리그에선 1
-이날까지 1
-부진하다 1
-아웃카운트 1
-물러나기 1
-모두에게 1
-절실했다 1
-딘이었다 1
-대결에서 1
-환호했다 1
-장거리포 1
-등판했다 1
-쿠에바스보다 1
-초반까지 1
-플라이로 1
-추격했다 1
-공략하지 1
-도전하게 1
-재무제표 1
-적극적이지 1
-기업분석연구소 1
-리더스인덱스 1
-추진하면서 1
-독려하고 1
-한정됐다 1
-기업별로 1
-배당했다 1
-장군멍군 1
-일으켰던 1
-양키스타디움 1
-원정경기 1
-되갚았다 1
-선두타자로 1
-살바도르 1
-터뜨렸고 1
-성공하자 1
-처리하며 1
-타석에선 1
-종횡무진했다 1
-카우프만필드 1
-자이언츠 1
-원정으로 1
-볼티모어로 1
-뉴욕으로 1
-제압하며 1
-에마누엘 1
-침묵시켰다 1
-기록하면서 1
-마무리로 1
-이어지면 1
-내놓으면서 1
-세계적으로 1
-문법까지 1
-내버려두 1
-규정이라 1
-공영방송 1
-독일어로 1
-표기한다 1
-고유명사 1
-허용하겠다 1
-공식적인 1
-위원회인 1
-공용언어로 1
-독일인들 1
-익숙하게 1
-내렸다고 1
-전통이었지 1
-간판에서 1
-비판하고 1
-내버려두고 1
-평론에서 1
-머리카락 1
-사람이라면 1
-프랑프푸르트 1
-일간지인 1
-중간중간 1
-어색하게 1
-끼워넣으며 1
-불가피하다고 1
-베를린자유대학 1
-언어학자 1
-스테파노비치 1
-프랑스어 1
-부임했다 1
-떨어졌던 1
-들려줬다 1
-등장했고 1
-캐럴이라 1
-출간한다 1
-셰익스피어 1
-다음으로 1
-탄생이었다 1
-등장시킨다 1
-삽화에서 1
-표현됐다 1
-베스트셀러 1
-애니메이션으로 1
-만들어지면서 1
-유명해졌지 1
-멸종했기 1
-모리셔스에 1
-서식했다 1
-모리셔스 1
-도착하면서 1
-지구에서 1
-사라졌다 1
-멸종시키고 1
-뒤뚱거리면서 1
-못했으며 1
-멍청하고 1
-기록으로 1
-포르투갈어로 1
-의미에서 1
-유래했다 1
-국제학술지 1
-있었으며 1
-살펴보자 1
-벌채하면서 1
-멍청해서 1
-월드처럼 1
-소망이었다 1
-산타크루즈 1
-교수이자 1
-생명공학 1
-바이오사이언스 1
-최고과학책임자 1
-줄기세포 1
-코펜하겐 1
-표본에서 1
-얻었다고 1
-엇갈린다 1
-공통조상 1
-진화하기 1
-도도인지 1
-자신한다 1
-태즈메이니아 1
-흰코뿔소 1
-영화감독 1
-투자했다 1
-유명인들 1
-구성원으로 1
-굴종적인 1
-울산시장 1
-불구하고 1
-꼽으라면 1
-문제이지 1
-상식적인 1
-거스르고 1
-상쇄되고 1
-심각해졌다 1
-지지자들 1
-극단적인 1
-내로남불로 1
-콘크리트 1
-생활인들 1
-돌아서게 1
-최대치인 1
-걷어붙이고 1
-금융위원회 1
-정책실장 1
-경제수석 1
-틀어쥐고 1
-불러오긴 1
-가계부채 1
-환기시킬 1
-금융감독원장일 1
-아우성치 1
-장차관들 1
-뛰어들어 1
-끈질지게 1
-노력하고 1
-식당주들 1
-신음하고 1
-마련하려고 1
-클러스터 1
-초래할지 1
-송배전망 1
-해결되고 1
-추진하다 1
-맞추었거나 1
-삐끗했다간 1
-주도적으로 1
-당연하다 1
-사람이라 1
-두드러지 1
-들었다고 1
-정권에서 1
-분류되길 1
-현안이라 1
-말해준다 1
-상층부에서 1
-사라지면 1
-만무하다 1
-책임지고 1
-장벽으로 1
-아메넴헤트 1
-바빌로니아 1
-네부카드네자르 1
-토목공사라 1
-언제까지나 1
-막아주지 1
-방지용으로 1
-테러단체 1
-센서까지 1
-요르단강 1
-하드리아누스 1
-변질됐다 1
-구축했다 1
-서독으로 1
-넘으려다 1
-아프가니스탄 1
-봉쇄하고 1
-모양이다 1
-장벽이다 1
-콘트리트 1
-감옥으로 1
-로마인들 1
-가능하지 1
-광주광역시 1
-졸업하던 1
-시인으로 1
-등단했다 1
-이듬해인 1
-단편소설 1
-서울신문 1
-신춘문예 1
-소설가로 1
-데뷔했다 1
-작가이거나 1
-평론가인 1
-이름이라 1
-한동림씨 1
-소설가이고 1
-경희사이버대 1
-미디어문예창작 1
-문학평론가다 1
-문예창작과 1
-동리문학상 1
-황순원문학상 1
-받았는데 1
-한승원씨 1
-좋아했다 1
-고등학교 1
-쓰겠다며 1
-선택하더니 1
-시달리다 1
-거부하게 1
-되어간다고 1
-트라우마로 1
-시선에서 1
-연작소설 1
-베스트셀러다 1
-수상했다 1
-대중소설가이기보다 1
-전위소설가다 1
-처음부터 1
-다룸으로써 1
-주목받지 1
-허덕이면서 1
-순간부터 1
-축적해온 1
-일어나기 1
-이사했던 1
-시발점으로 1
-들어왔다 1
-주제의식 1
-작품으로 1
-무고하게 1
-강연에서 1
-들여다보면 1
-맹세이자 1
-궁극적인 1
-시선으로 1
-풀어냈다 1
-문학상인 1
-움베르토 1
-처음이었다 1
-의료진들 1
-않았는데 1
-보건복지위원회 1
-징계위원회 1
-상태이며 1
-인사위원회 1
-국민권익위원회 1
-조치이다 1
-특혜이며 1
-것이므로 1
-대상이라고 1
-처리했다 1
-서울대병원행 1
-수술하면 1
-문의하자 1
-수술받기 1
-의학적으로 1
-출동하도록 1
-명확하게 1
-재정비해야 1
-심장마비로 1
-복귀했고 1
-선발에서 1
-지명됐다 1
-내비쳤기 1
-현재까지 1
-서머리그 1
-정도라면 1
-미네소타 1
-팀버울브스 1
-맞대결에선 1
-역사에서 1
-기록됐다 1
-외면했다 1
-전문매체 1
-드래프트 1
-되어있지 1
-레이커스에선 1
-혹평했다 1
-선배이자 1
-브로니라면 1
-해달라고 1
-한국문화재보존과학회 1
-총판매소 1
-인문학술원 1
-객원교수 1
-자유교양학부 1
-반도체생산설비 1
-웨이퍼마스터 1
-대표이기 1
-소프트웨어 1
-비교했다 1
-겹쳐보면 1
-즈려밟고 1
-구절에서 1
-조판해서 1
-현상이라 1
-활판인쇄 1
-한쪽으로 1
-인쇄된다 1
-평압식으로 1
-미루어봤 1
-진달래꽃 1
-학계에서 1
-초간본으로 1
-첨예하게 1
-문학서지 1
-발견했다며 1
-같습니다 1
-동참해주길 1
-몬트리올 1
-인권단체 1
-개인사정으로 1
-화상연결로 1
-강제북송 1
-증언했다 1
-국제활동팀 1
-소속으로 1
-이야기했다 1
-놀라워했다고 1
-중국에서 1
-북송시킨 1
-사과하고 1
-밝혔다고 1
-익숙하지 1
-가르쳐주고 1
-김일혁씨 1
-탈북했다 1
-유엔본부 1
-안전보장이사회 1
-비판하기 1
-지속하도록 1
-한보이스 1
-규탄하고 1
-공동성명 1
-상원의원 1
-실현된다면 1
-영구적으로 1
-선언했다 1
-조선인민군 1
-끊어버리고 1
-방어축성물로 1
-요새화하 1
-전화통지문 1
-군사훈련 1
-핵전략자산 1
-열거하며 1
-이어오고 1
-물리적으로 1
-보여주기 1
-합동참모본부 1
-현상변경 1
-좌시하지 1
-사라지고 1
-공고해지면서 1
-구축하기 1
-활성화될 1
-대응하기 1
-집중한다 1
-발표하며 1
-제시했다 1
-저장하고 1
-이동시키 1
-전체에서 1
-판매하고 1
-확장하기로 1
-주력인데 1
-미드니켈 1
-중저가형 1
-식당에게 1
-수준까지 1
-완화하겠다 1
-통제하자 1
-배달업계 1
-유력하게 1
-영세업체일수록 1
-낮춰준다 1
-구체적인 1
-확정되지 1
-않았으나 1
-수수료율까지 1
-고려하고 1
-문제의식하 1
-출범했다 1
-좁혀지지 1
-규율해야 1
-소극적인 1
-강력하게 1
-제재해야 1
-취지였다 1
-상한제란 1
-지역에선 1
-적용하고 1
-도입한다 1
-신호였다 1
-전방위적 1
-독과점적 1
-식당으로 1
-이듬해엔 1
-정률제로 1
-전환했다 1
-반박한다 1
-우버이츠 1
-저스트잇 1
-치킨이나 1
-한정됐던 1
-다양해졌다 1
-넓어졌다 1
-과점하면서 1
-증가시킨 1
-진단한다 1
-감당하지 1
-부담해서 1
-발의했지 1
-무산됐다 1
-파견검사 1
-후보자추천위 1
-구성에서 1
-상설특검법상 1
-구성된다 1
-추천하게 1
-삼부토건 1
-도이치모터스 1
-주가조작 1
-빠졌는데 1
-병행하겠다 1
-해석됐다 1
-형태인데 1
-결합될지 1
-결정된다 1
-분석하면 1
-결합하면 1
-개발이나 1
-사용된다 1
-수석연구원 1
-분석한다 1
-불과했다 1
-예측했다 1
-데이비드 1
-워싱턴대 1
-개발했고 1
-나노소재 1
-생명과학부 1
-로제타폴드 1
-주도하며 1
-연구단계 1
-해결하겠다 1
-화학물질 1
-먹어치우 1
-발생하지 1
-메탄가스 1
-화학비료 1
-농식품부 1
-계획하고 1
-기여하고 1
-무선으로 1
-로봇청소기인 1
-빨아들인 1
-걸러내고 1
-면적이나 1
-유용하다 1
-고용노동부 1
-소셜벤처경연대회 1
-최우수상 1
-해양수산부 1
-인정받았다 1
-경찰인재개발원 1
-서투르게 1
-들려왔다 1
-읽으면서 1
-고쳐줬다 1
-소화하고 1
-활성화하면서 1
-만들어달라고 1
-요청했고 1
-이수한다 1
-안팎이다 1
-투입하고 1
-범죄자들 1
-일어난다 1
-베트남으로 1
-도주했다 1
-송환됐다 1
-도피했다고 1
-수강생들 1
-한국인들 1
-다낭경찰서 1
-공안이라고 1
-무서워하 1
-한국어로 1
-친밀감이라 1
-신청하게 1
-좋아하고 1
-방탄소년단 1
-담당자인 1
-단계에서 1
-습득력들 1
-현지에서 1
-극단적으로 1
-기상학적 1
-당겨졌다 1
-늦어졌다 1
-기상학적으로 1
-여름이고 1
-빼앗기고 1
-길어지면서 1
-올라가고 1
-길어지기 1
-독해지기 1
-상승으로 1
-오전부터 1
-치솟았던 1
-원인으로 1
-등장으로 1
-색깔이기 1
-역할까지 1
-맡아가며 1
-지휘했다 1
-부위원장 1
-연출하고 1
-한국예술종합학교 1
-개막작인 1
-재미있게 1
-작년까지 1
-내우외환 1
-진행됐나 1
-프로그래머 1
-부국제밖 1
-공석이다 1
-비현실적 1
-시작해서 1
-지나치다 1
-영화제인 1
-외면하느냐 1
-자본이라서 1
-쏟아졌을까 1
-성사됐다 1
-옥외광고물법 1
-구청에서 1
-받아오면 1
-영화제라 1
-가능했던 1
-계약이기 1
-내년대로 1
-전국체전 1
-옮기기로 1
-재미없으면 1
-관객에게 1
-외면당한다 1
-떠나가게 1
diff --git a/assets/resources/vocabulary_v2.csv b/assets/resources/vocabulary.csv
similarity index 69%
rename from assets/resources/vocabulary_v2.csv
rename to assets/resources/vocabulary.csv
index 7b4dc9a7f06023ffe753830015b37a6506891b40..2dbfd8c392d87a07ad19cda65f3c66e527dca6f0 100644
--- a/assets/resources/vocabulary_v2.csv
+++ b/assets/resources/vocabulary.csv
@@ -144,74 +144,4 @@
12·12 계엄,12·3 계엄,
진척율,진척률,
갱신률,갱신율,
-울란바토르,울란바타르,윗층,위층,
-아랫층,아래층,
-윗쪽,위쪽,
-아랫쪽,아래쪽,
-뒷쪽,뒤쪽,
-갯수,개수,
-촛점,초점,
-댓가,대가,
-싯가,시가,
-헛점,허점,
-잇점,이점,
-숫가락,숟가락,
-깍뚜기,깍두기,
-수도물,수돗물,
-존대말,존댓말,
-인삿말,인사말,
-혼자말,혼잣말,
-노래말,노랫말,
-최소값,최솟값,
-절대값,절댓값,
-등교길,등굣길,
-하교길,하굣길,
-만두국,만둣국,
-순대국,순댓국,
-북어국,북엇국,
-뒷풀이,뒤풀이,
-오랫만,오랜만,
-오랜동안,오랫동안,
-웬지,왠지,
-왠만,웬만,
-왠일,웬일,
-왠걸,웬걸,
-금새,금세,
-어의없,어이없,
-희안하,희한하,
-곰곰히,곰곰이,
-일일히,일일이,
-틈틈히,틈틈이,
-깨끗히,깨끗이,
-몇일,며칠,
-역활,역할,
-할려고,하려고,
-귀뜸,귀띔,
-귀띰,귀띔,
-발자욱,발자국,
-눈꼽,눈곱,
-곱배기,곱빼기,
-덮히,덮이,
-덮혀,덮여,
-통채로,통째로,
-하마트면,하마터면,
-어물쩡,어물쩍,
-웅큼,움큼,
-일사분란,일사불란,
-절대절명,절체절명,
-홀홀단신,혈혈단신,
-동거동락,동고동락,
-성대묘사,성대모사,
-뒤치닥거리,뒤치다꺼리,
-안성마춤,안성맞춤,
-되물림,대물림,
-삼가하,삼가,
-첼린지,챌린지,
-리더쉽,리더십,
-멤버쉽,멤버십,
-파트너쉽,파트너십,
-컨텐츠,콘텐츠,
-메세지,메시지,
-어플리케이션,애플리케이션,
-초콜렛,초콜릿,
-팜플렛,팸플릿,
+울란바토르,울란바타르,
\ No newline at end of file
diff --git a/runner.py b/runner.py
index 5d3c11992d2764d1fc249b1b99edc09cf3c47183..c7e47ecdaa2d821713278e45a34cc07eb6b383cc 100644
--- a/runner.py
+++ b/runner.py
@@ -1,20 +1,18 @@
-"""교열 구성(파이프라인 × 프롬프트 × 모델)을 텍스트 1건에 실행하는 프로그래매틱 러너.
-
-교열 로직을 재구현하지 않고 solar-eval 엔진을 그대로 import 한다 — 데모가
-보여주는 동작이 평가 run 과 바이트 단위로 같아야 하기 때문이다 (사본 로직은
-반드시 갈라진다). 로컬에서는 uv 워크스페이스의 solar_eval 을, HF Space 에서는
-`build_space.py` 가 벤더링한 사본을 쓴다 (sys.path 상 스크립트 옆이 먼저라
-Space 에서는 벤더 사본이 이긴다).
-
-**구성은 (pipeline, prompt, model) 한 묶음이다** — 모델만 갈아끼우는 선택지는
-두지 않는다. mini4 의 이득은 모델 교체가 아니라 파이프라인·프롬프트 구조 변경에서
-나왔고(step1 을 생성에서 판정으로, step2 를 어휘/문법으로 분할, 규칙 층 2개 추가),
-옛 combo 프롬프트에 mini4 만 꽂으면 2026-09-16 에 이관 불가 판정을 받은 그 조합이
-된다. 그래서 세 축을 `Config` 하나로 묶고, 데모는 그 구성만 노출한다.
-
-config 자산(파이프라인 yaml·프롬프트·치환 사전·화이트리스트)은
- 1. `./assets/` — HF Space 레이아웃 (build_space.py 가 조립)
- 2. `../03-evaluation/` — 레포 레이아웃 (로컬 개발)
+"""고객 데모(HF Space) 교열 구성을 텍스트 1건에 실행하는 프로그래매틱 러너.
+
+교열 로직을 재구현하지 않고 solar-eval 엔진을 그대로 import 한다 - 데모가 보여주는 동작이 평가 run 과
+바이트 단위로 같아야 하기 때문이다(사본 로직은 반드시 갈라진다). 로컬에서는 uv 워크스페이스의 solar_eval 을,
+HF Space 에서는 `build_space.py` 가 벤더링한 사본을 쓴다(sys.path 상 스크립트 옆이 먼저라 Space 에서는 벤더
+사본이 이긴다).
+
+구성은 (pipeline, prompt, model) 한 묶음이다. 2026-10-29 부터 FT 교열 + Opus 기사 검토
+(`pipeline_exp_261021_ft_hybrid`, 근거 `03-evaluation/insights/261021_ft_hybrid_paragraph_article.md`)
+하나만 노출한다. 기사 검토 스텝은 `timely:` 모델이라 Timely 라우터를 거친다 - `UPSTAGE_API_KEY` 와
+`TIMELY_API_KEY` 가 둘 다 있어야 한다.
+
+config 자산(파이프라인 yaml·프롬프트·치환 사전)은
+ 1. `./assets/` - HF Space 레이아웃 (build_space.py 가 조립)
+ 2. `../03-evaluation/` - 레포 레이아웃 (로컬 개발)
순서로 찾는다.
"""
@@ -32,70 +30,63 @@ import yaml
@dataclass(frozen=True)
class Config:
- """데모가 고를 수 있는 한 구성. `base` 는 pipeline 이 extends 하는 파일(번들 대상)."""
+ """데모가 노출하는 한 구성. `assets` 는 Space 번들에 들어갈 config 파일(03-evaluation 기준 상대 경로)."""
label: str
pipeline: str
prompt: str
model: str
note: str
- base: str | None = None
-
-
-#: 데모에 노출되는 구성. 지금은 하나뿐이지만 tuple 로 둔다 — `build_space.required_assets`
-#: 가 이 목록에서 번들 자산을 유도하므로, 구성이 늘어도 조립 코드를 고칠 필요가 없다.
-CONFIGS: tuple[Config, ...] = (
- Config(
- label="solar-mini4 파이프라인",
- pipeline="pipeline_dev_v44", # = v41 + document_chunking
- prompt="prompt_dev_260917_vsplit",
- model="solar-mini4",
- note=(
- "FT 교열 → 판정형 step1(5KB) → 문맥 교정 어휘/문법 2스텝 → 결정론 규칙 6층. "
- "입력 3.3k 토큰."
- ),
- base="pipeline_dev_v40",
+ assets: tuple[str, ...]
+
+
+CONFIG = Config(
+ label="FT 교열 + Opus 기사 검토",
+ pipeline="pipeline_exp_261021_ft_hybrid",
+ prompt="prompt_exp_261021_ft_hybrid",
+ model="solar-pro4", # 모델을 지정하지 않은 스텝용 기본값 - 이 구성의 LLM 스텝은 FT·Opus 를 직접 지정한다
+ note=(
+ "FT 교열 모델이 문단 묶음마다 1차 교열 → Claude Opus 가 기사 전체를 한 번 보고 FT 오교정을 되돌리고 "
+ "사실·숫자·문맥 오류를 고침 → 따옴표·율/률·후처리 규칙."
),
+ assets=("resources/vocabulary.csv",),
)
-#: 라벨 → Config
-BY_LABEL: dict[str, Config] = {c.label: c for c in CONFIGS}
-DEFAULT_LABEL = CONFIGS[0].label
+#: 필요한 키. 기사 검토(Opus)가 Timely 를 거친다.
+REQUIRED_KEYS = ("UPSTAGE_API_KEY", "TIMELY_API_KEY")
-# 엔진의 StepCallback 과 같은 모양: (event, index, total, step_name, doc_chunk)
-StepCallback = Callable[[str, int, int, str, tuple[int, int] | None], None]
+# (event, index, total, step_name) - 스텝이 끝날 때마다 "done" 으로 불린다
+StepCallback = Callable[[str, int, int, str], None]
-def find_config_dir(pipeline: str) -> Path:
- """그 파이프라인 yaml 을 실제로 가진 자산 루트. Space 번들이 레포보다 먼저다."""
+def find_config_dir() -> Path:
+ """파이프라인 yaml 을 실제로 가진 자산 루트. Space 번들이 레포보다 먼저다."""
here = Path(__file__).resolve().parent
for cand in (here / "assets", here.parent / "03-evaluation"):
- if (cand / "pipelines" / f"{pipeline}.yaml").is_file():
+ if (cand / "pipelines" / f"{CONFIG.pipeline}.yaml").is_file():
return cand
raise FileNotFoundError(
- f"config 자산을 찾을 수 없습니다 — {here}/assets 또는 ../03-evaluation 에 "
- f"pipelines/{pipeline}.yaml 이 있어야 합니다 (build_space.py 참조)."
+ f"config 자산을 찾을 수 없습니다 - {here}/assets 또는 ../03-evaluation 에 "
+ f"pipelines/{CONFIG.pipeline}.yaml 이 있어야 합니다 (build_space.py 참조)."
)
-def build_pipeline(label: str = DEFAULT_LABEL) -> Any:
- """구성 하나의 파이프라인 인스턴스. 앱에서 구성별로 1회 만들어 재사용한다."""
- import solar_eval.pipelines.steps # noqa: F401 — STEP_REGISTRY 등록
+def build_pipeline() -> Any:
+ """파이프라인 인스턴스. 앱에서 1회 만들어 재사용한다."""
+ import solar_eval.pipelines.steps # noqa: F401 - STEP_REGISTRY 등록
from solar_eval.core.dataset_loader import DatasetLoader
from solar_eval.core.pipeline_compose import compose_pipeline
from solar_eval.models.prompt_version import load_step_prompts
from solar_eval.pipelines.registry import create_pipeline
- cfg = BY_LABEL[label]
- config_dir = find_config_dir(cfg.pipeline)
+ config_dir = find_config_dir()
pipelines_dir = config_dir / "pipelines"
def load_base(name: str) -> dict[str, Any]:
return yaml.safe_load((pipelines_dir / f"{name}.yaml").read_text(encoding="utf-8"))
- composed = compose_pipeline(load_base(cfg.pipeline), load_base=load_base)
- prompts = load_step_prompts(config_dir / "prompts" / cfg.prompt)
-
+ composed = compose_pipeline(load_base(CONFIG.pipeline), load_base=load_base)
+ prompts = load_step_prompts(config_dir / "prompts" / CONFIG.prompt)
return create_pipeline(
"multi_step",
input_fields=["original"],
@@ -111,47 +102,44 @@ def step_names(pipeline: Any) -> list[str]:
return [step.name for step in pipeline.steps]
-async def _run_async(
- pipeline: Any, text: str, cfg: Config, on_step: StepCallback | None
-) -> dict[str, Any]:
+async def _run_async(pipeline: Any, text: str, on_step: StepCallback | None) -> dict[str, Any]:
from solar_eval.models.sample import EvalSample
+ from solar_eval.providers import INFERENCE_ROUTES, RoutedProvider
from solar_eval.providers.upstage import UpstageProvider
+ names = step_names(pipeline)
+
+ def hook(name: str, _sample: Any, _ctx: Any) -> None:
+ if on_step is not None:
+ on_step("done", names.index(name) + 1, len(names), name)
+
sample = EvalSample(input={"original": text})
+ provider = RoutedProvider(UpstageProvider(), {k: c() for k, c in INFERENCE_ROUTES.items()})
start = time.monotonic()
- # 평가 run 과 같은 조건: temp 0.0, reasoning off (60초 지연 예산 제약)
+ # 평가 run 과 같은 조건: temp 0.0, reasoning off
result = await pipeline.run(
sample,
prompts="",
- provider=UpstageProvider(),
- model=cfg.model,
+ provider=provider,
+ model=CONFIG.model,
temperature=0.0,
max_tokens=8000,
reasoning_effort=None,
- on_step=on_step,
+ on_step=hook,
)
- elapsed = time.monotonic() - start
return {
"output": result.output,
- "step_outputs": result.artifacts.get("step_outputs", {}),
"usage": result.artifacts.get("usage", {}),
- "elapsed_s": elapsed,
- "pipeline_key": cfg.pipeline,
- "prompt_key": cfg.prompt,
- "model": cfg.model,
- "config_label": cfg.label,
+ "elapsed_s": time.monotonic() - start,
+ "pipeline_key": CONFIG.pipeline,
+ "prompt_key": CONFIG.prompt,
+ "model": CONFIG.model,
}
-def run_proofread(
- pipeline: Any,
- text: str,
- label: str = DEFAULT_LABEL,
- on_step: StepCallback | None = None,
-) -> dict[str, Any]:
- """텍스트 1건 교열. {output, step_outputs, usage, elapsed_s, pipeline_key, prompt_key, model, config_label}.
+def run_proofread(pipeline: Any, text: str, on_step: StepCallback | None = None) -> dict[str, Any]:
+ """텍스트 1건 교열. {output, usage, elapsed_s, pipeline_key, prompt_key, model}.
- `pipeline` 은 같은 `label` 로 `build_pipeline` 한 인스턴스여야 한다 — 앱이
- 구성별로 캐시한다. `on_step` 은 스텝 시작/종료와 chunk 진행마다 불린다(UI 진행 표시).
+ `on_step` 은 스텝이 끝날 때마다 ("done", 몇 번째, 전체, 스텝 이름) 으로 불린다(UI 진행 표시).
"""
- return asyncio.run(_run_async(pipeline, text, BY_LABEL[label], on_step))
+ return asyncio.run(_run_async(pipeline, text, on_step))
diff --git a/solar_eval/__pycache__/__init__.cpython-312.pyc b/solar_eval/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..6d0fc20b2ad738a1c9858850c96d71cf3e34ac27
Binary files /dev/null and b/solar_eval/__pycache__/__init__.cpython-312.pyc differ
diff --git a/solar_eval/__pycache__/_version.cpython-312.pyc b/solar_eval/__pycache__/_version.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..f3bb926029111c94bed30862c41f001b161e2b1f
Binary files /dev/null and b/solar_eval/__pycache__/_version.cpython-312.pyc differ
diff --git a/solar_eval/__pycache__/config.cpython-312.pyc b/solar_eval/__pycache__/config.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..79ae30c775e7a65788b4fd84d103f129ad5e4990
Binary files /dev/null and b/solar_eval/__pycache__/config.cpython-312.pyc differ
diff --git a/solar_eval/cli/commands/compare.py b/solar_eval/cli/commands/compare.py
index 4b2f18916454094ad48c1670be0ba002911c2618..09c5d4e7d4204d4c993f9d975fca9557ff4ad748 100644
--- a/solar_eval/cli/commands/compare.py
+++ b/solar_eval/cli/commands/compare.py
@@ -1,42 +1,105 @@
-"""Compare runs side by side."""
+"""Compare runs side by side.
+
+비교 대상 run 들의 행 선택(`run.json` `dataset.path`·`selection`·`rows_selected`)이 서로 다르면 각 run 의
+선택을 헤더에 싣고 "다른 행 집합의 점수다" 경고를 낸다(계획 2026-09-24-phase1a D19) — `--where` 슬라이스
+run 과 전체셋 run 의 점수가 같은 표에서 조용히 나란히 서지 않게. quick-run 이 직후에 부르는 compare 도
+같은 경고를 받는다. 선택이 모두 같으면 출력은 예전 그대로다.
+
+데이터셋 파일 바이트(`dataset.sha256`)만 다른 경우는 **선택 차이가 아니다** — 행 출처 키를 더한 재빌드처럼
+행 집합은 같고 바이트만 바뀔 수 있다. 그래도 정답이 바뀌었을 수 있으므로 따로 경고하고, 라벨에 sha 앞자리를
+실어 사람이 이유를 볼 수 있게 한다.
+"""
import json
+from pathlib import Path
+from typing import Any
import click
from tabulate import tabulate
+#: 비교 대상 행 집합이 다를 때의 경고.
+DIFFERENT_ROWS_WARNING = (
+ " ⚠ 비교 대상 run 들의 행 선택이 다릅니다 — 다른 행 집합의 점수라 직접 비교할 수 없습니다."
+)
-@click.command("compare")
-@click.argument("project")
-@click.option("--task", "-t", default=None, help="Filter by task name")
-@click.option(
- "--runs",
- "run_ids",
- default=None,
- help="Comma-separated run IDs to compare",
+#: 행 선택은 같은데 데이터셋 파일 바이트가 다를 때의 경고.
+DIFFERENT_BYTES_WARNING = (
+ " ⚠ 행 선택은 같지만 데이터셋 파일(sha256)이 다릅니다 — 행 내용(정답 등)이 바뀌었을 수 있으니 "
+ "점수를 비교하기 전에 두 파일의 차이를 확인하세요."
)
-@click.option("--last", "-n", default=None, type=int, help="Compare last N runs for a task")
-@click.pass_context
-def compare_command(ctx, project, task, run_ids, last):
- """Compare evaluation scores across runs.
- Examples:
- solar-eval compare my-project --task ci-v1-all --last 5
- solar-eval compare my-project --runs "run1,run2"
- """
- cfg = ctx.obj["config"]
- if cfg.is_remote:
- raise click.ClickException("Compare is local-mode only")
+#: 라벨에 싣는 sha256 앞자리 수 — legacy `prompt_sha` 표시와 맞춘다.
+SHA_DISPLAY_CHARS = 12
+
+
+def _dataset(run_meta: dict[str, Any]) -> dict[str, Any] | None:
+ dataset = run_meta.get("dataset")
+ return dataset if isinstance(dataset, dict) else None
+
+
+def _selection_signature(run_meta: dict[str, Any]) -> str | None:
+ """행 집합의 비교 키 — `dataset` 블록이 없는 옛 run 은 None(모름). 파일 바이트(sha)는 넣지 않는다."""
+ dataset = _dataset(run_meta)
+ if dataset is None:
+ return None
+ key = {
+ "path": dataset.get("path"),
+ "selection": dataset.get("selection"),
+ "rows_selected": dataset.get("rows_selected"),
+ }
+ return json.dumps(key, sort_keys=True, ensure_ascii=False)
+
+
+def _is_filtered(run_meta: dict[str, Any]) -> bool:
+ selection = (_dataset(run_meta) or {}).get("selection") or {}
+ return selection.get("where") is not None or selection.get("limit") is not None
- artifacts_dir = cfg.artifacts_dir(project)
- if not artifacts_dir.exists():
- click.secho("No artifacts found.", fg="yellow")
- return
- # Collect runs to compare
+def _selection_label(run_meta: dict[str, Any], show_sha: bool = False) -> str:
+ """사람용 선택 표기: `where tags=a · 12/159 rows` · `all 159/159 rows` · `selection unknown`.
+
+ `show_sha` 면 ` · sha <12>` 를 덧붙인다(파일 바이트가 다른 run 들을 비교할 때).
+ """
+ dataset = _dataset(run_meta)
+ if dataset is None:
+ return "selection unknown (no dataset block)"
+ selection = dataset.get("selection") or {}
+ parts = []
+ where = selection.get("where")
+ if where:
+ parts.append("where " + " ".join(f"{k}={','.join(v)}" for k, v in sorted(where.items())))
+ if selection.get("limit") is not None:
+ parts.append(f"limit {selection['limit']}")
+ label = " ".join(parts) or "all"
+ label = f"{label} · {dataset.get('rows_selected')}/{dataset.get('rows_total')} rows"
+ sha = dataset.get("sha256")
+ if show_sha and isinstance(sha, str):
+ label = f"{label} · sha {sha[:SHA_DISPLAY_CHARS]}"
+ return label
+
+
+def selections_differ(run_metas: list[dict[str, Any]]) -> bool:
+ """비교 대상의 행 집합이 다른가 — 알려진 키가 둘 이상이거나, 모르는 run 과 필터 run 이 섞였을 때."""
+ signatures = [_selection_signature(m) for m in run_metas]
+ known = {s for s in signatures if s is not None}
+ if len(known) > 1:
+ return True
+ unknown = any(s is None for s in signatures)
+ return unknown and any(_is_filtered(m) for m in run_metas)
+
+
+def dataset_bytes_differ(run_metas: list[dict[str, Any]]) -> bool:
+ """기록된 데이터셋 sha256 이 둘 이상인가 — 행 선택과 별개로 파일 내용이 바뀌었다는 신호."""
+ shas = {(_dataset(m) or {}).get("sha256") for m in run_metas}
+ return len(shas - {None}) > 1
+
+
+def _collect_run_dirs(
+ artifacts_dir: Path, task: str | None, run_ids: str | None, last: int | None
+) -> list[Path]:
+ """비교할 run 디렉토리 — `--runs` 가 있으면 그 목록, 없으면 (태스크로 거른) 전체."""
+ run_dirs: list[Path] = []
if run_ids:
- # Explicit run IDs
- run_dirs = []
for rid in run_ids.split(","):
rid = rid.strip()
d = artifacts_dir / rid
@@ -45,33 +108,25 @@ def compare_command(ctx, project, task, run_ids, last):
else:
click.secho(f" Run not found: {rid}, skipping", fg="yellow")
else:
- # Scan all runs, optionally filtered by task
- run_dirs = []
for d in sorted(artifacts_dir.iterdir()):
if not d.is_dir() or not (d / "run.json").exists():
continue
- if task:
- run_meta = json.loads((d / "run.json").read_text())
- if run_meta.get("task") != task:
- continue
+ if task and json.loads((d / "run.json").read_text()).get("task") != task:
+ continue
run_dirs.append(d)
-
if last and last < len(run_dirs):
run_dirs = run_dirs[-last:]
+ return run_dirs
- if not run_dirs:
- click.secho("No runs found to compare.", fg="yellow")
- return
- # Load run metadata and evaluation data
+def _load_runs_info(run_dirs: list[Path]) -> list[dict[str, Any]]:
+ """평가가 있는 run 만 (메타 + evaluation.json) 으로 읽는다."""
runs_info = []
- all_categories = set()
for d in run_dirs:
- run_meta = json.loads((d / "run.json").read_text())
eval_file = d / "evaluation.json"
if not eval_file.exists():
continue
- eval_data = json.loads(eval_file.read_text())
+ run_meta = json.loads((d / "run.json").read_text())
runs_info.append(
{
"run_id": d.name,
@@ -79,18 +134,19 @@ def compare_command(ctx, project, task, run_ids, last):
"model": run_meta.get("model", "-"),
"prompt": run_meta.get("prompt", run_meta.get("prompt_version", "-")),
"pipeline": run_meta.get("pipeline", "-"),
- "eval_data": eval_data,
+ "eval_data": json.loads(eval_file.read_text()),
+ "run_meta": run_meta,
}
)
- all_categories.update(eval_data.get("scores", {}).keys())
+ return runs_info
- if not runs_info:
- click.secho("No evaluation data available for comparison.", fg="yellow")
- return
- # Build comparison table
+def _score_table(runs_info: list[dict[str, Any]]) -> str:
+ """카테고리별 점수 + Overall 표."""
+ all_categories: set[str] = set()
+ for r in runs_info:
+ all_categories.update(r["eval_data"].get("scores", {}).keys())
headers = ["Category"] + [f"{r['prompt']}/{r['model']}" for r in runs_info]
-
rows = []
for cat in sorted(all_categories):
row = [cat]
@@ -98,26 +154,31 @@ def compare_command(ctx, project, task, run_ids, last):
score = r["eval_data"].get("scores", {}).get(cat)
row.append(f"{score:.4f}" if score is not None else "-")
rows.append(row)
-
- # Separator and overall row
rows.append(["─" * 20] + ["─" * 8] * len(runs_info))
overall_row = ["Overall"]
for r in runs_info:
overall = r["eval_data"].get("overall_score")
overall_row.append(f"{overall:.4f}" if overall is not None else "-")
rows.append(overall_row)
+ return tabulate(rows, headers=headers, tablefmt="simple")
- task_label = task or "all tasks"
- click.secho(f"\nComparison: {project}/{task_label}", fg="blue", bold=True)
- # Show run details
+def _echo_run_details(runs_info: list[dict[str, Any]]) -> None:
+ """run 별 한 줄 — 행 선택이나 데이터셋 바이트가 다르면 각 run 의 선택을 함께 싣는다(D19)."""
+ metas = [r["run_meta"] for r in runs_info]
+ rows_differ = selections_differ(metas)
+ bytes_differ = dataset_bytes_differ(metas)
for r in runs_info:
- click.echo(f" {r['prompt']} ({r['model']}, {r['pipeline']})")
- click.echo()
+ label = _selection_label(r["run_meta"], show_sha=bytes_differ)
+ suffix = f" [{label}]" if rows_differ or bytes_differ else ""
+ click.echo(f" {r['prompt']} ({r['model']}, {r['pipeline']}){suffix}")
+ if rows_differ:
+ click.secho(DIFFERENT_ROWS_WARNING, fg="yellow", bold=True)
+ elif bytes_differ:
+ click.secho(DIFFERENT_BYTES_WARNING, fg="yellow", bold=True)
- click.echo(tabulate(rows, headers=headers, tablefmt="simple"))
- # Highlight best
+def _echo_best(runs_info: list[dict[str, Any]]) -> None:
best_run = max(runs_info, key=lambda r: r["eval_data"].get("overall_score", 0))
best_score = best_run["eval_data"].get("overall_score", 0)
click.secho(
@@ -125,3 +186,45 @@ def compare_command(ctx, project, task, run_ids, last):
fg="green",
bold=True,
)
+
+
+@click.command("compare")
+@click.argument("project")
+@click.option("--task", "-t", default=None, help="Filter by task name")
+@click.option(
+ "--runs",
+ "run_ids",
+ default=None,
+ help="Comma-separated run IDs to compare",
+)
+@click.option("--last", "-n", default=None, type=int, help="Compare last N runs for a task")
+@click.pass_context
+def compare_command(ctx, project, task, run_ids, last):
+ """Compare evaluation scores across runs.
+
+ Examples:
+ solar-eval compare my-project --task ci-v1-all --last 5
+ solar-eval compare my-project --runs "run1,run2"
+ """
+ cfg = ctx.obj["config"]
+ if cfg.is_remote:
+ raise click.ClickException("Compare is local-mode only")
+
+ artifacts_dir = cfg.artifacts_dir(project)
+ if not artifacts_dir.exists():
+ click.secho("No artifacts found.", fg="yellow")
+ return
+ run_dirs = _collect_run_dirs(artifacts_dir, task, run_ids, last)
+ if not run_dirs:
+ click.secho("No runs found to compare.", fg="yellow")
+ return
+ runs_info = _load_runs_info(run_dirs)
+ if not runs_info:
+ click.secho("No evaluation data available for comparison.", fg="yellow")
+ return
+
+ click.secho(f"\nComparison: {project}/{task or 'all tasks'}", fg="blue", bold=True)
+ _echo_run_details(runs_info)
+ click.echo()
+ click.echo(_score_table(runs_info))
+ _echo_best(runs_info)
diff --git a/solar_eval/cli/commands/failure_keys.py b/solar_eval/cli/commands/failure_keys.py
new file mode 100644
index 0000000000000000000000000000000000000000..1f14eb399d88e42913c05126a2afcf229bff590b
--- /dev/null
+++ b/solar_eval/cli/commands/failure_keys.py
@@ -0,0 +1,143 @@
+"""추론 실패 샘플의 지문 — run.json `failed_sample_keys` (LP5 D-LP5-4 · 계획 §2.4).
+
+러너는 실패 위치(`failed_sample_indices`)와 예외 타입(`failed_sample_errors`)만 run.json 에 쓴다 — 러너 경로는
+`poc_bundle` 을 import 하지 않는다(D18). 이 모듈은 CLI 층이라 `poc_bundle.keys` 로 지문을 뜰 수 있다:
+`execute_plan` 이 러너가 **정상 반환한 뒤** 실패 위치마다 준비된 데이터셋의 원본 행에 결과 행과 같은
+`extract_input` · `extract_golden` 을 적용해 `{sample_idx, input_key, golden_key, error_type}` 를 더한다.
+
+텍스트는 싣지 않는다 — hex 지문과 타입 이름뿐이다. 계산할 수 없으면(위치가 선택 밖 · 입력이 빈 객체 ·
+JCS 가 거부하는 값) **아무것도 쓰지 않는다**(= 모름). wrap 은 그때 `failures.jsonl` 을 생략한다.
+단 재개 전 시도가 남긴 목록이 run 문서에 있으면 `[]` 로 덮는다(`record_failure_keys` — 낡은 지문을 남기지 않는다).
+경고 한 줄에는 이유만 적고 값은 적지 않는다.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import inspect
+import json
+from collections.abc import Mapping
+from typing import Any
+
+import click
+
+from poc_bundle.keys import golden_key, input_key
+from poc_bundle.v11 import FAILED_SAMPLE_KEYS_KEY
+from solar_eval.core.run_records import FAILED_SAMPLE_ERRORS_KEY, extract_golden, extract_input
+
+#: `failed_sample_errors` 에 그 위치가 없을 때의 타입 이름(wrap 은 `exception:unknown` 으로 적는다).
+UNKNOWN_ERROR_TYPE = "unknown"
+
+
+class _KeysUnknown(Exception):
+ """지문을 뜰 수 없다 — 이유 한 마디만 싣는다(값은 싣지 않는다)."""
+
+
+def failure_keys(
+ run_doc: Mapping[str, Any] | None, prepared: Any, task_config: Mapping[str, Any]
+) -> list[dict[str, Any]] | None:
+ """run 문서의 실패 위치 → `failed_sample_keys` 원소들(sample_idx 순). 모르면 None.
+
+ `prepared` 는 `PreparedDataset`(또는 `selected` 가 `(원본 위치, 행)` 목록인 객체)이다.
+ 실패가 없으면 `[]` 이다.
+ """
+ try:
+ indices = _failed_indices(run_doc)
+ rows = dict(prepared.selected)
+ types = _error_types(run_doc.get(FAILED_SAMPLE_ERRORS_KEY))
+ return [_entry(idx, rows, task_config, types) for idx in sorted(indices)]
+ except _KeysUnknown as unknown:
+ click.secho(f" failure keys not recorded ({unknown})", fg="yellow", err=True)
+ return None
+
+
+def record_failure_keys(
+ store: Any, run_id: str, prepared: Any, task_config: Mapping[str, Any]
+) -> None:
+ """run 문서를 읽어 실패 지문을 계산하고 `update_run` 으로 더한다.
+
+ run 문서에 앞선 시도(재개 전)가 남긴 비지 않은 목록이 있으면 **늘 덮는다** — 저장소 `update_run` 은 병합이라
+ 덮지 않으면 낡은 목록이 남는다. 새 목록이 있으면 그것으로, 실패가 없으면(`[]`, 재개가 실패를 모두 회복) `[]` 로
+ (wrap 이 0행을 쓴다), 모르면(None) 역시 `[]` 로 덮는다 — `[]` 는 비지 않은 `failed_sample_indices` 와 집합이 달라
+ wrap 이 생략(모름)하므로, 낡은 지문이 우연히 같은 위치 집합과 맞아 틀린 쌍으로 쓰이는 길이 없다.
+ 낡은 목록이 없으면: 모르면 쓰지 않고, 실패가 없어도 쓰지 않는다 — 실패 없음은 `failed_sample_indices = []` 가
+ 이미 말한다(성공 run 마다 run.json 을 한 번 더 쓰지 않는다). run 문서를 읽을 수 없는 저장소(`load_run_meta`
+ 없음)는 모름이다.
+ """
+ loader = getattr(store, "load_run_meta", None)
+ run_doc = loader() if callable(loader) else None
+ keys = failure_keys(run_doc, prepared, task_config)
+ if _has_stale_keys(run_doc):
+ keys = keys or []
+ elif not keys:
+ return
+ pending = store.update_run(run_id, {FAILED_SAMPLE_KEYS_KEY: keys})
+ if inspect.isawaitable(pending):
+ asyncio.run(pending)
+
+
+def _has_stale_keys(run_doc: Mapping[str, Any] | None) -> bool:
+ """run 문서에 `[]` 가 아닌 `failed_sample_keys` 가 이미 있다(앞선 시도의 목록 — 모양은 따지지 않는다)."""
+ return isinstance(run_doc, Mapping) and run_doc.get(FAILED_SAMPLE_KEYS_KEY, []) != []
+
+
+def _failed_indices(run_doc: Mapping[str, Any] | None) -> list[int]:
+ if not isinstance(run_doc, Mapping):
+ raise _KeysUnknown("run document unreadable")
+ indices = run_doc.get("failed_sample_indices")
+ if not isinstance(indices, list):
+ raise _KeysUnknown("no failed_sample_indices list")
+ if not all(isinstance(i, int) and not isinstance(i, bool) and i >= 0 for i in indices):
+ raise _KeysUnknown("failed_sample_indices is not a list of positions")
+ if len(set(indices)) != len(indices):
+ raise _KeysUnknown("failed_sample_indices has duplicates")
+ return indices
+
+
+def _error_types(errors: Any) -> dict[int, str]:
+ """`failed_sample_errors` → `{sample_idx: 타입 이름}`. 모양이 틀린 원소는 건너뛴다(→ unknown)."""
+ if not isinstance(errors, list):
+ return {}
+ types: dict[int, str] = {}
+ for element in errors:
+ if not isinstance(element, Mapping):
+ continue
+ idx, name = element.get("sample_idx"), element.get("error_type")
+ if isinstance(idx, int) and not isinstance(idx, bool) and isinstance(name, str) and name:
+ types[idx] = name
+ return types
+
+
+def _entry(
+ idx: int, rows: Mapping[int, Any], task_config: Mapping[str, Any], types: Mapping[int, str]
+) -> dict[str, Any]:
+ sample = rows.get(idx)
+ if not isinstance(sample, Mapping):
+ raise _KeysUnknown("failed position outside the selection")
+ input_data = _as_stored(extract_input(sample, task_config.get("input_fields", [])))
+ if not input_data:
+ raise _KeysUnknown("empty input")
+ golden = _as_stored(extract_golden(sample, task_config))
+ try:
+ # items 규칙과 같다 — 엔진 run 의 입력 정체성은 엔진이 고른 input 객체 전체(fields=None).
+ ik, gk = input_key(input_data, None), golden_key(golden)
+ except (TypeError, ValueError) as exc: # CanonicalizationError 는 ValueError 다
+ raise _KeysUnknown(f"input not fingerprintable: {type(exc).__name__}") from None
+ return {
+ "sample_idx": idx,
+ "input_key": ik,
+ "golden_key": gk,
+ "error_type": types.get(idx, UNKNOWN_ERROR_TYPE),
+ }
+
+
+def _as_stored(value: Any) -> Any:
+ """결과 저장소(`JsonlStore.insert_run_result`)가 쓰는 직렬화를 한 번 거친 값.
+
+ items 는 results.jsonl 을 다시 읽어 지문을 뜨므로, 같은 원본 행이면 같은 키가 나오게 같은
+ `json.dumps` 왕복을 거친다(튜플 → 목록 · 문자열이 아닌 키 → 문자열). 직렬화할 수 없으면 모름.
+ """
+ try:
+ return json.loads(json.dumps(value, ensure_ascii=False))
+ except (TypeError, ValueError) as exc:
+ raise _KeysUnknown(f"input not serializable: {type(exc).__name__}") from None
diff --git a/solar_eval/cli/commands/run_eval.py b/solar_eval/cli/commands/run_eval.py
new file mode 100644
index 0000000000000000000000000000000000000000..7f4df2675c440112167a7fa06f866f27c76492a1
--- /dev/null
+++ b/solar_eval/cli/commands/run_eval.py
@@ -0,0 +1,340 @@
+"""`solar-eval runs eval` — 새 추론 없이 디스크의 결과만 다시 채점한다 (계획 2026-09-24-phase1a D11).
+
+`runs.py` 에서 떼어 냈다(E1). 옮기면서 바뀐 것 두 가지:
+
+- evaluation.json `eval_model` 은 예전의 `"local"` 이 아니라 평가기가 보고하는 judge 모델이다
+ (judge 가 없는 lcs_diff·rule_based 는 null). 러너와 같은 규칙이다.
+- run.json 에 `snapshot` 이 있으면 `snapshot.config.evaluator_config` 를 새 평가기로 다시 쓰고
+ `config_fingerprint` 를 다시 계산하며 `provenance.re_evaluated_at` 을 남긴다 — 번들로 감쌀 때
+ 평가 파일과 스냅샷이 어긋나지 않게(SPEC §4 "평가 정체성 연기"의 최소 대응, Q14).
+
+**두 경로(계획 2026-09-27 L6 §2.12 B4 · 마스터 D-M2-6).** hub 대상 lcs_diff 계열의 완료된 run 은 **run 디렉토리를 건드리지
+않는다** — 채택된 run 번들의 결과로 채점해 평가 디렉토리에 남기고 `purpose=rescore_after_fix` 채점 번들로 발행한 뒤
+(opt-in) hub 에 싣는다(`solar_eval.scoring_publish`). 그 밖(hub 대상 아님·비-lcs·미완료)은 위의 오늘 동작(in-place) +
+안내 한 줄이다. hub 대상 run 을 옛 동작으로 돌리던 탈출구 플래그는 L10 컷오버에서 지웠다(계획 2026-09-28 L10 §2.6 · D-L10-7 — 주면 click 사용
+오류, 종료 2). 종료 코드: 채점을 마쳤으면 0(발행·hub 실패와 무관), `--strict-ingest`/
+`SOLAR_EVAL_STRICT_INGEST=1` 이고 실패 다리가 있으면 3, 새 경로에서 번들이 없거나 채점을 못 했으면 1.
+
+**judge 경로(계획 2026-09-28 L12 §2.5 · D-L12-6·9·13).** 태스크 평가기가 `rubric_judge` 인 hub 대상 완료 run 은 기준별 judge 채점
+번들을 발행한다(`scoring_publish.judge`). `--purpose {primary,variance_check}` 와 `--retry-of ` 는 이 경로에서만
+받는다(함께 쓰거나 다른 경로에서 쓰면 사용 오류, 종료 2). judge 설정은 경로 판정 전에 확인한다.
+judge 가지의 종료 코드: 발행 사전 거부 1 · retry 대상 없음 0 · 채점 뒤 실패는 위 규칙 그대로.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Mapping
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+import click
+
+from solar_eval.cli.commands import run_meta as run_meta_module
+from solar_eval.cli.commands.run_meta import PREP_ERRORS, find_task, load_run
+from solar_eval.cli.formatters import format_scores
+from solar_eval.core.run_records import (
+ build_eval_detail_docs,
+ build_eval_sample_from_result,
+ build_evaluation_doc,
+)
+from solar_eval.core.snapshot import self_check, with_evaluator
+from solar_eval.hub_hook import exit_code_for
+from solar_eval.hub_hook.optin import strict_requested
+from solar_eval.hub_hook.types import LEG_PUBLISH, PUBLISH_NO_BUNDLE_PREFIX, STATUS_SKIPPED
+from solar_eval.scoring_publish import FAMILY_JUDGE, EvalJob, Route, decide_route, publish_run_eval
+from solar_eval.scoring_publish import judge as judge_publish
+from solar_eval.scoring_publish.eval_dir import REPORT_SAMPLES
+from solar_eval.scoring_publish.publish import PublishOutcome, Scored
+
+#: `--no-ingest`·`--strict-ingest` 도움말(계획 L6 §2.2 `runs eval` 행).
+NO_INGEST_HELP = "새 경로에서 hub 다리(적재)만 건너뛴다 — 평가 디렉토리·채점 번들 발행은 한다"
+STRICT_INGEST_HELP = (
+ "새 경로의 번들·발행·hub 다리가 하나라도 실패하면 종료 코드 3 (기본 0). "
+ "SOLAR_EVAL_STRICT_INGEST=1 과 같다"
+)
+#: judge 경로 옵션 도움말(계획 2026-09-28 L12 D-L12-9).
+PURPOSE_HELP = (
+ "rubric judge 채점 번들의 purpose. 기본 primary — 같은 설정의 primary 가 이미 있으면 거부한다. "
+ "같은 설정을 다시 채점해 판정 분산을 재려면 variance_check"
+)
+RETRY_OF_HELP = "이 평가 실행(evaluation_run_id, ULID)의 error 판정만 다시 부른다 — purpose=retry"
+#: judge 경로에서만 받는 옵션 — 함께 쓰거나 다른 경로에서 쓰면 사용 오류(종료 2).
+USAGE_PURPOSE_WITH_RETRY = "--purpose 와 --retry-of 는 함께 쓸 수 없다"
+USAGE_JUDGE_ONLY = "--purpose/--retry-of 는 rubric judge 채점 번들 경로에서만 쓴다"
+#: 이 run 이 judge 경로가 아닌 이유(경로 안내)가 있으면 사용 오류 뒤에 붙인다.
+USAGE_NOT_JUDGE_REASON = " — 이 run 은 judge 경로가 아니다: {notice}"
+#: judge 가지에서 채점하지 못했을 때 — 종료 1. 플래그 권유는 그 플래그가 푸는 실패에만 붙인다(아래 둘).
+JUDGE_NOT_SCORED = "채점하지 않았다 — 위 요약의 실패 다리와 그 안내를 본다"
+#: 같은 설정의 primary 가 이미 있다(`publish_judge:primary_exists`) — purpose·retry 플래그가 푸는 유일한 실패.
+JUDGE_NOT_SCORED_PRIMARY_EXISTS = (
+ f"{JUDGE_NOT_SCORED}. 같은 설정을 다시 채점하려면 --purpose variance_check, "
+ "error 만 다시 부르려면 --retry-of "
+)
+#: 채택된 run 번들이 없다(`publish_no_bundle:*`) — 플래그로는 풀리지 않는다.
+JUDGE_NOT_SCORED_NO_BUNDLE = f"{JUDGE_NOT_SCORED}. 채택된 run 번들이 없다 — 요약의 백필 명령으로 부류를 확인한 뒤 다시 돌린다"
+#: retry 대상이 없을 때(종료 0).
+JUDGE_RETRY_NOTHING = "다시 부를 error 판정이 없다 — 원 평가 실행의 error 가 이미 모두 풀렸다"
+
+
+def _ulid_option(ctx: click.Context, param: click.Parameter, value: str | None) -> str | None:
+ """`--retry-of` 는 ULID 모양만 받는다 — 틀리면 사용 오류(종료 2)."""
+ from poc_bundle.mirror import ULID_RE
+
+ if value is None or ULID_RE.fullmatch(value) is not None:
+ return value
+ raise click.BadParameter("evaluation_run_id 는 ULID(26자 Crockford base32)여야 한다")
+
+
+async def _score_all(evaluator, judge_provider, results) -> tuple[list[dict], list[dict]]:
+ """샘플별 채점. 채점 실패는 점수가 아니라 실패로 남긴다 — aggregate() 에는 성공분만 넘긴다."""
+ eval_results: list[dict[str, Any]] = []
+ eval_failures: list[dict[str, Any]] = []
+ for result in results:
+ # 리스트 위치가 아니라 원본 sample_idx 가 정본이다(F6).
+ sample_idx = result["sample_idx"]
+ try:
+ eval_sample = build_eval_sample_from_result(result, reference=result.get("golden"))
+ evaluator.validate_required_fields(eval_sample)
+ eval_result = await evaluator.evaluate(sample=eval_sample, provider=judge_provider)
+ eval_results.append({**eval_result, "sample_idx": sample_idx})
+ except Exception as e:
+ click.secho(f" Sample {sample_idx} eval failed: {e}", fg="yellow")
+ eval_failures.append({"sample_idx": sample_idx, "error": str(e)})
+ return eval_results, eval_failures
+
+
+def _updated_snapshot(
+ run_meta: dict[str, Any], evaluator_config: dict[str, Any] | None, judge_provider: Any
+) -> dict[str, Any] | None:
+ """스냅샷이 있으면 새 평가기로 다시 쓴 스냅샷, 없으면 None(옛 run 에 스냅샷을 새로 쓰지 않는다).
+
+ 채점 **전에** 부른다 — 기록된 스냅샷이 깨졌거나 새 평가기로 지문을 다시 뜰 수 없으면 유료 judge
+ 호출을 하기 전에 멈춘다. `judge_provider` 는 이번 재채점이 실제로 쓰는 것이다 — judge 가 없던 run 을
+ llm_judge 로 다시 채점하면 그 프로바이더 모듈이 `runtime_impl_hash` 에 들어가야 한다(D7).
+ `re_evaluated_at` 은 채점이 끝난 뒤 `_stamp` 가 덮어쓴다.
+ """
+ snapshot = run_meta.get("snapshot")
+ if snapshot is None:
+ return None
+ started = datetime.now(timezone.utc).isoformat()
+ try:
+ # 기록된 스냅샷부터 검사한다 — `config` 가 없는 스냅샷이 맨 KeyError 로 죽지 않게.
+ self_check(snapshot)
+ updated = with_evaluator(
+ snapshot, evaluator_config, re_evaluated_at=started, judge_provider=judge_provider
+ )
+ self_check(updated)
+ except PREP_ERRORS as e:
+ raise click.ClickException(f"cannot update the run snapshot's evaluator: {e}") from None
+ return updated
+
+
+def _stamp(snapshot: dict[str, Any], at: str) -> dict[str, Any]:
+ """`provenance.re_evaluated_at` 을 채점이 끝난 시각으로 — provenance 는 지문 밖이다."""
+ return {**snapshot, "provenance": {**snapshot["provenance"], "re_evaluated_at": at}}
+
+
+def _make_evaluator(evaluator_config: dict[str, Any] | None):
+ """러너·스냅샷과 같은 규칙 — 키가 없을 때만 기본 평가기. 잘못된 설정은 채점 전에 멈춘다."""
+ from solar_eval.evaluators.registry import DEFAULT_EVALUATOR_CONFIG, create_evaluator
+
+ raw = DEFAULT_EVALUATOR_CONFIG if evaluator_config is None else evaluator_config
+ try:
+ return create_evaluator(raw)
+ except ValueError as e:
+ raise click.ClickException(f"invalid evaluator config: {e}") from None
+
+
+async def _evaluate_run(store, run_id, run_meta, task_config, results) -> dict[str, Any]:
+ """채점 → evaluation.json·eval_details·run.json(상태·스냅샷) 저장. 집계를 돌려준다."""
+ evaluator_config = task_config.get("evaluator")
+ evaluator = _make_evaluator(evaluator_config)
+ # judge provider 를 안 넘기면 llm_judge 계열은 전 샘플에서 실패한다 -- 그리고
+ # 그 실패가 예전엔 0점 placeholder 로 덮여 조용히 completed 로 끝났다(F8).
+ _, judge_provider = run_meta_module.make_providers(evaluator_config)
+ snapshot = _updated_snapshot(run_meta, evaluator_config, judge_provider)
+ eval_results, eval_failures = await _score_all(evaluator, judge_provider, results)
+ now = datetime.now(timezone.utc)
+ if snapshot is not None:
+ snapshot = _stamp(snapshot, now.isoformat())
+
+ aggregated = evaluator.aggregate(eval_results)
+ # eval_model = 평가기가 보고하는 judge 모델(없으면 None) — 예전의 "local" 은 거짓이었다(D11).
+ doc = build_evaluation_doc(
+ run_id, aggregated, eval_results, eval_failures, evaluator.judge_model
+ )
+ eval_id = await store.create_evaluation(doc)
+ await store.insert_eval_details(build_eval_detail_docs(eval_id, eval_results, eval_failures))
+ update: dict[str, Any] = {"status": "completed", "completed_at": now}
+ if snapshot is not None:
+ update["snapshot"] = snapshot
+ await store.update_run(run_id, update)
+ return {**aggregated, "overall_score": doc["overall_score"], "failed": len(eval_failures)}
+
+
+def _report(aggregated: Mapping[str, Any], run_dir: Path) -> None:
+ click.echo()
+ click.secho("Evaluation completed!", fg="green", bold=True)
+ overall = aggregated.get("overall_score")
+ if overall is None:
+ # 채점이 한 건도 성공하지 못했다 -- 숫자를 찍으면 "0점"으로 읽힌다.
+ click.secho(" Overall Score: 측정 없음 (전 샘플 채점 실패)", fg="red", bold=True)
+ else:
+ click.echo(f" Overall Score: {overall:.4f}")
+ samples = aggregated.get(REPORT_SAMPLES)
+ if samples is not None:
+ click.echo(f" Samples: {samples} (채택된 run 번들 기준)")
+ failed = aggregated.get("failed", 0)
+ if failed:
+ click.secho(f" 채점 실패: {failed}건 (score: null 로 기록됨)", fg="yellow")
+ scores = aggregated.get("scores", {})
+ if scores:
+ click.echo(f" Scores: {format_scores(scores)}")
+ click.echo(f"\nResults saved to: {run_dir}/")
+
+
+@click.command("eval")
+@click.argument("project")
+@click.option("--run-id", "-r", required=True, help="Run ID to evaluate")
+@click.option("--no-ingest", is_flag=True, help=NO_INGEST_HELP)
+@click.option("--strict-ingest", is_flag=True, help=STRICT_INGEST_HELP)
+@click.option(
+ "--purpose", type=click.Choice(judge_publish.CLI_PURPOSES), default=None, help=PURPOSE_HELP
+)
+@click.option(
+ "--retry-of",
+ "retry_of",
+ default=None,
+ metavar="EVALUATION_RUN_ID",
+ callback=_ulid_option,
+ help=RETRY_OF_HELP,
+)
+@click.pass_context
+def eval_run(
+ ctx: click.Context,
+ project: str,
+ run_id: str,
+ no_ingest: bool,
+ strict_ingest: bool,
+ purpose: str | None,
+ retry_of: str | None,
+) -> None:
+ """Run evaluation only on existing inference results (no new inference)."""
+ cfg = ctx.obj["config"]
+ if cfg.is_remote:
+ raise click.ClickException("Eval is local-mode only")
+ if purpose is not None and retry_of is not None:
+ raise click.UsageError(USAGE_PURPOSE_WITH_RETRY)
+
+ run_dir, store, run_meta = load_run(cfg, project, run_id)
+ results = store.load_existing_results()
+ if not results:
+ raise click.ClickException(f"No inference results found in {run_dir}/results.jsonl")
+ task = run_meta["task"]
+ click.secho(f"Evaluating run: {run_id}", fg="blue", bold=True)
+
+ _, task_config = find_task(cfg, project, task)
+ run_meta_module.preflight_judge_config(task_config)
+ route = decide_route(cfg, project, run_dir, run_meta, task_config)
+ if route.family != FAMILY_JUDGE and (purpose is not None or retry_of is not None):
+ raise click.UsageError(_judge_only_message(route))
+ if route.target is None:
+ click.secho(f" Task: {task}, Samples: {len(results)}", fg="blue")
+ _echo_route(route)
+ # 이전 평가를 미리 지우지 않는다(F3) -- 재평가가 실패해도 옛 점수는 남는다.
+ aggregated = asyncio.run(_evaluate_run(store, run_id, run_meta, task_config, results))
+ _report(aggregated, run_dir)
+ return
+ # 새 경로의 채점 입력은 채택된 run 번들 사본이다 — 원본 행 수(중복 포함)는 찍지 않고 채점 뒤 보고에 싣는다(리뷰 지적 4).
+ click.secho(f" Task: {task}, Samples: 채택된 run 번들 기준(아래 보고)", fg="blue")
+ strict = strict_requested(strict_ingest)
+ judge_options = (purpose, retry_of)
+ scored, code = _publish(route, project, run_dir, task_config, no_ingest, strict, judge_options)
+ if scored is not None:
+ _report(scored.aggregated, scored.path)
+ if code:
+ ctx.exit(code)
+
+
+def _judge_only_message(route: Route) -> str:
+ """judge 전용 플래그를 다른 경로에서 쓴 사용 오류 — 경로 안내(미완료 status·대상 아님 등)가 있으면 이유로 붙인다."""
+ if route.notice is None:
+ return USAGE_JUDGE_ONLY
+ return USAGE_JUDGE_ONLY + USAGE_NOT_JUDGE_REASON.format(notice=route.notice)
+
+
+def _echo_route(route: Route) -> None:
+ """옛 경로 안내 한 줄(standalone 은 없다)."""
+ if route.notice is not None:
+ click.secho(f" ⚠ {route.notice}", fg="blue")
+
+
+def _publish(
+ route: Route,
+ project: str,
+ run_dir: Path,
+ task_config: dict[str, Any],
+ no_ingest: bool,
+ strict: bool,
+ judge_options: tuple[str | None, str | None] = (None, None),
+) -> tuple[Scored | None, int]:
+ """새 경로(`scoring_publish.publish_run_eval`) → (채점, 종료 코드). 채점을 못 했으면 ClickException(종료 1).
+
+ `judge_options` = (`--purpose`, `--retry-of`) — judge 경로만 쓴다. judge 가지는 `_judge_outcome` 이 종료 코드를 정한다
+ (retry 대상 없음이면 채점 없이 종료 0 — 그때 채점은 None).
+ """
+ evaluator_config = task_config.get("evaluator")
+ evaluator = _make_evaluator(evaluator_config) # 잘못된 설정은 잠금·백필 전에 멈춘다
+ _, judge_provider = run_meta_module.make_providers(evaluator_config)
+ purpose, retry_of = judge_options
+ job = EvalJob(
+ project=project,
+ run_dir_name=run_dir.name,
+ evaluator=evaluator,
+ evaluator_config=evaluator_config,
+ judge_provider=judge_provider,
+ score_all=_score_all,
+ no_ingest=no_ingest,
+ strict=strict,
+ family=route.family,
+ purpose=purpose,
+ retry_of=retry_of,
+ )
+ outcome = publish_run_eval(route.target, job)
+ if route.family == FAMILY_JUDGE:
+ return _judge_outcome(outcome, strict)
+ if outcome.scored is None:
+ raise click.ClickException(
+ "채점하지 않았다 — 위 요약의 실패 다리와 되살리기를 본다(run 디렉토리는 그대로다)"
+ )
+ return outcome.scored, exit_code_for(outcome.result, strict)
+
+
+def _judge_outcome(outcome: PublishOutcome, strict: bool) -> tuple[Scored | None, int]:
+ """judge 가지의 종료 코드(§2.5 · 검토 F8). 채점했으면 L6b 규칙(0, strict 이고 실패 다리면 3).
+
+ 채점하지 못했으면: retry 대상 없음 → 안내 한 줄 뒤 (None, 0), 그 밖(발행 사전 거부·번들 없음·잠금 등) → judge 전용 문구로
+ 종료 1. purpose·retry 플래그는 그것이 푸는 실패(`primary_exists`)에만 권한다 — 번들 없음·잠금 실패에는 요약의 안내를 가리킨다.
+ """
+ if outcome.scored is not None:
+ return outcome.scored, exit_code_for(outcome.result, strict)
+ leg = outcome.result.legs.get(LEG_PUBLISH)
+ code = leg.code if leg is not None else None
+ if leg is not None and (leg.status, code) == (
+ STATUS_SKIPPED,
+ judge_publish.RETRY_NOTHING_TO_DO,
+ ):
+ click.secho(f" {JUDGE_RETRY_NOTHING}", fg="blue")
+ return None, 0
+ raise click.ClickException(_judge_not_scored_message(code))
+
+
+def _judge_not_scored_message(code: str | None) -> str:
+ """발행 다리 코드 → 종료 1 문구. 코드 문자열은 `scoring_publish.judge`·`hub_hook.types` 의 상수만 쓴다."""
+ if code == f"{judge_publish.PUBLISH_JUDGE_PREFIX}{judge_publish.JUDGE_PRIMARY_EXISTS}":
+ return JUDGE_NOT_SCORED_PRIMARY_EXISTS
+ if isinstance(code, str) and code.startswith(PUBLISH_NO_BUNDLE_PREFIX):
+ return JUDGE_NOT_SCORED_NO_BUNDLE
+ return JUDGE_NOT_SCORED
diff --git a/solar_eval/cli/commands/run_meta.py b/solar_eval/cli/commands/run_meta.py
new file mode 100644
index 0000000000000000000000000000000000000000..0f4ee78d5e94d1dde078eddd277fdfd02ec50d65
--- /dev/null
+++ b/solar_eval/cli/commands/run_meta.py
@@ -0,0 +1,538 @@
+"""run 시작·resume 이 공유하는 준비 단계와 run.json 메타 조립 (계획 2026-09-24-phase1a D3·D5·D12·D17·D19).
+
+`runs start` 는 run 디렉토리를 만들기 **전에** 이 모듈로 모든 것을 정한다:
+태스크·프롬프트·파이프라인 해석 → 행 선택(`where`·`limit`) → 데이터셋 한 번 읽기 → 파이프라인 생성 →
+스냅샷(run.json `dataset`·`snapshot` 블록, 자기 검증까지). 그래서 0행 선택·라벨 누락·없는 사전·깨진 lock
+같은 오류가 빈 run 디렉토리를 남기지 않고, run.json 이 적은 sha256·스냅샷과 러너가 실제로 도는
+데이터·파이프라인이 **같은 객체**에서 나온다.
+
+resume 은 같은 조립 함수(`build_snapshot`)로 지금 디스크 기준 스냅샷을 다시 떠 설정 드리프트를 막는다(D17).
+
+`poc_bundle` import 는 이 모듈(과 `run_resume`·`run_eval`)과 `core/snapshot/**` 에서만 한다(D18).
+"""
+
+from __future__ import annotations
+
+import subprocess
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+import click
+from poc_bundle.data.errors import DataError
+
+from solar_eval.core.dataset_loader import DatasetLoader, LoadedJsonl
+from solar_eval.core.dataset_prep import (
+ PreparedDataset,
+ build_selection,
+ prepare_dataset,
+ reject_defaults_where,
+)
+from solar_eval.core.pipeline_factory import build_task_pipeline
+from solar_eval.core.row_filter import RowSelection, parse_cli_where
+from solar_eval.core.snapshot import EngineSnapshotInputs, build_engine_snapshot, self_check
+from solar_eval.core.snapshot import environment
+from solar_eval.core.snapshot.dataset_block import describe_dataset
+from solar_eval.evaluators.judge_config import JudgeConfigError, validate_judge_config
+
+#: run 을 시작하기 전에 사람이 읽을 메시지로 바꿔 멈추는 오류 — 행 선택·meta_fields·defaults.where
+#: (ValueError 계열), 없는 데이터셋·지식 자산(FileNotFoundError 계열), 깨진 datasets.lock(DataError).
+PREP_ERRORS: tuple[type[Exception], ...] = (ValueError, FileNotFoundError, DataError)
+
+#: 지문을 사람이 눈으로 대조할 때 보여 주는 앞자리 수 — legacy `prompt_sha` 형식과 맞춘다.
+FINGERPRINT_DISPLAY_CHARS = 12
+
+
+def resolve_prompt(prompts_dir: Path, prompt_name: str) -> Path:
+ """Resolve prompt from prompts/ directory (directory or YAML file).
+
+ Resolution order:
+ 1. prompts/{name}/ (directory TXT format)
+ 2. prompts/{name}.yaml (flat YAML layout)
+ 3. prompts/_library/{name}.yaml (legacy layout)
+ """
+ dir_path = prompts_dir / prompt_name
+ if dir_path.is_dir():
+ return dir_path
+ yaml_path = prompts_dir / f"{prompt_name}.yaml"
+ if yaml_path.exists():
+ return yaml_path
+ legacy_path = prompts_dir / "_library" / f"{prompt_name}.yaml"
+ if legacy_path.exists():
+ return legacy_path
+ raise click.ClickException(
+ f"Prompt '{prompt_name}' not found. Searched:\n"
+ f" {dir_path}/ (directory)\n"
+ f" {yaml_path} (YAML)\n"
+ f" {legacy_path} (legacy)"
+ )
+
+
+def resolve_pipeline_file(project_dir: Path, pipeline_name: str) -> Path:
+ """Resolve pipeline YAML from pipelines/ directory."""
+ path = project_dir / "pipelines" / f"{pipeline_name}.yaml"
+ if not path.exists():
+ raise click.ClickException(f"Pipeline '{pipeline_name}' not found: {path}")
+ return path
+
+
+def compose_pipeline_config(project_dir: Path, pipeline_name: str, overrides: dict) -> dict:
+ """파이프라인을 `extends`/override 까지 해석해 돌려준다.
+
+ 조립 실패(없는 base, 없는 스텝을 가리키는 override 등)는 run 을 시작하기 전에
+ 죽인다 — 효과 없는 override 로 돌아간 run 은 결론을 조용히 오염시킨다.
+ """
+ from solar_eval.core.pipeline_compose import PipelineCompositionError, compose_pipeline
+ from solar_eval.core.project_loader import load_pipeline_file
+
+ pipelines_dir = resolve_pipeline_file(project_dir, pipeline_name).parent
+ try:
+ composed = load_pipeline_file(pipelines_dir, pipeline_name)
+ if overrides:
+ composed = compose_pipeline(composed, overrides=overrides)
+ return composed
+ except (PipelineCompositionError, FileNotFoundError) as e:
+ raise click.ClickException(str(e)) from None
+
+
+def config_source(cfg, project: str) -> str:
+ """run 의 config 출처 — 레포 관리 프로젝트면 레포 상대 경로, 아니면 데이터 홈 절대 경로."""
+ config_dir = cfg.config_dir(project)
+ if cfg.is_repo_managed(project) and cfg.repo_root is not None:
+ return str(config_dir.relative_to(cfg.repo_root))
+ return str(config_dir)
+
+
+def git_commit(repo_root: Path | None) -> str | None:
+ """재현용 도장: 레포 HEAD 커밋. 작업 트리가 더러우면 '-dirty' 를 붙인다 (레포 전체 — D8 불변)."""
+ if repo_root is None:
+ return None
+ try:
+ head = subprocess.run(
+ ["git", "-C", str(repo_root), "rev-parse", "--short", "HEAD"],
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout.strip()
+ status = subprocess.run(
+ ["git", "-C", str(repo_root), "status", "--porcelain"],
+ capture_output=True,
+ text=True,
+ check=True,
+ ).stdout.strip()
+ return f"{head}-dirty" if status else head
+ except (OSError, subprocess.CalledProcessError):
+ return None
+
+
+def find_task(cfg, project: str, task: str) -> tuple[dict[str, Any], dict[str, Any]]:
+ """로드된 프로젝트 설정과 태스크 설정. 없으면 ClickException."""
+ from solar_eval.core.project_loader import load_all_project_configs
+
+ configs = load_all_project_configs(cfg.projects_dir, cfg.config_dirs)
+ project_config = next((c for c in configs if c["name"] == project), None)
+ if not project_config:
+ raise click.ClickException(f"Project not found in {cfg.projects_dir}: {project}")
+ task_config = next((t for t in project_config.get("tasks", []) if t["name"] == task), None)
+ if not task_config:
+ raise click.ClickException(f"Task '{task}' not found in project '{project}'")
+ return project_config, task_config
+
+
+def load_run(cfg, project: str, run_id: str) -> tuple[Path, Any, dict[str, Any]]:
+ """(run 디렉토리, 스토어, run.json). 없으면 ClickException."""
+ from solar_eval.stores import JsonlStore
+
+ run_dir = cfg.artifacts_dir(project) / run_id
+ if not run_dir.exists():
+ raise click.ClickException(f"Run directory not found: {run_dir}")
+ store = JsonlStore(run_dir)
+ run_meta = store.load_run_meta()
+ if not run_meta:
+ raise click.ClickException(f"No run.json found in {run_dir}")
+ if not run_meta.get("task"):
+ raise click.ClickException("Cannot determine task from run.json")
+ return run_dir, store, run_meta
+
+
+def load_prompt(
+ prompt_path: Path,
+ model: str,
+ temperature: float,
+ max_tokens: int,
+ reasoning_effort: str | None,
+) -> tuple[dict[str, Any], str]:
+ """프롬프트 파일을 러너가 받는 dict 로 — (prompt, 포맷 `multi_step`|`single_step`)."""
+ from solar_eval.models.prompt_version import (
+ detect_prompt_format,
+ load_prompt_messages,
+ load_step_prompts,
+ )
+
+ fmt = detect_prompt_format(prompt_path)
+ if fmt == "multi_step":
+ prompt: dict[str, Any] = {"step_prompts": load_step_prompts(prompt_path)}
+ else:
+ prompt = {"messages": load_prompt_messages(prompt_path)}
+ prompt.update({"model": model, "temperature": temperature, "max_tokens": max_tokens})
+ if reasoning_effort:
+ prompt["reasoning_effort"] = reasoning_effort
+ return prompt, fmt
+
+
+def make_providers(evaluator_config: Mapping[str, Any] | None = None) -> tuple[Any, Any]:
+ """(추론 프로바이더, judge 프로바이더). 테스트는 이 함수를 바꿔 끼워 네트워크 없이 돈다.
+
+ `evaluator_config` 는 태스크의 평가기 설정(키가 없으면 None) — judge 인스턴스를 선언(`judge_provider`)으로 고르는
+ 자리다(계획 2026-09-28 L12 §2.8 · D-L12-10). 선언이 없거나 판정 없는 평가기는 오늘처럼 `OpenAIProvider()` —
+ run.json 스냅샷 `provenance.providers.judge` 바이트가 바뀌지 않는다. 추론 프로바이더는 늘 `UpstageProvider()`.
+
+ Raises:
+ JudgeConfigError: 모르는 provider·합성 평가기 안의 provider 충돌.
+ """
+ from solar_eval.evaluators.judge_config import (
+ PROVIDER_OPENAI,
+ PROVIDER_UPSTAGE,
+ judge_provider_name,
+ )
+ from solar_eval.providers import UpstageProvider
+ from solar_eval.providers.openai_provider import OpenAIProvider
+
+ judge_classes = {PROVIDER_UPSTAGE: UpstageProvider, PROVIDER_OPENAI: OpenAIProvider}
+ name = judge_provider_name(evaluator_config) or PROVIDER_OPENAI
+ return UpstageProvider(), judge_classes[name]()
+
+
+def route_inference_provider(provider: Any, step_models: Sequence[str]) -> Any:
+ """스텝 모델에 라우트 접두어(`timely:<모델>`)가 하나라도 있으면 추론 제공자를 `RoutedProvider` 로 감싼다.
+
+ 없으면 그대로 돌려준다 — 라우트 없는 run 의 provenance(`providers.inference` 모듈)가 예전 바이트 그대로여야 한다.
+ 외부 라우트를 써도 되는가는 엔진이 아니라 case 의 데이터 정책(계약)이 정한다 — 2026-10 judge egress 정책 폐지(#353)와 같은 자리.
+ """
+ from solar_eval.providers import INFERENCE_ROUTES, RoutedProvider
+ from solar_eval.providers.routed import route_prefixes
+
+ if not route_prefixes(list(step_models), INFERENCE_ROUTES):
+ return provider
+ return RoutedProvider(provider, {k: cls() for k, cls in INFERENCE_ROUTES.items()})
+
+
+@dataclass(frozen=True)
+class RunPlan:
+ """run 하나가 무엇으로 도는가 — run 디렉토리를 만들기 전에 전부 정해진다."""
+
+ project: str
+ task: str
+ project_config: dict[str, Any]
+ task_config: dict[str, Any]
+ prompt_name: str
+ prompt_path: Path
+ prompt_format: str
+ prompt: dict[str, Any]
+ model: str
+ temperature: float
+ max_tokens: int
+ reasoning_effort: str | None
+ pipeline_name: str
+ pipeline_config: dict[str, Any]
+ pipeline_overrides: dict[str, Any] | None
+ max_workers: int
+ dataset_loader: DatasetLoader
+ prepared: PreparedDataset
+ pipeline: Any
+ provider: Any
+ judge_provider: Any
+
+ @property
+ def selection(self) -> RowSelection | None:
+ return self.prepared.selection
+
+
+def resolve_pipeline(
+ cfg, project: str, task_config: dict[str, Any], pipeline_name: str | None, overrides: dict
+) -> tuple[dict[str, Any], str, dict[str, Any]]:
+ """(파이프라인을 얹은 task_config, 실효 파이프라인 이름, 해석된 파이프라인 dict).
+
+ `-P` 가 있으면 config 정본의 pipelines/ 에서 `extends`·`--set` 까지 해석하고, 없으면
+ project.yaml 기본값(project_loader 가 이미 해석)을 쓴다. config 는 레포 관리 프로젝트면 git
+ 쪽이 정본이다. `extends` 상속과 `--set` override 는 여기서 전부 해석돼, 실행 엔진에는 완성된
+ steps 목록만 넘어간다.
+ """
+ if pipeline_name:
+ pipeline_config = compose_pipeline_config(cfg.config_dir(project), pipeline_name, overrides)
+ return {**task_config, "pipeline_config": pipeline_config}, pipeline_name, pipeline_config
+ if overrides:
+ raise click.ClickException("--set requires an explicit --pipeline/-P")
+ pc = task_config.get("pipeline_config", {})
+ effective = pc.get("name", "default") if isinstance(pc, dict) else str(pc)
+ return task_config, effective, pc if isinstance(pc, dict) else {}
+
+
+class _CheckedReader:
+ """`prepare_dataset` 에 넘기는 얇은 대리자 — 파일을 읽은 **직후**, 행을 고르기 **전에** `on_read` 를 부른다.
+
+ resume 의 데이터셋 sha 가드(D17 순서의 첫째)가 선택·라벨 검사보다 먼저 걸리게 하려는 것이다 —
+ 데이터셋이 바뀌어 선택이 0행이 되면 원인은 "where 가 0행" 이 아니라 "데이터셋이 바뀌었다" 다.
+ 바이트는 여전히 한 번만 읽는다(D16).
+ """
+
+ def __init__(self, loader: DatasetLoader, on_read: Callable[[LoadedJsonl], None]) -> None:
+ self._loader = loader
+ self._on_read = on_read
+
+ def read_jsonl(self, repo: str, path: str, source: str) -> LoadedJsonl:
+ loaded = self._loader.read_jsonl(repo, path, source=source)
+ self._on_read(loaded)
+ return loaded
+
+
+def _step_models(pipeline: Any) -> list[str]:
+ """파이프라인 스텝들이 명시한 모델 문자열 — 라우트 접두어 검사용(없는 스텝은 run 의 `-m` 모델 = Upstage)."""
+ return [
+ str(step.config["model"])
+ for step in getattr(pipeline, "steps", [])
+ if isinstance(getattr(step, "config", None), dict) and step.config.get("model")
+ ]
+
+
+def assemble_plan(
+ cfg,
+ selection: RowSelection | None,
+ *,
+ on_read: Callable[[LoadedJsonl], None] | None = None,
+ **fields: Any,
+) -> RunPlan:
+ """데이터셋을 한 번 읽어 고르고, 러너가 돌 파이프라인 인스턴스를 만든다(D6·D16).
+
+ `fields` 는 `RunPlan` 의 나머지 필드(project·task_config·prompt 등)다. `on_read` 는 데이터셋을
+ 읽은 직후·선택 전에 불린다(resume 의 sha 가드 — 거기서 난 ClickException 은 그대로 올라간다).
+
+ judge 설정 오류는 데이터셋 읽기·파이프라인·provider 생성보다 먼저 확인한다.
+ `runs start`·`quick-run`·`runs batch`(프롬프트마다)·`runs resume` 이 모두 이 자리를 지난다.
+
+ Raises:
+ click.ClickException: judge 설정·선택·meta_fields·데이터셋·파이프라인 생성 오류(run 시작 전).
+ """
+ preflight_judge_config(fields["task_config"])
+ loader = DatasetLoader(base_dir=cfg.projects_dir)
+ reader: Any = loader if on_read is None else _CheckedReader(loader, on_read)
+ try:
+ prepared = prepare_dataset(
+ reader, fields["project_config"], fields["task_config"], selection
+ )
+ pipeline = build_task_pipeline(
+ fields["project_config"], fields["task_config"], fields["prompt"], loader
+ )
+ except PREP_ERRORS as e:
+ raise click.ClickException(str(e)) from None
+ provider, judge_provider = make_providers(fields["task_config"].get("evaluator"))
+ provider = route_inference_provider(provider, _step_models(pipeline))
+ return RunPlan(
+ dataset_loader=loader,
+ prepared=prepared,
+ pipeline=pipeline,
+ provider=provider,
+ judge_provider=judge_provider,
+ **fields,
+ )
+
+
+def preflight_judge_config(task_config: Mapping[str, Any]) -> None:
+ """Judge 설정 오류를 CLI 오류로 바꾸되 provider 는 만들지 않는다."""
+ try:
+ validate_judge_config(task_config)
+ except JudgeConfigError as exc:
+ raise click.ClickException(str(exc)) from None
+
+
+def start_options(
+ project_config: dict[str, Any],
+ task_config: dict[str, Any],
+ set_options: tuple[str, ...],
+ where: tuple[str, ...],
+ limit: int | None,
+) -> tuple[dict[str, Any], RowSelection]:
+ """(`--set` override, 행 선택) — 프롬프트와 무관한 시작 검사. `runs batch` 는 루프 전에 한 번 부른다.
+
+ Raises:
+ click.ClickException: `defaults.where`·where 문법·`--set` 문법 오류.
+ """
+ from solar_eval.core.pipeline_compose import PipelineCompositionError, parse_set_option
+
+ try:
+ reject_defaults_where(project_config)
+ overrides = parse_set_option(set_options)
+ selection = build_selection(task_config, parse_cli_where(where), limit)
+ except (PipelineCompositionError, *PREP_ERRORS) as e:
+ raise click.ClickException(str(e)) from None
+ return overrides, selection
+
+
+def plan_run(
+ cfg,
+ *,
+ project: str,
+ task: str,
+ prompt_name: str,
+ model: str,
+ pipeline_name: str | None,
+ reasoning_effort: str | None,
+ temperature: float,
+ max_tokens: int,
+ max_workers: int,
+ limit: int | None,
+ set_options: tuple[str, ...] = (),
+ where: tuple[str, ...] = (),
+) -> RunPlan:
+ """`runs start` 의 준비 단계 전부 — `reject_defaults_where` → 선택 → `prepare_dataset` →
+ `build_task_pipeline` (계획 §9 G2 순서). 모든 오류는 run 디렉토리를 만들기 전에 난다.
+ """
+ project_config, task_config = find_task(cfg, project, task)
+ overrides, selection = start_options(project_config, task_config, set_options, where, limit)
+ prompt_path = resolve_prompt(cfg.prompts_dir(project), prompt_name)
+ prompt, fmt = load_prompt(prompt_path, model, temperature, max_tokens, reasoning_effort)
+ task_config, effective, pipeline_config = resolve_pipeline(
+ cfg, project, task_config, pipeline_name, overrides
+ )
+ return assemble_plan(
+ cfg,
+ selection,
+ project=project,
+ task=task,
+ project_config=project_config,
+ task_config=task_config,
+ prompt_name=prompt_name,
+ prompt_path=prompt_path,
+ prompt_format=fmt,
+ prompt=prompt,
+ model=model,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ reasoning_effort=reasoning_effort,
+ pipeline_name=effective,
+ pipeline_config=pipeline_config,
+ pipeline_overrides=overrides or None,
+ max_workers=max_workers,
+ )
+
+
+def dataset_block(cfg, plan: RunPlan) -> dict[str, Any]:
+ """run.json `dataset` 블록 — 읽은 바이트의 sha256·행 수·정규화된 선택·lock 대조(S6)."""
+ prepared = plan.prepared
+ selection = plan.selection or RowSelection()
+ try:
+ return describe_dataset(
+ sha256=prepared.sha256,
+ rows_total=prepared.rows_total,
+ rows_selected=prepared.rows_selected,
+ selection=selection.to_json(),
+ meta_fields=prepared.meta_fields,
+ task_config=plan.task_config,
+ loader_cfg=plan.project_config.get("dataset") or {},
+ config_dir=cfg.config_dir(plan.project),
+ project_name=plan.project,
+ repo_root=environment.engine_repo(),
+ )
+ except PREP_ERRORS as e:
+ raise click.ClickException(f"dataset lock check failed: {e}") from None
+
+
+def build_snapshot(
+ cfg,
+ plan: RunPlan,
+ dataset: dict[str, Any] | None,
+ *,
+ source: str | None,
+ commit: str | None,
+) -> dict[str, Any]:
+ """run.json `snapshot` 블록(engine-snapshot/v1). start 와 resume 이 같은 함수를 쓴다(D17).
+
+ Raises:
+ click.ClickException: 선언한 자원이 없거나 스냅샷이 자기 검증(D5)을 통과하지 못할 때.
+ """
+ inputs = EngineSnapshotInputs(
+ model=plan.model,
+ temperature=plan.temperature,
+ max_tokens=plan.max_tokens,
+ reasoning_effort=plan.reasoning_effort,
+ pipeline_type=plan.task_config.get("pipeline", "single_step"),
+ input_fields=plan.task_config.get("input_fields", []),
+ resolved_pipeline=plan.pipeline_config,
+ prompt_path=plan.prompt_path,
+ pipeline=plan.pipeline,
+ evaluator_config=plan.task_config.get("evaluator"),
+ inference_provider=plan.provider,
+ judge_provider=plan.judge_provider,
+ prompt_name=plan.prompt_name,
+ pipeline_name=plan.pipeline_name,
+ config_source=source,
+ git_commit=commit,
+ pipeline_overrides=plan.pipeline_overrides,
+ max_workers=plan.max_workers,
+ config_dir=cfg.config_dir(plan.project),
+ repo_managed=cfg.is_repo_managed(plan.project),
+ dataset=dataset,
+ )
+ try:
+ snapshot = build_engine_snapshot(inputs)
+ self_check(snapshot)
+ except PREP_ERRORS as e:
+ raise click.ClickException(f"cannot snapshot this run's config: {e}") from None
+ return snapshot
+
+
+def auto_note(note: str | None, plan: RunPlan) -> str | None:
+ """`--where` run 의 note 자동 기입(D19) — Experiment Hub 목록에서 전체셋 반복과 사람이 구분하게.
+
+ note 가 비면 `where tags=yul-ryul · 12/159 rows`, 사용자가 주면 그 뒤에 ` [where …]`.
+ where 가 없는 run 은 note 를 그대로 둔다(다른 run 의 run.json 바이트 불변).
+ """
+ selection = plan.selection
+ if selection is None or selection.where is None:
+ return note
+ prepared = plan.prepared
+ label = (
+ f"where {selection.where.describe()} · {prepared.rows_selected}/{prepared.rows_total} rows"
+ )
+ return f"{note} [{label}]" if note else label
+
+
+def build_run_meta(cfg, plan: RunPlan, note: str | None) -> dict[str, Any]:
+ """run.json 초기 내용 — legacy 키(값 규칙 불변, D12) 뒤에 `dataset`·`snapshot`(계획 §3.2 순서)."""
+ from solar_eval.core.fingerprint import pipeline_fingerprint, prompt_fingerprint
+
+ source = config_source(cfg, plan.project)
+ commit = git_commit(cfg.repo_root) if cfg.is_repo_managed(plan.project) else None
+ dataset = dataset_block(cfg, plan)
+ return {
+ "task": plan.task,
+ "model": plan.model,
+ "prompt": plan.prompt_name,
+ "pipeline": plan.pipeline_name,
+ "temperature": plan.temperature,
+ "max_tokens": plan.max_tokens,
+ "reasoning_effort": plan.reasoning_effort,
+ # 왜 돌렸는지 — 예전에는 별도 랩노트(experiments/*.yaml)가 담았지만 run 과 분리돼 있어
+ # 참조가 끊어지고 썩었다. run 과 같은 파일에 두면 끊어질 수가 없다. where run 은 자동 기입(D19).
+ "note": auto_note(note, plan),
+ # provenance: 이 run 의 config(프롬프트·파이프라인)가 어디서 왔는지 (W&B/DVC 식 도장)
+ "config_source": source,
+ "git_commit": commit,
+ # 내용 지문 — 이름이 아니라 내용으로 설정을 식별한다. 이름만 다른 사본,
+ # `extends` 로 조립한 것, `--set` 으로 즉석에서 만든 것이 모두 같은 값이면
+ # 같은 지문을 갖는다 (core/fingerprint.py). legacy 값 규칙 그대로(D12) — 새 지문은 snapshot 에.
+ "prompt_sha": prompt_fingerprint(plan.prompt_path),
+ "pipeline_sha": pipeline_fingerprint(plan.pipeline_config),
+ # 파일 없이 돌린 실험이면 무엇을 바꿨는지가 여기 남는다.
+ "pipeline_overrides": plan.pipeline_overrides,
+ "dataset": dataset,
+ "snapshot": build_snapshot(cfg, plan, dataset, source=source, commit=commit),
+ }
+
+
+def short_fingerprint(value: str | None) -> str:
+ """64-hex 지문을 `sha256:<12>` 로 — legacy `prompt_sha` 와 같은 모양으로 눈 대조."""
+ if not value:
+ return "-"
+ return f"sha256:{value[:FINGERPRINT_DISPLAY_CHARS]}"
diff --git a/solar_eval/cli/commands/run_progress.py b/solar_eval/cli/commands/run_progress.py
new file mode 100644
index 0000000000000000000000000000000000000000..14a5acffb727470abec44c6ee2b7b907630ee91b
--- /dev/null
+++ b/solar_eval/cli/commands/run_progress.py
@@ -0,0 +1,230 @@
+"""run 실행 + 진행 표시 (Rich progress bar) — `runs start`·`runs resume` 이 공유한다.
+
+`runs.py` 가 800줄 상한을 넘어 떼어 냈다(계획 2026-09-24-phase1a E1). 동작은 옮기기 전과 같고,
+러너에 선택·준비된 데이터셋·파이프라인 인스턴스를 넘기는 인자만 더했다(D3·D6).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import Any
+
+import click
+from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
+
+from solar_eval.cli.formatters import format_scores
+
+#: 진행 표시 위로 끌어올려 찍는 로거들.
+_LOG_NAMES = (
+ "solar_eval.pipelines.pipeline",
+ "solar_eval.pipelines.steps.llm",
+ "solar_eval.core.runner",
+ "solar_eval.providers.upstage",
+)
+
+
+class _ProgressView:
+ """Rich 진행 표시(가능할 때)와 평문 출력 사이를 가르는 작은 상태 묶음."""
+
+ def __init__(self) -> None:
+ from rich.console import Console
+ from rich.live import Live
+
+ self.bar = Progress(
+ SpinnerColumn(),
+ TextColumn("[progress.description]{task.description}"),
+ BarColumn(),
+ TextColumn("{task.completed}/{task.total}"),
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
+ TimeElapsedColumn(),
+ )
+ self.live: Live | None = None
+ self.use_rich = True
+ self.inference_task_id: Any = None
+ self.eval_task_id: Any = None
+ self._handler: logging.Handler | None = None
+ self._closed = False
+ try:
+ self.live = Live(self.bar, console=Console(stderr=True), refresh_per_second=8)
+ self.live.start()
+ except Exception as e: # 터미널이 Rich 를 못 받으면 평문으로 — 원인은 한 줄 남긴다.
+ click.secho(f" (진행 표시 없이 평문으로 출력합니다: {e})", fg="yellow")
+ self.use_rich = False
+ if self.use_rich:
+ self._redirect_logs()
+
+ def _redirect_logs(self) -> None:
+ """파이프라인·러너 로그를 진행 표시 위로 올린다."""
+ from rich.text import Text
+
+ live = self.live
+
+ class _LiveLogHandler(logging.Handler):
+ def emit(self, record):
+ if live:
+ live.console.print(Text(record.getMessage(), style="dim"))
+
+ self._handler = _LiveLogHandler()
+ for name in _LOG_NAMES:
+ lg = logging.getLogger(name)
+ lg.addHandler(self._handler)
+ lg.propagate = False
+ logging.getLogger("httpx").setLevel(logging.WARNING)
+
+ def cleanup(self) -> None:
+ """진행 표시를 닫고 로거를 되돌린다. 여러 번 불러도 된다(끝 이벤트 + finally)."""
+ if self.use_rich and self.live is not None and not self._closed:
+ self._closed = True
+ self.live.stop()
+ for name in _LOG_NAMES:
+ logging.getLogger(name).removeHandler(self._handler)
+ logging.getLogger(name).propagate = True
+
+
+def _on_inference(view: _ProgressView, etype: str, event: dict[str, Any]) -> None:
+ if etype == "dataset_loaded":
+ if view.use_rich:
+ view.inference_task_id = view.bar.add_task("Inference", total=event["total"])
+ else:
+ click.secho(f" Dataset loaded: {event['total']} samples", fg="blue")
+ elif etype == "inference_progress":
+ if view.use_rich and view.inference_task_id is not None:
+ view.bar.update(view.inference_task_id, completed=event["completed"])
+ elif not view.use_rich:
+ c, t = event["completed"], event["total"]
+ if c % 10 == 0 or c == t:
+ click.echo(f" Inference: {c}/{t}")
+ elif etype == "inference_complete":
+ if view.use_rich and view.inference_task_id is not None:
+ view.bar.update(view.inference_task_id, completed=event["total"])
+ else:
+ click.secho(f" Inference complete: {event['total']} samples", fg="green")
+
+
+def _on_evaluation(view: _ProgressView, etype: str, event: dict[str, Any]) -> None:
+ if etype == "evaluation_start":
+ if view.use_rich:
+ view.eval_task_id = view.bar.add_task("Evaluation", total=event.get("total", 0) or 1)
+ else:
+ click.secho(" Starting evaluation...", fg="blue")
+ elif etype == "evaluation_progress" and view.use_rich and view.eval_task_id is not None:
+ view.bar.update(view.eval_task_id, completed=event["completed"])
+
+
+def _on_finish(view: _ProgressView, etype: str, event: dict[str, Any]) -> None:
+ if etype == "done":
+ if view.use_rich and view.eval_task_id is not None:
+ view.bar.update(view.eval_task_id, completed=view.bar.tasks[view.eval_task_id].total)
+ view.cleanup()
+ click.echo()
+ click.secho("Run completed!", fg="green", bold=True)
+ overall = event.get("overall_score")
+ if overall is not None:
+ click.echo(f" Overall Score: {overall:.4f}")
+ scores = event.get("scores", {})
+ if scores:
+ click.echo(f" Scores: {format_scores(scores)}")
+ elif etype == "error":
+ view.cleanup()
+ click.echo()
+ click.secho(f" Run failed: {event.get('error')}", fg="red", bold=True)
+
+
+def _run_with_progress(
+ runner,
+ run_id,
+ project_config,
+ task_config,
+ prompt,
+ max_workers,
+ limit,
+ completed_results: list[dict[str, Any]] | None = None,
+ selection=None,
+ prepared=None,
+ pipeline=None,
+):
+ """Execute a run with Rich progress bar display.
+
+ `completed_results` 는 resume 이 이미 끝난 샘플을 다시 돌리지 않게 하는 다리다 --
+ 여기서 안 넘기면 runner 가 파라미터를 갖고 있어도 소용이 없다(F2). `selection`·`prepared`·
+ `pipeline` 은 CLI 가 run 시작 전에 정한 행 선택·데이터셋·파이프라인 인스턴스다(D3·D6).
+
+ `execute_run` 은 치명적 실패에서 raise 하므로 이 함수도 raise 한다 --
+ 일부러 감싸지 않는다. "error" 이벤트가 이미 사람이 읽을 메시지를 찍었고,
+ 여기서 또 감싸면 같은 내용이 두 번 나온다.
+ """
+ view = _ProgressView()
+
+ async def on_progress(event):
+ etype = event.get("type")
+ _on_inference(view, etype, event)
+ _on_evaluation(view, etype, event)
+ _on_finish(view, etype, event)
+
+ try:
+ asyncio.run(
+ runner.execute_run(
+ run_id=run_id,
+ project_config=project_config,
+ task_config=task_config,
+ prompt=prompt,
+ on_progress=on_progress,
+ max_workers=max_workers,
+ limit=limit,
+ completed_results=completed_results,
+ selection=selection,
+ prepared=prepared,
+ pipeline=pipeline,
+ )
+ )
+ finally:
+ # 끝 이벤트 없이 끝나도(러너가 이벤트 전에 죽음) 터미널·로거를 원래대로 돌린다.
+ view.cleanup()
+
+
+def execute_plan(
+ plan, run_id: str, store, completed_results: list[dict[str, Any]] | None = None
+) -> None:
+ """준비된 `RunPlan` 을 러너로 돌린다 — `runs start`·`runs resume` 공용.
+
+ 러너는 plan 이 쥔 **같은 인스턴스**(준비된 데이터셋·파이프라인·프로바이더)로 돈다(D6·D16).
+ `limit` 은 `prepared.selection` 안에 있으므로 따로 넘기지 않는다.
+ """
+ from solar_eval.core.runner import BatchRunner
+
+ runner = BatchRunner(
+ store=store,
+ inference_provider=plan.provider,
+ judge_provider=plan.judge_provider,
+ dataset_loader=plan.dataset_loader,
+ )
+ _run_with_progress(
+ runner,
+ run_id,
+ plan.project_config,
+ plan.task_config,
+ plan.prompt,
+ plan.max_workers,
+ limit=None,
+ completed_results=completed_results,
+ prepared=plan.prepared,
+ pipeline=plan.pipeline,
+ )
+ # 러너가 raise 하면(FAILED) 여기 오지 않는다. 훅은 호출자가 이 함수 **뒤에** 부른다 — 순서 그대로.
+ _record_failure_keys(plan, run_id, store)
+
+
+def _record_failure_keys(plan, run_id: str, store) -> None:
+ """실패 샘플 지문을 run.json 에 더한다(LP5 D-LP5-4). 못 해도 run 은 그대로 끝난다.
+
+ 실패 지문은 없어도 되는 정보다 — 없으면 wrap 이 `failures.jsonl` 을 생략(모름)한다. 그래서 도우미의
+ 예외는 경고 한 줄(예외 **타입만** — 메시지는 입력 조각을 담을 수 있다)로 드러내고 run 은 계속 끝낸다.
+ `poc_bundle` 은 여기서 처음 import 한다 — CLI 를 띄우는 것만으로 번들 계층을 싣지 않는다.
+ """
+ from solar_eval.cli.commands import failure_keys
+
+ try:
+ failure_keys.record_failure_keys(store, run_id, plan.prepared, plan.task_config)
+ except Exception as exc: # 실패 지문은 선택 정보 — run 을 실패로 만들지 않는다(계획 §2.4)
+ click.secho(f" failure keys not recorded ({type(exc).__name__})", fg="yellow")
diff --git a/solar_eval/cli/commands/run_resume.py b/solar_eval/cli/commands/run_resume.py
new file mode 100644
index 0000000000000000000000000000000000000000..0733e8d661cbc7109386b7cd9c2d893a8918381c
--- /dev/null
+++ b/solar_eval/cli/commands/run_resume.py
@@ -0,0 +1,298 @@
+"""`solar-eval runs resume` — 끊긴 run 을 이어 돌린다 (계획 2026-09-24-phase1a D3·D17).
+
+`runs.py` 에서 떼어 냈다(E1). resume 은 **run.json 이 기록한 것**으로 이어 간다 — 지금의 project.yaml·CLI 에서
+선택을 다시 만들지 않는다. 러너 호출 **전에** 네 가드를 차례로 건다(실패해도 results 가 늘지 않는다):
+
+1. 데이터셋 sha 가드 — 읽은 파일 sha256 이 run.json `dataset.sha256` 과 다르면 멈춘다. 파일을 읽은
+ 직후·행을 고르기 전에 건다 — 바뀐 데이터셋이 선택·라벨 오류로 잘못 보고되지 않게.
+2. 설정 드리프트 가드 — 시작 때와 같은 조립 함수로 지금 디스크 기준 스냅샷을 다시 떠
+ `config_fingerprint` 가 다르면 멈춘다. 우회 플래그는 없다 — 두 설정이 섞인 results 가 한 스냅샷 아래
+ 묶이는 것이 이 가드가 막는 거짓 그 자체다(검토 F2·M4).
+3. 선택 밖 idx 가드 — 이미 끝난 `sample_idx` 가 선택 밖이면 멈춘다.
+4. 정답 키 가드 — 끝난 행의 저장된 `golden`·`input` 이 지금 태스크 설정(`golden_field`·`golden_fields`·
+ `input_fields`)으로 뽑은 값과 다르면 멈춘다. `golden_field` 는 지문에도 sha 에도 들어가지 않기 때문이다.
+
+config 가 같으면 `snapshot.provenance.resumes` 에 이번 엔진 상태를 덧붙이고(dirty OR, 트리 해시가 바뀌면
+`resumed_with_engine_change`) 이어 간다. `dataset`·`snapshot` 블록이 없는 옛 run 은 오늘처럼 전체 행으로 이어
+가고 경고만 남긴다 — 시작 시점 설정을 모르므로 스냅샷을 새로 쓰지 않는다.
+
+끝나면 `runs start` 와 같은 run 종료 훅(`hub_hook.run_hook`, command=`resume`)을 부른다(계획 2026-09-27 L6a §2.2) —
+`--no-ingest` 는 훅 전체를 건너뛰고, `--strict-ingest`(또는 `SOLAR_EVAL_STRICT_INGEST=1`)면 실패 다리가 있을 때 종료 3.
+스모크 판정은 run.json 이 기록한 선택(`dataset.selection.limit`)으로 한다(D-L6-10).
+"""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+import click
+
+from solar_eval.cli.commands.run_meta import (
+ PREP_ERRORS,
+ RunPlan,
+ assemble_plan,
+ build_snapshot,
+ compose_pipeline_config,
+ config_source,
+ find_task,
+ git_commit,
+ load_prompt,
+ load_run,
+ resolve_prompt,
+)
+from solar_eval.cli.commands.run_progress import execute_plan
+from solar_eval.core.dataset_prep import check_resume_indices
+from solar_eval.core.row_filter import RowSelection, format_indices
+from solar_eval.core.run_records import extract_golden, extract_input
+from solar_eval.core.snapshot import config_diff_keys
+from solar_eval.core.snapshot.reproducibility import merge_resume
+from solar_eval.hub_hook import HookOptions, exit_code_for, run_hook
+from solar_eval.hub_hook.optin import strict_requested
+from solar_eval.hub_hook.target import is_smoke_run
+
+
+def _recorded_selection(run_meta: dict[str, Any]) -> RowSelection | None:
+ """run.json 이 기록한 선택. `dataset` 블록이 없는 옛 run 은 None(전체 — 오늘 동작)."""
+ dataset = run_meta.get("dataset")
+ if dataset is None:
+ return None
+ try:
+ return RowSelection.from_json(dataset.get("selection") or {"where": None, "limit": None})
+ except PREP_ERRORS as e:
+ raise click.ClickException(f"run.json dataset.selection is invalid: {e}") from None
+
+
+def _resume_task_config(run_meta: dict[str, Any], task_config: dict[str, Any]) -> dict[str, Any]:
+ """run.json 이 기록한 `meta_fields` 로 맞춘다 — 한 run 의 results 행 모양이 섞이지 않게.
+
+ `dataset` 블록이 없는 옛 run 은 `meta_fields` 가 없던 시절 run 이라 meta 를 싣지 않는다.
+ """
+ dataset = run_meta.get("dataset")
+ recorded = dataset.get("meta_fields") if dataset is not None else None
+ base = {k: v for k, v in task_config.items() if k != "meta_fields"}
+ return {**base, "meta_fields": list(recorded)} if recorded else base
+
+
+def _resume_prompt(cfg, project: str, run_meta: dict[str, Any]) -> dict[str, Any]:
+ """run.json 의 프롬프트 이름·모델·파라미터로 러너 prompt 를 다시 만든다(현재 파일 기준)."""
+ prompt_name = run_meta.get("prompt")
+ if not prompt_name:
+ raise click.ClickException(
+ "Cannot determine prompt from run.json. Only runs created with the new CLI can be resumed."
+ )
+ model = run_meta.get("model", "solar-pro2")
+ temperature = run_meta.get("temperature", 0.0)
+ max_tokens = run_meta.get("max_tokens", 4000)
+ effort = run_meta.get("reasoning_effort")
+ prompt_path = resolve_prompt(cfg.prompts_dir(project), prompt_name)
+ prompt, fmt = load_prompt(prompt_path, model, temperature, max_tokens, effort)
+ return {
+ "prompt_name": prompt_name,
+ "prompt_path": prompt_path,
+ "prompt_format": fmt,
+ "prompt": prompt,
+ "model": model,
+ "temperature": temperature,
+ "max_tokens": max_tokens,
+ "reasoning_effort": effort,
+ }
+
+
+def _resume_pipeline(
+ cfg, project: str, run_meta: dict[str, Any], task_config: dict[str, Any]
+) -> dict[str, Any]:
+ """run.json 의 파이프라인 이름(+`--set` overrides)으로 파이프라인을 다시 해석한다."""
+ overrides = run_meta.get("pipeline_overrides") or {}
+ pipeline_name = run_meta.get("pipeline") or "default"
+ if pipeline_name != "default":
+ # 원래 run 이 `--set` 으로 바꿔 돌린 것이면 그 override 까지 복원해야 같은 설정이다.
+ pipeline_config = compose_pipeline_config(cfg.config_dir(project), pipeline_name, overrides)
+ task_config = {**task_config, "pipeline_config": pipeline_config}
+ else:
+ pc = task_config.get("pipeline_config")
+ pipeline_config = pc if isinstance(pc, dict) else {}
+ return {
+ "task_config": task_config,
+ "pipeline_name": pipeline_name,
+ "pipeline_config": pipeline_config,
+ "pipeline_overrides": overrides or None,
+ }
+
+
+def _plan_resume(
+ cfg, project: str, run_meta: dict[str, Any], max_workers: int
+) -> tuple[RunPlan, RowSelection | None]:
+ """run.json 의 모델·프롬프트·파이프라인(+overrides)·선택으로 실행 계획을 다시 만든다.
+
+ 데이터셋 sha 가드는 파일을 읽은 직후·행을 고르기 전에 건다(`on_read`) — 데이터셋이 바뀌어
+ 선택이 0행이 되거나 라벨이 빠졌을 때 "where 가 틀렸다" 가 아니라 원인을 알린다(D17 순서).
+ """
+ project_config, task_config = find_task(cfg, project, run_meta["task"])
+ task_config = _resume_task_config(run_meta, task_config)
+ selection = _recorded_selection(run_meta)
+ plan = assemble_plan(
+ cfg,
+ selection,
+ on_read=lambda loaded: _guard_dataset_sha(loaded.sha256, run_meta),
+ project=project,
+ task=run_meta["task"],
+ project_config=project_config,
+ max_workers=max_workers,
+ **_resume_prompt(cfg, project, run_meta),
+ **_resume_pipeline(cfg, project, run_meta, task_config),
+ )
+ return plan, selection
+
+
+def _guard_dataset_sha(current: str, run_meta: dict[str, Any]) -> None:
+ recorded = (run_meta.get("dataset") or {}).get("sha256")
+ if recorded is not None and recorded != current:
+ raise click.ClickException(
+ "the dataset changed since the run started "
+ f"(sha256 {recorded[:12]}… → {current[:12]}…) — start a new run"
+ )
+
+
+def _guard_config_drift(cfg, plan: RunPlan, run_meta: dict[str, Any]) -> dict[str, Any] | None:
+ """지금 디스크 기준 스냅샷을 다시 떠 지문을 대조한다. 같으면 resume 을 반영한 새 스냅샷(D17)."""
+ snapshot = run_meta.get("snapshot")
+ if snapshot is None:
+ click.secho(
+ " ⚠ run.json 에 snapshot 이 없는 옛 run 입니다 — 설정 드리프트를 검사하지 못하고 이어 갑니다.",
+ fg="yellow",
+ )
+ return None
+ current = build_snapshot(
+ cfg,
+ plan,
+ run_meta.get("dataset"),
+ source=config_source(cfg, plan.project),
+ commit=git_commit(cfg.repo_root) if cfg.is_repo_managed(plan.project) else None,
+ )
+ if current["config_fingerprint"] != snapshot.get("config_fingerprint"):
+ changed = config_diff_keys(snapshot.get("config") or {}, current["config"])
+ raise click.ClickException(
+ f"the run's config changed since it started: {', '.join(changed) or '?'} "
+ "— start a new run (mixing two configs in one run is not allowed)"
+ )
+ at = datetime.now(timezone.utc).isoformat()
+ return merge_resume(snapshot, current["provenance"]["engine"], at)
+
+
+def _guard_selection(plan: RunPlan, completed_indices: set[int]) -> None:
+ try:
+ check_resume_indices(plan.prepared, completed_indices)
+ except PREP_ERRORS as e:
+ raise click.ClickException(str(e)) from None
+
+
+def _drifted_rows(plan: RunPlan, completed_results: list[dict[str, Any]]) -> list[int]:
+ """이미 끝난 행 중 저장된 `input`·`golden` 이 지금 태스크 설정으로 뽑은 값과 다른 sample_idx."""
+ records = plan.prepared.loaded.records
+ input_fields = plan.task_config.get("input_fields", [])
+ drifted = set()
+ for result in completed_results:
+ row = records[result["sample_idx"]]
+ if "golden" in result and result["golden"] != extract_golden(row, plan.task_config):
+ drifted.add(result["sample_idx"])
+ if "input" in result and result["input"] != extract_input(row, input_fields):
+ drifted.add(result["sample_idx"])
+ return sorted(drifted)
+
+
+def _guard_answer_key(plan: RunPlan, completed_results: list[dict[str, Any]]) -> None:
+ """정답 키(`golden_field`·`golden_fields`)·입력 필드가 run 시작 뒤 바뀌었으면 멈춘다(D17 보강).
+
+ `golden_field` 는 스냅샷 config 에도 run.json `dataset` 블록에도 없어 지문·sha 가드를 지나간다 —
+ 그대로 이어 가면 한 run 에 두 정답 키가 섞이고, 끝난 행까지 새 정답으로 채점돼 results 의 `golden`
+ 과 점수가 어긋난다. 그래서 저장된 값과 지금 뽑은 값을 행마다 직접 대조한다(옛 run 도 같은 규칙).
+ 선택 밖 idx 가드 뒤에 부른다 — 끝난 idx 가 모두 파일 안에 있다.
+ """
+ drifted = _drifted_rows(plan, completed_results)
+ if drifted:
+ raise click.ClickException(
+ "the task's answer key or input fields changed since the run started "
+ f"(stored golden/input differs for {format_indices(drifted)}) — start a new run"
+ )
+
+
+@click.command("resume")
+@click.argument("project")
+@click.option("--run-id", "-r", required=True, help="Run ID to resume")
+@click.option("--max-workers", "-w", default=5, type=int, help="Max parallel workers")
+@click.option(
+ "--no-ingest",
+ is_flag=True,
+ help="run 이 끝난 뒤 hub 훅(번들·hub 적재)을 통째로 건너뛴다 — 되살리는 명령을 한 줄로 알려 준다",
+)
+@click.option(
+ "--strict-ingest",
+ is_flag=True,
+ help="hub 훅 다리가 하나라도 실패하면 종료 코드 3 (기본 0). SOLAR_EVAL_STRICT_INGEST=1 과 같다",
+)
+@click.pass_context
+def resume_run(
+ ctx: click.Context,
+ project: str,
+ run_id: str,
+ max_workers: int,
+ no_ingest: bool,
+ strict_ingest: bool,
+) -> None:
+ """Resume an interrupted evaluation run."""
+ cfg = ctx.obj["config"]
+ if cfg.is_remote:
+ raise click.ClickException("Resume is local-mode only")
+ run_dir, store, run_meta = load_run(cfg, project, run_id)
+ if run_meta.get("status", "unknown") == "completed":
+ raise click.ClickException(f"Run {run_id} is already completed. Nothing to resume.")
+
+ existing_results = store.load_existing_results()
+ completed = {r["sample_idx"] for r in existing_results}
+ click.secho(f"Resuming run: {run_id}", fg="blue", bold=True)
+ click.secho(
+ f" Task: {run_meta['task']}, Already completed: {len(completed)} samples", fg="blue"
+ )
+
+ plan, selection = _plan_resume(cfg, project, run_meta, max_workers)
+ if selection is None:
+ click.secho(
+ " ⚠ run.json 에 dataset 블록이 없는 옛 run — 전체 행으로 이어 갑니다.", fg="yellow"
+ )
+ # 순서: 데이터셋 sha(읽는 즉시, _plan_resume 안) → 설정 드리프트 → 선택 밖 idx → 정답 키 (D17).
+ # 모두 러너 호출 전이라 실패해도 results 가 늘지 않는다.
+ merged = _guard_config_drift(cfg, plan, run_meta)
+ _guard_selection(plan, completed)
+ _guard_answer_key(plan, existing_results)
+ if merged is not None:
+ asyncio.run(store.update_run(run_id, {"snapshot": merged}))
+ click.secho(f" Model: {plan.model}", fg="blue")
+
+ # 이전 평가를 미리 지우지 않는다(F3) -- create_evaluation() 이 원자적 교체라 안전하다.
+ # 이미 끝난 샘플은 건너뛴다 -- completed_results 를 안 넘기면 resume 이 전 샘플을 다시 유료
+ # 호출하고 results.jsonl 에 같은 sample_idx 를 중복 append 한다(F2).
+ execute_plan(plan, run_id, store, completed_results=existing_results)
+ click.echo(f"\nResults saved to: {run_dir}/")
+ _finish_with_hook(ctx, project, run_dir, run_meta, no_ingest, strict_ingest)
+
+
+def _finish_with_hook(
+ ctx: click.Context,
+ project: str,
+ run_dir: Path,
+ run_meta: dict[str, Any],
+ no_ingest: bool,
+ strict_ingest: bool,
+) -> None:
+ """run 종료 훅(command=`resume`). 스모크는 run.json 이 기록한 선택으로 본다(D-L6-10)."""
+ strict = strict_requested(strict_ingest)
+ options = HookOptions(
+ command="resume", no_ingest=no_ingest, strict=strict, smoke=is_smoke_run(run_meta)
+ )
+ # 훅 실패는 기본 종료 0 — strict 에서만 3(D-L6-11 · 마스터 D-M2-3).
+ code = exit_code_for(run_hook(ctx.obj["config"], project, run_dir, options), strict)
+ if code:
+ ctx.exit(code)
diff --git a/solar_eval/cli/commands/runs.py b/solar_eval/cli/commands/runs.py
index 104be2789be9eec83399f2683dd9c2929774e848..157f1722dec20cc14bfc6101cdf94482c3a16b85 100644
--- a/solar_eval/cli/commands/runs.py
+++ b/solar_eval/cli/commands/runs.py
@@ -1,23 +1,41 @@
-"""Run management commands — local execution."""
+"""Run management commands — local execution.
+
+`resume`·`eval` 명령과 진행 표시·run 준비(run.json 메타) 는 800줄 상한 때문에 옆 모듈로 떼어 냈다
+(계획 2026-09-24-phase1a E1): `run_resume`·`run_eval`·`run_progress`·`run_meta`. 이 모듈은 `list`·`start`·
+`batch`·`results` 와 run 디렉토리 할당을 맡는다. run 이 끝난 뒤의 적재는 `solar_eval.hub_hook` 훅이 한다
+(계획 2026-09-27 L6a — 번들 다리 → hub 다리, 레거시 적재는 부르지 않는다: 마스터 D-M2-4 (b)).
+"""
-import asyncio
import json
-import subprocess
import time
+from dataclasses import replace
from datetime import datetime
from pathlib import Path
from typing import Any
import click
-from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn
from solar_eval.cli.commands.projects import _resolve_project_id
-from solar_eval.cli.formatters import format_runs_table, format_results_table, format_scores
-
-
-#: run 이 끝난 뒤 자동 적재에 허용하는 시간. 전체 아카이브가 아니라 프로젝트 하나만
-#: 적재하므로 보통 수 초면 끝난다 — 넘어가면 뭔가 잘못된 것이라 기다리지 않는다.
-INGEST_TIMEOUT_SECONDS = 180
+from solar_eval.cli.commands.run_eval import eval_run
+from solar_eval.cli.commands.run_meta import find_task, preflight_judge_config, start_options
+from solar_eval.cli.commands.run_meta import resolve_prompt as _resolve_prompt # noqa: F401 -- prompts.py 가 쓴다
+from solar_eval.cli.commands.run_progress import execute_plan
+from solar_eval.cli.commands.run_resume import resume_run
+from solar_eval.cli.formatters import format_results_table, format_runs_table
+from solar_eval.hub_hook import HookOptions, HookResult, exit_code_for, finish_batch, run_hook
+from solar_eval.hub_hook.console import echo_batch_summary
+from solar_eval.hub_hook.optin import strict_requested
+from solar_eval.hub_hook.target import is_smoke_run
+from solar_eval.hub_hook.types import (
+ BATCH_PENDING,
+ COMMAND_BATCH,
+ LEG_BUNDLE,
+ LEG_HUB,
+ NO_BUNDLE,
+ HookMode,
+ LegResult,
+ bundle_ready,
+)
@click.group("runs")
@@ -25,6 +43,31 @@ def runs_group() -> None:
"""Manage evaluation runs."""
+#: `--where` 옵션 도움말 — start·batch 가 같은 문법을 쓴다(계획 D1).
+WHERE_HELP = (
+ "행 필터 key=v1,v2 (반복 가능, AND). tags 는 모두 가진 행, category 는 목록 중 하나. "
+ "예: --where tags=yul-ryul --where category=a,b"
+)
+
+#: `--no-ingest`·`--strict-ingest` 도움말 — start·resume 이 같은 뜻으로 쓴다(계획 L6 §2.2).
+NO_INGEST_HELP = (
+ "run 이 끝난 뒤 hub 훅(번들·hub 적재)을 통째로 건너뛴다 — 되살리는 명령을 한 줄로 알려 준다"
+)
+STRICT_INGEST_HELP = (
+ "hub 훅 다리가 하나라도 실패하면 종료 코드 3 (기본은 0 — run 결과는 디스크에 있다). "
+ "SOLAR_EVAL_STRICT_INGEST=1 과 같다"
+)
+
+#: Experiment Hub 묶음 지문이 선택을 보지 않는다는 경고(계획 D19 · §11-2).
+FILTERED_RUN_EXPERIMENT_HUB_WARNING = (
+ " ⚠ Experiment Hub 는 아직 필터 run 을 같은 태스크 전체셋의 '같은 설정 반복' 과 구분하지 못합니다 "
+ "(§11-2) — 이 run 의 점수는 위 CLI 출력과 note 로 읽으세요."
+)
+
+#: rubric judge 태스크의 `runs start` 뒤 안내(계획 2026-09-28 L12 D-L12-6) — in-run 은 레거시 채점만, 채점 번들은 `runs eval` 이 낸다.
+JUDGE_BUNDLE_HINT = "judge 채점 번들은 `solar-eval runs eval {project} -r {run}` 이 발행한다"
+
+
@runs_group.command("list")
@click.argument("project")
@click.option("--task", "-t", default=None, help="Filter by task name")
@@ -102,7 +145,7 @@ def list_runs(ctx: click.Context, project: str, task: str | None) -> None:
@click.option(
"--note",
default=None,
- help="이 run 을 왜 돌리는지 한 줄 (run.json 에 기록되고 evalhub 가 그대로 읽는다)",
+ help="이 run 을 왜 돌리는지 한 줄 (run.json 에 기록되고 Experiment Hub 가 그대로 읽는다)",
)
@click.option(
"--set",
@@ -114,28 +157,11 @@ def list_runs(ctx: click.Context, project: str, task: str | None) -> None:
"예: --set steps.basic_correction.reasoning_effort=high. 여러 번 쓸 수 있다."
),
)
-@click.option(
- "--no-ingest",
- is_flag=True,
- help="run 이 끝난 뒤 evalhub 자동 적재를 건너뛴다 (여러 run 을 돌리고 한 번에 적재할 때)",
-)
+@click.option("--no-ingest", is_flag=True, help=NO_INGEST_HELP)
+@click.option("--strict-ingest", is_flag=True, help=STRICT_INGEST_HELP)
+@click.option("--where", "where", multiple=True, metavar="KEY=V1,V2", help=WHERE_HELP)
@click.pass_context
-def start_run(
- ctx: click.Context,
- project: str,
- task: str,
- prompt_name: str,
- model: str,
- pipeline_name: str | None,
- reasoning_effort: str | None,
- temperature: float,
- max_tokens: int,
- max_workers: int,
- limit: int | None,
- note: str | None,
- set_options: tuple[str, ...],
- no_ingest: bool,
-) -> None:
+def start_run(ctx: click.Context, project: str, **options: Any) -> None:
"""Start an evaluation run.
Example:
@@ -147,80 +173,17 @@ def start_run(
-p prompt_dev_v23 -m solar-pro3 -P pipeline_dev_v24 \\
--set steps.basic_correction.repeat_temperatures='[0.0, 0.5]' \\
--note "SC 온도 폭 스윕"
- """
- cfg = ctx.obj["config"]
-
- _start_local(
- cfg,
- project=project,
- task=task,
- prompt_name=prompt_name,
- model=model,
- pipeline_name=pipeline_name,
- reasoning_effort=reasoning_effort,
- temperature=temperature,
- max_tokens=max_tokens,
- max_workers=max_workers,
- limit=limit,
- note=note,
- set_options=set_options,
- no_ingest=no_ingest,
- )
-
-
-def _resolve_prompt(prompts_dir: Path, prompt_name: str) -> Path:
- """Resolve prompt from prompts/ directory (directory or YAML file).
- Resolution order:
- 1. prompts/{name}/ (directory TXT format)
- 2. prompts/{name}.yaml (flat YAML layout)
- 3. prompts/_library/{name}.yaml (legacy layout)
+ solar-eval runs start chosun-proofreading -t ci-v2-all \\
+ -p prompt_prod_251231 -m solar-pro3 --where tags=yul-ryul
"""
- # Directory TXT format (new)
- dir_path = prompts_dir / prompt_name
- if dir_path.is_dir():
- return dir_path
- # Flat YAML layout
- yaml_path = prompts_dir / f"{prompt_name}.yaml"
- if yaml_path.exists():
- return yaml_path
- # Legacy _library layout
- legacy_path = prompts_dir / "_library" / f"{prompt_name}.yaml"
- if legacy_path.exists():
- return legacy_path
- raise click.ClickException(
- f"Prompt '{prompt_name}' not found. Searched:\n"
- f" {dir_path}/ (directory)\n"
- f" {yaml_path} (YAML)\n"
- f" {legacy_path} (legacy)"
- )
-
-
-def _resolve_pipeline_file(project_dir: Path, pipeline_name: str) -> Path:
- """Resolve pipeline YAML from pipelines/ directory."""
- path = project_dir / "pipelines" / f"{pipeline_name}.yaml"
- if not path.exists():
- raise click.ClickException(f"Pipeline '{pipeline_name}' not found: {path}")
- return path
-
-
-def _compose_pipeline_config(project_dir: Path, pipeline_name: str, overrides: dict) -> dict:
- """파이프라인을 `extends`/override 까지 해석해 돌려준다.
-
- 조립 실패(없는 base, 없는 스텝을 가리키는 override 등)는 run 을 시작하기 전에
- 죽인다 — 효과 없는 override 로 돌아간 run 은 결론을 조용히 오염시킨다.
- """
- from solar_eval.core.pipeline_compose import PipelineCompositionError, compose_pipeline
- from solar_eval.core.project_loader import load_pipeline_file
-
- pipelines_dir = _resolve_pipeline_file(project_dir, pipeline_name).parent
- try:
- composed = load_pipeline_file(pipelines_dir, pipeline_name)
- if overrides:
- composed = compose_pipeline(composed, overrides=overrides)
- return composed
- except (PipelineCompositionError, FileNotFoundError) as e:
- raise click.ClickException(str(e)) from None
+ # click 옵션 이름이 `_start_local` 키워드 인자 이름과 같다 — 그대로 넘긴다.
+ strict = strict_requested(options.pop("strict_ingest"))
+ result = _start_local(ctx.obj["config"], project=project, strict_ingest=strict, **options)
+ # 훅 실패는 기본 종료 0 — strict 에서만 3(D-L6-11 · 마스터 D-M2-3).
+ code = exit_code_for(result, strict)
+ if code:
+ ctx.exit(code)
def _build_run_name(task: str, pipeline_name: str, prompt_name: str) -> str:
@@ -241,7 +204,7 @@ def _make_run_dir(
같은 초에 같은 (task, pipeline, prompt) 조합으로 두 run 이 시작되면
`_build_run_name` 이 같은 이름을 낸다. `mkdir(exist_ok=False)` 를 쓰면 두 프로세스가
동시에 같은 이름으로 mkdir 해도 정확히 하나만 성공하고, 진 쪽은 새 타임스탬프로
- 재시도한다 — 파일 포맷 자체는 안 바꾸므로 evalhub 의 run_id 파싱과 호환된다.
+ 재시도한다 — 파일 포맷 자체는 안 바꾸므로 Experiment Hub 의 run_id 파싱과 호환된다.
"""
for _ in range(_RUN_DIR_MAX_ATTEMPTS):
run_name = _build_run_name(task, pipeline_name, prompt_name)
@@ -254,254 +217,104 @@ def _make_run_dir(
raise click.ClickException(f"Could not allocate a unique run directory under {artifacts_dir}")
-def _config_source(cfg, project: str) -> str:
- """run 의 config 출처 — 레포 관리 프로젝트면 레포 상대 경로, 아니면 데이터 홈 절대 경로."""
- config_dir = cfg.config_dir(project)
- if cfg.is_repo_managed(project) and cfg.repo_root is not None:
- return str(config_dir.relative_to(cfg.repo_root))
- return str(config_dir)
-
-
-def _git_commit(repo_root: Path | None) -> str | None:
- """재현용 도장: 레포 HEAD 커밋. 작업 트리가 더러우면 '-dirty' 를 붙인다."""
- if repo_root is None:
- return None
- try:
- head = subprocess.run(
- ["git", "-C", str(repo_root), "rev-parse", "--short", "HEAD"],
- capture_output=True,
- text=True,
- check=True,
- ).stdout.strip()
- status = subprocess.run(
- ["git", "-C", str(repo_root), "status", "--porcelain"],
- capture_output=True,
- text=True,
- check=True,
- ).stdout.strip()
- return f"{head}-dirty" if status else head
- except (OSError, subprocess.CalledProcessError):
- return None
-
-
def _start_local(
cfg,
- project: str,
- task: str,
- prompt_name: str,
- model: str,
- pipeline_name: str | None,
- reasoning_effort: str | None,
- temperature: float,
- max_tokens: int,
- max_workers: int,
- limit: int | None,
+ *,
note: str | None = None,
- set_options: tuple[str, ...] = (),
no_ingest: bool = False,
-) -> None:
- """Start run locally with the new prompt/model/pipeline interface."""
- from solar_eval.core.dataset_loader import DatasetLoader
- from solar_eval.core.fingerprint import pipeline_fingerprint, prompt_fingerprint
- from solar_eval.core.pipeline_compose import PipelineCompositionError, parse_set_option
- from solar_eval.core.project_loader import load_all_project_configs
- from solar_eval.core.runner import BatchRunner
- from solar_eval.models.prompt_version import (
- detect_prompt_format,
- load_prompt_messages,
- load_step_prompts,
- )
- from solar_eval.providers import UpstageProvider
- from solar_eval.providers.openai_provider import OpenAIProvider
+ strict_ingest: bool = False,
+ hook_options: HookOptions | None = None,
+ **plan_options: Any,
+) -> HookResult | None:
+ """run 하나를 로컬에서 돌린다 (계획 §9 G2 순서).
+
+ `plan_options` 는 `run_meta.plan_run` 의 키워드 인자 그대로다 — `project`·`task`·`prompt_name`·
+ `model`·`pipeline_name`·`reasoning_effort`·`temperature`·`max_tokens`·`max_workers`·`limit`
+ (필수), `set_options`·`where`(선택). 모르는 키는 `plan_run` 이 TypeError 로 거부한다.
+
+ `reject_defaults_where` → 선택(`--where`·`limit`) → `prepare_dataset` → `build_task_pipeline`
+ → run.json 메타(`dataset`·`snapshot` + 자기 검증) → `_make_run_dir` → 러너에 준비된 데이터셋·
+ 선택·파이프라인 **같은 인스턴스**를 넘긴다. run.json 메타는 디렉토리를 만들기 전에 조립한다 —
+ 없는 사전·깨진 lock 같은 오류가 빈 run 디렉토리를 남기지 않게.
+
+ 끝나면 run 종료 훅(`hub_hook.run_hook`)을 부르고 그 결과를 돌려준다(hub 대상이 아니면 None 일 수 있다).
+ 훅은 실패해도 예외를 내지 않는다 — 종료 코드는 호출자가 `exit_code_for` 로 정한다.
+ `hook_options` 를 주면(batch — `HookMode.BATCH_ITEM`) `no_ingest`·`strict_ingest` 대신 그것을 쓴다(스모크 판정만 덧씌운다).
+ """
+ from solar_eval.cli.commands import run_meta
from solar_eval.stores import JsonlStore
- # Load project config
- configs = load_all_project_configs(cfg.projects_dir, cfg.config_dirs)
- project_config = next((c for c in configs if c["name"] == project), None)
- if not project_config:
- raise click.ClickException(f"Project not found in {cfg.projects_dir}: {project}")
-
- task_config = next((t for t in project_config.get("tasks", []) if t["name"] == task), None)
- if not task_config:
- raise click.ClickException(f"Task '{task}' not found in project '{project}'")
-
- # Resolve prompt file
- prompts_dir = cfg.prompts_dir(project)
- prompt_path = _resolve_prompt(prompts_dir, prompt_name)
-
- fmt = detect_prompt_format(prompt_path)
- if fmt == "multi_step":
- step_prompts = load_step_prompts(prompt_path)
- prompt = {
- "step_prompts": step_prompts,
- "model": model,
- "temperature": temperature,
- "max_tokens": max_tokens,
- }
- else:
- messages = load_prompt_messages(prompt_path)
- prompt = {
- "messages": messages,
- "model": model,
- "temperature": temperature,
- "max_tokens": max_tokens,
- }
-
- if reasoning_effort:
- prompt["reasoning_effort"] = reasoning_effort
-
- # Resolve pipeline — config 는 레포 관리 프로젝트면 git 쪽이 정본.
- # `extends` 상속과 `--set` override 는 여기서 전부 해석돼, 실행 엔진에는 완성된
- # steps 목록만 넘어간다.
- try:
- cli_overrides = parse_set_option(set_options)
- except PipelineCompositionError as e:
- raise click.ClickException(str(e)) from None
-
- effective_pipeline = pipeline_name
- if pipeline_name:
- pipeline_config = _compose_pipeline_config(
- cfg.config_dir(project), pipeline_name, cli_overrides
- )
- task_config = {**task_config, "pipeline_config": pipeline_config}
- else:
- # Use default from project.yaml (already resolved by project_loader)
- pc = task_config.get("pipeline_config", {})
- effective_pipeline = pc.get("name", "default") if isinstance(pc, dict) else str(pc)
- if cli_overrides:
- raise click.ClickException("--set requires an explicit --pipeline/-P")
- pipeline_config = pc if isinstance(pc, dict) else {}
-
- # Build run name and directory -- 이름 충돌은 여기서 원자적으로 걸러진다(F10).
+ plan = run_meta.plan_run(cfg, **plan_options)
+ project = plan.project
+ meta = run_meta.build_run_meta(cfg, plan, note)
+ # 이름 충돌은 여기서 원자적으로 걸러진다(F10). run.json 은 그 직후 한 번에 쓴다.
run_name, run_dir = _make_run_dir(
- cfg.artifacts_dir(project), task, effective_pipeline, prompt_name
- )
- store = JsonlStore(run_dir)
-
- # Save run metadata
- run_meta = {
- "task": task,
- "model": model,
- "prompt": prompt_name,
- "pipeline": effective_pipeline,
- "temperature": temperature,
- "max_tokens": max_tokens,
- "reasoning_effort": reasoning_effort,
- # 왜 돌렸는지 — 예전에는 별도 랩노트(experiments/*.yaml)가 담았지만 run 과 분리돼 있어
- # 참조가 끊어지고 썩었다. run 과 같은 파일에 두면 끊어질 수가 없다.
- "note": note,
- # provenance: 이 run 의 config(프롬프트·파이프라인)가 어디서 왔는지 (W&B/DVC 식 도장)
- "config_source": _config_source(cfg, project),
- "git_commit": _git_commit(cfg.repo_root) if cfg.is_repo_managed(project) else None,
- # 내용 지문 — 이름이 아니라 내용으로 설정을 식별한다. 이름만 다른 사본,
- # `extends` 로 조립한 것, `--set` 으로 즉석에서 만든 것이 모두 같은 값이면
- # 같은 지문을 갖는다 (core/fingerprint.py).
- "prompt_sha": prompt_fingerprint(prompt_path),
- "pipeline_sha": pipeline_fingerprint(pipeline_config),
- # 파일 없이 돌린 실험이면 무엇을 바꿨는지가 여기 남는다.
- "pipeline_overrides": cli_overrides or None,
- }
- # run_dir 은 _make_run_dir 이 이미 원자적으로 만들었다 -- 여기서 다시 mkdir 하지 않는다.
- (run_dir / "run.json").write_text(json.dumps(run_meta, ensure_ascii=False, indent=2))
-
- # Log run info
- click.secho(f"Prompt: {prompt_name} [{fmt}]", fg="blue")
- click.secho(f"Model: {model}", fg="blue")
- click.secho(f"Pipeline: {effective_pipeline}", fg="blue")
- for path, value in cli_overrides.items():
- click.secho(f" override: {path} = {value!r}", fg="magenta")
- click.secho(
- f"Fingerprint: prompt {run_meta['prompt_sha']} / pipeline {run_meta['pipeline_sha']}",
- fg="blue",
+ cfg.artifacts_dir(project), plan.task, plan.pipeline_name, plan.prompt_name
)
- if reasoning_effort:
- click.secho(f"Reasoning effort: {reasoning_effort}", fg="blue")
- if limit:
- click.secho(f"Limit: {limit} samples", fg="yellow")
- click.secho(f"Starting local run: {project}/{run_name}", fg="blue")
- click.secho(f"Output: {run_dir}", fg="blue")
-
- # Create runner
- provider = UpstageProvider()
- judge_provider = OpenAIProvider()
- dataset_loader = DatasetLoader(base_dir=cfg.projects_dir)
- runner = BatchRunner(
- store=store,
- inference_provider=provider,
- judge_provider=judge_provider,
- dataset_loader=dataset_loader,
- )
-
- # Run with progress display
- _run_with_progress(runner, run_name, project_config, task_config, prompt, max_workers, limit)
+ (run_dir / "run.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2))
+ _echo_run_info(plan, meta, f"{project}/{run_name}", run_dir)
+ execute_plan(plan, run_name, JsonlStore(run_dir))
_warn_if_incomplete(run_dir)
+ if plan.selection is not None and plan.selection.where is not None:
+ click.secho(FILTERED_RUN_EXPERIMENT_HUB_WARNING, fg="yellow")
click.echo(f"\nResults saved to: {run_dir}/")
- if not no_ingest:
- _ingest_into_evalhub(cfg, project, run_dir)
+ if hook_options is None:
+ hook_options = HookOptions(command="start", no_ingest=no_ingest, strict=strict_ingest)
+ options = replace(hook_options, smoke=is_smoke_run(plan))
+ result = run_hook(cfg, project, run_dir, options)
+ _echo_judge_bundle_hint(plan, project, run_name)
+ return result
-def _ingest_into_evalhub(cfg, project: str, run_dir: Path) -> None:
- """방금 만든 run 을 evalhub 로 적재한다.
+def _echo_judge_bundle_hint(plan, project: str, run_name: str) -> None:
+ """rubric judge 태스크면 훅 뒤 안내 한 줄 — 훅(eval-wrap)은 lcs 전용이라 judge 채점 번들을 내지 않는다(D-L12-6)."""
+ from solar_eval.evaluators.judge_config import RUBRIC_JUDGE_TYPE
+ from solar_eval.scoring_publish.publish import evaluator_type
- **실패해도 run 은 성공이다** — 결과는 이미 디스크에 있고, 적재는 나중에 다시
- 돌리면 되는 멱등 작업이다. 그래서 어떤 실패도 exit code 를 바꾸지 않고
- "무엇이 안 됐고 어떻게 되살리는지"만 알린다. 적재를 못 했다는 사실 자체를
- 조용히 넘기지도 않는다 — 그러면 evalhub 화면이 낡은 채로 남는다.
+ if evaluator_type(plan.task_config.get("evaluator")) == RUBRIC_JUDGE_TYPE:
+ click.secho(f" {JUDGE_BUNDLE_HINT.format(project=project, run=run_name)}", fg="blue")
- 레포 밖 standalone 설치에는 evalhub 자체가 없으므로 조용히 건너뛴다.
- """
- if cfg.repo_root is None:
- return
- click.secho("\nevalhub 적재 중...", fg="blue")
- try:
- result = subprocess.run(
- ["pnpm", "--filter", "@poc/eval-store", "ingest", "--only", project],
- cwd=str(cfg.repo_root),
- capture_output=True,
- text=True,
- timeout=INGEST_TIMEOUT_SECONDS,
- )
- except FileNotFoundError:
- _warn_ingest_skipped(project, run_dir, "pnpm 을 찾을 수 없습니다")
- return
- except subprocess.TimeoutExpired:
- _warn_ingest_skipped(project, run_dir, f"{INGEST_TIMEOUT_SECONDS}초 안에 끝나지 않았습니다")
- return
- except OSError as e:
- _warn_ingest_skipped(project, run_dir, str(e))
- return
-
- if result.returncode == 0:
- click.secho(f" evalhub 적재 완료 — {project}", fg="green")
- return
- _warn_ingest_skipped(project, run_dir, _ingest_failure_reason(result))
-
-
-def _ingest_failure_reason(result: subprocess.CompletedProcess[str]) -> str:
- """ingest 실패 출력에서 사람이 읽을 한 줄을 뽑는다.
+def _echo_run_info(plan, meta: dict, run_ref: str, run_dir: Path) -> None:
+ """run 시작 요약 — 설정·지문(config sha256:<12>)·선택·재현 가능성."""
+ from solar_eval.cli.commands.run_meta import short_fingerprint
- stderr 전체는 node 스택트레이스라 그대로 보여주면 정작 원인이 묻힌다.
- 가장 흔한 원인(백엔드 미기동)은 연결 거부로 나타나므로 먼저 짚는다.
- """
- output = f"{result.stderr}\n{result.stdout}"
- if "ECONNREFUSED" in output or "connect ECONNREFUSED" in output:
- return "Postgres 에 연결할 수 없습니다 (백엔드가 꺼져 있는 것 같습니다)"
- for line in result.stderr.splitlines():
- stripped = line.strip()
- if stripped and not stripped.startswith("at ") and "node_modules" not in stripped:
- return stripped[:160]
- return f"ingest 가 exit {result.returncode} 로 끝났습니다"
+ snapshot = meta["snapshot"]
+ dataset = meta["dataset"]
+ click.secho(f"Prompt: {plan.prompt_name} [{plan.prompt_format}]", fg="blue")
+ click.secho(f"Model: {plan.model}", fg="blue")
+ click.secho(f"Pipeline: {plan.pipeline_name}", fg="blue")
+ for path, value in (plan.pipeline_overrides or {}).items():
+ click.secho(f" override: {path} = {value!r}", fg="magenta")
+ click.secho(
+ f"Fingerprint: config {short_fingerprint(snapshot['config_fingerprint'])}"
+ f" / prompt {meta['prompt_sha']} / pipeline {meta['pipeline_sha']}",
+ fg="blue",
+ )
+ if plan.reasoning_effort:
+ click.secho(f"Reasoning effort: {plan.reasoning_effort}", fg="blue")
+ selection = plan.selection
+ if selection is not None and selection.where is not None:
+ click.secho(f"Where: {selection.where.describe()}", fg="yellow")
+ if selection is not None and selection.limit:
+ click.secho(f"Limit: {selection.limit} samples", fg="yellow")
+ click.secho(f"Rows: {dataset['rows_selected']}/{dataset['rows_total']}", fg="blue")
+ _echo_reproducibility(snapshot)
+ click.secho(f"Starting local run: {run_ref}", fg="blue")
+ click.secho(f"Output: {run_dir}", fg="blue")
-def _warn_ingest_skipped(project: str, run_dir: Path, reason: str) -> None:
- """적재 실패를 알리고 되살리는 명령을 그대로 실어 준다 (exit code 는 0 유지)."""
- click.secho(f"\n ⚠ evalhub 적재를 건너뛰었습니다 — {reason}", fg="yellow", bold=True)
- click.secho(f" run 결과는 그대로 있습니다: {run_dir}", fg="yellow")
- click.secho(" 백엔드를 띄운 뒤 아래를 실행하면 반영됩니다:", fg="yellow")
- click.secho(" make evalhub-up", fg="yellow")
- click.secho(f" make evalhub-ingest ONLY={project}", fg="yellow")
+def _echo_reproducibility(snapshot: dict) -> None:
+ """재현 가능성 한 줄 — 막는 이유를 그대로 보여 준다(계획 R5)."""
+ repro = snapshot.get("reproducibility") or {}
+ verdict = repro.get("rerunnable")
+ label = {True: "true", False: "false", None: "unknown"}[verdict]
+ blockers = repro.get("rerunnable_blockers") or []
+ suffix = f" — {', '.join(blockers)}" if blockers else ""
+ click.secho(f"Rerunnable: {label}{suffix}", fg="green" if verdict else "yellow")
+ if snapshot.get("missing"):
+ click.secho(f" snapshot missing: {', '.join(snapshot['missing'])}", fg="yellow")
def _warn_if_incomplete(run_dir: Path) -> None:
@@ -535,141 +348,6 @@ def _warn_if_incomplete(run_dir: Path) -> None:
)
-def _run_with_progress(
- runner,
- run_id,
- project_config,
- task_config,
- prompt,
- max_workers,
- limit,
- completed_results: list[dict[str, Any]] | None = None,
-):
- """Execute a run with Rich progress bar display.
-
- `completed_results` 는 resume 이 이미 끝난 샘플을 다시 돌리지 않게 하는 다리다 --
- 여기서 안 넘기면 runner 가 파라미터를 갖고 있어도 소용이 없다(F2).
-
- `execute_run` 은 치명적 실패에서 raise 하므로 이 함수도 raise 한다 --
- 일부러 감싸지 않는다. 위쪽 `on_progress` 의 "error" 이벤트가 이미 사람이 읽을
- 메시지를 찍었고, 여기서 또 감싸면 같은 내용이 두 번 나온다.
- """
- import logging as _logging
-
- from rich.console import Console
- from rich.live import Live
- from rich.text import Text
-
- console = Console(stderr=True)
- use_rich = True
- live_ctx: Live | None = None
-
- progress_bar = Progress(
- SpinnerColumn(),
- TextColumn("[progress.description]{task.description}"),
- BarColumn(),
- TextColumn("{task.completed}/{task.total}"),
- TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
- TimeElapsedColumn(),
- )
-
- try:
- live_ctx = Live(progress_bar, console=console, refresh_per_second=8)
- live_ctx.start()
- except Exception:
- use_rich = False
-
- # Redirect pipeline/runner logs above the progress bar
- class _LiveLogHandler(_logging.Handler):
- def emit(self, record):
- if live_ctx:
- live_ctx.console.print(Text(record.getMessage(), style="dim"))
-
- _log_names = (
- "solar_eval.pipelines.pipeline",
- "solar_eval.pipelines.steps.llm",
- "solar_eval.core.runner",
- "solar_eval.providers.upstage",
- )
- if use_rich:
- _live_handler = _LiveLogHandler()
- for name in _log_names:
- lg = _logging.getLogger(name)
- lg.addHandler(_live_handler)
- lg.propagate = False
- _logging.getLogger("httpx").setLevel(_logging.WARNING)
-
- def _cleanup_live():
- if use_rich:
- live_ctx.stop()
- for name in _log_names:
- _logging.getLogger(name).removeHandler(_live_handler)
- _logging.getLogger(name).propagate = True
-
- inference_task_id = None
- eval_task_id = None
-
- async def on_progress(event):
- nonlocal inference_task_id, eval_task_id
- etype = event.get("type")
- if etype == "dataset_loaded":
- total = event["total"]
- if use_rich:
- inference_task_id = progress_bar.add_task("Inference", total=total)
- else:
- click.secho(f" Dataset loaded: {total} samples", fg="blue")
- elif etype == "inference_progress":
- if use_rich and inference_task_id is not None:
- progress_bar.update(inference_task_id, completed=event["completed"])
- elif not use_rich:
- c, t = event["completed"], event["total"]
- if c % 10 == 0 or c == t:
- click.echo(f" Inference: {c}/{t}")
- elif etype == "inference_complete":
- if use_rich and inference_task_id is not None:
- progress_bar.update(inference_task_id, completed=event["total"])
- else:
- click.secho(f" Inference complete: {event['total']} samples", fg="green")
- elif etype == "evaluation_start":
- total = event.get("total", 0)
- if use_rich:
- eval_task_id = progress_bar.add_task("Evaluation", total=total or 1)
- else:
- click.secho(" Starting evaluation...", fg="blue")
- elif etype == "evaluation_progress":
- if use_rich and eval_task_id is not None:
- progress_bar.update(eval_task_id, completed=event["completed"])
- elif etype == "done":
- if use_rich and eval_task_id is not None:
- progress_bar.update(eval_task_id, completed=progress_bar.tasks[eval_task_id].total)
- _cleanup_live()
- click.echo()
- click.secho("Run completed!", fg="green", bold=True)
- overall = event.get("overall_score")
- if overall is not None:
- click.echo(f" Overall Score: {overall:.4f}")
- scores = event.get("scores", {})
- if scores:
- click.echo(f" Scores: {format_scores(scores)}")
- elif etype == "error":
- _cleanup_live()
- click.echo()
- click.secho(f" Run failed: {event.get('error')}", fg="red", bold=True)
-
- asyncio.run(
- runner.execute_run(
- run_id=run_id,
- project_config=project_config,
- task_config=task_config,
- prompt=prompt,
- on_progress=on_progress,
- max_workers=max_workers,
- limit=limit,
- completed_results=completed_results,
- )
- )
-
-
@runs_group.command("batch")
@click.argument("project")
@click.option("--task", "-t", required=True, help="Task name")
@@ -684,331 +362,101 @@ def _run_with_progress(
)
@click.option("--reasoning-effort", "-re", default=None, help="Reasoning effort")
@click.option("--max-workers", "-w", default=5, type=int, help="Max parallel workers")
+@click.option("--where", "where", multiple=True, metavar="KEY=V1,V2", help=WHERE_HELP)
+@click.option("--no-ingest", is_flag=True, help=NO_INGEST_HELP)
+@click.option("--strict-ingest", is_flag=True, help=STRICT_INGEST_HELP)
@click.pass_context
-def batch_runs(ctx, project, task, prompts, model, pipeline_name, reasoning_effort, max_workers):
- """Run evaluation for multiple prompts sequentially."""
+def batch_runs(ctx, project, task, prompts, no_ingest, strict_ingest, **run_options):
+ """Run evaluation for multiple prompts sequentially.
+
+ run 마다 번들 다리만 돌고(`HookMode.BATCH_ITEM`), 끝에 hub 다리를 한 번 부른다(계획 L6 D-L6-12).
+ 종료 코드: 프롬프트 실패가 있으면 1(먼저), 아니고 strict 이며 훅이 실패한 run 이 있으면 3, 그 밖 0(D-L6-11).
+ """
cfg = ctx.obj["config"]
if cfg.is_remote:
raise click.ClickException("Batch is local-mode only")
prompt_list = [p.strip() for p in prompts.split(",")]
+ # 프롬프트와 무관한 검사(defaults.where·--where 문법·태스크 where 합치기)는 루프 전에 한 번 —
+ # 틀렸으면 프롬프트마다 같은 오류를 되풀이하지 않고 바로 멈춘다(D3 "실패는 크게").
+ project_config, task_config = find_task(cfg, project, task)
+ start_options(project_config, task_config, (), run_options["where"], None)
+ preflight_judge_config(task_config)
click.secho(f"Batch run: {project}/{task} — prompts: {prompt_list}", fg="blue", bold=True)
+ options = HookOptions(
+ command=COMMAND_BATCH,
+ mode=HookMode.BATCH_ITEM,
+ no_ingest=no_ingest,
+ strict=strict_requested(strict_ingest),
+ )
+ results, failed = _run_batch_prompts(cfg, project, task, prompt_list, options, run_options)
+ results = _with_batch_hub_leg(results, finish_batch(cfg, project, results, options))
+ click.secho(f"\n{'=' * 50}", fg="blue")
+ # standalone(레포 밖 — 훅이 None 만 돌려준다)은 훅 콘솔이 없다(§2.3 "오늘과 같다", 리뷰 지적 3).
+ if cfg.repo_root is not None or any(r is not None for r in results):
+ echo_batch_summary(results)
+ if failed:
+ raise click.ClickException(
+ f"{len(failed)} of {len(prompt_list)} prompts failed: {', '.join(failed)}"
+ )
+ code = max((exit_code_for(r, options.strict) for r in results), default=0)
+ if code:
+ ctx.exit(code)
+ click.secho("Batch complete!", fg="green", bold=True)
+
+
+def _run_batch_prompts(
+ cfg, project: str, task: str, prompt_list: list[str], options: HookOptions, run_options: dict
+) -> tuple[list[HookResult | None], list[str]]:
+ """프롬프트마다 run 하나 + 번들 다리. 훅 결과와 실패한 프롬프트 이름을 돌려준다.
+
+ 훅은 예외를 내지 않는다(`run_hook` 이 봉쇄한다, D-L6-2) — 여기서 잡히는 예외는 run 자체의 실패뿐이다.
+ """
+ results: list[HookResult | None] = []
+ failed: list[str] = []
for pname in prompt_list:
click.secho(f"\n{'=' * 50}", fg="blue")
click.secho(f"Running prompt: {pname}...", fg="blue", bold=True)
click.secho(f"{'=' * 50}", fg="blue")
try:
- _start_local(
- cfg,
- project=project,
- task=task,
- prompt_name=pname,
- model=model,
- pipeline_name=pipeline_name,
- reasoning_effort=reasoning_effort,
- temperature=0.0,
- max_tokens=4000,
- max_workers=max_workers,
- limit=None,
+ results.append(
+ _start_local(
+ cfg,
+ project=project,
+ task=task,
+ prompt_name=pname,
+ temperature=0.0,
+ max_tokens=4000,
+ limit=None,
+ hook_options=options,
+ **run_options,
+ )
)
except Exception as e:
+ # 한 프롬프트의 실패가 나머지를 막지 않게 이어 가되, 끝에서 exit code 로 알린다.
click.secho(f" {pname} failed: {e}", fg="red")
-
- click.secho(f"\n{'=' * 50}", fg="blue")
- click.secho("Batch complete!", fg="green", bold=True)
+ failed.append(pname)
+ return results, failed
-@runs_group.command("resume")
-@click.argument("project")
-@click.option("--run-id", "-r", required=True, help="Run ID to resume")
-@click.option("--max-workers", "-w", default=5, type=int, help="Max parallel workers")
-@click.pass_context
-def resume_run(ctx: click.Context, project: str, run_id: str, max_workers: int) -> None:
- """Resume an interrupted evaluation run."""
- cfg = ctx.obj["config"]
- if cfg.is_remote:
- raise click.ClickException("Resume is local-mode only")
-
- from solar_eval.core.dataset_loader import DatasetLoader
- from solar_eval.core.project_loader import load_all_project_configs
- from solar_eval.core.runner import BatchRunner
- from solar_eval.providers import UpstageProvider
- from solar_eval.providers.openai_provider import OpenAIProvider
- from solar_eval.stores import JsonlStore
-
- # Find the run directory
- run_dir = cfg.artifacts_dir(project) / run_id
- if not run_dir.exists():
- raise click.ClickException(f"Run directory not found: {run_dir}")
-
- store = JsonlStore(run_dir)
- run_meta = store.load_run_meta()
-
- if not run_meta:
- raise click.ClickException(f"No run.json found in {run_dir}")
-
- status = run_meta.get("status", "unknown")
- if status == "completed":
- raise click.ClickException(f"Run {run_id} is already completed. Nothing to resume.")
-
- task = run_meta.get("task")
- if not task:
- raise click.ClickException("Cannot determine task from run.json")
-
- # Load existing results
- existing_results = store.load_existing_results()
- completed_indices = {r["sample_idx"] for r in existing_results}
- click.secho(f"Resuming run: {run_id}", fg="blue", bold=True)
- click.secho(f" Task: {task}, Already completed: {len(completed_indices)} samples", fg="blue")
-
- # Load project/task config
- configs = load_all_project_configs(cfg.projects_dir, cfg.config_dirs)
- project_config = next((c for c in configs if c["name"] == project), None)
- if not project_config:
- raise click.ClickException(f"Project not found: {project}")
-
- task_config = next((t for t in project_config.get("tasks", []) if t["name"] == task), None)
- if not task_config:
- raise click.ClickException(f"Task '{task}' not found in project '{project}'")
-
- # Resolve prompt from run.json
- prompt_name = run_meta.get("prompt")
- pipeline_name = run_meta.get("pipeline")
- model = run_meta.get("model", "solar-pro2")
-
- if not prompt_name:
- raise click.ClickException(
- "Cannot determine prompt from run.json. Only runs created with the new CLI can be resumed."
- )
+def _with_batch_hub_leg(
+ results: list[HookResult | None], hub: LegResult | None
+) -> list[HookResult | None]:
+ """batch 끝 hub 다리 결과를 run 별 결과에 얹는다(요약·종료 코드용 — 상태 파일은 `finish_batch` 가 고친다).
- from solar_eval.models.prompt_version import (
- detect_prompt_format,
- load_prompt_messages,
- load_step_prompts,
- )
-
- prompt_path = _resolve_prompt(cfg.prompts_dir(project), prompt_name)
- fmt = detect_prompt_format(prompt_path)
- if fmt == "multi_step":
- prompt = {
- "step_prompts": load_step_prompts(prompt_path),
- "model": model,
- "temperature": run_meta.get("temperature", 0.0),
- "max_tokens": run_meta.get("max_tokens", 4000),
- }
- else:
- prompt = {
- "messages": load_prompt_messages(prompt_path),
- "model": model,
- "temperature": run_meta.get("temperature", 0.0),
- "max_tokens": run_meta.get("max_tokens", 4000),
- }
- if run_meta.get("reasoning_effort"):
- prompt["reasoning_effort"] = run_meta["reasoning_effort"]
-
- # Override pipeline if specified — 원래 run 이 `--set` 으로 바꿔 돌린 것이면
- # 그 override 까지 복원해야 같은 설정으로 이어진다.
- if pipeline_name and pipeline_name != "default":
- task_config = {
- **task_config,
- "pipeline_config": _compose_pipeline_config(
- cfg.config_dir(project),
- pipeline_name,
- run_meta.get("pipeline_overrides") or {},
- ),
- }
-
- click.secho(f" Model: {model}", fg="blue")
-
- # 이전 평가를 미리 지우지 않는다(F3) -- 재평가가 도중에 죽으면 복구할 점수가
- # 없어진다. create_evaluation() 이 원자적 교체라 지우지 않아도 안전하다.
-
- # Setup providers and runner
- provider = UpstageProvider()
- judge_provider = OpenAIProvider()
- dataset_loader = DatasetLoader(base_dir=cfg.projects_dir)
- runner = BatchRunner(
- store=store,
- inference_provider=provider,
- judge_provider=judge_provider,
- dataset_loader=dataset_loader,
- )
-
- _run_with_progress(
- runner,
- run_id,
- project_config,
- task_config,
- prompt,
- max_workers,
- limit=None,
- # 이미 끝난 샘플은 건너뛴다 -- 안 넘기면 resume 이 전 샘플을 다시 유료 호출하고
- # results.jsonl 에 같은 sample_idx 를 중복 append 한다(F2).
- completed_results=existing_results,
- )
-
- click.echo(f"\nResults saved to: {run_dir}/")
-
-
-@runs_group.command("eval")
-@click.argument("project")
-@click.option(
- "--run-id",
- "-r",
- required=True,
- help="Run ID to evaluate",
-)
-@click.pass_context
-def eval_run(ctx: click.Context, project: str, run_id: str) -> None:
- """Run evaluation only on existing inference results (no new inference)."""
- cfg = ctx.obj["config"]
- if cfg.is_remote:
- raise click.ClickException("Eval is local-mode only")
-
- from solar_eval.core.project_loader import load_all_project_configs
- from solar_eval.core.runner import build_eval_sample_from_result
- from solar_eval.evaluators.registry import create_evaluator
- from solar_eval.stores import JsonlStore
-
- # Find run directory
- run_dir = cfg.artifacts_dir(project) / run_id
- if not run_dir.exists():
- raise click.ClickException(f"Run directory not found: {run_dir}")
-
- store = JsonlStore(run_dir)
- run_meta = store.load_run_meta()
- if not run_meta:
- raise click.ClickException(f"No run.json found in {run_dir}")
-
- task = run_meta.get("task")
- if not task:
- raise click.ClickException("Cannot determine task from run.json")
-
- # Load results
- results = store.load_existing_results()
- if not results:
- raise click.ClickException(f"No inference results found in {run_dir}/results.jsonl")
-
- click.secho(f"Evaluating run: {run_id}", fg="blue", bold=True)
- click.secho(f" Task: {task}, Samples: {len(results)}", fg="blue")
-
- # Load task config for evaluator settings
- configs = load_all_project_configs(cfg.projects_dir, cfg.config_dirs)
- project_config = next((c for c in configs if c["name"] == project), None)
- if not project_config:
- raise click.ClickException(f"Project not found: {project}")
-
- task_config = next((t for t in project_config.get("tasks", []) if t["name"] == task), None)
- if not task_config:
- raise click.ClickException(f"Task '{task}' not found in project '{project}'")
-
- # 이전 평가를 미리 지우지 않는다(F3) -- 아래 재평가가 실패해도 옛 점수는 남는다.
-
- # Run evaluation
- evaluator = create_evaluator(task_config.get("evaluator", {"type": "llm_judge"}))
- # judge provider 를 안 넘기면 llm_judge 계열은 전 샘플에서 실패한다 -- 그리고
- # 그 실패가 예전엔 0점 placeholder 로 덮여 조용히 completed 로 끝났다(F8).
- from solar_eval.providers.openai_provider import OpenAIProvider
-
- judge_provider = OpenAIProvider()
-
- from datetime import timezone
-
- async def run_eval():
- # 채점 실패는 점수가 아니라 실패로 남긴다 -- aggregate() 에는 성공분만 넘긴다.
- eval_results: list[dict[str, Any]] = []
- eval_failures: list[dict[str, Any]] = []
-
- for result in results:
- # 리스트 위치가 아니라 원본 sample_idx 가 정본이다(F6).
- sample_idx = result["sample_idx"]
- try:
- eval_sample = build_eval_sample_from_result(result, reference=result.get("golden"))
- evaluator.validate_required_fields(eval_sample)
- eval_result = await evaluator.evaluate(sample=eval_sample, provider=judge_provider)
- eval_results.append({**eval_result, "sample_idx": sample_idx})
- except Exception as e:
- click.secho(f" Sample {sample_idx} eval failed: {e}", fg="yellow")
- eval_failures.append({"sample_idx": sample_idx, "error": str(e)})
-
- # Aggregate and save
- aggregated = evaluator.aggregate(eval_results)
- # 성공한 채점이 하나도 없으면 점수 자리를 비운다 -- 0.0 은 "0점"이라는 측정값이라
- # "측정이 없었다"와 구분되지 않는다(runner.py 와 같은 규칙).
- overall_score = aggregated.get("overall_score", 0.0) if eval_results else None
- eval_id = await store.create_evaluation(
- {
- "run_id": run_id,
- "scores": aggregated.get("scores", {}),
- "overall_score": overall_score,
- "eval_model": "local",
- "eval_success_count": len(eval_results),
- "eval_failed_count": len(eval_failures),
- "failed_sample_indices": [f["sample_idx"] for f in eval_failures],
- }
- )
-
- # Save per-sample eval details -- 성공/실패 둘 다 한 행씩 남긴다.
- eval_detail_docs = []
- for er in eval_results:
- eval_detail_docs.append(
- {
- "evaluation_id": eval_id,
- "sample_idx": er["sample_idx"],
- "category_scores": er.get("category_scores", {}),
- "error_counts": {
- k: v.get("error_count", 0)
- for k, v in er.get("details", {}).items()
- if isinstance(v, dict)
- },
- "severity": er.get("severity", ""),
- "score": er.get("score", 0.0),
- }
- )
- for f in eval_failures:
- eval_detail_docs.append(
- {
- "evaluation_id": eval_id,
- "sample_idx": f["sample_idx"],
- "category_scores": {},
- "error_counts": {},
- "severity": None,
- # 0.0 이 아니라 None -- 채점 실패를 최저 점수와 구분한다.
- "score": None,
- "error": f["error"],
- }
- )
- await store.insert_eval_details(eval_detail_docs)
-
- # Update run status
- await store.update_run(
- run_id,
- {
- "status": "completed",
- "completed_at": datetime.now(timezone.utc),
- },
- )
-
- return {**aggregated, "overall_score": overall_score, "failed": len(eval_failures)}
-
- aggregated = asyncio.run(run_eval())
-
- click.echo()
- click.secho("Evaluation completed!", fg="green", bold=True)
- overall = aggregated.get("overall_score")
- if overall is None:
- # 채점이 한 건도 성공하지 못했다 -- 숫자를 찍으면 "0점"으로 읽힌다.
- click.secho(" Overall Score: 측정 없음 (전 샘플 채점 실패)", fg="red", bold=True)
- else:
- click.echo(f" Overall Score: {overall:.4f}")
- failed = aggregated.get("failed", 0)
- if failed:
- click.secho(f" 채점 실패: {failed}건 (score: null 로 기록됨)", fg="yellow")
- scores = aggregated.get("scores", {})
- if scores:
- click.echo(f" Scores: {format_scores(scores)}")
- click.echo(f"\nResults saved to: {run_dir}/")
+ 번들이 생긴 run 은 끝 다리 결과를, 번들이 없는 run 은 `skipped:no_bundle` 을 받는다(`hook._legs_in_lock` 과 같은 규칙).
+ """
+ merged: list[HookResult | None] = []
+ for result in results:
+ pending = result.legs.get(LEG_HUB) if result is not None else None
+ if result is None or pending is None or pending.code != BATCH_PENDING:
+ merged.append(result)
+ continue
+ leg = hub if hub is not None and bundle_ready(result.legs.get(LEG_BUNDLE)) else None
+ leg = leg or LegResult.skipped(NO_BUNDLE)
+ merged.append(replace(result, legs={**result.legs, LEG_HUB: leg}))
+ return merged
@runs_group.command("results")
@@ -1048,3 +496,8 @@ def show_results(ctx: click.Context, run_ref: str, limit: int | None) -> None:
results = results[:limit]
click.secho(format_results_table(results), fg="blue")
click.echo(f"\nShowing {len(results)} result(s)")
+
+
+# 떼어 낸 명령 등록 — `solar-eval runs resume|eval` 표면은 그대로다.
+runs_group.add_command(resume_run)
+runs_group.add_command(eval_run)
diff --git a/solar_eval/cli/commands/score.py b/solar_eval/cli/commands/score.py
new file mode 100644
index 0000000000000000000000000000000000000000..4ec0740a368d176895f77f54557b11c1a1c6c2e0
--- /dev/null
+++ b/solar_eval/cli/commands/score.py
@@ -0,0 +1,187 @@
+"""`solar-eval score` — 오프라인 재채점 명령 그룹 (계획 2026-09-26 L3b §2.1).
+
+`score proofread-diff` 는 run 번들의 `results.jsonl` 을 레거시 `lcs_diff` 와 같은 함수로 다시 채점해 채점 번들
+(`poc-scoring/v1`, `purpose=rescore_after_fix`)로 낸다. 두 모드:
+
+- `…` 모드: 번들이 `…/eval/bundles/` 에 있으면 기본 출력은 `…/eval/evaluations`, 아니면 `--out` 필수.
+ `--mirror` 면 각 번들 매니페스트 `case` 의 미러 `evaluations` 에 놓는다.
+- `--case a/c --all` 모드: 미러의 그 case run 번들 전부(`poc_bundle.adopt` 로 채택). `--mirror` 또는 `--out` 이 필요하다.
+ `--run `(반복)를 주면 그 run 들만(계획 2026-09-27 L6 §2.11 — 채택 규칙은 같고, 영역에 없는 run 은 오류
+ `run_not_in_mirror`). `--run` 은 이 모드에서만 받고, 빈 값·공백만·제어 문자·512자 초과는 사용 오류다(ES
+ `hub ingest --expect-run` 과 같은 범위 — 없는 run 이 stderr 에 그대로 찍히므로 줄 위조를 막는다).
+- `--out DIR` 과 `--mirror` 는 함께 쓰지 않는다. `--mirror-root` 는 미러 **버킷의 부모**다(§2.1 "미러 루트 층").
+ 디렉토리 모드에서는 `--mirror` 와 함께만 받는다. 없는 run 번들 경로는 사용 오류(종료 2)다.
+
+stdout 은 run 마다 한 줄 `written ` · `exists ` · `skip ` 이고, case 모드는 마지막 줄에 JSON 요약
+`{written, exists, skipped:{code:n}, errors, n_original_missing}` 을 낸다. 오류는 stderr 에 `error ` 한 줄씩
+(case 전체 오류는 run_id 자리에 `-`, `case_area_missing`·`case_area_empty` 는 뒤에 해석한 영역 경로를 붙인다).
+어느 쪽에도 입력·출력·정답 원문은 싣지 않는다.
+
+종료 코드: 0 = 전부 written/exists/규칙 skip · 1 = 오류 1건 이상 · 2 = 사용 오류.
+
+import 경계(D1): 이 모듈은 click 만 모듈 최상위에서 import 한다. `solar_eval.rescore`(그리고 그것이 끌어오는
+`poc_bundle`)는 **명령 함수 안에서** import 한다 — `solar-eval` CLI 를 띄우는 것만으로 재채점기·번들 writer 가
+로딩되지 않게.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+from typing import Any
+
+import click
+
+_DIR = click.Path(file_okay=False, path_type=Path)
+# 입력 run 번들은 있어야 한다 — 경로 오타가 규칙 skip(`latest_bundle_invalid`)·종료 0 으로 끝나지 않게 click 이 종료 2 로 막는다.
+_EXISTING_DIR = click.Path(exists=True, file_okay=False, path_type=Path)
+EXIT_ERRORS = 1
+EXIT_USAGE = 2
+
+
+@click.group("score")
+def score_group() -> None:
+ """Offline rescoring of run bundles (scoring bundles, poc-scoring/v1)."""
+
+
+@score_group.command("proofread-diff")
+@click.argument("run_bundle_dirs", nargs=-1, type=_EXISTING_DIR)
+@click.option("--case", "case_key", default=None, help="accounts// as a/c (with --all).")
+@click.option("--all", "all_runs", is_flag=True, default=False, help="Every adopted run bundle.")
+@click.option("--out", "out_dir", type=_DIR, default=None, help="Parent dir for scoring bundles.")
+@click.option("--mirror", is_flag=True, default=False, help="Write into the local mirror.")
+@click.option(
+ "--mirror-root",
+ type=_DIR,
+ default=None,
+ help="Parent of the mirror bucket (default: data home blob-mirror).",
+)
+@click.option(
+ "--run",
+ "run_ids",
+ multiple=True,
+ help="Only this run_id (repeatable; with --case --all).",
+)
+def proofread_diff(
+ run_bundle_dirs: tuple[Path, ...],
+ case_key: str | None,
+ all_runs: bool,
+ out_dir: Path | None,
+ mirror: bool,
+ mirror_root: Path | None,
+ run_ids: tuple[str, ...],
+) -> None:
+ """Rescore run bundles with proofread_diff@2 (same scoring as legacy lcs_diff)."""
+ _check_usage(run_bundle_dirs, case_key, all_runs, out_dir, mirror, mirror_root, run_ids)
+ if case_key is not None:
+ code = _run_case(case_key, out_dir, mirror_root, frozenset(run_ids) or None)
+ else:
+ code = _run_dirs(run_bundle_dirs, out_dir, mirror_root if mirror else None, mirror)
+ sys.exit(code)
+
+
+def _usage(message: str) -> None:
+ click.echo(f"usage error: {message}", err=True)
+ sys.exit(EXIT_USAGE)
+
+
+def _check_usage(
+ dirs: tuple[Path, ...],
+ case_key: str | None,
+ all_runs: bool,
+ out_dir: Path | None,
+ mirror: bool,
+ mirror_root: Path | None,
+ run_ids: tuple[str, ...] = (),
+) -> None:
+ """두 모드의 인자 조합 — 어긋나면 종료 2."""
+ if out_dir is not None and mirror:
+ _usage("--out and --mirror are mutually exclusive")
+ if case_key is not None or all_runs:
+ if dirs:
+ _usage("give run bundle directories or --case/--all, not both")
+ if case_key is None or not all_runs:
+ _usage("--case a/c needs --all (and --all needs --case)")
+ if out_dir is None and not mirror:
+ _usage("--case --all needs --mirror or --out DIR")
+ elif not dirs:
+ _usage("give run bundle directories, or --case a/c --all")
+ elif mirror_root is not None and not mirror:
+ # 디렉토리 모드에서 --mirror-root 는 --mirror 의 출력 자리일 뿐이다 — 조용히 버리지 않는다
+ _usage("--mirror-root needs --mirror when rescoring run bundle directories")
+ if run_ids:
+ _check_run_filter(case_key, run_ids)
+
+
+def _check_run_filter(case_key: str | None, run_ids: tuple[str, ...]) -> None:
+ """`--run` 은 case 모드의 run 필터다(L6 §2.11) — 디렉토리 모드에서 조용히 버리지 않고, 값은 경계에서 거른다.
+
+ case 모드의 `--case`/`--all` 짝은 `_check_usage` 가 먼저 막으므로 여기서 `case_key is None` 이면 디렉토리 모드다.
+ 값 규칙은 라이브러리 경계(`rescore_case`)와 같은 `is_valid_run_id` 다. 거부 문구에 값 원문은 싣지 않는다.
+ """
+ from solar_eval.rescore.case import MAX_RUN_ID_LENGTH, is_valid_run_id
+
+ if case_key is None:
+ _usage("--run needs --case a/c --all (not run bundle directories)")
+ if not all(is_valid_run_id(r) for r in run_ids):
+ _usage(
+ "--run needs a non-blank run_id without control characters,"
+ f" at most {MAX_RUN_ID_LENGTH} characters"
+ )
+
+
+def _run_case(
+ case_key: str,
+ out_dir: Path | None,
+ mirror_root: Path | None,
+ run_ids: frozenset[str] | None = None,
+) -> int:
+ from solar_eval.rescore.case import rescore_case
+ from solar_eval.rescore.paths import RescorePathError, parse_case_key
+
+ try:
+ account, case = parse_case_key(case_key)
+ except RescorePathError as exc:
+ _usage(str(exc))
+ summary = rescore_case(account, case, mirror_root=mirror_root, out_dir=out_dir, run_ids=run_ids)
+ for outcome in summary.outcomes:
+ _echo_outcome(outcome)
+ for failure in summary.failures:
+ suffix = f" {failure.detail}" if failure.detail else ""
+ click.echo(f"error {failure.run_id or '-'} {failure.code}{suffix}", err=True)
+ report: dict[str, Any] = summary.to_json()
+ click.echo(json.dumps(report, ensure_ascii=False, sort_keys=False))
+ return EXIT_ERRORS if report["errors"] else 0
+
+
+def _run_dirs(
+ dirs: tuple[Path, ...], out_dir: Path | None, mirror_root: Path | None, mirror: bool
+) -> int:
+ from solar_eval.rescore.evaluator_doc import current_evaluator_document
+ from solar_eval.rescore.from_bundle import RescoreError, rescore_run_bundle
+ from solar_eval.rescore.paths import default_evaluations_dir, resolve_mirror_root
+
+ targets = [(d, out_dir or (None if mirror else default_evaluations_dir(d))) for d in dirs]
+ missing = [d for d, target in targets if target is None and not mirror]
+ if missing:
+ _usage(f"{missing[0]} is not under …/eval/bundles/ — give --out DIR")
+ root = resolve_mirror_root(mirror_root) if mirror else None
+ evaluator = current_evaluator_document()
+ errors = 0
+ for bundle_dir, target in targets:
+ try:
+ outcome = rescore_run_bundle(bundle_dir, target, mirror_root=root, evaluator=evaluator)
+ except RescoreError as exc:
+ click.echo(f"error {exc.run_id or bundle_dir.name} {exc.code}", err=True)
+ errors += 1
+ continue
+ _echo_outcome(outcome, fallback=bundle_dir.name)
+ return EXIT_ERRORS if errors else 0
+
+
+def _echo_outcome(outcome: Any, fallback: str = "-") -> None:
+ if outcome.status == "skip":
+ click.echo(f"skip {outcome.run_id or fallback} {outcome.code}")
+ else:
+ click.echo(f"{outcome.status} {outcome.path}")
diff --git a/solar_eval/cli/commands/sync.py b/solar_eval/cli/commands/sync.py
index 93644bb952ffde8f47d32da690a772dd03495509..8d62454e238237ab07d6ee7c3f51b2db7667e155 100644
--- a/solar_eval/cli/commands/sync.py
+++ b/solar_eval/cli/commands/sync.py
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
@click.option(
"--mongo-uri",
envvar="MONGODB_URI",
- default="mongodb://localhost:27017",
+ default="mongodb://localhost:57017",
help="MongoDB connection URI",
)
@click.option("--db", "database", default="solar_eval", help="MongoDB database name")
@@ -122,9 +122,10 @@ def _sync_single_run(store, project: str, run_dir: Path, cfg) -> dict[str, int]:
"input": result.get("input", {}),
"output": result.get("output", ""),
"golden": result.get("golden", ""),
- "input_tokens": result.get("input_tokens", 0),
- "output_tokens": result.get("output_tokens", 0),
- "inference_time_ms": result.get("inference_time_ms", 0),
+ # 사용량·지연이 없는 행은 None 그대로 -- 0 을 지어내지 않는다(L6b B1).
+ "input_tokens": result.get("input_tokens"),
+ "output_tokens": result.get("output_tokens"),
+ "inference_time_ms": result.get("inference_time_ms"),
}
)
sample_count = store.bulk_insert_samples(samples)
diff --git a/solar_eval/cli/config.py b/solar_eval/cli/config.py
index c0df5ada87347cf21bb6fae92628c0fc3f0febd3..1f0aa6578535d62bfb8a1ad600443ce9d282c4b8 100644
--- a/solar_eval/cli/config.py
+++ b/solar_eval/cli/config.py
@@ -1,28 +1,155 @@
-"""CLI configuration from YAML file and environment variables."""
+"""CLI configuration from YAML file and environment variables.
+**`hub:` 절(계획 2026-09-28 L10 §2.6 · D-L10-6).** 사용자 설정 파일의 `hub.database_url`·`hub.confirm` 은 run 종료 훅의 hub
+다리 opt-in 이다. 여기서는 모양만 읽어 `CLIConfig.hub`(`HubConfig`)에 두고, 환경 변수와의 우선순위·에이전트 세션 판정은
+`solar_eval.hub_hook.optin.resolve_hub_optin` 한 곳이 한다. 모양 오류(매핑이 아님·URL 모양이 아님·모르는 키·설정 파일이
+소유자 전용 권한이 아님)는 `problems` 에 **키 이름만** 싣는다 — 값은 로그·예외·`repr` 어디에도 없다.
+"""
+
+import functools
import logging
import os
+import stat
+from collections.abc import Mapping
+from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
-DEFAULT_CONFIG_PATH = Path.home() / ".upstage-cloud-dev" / "solar-eval-config.yaml"
+#: 2026-10-02 전 위치 — 새 경로에 파일이 없을 때만 경고와 함께 읽는다(`make dev-docker-up` 이 새 경로로 복사한다).
+LEGACY_CONFIG_PATH = Path.home() / ".upstage-cloud-dev" / "solar-eval-config.yaml"
+
+
+def _config_home() -> Path:
+ """레포 사용자 설정 홈 — `CBS_ARSENAL_CONFIG_HOME` > `$XDG_CONFIG_HOME/cbs-arsenal` > `~/.config/cbs-arsenal`.
+
+ Go `internal/config.ConfigHome` 과 같은 규칙이다(monorepo-conventions.md "사용자 설정 홈").
+ """
+ override = os.environ.get("CBS_ARSENAL_CONFIG_HOME")
+ if override:
+ return Path(override)
+ xdg = os.environ.get("XDG_CONFIG_HOME")
+ return Path(xdg) / "cbs-arsenal" if xdg else Path.home() / ".config" / "cbs-arsenal"
+
+
+DEFAULT_CONFIG_PATH = _config_home() / "solar-eval.yaml"
+
+#: `hub:` 절이 받는 키.
+HUB_CONFIG_KEYS = ("database_url", "confirm")
+#: `hub.database_url` 이 받는 URL 모양(hub CLI `connect.ts` 와 같은 두 스킴).
+HUB_URL_SCHEMES = ("postgres://", "postgresql://")
+#: 그룹·다른 사용자 권한 비트 — 자격증명 URL 을 담은 설정 파일은 소유자만 읽고 써야 한다(ssh 키와 같은 규칙).
+LOOSE_MODE_BITS = stat.S_IRWXG | stat.S_IRWXO
+#: 문제 목록에 싣는 키 이름 길이 상한(값이 아니라 이름이지만 길게 싣지 않는다).
+_KEY_NAME_MAX = 40
logger = logging.getLogger(__name__)
+@dataclass(frozen=True)
+class HubConfig:
+ """설정 파일 `hub:` 절 — 값은 `repr` 에 싣지 않는다. `problems` 가 있으면 설정 출처 opt-in 은 꺼진다(optin 이 판정)."""
+
+ database_url: str | None = field(default=None, repr=False)
+ confirm: str | None = field(default=None, repr=False)
+ problems: tuple[str, ...] = ()
+
+
+def config_file_path(config_path: Path | None = None) -> Path:
+ """읽을 설정 파일 — 명시 경로 > `SOLAR_EVAL_CONFIG` > 기본 경로(`<설정 홈>/solar-eval.yaml`) > 옛 경로(있을 때만, 경고).
+
+ 기본 경로에 파일이 없고 옛 `~/.upstage-cloud-dev/solar-eval-config.yaml` 이 있으면 옛 것을 읽는다 — 옮기기 전의 설정이
+ 조용히 사라지지 않게. 둘 다 없으면 기본 경로(없는 파일 = 빈 설정)다.
+ """
+ if config_path:
+ return config_path
+ env_path = os.environ.get("SOLAR_EVAL_CONFIG")
+ if env_path is not None:
+ return Path(env_path)
+ if DEFAULT_CONFIG_PATH.exists() or not LEGACY_CONFIG_PATH.exists():
+ return DEFAULT_CONFIG_PATH
+ _warn_legacy_once(LEGACY_CONFIG_PATH, DEFAULT_CONFIG_PATH)
+ return LEGACY_CONFIG_PATH
+
+
+@functools.cache
+def _warn_legacy_once(legacy: Path, primary: Path) -> None:
+ """옛 경로 경고는 경로 쌍마다 프로세스당 한 번 — `CLIConfig` 가 여러 번 만들어져도 반복하지 않는다."""
+ logger.warning(
+ "옛 설정 경로 %s 를 읽는다 — %s 로 옮긴다(make dev-docker-up 이 복사한다)", legacy, primary
+ )
+
+
+def parse_hub(yaml_cfg: Mapping[str, Any], path: Path | None = None) -> HubConfig | None:
+ """`hub:` 절 → `HubConfig`(없거나 비었으면 None). 모양 오류는 값 없이 `problems` 로(§2.6 "설정 모양 오류")."""
+ raw = yaml_cfg.get("hub")
+ if raw is None:
+ return None
+ if not isinstance(raw, Mapping):
+ return HubConfig(problems=("hub: 매핑이 아니다",))
+ problems = [f"hub.{_key_name(key)}: 모르는 키" for key in raw if key not in HUB_CONFIG_KEYS]
+ url = _hub_url(raw.get("database_url"), problems)
+ confirm = _hub_confirm(raw.get("confirm"), problems)
+ if url is not None and path is not None:
+ problems += _permission_problems(path)
+ return HubConfig(url, confirm, tuple(problems))
+
+
+def _key_name(key: Any) -> str:
+ return str(key)[:_KEY_NAME_MAX] if isinstance(key, str) else f"<{type(key).__name__}>"
+
+
+def _hub_url(value: Any, problems: list[str]) -> str | None:
+ """URL 문자열(앞뒤 공백 제거). 모양이 틀려도 문자열이면 돌려준다 — 끈 설정의 값도 가림·거부 대상이다."""
+ if value is None:
+ return None
+ if not isinstance(value, str):
+ problems.append("hub.database_url: 문자열이 아니다")
+ return None
+ url = value.strip()
+ if not url:
+ return None
+ if not url.startswith(HUB_URL_SCHEMES):
+ problems.append("hub.database_url: postgres:// 또는 postgresql:// URL 이 아니다")
+ return url
+
+
+def _hub_confirm(value: Any, problems: list[str]) -> str | None:
+ if value is None:
+ return None
+ if not isinstance(value, str):
+ problems.append("hub.confirm: 문자열이 아니다")
+ return None
+ return value.strip() or None
+
+
+def _permission_problems(path: Path) -> list[str]:
+ """자격증명 URL 을 담은 설정 파일이 그룹·다른 사용자에게 열려 있으면 문제 한 줄(값·경로 없음)."""
+ if os.name == "nt": # POSIX 권한 비트가 뜻을 갖지 않는다
+ return []
+ try:
+ mode = path.stat().st_mode
+ except OSError as exc:
+ return [f"설정 파일 권한을 확인할 수 없다({type(exc).__name__})"]
+ if mode & LOOSE_MODE_BITS:
+ return ["설정 파일이 소유자 전용이 아니다 — chmod 600 <설정 파일>"]
+ return []
+
+
def default_projects_dir() -> Path:
"""CBS 공용 데이터 홈 아래의 프로젝트 루트 (huggingface 캐시와 같은 방식).
- 기본값: ``~/.cache/cbs-data/solar-eval-projects``
- ``CBS_DATA_HOME`` 이 설정되면 데이터 홈 자체를 옮긴다: ``$CBS_DATA_HOME/solar-eval-projects``
- ``XDG_CACHE_HOME`` 이 설정되면 캐시 루트로 존중한다: ``$XDG_CACHE_HOME/cbs-data/solar-eval-projects``
+ - 빈 ``XDG_CACHE_HOME`` 은 XDG 규약대로 없는 것으로 본다 — ``poc_bundle.home.data_home`` 과 같은 규칙이다
+ (계획 2026-09-27 L6 Q-L6-17 — 전에는 상대 경로 ``cbs-data/…`` 로 풀려 호출 위치마다 달랐다).
"""
data_home = os.environ.get("CBS_DATA_HOME")
if data_home:
return Path(data_home).expanduser() / "solar-eval-projects"
- cache_root = os.environ.get("XDG_CACHE_HOME", "~/.cache")
+ cache_root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
return Path(cache_root).expanduser() / "cbs-data" / "solar-eval-projects"
@@ -66,7 +193,7 @@ def discover_config_dirs(repo_root: Path | None) -> dict[str, Path]:
def _load_yaml_config(config_path: Path | None = None) -> dict[str, Any]:
"""YAML 설정 파일 로드. 파일 없으면 빈 dict 반환."""
- path = config_path or Path(os.environ.get("SOLAR_EVAL_CONFIG", str(DEFAULT_CONFIG_PATH)))
+ path = config_file_path(config_path)
if not path.is_file():
return {}
try:
@@ -74,7 +201,8 @@ def _load_yaml_config(config_path: Path | None = None) -> dict[str, Any]:
data = yaml.safe_load(f)
return data if isinstance(data, dict) else {}
except Exception as e:
- logger.warning("Failed to load config from %s: %s", path, e)
+ # 예외 원문은 싣지 않는다 — YAML 오류 문구가 줄 조각(자격증명 URL 포함)을 담을 수 있다(L10 §2.6 "비밀").
+ logger.warning("Failed to load config from %s: %s", path, type(e).__name__)
return {}
@@ -83,13 +211,16 @@ class CLIConfig:
Priority (low → high):
1. Hard-coded defaults (projects_dir: ~/.cache/cbs-data/solar-eval-projects)
- 2. YAML config file (~/.upstage-cloud-dev/solar-eval-config.yaml)
- 3. Environment variables (SOLAR_EVAL_URL, CBS_DATA_HOME, SOLAR_EVAL_PROJECTS, etc.)
+ 2. YAML config file (~/.config/cbs-arsenal/solar-eval.yaml, 옛 ~/.upstage-cloud-dev/solar-eval-config.yaml) — `hub:` 절 포함(모듈 설명)
+ 3. Environment variables (SOLAR_EVAL_URL, CBS_DATA_HOME, SOLAR_EVAL_PROJECTS, HUB_DATABASE_URL, etc.)
4. CLI options (--remote, etc.)
"""
def __init__(self, config_path: Path | None = None) -> None:
- yaml_cfg = _load_yaml_config(config_path)
+ path = config_file_path(config_path)
+ yaml_cfg = _load_yaml_config(path)
+ #: run 종료 훅 hub 다리 opt-in(설정 출처) — 해석은 `hub_hook.optin.resolve_hub_optin`.
+ self.hub: HubConfig | None = parse_hub(yaml_cfg, path)
yaml_remote = yaml_cfg.get("remote_url") or yaml_cfg.get("remote")
self.remote_url: str | None = os.environ.get("SOLAR_EVAL_URL", yaml_remote)
@@ -126,6 +257,10 @@ class CLIConfig:
if openai_cfg.get("api_key") and not os.environ.get("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = openai_cfg["api_key"]
+ timely_cfg = yaml_cfg.get("timely", {})
+ if timely_cfg.get("api_key") and not os.environ.get("TIMELY_API_KEY"):
+ os.environ["TIMELY_API_KEY"] = timely_cfg["api_key"]
+
@property
def is_remote(self) -> bool:
return self.remote_url is not None
diff --git a/solar_eval/cli/formatters.py b/solar_eval/cli/formatters.py
index aece2d8da0bc0cadfa60f8983a14f51a605648b8..b48f78372898fee590a45b9cbe2761abf5692d04 100644
--- a/solar_eval/cli/formatters.py
+++ b/solar_eval/cli/formatters.py
@@ -29,36 +29,69 @@ def format_overall_score(score: float | None) -> str:
def format_projects_table(projects: list[dict[str, Any]]) -> str:
- rows = [[
- p.get("name", ""),
- p.get("display_name", ""),
- p.get("description", "")[:50],
- p.get("task_count", 0),
- ] for p in projects]
- return tabulate(rows, headers=["Name", "Display Name", "Description", "Tasks"], tablefmt="simple")
+ rows = [
+ [
+ p.get("name", ""),
+ p.get("display_name", ""),
+ p.get("description", "")[:50],
+ p.get("task_count", 0),
+ ]
+ for p in projects
+ ]
+ return tabulate(
+ rows, headers=["Name", "Display Name", "Description", "Tasks"], tablefmt="simple"
+ )
def format_prompts_table(prompts: list[dict[str, Any]]) -> str:
- rows = [[
- p.get("id", "")[:12],
- p.get("task", ""),
- f"v{p.get('version', '?')}",
- p.get("model", ""),
- p.get("temperature", ""),
- (p.get("system_prompt", "") or "")[:40] + "...",
- ] for p in prompts]
- return tabulate(rows, headers=["ID", "Task", "Version", "Model", "Temp", "Prompt Preview"], tablefmt="simple")
+ rows = [
+ [
+ p.get("id", "")[:12],
+ p.get("task", ""),
+ f"v{p.get('version', '?')}",
+ p.get("model", ""),
+ p.get("temperature", ""),
+ (p.get("system_prompt", "") or "")[:40] + "...",
+ ]
+ for p in prompts
+ ]
+ return tabulate(
+ rows,
+ headers=["ID", "Task", "Version", "Model", "Temp", "Prompt Preview"],
+ tablefmt="simple",
+ )
def format_runs_table(runs: list[dict[str, Any]]) -> str:
- rows = [[
- r.get("id", "")[:12],
- r.get("task", ""),
- format_status(r.get("status", "")),
- f"{r.get('completed_samples', 0)}/{r.get('total_samples', 0)}",
- r.get("created_at", "")[:19],
- ] for r in runs]
- return tabulate(rows, headers=["ID", "Task", "Status", "Progress", "Created"], tablefmt="simple")
+ rows = [
+ [
+ r.get("id", "")[:12],
+ r.get("task", ""),
+ format_status(r.get("status", "")),
+ f"{r.get('completed_samples', 0)}/{r.get('total_samples', 0)}",
+ r.get("created_at", "")[:19],
+ ]
+ for r in runs
+ ]
+ return tabulate(
+ rows, headers=["ID", "Task", "Status", "Progress", "Created"], tablefmt="simple"
+ )
+
+
+# 사용량·지연이 측정되지 않은 칸(`None`) -- 0 과 구분되게 찍는다(L6b B1 · D-M2-24).
+MISSING_CELL = "—"
+
+
+def _usage_cell(value: Any) -> Any:
+ """토큰 칸 -- `None` 이면 `—`, 그 밖은 그대로(키가 아예 없을 때의 빈 칸 포함)."""
+ return MISSING_CELL if value is None else value
+
+
+def format_latency(value: Any) -> str:
+ """지연 칸 -- 숫자일 때만 `…ms`, 그 밖(`None`·키 없음·문자열·불리언)은 `—`."""
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ return MISSING_CELL
+ return f"{value:.0f}ms"
def format_results_table(results: list[dict[str, Any]]) -> str:
@@ -66,15 +99,19 @@ def format_results_table(results: list[dict[str, Any]]) -> str:
for r in results:
inp = str(r.get("input", ""))[:40]
out = str(r.get("output", ""))[:40]
- rows.append([
- r.get("sample_idx", ""),
- inp + ("..." if len(inp) >= 40 else ""),
- out + ("..." if len(out) >= 40 else ""),
- r.get("input_tokens", ""),
- r.get("output_tokens", ""),
- f"{r.get('inference_time_ms', 0):.0f}ms",
- ])
- return tabulate(rows, headers=["#", "Input", "Output", "In Tok", "Out Tok", "Time"], tablefmt="simple")
+ rows.append(
+ [
+ r.get("sample_idx", ""),
+ inp + ("..." if len(inp) >= 40 else ""),
+ out + ("..." if len(out) >= 40 else ""),
+ _usage_cell(r.get("input_tokens", "")),
+ _usage_cell(r.get("output_tokens", "")),
+ format_latency(r.get("inference_time_ms")),
+ ]
+ )
+ return tabulate(
+ rows, headers=["#", "Input", "Output", "In Tok", "Out Tok", "Time"], tablefmt="simple"
+ )
def format_evaluation_detail(evaluation: dict[str, Any]) -> str:
@@ -91,14 +128,21 @@ def format_evaluation_detail(evaluation: dict[str, Any]) -> str:
def format_dashboard_table(dashboard: dict[str, Any]) -> str:
- rows = [[
- r.get("run_id", "")[:12],
- r.get("task", ""),
- f"v{r.get('prompt_version', '?')}",
- r.get("model", ""),
- format_overall_score(r.get("overall_score")),
- format_scores(r.get("scores")),
- r.get("total_samples", ""),
- r.get("date", "")[:10],
- ] for r in dashboard.get("runs", [])]
- return tabulate(rows, headers=["Run ID", "Task", "Version", "Model", "Score", "Metrics", "Samples", "Date"], tablefmt="simple")
+ rows = [
+ [
+ r.get("run_id", "")[:12],
+ r.get("task", ""),
+ f"v{r.get('prompt_version', '?')}",
+ r.get("model", ""),
+ format_overall_score(r.get("overall_score")),
+ format_scores(r.get("scores")),
+ r.get("total_samples", ""),
+ r.get("date", "")[:10],
+ ]
+ for r in dashboard.get("runs", [])
+ ]
+ return tabulate(
+ rows,
+ headers=["Run ID", "Task", "Version", "Model", "Score", "Metrics", "Samples", "Date"],
+ tablefmt="simple",
+ )
diff --git a/solar_eval/cli/main.py b/solar_eval/cli/main.py
index bea6a7de3c78a23cc1ff831317c99156f07d8c62..b5c901f701048fded3808b3625a0ec8ac70e5fd2 100644
--- a/solar_eval/cli/main.py
+++ b/solar_eval/cli/main.py
@@ -59,6 +59,7 @@ from solar_eval.cli.commands.runs import runs_group
from solar_eval.cli.commands.eval import eval_group
from solar_eval.cli.commands.dashboard import dashboard_group
from solar_eval.cli.commands.datasets import datasets_group
+from solar_eval.cli.commands.score import score_group
from solar_eval.cli.commands.compare import compare_command
from solar_eval.cli.commands.quickrun import quick_run
@@ -73,6 +74,8 @@ solar_eval_cli.add_command(datasets_group)
solar_eval_cli.add_command(compare_command)
solar_eval_cli.add_command(quick_run)
solar_eval_cli.add_command(sync_to_mongodb)
+# 오프라인 재채점(`score proofread-diff`) — 계획 2026-09-26 L3b §2.1.
+solar_eval_cli.add_command(score_group)
def entrypoint() -> None:
diff --git a/solar_eval/config.py b/solar_eval/config.py
index 679d9ccb85fecc6ae7465b0833f92a7adae1e6b8..921d9afa2aebcd8d342bb8eee022bb7ad380a53f 100644
--- a/solar_eval/config.py
+++ b/solar_eval/config.py
@@ -7,6 +7,9 @@ class ProviderConfig(BaseSettings):
upstage_api_key: str = ""
upstage_api_base: str = "https://api.upstage.ai/v1"
openai_api_key: str = ""
+ # Timely Router (OpenAI 호환 멀티모델 라우터) — 스텝 `model: timely:<모델>` 이 쓴다
+ timely_api_key: str = ""
+ timely_api_base: str = "https://api.timelyrouter.ai/v1"
hf_token: str = ""
default_model: str = "solar-pro2"
diff --git a/solar_eval/core/__pycache__/__init__.cpython-312.pyc b/solar_eval/core/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..452334e170db0b4b51bef04de4a1d7147bdab1ed
Binary files /dev/null and b/solar_eval/core/__pycache__/__init__.cpython-312.pyc differ
diff --git a/solar_eval/core/__pycache__/dataset_loader.cpython-312.pyc b/solar_eval/core/__pycache__/dataset_loader.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c602155b3ad6d716b02079198175faa33a8bc8e3
Binary files /dev/null and b/solar_eval/core/__pycache__/dataset_loader.cpython-312.pyc differ
diff --git a/solar_eval/core/__pycache__/pipeline_compose.cpython-312.pyc b/solar_eval/core/__pycache__/pipeline_compose.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..52055932813ecf6cb8fc5cbcf901e73e926be9dd
Binary files /dev/null and b/solar_eval/core/__pycache__/pipeline_compose.cpython-312.pyc differ
diff --git a/solar_eval/core/dataset_loader.py b/solar_eval/core/dataset_loader.py
index 0671e7567b79b32f33deef074681c7de4b7bf7ca..4dffb258bd8f6cede138df3a19536bb579b51524 100644
--- a/solar_eval/core/dataset_loader.py
+++ b/solar_eval/core/dataset_loader.py
@@ -1,6 +1,9 @@
import csv
+import hashlib
+import io
import json
import logging
+from dataclasses import dataclass
from pathlib import Path
from huggingface_hub import hf_hub_download, list_repo_files
@@ -10,6 +13,34 @@ from solar_eval.config import get_provider_config
logger = logging.getLogger(__name__)
+@dataclass(frozen=True)
+class LoadedJsonl:
+ """한 번 읽은 JSONL -- 파싱한 레코드와 **같은 바이트**의 sha256 (계획 D16).
+
+ `sha256` 은 64자 소문자 hex 다(`sha256:` 접두어 없음). 파일 sha 와 파싱이 서로
+ 다른 read 에서 나오면 그 사이에 파일이 바뀌었을 때 run.json 이 거짓을 적는다.
+ """
+
+ records: list[dict]
+ sha256: str
+ local_path: Path
+
+
+def parse_jsonl_bytes(data: bytes) -> list[dict]:
+ """JSONL 바이트를 레코드 목록으로. 오늘 `open()` 반복과 **같은 줄 규칙**이다.
+
+ universal newlines(`\n`·`\r\n`·단독 `\r` 모두 줄 끝)로 줄을 나누고, `strip()` 뒤
+ 빈 줄은 건너뛴다 -- `sample_idx`(레코드 위치)가 옛 run 과 어긋나지 않아야 한다.
+ """
+ records = []
+ with io.TextIOWrapper(io.BytesIO(data), encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if line:
+ records.append(json.loads(line))
+ return records
+
+
class DatasetLoader:
"""Loads datasets from HuggingFace repos or local filesystem."""
@@ -37,24 +68,34 @@ class DatasetLoader:
return self.base_dir / repo / path
return repo_path / path
- def load_jsonl(
+ def read_jsonl(
self, repo: str, path: str, source: str = "huggingface", force: bool = False
- ) -> list[dict]:
- """Load and parse a JSONL file from HuggingFace or local filesystem."""
+ ) -> LoadedJsonl:
+ """JSONL 을 **바이트로 한 번** 읽어 sha256 과 레코드를 함께 돌려준다 (D16).
+
+ sha 는 `hashlib` 로 계산한다 -- 이 모듈은 벤더 사본(HF Space)에 실리므로
+ poc-bundle 패키지를 가져오지 않는다(D18).
+ """
if source == "local":
local_path = self._resolve_local_path(repo, path)
if not local_path.exists():
raise FileNotFoundError(f"Local dataset not found: {local_path}")
else:
local_path = self.download_file(repo, path, force=force)
- records = []
- with open(local_path, encoding="utf-8") as f:
- for line in f:
- line = line.strip()
- if line:
- records.append(json.loads(line))
+ data = Path(local_path).read_bytes()
+ records = parse_jsonl_bytes(data)
logger.info(f"Loaded {len(records)} records from {local_path}")
- return records
+ return LoadedJsonl(
+ records=records,
+ sha256=hashlib.sha256(data).hexdigest(),
+ local_path=Path(local_path),
+ )
+
+ def load_jsonl(
+ self, repo: str, path: str, source: str = "huggingface", force: bool = False
+ ) -> list[dict]:
+ """Load and parse a JSONL file from HuggingFace or local filesystem."""
+ return self.read_jsonl(repo, path, source=source, force=force).records
def load_csv(
self, repo: str, path: str, source: str = "huggingface", force: bool = False
diff --git a/solar_eval/core/dataset_prep.py b/solar_eval/core/dataset_prep.py
new file mode 100644
index 0000000000000000000000000000000000000000..969085b95e2fb8647a9cb5623b33fe89065507ff
--- /dev/null
+++ b/solar_eval/core/dataset_prep.py
@@ -0,0 +1,169 @@
+"""run 시작 전 데이터셋 준비 -- 한 번 읽고, 고르고, 행 메타 규칙을 검증한다 (계획 D3·D13·D16).
+
+CLI(`runs start/batch`)는 run 디렉토리를 만들기 **전에** `prepare_dataset` 을 불러
+0행 선택·라벨 누락·`meta_fields` 오류를 먼저 터뜨리고, 같은 `PreparedDataset` 을
+러너(`execute_run(prepared=...)`)와 run.json 기록(sha256·행 수·선택)에 함께 넘긴다.
+그래서 run.json 의 sha256 과 실제로 돈 행이 **같은 바이트**에서 나온다.
+
+러너는 `prepared` 가 없으면(서비스·옛 호출자) 같은 함수로 스스로 준비한다.
+
+이 모듈은 poc-bundle 패키지와 packaging 을 가져오지 않는다(D18).
+"""
+
+from __future__ import annotations
+
+import copy
+from dataclasses import dataclass
+from typing import Any, Mapping
+
+from solar_eval.core.dataset_loader import DatasetLoader, LoadedJsonl
+from solar_eval.core.row_filter import (
+ RowSelection,
+ SelectionError,
+ Where,
+ combine,
+ format_indices,
+ parse_where,
+ select,
+)
+
+
+class DatasetPrepError(ValueError):
+ """태스크의 행 메타·where 선언이 규칙에 맞지 않을 때 -- run 을 시작하지 않는다."""
+
+
+@dataclass(frozen=True)
+class PreparedDataset:
+ """run 이 돌 행 집합. `selected` 는 `(원본 파일의 0-based 위치, 행)` 튜플들이다."""
+
+ loaded: LoadedJsonl
+ selected: tuple[tuple[int, dict[str, Any]], ...]
+ rows_total: int
+ meta_fields: tuple[str, ...] | None
+ selection: RowSelection | None = None
+
+ @property
+ def rows_selected(self) -> int:
+ return len(self.selected)
+
+ @property
+ def sha256(self) -> str:
+ return self.loaded.sha256
+
+ @property
+ def selected_indices(self) -> frozenset[int]:
+ return frozenset(idx for idx, _ in self.selected)
+
+
+def reject_defaults_where(project_config: Mapping[str, Any]) -> None:
+ """`defaults.where` 를 거부한다 (D1).
+
+ `_resolve_defaults` 는 키 단위 얕은 병합이라 `defaults.where` 가 라벨 없는 태스크에도
+ 퍼져 그 run 이 전부 죽고, 태스크에 where 가 있으면 통째로 무시된다("병합"이 아니라
+ "대체"). 로드된 dict 에 `defaults` 키가 병합 뒤에도 남아 있으므로 그것을 본다.
+ 로드 시점이 아니라 run 시작 시 검사한다 -- 한 프로젝트의 잘못된 선언이 다른
+ 프로젝트 로드를 막지 않게.
+ """
+ defaults = project_config.get("defaults") or {}
+ if isinstance(defaults, Mapping) and "where" in defaults:
+ name = project_config.get("name", "?")
+ raise DatasetPrepError(
+ f"project {name!r}: defaults.where is not allowed -- "
+ "declare where on a task or pass --where on the CLI"
+ )
+
+
+def _data_keys(task_config: Mapping[str, Any]) -> set[str]:
+ """입력·정답으로 이미 쓰는 데이터 행 키 -- meta 로 중복 복제하지 않는다."""
+ keys = set(task_config.get("input_fields") or [])
+ golden_field = task_config.get("golden_field")
+ if golden_field:
+ keys.add(golden_field)
+ golden_fields = task_config.get("golden_fields") or {}
+ if isinstance(golden_fields, Mapping):
+ keys.update(v for v in golden_fields.values() if isinstance(v, str))
+ return keys
+
+
+def validate_meta_fields(task_config: Mapping[str, Any]) -> tuple[str, ...] | None:
+ """태스크 `meta_fields` 검증 (D13) -- 비어 있지 않은 문자열 리스트, 중복·입력/정답 겹침 불가.
+
+ 선언이 없으면 `None`(results 행에 `meta` 키를 쓰지 않는다 -- 다른 프로젝트의 바이트 불변).
+ """
+ raw = task_config.get("meta_fields")
+ if raw is None:
+ return None
+ task = task_config.get("name", "?")
+ if not isinstance(raw, list) or not raw:
+ raise DatasetPrepError(f"task {task!r}: meta_fields must be a non-empty list of strings")
+ if not all(isinstance(f, str) and f.strip() for f in raw):
+ raise DatasetPrepError(f"task {task!r}: meta_fields values must be non-empty strings")
+ duplicates = sorted({f for f in raw if raw.count(f) > 1})
+ if duplicates:
+ raise DatasetPrepError(f"task {task!r}: meta_fields has duplicates: {duplicates}")
+ overlap = sorted(set(raw) & _data_keys(task_config))
+ if overlap:
+ raise DatasetPrepError(f"task {task!r}: meta_fields overlap input/golden fields: {overlap}")
+ return tuple(raw)
+
+
+def row_meta(row: Mapping[str, Any], meta_fields: tuple[str, ...] | None) -> dict[str, Any] | None:
+ """데이터 행에서 `meta_fields` 키만 골라 복사한다. 행에 없는 키는 **생략**한다.
+
+ 값은 깊은 복사본이다 -- 결과 행이 데이터셋 행 객체를 공유하지 않게.
+ """
+ if meta_fields is None:
+ return None
+ return {key: copy.deepcopy(row[key]) for key in meta_fields if key in row}
+
+
+def build_selection(
+ task_config: Mapping[str, Any], cli_where: Where | None = None, limit: int | None = None
+) -> RowSelection:
+ """태스크 `where` 와 CLI where(`Where` 또는 `None`)를 합쳐 run 의 선택을 만든다.
+
+ 선택은 CLI 가 **명시적으로** 만들어 러너에 넘긴다 -- 러너는 `task_config["where"]` 를
+ 스스로 읽지 않는다(D3).
+ """
+ task_name = task_config.get("name", "?")
+ task_where = parse_where(task_config.get("where"), origin=f"task {task_name!r}")
+ return RowSelection(where=combine(task_where, cli_where), limit=limit or None)
+
+
+def prepare_dataset(
+ loader: DatasetLoader,
+ project_config: Mapping[str, Any],
+ task_config: Mapping[str, Any],
+ selection: RowSelection | None,
+) -> PreparedDataset:
+ """데이터셋을 한 번 읽고 선택을 적용한다. 0행·라벨 누락·meta 오류는 여기서 터진다."""
+ meta_fields = validate_meta_fields(task_config)
+ dataset_config = project_config.get("dataset") or {}
+ loaded = loader.read_jsonl(
+ dataset_config.get("repo", ""),
+ task_config.get("dataset_path", ""),
+ source=dataset_config.get("source", "huggingface"),
+ )
+ selected = select(loaded.records, selection)
+ return PreparedDataset(
+ loaded=loaded,
+ selected=tuple(selected),
+ rows_total=len(loaded.records),
+ meta_fields=meta_fields,
+ selection=selection,
+ )
+
+
+def check_resume_indices(prepared: PreparedDataset, completed_indices: set[int]) -> None:
+ """이미 끝난 `sample_idx` 가 선택 밖이면 실패한다 (D3 resume 가드).
+
+ 선택 밖 결과가 섞인 results 는 이 run 의 선택으로 설명되지 않는다 -- 선택이나
+ 데이터셋이 run 시작 뒤 바뀌었다는 뜻이다.
+ """
+ outside = sorted(completed_indices - prepared.selected_indices)
+ if outside:
+ raise SelectionError(
+ "completed results are outside this run's selection "
+ f"({format_indices(outside)}) "
+ "-- the selection or dataset changed since the run started; start a new run"
+ )
diff --git a/solar_eval/core/fingerprint.py b/solar_eval/core/fingerprint.py
index 43760a3cef0a7c3be5d6306df3edb13195eb41b0..86f2fc858d44e2c54b248da6daa1b20fa5919c8a 100644
--- a/solar_eval/core/fingerprint.py
+++ b/solar_eval/core/fingerprint.py
@@ -3,7 +3,7 @@
왜 필요한가: 이 엔진의 자산은 오래 이름으로만 식별돼 왔고(`prompt_dev_v23`
↔ `pipeline_dev_v24` 처럼 짝의 번호가 어긋나는 일이 잦았다), 그래서
"이름이 다른 두 run 이 사실 같은 설정" 인지를 사람이 해시를 떠서 대조해야 했다
-(`insights/lab_notes_archive.md#260803_etd_regression_prod_vs_v24` 의 '이름 매핑' 절이 그 흔적이다).
+(`insights/260803_etd_regression_prod_vs_v24.md` 의 '이름 매핑' 절이 그 흔적이다).
run 에 지문을 찍어 두면 그 대조가 조회 한 번으로 끝난다.
지문은 **해석이 끝난 설정**을 대상으로 계산한다. `extends`/override 로 조립한
@@ -16,7 +16,7 @@ run 에 지문을 찍어 두면 그 대조가 조회 한 번으로 끝난다.
import hashlib
import json
-from collections.abc import Mapping, Sequence
+from collections.abc import Iterator, Mapping, Sequence
from pathlib import Path
from typing import Any
@@ -84,16 +84,28 @@ def prompt_fingerprint(path: Path) -> str | None:
return None
-def _collect_prompt_assets(root: Path) -> dict[str, str]:
- """프롬프트 디렉토리의 `{상대경로: 파일지문}` 맵. 숨김 파일은 제외한다."""
- assets: dict[str, str] = {}
+def iter_prompt_asset_files(root: Path) -> Iterator[Path]:
+ """프롬프트 디렉토리에서 지문 대상 파일을 경로 정렬 순서로 낸다.
+
+ 규칙: `PROMPT_ASSET_SUFFIXES` 확장자(대소문자 무시)의 일반 파일, `root` 기준 경로의
+ 어느 부분이든 `.` 으로 시작하면(숨김) 제외. legacy `prompt_sha` 와 스냅샷의
+ `prompt_assets`(`core/snapshot/resources.py`)가 **같은 파일 집합**을 보도록 이 함수
+ 하나를 공유한다(계획 2026-09-24-phase1a D12).
+ """
for file in sorted(root.rglob("*")):
if not file.is_file() or file.suffix.lower() not in PROMPT_ASSET_SUFFIXES:
continue
if any(part.startswith(".") for part in file.relative_to(root).parts):
continue
- assets[file.relative_to(root).as_posix()] = _file_digest(file)
- return assets
+ yield file
+
+
+def _collect_prompt_assets(root: Path) -> dict[str, str]:
+ """프롬프트 디렉토리의 `{상대경로: 파일지문}` 맵. 숨김 파일은 제외한다."""
+ return {
+ file.relative_to(root).as_posix(): _file_digest(file)
+ for file in iter_prompt_asset_files(root)
+ }
def _file_digest(path: Path) -> str:
diff --git a/solar_eval/core/pipeline_factory.py b/solar_eval/core/pipeline_factory.py
new file mode 100644
index 0000000000000000000000000000000000000000..5adf6d683bd38d3d5f1e44d1383da3da506037f4
--- /dev/null
+++ b/solar_eval/core/pipeline_factory.py
@@ -0,0 +1,54 @@
+"""태스크 → 파이프라인 생성 한 곳 (계획 D6·검토 F11).
+
+CLI 와 러너가 파이프라인을 따로 만들면 `project_dir`·`config_dir` 해석이 갈릴 수 있다.
+그래서 생성 규칙을 여기 하나로 두고, CLI 는 한 번 만든 인스턴스를 스냅샷
+(`step_impl_hash`·`resources`)과 `execute_run(pipeline=...)` 에 **같이** 넘긴다.
+러너는 `pipeline` 인자가 없으면(서비스·옛 호출자) 이 함수로 만든다.
+
+이 모듈은 poc-bundle 패키지와 packaging 을 가져오지 않는다(D18).
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Mapping
+
+from solar_eval.core.dataset_loader import DatasetLoader
+from solar_eval.pipelines.base import BasePipeline
+from solar_eval.pipelines.registry import create_pipeline
+
+
+def task_project_dir(
+ project_config: Mapping[str, Any], dataset_loader: DatasetLoader
+) -> Path | None:
+ """데이터 루트 기준 프로젝트 디렉토리 -- v24 `tool_calling_judge` 가 `pmi_lookup` 상대경로를 풀 때 쓴다.
+
+ `dataset_loader.base_dir` 이 projects 루트이므로 project 이름을 붙이면 project_dir 이
+ 된다 -- CLI(`_start_local`)가 이미 쓰는 것과 같은 관례(§5-F).
+ """
+ project_name = project_config.get("name")
+ if dataset_loader.base_dir and project_name:
+ return dataset_loader.base_dir / project_name
+ return None
+
+
+def build_task_pipeline(
+ project_config: Mapping[str, Any],
+ task_config: Mapping[str, Any],
+ prompt: Mapping[str, Any],
+ dataset_loader: DatasetLoader,
+) -> BasePipeline:
+ """태스크 설정으로 실행할 파이프라인을 만든다. 모르는 스텝 이름은 여기서 죽는다(run 전).
+
+ `config_dir` 은 레포 관리 프로젝트면 project_loader 가 채워 둔 config 정본 경로
+ (03-evaluation) -- 치환 사전 같은 지식 자산이 여기서 온다.
+ """
+ return create_pipeline(
+ pipeline_type=task_config.get("pipeline", "single_step"),
+ input_fields=task_config.get("input_fields", []),
+ pipeline_config=task_config.get("pipeline_config"),
+ prompts=prompt.get("step_prompts", {}),
+ dataset_loader=dataset_loader,
+ project_dir=task_project_dir(project_config, dataset_loader),
+ config_dir=project_config.get("config_dir"),
+ )
diff --git a/solar_eval/core/row_filter.py b/solar_eval/core/row_filter.py
new file mode 100644
index 0000000000000000000000000000000000000000..e0e954f3cbeabb25ead7e6ec27b7984de5ef5577
--- /dev/null
+++ b/solar_eval/core/row_filter.py
@@ -0,0 +1,288 @@
+"""태그 행 필터 -- `where: {tags, category}` 문법과 행 선택 (계획 D1~D3).
+
+문법은 두 키로 닫혀 있다:
+
+- `tags`: **AND** -- 행의 `tags` 가 나열한 태그를 모두 가져야 한다(다중값 라벨).
+- `category`: **OR** -- 행의 `category`(행마다 하나)가 목록 중 하나여야 한다.
+- 둘 다 있으면 AND.
+
+선택 순서는 `where → limit(앞에서 N개)` 이고, 선택된 행은 **원본 파일의 0-based 레코드
+위치**(`sample_idx`)를 그대로 들고 간다 -- Experiment Hub 조인·resume·eval_details 가 모두 이
+번호를 정본으로 쓰기 때문이다(D2).
+
+오류 메시지는 `sample_idx` 와 키 이름·타입 이름만 싣고 **행 본문을 싣지 않는다**
+(고객 텍스트가 로그·예외로 새지 않게).
+
+이 모듈은 poc-bundle 패키지와 packaging 을 가져오지 않는다(D18 -- HF Space 벤더 사본).
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Iterable, Mapping, Sequence
+
+WHERE_KEYS: tuple[str, ...] = ("tags", "category")
+# 오류 메시지에 싣는 문제 행 번호의 최대 개수 -- 전부 나열하면 메시지가 데이터셋 크기만큼 길어진다.
+_MAX_REPORTED_IDX = 5
+
+
+class SelectionError(ValueError):
+ """where 문법 오류, 라벨이 덜 붙은 데이터셋, 0행 선택 등 -- run 을 시작하지 않는다."""
+
+
+@dataclass(frozen=True)
+class Where:
+ """정규화된 행 필터. 값은 정렬·중복 제거된 튜플이고, 지정 안 한 키는 `None` 이다."""
+
+ tags: tuple[str, ...] | None = None
+ category: tuple[str, ...] | None = None
+
+ def is_empty(self) -> bool:
+ return self.tags is None and self.category is None
+
+ def to_json(self) -> dict[str, list[str]]:
+ """정규화 JSON -- 키 정렬(D3: category → tags), 값 정렬, 지정 안 한 키는 생략."""
+ out: dict[str, list[str]] = {}
+ for key in sorted(WHERE_KEYS):
+ values = getattr(self, key)
+ if values is not None:
+ out[key] = list(values)
+ return out
+
+ def describe(self) -> str:
+ """사람용 한 줄 표기: `category=c tags=a,b` -- 키 순서는 `to_json` 과 같다(정렬)."""
+ return " ".join(f"{k}={','.join(v)}" for k, v in self.to_json().items())
+
+
+@dataclass(frozen=True)
+class RowSelection:
+ """run 이 돌 행 집합의 정의: `where`(없으면 전체) 뒤 앞에서 `limit` 개."""
+
+ where: Where | None = None
+ limit: int | None = None
+
+ def __post_init__(self) -> None:
+ if self.where is not None and self.where.is_empty():
+ # 빈 Where 와 None 이 같은 뜻인데 서로 다르게 비교되면 resume 가드가 흔들린다.
+ object.__setattr__(self, "where", None)
+ limit = self.limit
+ if limit is not None and (isinstance(limit, bool) or not isinstance(limit, int)):
+ raise SelectionError(f"limit must be a positive integer, got {type(limit).__name__}")
+ if limit is not None and limit < 1:
+ raise SelectionError(f"limit must be a positive integer, got {limit}")
+
+ def to_json(self) -> dict[str, Any]:
+ """run.json `dataset.selection` 에 기록하는 정규화 형태."""
+ return {
+ "where": self.where.to_json() if self.where is not None else None,
+ "limit": self.limit,
+ }
+
+ @classmethod
+ def from_json(cls, obj: Any) -> RowSelection:
+ """`to_json()` 의 역. resume 이 run.json 에 기록된 선택을 되살릴 때 쓴다."""
+ if not isinstance(obj, Mapping):
+ raise SelectionError(f"selection must be an object, got {type(obj).__name__}")
+ unknown = sorted(set(obj) - {"where", "limit"})
+ if unknown:
+ raise SelectionError(f"selection has unknown keys: {unknown}")
+ where = parse_where(obj.get("where"), origin="recorded selection")
+ return cls(where=where, limit=obj.get("limit"))
+
+
+def _parse_values(key: str, raw: Any, origin: str) -> tuple[str, ...]:
+ """where 값 하나 검증: 비어 있지 않은 문자열의 비어 있지 않은 리스트, 중복 불가.
+
+ 값은 앞뒤 공백을 떼어 저장한다 -- CLI(`parse_cli_where`)도 떼므로 YAML 과 CLI 가 같은
+ 값으로 정규화된다. 중복 검사도 뗀 값으로 한다.
+ """
+ if not isinstance(raw, list) or not raw:
+ raise SelectionError(f"{origin}: where.{key} must be a non-empty list of strings")
+ for value in raw:
+ if not isinstance(value, str) or not value.strip():
+ raise SelectionError(f"{origin}: where.{key} values must be non-empty strings")
+ values = [value.strip() for value in raw]
+ duplicates = sorted({v for v in values if values.count(v) > 1})
+ if duplicates:
+ raise SelectionError(f"{origin}: where.{key} has duplicate values: {duplicates}")
+ return tuple(sorted(values))
+
+
+def parse_where(obj: Any, origin: str) -> Where | None:
+ """`project.yaml` 태스크의 `where` 객체(또는 기록된 선택)를 검증해 `Where` 로 만든다.
+
+ `None` 은 "필터 없음". 모르는 키·빈 리스트·중복은 오류다(문법이 닫혀 있어야 오타가
+ 조용히 0행이 되지 않는다).
+ """
+ if obj is None:
+ return None
+ if not isinstance(obj, Mapping):
+ raise SelectionError(f"{origin}: where must be an object, got {type(obj).__name__}")
+ unknown = sorted(str(k) for k in set(obj) - set(WHERE_KEYS))
+ if unknown:
+ raise SelectionError(
+ f"{origin}: where has unknown keys {unknown} (allowed: tags, category)"
+ )
+ if not obj:
+ raise SelectionError(f"{origin}: where is empty -- omit it instead")
+ parsed = {key: _parse_values(key, obj[key], origin) for key in WHERE_KEYS if key in obj}
+ return Where(**parsed)
+
+
+def parse_cli_where(values: Iterable[str]) -> Where | None:
+ """CLI `--where key=v1,v2` (반복 가능)를 하나의 `Where` 로 접는다.
+
+ 반복은 AND 다: `tags` 는 합집합(모두 가져야 함), `category` 는 교집합 -- `combine` 과
+ 같은 규칙이다.
+ """
+ result: Where | None = None
+ for raw in values:
+ key, sep, rest = raw.partition("=")
+ key = key.strip()
+ if not sep:
+ raise SelectionError(f"--where {raw!r}: expected key=value1,value2")
+ if key not in WHERE_KEYS:
+ raise SelectionError(f"--where {raw!r}: unknown key {key!r} (allowed: tags, category)")
+ items = [part.strip() for part in rest.split(",")]
+ if not rest.strip() or any(not part for part in items):
+ raise SelectionError(f"--where {raw!r}: empty value")
+ one = parse_where({key: items}, origin=f"--where {raw!r}")
+ result = combine(result, one)
+ return result
+
+
+def combine(task: Where | None, cli: Where | None) -> Where | None:
+ """태스크 where 와 CLI where 를 AND 로 합친다.
+
+ `tags` 는 합집합(모두 가져야 함), `category` 는 교집합(둘 다 있을 때). 교집합이 비면
+ 어떤 행도 고를 수 없으므로 run 을 시작하기 전에 실패한다.
+ """
+ if task is None or task.is_empty():
+ return cli if cli is not None and not cli.is_empty() else None
+ if cli is None or cli.is_empty():
+ return task
+ tags = _merge_optional(task.tags, cli.tags, lambda a, b: a | b)
+ category = _merge_optional(task.category, cli.category, lambda a, b: a & b)
+ if category is not None and not category:
+ raise SelectionError(
+ f"where category has no overlap: {list(task.category or ())} "
+ f"vs {list(cli.category or ())}"
+ )
+ return Where(tags=tags, category=category)
+
+
+def _merge_optional(a, b, op) -> tuple[str, ...] | None:
+ if a is None:
+ return b
+ if b is None:
+ return a
+ return tuple(sorted(op(set(a), set(b))))
+
+
+def format_indices(indices: Sequence[int]) -> str:
+ """오류 메시지용 `sample_idx 1, 4, ... (+N more)` -- 행 본문 없이 번호만."""
+ shown = ", ".join(str(i) for i in indices[:_MAX_REPORTED_IDX])
+ more = (
+ f" (+{len(indices) - _MAX_REPORTED_IDX} more)" if len(indices) > _MAX_REPORTED_IDX else ""
+ )
+ return f"sample_idx {shown}{more}"
+
+
+def _check_labels(records: Sequence[Any], where: Where) -> None:
+ """where 가 보는 키가 모든 행에 올바른 타입으로 있는지 -- 덜 붙은 라벨을 조용히 자르지 않는다."""
+ problems: dict[str, list[int]] = {}
+ for idx, row in enumerate(records):
+ if not isinstance(row, Mapping):
+ problems.setdefault("row is not an object", []).append(idx)
+ continue
+ if where.tags is not None:
+ _check_tags(row, idx, problems)
+ if where.category is not None:
+ _check_category(row, idx, problems)
+ if problems:
+ details = "; ".join(
+ f"{reason} at {format_indices(idx)}" for reason, idx in problems.items()
+ )
+ raise SelectionError(
+ f"dataset rows cannot be filtered by where {where.describe()}: {details}"
+ )
+
+
+def _check_tags(row: Mapping[str, Any], idx: int, problems: dict[str, list[int]]) -> None:
+ if "tags" not in row:
+ problems.setdefault("missing 'tags'", []).append(idx)
+ elif not isinstance(row["tags"], list):
+ problems.setdefault("'tags' is not a list", []).append(idx)
+ elif not all(isinstance(t, str) for t in row["tags"]):
+ problems.setdefault("'tags' has a non-string element", []).append(idx)
+
+
+def _check_category(row: Mapping[str, Any], idx: int, problems: dict[str, list[int]]) -> None:
+ if "category" not in row:
+ problems.setdefault("missing 'category'", []).append(idx)
+ elif row["category"] is not None and not isinstance(row["category"], str):
+ # `category: null`(apply --lenient 가 남기는 행)은 타입 오류가 아니라 불일치다.
+ problems.setdefault("'category' is not a string", []).append(idx)
+
+
+def _absent_values(records: Sequence[Mapping[str, Any]], where: Where) -> dict[str, list[str]]:
+ """where 값 중 데이터셋의 **어느 행에도** 없는 값 -- 오타·대소문자 차이를 잡는다.
+
+ 필터 결과가 아니라 파일 전체와 대조한다: `tags=a, category=x,y` 에서 `y` 가 파일엔 있지만
+ `a` 태그 행에만 없는 것은 정당한 빈 조합이지 오타가 아니다. 라벨 검사(`_check_labels`)를
+ 통과한 뒤에 부르므로 타입은 이미 맞다.
+ """
+ absent: dict[str, list[str]] = {}
+ if where.category is not None:
+ present = {row["category"] for row in records}
+ missing = [v for v in where.category if v not in present]
+ if missing:
+ absent["category"] = missing
+ if where.tags is not None:
+ present = {tag for row in records for tag in row["tags"]}
+ missing = [v for v in where.tags if v not in present]
+ if missing:
+ absent["tags"] = missing
+ return absent
+
+
+def _describe_absent(absent: dict[str, list[str]]) -> str:
+ return "; ".join(f"{key} {values}" for key, values in absent.items())
+
+
+def _matches(row: Mapping[str, Any], where: Where) -> bool:
+ if where.tags is not None and not set(where.tags).issubset(row["tags"]):
+ return False
+ return where.category is None or row["category"] in where.category
+
+
+def select(
+ records: Sequence[dict[str, Any]], selection: RowSelection | None
+) -> list[tuple[int, dict[str, Any]]]:
+ """선택을 적용해 `(원본 idx, 행)` 목록을 돌려준다. 순서는 원본 순서.
+
+ `selection` 이 `None` 이면 전체. where 가 있는데 0행이면 `SelectionError`
+ (조용히 빈 run 을 만들지 않는다). where 없이 빈 파일인 경우는 오늘 동작 그대로 둔다.
+ where 값 중 파일의 어느 행에도 없는 값이 있어도 `SelectionError` 다 -- `category` 는
+ OR 라 오타 하나가 0행이 아니라 "조용히 줄어든 선택"이 되기 때문이다(D1 이 표현식
+ 문자열을 버린 것과 같은 이유).
+ """
+ indexed = list(enumerate(records))
+ if selection is None:
+ return indexed
+ where = selection.where
+ if where is not None:
+ _check_labels(records, where)
+ absent = _absent_values(records, where)
+ indexed = [(idx, row) for idx, row in indexed if _matches(row, where)]
+ if not indexed:
+ hint = f" (not in any row: {_describe_absent(absent)})" if absent else ""
+ raise SelectionError(f"where {where.describe()} matched 0 of {len(records)} rows{hint}")
+ if absent:
+ raise SelectionError(
+ f"where value(s) not in any of {len(records)} rows: {_describe_absent(absent)} "
+ "-- check spelling/case, or drop them"
+ )
+ if selection.limit is not None:
+ indexed = indexed[: selection.limit]
+ return indexed
diff --git a/solar_eval/core/run_records.py b/solar_eval/core/run_records.py
new file mode 100644
index 0000000000000000000000000000000000000000..4a6c5175d763f6958d41c18d8548e92ad9a6ac2b
--- /dev/null
+++ b/solar_eval/core/run_records.py
@@ -0,0 +1,205 @@
+"""run 산출물 레코드 조립 -- results 행 · eval_details 행 · evaluation 문서.
+
+러너(`execute_run`)와 `runs eval`(재채점) CLI 가 같은 모양을 쓰도록 한 곳에 둔다
+(예전엔 두 곳에 복붙돼 있었다). 저장 키 이름은 eval-store 의 `resultRowSchema`·
+`evaluationJsonSchema` 와 하위 호환이어야 하므로 바꾸지 않는다.
+
+`evaluation` 문서의 `eval_model` 은 **인자**다 -- 어떤 judge 모델로 채점했는지는
+호출자(평가기)가 안다. 이 모듈이 값을 지어내지 않는다.
+
+이 모듈은 poc-bundle 패키지와 packaging 을 가져오지 않는다(D18).
+"""
+
+from __future__ import annotations
+
+from typing import Any, Mapping, Sequence
+
+from solar_eval.models.sample import EvalSample
+
+#: run.json 에서 추론 실패 샘플의 예외 타입 목록이 사는 키(LP5 D-LP5-4). 이름의 정본은 여기 한 곳이다 --
+#: CLI 의 실패 지문 도우미(`cli/commands/failure_keys.py`)도 이 상수로 읽는다.
+FAILED_SAMPLE_ERRORS_KEY = "failed_sample_errors"
+
+
+def build_failure_errors(errors: Sequence[tuple[int, BaseException]]) -> list[dict[str, Any]]:
+ """추론 실패 `(원본 sample_idx, 예외)` 목록 → run.json `failed_sample_errors` 원소들.
+
+ 원소는 `{"sample_idx", "error_type"}` 두 키이고 `error_type` 은 예외 **타입 이름**뿐이다 --
+ 메시지는 입력 조각을 담을 수 있어 싣지 않는다. `sample_idx` 오름차순, 실패가 없으면 `[]`.
+ """
+ ordered = sorted(errors, key=lambda pair: pair[0])
+ return [{"sample_idx": idx, "error_type": type(exc).__name__} for idx, exc in ordered]
+
+
+def extract_input(sample: Mapping[str, Any], input_fields: Sequence[str]) -> dict[str, Any]:
+ """데이터 행에서 `input_fields` 만 골라 입력 dict 를 만든다(없는 키는 빈 문자열)."""
+ return {field: sample.get(field, "") for field in input_fields}
+
+
+def extract_golden(sample: Mapping[str, Any], task_config: Mapping[str, Any]) -> Any:
+ """데이터 행에서 정답(golden)을 꺼낸다 -- `golden_field` 우선, 다음 `golden_fields`."""
+ golden_field = task_config.get("golden_field")
+ golden_fields = task_config.get("golden_fields")
+ if golden_field:
+ return sample.get(golden_field, "")
+ if golden_fields:
+ return {k: sample.get(v, "") for k, v in golden_fields.items()}
+ return ""
+
+
+def build_eval_sample_from_result(result: dict[str, Any], reference: Any) -> EvalSample:
+ """저장된 결과 dict(레거시 8+trace 키)에서 채점용 `EvalSample` 을 되살린다.
+
+ `insert_run_result` 가 쓰는 키 이름(`golden`/`trace`)은 `EvalSample` 필드 이름
+ (`reference`/`artifacts`)과 다르다 -- eval-store 하위 호환을 위해 저장 스키마는
+ 그대로 두고(마이그레이션 계획 §9-1) 채점 직전에만 여기서 되돌린다. `execute_run`
+ 내부 루프와 `runs eval` CLI(재채점, 새 추론 없이 디스크의 `results.jsonl` 을
+ 그대로 씀) 양쪽에서 공유한다.
+
+ Args:
+ result: `insert_run_result` 에 넘긴 것과 같은 형태(또는 `JsonlStore.
+ load_existing_results()` 로 디스크에서 다시 읽은 것). `input`/`output`/
+ `trace` 키를 읽는다.
+ reference: 정답(golden) 값. 호출자가 직접 넘긴다 -- 두 호출자의 golden
+ 추출 경로가 다르기 때문이다(아래 참고). 이 함수는 어느 쪽인지 모른 채
+ 값만 받아 그대로 `sample.reference` 에 채운다.
+
+ - `execute_run` 내부 루프: `_golden_raw`(디스크에 안 남는 실행 중
+ 임시 키, resume 시에도 매 샘플마다 새로 계산됨)를 넘긴다.
+ - `runs eval` CLI: 디스크에 저장된 `golden` 키를 그대로 넘긴다
+ (`_golden_raw` 는 애초에 저장되지 않아 CLI 쪽엔 없다).
+
+ Returns:
+ `input`/`output`/`reference`/`artifacts` 가 채워진 `EvalSample`.
+ `contexts` 는 아직 아무도 안 써서 기본값(`None`) 그대로다.
+ """
+ trace = result.get("trace") or {}
+ return EvalSample(
+ input=result.get("input"),
+ output=result.get("output"),
+ reference=reference,
+ artifacts=trace,
+ )
+
+
+def _usage_mapping(usage: Any) -> Mapping[str, Any]:
+ """artifacts 의 `usage` 를 읽을 수 있는 dict 로 -- dict 가 아니면(없음·`None` 포함) 빈 dict.
+
+ 빈 dict 에서 꺼낸 토큰 칸은 `None` 이 된다(측정 없음). 예전에는 `usage: None` 인 샘플이
+ `None.get` 으로 행 조립 자체를 죽였다.
+ """
+ return usage if isinstance(usage, Mapping) else {}
+
+
+def build_result_row(
+ run_id: str,
+ sample_idx: int,
+ input_data: dict[str, Any],
+ golden: Any,
+ eval_sample: EvalSample,
+ meta: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ """results.jsonl 한 행. 오늘의 8키 + `trace`, 그리고 `meta_fields` 가 있을 때만 `meta`.
+
+ `sample_idx` 는 **원본 파일의 0-based 레코드 위치**다(필터·limit 뒤에도, D2).
+
+ trace 는 artifacts 전체를 그대로 넣는다 -- step_outputs 뿐 아니라 v24 가 채우는
+ judge_decisions/judge_tool_calls/self_consistency_runs/corrections 도 여기 안 넣으면
+ 저장 직전에 통째로 버려진다 (실제 judge LLM 호출·self-consistency 반복 호출 비용이
+ 나간 산출물이다). 특정 키만 하드코딩해 옮기면 다음에 파이프라인이 새 artifacts 키를
+ 추가할 때마다 여기를 또 고쳐야 하므로 통째로 넘긴다 -- resultRowSchema.trace 는
+ .passthrough() 라 여분 키를 그대로 받는다 (source-schemas.ts:223-228).
+
+ `meta` 는 input 이 아니다 -- 데이터 행의 태그·출처 복사본이고(D13), `meta_fields`
+ 선언이 없으면 키 자체를 쓰지 않아 다른 프로젝트의 results 바이트가 그대로다.
+
+ 사용량·지연(`input_tokens`·`output_tokens`·`inference_time_ms`)은 artifacts 에 없으면
+ `None` 이다(L6b B1 · D-M2-24) -- `0` 을 지어 넣으면 "측정 없음" 이 "0 을 썼다" 는
+ 측정값과 구분되지 않는다. 값이 있으면 그대로 옮긴다(`0` 도 그대로) -- 사용량이 있는
+ 행의 바이트는 예전과 같다.
+ """
+ usage = _usage_mapping(eval_sample.artifacts.get("usage"))
+ row: dict[str, Any] = {
+ "run_id": run_id,
+ "sample_idx": sample_idx,
+ "input": input_data,
+ "output": eval_sample.output,
+ "golden": golden,
+ "input_tokens": usage.get("prompt_tokens"),
+ "output_tokens": usage.get("completion_tokens"),
+ "inference_time_ms": eval_sample.artifacts.get("inference_time_ms"),
+ "trace": {
+ "step_outputs": {},
+ **eval_sample.artifacts,
+ },
+ }
+ if meta is not None:
+ row["meta"] = meta
+ return row
+
+
+def build_evaluation_doc(
+ run_id: str,
+ aggregated: Mapping[str, Any],
+ eval_results: Sequence[Mapping[str, Any]],
+ eval_failures: Sequence[Mapping[str, Any]],
+ eval_model: str | None,
+) -> dict[str, Any]:
+ """evaluation.json 문서.
+
+ 채점에 성공한 샘플이 하나도 없으면 점수 자리를 비운다 -- aggregate() 는 빈 입력에
+ 0.0 을 돌려주는데, 그건 "0점을 받았다"는 측정값이라 "측정 자체가 없었다"와 구분되지
+ 않는다. judge 가 통째로 죽은 run 이 Experiment Hub 차트에서 품질 급락으로 보이면 안 된다.
+ """
+ overall_score = aggregated.get("overall_score", 0.0) if eval_results else None
+ return {
+ "run_id": run_id,
+ "scores": aggregated.get("scores", {}),
+ "overall_score": overall_score,
+ "eval_model": eval_model,
+ "eval_success_count": len(eval_results),
+ "eval_failed_count": len(eval_failures),
+ "failed_sample_indices": [f["sample_idx"] for f in eval_failures],
+ }
+
+
+def build_eval_detail_docs(
+ evaluation_id: Any,
+ eval_results: Sequence[Mapping[str, Any]],
+ eval_failures: Sequence[Mapping[str, Any]],
+) -> list[dict[str, Any]]:
+ """eval_details.jsonl 행들 -- 성공/실패 둘 다 한 행씩 남긴다.
+
+ results.jsonl 과 eval_details.jsonl 이 항상 같은 sample_idx 집합을 가리키게 해서
+ 부분적으로만 채점된 run 에서 "이 샘플은 왜 안 보이지"를 없앤다.
+ """
+ docs: list[dict[str, Any]] = []
+ for er in eval_results:
+ docs.append(
+ {
+ "evaluation_id": evaluation_id,
+ "sample_idx": er["sample_idx"],
+ "category_scores": er.get("category_scores", {}),
+ "error_counts": {
+ k: v.get("error_count", 0)
+ for k, v in er.get("details", {}).items()
+ if isinstance(v, dict)
+ },
+ "severity": er.get("severity", ""),
+ "score": er.get("score", 0.0),
+ }
+ )
+ for f in eval_failures:
+ docs.append(
+ {
+ "evaluation_id": evaluation_id,
+ "sample_idx": f["sample_idx"],
+ "category_scores": {},
+ "error_counts": {},
+ "severity": None,
+ # 0.0 이 아니라 None -- 채점 실패를 최저 점수와 구분한다 (F1).
+ "score": None,
+ "error": f["error"],
+ }
+ )
+ return docs
diff --git a/solar_eval/core/runner.py b/solar_eval/core/runner.py
index 2568cc5e6ee251bb990d2d33fc15ca2ebb683442..faf25ec486939760302ead545e25d955b4b8f256 100644
--- a/solar_eval/core/runner.py
+++ b/solar_eval/core/runner.py
@@ -1,6 +1,7 @@
import asyncio
import logging
import sys
+from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable
@@ -8,51 +9,106 @@ from typing import Any, Callable
logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="%(message)s")
from solar_eval.core.dataset_loader import DatasetLoader
+from solar_eval.core.dataset_prep import (
+ PreparedDataset,
+ check_resume_indices,
+ prepare_dataset,
+ row_meta,
+)
+from solar_eval.core.pipeline_factory import build_task_pipeline
+from solar_eval.core.row_filter import RowSelection
+from solar_eval.core.run_records import (
+ FAILED_SAMPLE_ERRORS_KEY,
+ build_eval_detail_docs,
+ build_eval_sample_from_result,
+ build_evaluation_doc,
+ build_failure_errors,
+ build_result_row,
+ extract_golden,
+ extract_input,
+)
from solar_eval.evaluators.registry import create_evaluator
from solar_eval.models.enums import RunStatus
from solar_eval.models.sample import EvalSample
-from solar_eval.pipelines.registry import create_pipeline
+from solar_eval.pipelines.base import BasePipeline
from solar_eval.providers.base import BaseProvider
from solar_eval.stores.base import ResultStore
+# `build_eval_sample_from_result` 는 run_records 로 옮겼지만 `runs eval` 등 기존 호출자가
+# 이 모듈에서 import 하므로 그대로 노출한다.
+__all__ = ["BatchRunner", "build_eval_sample_from_result", "resolve_selection"]
+
logger = logging.getLogger(__name__)
OnProgress = Callable[[dict[str, Any]], Any]
+# 태스크에 evaluator 선언이 없을 때 쓰는 기본 평가기.
+DEFAULT_EVALUATOR_CONFIG: dict[str, Any] = {"type": "llm_judge"}
-def build_eval_sample_from_result(result: dict[str, Any], reference: Any) -> EvalSample:
- """저장된 결과 dict(레거시 8+trace 키)에서 채점용 `EvalSample` 을 되살린다.
-
- `insert_run_result` 가 쓰는 키 이름(`golden`/`trace`)은 `EvalSample` 필드 이름
- (`reference`/`artifacts`)과 다르다 -- eval-store 하위 호환을 위해 저장 스키마는
- 그대로 두고(마이그레이션 계획 §9-1) 채점 직전에만 여기서 되돌린다. `execute_run`
- 내부 루프와 `runs eval` CLI(재채점, 새 추론 없이 디스크의 `results.jsonl` 을
- 그대로 씀) 양쪽에서 공유한다.
-
- Args:
- result: `insert_run_result` 에 넘긴 것과 같은 형태(또는 `JsonlStore.
- load_existing_results()` 로 디스크에서 다시 읽은 것). `input`/`output`/
- `trace` 키를 읽는다.
- reference: 정답(golden) 값. 호출자가 직접 넘긴다 -- 두 호출자의 golden
- 추출 경로가 다르기 때문이다(아래 참고). 이 함수는 어느 쪽인지 모른 채
- 값만 받아 그대로 `sample.reference` 에 채운다.
- - `execute_run` 내부 루프: `_golden_raw`(디스크에 안 남는 실행 중
- 임시 키, resume 시에도 매 샘플마다 새로 계산됨)를 넘긴다.
- - `runs eval` CLI: 디스크에 저장된 `golden` 키를 그대로 넘긴다
- (`_golden_raw` 는 애초에 저장되지 않아 CLI 쪽엔 없다).
+def resolve_selection(
+ task_config: dict[str, Any],
+ selection: RowSelection | None,
+ prepared: PreparedDataset | None,
+ limit: int | None,
+) -> RowSelection | None:
+ """러너가 따를 선택을 정한다 -- 러너는 넘겨받은 선택만 따른다(계획 D3).
- Returns:
- `input`/`output`/`reference`/`artifacts` 가 채워진 `EvalSample`.
- `contexts` 는 아직 아무도 안 써서 기본값(`None`) 그대로다.
+ - `prepared` 가 있으면 그 선택이 정본이다. `selection`·`limit` 가 함께 오면 같아야 한다.
+ - 옛 `limit` 인자만 오면 `RowSelection(limit=...)` 로 바꾼다(0·None = 전체, 오늘 동작).
+ - 러너는 `task_config["where"]` 를 스스로 읽지 않는다. 호출자가 선택을 넘기지 않았는데
+ 태스크에 where 가 있으면 경고만 남기고 전체 행을 돈다(서비스 경로 -- 조용한 필터 run 금지).
"""
- trace = result.get("trace") or {}
- return EvalSample(
- input=result.get("input"),
- output=result.get("output"),
- reference=reference,
- artifacts=trace,
- )
+ legacy_limit = limit or None
+ if prepared is not None:
+ if selection is not None and selection != prepared.selection:
+ raise ValueError("execute_run: selection differs from prepared.selection")
+ selection = prepared.selection
+ prepared_limit = selection.limit if selection is not None else None
+ if legacy_limit is not None and legacy_limit != prepared_limit:
+ raise ValueError("execute_run: limit differs from prepared.selection.limit")
+ elif selection is not None:
+ if legacy_limit is not None and legacy_limit != selection.limit:
+ raise ValueError("execute_run: limit differs from selection.limit")
+ elif legacy_limit is not None:
+ selection = RowSelection(limit=legacy_limit)
+
+ # CLI 는 태스크 where 를 선택에 합쳐 넘기므로, 태스크에 where 가 있는데 적용할 선택에
+ # where 가 없다면 호출자가 where 를 모르는 경로(서비스·옛 호출자)다.
+ if task_config.get("where") is not None and (selection is None or selection.where is None):
+ logger.warning(
+ "Task %r declares where, but where is not applied on this path -- running all rows",
+ task_config.get("name"),
+ )
+ return selection
+
+
+def _index_completed(
+ completed_results: list[dict[str, Any]] | None, prepared: PreparedDataset
+) -> dict[int, dict[str, Any]]:
+ """resume 용 `{sample_idx: 결과}`. 선택 밖 idx 가 섞여 있으면 실패한다(D3)."""
+ completed_by_idx = {r["sample_idx"]: r for r in completed_results or []}
+ check_resume_indices(prepared, set(completed_by_idx))
+ return completed_by_idx
+
+
+@dataclass
+class _RunContext:
+ """한 run 의 실행 상태 -- 샘플 처리 함수들이 공유한다."""
+
+ run_id: str
+ task_config: dict[str, Any]
+ prompt: dict[str, Any]
+ pipeline: BasePipeline
+ prepared: PreparedDataset
+ completed_by_idx: dict[int, dict[str, Any]]
+ on_progress: OnProgress | None
+ semaphore: asyncio.Semaphore
+ completed: int = 0
+
+ @property
+ def total(self) -> int:
+ return self.prepared.rows_selected
class BatchRunner:
@@ -80,27 +136,31 @@ class BatchRunner:
max_workers: int = 5,
limit: int | None = None,
completed_results: list[dict[str, Any]] | None = None,
+ selection: RowSelection | None = None,
+ prepared: PreparedDataset | None = None,
+ pipeline: BasePipeline | None = None,
) -> None:
"""Execute a full inference + evaluation run.
Args:
completed_results: Previously completed results for resume.
Samples with matching sample_idx will be skipped.
+ selection: 돌 행의 선택(`where`·`limit`). `None` = 전체. 러너는 태스크의
+ `where` 를 스스로 읽지 않는다 -- 호출자(CLI)가 만들어 넘긴다(D3).
+ prepared: 호출자가 미리 읽고 고른 데이터셋. 주면 러너가 다시 읽지 않는다 --
+ run.json 의 sha256 과 실제로 돈 행이 같은 바이트에서 나온다(D16).
+ pipeline: 호출자가 만든 파이프라인. 스냅샷을 뜬 것과 **같은 인스턴스**를
+ 돌리기 위해 받는다(D6). 없으면 `build_task_pipeline` 으로 만든다.
+
+ `sample_idx` 는 필터·limit 뒤에도 원본 파일의 0-based 레코드 위치다(D2).
샘플 단위 실패(추론/평가)는 삼키고 상태(PARTIAL/eval_failed_count)로 기록하지만,
- run 을 통째로 못 돌게 만드는 예외(데이터셋 로드 실패, 파이프라인/평가기 생성
- 실패, 스토어 쓰기 실패 등)는 상태를 FAILED 로 남긴 뒤 그대로 재전파한다 --
- "무슨 일이 있어도 예외 없이 반환"이 아니다. 호출자는 이 함수가 raise 할 수
- 있다고 가정해야 한다.
+ run 을 통째로 못 돌게 만드는 예외(데이터셋 로드 실패, 0행 선택, 선택 밖 resume,
+ 파이프라인/평가기 생성 실패, 스토어 쓰기 실패 등)는 상태를 FAILED 로 남긴 뒤
+ 그대로 재전파한다 -- "무슨 일이 있어도 예외 없이 반환"이 아니다. 호출자는 이
+ 함수가 raise 할 수 있다고 가정해야 한다.
"""
try:
- # Build set of already-completed sample indices for resume
- completed_by_idx: dict[int, dict[str, Any]] = {}
- if completed_results:
- for r in completed_results:
- completed_by_idx[r["sample_idx"]] = r
-
- # Update status to running
await self.store.update_run(
run_id,
{
@@ -108,275 +168,28 @@ class BatchRunner:
"started_at": datetime.now(timezone.utc),
},
)
-
- # Load dataset
- dataset_config = project_config.get("dataset", {})
- source = dataset_config.get("source", "huggingface")
- repo_name = dataset_config.get("repo", "")
- dataset_path = task_config.get("dataset_path", "")
- data = self.dataset_loader.load_jsonl(repo_name, dataset_path, source=source)
-
- if limit and limit < len(data):
- data = data[:limit]
-
- remaining = len(data) - len(completed_by_idx)
- await self.store.update_run(run_id, {"total_samples": len(data)})
- if on_progress:
- await on_progress({"type": "dataset_loaded", "total": len(data)})
- if completed_by_idx:
- logger.info(f"Resuming: {len(completed_by_idx)} done, {remaining} remaining")
-
- # Create pipeline
- # project_dir: v24 의 tool_calling_judge 가 pmi_lookup 상대경로를 풀 때만
- # 쓴다 (§5-F). dataset_loader.base_dir 이 projects 루트이므로 project 이름을
- # 붙이면 project_dir 이 된다 -- CLI(`_start_local`)가 이미 쓰는 것과 같은 관례.
- project_name = project_config.get("name")
- project_dir = (
- self.dataset_loader.base_dir / project_name
- if self.dataset_loader.base_dir and project_name
- else None
- )
- # config_dir: 레포 관리 프로젝트면 project_loader 가 채워 둔 config 정본
- # 경로(03-evaluation). 치환 사전 같은 지식 자산이 여기서 온다.
- pipeline = create_pipeline(
- pipeline_type=task_config.get("pipeline", "single_step"),
- input_fields=task_config.get("input_fields", []),
- pipeline_config=task_config.get("pipeline_config"),
- prompts=prompt.get("step_prompts", {}),
- dataset_loader=self.dataset_loader,
- project_dir=project_dir,
- config_dir=project_config.get("config_dir"),
- )
-
- # Run inference with concurrency control
- semaphore = asyncio.Semaphore(max_workers)
- completed = len(completed_by_idx)
-
- async def process_sample(idx: int, sample: dict) -> dict[str, Any]:
- nonlocal completed
-
- # Skip already-completed samples (resume)
- if idx in completed_by_idx:
- existing = completed_by_idx[idx]
- golden_field = task_config.get("golden_field")
- golden = sample.get(golden_field, "") if golden_field else ""
- return {**existing, "_golden_raw": golden}
-
- async with semaphore:
- input_data = {
- field: sample.get(field, "")
- for field in task_config.get("input_fields", [])
- }
-
- # Get golden reference
- golden_field = task_config.get("golden_field")
- golden_fields = task_config.get("golden_fields")
- if golden_field:
- golden = sample.get(golden_field, "")
- elif golden_fields:
- golden = {k: sample.get(v, "") for k, v in golden_fields.items()}
- else:
- golden = ""
-
- eval_sample = EvalSample(input=input_data, reference=golden)
-
- try:
- eval_sample = await pipeline.run(
- sample=eval_sample,
- prompts=prompt.get("system_prompt", ""),
- provider=self.inference_provider,
- model=prompt.get("model", "solar-pro2"),
- temperature=prompt.get("temperature", 0.0),
- max_tokens=prompt.get("max_tokens", 8000),
- reasoning_effort=prompt.get("reasoning_effort"),
- messages=prompt.get("messages"),
- )
- except Exception as e:
- ts = datetime.now().strftime("%H:%M:%S")
- logger.warning(f"[{ts}] Sample {idx} failed: {type(e).__name__}: {e}")
- raise
-
- # 저장 형태는 이전과 같은 8키 dict (eval-store 의 resultRowSchema 가
- # 읽는 이름들과 하위 호환) + trace 를 추가한다. trace 는 artifacts
- # 전체를 그대로 넣는다 -- step_outputs 뿐 아니라 v24 가 채우는
- # judge_decisions/judge_tool_calls/self_consistency_runs/corrections
- # 도 여기 안 넣으면 저장 직전에 통째로 버려진다 (실제 judge LLM 호출·
- # self-consistency 반복 호출 비용이 나간 산출물이다). 특정 키만
- # 하드코딩해 옮기면 다음에 파이프라인이 새 artifacts 키를 추가할
- # 때마다 여기를 또 고쳐야 하므로 통째로 넘긴다 --
- # resultRowSchema.trace 는 .passthrough() 라 여분 키를 그대로 받는다
- # (source-schemas.ts:223-228).
- run_result = {
- "run_id": run_id,
- "sample_idx": idx,
- "input": input_data,
- "output": eval_sample.output,
- "golden": golden,
- "input_tokens": eval_sample.artifacts.get("usage", {}).get(
- "prompt_tokens", 0
- ),
- "output_tokens": eval_sample.artifacts.get("usage", {}).get(
- "completion_tokens", 0
- ),
- "inference_time_ms": eval_sample.artifacts.get("inference_time_ms", 0),
- "trace": {
- "step_outputs": {},
- **eval_sample.artifacts,
- },
- }
- await self.store.insert_run_result(run_result)
-
- completed += 1
- ts = datetime.now().strftime("%H:%M:%S")
- logger.info(f"[{ts}] Sample {idx} completed ({completed}/{len(data)})")
- await self.store.update_run(run_id, {"completed_samples": completed})
- if on_progress:
- await on_progress(
- {
- "type": "inference_progress",
- "completed": completed,
- "total": len(data),
- "sample_idx": idx,
- }
- )
-
- return {**run_result, "_golden_raw": golden}
-
- tasks = [process_sample(i, sample) for i, sample in enumerate(data)]
- results = await asyncio.gather(*tasks, return_exceptions=True)
-
- # Filter out exceptions
- valid_results = [r for r in results if isinstance(r, dict)]
- errors = [(i, r) for i, r in enumerate(results) if isinstance(r, Exception)]
- if errors:
- logger.warning(f"Run {run_id}: {len(errors)}/{len(results)} samples failed")
- for sample_idx, err in errors:
- logger.warning(f" Sample {sample_idx} error: {type(err).__name__}: {err}")
-
- if on_progress:
- await on_progress({"type": "inference_complete", "total": len(valid_results)})
-
- # Run evaluation
- await self.store.update_run(run_id, {"status": RunStatus.EVALUATING})
- if on_progress:
- await on_progress({"type": "evaluation_start", "total": len(valid_results)})
-
- evaluator = create_evaluator(task_config.get("evaluator", {"type": "llm_judge"}))
- # eval_results: 성공한 evaluate() 반환값 + "sample_idx"(정본, §0.5).
- # eval_failures: 채점 자체가 안 된 샘플 -- aggregate() 에는 절대 안 넘긴다
- # (lcs_diff.aggregate() 처럼 r["details"]["tp"] 를 직접 읽는 구현이 실패
- # 항목을 만나면 KeyError 로 죽는다, §0.1).
- eval_results: list[dict[str, Any]] = []
- eval_failures: list[dict[str, Any]] = []
-
- for i, result in enumerate(valid_results):
- # F6: enumerate 위치가 아니라 result["sample_idx"] 가 정본이다 --
- # 중간 샘플이 추론에서 실패하면 valid_results 의 리스트 위치와 원본
- # sample_idx 가 어긋난다.
- sample_idx = result["sample_idx"]
- try:
- eval_sample = build_eval_sample_from_result(
- result, reference=result["_golden_raw"]
- )
- evaluator.validate_required_fields(eval_sample)
- eval_result = await evaluator.evaluate(
- sample=eval_sample,
- provider=self.judge_provider,
- )
- eval_results.append({**eval_result, "sample_idx": sample_idx})
-
- if on_progress and i % 10 == 0:
- await on_progress(
- {
- "type": "evaluation_progress",
- "completed": i + 1,
- "total": len(valid_results),
- }
- )
- except Exception as e:
- logger.warning(f"Evaluation failed for sample {sample_idx}: {e}")
- eval_failures.append({"sample_idx": sample_idx, "error": str(e)})
-
- # Aggregate and save evaluation -- eval_results 에는 실패 항목이 안 섞여
- # 있으므로 기존 aggregate() 구현이 그대로 동작한다.
- aggregated = evaluator.aggregate(eval_results)
- # 채점에 성공한 샘플이 하나도 없으면 점수 자리를 비운다 -- aggregate() 는 빈
- # 입력에 0.0 을 돌려주는데, 그건 "0점을 받았다"는 측정값이라 "측정 자체가
- # 없었다"와 구분되지 않는다. judge 가 통째로 죽은 run 이 evalhub 차트에서
- # 품질 급락으로 보이면 F1 을 반만 고친 셈이다.
- overall_score = aggregated.get("overall_score", 0.0) if eval_results else None
- eval_id = await self.store.create_evaluation(
- {
- "run_id": run_id,
- "scores": aggregated.get("scores", {}),
- "overall_score": overall_score,
- "eval_model": "gpt-4o",
- "eval_success_count": len(eval_results),
- "eval_failed_count": len(eval_failures),
- "failed_sample_indices": [f["sample_idx"] for f in eval_failures],
- }
- )
-
- # Save per-sample eval details -- 성공/실패 둘 다 한 행씩 남긴다.
- # results.jsonl 과 eval_details.jsonl 이 항상 같은 sample_idx 집합을
- # 가리키게 해서 부분적으로만 채점된 run 에서 "이 샘플은 왜 안 보이지"를
- # 없앤다.
- eval_detail_docs = []
- for er in eval_results:
- eval_detail_docs.append(
- {
- "evaluation_id": eval_id,
- "sample_idx": er["sample_idx"],
- "category_scores": er.get("category_scores", {}),
- "error_counts": {
- k: v.get("error_count", 0)
- for k, v in er.get("details", {}).items()
- if isinstance(v, dict)
- },
- "severity": er.get("severity", ""),
- "score": er.get("score", 0.0),
- }
+ effective = resolve_selection(task_config, selection, prepared, limit)
+ if prepared is None:
+ prepared = prepare_dataset(
+ self.dataset_loader, project_config, task_config, effective
)
- for f in eval_failures:
- eval_detail_docs.append(
- {
- "evaluation_id": eval_id,
- "sample_idx": f["sample_idx"],
- "category_scores": {},
- "error_counts": {},
- "severity": None,
- # 0.0 이 아니라 None -- 채점 실패를 최저 점수와 구분한다 (F1).
- "score": None,
- "error": f["error"],
- }
+ completed_by_idx = _index_completed(completed_results, prepared)
+ if pipeline is None:
+ pipeline = build_task_pipeline(
+ project_config, task_config, prompt, self.dataset_loader
)
- await self.store.insert_eval_details(eval_detail_docs)
-
- # Mark run as completed/partial -- 추론이 일부 샘플에서 실패했으면
- # PARTIAL, 평가 실패는 이 상태에 영향을 주지 않는다(평가 성공/실패는
- # 위 eval_success_count/eval_failed_count 로만 표현한다, §0.4). errors
- # 의 인덱스는 asyncio.gather 가 tasks 순서를 보존하므로 이미 진짜
- # sample_idx 다.
- final_status = RunStatus.PARTIAL if errors else RunStatus.COMPLETED
- await self.store.update_run(
- run_id,
- {
- "status": final_status,
- "completed_at": datetime.now(timezone.utc),
- "failed_samples": len(errors),
- "failed_sample_indices": [i for i, _ in errors],
- },
+ ctx = _RunContext(
+ run_id=run_id,
+ task_config=task_config,
+ prompt=prompt,
+ pipeline=pipeline,
+ prepared=prepared,
+ completed_by_idx=completed_by_idx,
+ on_progress=on_progress,
+ completed=len(completed_by_idx),
+ semaphore=asyncio.Semaphore(max_workers),
)
-
- if on_progress:
- await on_progress(
- {
- "type": "done",
- "overall_score": aggregated.get("overall_score", 0.0),
- "scores": aggregated.get("scores", {}),
- }
- )
-
+ await self._run_all(ctx)
except Exception as e:
logger.exception(f"Run {run_id} failed")
await self.store.update_run(
@@ -389,6 +202,185 @@ class BatchRunner:
if on_progress:
await on_progress({"type": "error", "error": str(e)})
# F4: 상태를 FAILED 로 남기고 진행 상황까지 알린 뒤 **그대로 재전파한다**.
- # 여기서 삼키면 호출자(CLI)는 "Results saved to ..." 를 찍고 evalhub 적재까지
+ # 여기서 삼키면 호출자(CLI)는 "Results saved to ..." 를 찍고 Experiment Hub 적재까지
# 시도한 다음 exit 0 을 낸다 -- 데이터셋조차 못 읽은 run 이 성공으로 보인다.
raise
+
+ async def _run_all(self, ctx: _RunContext) -> None:
+ """추론 → 채점 → 상태 마감. 치명적 예외는 호출자(`execute_run`)가 FAILED 로 남긴다."""
+ await self.store.update_run(ctx.run_id, {"total_samples": ctx.total})
+ if ctx.on_progress:
+ await ctx.on_progress({"type": "dataset_loaded", "total": ctx.total})
+ if ctx.completed_by_idx:
+ remaining = ctx.total - len(ctx.completed_by_idx)
+ logger.info(f"Resuming: {len(ctx.completed_by_idx)} done, {remaining} remaining")
+
+ valid_results, errors = await self._infer_all(ctx)
+ aggregated = await self._evaluate_all(ctx, valid_results)
+
+ # Mark run as completed/partial -- 추론이 일부 샘플에서 실패했으면 PARTIAL, 평가
+ # 실패는 이 상태에 영향을 주지 않는다(평가 성공/실패는 eval_success_count/
+ # eval_failed_count 로만 표현한다, §0.4). errors 의 인덱스는 `_infer_all` 이
+ # gather 위치를 원본 sample_idx 로 매핑해 둔 값이다(D2).
+ final_status = RunStatus.PARTIAL if errors else RunStatus.COMPLETED
+ await self.store.update_run(
+ ctx.run_id,
+ {
+ "status": final_status,
+ "completed_at": datetime.now(timezone.utc),
+ "failed_samples": len(errors),
+ "failed_sample_indices": [i for i, _ in errors],
+ # 같은 호출에서 쓴다 -- 두 목록의 위치 집합이 늘 같다. 타입 이름만(LP5 D-LP5-4).
+ FAILED_SAMPLE_ERRORS_KEY: build_failure_errors(errors),
+ },
+ )
+ if ctx.on_progress:
+ await ctx.on_progress(
+ {
+ "type": "done",
+ "overall_score": aggregated.get("overall_score", 0.0),
+ "scores": aggregated.get("scores", {}),
+ }
+ )
+
+ async def _infer_all(
+ self, ctx: _RunContext
+ ) -> tuple[list[dict[str, Any]], list[tuple[int, BaseException]]]:
+ """선택된 행을 동시에 추론한다. 실패는 `(원본 sample_idx, 예외)` 로 돌려준다."""
+ selected = ctx.prepared.selected
+ tasks = [self._infer_one(ctx, idx, sample) for idx, sample in selected]
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+
+ valid_results = [r for r in results if isinstance(r, dict)]
+ # gather 는 tasks 순서를 보존하므로 결과 위치 = selected 위치다. 위치를 그대로
+ # 쓰면 필터·limit run 에서 엉뚱한 행을 실패로 적는다 -- 원본 idx 로 매핑한다(D2).
+ errors = [
+ (selected[pos][0], r) for pos, r in enumerate(results) if isinstance(r, BaseException)
+ ]
+ if errors:
+ logger.warning(f"Run {ctx.run_id}: {len(errors)}/{len(results)} samples failed")
+ for sample_idx, err in errors:
+ logger.warning(f" Sample {sample_idx} error: {type(err).__name__}: {err}")
+
+ if ctx.on_progress:
+ await ctx.on_progress({"type": "inference_complete", "total": len(valid_results)})
+ return valid_results, errors
+
+ async def _infer_one(self, ctx: _RunContext, idx: int, sample: dict) -> dict[str, Any]:
+ """샘플 하나 추론·저장. `idx` 는 원본 파일 위치다."""
+ golden = extract_golden(sample, ctx.task_config)
+ # Skip already-completed samples (resume)
+ if idx in ctx.completed_by_idx:
+ return {**ctx.completed_by_idx[idx], "_golden_raw": golden}
+
+ async with ctx.semaphore:
+ input_data = extract_input(sample, ctx.task_config.get("input_fields", []))
+ eval_sample = EvalSample(input=input_data, reference=golden)
+ prompt = ctx.prompt
+ try:
+ eval_sample = await ctx.pipeline.run(
+ sample=eval_sample,
+ prompts=prompt.get("system_prompt", ""),
+ provider=self.inference_provider,
+ model=prompt.get("model", "solar-pro2"),
+ temperature=prompt.get("temperature", 0.0),
+ max_tokens=prompt.get("max_tokens", 8000),
+ reasoning_effort=prompt.get("reasoning_effort"),
+ messages=prompt.get("messages"),
+ )
+ except Exception as e:
+ ts = datetime.now().strftime("%H:%M:%S")
+ logger.warning(f"[{ts}] Sample {idx} failed: {type(e).__name__}: {e}")
+ raise
+
+ run_result = build_result_row(
+ ctx.run_id,
+ idx,
+ input_data,
+ golden,
+ eval_sample,
+ meta=row_meta(sample, ctx.prepared.meta_fields),
+ )
+ await self.store.insert_run_result(run_result)
+ await self._report_inference(ctx, idx)
+ return {**run_result, "_golden_raw": golden}
+
+ async def _report_inference(self, ctx: _RunContext, idx: int) -> None:
+ ctx.completed += 1
+ ts = datetime.now().strftime("%H:%M:%S")
+ logger.info(f"[{ts}] Sample {idx} completed ({ctx.completed}/{ctx.total})")
+ await self.store.update_run(ctx.run_id, {"completed_samples": ctx.completed})
+ if ctx.on_progress:
+ await ctx.on_progress(
+ {
+ "type": "inference_progress",
+ "completed": ctx.completed,
+ "total": ctx.total,
+ "sample_idx": idx,
+ }
+ )
+
+ async def _evaluate_all(
+ self, ctx: _RunContext, valid_results: list[dict[str, Any]]
+ ) -> dict[str, Any]:
+ """채점하고 evaluation·eval_details 를 저장한다. 집계 dict 를 돌려준다."""
+ await self.store.update_run(ctx.run_id, {"status": RunStatus.EVALUATING})
+ if ctx.on_progress:
+ await ctx.on_progress({"type": "evaluation_start", "total": len(valid_results)})
+
+ evaluator = create_evaluator(ctx.task_config.get("evaluator", DEFAULT_EVALUATOR_CONFIG))
+ eval_results, eval_failures = await self._score(ctx, evaluator, valid_results)
+
+ # eval_results 에는 실패 항목이 안 섞여 있으므로 기존 aggregate() 구현이 그대로 동작한다.
+ aggregated = evaluator.aggregate(eval_results)
+ eval_id = await self.store.create_evaluation(
+ build_evaluation_doc(
+ # eval_model = 평가기가 보고하는 judge 모델(없으면 None) -- 예전의 하드코딩
+ # "gpt-4o" 는 lcs_diff run 에도 judge 가 있는 것처럼 적었다(계획 D11).
+ ctx.run_id,
+ aggregated,
+ eval_results,
+ eval_failures,
+ evaluator.judge_model,
+ )
+ )
+ await self.store.insert_eval_details(
+ build_eval_detail_docs(eval_id, eval_results, eval_failures)
+ )
+ return aggregated
+
+ async def _score(
+ self, ctx: _RunContext, evaluator: Any, valid_results: list[dict[str, Any]]
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+ """샘플별 채점.
+
+ eval_results: 성공한 evaluate() 반환값 + "sample_idx"(정본, §0.5).
+ eval_failures: 채점 자체가 안 된 샘플 -- aggregate() 에는 절대 안 넘긴다
+ (lcs_diff.aggregate() 처럼 r["details"]["tp"] 를 직접 읽는 구현이 실패 항목을
+ 만나면 KeyError 로 죽는다, §0.1).
+ """
+ eval_results: list[dict[str, Any]] = []
+ eval_failures: list[dict[str, Any]] = []
+ for i, result in enumerate(valid_results):
+ # F6: enumerate 위치가 아니라 result["sample_idx"] 가 정본이다.
+ sample_idx = result["sample_idx"]
+ try:
+ eval_sample = build_eval_sample_from_result(result, reference=result["_golden_raw"])
+ evaluator.validate_required_fields(eval_sample)
+ eval_result = await evaluator.evaluate(
+ sample=eval_sample,
+ provider=self.judge_provider,
+ )
+ eval_results.append({**eval_result, "sample_idx": sample_idx})
+ if ctx.on_progress and i % 10 == 0:
+ await ctx.on_progress(
+ {
+ "type": "evaluation_progress",
+ "completed": i + 1,
+ "total": len(valid_results),
+ }
+ )
+ except Exception as e:
+ logger.warning(f"Evaluation failed for sample {sample_idx}: {e}")
+ eval_failures.append({"sample_idx": sample_idx, "error": str(e)})
+ return eval_results, eval_failures
diff --git a/solar_eval/core/snapshot/__init__.py b/solar_eval/core/snapshot/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..84810d4bce8750e06141733bdfa4381901e9aec4
--- /dev/null
+++ b/solar_eval/core/snapshot/__init__.py
@@ -0,0 +1,400 @@
+"""run.json `snapshot` 블록(engine-snapshot/v1) — "무엇으로 돌렸는가"를 완전히 적는다.
+
+계획 2026-09-24-phase1a D5~D12. 모양의 정본은 poc-bundle(SPEC §3.4 완전성 규범 키 8개)이고, 지문은
+`poc_bundle.snapshot.config_fingerprint`(JCS) 를 그대로 쓴다 — 규칙을 두 벌 짜지 않는다.
+
+- `config` = 지문 대상(기능). `provenance` = 지문 밖(이름·위치·커밋·실행 옵션).
+- `build_engine_snapshot(inputs)` 는 run 시작 **전에** 부른다. 선언했는데 없는 자원은 여기서 죽는다.
+- `self_check(snapshot)` = 규범 키 누락 없음 + 지문 재계산 일치 (엔진 자기 검증, D5).
+
+`poc_bundle`·`packaging` import 는 이 패키지(와 cli 의 run 메타 모듈) 안에서만 한다 — HF Space 벤더
+사본이 import 하는 모듈은 둘 없이 돌아야 한다(D18).
+"""
+
+from __future__ import annotations
+
+import copy
+import subprocess
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from poc_bundle.canonical import json_fingerprint
+from poc_bundle.snapshot import completeness_gaps, config_fingerprint
+
+from solar_eval.core.snapshot import environment, impl_hash, reproducibility, resources
+from solar_eval.core.snapshot.impl_hash import ImplRoots
+from solar_eval.evaluators.registry import DEFAULT_EVALUATOR_CONFIG, create_evaluator
+
+ENGINE_SNAPSHOT_SCHEMA = "engine-snapshot/v1"
+
+#: `snapshot.config` 키 순서 (계획 §3.2). 지문은 JCS 라 순서와 무관하지만 사람이 읽는 순서를 고정한다.
+CONFIG_KEYS = (
+ "model",
+ "temperature",
+ "max_tokens",
+ "reasoning_effort",
+ "pipeline_type",
+ "input_fields",
+ "resolved_pipeline",
+ "prompt_assets",
+ "resources",
+ "evaluator_config",
+ "step_impl_hash",
+ "runtime_impl_hash",
+ "dependency_lock",
+ "runner_version",
+)
+
+#: 런타임·평가기 폐포 목록을 `provenance.impl_modules` 에 남길 때의 키.
+RUNTIME_MODULES_KEY = "@runtime"
+EVALUATOR_MODULES_KEY = "@evaluator"
+
+#: 프로바이더 모듈 이름을 남기는 provenance 키 — 재채점이 런타임 해시를 다시 뜰 재료.
+PROVIDERS_KEY = "providers"
+
+#: 엔진 레포를 자동으로 찾으라는 표시(`EngineSnapshotInputs.repo` 기본값).
+AUTO = "auto"
+
+
+class SnapshotError(ValueError):
+ """스냅샷이 스스로의 규칙을 어겼다 — 규범 키 누락·지문 불일치·모르는 schema."""
+
+
+@dataclass(frozen=True)
+class EngineSnapshotInputs:
+ """스냅샷 재료. 파일·git 읽기는 `roots`·`engine`·`repo`·`git_run` 으로 바꿔 끼울 수 있다.
+
+ Attributes:
+ pipeline: 러너가 **실제로 돌릴** `Pipeline` 인스턴스(D6) — `step_impl_hash`·`resources` 재료.
+ resolved_pipeline: `extends`·`--set` 까지 해석된 파이프라인 dict(없으면 None/{}).
+ prompt_path: 프롬프트 디렉토리 또는 단일 YAML 파일.
+ evaluator_config: 태스크의 evaluator 설정. None 은 "키 없음"으로 읽어 러너 기본값
+ (`llm_judge`)을 쓴다. 키가 있는데 비었으면(`{}`) 여기서 ValueError — 러너가 죽을 설정이다.
+ dataset: `dataset_block.describe_dataset` 결과(재현 판정 재료). None = 모름.
+ engine: 미리 계산한 `environment.engine_state` 결과. None 이면 여기서 계산한다.
+ repo: 엔진 레포(uv.lock·engine_state 용). `AUTO` 면 `environment.engine_repo()`.
+ """
+
+ model: str
+ temperature: float
+ max_tokens: int
+ reasoning_effort: str | None
+ pipeline_type: str
+ input_fields: Sequence[str]
+ resolved_pipeline: Mapping[str, Any] | None
+ prompt_path: Path
+ pipeline: Any
+ evaluator_config: Mapping[str, Any] | None
+ inference_provider: Any
+ judge_provider: Any = None
+ prompt_name: str | None = None
+ pipeline_name: str | None = None
+ config_source: str | None = None
+ git_commit: str | None = None
+ pipeline_overrides: Mapping[str, Any] | None = None
+ max_workers: int | None = None
+ config_dir: Path | None = None
+ repo_managed: bool = False
+ dataset: Mapping[str, Any] | None = None
+ roots: ImplRoots | None = None
+ engine: Mapping[str, Any] | None = None
+ repo: Path | str | None = AUTO
+ git_run: Any = field(default=subprocess.run)
+
+
+def evaluator_block(
+ evaluator_config: Mapping[str, Any] | None, roots: ImplRoots | None = None
+) -> tuple[dict[str, Any], list[str]]:
+ """`config.evaluator_config = {config, judge_model, impl_hash}` 와 폐포 라벨.
+
+ 기본값은 `evaluator_config is None`(태스크에 키가 없음)일 때만 쓴다 — 러너의
+ `task_config.get("evaluator", DEFAULT)` 와 같은 규칙. `{}`·`type` 없는 설정은 기본값으로 덮지 않고
+ `create_evaluator` 가 ValueError 로 거부한다(추론 전에 죽는다, D5).
+
+ Raises:
+ ValueError: 평가기 설정이 객체가 아니거나 `type` 이 없거나 모르는 값일 때.
+ """
+ raw = DEFAULT_EVALUATOR_CONFIG if evaluator_config is None else evaluator_config
+ evaluator = create_evaluator(copy.deepcopy(raw))
+ effective = copy.deepcopy(dict(raw))
+ digest, modules = impl_hash.evaluator_impl_hash(evaluator, roots)
+ block = {"config": effective, "judge_model": evaluator.judge_model, "impl_hash": digest}
+ return block, modules
+
+
+def _resolve_repo(inputs: EngineSnapshotInputs) -> Path | None:
+ if inputs.repo == AUTO:
+ return environment.engine_repo()
+ return Path(inputs.repo) if inputs.repo is not None else None
+
+
+def _engine(inputs: EngineSnapshotInputs, repo: Path | None, roots: ImplRoots) -> dict[str, Any]:
+ """`provenance.engine` — 주입값이 있으면 그것, 레포가 없으면 트리 해시만(commit·dirty null)."""
+ if inputs.engine is not None:
+ return copy.deepcopy(dict(inputs.engine))
+ if repo is None:
+ tree = impl_hash.engine_tree_sha256(roots)
+ return {"commit": None, "tree_sha256": tree, "dirty": None, "dirty_scope": []}
+ return environment.engine_state(
+ inputs.config_dir,
+ inputs.repo_managed,
+ repo=repo,
+ roots=roots,
+ run=inputs.git_run,
+ )
+
+
+def _recorded_providers(inputs: EngineSnapshotInputs) -> dict[str, str | None]:
+ """`provenance.providers = {inference, judge}` — 이번 run 에 주입된 프로바이더의 모듈 이름.
+
+ judge 는 쓰든 안 쓰든 적는다 — 재채점(`with_evaluator`)이 judge 를 새로 쓰게 되면 이 이름으로
+ 런타임 해시를 다시 뜬다.
+ """
+ judge = inputs.judge_provider
+ return {
+ "inference": impl_hash.provider_module(inputs.inference_provider),
+ "judge": None if judge is None else impl_hash.provider_module(judge),
+ }
+
+
+def _runtime(
+ providers: Mapping[str, str | None], judge_model: str | None, roots: ImplRoots
+) -> tuple[str, list[str]]:
+ """런타임 해시 — 추론 프로바이더 + (평가기가 judge 를 실제로 부를 때만) judge 프로바이더.
+
+ lcs_diff run 은 judge 를 안 쓰므로 넣지 않는다. 빌드와 재채점이 같은 규칙을 쓴다.
+ """
+ used = [providers["inference"]]
+ if judge_model is not None and providers.get("judge") is not None:
+ used.append(providers["judge"])
+ return impl_hash.runtime_impl_hash_for_modules([m for m in used if m is not None], roots)
+
+
+@dataclass(frozen=True)
+class _Materials:
+ """`config` 와, 지문 밖으로 가는 부산물(자원 해석·폐포 라벨·프로바이더 모듈)."""
+
+ config: dict[str, Any]
+ collected: resources.CollectedResources
+ impl_modules: dict[str, list[str]]
+ providers: dict[str, str | None]
+
+
+def _collect_config(inputs: EngineSnapshotInputs, roots: ImplRoots) -> _Materials:
+ """지문 대상 `config` 를 `CONFIG_KEYS` 순서로 모은다 — 파일·자원·구현 해시를 여기서 읽는다."""
+ collected = resources.collect_resources(inputs.pipeline)
+ evaluator, evaluator_modules = evaluator_block(inputs.evaluator_config, roots)
+ step_hashes, step_modules = impl_hash.step_impl_hashes(inputs.pipeline, roots)
+ providers = _recorded_providers(inputs)
+ runtime_hash, runtime_modules = _runtime(providers, evaluator["judge_model"], roots)
+ config = {
+ "model": inputs.model,
+ "temperature": inputs.temperature,
+ "max_tokens": inputs.max_tokens,
+ "reasoning_effort": inputs.reasoning_effort,
+ "pipeline_type": inputs.pipeline_type,
+ "input_fields": list(inputs.input_fields),
+ "resolved_pipeline": copy.deepcopy(dict(inputs.resolved_pipeline or {})),
+ "prompt_assets": resources.prompt_assets(inputs.prompt_path),
+ "resources": collected.hashes,
+ "evaluator_config": evaluator,
+ "step_impl_hash": step_hashes,
+ "runtime_impl_hash": runtime_hash,
+ "dependency_lock": environment.dependency_lock(),
+ "runner_version": environment.runner_version(),
+ }
+ impl_modules = {
+ **step_modules,
+ EVALUATOR_MODULES_KEY: evaluator_modules,
+ RUNTIME_MODULES_KEY: runtime_modules,
+ }
+ config = {key: config[key] for key in CONFIG_KEYS}
+ return _Materials(config, collected, impl_modules, providers)
+
+
+def _provenance(
+ inputs: EngineSnapshotInputs,
+ repo: Path | None,
+ engine: dict[str, Any],
+ materials: _Materials,
+ steps_use_model: bool,
+) -> dict[str, Any]:
+ """지문 밖 출처 — 이름·위치·커밋·엔진 상태·설치 폐포·실행 옵션 (계획 §3.2 순서)."""
+ return {
+ "prompt": inputs.prompt_name,
+ "pipeline": inputs.pipeline_name,
+ "config_source": inputs.config_source,
+ "git_commit": inputs.git_commit,
+ "pipeline_overrides": copy.deepcopy(inputs.pipeline_overrides),
+ "engine": engine,
+ "uv_lock_sha256": environment.uv_lock_sha256(repo),
+ "installed_closure": environment.installed_closure(),
+ "impl_modules": materials.impl_modules,
+ PROVIDERS_KEY: materials.providers,
+ "steps_use_model": steps_use_model,
+ "execution": {"max_workers": inputs.max_workers},
+ "resumes": [],
+ }
+
+
+def _reproducibility(
+ inputs: EngineSnapshotInputs,
+ materials: _Materials,
+ engine: dict[str, Any],
+ steps_use_model: bool,
+) -> dict[str, Any]:
+ verdict, blockers = reproducibility.rerunnable(
+ engine_dirty=engine.get("dirty"),
+ repo_managed=inputs.repo_managed,
+ dataset=inputs.dataset,
+ resources=materials.collected.refs,
+ )
+ config = materials.config
+ return {
+ "engine_dirty": engine.get("dirty"),
+ "rerunnable": verdict,
+ "rerunnable_blockers": blockers,
+ "deterministic": reproducibility.deterministic(
+ temperature=inputs.temperature,
+ resolved_pipeline=config["resolved_pipeline"],
+ steps_use_model=steps_use_model,
+ judge_model=config["evaluator_config"]["judge_model"],
+ ),
+ }
+
+
+def build_engine_snapshot(inputs: EngineSnapshotInputs) -> dict[str, Any]:
+ """`{schema, config, config_fingerprint, missing, provenance, reproducibility}` 를 조립한다.
+
+ Raises:
+ KnowledgeAssetError: 스텝이 선언한 자원을 로컬에서 찾을 수 없을 때(run 시작 전).
+ FileNotFoundError / ValueError: 프롬프트가 없거나 비었을 때, 구현 모듈을 풀 수 없을 때.
+ """
+ roots = inputs.roots or impl_hash.default_roots()
+ materials = _collect_config(inputs, roots)
+ repo = _resolve_repo(inputs)
+ engine = _engine(inputs, repo, roots)
+ steps_use_model = any(step.uses_model for step in inputs.pipeline.steps)
+ return {
+ "schema": ENGINE_SNAPSHOT_SCHEMA,
+ "config": materials.config,
+ "config_fingerprint": config_fingerprint(materials.config),
+ "missing": ["resources"] if materials.collected.unresolved else [],
+ "provenance": _provenance(inputs, repo, engine, materials, steps_use_model),
+ "reproducibility": _reproducibility(inputs, materials, engine, steps_use_model),
+ }
+
+
+def self_check(snapshot: Mapping[str, Any]) -> None:
+ """엔진 자기 검증(D5) — 이미 있는 poc-bundle 함수만 쓴다.
+
+ Raises:
+ SnapshotError: schema 가 다르거나, 규범 키가 빠졌거나, 지문이 재계산과 다를 때.
+ """
+ if snapshot.get("schema") != ENGINE_SNAPSHOT_SCHEMA:
+ raise SnapshotError(f"unknown snapshot schema {snapshot.get('schema')!r}")
+ config = snapshot.get("config")
+ if not isinstance(config, Mapping):
+ raise SnapshotError("snapshot.config must be an object")
+ gaps = completeness_gaps(config)
+ if gaps:
+ raise SnapshotError(f"snapshot.config lacks normative keys {gaps} (SPEC §3.4)")
+ expected = config_fingerprint(config)
+ if snapshot.get("config_fingerprint") != expected:
+ raise SnapshotError(
+ f"config_fingerprint {snapshot.get('config_fingerprint')!r} != recomputed {expected!r}"
+ )
+
+
+def config_diff_keys(a: Mapping[str, Any], b: Mapping[str, Any]) -> list[str]:
+ """두 `snapshot.config` 의 값이 다른 최상위 키(정렬) — resume 드리프트 메시지용(D17).
+
+ 값 비교는 JCS 지문으로 한다(`0.0` ≡ `0`).
+ """
+ keys = set(a) | set(b)
+ return sorted(
+ k
+ for k in keys
+ if k not in a or k not in b or json_fingerprint(a[k]) != json_fingerprint(b[k])
+ )
+
+
+def _rescore_providers(provenance: Mapping[str, Any], judge_provider: Any) -> dict[str, str | None]:
+ """재채점의 프로바이더 모듈 — 추론은 기록된 이름, judge 는 넘겨받은 것(없으면 기록된 이름).
+
+ Raises:
+ SnapshotError: 기록된 추론 프로바이더 모듈이 없어 런타임 해시를 다시 뜰 수 없을 때.
+ """
+ recorded = provenance.get(PROVIDERS_KEY)
+ if not isinstance(recorded, Mapping) or not isinstance(recorded.get("inference"), str):
+ raise SnapshotError(
+ "snapshot.provenance.providers.inference is missing — cannot recompute "
+ "runtime_impl_hash for the new evaluator"
+ )
+ judge = recorded.get("judge")
+ if judge_provider is not None:
+ judge = impl_hash.provider_module(judge_provider)
+ return {"inference": recorded["inference"], "judge": judge}
+
+
+def with_evaluator(
+ snapshot: Mapping[str, Any],
+ evaluator_config: Mapping[str, Any] | None,
+ re_evaluated_at: str,
+ roots: ImplRoots | None = None,
+ judge_provider: Any = None,
+) -> dict[str, Any]:
+ """`runs eval`(재채점)용 — 평가기 블록을 새 평가기로 바꾸고 지문을 다시 계산한 **새** 스냅샷(D11).
+
+ judge 유무가 바뀌면 judge 프로바이더 모듈이 런타임 해시에 들어가거나 빠져야 하므로
+ `runtime_impl_hash`·`impl_modules["@runtime"]` 도 빌드와 같은 규칙(`_runtime`)으로 다시 뜬다 —
+ 재채점 run 이 같은 기능 설정의 새 run 과 같은 지문을 갖게. `judge_provider` 를 안 주면 빌드 때
+ 기록한 judge 모듈을 쓴다. `provenance.re_evaluated_at` 을 남기고 `deterministic` 도 다시 판정한다.
+
+ Raises:
+ ValueError: 평가기 설정이 잘못됐을 때(`evaluator_block`).
+ SnapshotError: 기록된 프로바이더 모듈이 없어 런타임 해시를 다시 뜰 수 없을 때.
+ """
+ roots = roots or impl_hash.default_roots()
+ updated = copy.deepcopy(dict(snapshot))
+ block, modules = evaluator_block(evaluator_config, roots)
+ provenance = updated.setdefault("provenance", {})
+ providers = _rescore_providers(provenance, judge_provider)
+ runtime_hash, runtime_modules = _runtime(providers, block["judge_model"], roots)
+ config = updated["config"]
+ config["evaluator_config"] = block
+ config["runtime_impl_hash"] = runtime_hash
+ updated["config_fingerprint"] = config_fingerprint(config)
+ impl_modules = provenance.setdefault("impl_modules", {})
+ impl_modules[EVALUATOR_MODULES_KEY] = modules
+ impl_modules[RUNTIME_MODULES_KEY] = runtime_modules
+ provenance[PROVIDERS_KEY] = providers
+ provenance["re_evaluated_at"] = re_evaluated_at
+ steps_use_model = provenance.get("steps_use_model")
+ if isinstance(steps_use_model, bool):
+ updated.setdefault("reproducibility", {})["deterministic"] = reproducibility.deterministic(
+ temperature=config.get("temperature"),
+ resolved_pipeline=config.get("resolved_pipeline") or {},
+ steps_use_model=steps_use_model,
+ judge_model=block["judge_model"],
+ )
+ return updated
+
+
+__all__ = [
+ "AUTO",
+ "CONFIG_KEYS",
+ "DEFAULT_EVALUATOR_CONFIG",
+ "ENGINE_SNAPSHOT_SCHEMA",
+ "EngineSnapshotInputs",
+ "ImplRoots",
+ "PROVIDERS_KEY",
+ "SnapshotError",
+ "build_engine_snapshot",
+ "config_diff_keys",
+ "evaluator_block",
+ "self_check",
+ "with_evaluator",
+]
diff --git a/solar_eval/core/snapshot/dataset_block.py b/solar_eval/core/snapshot/dataset_block.py
new file mode 100644
index 0000000000000000000000000000000000000000..75e0c6ca52ad571ba4981a9d668e5e9673463b47
--- /dev/null
+++ b/solar_eval/core/snapshot/dataset_block.py
@@ -0,0 +1,179 @@
+"""run.json `dataset` 블록(engine-dataset/v1)과 lock 대조 (계획 2026-09-24-phase1a S6 · D15).
+
+lock 대조의 정의(검토 F8) — `matched = true` 는 셋이 **모두** 맞을 때다:
+
+1. 읽은 파일 sha256 == lock 항목 `sha256`
+2. 이 run 의 태스크 이름 ∈ lock 항목 `dataset.tasks`
+3. 태스크의 `input_fields`·`golden_field`·`golden_fields` == lock 항목의 `inputFields`·`goldenField`·
+ `goldenFields` (lock `datasetVersion` 은 이 스펙으로 계산됐다)
+
+하나라도 다르면 `matched: false` + `mismatch: ["sha256" | "task" | "fields", …]`. 그래야 "전체셋 run 의
+`dataset_version` == lock `datasetVersion`" 이 조용히 깨지지 않는다.
+
+이 모듈은 engine-filter 의 타입(`PreparedDataset`)에 기대지 않고 **원시 값**을 받는다.
+"""
+
+from __future__ import annotations
+
+import copy
+import logging
+from collections.abc import Mapping, Sequence
+from pathlib import Path
+from typing import Any
+
+from poc_bundle.data.lockfile import read_lock
+from poc_bundle.data.lock_model import DatasetsLock, LockDatasetEntry
+
+logger = logging.getLogger(__name__)
+
+ENGINE_DATASET_SCHEMA = "engine-dataset/v1"
+
+#: config_dir 기준 lock 위치 (`poc-bundle data push` 가 쓴다).
+LOCK_RELATIVE_PATH = Path("dataset") / "datasets.lock.yaml"
+
+#: lock 대조가 적용되는 로더 — lock 은 로컬 데이터 홈(cbs-data 에서 pull 한 datasets 영역)의 원장이다.
+LOCAL_SOURCE = "local"
+
+MISMATCH_SHA = "sha256"
+MISMATCH_TASK = "task"
+MISMATCH_FIELDS = "fields"
+
+
+def lock_path_for(config_dir: Path) -> Path:
+ return Path(config_dir) / LOCK_RELATIVE_PATH
+
+
+def task_field_spec(
+ task_config: Mapping[str, Any],
+) -> tuple[list[str], str | None, dict[str, str] | None]:
+ """(input_fields, golden_field, golden_fields) — lock 계산(`poc_bundle.data.taskfields._golden`)과
+ 같은 정규화: 빈 값은 None, 둘 다 있으면 golden_field 가 이긴다(엔진 runner 규칙).
+ """
+ inputs = list(task_config.get("input_fields") or [])
+ field = task_config.get("golden_field") or None
+ fields = task_config.get("golden_fields") or None
+ if field and fields:
+ fields = None
+ return inputs, field, dict(fields) if fields else None
+
+
+def _display_path(path: Path, repo_root: Path | None) -> str:
+ if repo_root is not None:
+ try:
+ return path.resolve().relative_to(repo_root.resolve()).as_posix()
+ except ValueError:
+ pass
+ return path.resolve().as_posix()
+
+
+def _find_entry(lock: DatasetsLock, dataset_path: str) -> LockDatasetEntry | None:
+ return next((e for e in lock.datasets if e.path == dataset_path), None)
+
+
+def compare_entry(
+ entry: LockDatasetEntry, sha256: str, task_name: str, task_config: Mapping[str, Any]
+) -> list[str]:
+ """lock 항목과 이 run 의 불일치 사유(순서 고정: sha256 → task → fields). 빈 목록 = matched."""
+ mismatch: list[str] = []
+ if entry.sha256 != sha256:
+ mismatch.append(MISMATCH_SHA)
+ info = entry.dataset
+ if info is None:
+ # dataset 블록이 없는 항목(.jsonl 이 아님)은 태스크·필드를 대조할 수 없다.
+ return [*mismatch, MISMATCH_TASK, MISMATCH_FIELDS]
+ if task_name not in info.tasks:
+ mismatch.append(MISMATCH_TASK)
+ inputs, field, fields = task_field_spec(task_config)
+ lock_fields = dict(info.golden_fields) if info.golden_fields else None
+ if (inputs, field, fields) != (list(info.input_fields), info.golden_field, lock_fields):
+ mismatch.append(MISMATCH_FIELDS)
+ return mismatch
+
+
+def lock_block(
+ *,
+ sha256: str,
+ dataset_path: str,
+ task_config: Mapping[str, Any],
+ config_dir: Path | None,
+ project_name: str,
+ repo_root: Path | None = None,
+) -> dict[str, Any] | None:
+ """`dataset.lock` — lock 이 없거나·항목이 없거나·프로젝트 이름이 다르면 None.
+
+ Raises:
+ poc_bundle.data.errors.LockError: lock 파일이 규칙(L01~L20)을 어길 때 — 깨진 원장으로 대조한
+ run 을 만들지 않는다(`make data-lint` 가 같은 오류를 낸다).
+ """
+ if config_dir is None:
+ return None
+ path = lock_path_for(config_dir)
+ if not path.is_file():
+ return None
+ lock = read_lock(path)
+ if lock.project is not None and lock.project != project_name:
+ logger.warning(
+ "datasets.lock.yaml project %r != run project %r — lock not used for %s",
+ lock.project,
+ project_name,
+ dataset_path,
+ )
+ return None
+ entry = _find_entry(lock, dataset_path)
+ if entry is None:
+ return None
+ mismatch = compare_entry(entry, sha256, str(task_config.get("name", "")), task_config)
+ info = entry.dataset
+ return {
+ "path": _display_path(path, repo_root),
+ "matched": not mismatch,
+ "mismatch": mismatch,
+ "dataset_version": info.dataset_version if info else None,
+ "source": info.source.model_dump(mode="json") if info and info.source else None,
+ "builder": info.builder.model_dump(mode="json") if info and info.builder else None,
+ }
+
+
+def describe_dataset(
+ *,
+ sha256: str,
+ rows_total: int,
+ rows_selected: int,
+ selection: Mapping[str, Any] | None,
+ meta_fields: Sequence[str] | None,
+ task_config: Mapping[str, Any],
+ loader_cfg: Mapping[str, Any],
+ config_dir: Path | None,
+ project_name: str,
+ repo_root: Path | None = None,
+) -> dict[str, Any]:
+ """run.json `dataset` 블록. `selection` 은 engine-filter 가 정규화한 JSON(`{where, limit}`).
+
+ Raises:
+ ValueError: 행 수가 음수이거나 선택 행이 전체보다 많을 때(호출자 버그를 크게 알린다).
+ """
+ if rows_total < 0 or rows_selected < 0 or rows_selected > rows_total:
+ raise ValueError(f"invalid row counts: selected={rows_selected}, total={rows_total}")
+ source = loader_cfg.get("source", "huggingface")
+ dataset_path = str(task_config.get("dataset_path", ""))
+ lock = None
+ if source == LOCAL_SOURCE:
+ lock = lock_block(
+ sha256=sha256,
+ dataset_path=dataset_path,
+ task_config=task_config,
+ config_dir=config_dir,
+ project_name=project_name,
+ repo_root=repo_root,
+ )
+ return {
+ "schema": ENGINE_DATASET_SCHEMA,
+ "loader": {"source": source, "repo": loader_cfg.get("repo", "")},
+ "path": dataset_path,
+ "sha256": sha256,
+ "rows_total": rows_total,
+ "rows_selected": rows_selected,
+ "selection": copy.deepcopy(dict(selection)) if selection is not None else None,
+ "meta_fields": list(meta_fields) if meta_fields else None,
+ "lock": lock,
+ }
diff --git a/solar_eval/core/snapshot/environment.py b/solar_eval/core/snapshot/environment.py
new file mode 100644
index 0000000000000000000000000000000000000000..b0d4fb6095bf50dbea619552d5ac47289039f4f2
--- /dev/null
+++ b/solar_eval/core/snapshot/environment.py
@@ -0,0 +1,210 @@
+"""실행 환경 재료 — 의존 버전·엔진 버전·엔진 레포 상태 (계획 2026-09-24-phase1a D8·D9).
+
+config 에 들어가는 것(지문 대상)은 출력 경로에 있는 **직접** 런타임 의존과 엔진 버전·파이썬 부버전뿐이다.
+전이 설치 폐포·uv.lock·엔진 커밋·트리 해시·dirty 는 provenance(지문 밖)로 간다 — 무관한 워크스페이스
+멤버의 변화가 설정 지문을 흔들지 않게.
+"""
+
+from __future__ import annotations
+
+import logging
+import subprocess
+import sys
+from collections.abc import Callable
+from importlib import metadata
+from pathlib import Path
+from typing import Any
+
+from packaging.requirements import InvalidRequirement, Requirement
+from packaging.utils import canonicalize_name
+from poc_bundle.canonical import content_sha256
+
+import solar_eval
+from solar_eval._version import __version__
+from solar_eval.core.snapshot.impl_hash import ImplRoots, engine_tree_sha256
+
+logger = logging.getLogger(__name__)
+
+#: 엔진 배포 이름.
+ENGINE_DISTRIBUTION = "solar-eval"
+
+#: 모델 입출력에 닿지 않는 직접 의존 — CLI 표시·설정 로드·번들 도구. `dependency_lock` 에서 뺀다.
+#: 새 직접 의존을 추가하면 이 목록이나 출력 의존 중 하나로 분류하기 전까지 테스트가 깨진다(의도).
+NON_OUTPUT_DEPENDENCIES = frozenset(
+ {"click", "tabulate", "rich", "pydantic-settings", "poc-bundle", "packaging"}
+)
+
+DEPENDENCY_LOCK_SCHEME = "direct-runtime/v1"
+
+#: config_dir 안에서 출력에 영향을 주는 경로(dirty 범위). scripts/·insights/·README 는 뺀다(검토 F12·m3).
+CONFIG_DIRTY_ENTRIES = (
+ "project.yaml",
+ "prompts",
+ "pipelines",
+ "resources",
+ "dataset/datasets.lock.yaml",
+)
+
+GitRun = Callable[..., Any]
+
+
+def _marker_env() -> dict[str, str]:
+ return {"extra": ""}
+
+
+def direct_requirements(distribution: str = ENGINE_DISTRIBUTION) -> list[str]:
+ """배포의 직접 `Requires-Dist` 중 현재 환경에서 켜지는 것(extra 없음)의 정규화 이름(정렬)."""
+ names: set[str] = set()
+ for raw in metadata.requires(distribution) or []:
+ try:
+ req = Requirement(raw)
+ except InvalidRequirement as exc:
+ raise ValueError(f"{distribution}: unparsable requirement {raw!r}: {exc}") from exc
+ if req.marker is not None and not req.marker.evaluate(_marker_env()):
+ continue
+ names.add(canonicalize_name(req.name))
+ return sorted(names)
+
+
+def direct_runtime_dependencies() -> dict[str, str]:
+ """`dependency_lock.packages` — 출력 경로에 있는 직접 의존의 설치 버전."""
+ return {
+ name: metadata.version(name)
+ for name in direct_requirements()
+ if name not in NON_OUTPUT_DEPENDENCIES
+ }
+
+
+def dependency_lock() -> dict[str, Any]:
+ return {
+ "scheme": DEPENDENCY_LOCK_SCHEME,
+ "root": ENGINE_DISTRIBUTION,
+ "packages": direct_runtime_dependencies(),
+ }
+
+
+def installed_closure(distribution: str = ENGINE_DISTRIBUTION) -> dict[str, str | None]:
+ """엔진에서 닿는 설치 폐포 `{이름: 버전}` — provenance 전용(환경 재구성·디버그).
+
+ 켜진 요구인데 설치돼 있지 않으면 버전 None 으로 남긴다(숨기지 않는다).
+ """
+ seen: dict[str, str | None] = {}
+ queue: list[str] = [str(canonicalize_name(distribution))]
+ while queue:
+ name = queue.pop()
+ if name in seen:
+ continue
+ try:
+ seen[name] = metadata.version(name)
+ except metadata.PackageNotFoundError:
+ seen[name] = None
+ continue
+ queue.extend(n for n in direct_requirements(name) if n not in seen)
+ return dict(sorted(seen.items()))
+
+
+def runner_version() -> dict[str, str]:
+ """`{"package", "version", "python": "."}` — 패치 버전은 넣지 않는다."""
+ return {
+ "package": ENGINE_DISTRIBUTION,
+ "version": __version__,
+ "python": f"{sys.version_info.major}.{sys.version_info.minor}",
+ }
+
+
+def engine_src_dir() -> Path:
+ """엔진 소스 루트(`packages/solar-eval-python/src`) — `solar_eval.__file__` 기준."""
+ return Path(solar_eval.__file__).resolve().parent.parent
+
+
+def engine_repo(start: Path | None = None) -> Path | None:
+ """엔진 코드가 든 git 레포 — `start`(기본 엔진 소스)에서 위로 `.git` 을 찾는다.
+
+ `cfg.repo_root` 를 쓰지 않는다: `--projects-dir` 면 None 이 된다(검토 F13). 워크트리의 `.git` 은
+ 파일이라 존재만 본다.
+ """
+ here = (start or engine_src_dir()).resolve()
+ for candidate in (here, *here.parents):
+ if (candidate / ".git").exists():
+ return candidate
+ return None
+
+
+def dirty_scope(
+ repo: Path, config_dir: Path | None, repo_managed: bool, src_dir: Path | None = None
+) -> list[str]:
+ """dirty 판정 범위(레포 상대 POSIX, 정렬). 레포 밖 경로는 넣지 않는다."""
+ src = (src_dir or engine_src_dir()).resolve()
+ candidates = [src, src.parent / "pyproject.toml", repo / "uv.lock"]
+ if repo_managed and config_dir is not None:
+ candidates += [Path(config_dir).resolve() / entry for entry in CONFIG_DIRTY_ENTRIES]
+ root = repo.resolve()
+ scope = set()
+ for path in candidates:
+ try:
+ scope.add(path.resolve().relative_to(root).as_posix())
+ except ValueError:
+ logger.warning("dirty scope path outside the engine repo, skipped: %s", path)
+ return sorted(scope)
+
+
+def _git(run: GitRun, repo: Path, *args: str) -> str | None:
+ """git 한 번. 실패(git 없음·레포 아님·비정상 종료)는 None — 호출자가 null 로 기록한다."""
+ try:
+ result = run(["git", "-C", str(repo), *args], capture_output=True, text=True, check=True)
+ except (OSError, subprocess.CalledProcessError) as exc:
+ logger.warning("git %s failed in %s: %s", " ".join(args[:2]), repo, exc)
+ return None
+ return result.stdout
+
+
+def engine_state(
+ config_dir: Path | None,
+ repo_managed: bool,
+ *,
+ repo: Path | None = None,
+ src_dir: Path | None = None,
+ roots: ImplRoots | None = None,
+ run: GitRun = subprocess.run,
+) -> dict[str, Any]:
+ """`provenance.engine = {commit, tree_sha256, dirty, dirty_scope}` (D8).
+
+ `repo` 를 안 주면 `engine_repo()`. 레포를 못 찾거나 git 이 실패하면 `commit`·`dirty` 는 null,
+ `tree_sha256` 은 git 없이도 계산된다.
+ """
+ repo = repo if repo is not None else engine_repo(src_dir)
+ tree = engine_tree_sha256(roots)
+ if repo is None:
+ return {"commit": None, "tree_sha256": tree, "dirty": None, "dirty_scope": []}
+ scope = dirty_scope(repo, config_dir, repo_managed, src_dir)
+ head = _git(run, repo, "rev-parse", "HEAD")
+ # 빈 범위로 status 를 부르면 레포 전체를 본다 — 범위가 없으면 판정하지 않는다(null).
+ # `--literal-pathspecs`: config_dir 이름의 `[`·`*`·`?` 를 glob 으로 읽지 않게(범위 밖 변경이 dirty 로).
+ status = (
+ _git(
+ run,
+ repo,
+ "--literal-pathspecs",
+ "status",
+ "--porcelain",
+ "--untracked-files=normal",
+ "--",
+ *scope,
+ )
+ if scope
+ else None
+ )
+ return {
+ "commit": head.strip() if head else None,
+ "tree_sha256": tree,
+ "dirty": None if status is None else bool(status.strip()),
+ "dirty_scope": scope,
+ }
+
+
+def uv_lock_sha256(repo: Path | None) -> str | None:
+ """레포 `uv.lock` 의 sha256 — provenance 전용(모노레포 lock 은 무관한 멤버로도 바뀐다)."""
+ if repo is None:
+ return None
+ lock = repo / "uv.lock"
+ return content_sha256(lock) if lock.is_file() else None
diff --git a/solar_eval/core/snapshot/impl_hash.py b/solar_eval/core/snapshot/impl_hash.py
new file mode 100644
index 0000000000000000000000000000000000000000..955f96071550ad9478534df3b93f9c60122fbd64
--- /dev/null
+++ b/solar_eval/core/snapshot/impl_hash.py
@@ -0,0 +1,365 @@
+"""구현 해시 — 모듈의 AST import 폐포 × 파일 바이트 (계획 2026-09-24-phase1a D7).
+
+세 갈래가 같은 재료를 쓴다:
+
+- **스텝**(`step_impl_hashes`): 스텝 클래스 모듈에서 시작한 import 폐포.
+- **평가기**(`evaluator_impl_hash`): 평가기 클래스(합성이면 하위 전부)의 모듈에서 시작한 폐포.
+- **런타임**(`runtime_impl_hash`): `RUNTIME_MODULES` + 실제로 쓴 프로바이더 모듈 — 폐포를 돌지 않고
+ 목록의 파일 바이트만 본다(목록이 곧 정의).
+
+모듈 → 파일은 **이름으로** 푼다(`ImplRoots`). 이미 import 된 모듈의 `__file__` 이나 importlib spec 을
+쓰지 않으므로, 테스트가 기본 루트를 tmp 사본으로 바꿔 끼우면 재import 없이 "구현만 바꾼" 상황이
+만들어진다. 해시는 계산하는 순간 디스크의 파일을 읽는다.
+"""
+
+from __future__ import annotations
+
+import ast
+import os
+import sys
+import sysconfig
+from collections.abc import Iterable, Sequence
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from poc_bundle.canonical import content_sha256, json_fingerprint
+
+import solar_eval
+
+#: 엔진 패키지 이름 — 패키지 루트에서는 이 이름 아래 모듈만 푼다(서드파티 차단).
+ENGINE_PACKAGE = "solar_eval"
+
+#: 러너·파이프라인 실행기·프롬프트 조립 — 모든 스텝에 똑같이 영향을 주는 프레임워크 모듈.
+#: 여기에 이번 run 이 쓰는 프로바이더 모듈을 더해 `runtime_impl_hash` 를 뜬다. 테스트가 집합을 고정한다.
+#: 러너에서 떼어 낸 도우미(입력·정답 추출 `run_records`, 행 선택 `dataset_prep`·`row_filter`, 파이프라인
+#: 생성 `pipeline_factory`)도 러너 로직이라 함께 본다 — `solar_eval.core.*` 는 스텝·평가기 폐포에서 빠지므로
+#: 여기 없으면 어떤 해시도 이 파일들을 보지 않는다. 테스트가 러너의 core import 가 전부 여기 있는지 지킨다.
+RUNTIME_MODULES: tuple[str, ...] = (
+ "solar_eval.core.runner",
+ "solar_eval.core.dataset_loader",
+ "solar_eval.core.dataset_prep",
+ "solar_eval.core.row_filter",
+ "solar_eval.core.pipeline_factory",
+ "solar_eval.core.run_records",
+ "solar_eval.pipelines.pipeline",
+ "solar_eval.pipelines.registry",
+ "solar_eval.pipelines.base",
+ "solar_eval.evaluators.base",
+ "solar_eval.evaluators.registry",
+ "solar_eval.providers.base",
+ "solar_eval.models.prompt_version",
+ "solar_eval.models.sample",
+)
+
+#: 스텝·평가기 폐포에서 빼는 공용 기반 — 런타임 해시가 한 번 보거나 출력과 무관한 것.
+_EXCLUDED_EXACT = frozenset(
+ {*RUNTIME_MODULES, "solar_eval.pipelines.errors", "solar_eval.config", "solar_eval._version"}
+)
+_EXCLUDED_PREFIXES = (
+ "solar_eval.providers.",
+ "solar_eval.cli.",
+ "solar_eval.stores.",
+ "solar_eval.core.",
+)
+
+#: 구현 해시 라벨 접두어 — `pkg:<루트 기준 POSIX 경로>`.
+LABEL_PREFIX = "pkg:"
+#: 루트에서 풀 수 없는 프로바이더 모듈(테스트 mock 등)의 라벨 접두어. 값은 null 로 들어간다.
+UNRESOLVED_PREFIX = "unresolved:"
+
+
+class ImplRootError(ValueError):
+ """구현 해시 루트로 쓸 수 없는 디렉토리(서드파티·표준 라이브러리 영역)."""
+
+
+def _forbidden_bases() -> list[Path]:
+ """등록 루트로 거부하는 영역 — site-packages·표준 라이브러리·인터프리터 prefix."""
+ paths = sysconfig.get_paths()
+ bases = [paths.get(k) for k in ("purelib", "platlib", "stdlib")]
+ bases += [sys.prefix, sys.base_prefix]
+ return [Path(b).resolve() for b in bases if b]
+
+
+def _is_within(path: Path, base: Path) -> bool:
+ try:
+ path.relative_to(base)
+ except ValueError:
+ return False
+ return True
+
+
+@dataclass(frozen=True)
+class ResolvedModule:
+ """이름으로 푼 모듈 하나. `label` 은 지문 키(`pkg:solar_eval/pipelines/rules.py`)."""
+
+ name: str
+ path: Path
+ label: str
+
+
+@dataclass(frozen=True)
+class ImplRoots:
+ """모듈 이름 → 파일 해석 루트.
+
+ - `package_parent`: `solar_eval` 패키지의 부모 디렉토리. 여기서는 `solar_eval.*` 이름만 푼다 —
+ 엔진이 site-packages 에 설치돼 있어도 서드파티가 폐포에 들어오지 않는다.
+ - `extra`: 테스트·확장이 명시 등록한 디렉토리. 여기서는 어떤 이름이든 푼다. 서드파티·표준
+ 라이브러리 영역(`sysconfig` purelib/platlib/stdlib, `sys.prefix`, `sys.base_prefix`)은 거부한다
+ (uv 워크스페이스 venv 가 레포 안에 있어 "레포 안" 규칙이면 httpx 가 폐포에 들어온다 — 검토 F3).
+ """
+
+ package_parent: Path
+ extra: tuple[Path, ...] = field(default=())
+
+ def __post_init__(self) -> None:
+ forbidden = _forbidden_bases()
+ for root in self.extra:
+ resolved = Path(root).resolve()
+ if any(_is_within(resolved, base) for base in forbidden):
+ raise ImplRootError(
+ f"{root} is inside the interpreter/site-packages area — third-party code is "
+ "covered by dependency_lock, not by implementation hashes"
+ )
+
+ @classmethod
+ def for_engine(cls, extra: Iterable[Path] = ()) -> ImplRoots:
+ """지금 import 된 `solar_eval` 패키지의 부모 + 등록 디렉토리."""
+ parent = Path(solar_eval.__file__).resolve().parent.parent
+ return cls(parent, tuple(Path(p) for p in extra))
+
+ def with_extra(self, *dirs: Path) -> ImplRoots:
+ return ImplRoots(self.package_parent, (*self.extra, *(Path(d) for d in dirs)))
+
+ def resolve(self, name: str) -> ResolvedModule | None:
+ """모듈 이름을 파일로 푼다: `R//.py` 또는 `R///__init__.py`. 없으면 None."""
+ parts = name.split(".")
+ if not all(p.isidentifier() for p in parts):
+ return None
+ roots: list[Path] = []
+ if parts[0] == ENGINE_PACKAGE:
+ roots.append(self.package_parent)
+ roots.extend(self.extra)
+ for root in roots:
+ base = Path(root).joinpath(*parts)
+ for candidate in (base.with_suffix(".py"), base / "__init__.py"):
+ if candidate.is_file() and _exact_case(Path(root), candidate):
+ rel = candidate.relative_to(root).as_posix()
+ return ResolvedModule(name, candidate, LABEL_PREFIX + rel)
+ return None
+
+
+def _exact_case(root: Path, candidate: Path) -> bool:
+ """`candidate` 의 경로 성분이 디렉토리 목록의 이름과 **대소문자까지** 같은가.
+
+ macOS APFS 의 `is_file()` 은 대소문자를 무시한다 — `from pkg import Helper`(클래스)가
+ 형제 모듈 `pkg/helper.py` 로 잘못 풀려, 같은 코드가 Linux 와 다른 폐포·라벨을 낸다.
+ 파이썬 import 는 대소문자를 구분하므로 여기서도 목록과 정확히 대조해 Linux 와 같게 한다.
+ """
+ current = root
+ for part in candidate.relative_to(root).parts:
+ try:
+ names = os.listdir(current)
+ except OSError:
+ return False
+ if part not in names:
+ return False
+ current = current / part
+ return True
+
+
+# 기본 루트 — None 이면 `ImplRoots.for_engine()`. 테스트는 `set_default_roots` 로 사본을 끼운다.
+_DEFAULT_ROOTS: ImplRoots | None = None
+
+
+def default_roots() -> ImplRoots:
+ """기본 해석 루트. `set_default_roots` 로 바꾼 값이 있으면 그것."""
+ return _DEFAULT_ROOTS if _DEFAULT_ROOTS is not None else ImplRoots.for_engine()
+
+
+def set_default_roots(roots: ImplRoots | None) -> ImplRoots | None:
+ """기본 루트를 바꾸고 이전 값을 돌려준다(복원용). None 이면 엔진 기본으로 되돌린다."""
+ global _DEFAULT_ROOTS
+ previous = _DEFAULT_ROOTS
+ _DEFAULT_ROOTS = roots
+ return previous
+
+
+def is_excluded(module: ResolvedModule) -> bool:
+ """스텝·평가기 폐포에서 빼는가 — 런타임 목록·공용 기반·모든 패키지 `__init__`."""
+ if module.path.name == "__init__.py":
+ return True
+ if module.name in _EXCLUDED_EXACT:
+ return True
+ return module.name.startswith(_EXCLUDED_PREFIXES)
+
+
+def _package_of(module: ResolvedModule) -> str:
+ if module.path.name == "__init__.py":
+ return module.name
+ return module.name.rpartition(".")[0]
+
+
+def _absolute_from(module: ResolvedModule, node: ast.ImportFrom) -> str | None:
+ """`from ..x import y` 의 기준 모듈 이름. 최상위 밖으로 나가면 None."""
+ if node.level == 0:
+ return node.module
+ package = _package_of(module).split(".") if _package_of(module) else []
+ keep = len(package) - (node.level - 1)
+ if keep < 0 or (keep == 0 and not node.module):
+ return None
+ base = package[:keep]
+ if node.module:
+ base += node.module.split(".")
+ return ".".join(base) if base else None
+
+
+def imported_names(module: ResolvedModule, roots: ImplRoots) -> list[str]:
+ """모듈 소스의 **모든** import(함수 안·TYPE_CHECKING 블록 포함)가 가리키는 모듈 이름.
+
+ `from P import X` 는 `P.X` 가 모듈이면 그 모듈, 아니면 `P`.
+ """
+ tree = ast.parse(module.path.read_bytes(), filename=str(module.path))
+ names: list[str] = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Import):
+ names.extend(alias.name for alias in node.names)
+ elif isinstance(node, ast.ImportFrom):
+ base = _absolute_from(module, node)
+ if base is None:
+ continue
+ for alias in node.names:
+ candidate = f"{base}.{alias.name}"
+ is_module = alias.name != "*" and roots.resolve(candidate) is not None
+ names.append(candidate if is_module else base)
+ return names
+
+
+def import_closure(
+ start: Iterable[str], roots: ImplRoots, *, exclude: bool = True
+) -> dict[str, ResolvedModule]:
+ """시작 모듈들에서 루트 안으로 닿는 import 폐포. 시작 모듈은 제외 규칙과 무관하게 들어간다.
+
+ Raises:
+ ValueError: 시작 모듈을 루트에서 풀 수 없을 때(구현을 볼 수 없는 스텝·평가기).
+ """
+ closure: dict[str, ResolvedModule] = {}
+ queue: list[ResolvedModule] = []
+ for name in start:
+ resolved = roots.resolve(name)
+ if resolved is None:
+ raise ValueError(
+ f"module {name!r} is not under the implementation roots "
+ f"({roots.package_parent}, extra={list(roots.extra)}) — register its directory"
+ )
+ queue.append(resolved)
+ while queue:
+ module = queue.pop()
+ if module.name in closure:
+ continue
+ closure[module.name] = module
+ for name in imported_names(module, roots):
+ found = roots.resolve(name)
+ if found is None or found.name in closure:
+ continue
+ if exclude and is_excluded(found):
+ continue
+ queue.append(found)
+ return closure
+
+
+def impl_hash(modules: Iterable[ResolvedModule]) -> str:
+ """`json_fingerprint({label: content_sha256(파일 바이트)})` — 주석 변경도 해시를 바꾼다(의도)."""
+ return json_fingerprint({m.label: content_sha256(m.path) for m in modules})
+
+
+def closure_hash(start: Iterable[str], roots: ImplRoots) -> tuple[str, list[str]]:
+ """(폐포 해시, 정렬된 라벨 목록)."""
+ closure = import_closure(start, roots)
+ return impl_hash(closure.values()), sorted(m.label for m in closure.values())
+
+
+def step_impl_hashes(
+ pipeline: Any, roots: ImplRoots | None = None
+) -> tuple[dict[str, str], dict[str, list[str]]]:
+ """생성된 `Pipeline` 의 스텝마다 (키 → 해시, 키 → 폐포 라벨). 키는 레지스트리 조회 이름.
+
+ single_step 의 암묵 `llm_completion` 도 파이프라인 객체에 있으므로 들어간다(검토 F10).
+ """
+ from solar_eval.pipelines.registry import step_key
+
+ roots = roots or default_roots()
+ hashes: dict[str, str] = {}
+ modules: dict[str, list[str]] = {}
+ for step in pipeline.steps:
+ key = step_key(step.config)
+ if key in hashes:
+ continue
+ hashes[key], modules[key] = closure_hash([type(step).__module__], roots)
+ return dict(sorted(hashes.items())), dict(sorted(modules.items()))
+
+
+def _evaluator_modules(evaluator: Any) -> list[str]:
+ names = [type(evaluator).__module__]
+ for child in evaluator.sub_evaluators():
+ names.extend(_evaluator_modules(child))
+ return names
+
+
+def evaluator_impl_hash(evaluator: Any, roots: ImplRoots | None = None) -> tuple[str, list[str]]:
+ """평가기(합성이면 하위 전부) 모듈에서 시작한 폐포의 (해시, 라벨)."""
+ return closure_hash(_evaluator_modules(evaluator), roots or default_roots())
+
+
+def provider_module(provider: Any) -> str:
+ """프로바이더(인스턴스나 클래스)가 정의된 모듈 이름."""
+ return provider.__module__ if isinstance(provider, type) else type(provider).__module__
+
+
+def runtime_impl_hash(
+ providers: Sequence[Any], roots: ImplRoots | None = None
+) -> tuple[str, list[str]]:
+ """`RUNTIME_MODULES` + 프로바이더 모듈의 파일 바이트 지문 (폐포 없음).
+
+ 프로바이더는 인스턴스나 클래스를 받는다. 루트에서 풀 수 없는 프로바이더 모듈(테스트 mock 등)은
+ `unresolved:<모듈>` 키에 null 로 들어간다 — 모르는 코드를 조용히 빼지 않는다.
+
+ Raises:
+ ValueError: `RUNTIME_MODULES` 중 하나를 루트에서 풀 수 없을 때.
+ """
+ return runtime_impl_hash_for_modules([provider_module(p) for p in providers], roots)
+
+
+def runtime_impl_hash_for_modules(
+ provider_modules: Iterable[str], roots: ImplRoots | None = None
+) -> tuple[str, list[str]]:
+ """`runtime_impl_hash` 의 모듈 이름판 — 재채점(`with_evaluator`)이 기록된 모듈 이름으로 다시 뜬다."""
+ roots = roots or default_roots()
+ entries: dict[str, str | None] = {}
+ for name in RUNTIME_MODULES:
+ resolved = roots.resolve(name)
+ if resolved is None:
+ raise ValueError(f"runtime module {name!r} not found under {roots.package_parent}")
+ entries[resolved.label] = content_sha256(resolved.path)
+ for name in sorted(set(provider_modules)):
+ resolved = roots.resolve(name)
+ if resolved is None:
+ entries[UNRESOLVED_PREFIX + name] = None
+ else:
+ entries[resolved.label] = content_sha256(resolved.path)
+ return json_fingerprint(entries), sorted(entries)
+
+
+def engine_tree_sha256(roots: ImplRoots | None = None) -> str:
+ """엔진 패키지 트리(`solar_eval/**/*.py`, `__pycache__` 제외)의 파일 바이트 지문 — provenance 용.
+
+ git 없이도 계산된다. 키는 패키지 부모 기준 POSIX 경로(`solar_eval/core/runner.py`).
+ """
+ parent = (roots or default_roots()).package_parent
+ package = Path(parent) / ENGINE_PACKAGE
+ entries = {
+ path.relative_to(parent).as_posix(): content_sha256(path)
+ for path in sorted(package.rglob("*.py"))
+ if "__pycache__" not in path.relative_to(parent).parts
+ }
+ return json_fingerprint(entries)
diff --git a/solar_eval/core/snapshot/reproducibility.py b/solar_eval/core/snapshot/reproducibility.py
new file mode 100644
index 0000000000000000000000000000000000000000..09c74557f4f665a62cf075a6df8317a8859ce091
--- /dev/null
+++ b/solar_eval/core/snapshot/reproducibility.py
@@ -0,0 +1,149 @@
+"""run.json `snapshot.reproducibility` — 다시 돌릴 수 있는가·결정적인가 (계획 2026-09-24-phase1a D10·D17).
+
+두 필드 모두 run.json(과 번들 `config_snapshot.json` provenance)에만 둔다. 매니페스트 노출은
+`poc-bundle/v2` 로 미룬다(Q8).
+"""
+
+from __future__ import annotations
+
+import copy
+from collections.abc import Iterable, Mapping, Sequence
+from typing import Any
+
+from solar_eval.pipelines.pipeline import ResourceRef
+
+BLOCKER_ENGINE_DIRTY = "engine_dirty"
+BLOCKER_CONFIG_OUTSIDE_REPO = "config_outside_repo"
+BLOCKER_DATASET_NOT_LOCKED = "dataset_not_locked"
+BLOCKER_DATASET_LOCK_MISMATCH = "dataset_lock_mismatch"
+BLOCKER_DATASET_REMOTE = "dataset_remote_source"
+BLOCKER_RESUMED_ENGINE_CHANGE = "resumed_with_engine_change"
+BLOCKER_RESOURCE_UNRESOLVED = "resource_unresolved:"
+BLOCKER_RESOURCE_OUTSIDE_REPO = "resource_outside_repo:"
+
+_LOCAL_SOURCE = "local"
+
+
+def _dataset_blockers(dataset: Mapping[str, Any] | None) -> list[str]:
+ if dataset is None:
+ return [BLOCKER_DATASET_NOT_LOCKED]
+ if dataset.get("loader", {}).get("source") != _LOCAL_SOURCE:
+ return [BLOCKER_DATASET_REMOTE]
+ lock = dataset.get("lock")
+ if lock is None:
+ return [BLOCKER_DATASET_NOT_LOCKED]
+ if not lock.get("matched"):
+ return [BLOCKER_DATASET_LOCK_MISMATCH]
+ return []
+
+
+def _resource_blockers(refs: Mapping[str, ResourceRef]) -> list[str]:
+ blockers: list[str] = []
+ for label, ref in sorted(refs.items()):
+ if ref.path is None:
+ blockers.append(BLOCKER_RESOURCE_UNRESOLVED + label)
+ elif ref.kind != "config":
+ # data: 자원(PMI 등)은 레포 밖 데이터 홈이고 lock 대상도 아니다 — 커밋으로 복원할 수 없다.
+ blockers.append(BLOCKER_RESOURCE_OUTSIDE_REPO + label)
+ return blockers
+
+
+def _verdict(blockers: Sequence[str], engine_dirty: bool | None) -> bool | None:
+ if blockers:
+ return False
+ return None if engine_dirty is None else True
+
+
+def rerunnable(
+ *,
+ engine_dirty: bool | None,
+ repo_managed: bool,
+ dataset: Mapping[str, Any] | None,
+ resources: Mapping[str, ResourceRef],
+) -> tuple[bool | None, list[str]]:
+ """(rerunnable, 막는 이유 목록). true = 기록된 정보만으로 같은 입력을 다시 만들 수 있다.
+
+ 막는 이유가 하나라도 있으면 false, 없는데 `engine_dirty` 가 null(git 불명)이면 null.
+ """
+ blockers: list[str] = []
+ if engine_dirty:
+ blockers.append(BLOCKER_ENGINE_DIRTY)
+ if not repo_managed:
+ blockers.append(BLOCKER_CONFIG_OUTSIDE_REPO)
+ blockers += _dataset_blockers(dataset)
+ blockers += _resource_blockers(resources)
+ return _verdict(blockers, engine_dirty), blockers
+
+
+def _positive(value: Any) -> bool:
+ return isinstance(value, int | float) and not isinstance(value, bool) and value > 0
+
+
+def _step_temperatures(step: Mapping[str, Any]) -> Iterable[Any]:
+ yield step.get("temperature")
+ temps = step.get("repeat_temperatures")
+ if isinstance(temps, Sequence) and not isinstance(temps, str | bytes):
+ yield from temps
+
+
+def deterministic(
+ *,
+ temperature: Any,
+ resolved_pipeline: Mapping[str, Any],
+ steps_use_model: bool,
+ judge_model: str | None,
+) -> bool | None:
+ """true = 모델 호출이 전혀 없다 · false = 샘플링 온도 > 0 이 확인됨 · null = 온도 0 의 LLM 호출.
+
+ API 는 온도 0 에서도 결정적이라는 보장이 없으므로 그 경우는 모른다(null, Q7).
+ """
+ if not steps_use_model and judge_model is None:
+ return True
+ temps: list[Any] = [temperature]
+ for step in resolved_pipeline.get("steps") or []:
+ if isinstance(step, Mapping):
+ temps.extend(_step_temperatures(step))
+ if any(_positive(t) for t in temps):
+ return False
+ return None
+
+
+def _or3(values: Iterable[bool | None]) -> bool | None:
+ """하나라도 true → true, 아니고 하나라도 null → null, 모두 false → false."""
+ items = list(values)
+ if any(v is True for v in items):
+ return True
+ if any(v is None for v in items):
+ return None
+ return False
+
+
+def merge_resume(snapshot: Mapping[str, Any], resume_engine: Mapping[str, Any], at: str) -> dict:
+ """resume 1회를 스냅샷에 반영한 **새** 스냅샷(입력은 바꾸지 않는다, D17).
+
+ - `provenance.resumes` 에 `{at, engine: {commit, tree_sha256, dirty}}` 를 덧붙인다.
+ - `reproducibility.engine_dirty` = 시작·모든 resume 의 dirty OR.
+ - 엔진 트리 해시가 시작과 다르면 `resumed_with_engine_change` 를 막는 이유에 더한다.
+ config·지문은 건드리지 않는다 — 설정 드리프트는 호출자가 지문 비교로 먼저 막는다.
+ """
+ merged = copy.deepcopy(dict(snapshot))
+ provenance = merged.setdefault("provenance", {})
+ start = provenance.get("engine") or {}
+ entry = {
+ "at": at,
+ "engine": {k: resume_engine.get(k) for k in ("commit", "tree_sha256", "dirty")},
+ }
+ resumes = [*provenance.get("resumes", []), entry]
+ provenance["resumes"] = resumes
+ repro = merged.setdefault("reproducibility", {})
+ dirty = _or3([start.get("dirty"), *(r["engine"]["dirty"] for r in resumes)])
+ blockers = list(repro.get("rerunnable_blockers", []))
+ if dirty and BLOCKER_ENGINE_DIRTY not in blockers:
+ blockers.insert(0, BLOCKER_ENGINE_DIRTY)
+ changed = any(r["engine"]["tree_sha256"] != start.get("tree_sha256") for r in resumes)
+ if changed and BLOCKER_RESUMED_ENGINE_CHANGE not in blockers:
+ blockers.append(BLOCKER_RESUMED_ENGINE_CHANGE)
+ repro["engine_dirty"] = dirty
+ repro["rerunnable_blockers"] = blockers
+ repro["rerunnable"] = _verdict(blockers, dirty)
+ return merged
diff --git a/solar_eval/core/snapshot/resources.py b/solar_eval/core/snapshot/resources.py
new file mode 100644
index 0000000000000000000000000000000000000000..21404ecca1c45053321b03ec394039cd9c40bc61
--- /dev/null
+++ b/solar_eval/core/snapshot/resources.py
@@ -0,0 +1,78 @@
+"""스냅샷의 파일 재료 — 스텝이 읽을 자원(`resources`)과 프롬프트 파일(`prompt_assets`).
+
+둘 다 **내용**만 본다: 디렉토리·파일 이름은 값에 들어가지 않으므로(프롬프트는 디렉토리 기준 상대경로가
+키) 이름만 바꾼 사본은 같은 값이다 (계획 2026-09-24-phase1a D6).
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from poc_bundle.canonical import content_sha256
+
+from solar_eval.core.fingerprint import iter_prompt_asset_files
+from solar_eval.pipelines.pipeline import ResourceRef
+
+#: 단일 YAML 프롬프트 파일의 `prompt_assets` 키 — 파일 이름이 지문에 들어가지 않게.
+SINGLE_FILE_KEY = "@file"
+
+
+@dataclass(frozen=True)
+class CollectedResources:
+ """`resources` 값과 그 해석 결과.
+
+ Attributes:
+ hashes: `{라벨: sha256_64 | None}`(라벨 정렬). None = 해석 못 함(`hf:` 선언 등).
+ refs: 라벨 → 원래 `ResourceRef`(재현 판정이 종류를 본다).
+ """
+
+ hashes: dict[str, str | None]
+ refs: dict[str, ResourceRef]
+
+ @property
+ def unresolved(self) -> list[str]:
+ """값이 None 인 라벨 — 하나라도 있으면 `snapshot.missing` 에 `resources` 가 오른다."""
+ return [label for label, value in self.hashes.items() if value is None]
+
+
+def collect_resources(pipeline: Any) -> CollectedResources:
+ """`Pipeline` 인스턴스의 스텝마다 `resource_refs()` 를 모아 파일 sha256 을 뜬다.
+
+ 같은 라벨(같은 사전을 두 스텝이 쓰는 prod 파이프라인)은 한 번만 적는다. `hf:` 자원은 네트워크를
+ 타지 않고 null 이다. 선언했는데 로컬에 없는 자원은 스텝의 `resource_refs()` 가
+ `KnowledgeAssetError` 로 죽인다 — run 시작 전에.
+ """
+ refs: dict[str, ResourceRef] = {}
+ for step in pipeline.steps:
+ for ref in step.resource_refs():
+ refs.setdefault(ref.label, ref)
+ hashes: dict[str, str | None] = {}
+ for label in sorted(refs):
+ path = refs[label].path
+ hashes[label] = content_sha256(path) if path is not None else None
+ return CollectedResources(hashes, {label: refs[label] for label in sorted(refs)})
+
+
+def prompt_assets(prompt_path: Path) -> dict[str, str]:
+ """프롬프트의 `{상대경로: sha256_64}` — 디렉토리면 legacy `prompt_sha` 와 같은 파일 집합.
+
+ 단일 파일(YAML 프롬프트)은 `{"@file": sha256_64}`.
+
+ Raises:
+ FileNotFoundError: 경로가 없을 때.
+ ValueError: 디렉토리에 지문 대상 파일이 하나도 없을 때(빈 프롬프트로 돈 run 을 막는다).
+ """
+ path = Path(prompt_path)
+ if path.is_dir():
+ assets = {
+ file.relative_to(path).as_posix(): content_sha256(file)
+ for file in iter_prompt_asset_files(path)
+ }
+ if not assets:
+ raise ValueError(f"prompt directory has no prompt asset files: {path}")
+ return assets
+ if path.is_file():
+ return {SINGLE_FILE_KEY: content_sha256(path)}
+ raise FileNotFoundError(f"prompt not found: {path}")
diff --git a/solar_eval/core/task_discovery.py b/solar_eval/core/task_discovery.py
index ece075da96560c078e21af52e9e744e431c33889..57cfbb7101bbe7ca94793ede9fa620ae06d6fe13 100644
--- a/solar_eval/core/task_discovery.py
+++ b/solar_eval/core/task_discovery.py
@@ -15,7 +15,7 @@
**명시 태스크가 이긴다.** 이미 `tasks:` 에 있는 dataset_path 는 발견 대상에서
빠지므로, 규칙에 안 맞는 이름(`paragraph`, `yul-ryul`)은 예전 그대로 적어 두면
-된다. 과거 run 이 그 이름으로 기록돼 있어 이름을 바꾸면 evalhub 에서 계보가
+된다. 과거 run 이 그 이름으로 기록돼 있어 이름을 바꾸면 Experiment Hub 에서 계보가
끊어지기 때문에, 이 우선순위는 타협 대상이 아니다.
"""
diff --git a/solar_eval/evaluators/base.py b/solar_eval/evaluators/base.py
index f467747f735556434cf3195cdee7066d10862c72..9c45b267f44b79ef0b550363ce2c6bbe01f80402 100644
--- a/solar_eval/evaluators/base.py
+++ b/solar_eval/evaluators/base.py
@@ -19,15 +19,33 @@ class BaseEvaluator(ABC):
required_fields: frozenset[str] = frozenset()
+ @property
+ def judge_model(self) -> str | None:
+ """채점에 쓰는 judge 모델 — 모델을 부르지 않는 평가기는 None.
+
+ evaluation.json 의 `eval_model` 과 run.json 스냅샷의 `evaluator_config.judge_model`
+ 이 이 값을 그대로 쓴다(계획 2026-09-24-phase1a D11). 예전에는 러너가 평가기와
+ 무관하게 `"gpt-4o"` 를 적어 lcs_diff run 에도 judge 가 있는 것처럼 보였다.
+ """
+ return None
+
+ def sub_evaluators(self) -> list["BaseEvaluator"]:
+ """합성 평가기의 하위 평가기 — 스냅샷의 구현 해시가 하위 모듈까지 보도록. 기본 []."""
+ return []
+
@abstractmethod
async def evaluate(
self,
sample: EvalSample,
provider: BaseProvider | None = None,
- judge_model: str = "gpt-4o",
+ judge_model: str | None = None,
) -> dict[str, Any]:
"""`sample` 한 건을 채점한다. `{score, details}` 를 반환한다.
+ `judge_model` 은 호출자가 judge 모델을 **덮어쓸** 때만 준다. None 이면 평가기가
+ 자기 설정값(`self.judge_model`)을 쓴다 — 러너·`runs eval` 은 넘기지 않으므로 보고값과
+ 실제 호출 모델이 어긋나지 않는다.
+
채점에 실패하면(judge 호출 예외, 잘못된 응답 등) 그 실패는 **예외로 전파한다** --
점수(0.0 이든 1.0 이든)로 치환해 반환하지 않는다. fail-open(장애를 만점으로
둔갑시키는 것)을 만들지 않는 것이 이 계약의 핵심이다. 이 예외를 잡아 실패
diff --git a/solar_eval/evaluators/composite.py b/solar_eval/evaluators/composite.py
index 33c5f6c63f1646e31df4800ce0a51d81b9e69511..a5d507b1a2957d6a2985bfddb52592d6c02ddbd0 100644
--- a/solar_eval/evaluators/composite.py
+++ b/solar_eval/evaluators/composite.py
@@ -28,12 +28,27 @@ class CompositeEvaluator(BaseEvaluator):
fields |= comp["evaluator"].required_fields
return fields
+ @property
+ def judge_model(self) -> str | None:
+ """하위 평가기의 non-null judge 모델: 0개 → None, 1개 → 그 값, 여럿 → 정렬해 `+` 로 잇는다."""
+ models = sorted(
+ {m for comp in self.components if (m := comp["evaluator"].judge_model) is not None}
+ )
+ if not models:
+ return None
+ return "+".join(models)
+
+ def sub_evaluators(self) -> list[BaseEvaluator]:
+ return [comp["evaluator"] for comp in self.components]
+
async def evaluate(
self,
sample: EvalSample,
provider: BaseProvider | None = None,
- judge_model: str = "gpt-4o",
+ judge_model: str | None = None,
) -> dict[str, Any]:
+ # judge_model 은 그대로(None 이면 None) 넘긴다 — 하위는 None 이면 자기 설정값을 쓴다.
+ # 예전에는 자기 기본값 "gpt-4o" 를 명시 전달해 하위 설정을 덮어썼다(검토 F9).
component_results = []
weighted_score = 0.0
diff --git a/solar_eval/evaluators/judge_config.py b/solar_eval/evaluators/judge_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..66e5caa7839a9a156b8904c9381b41453ab032d3
--- /dev/null
+++ b/solar_eval/evaluators/judge_config.py
@@ -0,0 +1,243 @@
+"""judge 설정 공용 규칙 — provider 이름·평가기별 기본 provider·판정 선언·endpoint (계획 2026-09-28 L12 §2.8 · D-L12-10·11).
+
+평가기(`registry`)·채점 번들 발행(`scoring_publish`)이 함께 읽는 계약이다. 규칙은 이 모듈 한 곳이다 —
+다른 모듈은 사본을 두지 않는다. 인정된 사본은 둘뿐이고 시험이 대조한다:
+
+- `DEFAULT_EVALUATOR_TYPE` ↔ `evaluators.registry.DEFAULT_EVALUATOR_CONFIG["type"]`(registry 를 import 하면 평가기 전부를 끌어온다).
+- `DEFAULT_LLM_JUDGE_MODEL` ↔ `evaluators.llm_judge.DEFAULT_JUDGE_MODEL`.
+- `PROVIDER_MODULES` ↔ `evaluators.rubric_judge._PROVIDER_MODULES`(rubric_judge 는 이 모듈을 import 하지 않는다 —
+ 설정 모듈 편집이 모든 rubric judge 구현 해시를 바꾸지 않게, 검토 F9).
+
+설정 오류는 provider 인스턴스를 만들기 전에 검사한다. endpoint 는 `ProviderConfig().upstage_api_base`(대소문자 무시 env)이고,
+콘솔·번들에는 `normalize_endpoint` 를 지난 값만 싣는다(userinfo·쿼리·조각이 로그·미러에 남지 않게, 검토 F18).
+
+import 는 표준 라이브러리 + `solar_eval.config` 뿐이다 — runner 경로에서 불려도 `poc_bundle`·`packaging` 을 끌어오지 않는다(D18).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import Any
+from urllib.parse import urlsplit, urlunsplit
+
+from solar_eval.config import ProviderConfig
+
+# ── 평가기 type ─────────────────────────────────────────────────────────
+
+LLM_JUDGE_TYPE = "llm_judge"
+RUBRIC_JUDGE_TYPE = "rubric_judge"
+COMPOSITE_TYPE = "composite"
+#: judge 를 부르지 않는 평가기.
+NO_JUDGE_TYPES = ("lcs_diff", "rule_based")
+#: 태스크에 `evaluator` 키가 없을 때의 평가기 — `registry.DEFAULT_EVALUATOR_CONFIG` 와 같다(시험 대조).
+DEFAULT_EVALUATOR_TYPE = LLM_JUDGE_TYPE
+
+EVALUATOR_KEY = "evaluator"
+TYPE_KEY = "type"
+COMPONENTS_KEY = "components"
+JUDGE_PROVIDER_KEY = "judge_provider"
+JUDGE_MODEL_KEY = "judge_model"
+
+# ── provider ────────────────────────────────────────────────────────────
+
+PROVIDER_UPSTAGE = "upstage"
+PROVIDER_OPENAI = "openai"
+#: `judge_provider` 의 닫힌 집합 — 그 밖 값은 `judge_provider_unknown`.
+JUDGE_PROVIDERS = (PROVIDER_UPSTAGE, PROVIDER_OPENAI)
+#: 평가기별 기본 provider — llm_judge 는 오늘 동작(OpenAI), rubric_judge 는 Upstage(D-L12-10).
+DEFAULT_JUDGE_PROVIDER: Mapping[str, str] = MappingProxyType(
+ {LLM_JUDGE_TYPE: PROVIDER_OPENAI, RUBRIC_JUDGE_TYPE: PROVIDER_UPSTAGE}
+)
+#: `llm_judge` 의 judge 모델 기본값 — `llm_judge.DEFAULT_JUDGE_MODEL` 과 같다(시험 대조).
+DEFAULT_LLM_JUDGE_MODEL = "gpt-4o"
+#: OpenAI judge 의 endpoint — `providers/openai_provider.py` 가 부르는 URL 의 base.
+OPENAI_ENDPOINT = "https://api.openai.com/v1"
+#: provider 이름 → 실제 provider 클래스의 모듈 — `rubric_judge._PROVIDER_MODULES` 와 같다(시험 대조, D-L12-18).
+PROVIDER_MODULES: Mapping[str, str] = MappingProxyType(
+ {
+ PROVIDER_UPSTAGE: "solar_eval.providers.upstage",
+ PROVIDER_OPENAI: "solar_eval.providers.openai_provider",
+ }
+)
+
+# ── 오류 사유 코드(§2.15 — CLI 종료 1 첫 줄 머리) ──────────────────────────
+
+JUDGE_PROVIDER_UNKNOWN = "judge_provider_unknown"
+JUDGE_PROVIDER_CONFLICT = "judge_provider_conflict"
+EVALUATOR_CONFIG_INVALID = "evaluator_config_invalid"
+JUDGE_ENDPOINT_INVALID = "judge_endpoint_invalid"
+
+#: `task_config` 에 `evaluator` 키가 없음 — `evaluator: null` 과 구별한다(검토 F7).
+_ABSENT = object()
+
+
+class JudgeConfigError(ValueError):
+ """judge 설정 오류 — `reason` 은 위 코드 중 하나. 메시지는 코드와 이름·타입만 싣는다(설정 값·URL 원문 없음)."""
+
+ def __init__(self, reason: str, detail: str = "") -> None:
+ super().__init__(f"{reason}: {detail}" if detail else reason)
+ self.reason = reason
+
+
+@dataclass(frozen=True)
+class JudgeDecl:
+ """평가기 설정이 선언한 judge 하나 — (평가기 type, provider 이름, 모델). 모델을 모르면 None."""
+
+ evaluator_type: str
+ provider: str
+ model: str | None
+
+
+def evaluator_of(task_config: Mapping[str, Any]) -> Mapping[str, Any] | None:
+ """태스크의 평가기 설정. 키가 없으면 None(= 기본 llm_judge), `null`·`{}`·매핑 아님은 `evaluator_config_invalid`.
+
+ registry 와 같은 규칙이다 — 키가 **없을 때만** 기본값이고, 비었거나 null 인 선언을 기본값으로 바꾸지 않는다.
+ """
+ if not isinstance(task_config, Mapping):
+ raise JudgeConfigError(EVALUATOR_CONFIG_INVALID, "task config is not a mapping")
+ raw = task_config.get(EVALUATOR_KEY, _ABSENT)
+ if raw is _ABSENT:
+ return None
+ if not isinstance(raw, Mapping) or not raw:
+ raise JudgeConfigError(
+ EVALUATOR_CONFIG_INVALID, f"evaluator must be a non-empty mapping, got {_kind(raw)}"
+ )
+ return raw
+
+
+def judge_decls(evaluator_config: Mapping[str, Any] | None) -> tuple[JudgeDecl, ...]:
+ """평가기 설정이 부르는 judge 전부(합성은 하위를 순서대로). None 은 기본 평가기(`DEFAULT_EVALUATOR_TYPE`).
+
+ Raises:
+ JudgeConfigError: 매핑 아님·type 없음·모르는 type(`evaluator_config_invalid`), provider 가 닫힌 집합 밖
+ (`judge_provider_unknown`).
+ """
+ config = {TYPE_KEY: DEFAULT_EVALUATOR_TYPE} if evaluator_config is None else evaluator_config
+ return _decls(config)
+
+
+def _decls(config: Any) -> tuple[JudgeDecl, ...]:
+ if not isinstance(config, Mapping):
+ raise JudgeConfigError(EVALUATOR_CONFIG_INVALID, f"evaluator is {_kind(config)}")
+ kind = config.get(TYPE_KEY)
+ if not isinstance(kind, str) or not kind:
+ raise JudgeConfigError(EVALUATOR_CONFIG_INVALID, "evaluator needs a non-empty 'type'")
+ if kind in NO_JUDGE_TYPES:
+ return ()
+ if kind == LLM_JUDGE_TYPE:
+ model = _model(config.get(JUDGE_MODEL_KEY, DEFAULT_LLM_JUDGE_MODEL))
+ return (JudgeDecl(kind, _provider(config, kind), model),)
+ if kind == RUBRIC_JUDGE_TYPE:
+ # judge_model 은 필수지만(기본값 없음) 없다는 판정은 평가기 생성자가 한다 — 여기서는 모델을 None 으로 둔다.
+ return (JudgeDecl(kind, _provider(config, kind), _model(config.get(JUDGE_MODEL_KEY))),)
+ if kind == COMPOSITE_TYPE:
+ components = config.get(COMPONENTS_KEY, [])
+ if not isinstance(components, list):
+ raise JudgeConfigError(EVALUATOR_CONFIG_INVALID, "composite components must be a list")
+ return tuple(decl for component in components for decl in _decls(component))
+ raise JudgeConfigError(EVALUATOR_CONFIG_INVALID, f"unknown evaluator type {kind!r}")
+
+
+def _provider(config: Mapping[str, Any], kind: str) -> str:
+ value = config.get(JUDGE_PROVIDER_KEY, DEFAULT_JUDGE_PROVIDER[kind])
+ if value not in JUDGE_PROVIDERS:
+ allowed = " · ".join(JUDGE_PROVIDERS)
+ shown = repr(value) if isinstance(value, str) else _kind(value)
+ raise JudgeConfigError(JUDGE_PROVIDER_UNKNOWN, f"{kind} {shown} (allowed: {allowed})")
+ return value
+
+
+def _model(value: Any) -> str | None:
+ return value if isinstance(value, str) and value else None
+
+
+def _kind(value: Any) -> str:
+ return "null" if value is None else type(value).__name__
+
+
+def validate_judge_config(task_config: Mapping[str, Any]) -> None:
+ """Judge 설정 오류를 provider 생성 전에 판정한다. 전송 대상에 따른 허용/거부 정책은 없다."""
+ evaluator = evaluator_of(task_config)
+ judge_provider_name(evaluator)
+ require_upstage_judge_models(evaluator)
+ for decl in judge_decls(evaluator):
+ judge_endpoint(decl.provider)
+
+
+def require_upstage_judge_models(evaluator_config: Mapping[str, Any] | None) -> None:
+ """`llm_judge` 가 `judge_provider: upstage` 인데 `judge_model` 선언이 없으면 `evaluator_config_invalid`(합성은 하위까지).
+
+ `llm_judge` 의 모델 기본값(`DEFAULT_LLM_JUDGE_MODEL`)은 OpenAI 모델이라 Upstage 로 보내면 추론을 다 돌린 뒤 판정 호출이
+ 실패한다 — 그 조합은 모델을 적게 한다. 모델을 **명시**했으면 그 값은 보지 않는다(사람이 고른 값이다).
+ """
+ config = {TYPE_KEY: DEFAULT_EVALUATOR_TYPE} if evaluator_config is None else evaluator_config
+ for kind, component in _judge_components(config):
+ if kind != LLM_JUDGE_TYPE or component.get(JUDGE_PROVIDER_KEY) != PROVIDER_UPSTAGE:
+ continue
+ if _model(component.get(JUDGE_MODEL_KEY)) is None:
+ raise JudgeConfigError(
+ EVALUATOR_CONFIG_INVALID,
+ f"{LLM_JUDGE_TYPE} with {JUDGE_PROVIDER_KEY}: {PROVIDER_UPSTAGE} needs a "
+ f"{JUDGE_MODEL_KEY} (the default {DEFAULT_LLM_JUDGE_MODEL} is an OpenAI model)",
+ )
+
+
+def _judge_components(config: Any) -> tuple[tuple[str, Mapping[str, Any]], ...]:
+ """(type, 설정) — 합성은 하위를 순서대로 펼친다. 모양 검사는 `_decls` 몫이라 매핑이 아닌 값은 건너뛴다."""
+ if not isinstance(config, Mapping):
+ return ()
+ kind = config.get(TYPE_KEY)
+ if kind == COMPOSITE_TYPE:
+ components = config.get(COMPONENTS_KEY, [])
+ items = components if isinstance(components, list) else []
+ return tuple(pair for component in items for pair in _judge_components(component))
+ return ((kind, config),) if isinstance(kind, str) else ()
+
+
+def judge_provider_name(evaluator_config: Mapping[str, Any] | None) -> str | None:
+ """평가기 설정이 쓰는 judge provider 이름 하나. judge 가 없으면 None, 하위 judge 들이 서로 다르면 `judge_provider_conflict`."""
+ names = sorted({decl.provider for decl in judge_decls(evaluator_config)})
+ if not names:
+ return None
+ if len(names) > 1:
+ raise JudgeConfigError(JUDGE_PROVIDER_CONFLICT, " vs ".join(names))
+ return names[0]
+
+
+def normalize_endpoint(url: str) -> str:
+ """`scheme://host[:port]/path` — userinfo·쿼리·조각·경로 세그먼트의 `;` 매개변수·끝 `/` 를 떼고 scheme·host 를 소문자로.
+ scheme·host 가 없으면 `judge_endpoint_invalid`.
+
+ 포트는 적힌 그대로 남긴다(기본 포트도 지우지 않는다). 메시지에는 URL 원문을 싣지 않는다(userinfo 토큰이 로그에 남지 않게).
+ `urlsplit` 은 `;tok=…` 를 경로에 남기므로 세그먼트마다 `;` 뒤를 지운다(프록시 토큰 누출 방지, F18). 경로 세그먼트 자체
+ (`/<토큰>/v1` 모양)는 구분할 수 없어 그대로 남는다 — RUBRIC_JUDGE.md §4.
+ """
+ if not isinstance(url, str):
+ raise JudgeConfigError(JUDGE_ENDPOINT_INVALID, f"endpoint is {_kind(url)}")
+ try:
+ parts = urlsplit(url.strip())
+ host = parts.hostname
+ port = parts.port
+ except ValueError:
+ raise JudgeConfigError(JUDGE_ENDPOINT_INVALID, "endpoint is not a URL") from None
+ scheme = parts.scheme.lower()
+ if not scheme or not host:
+ raise JudgeConfigError(JUDGE_ENDPOINT_INVALID, "endpoint needs a scheme and a host")
+ shown_host = f"[{host}]" if ":" in host else host
+ netloc = shown_host if port is None else f"{shown_host}:{port}"
+ path = "/".join(segment.split(";", 1)[0] for segment in parts.path.split("/"))
+ return urlunsplit((scheme, netloc, path.rstrip("/"), "", ""))
+
+
+def judge_endpoint(provider: str, *, upstage_api_base: str | None = None) -> str:
+ """judge provider 가 실제로 부를 endpoint(정규화 값). upstage 는 주입값, 없으면 `ProviderConfig().upstage_api_base`."""
+ if provider == PROVIDER_UPSTAGE:
+ base = ProviderConfig().upstage_api_base if upstage_api_base is None else upstage_api_base
+ elif provider == PROVIDER_OPENAI:
+ base = OPENAI_ENDPOINT
+ else:
+ shown = repr(provider) if isinstance(provider, str) else _kind(provider)
+ raise JudgeConfigError(JUDGE_PROVIDER_UNKNOWN, shown)
+ return normalize_endpoint(base)
diff --git a/solar_eval/evaluators/lcs_diff.py b/solar_eval/evaluators/lcs_diff.py
index f2bf6641db2d13d3f8aebc6bb837b0391be27a18..d7df7b25b8350d7a078ee4c64f93d8ae8b4c03d4 100644
--- a/solar_eval/evaluators/lcs_diff.py
+++ b/solar_eval/evaluators/lcs_diff.py
@@ -24,7 +24,7 @@ class LCSDiffEvaluator(BaseEvaluator):
self,
sample: EvalSample,
provider: BaseProvider | None = None,
- judge_model: str = "gpt-4o",
+ judge_model: str | None = None,
) -> dict[str, Any]:
original = sample.input.get("original", "")
result = compute_proofread_metrics(original, str(sample.reference), sample.output)
diff --git a/solar_eval/evaluators/llm_judge.py b/solar_eval/evaluators/llm_judge.py
index e9f8d67bf9cfacf80a7806113c8f02f712503b0a..bd97329ca2e64b016c2cfea075e2c2390c6b9e93 100644
--- a/solar_eval/evaluators/llm_judge.py
+++ b/solar_eval/evaluators/llm_judge.py
@@ -10,6 +10,38 @@ from solar_eval.evaluators.scoring import (
from solar_eval.models.sample import EvalSample
from solar_eval.providers.base import BaseProvider
+#: 설정(`evaluator.judge_model`)이 없을 때의 judge 모델 — 이 PR 전의 하드코딩 값 그대로(동작 불변).
+DEFAULT_JUDGE_MODEL = "gpt-4o"
+
+#: 카테고리 키가 없을 때 읽는 폴백 키 — 기본 프롬프트(`_default_prompt`)가 이 키를 요구한다.
+FALLBACK_COUNT_KEY = "error_count"
+
+#: 채점 실패 어휘(보고서 §5.4 측정 쪽 — `grader_failed:<사유>:`, 계획 Q-L6-11).
+JUDGE_KEY_MISSING = "grader_failed:judge_key_missing"
+JUDGE_COUNT_INVALID = "grader_failed:judge_count_invalid"
+
+
+class JudgeResponseError(ValueError):
+ """judge 응답에서 개수를 읽을 수 없다 — 만점·0점으로 치환하지 않고 채점 실패로 남긴다.
+
+ 메시지는 고정 어휘 + 카테고리 이름뿐이다. judge 응답 원문(고객 텍스트가 섞일 수 있다)은
+ 싣지 않는다 — 이 메시지가 `eval_details` 의 `error` 와 러너 로그로 그대로 간다.
+ """
+
+
+def _is_count(value: Any) -> bool:
+ """0 이상의 정수 값인가. `2.0` 처럼 `is_integer()` 인 float 도 받는다(오늘 `good` 과 같다).
+
+ bool 은 int 의 하위형이지만 개수가 아니다 — 오늘 `True` 가 `excellent` 로 채점되던 구멍을 막는다.
+ """
+ if isinstance(value, bool):
+ return False
+ if isinstance(value, int):
+ return value >= 0
+ if isinstance(value, float):
+ return value.is_integer() and value >= 0
+ return False
+
class LLMJudgeEvaluator(BaseEvaluator):
"""LLM-as-Judge evaluator with weighted categories.
@@ -24,19 +56,29 @@ class LLMJudgeEvaluator(BaseEvaluator):
self,
categories: list[dict[str, Any]],
judge_prompts: dict[str, str] | None = None,
+ judge_model: str = DEFAULT_JUDGE_MODEL,
) -> None:
+ if not isinstance(judge_model, str) or not judge_model:
+ raise ValueError(f"judge_model must be a non-empty string, got {judge_model!r}")
self.categories = categories # [{name, weight}]
self.weights = {c["name"]: c["weight"] for c in categories}
self.judge_prompts = judge_prompts or {}
+ self._judge_model = judge_model
+
+ @property
+ def judge_model(self) -> str:
+ return self._judge_model
async def evaluate(
self,
sample: EvalSample,
provider: BaseProvider | None = None,
- judge_model: str = "gpt-4o",
+ judge_model: str | None = None,
) -> dict[str, Any]:
if provider is None:
raise ValueError("LLMJudgeEvaluator requires a provider")
+ # 호출자가 덮어쓰지 않으면 자기 설정값 — 보고값(`self.judge_model`)과 호출 모델이 같다.
+ judge_model = judge_model or self._judge_model
scores = {}
details = {}
@@ -62,8 +104,8 @@ class LLMJudgeEvaluator(BaseEvaluator):
],
model=judge_model,
)
- count_key = self._get_count_key(cat_name)
- error_count = result.get(count_key, result.get("error_count", 0))
+ # 개수 키가 없거나 값이 개수가 아니면 JudgeResponseError — 예전의 기본값 0(만점)은 없다.
+ error_count = self._read_count(result, cat_name)
severity = error_count_to_severity(error_count)
score = severity_to_score(severity)
@@ -108,6 +150,24 @@ class LLMJudgeEvaluator(BaseEvaluator):
"num_samples": len(results),
}
+ def _read_count(self, result: Any, cat_name: str) -> int | float:
+ """judge 응답에서 카테고리의 개수를 읽는다(계획 §2.12 B2).
+
+ 카테고리 키(`_get_count_key`) 값, 없거나 `null` 이면 `error_count` 값. 둘 다 없거나 `null`
+ 이면 `judge_key_missing`, 값이 0 이상 정수 값이 아니면 `judge_count_invalid`. 받은 값은
+ 그대로 돌려준다(`2.0` 은 `2.0` — `eval_details` 의 `error_counts` 바이트가 오늘과 같다).
+ """
+ # JSON 파싱 결과가 객체가 아니면(배열·null 등) 읽을 키 자체가 없다.
+ fields = result if isinstance(result, dict) else {}
+ value = fields.get(self._get_count_key(cat_name))
+ if value is None:
+ value = fields.get(FALLBACK_COUNT_KEY)
+ if value is None:
+ raise JudgeResponseError(f"{JUDGE_KEY_MISSING}:{cat_name}")
+ if not _is_count(value):
+ raise JudgeResponseError(f"{JUDGE_COUNT_INVALID}:{cat_name}")
+ return value
+
def _get_count_key(self, category: str) -> str:
key_map = {
"factual_accuracy": "error_count",
diff --git a/solar_eval/evaluators/registry.py b/solar_eval/evaluators/registry.py
index 465338a40cc81054de3c460bdd90c3ebaa8eaf34..8724b41bd7c13196bed04340b31c2ba0b3023406 100644
--- a/solar_eval/evaluators/registry.py
+++ b/solar_eval/evaluators/registry.py
@@ -1,30 +1,90 @@
+from collections.abc import Mapping
from typing import Any
from solar_eval.evaluators.base import BaseEvaluator
from solar_eval.evaluators.composite import CompositeEvaluator
+from solar_eval.evaluators.judge_config import (
+ DEFAULT_JUDGE_PROVIDER,
+ JUDGE_MODEL_KEY,
+ JUDGE_PROVIDER_KEY,
+ RUBRIC_JUDGE_TYPE,
+)
from solar_eval.evaluators.lcs_diff import LCSDiffEvaluator
-from solar_eval.evaluators.llm_judge import LLMJudgeEvaluator
+from solar_eval.evaluators.llm_judge import DEFAULT_JUDGE_MODEL, LLMJudgeEvaluator
+from solar_eval.evaluators.rubric_judge import CONFIG_KEYS as RUBRIC_JUDGE_KEYS
+from solar_eval.evaluators.rubric_judge import (
+ DEFAULT_MAX_TOKENS,
+ RUBRIC_JUDGE_IN_COMPOSITE,
+ UNKNOWN_KEYS_PREFIX,
+ RubricJudgeEvaluator,
+)
from solar_eval.evaluators.rule_based import RuleBasedEvaluator
+#: 태스크에 `evaluator` 키가 **없을 때** 쓰는 기본 평가기 — 러너·`runs eval`·스냅샷이 공유할 정본.
+#: 키가 있는데 비었거나(`{}`) null 이면 기본값으로 바꾸지 않는다(`create_evaluator` 가 거부한다).
+DEFAULT_EVALUATOR_CONFIG: dict[str, Any] = {"type": "llm_judge"}
+#: 받는 평가기 type — 오류 문구에 싣는다.
+EVALUATOR_TYPES = ("llm_judge", "lcs_diff", "rule_based", "composite", RUBRIC_JUDGE_TYPE)
-def create_evaluator(config: dict[str, Any]) -> BaseEvaluator:
- """Factory function to create evaluator from config dict."""
- eval_type = config["type"]
+
+def create_evaluator(config: Mapping[str, Any]) -> BaseEvaluator:
+ """Factory function to create evaluator from config dict.
+
+ Raises:
+ ValueError: 설정이 객체가 아니거나 `type` 이 없거나 모르는 값일 때 — 추론을 다 돌린 뒤
+ 평가 단계에서 맨 KeyError/TypeError 로 죽지 않게 경계에서 이유를 적어 거부한다.
+ """
+ if not isinstance(config, Mapping):
+ raise ValueError(
+ f"evaluator config must be a mapping with a 'type' key, got {type(config).__name__}"
+ )
+ eval_type = config.get("type")
+ if not isinstance(eval_type, str) or not eval_type:
+ raise ValueError(
+ f"evaluator config needs a non-empty 'type' "
+ f"({' · '.join(EVALUATOR_TYPES)}), got {dict(config)!r}"
+ )
if eval_type == "llm_judge":
return LLMJudgeEvaluator(
categories=config.get("categories", []),
judge_prompts=config.get("judge_prompts"),
+ judge_model=config.get("judge_model", DEFAULT_JUDGE_MODEL),
)
elif eval_type == "lcs_diff":
return LCSDiffEvaluator(metrics=config.get("metrics"))
elif eval_type == "rule_based":
return RuleBasedEvaluator(rules=config.get("rules", []))
+ elif eval_type == RUBRIC_JUDGE_TYPE:
+ return _rubric_judge(config)
elif eval_type == "composite":
components = []
for comp in config.get("components", []):
+ # 합성은 판정 점수를 가중 평균할 뿐이라 기준별 행을 담을 자리가 없다(계획 2026-09-28 L12 D-L12-1).
+ if isinstance(comp, Mapping) and comp.get("type") == RUBRIC_JUDGE_TYPE:
+ raise ValueError(RUBRIC_JUDGE_IN_COMPOSITE)
sub_evaluator = create_evaluator(comp)
components.append({"evaluator": sub_evaluator, "weight": comp.get("weight", 0.5)})
return CompositeEvaluator(components=components)
else:
- raise ValueError(f"Unknown evaluator type: {eval_type}")
+ raise ValueError(
+ f"Unknown evaluator type: {eval_type} (allowed: {' · '.join(EVALUATOR_TYPES)})"
+ )
+
+
+def _rubric_judge(config: Mapping[str, Any]) -> RubricJudgeEvaluator:
+ """`rubric_judge` — 닫힌 키(오타가 기본값으로 묻히지 않게), 기본 provider 는 여기서 채운다.
+
+ `judge_config` 는 이 레지스트리만 import 한다 — `rubric_judge` 모듈이 import 하면 `impl_hash` 폐포에 들어가
+ 설정 모듈 편집이 모든 rubric judge 평가기 해시를 바꾼다(계획 2026-09-28 L12 D-L12-10 · 검토 F9).
+ """
+ unknown = sorted(str(key) for key in config if key not in RUBRIC_JUDGE_KEYS)
+ if unknown:
+ allowed = sorted(RUBRIC_JUDGE_KEYS)
+ raise ValueError(f"{UNKNOWN_KEYS_PREFIX} {unknown} (allowed: {allowed})")
+ return RubricJudgeEvaluator(
+ rubric=config.get("rubric"),
+ judge_model=config.get(JUDGE_MODEL_KEY),
+ judge_provider=config.get(JUDGE_PROVIDER_KEY, DEFAULT_JUDGE_PROVIDER[RUBRIC_JUDGE_TYPE]),
+ max_tokens=config.get("max_tokens", DEFAULT_MAX_TOKENS),
+ )
diff --git a/solar_eval/evaluators/rubric_judge.py b/solar_eval/evaluators/rubric_judge.py
new file mode 100644
index 0000000000000000000000000000000000000000..311a744658fc2cc5b9709976aa07d846d0735c27
--- /dev/null
+++ b/solar_eval/evaluators/rubric_judge.py
@@ -0,0 +1,763 @@
+"""`rubric_judge` 평가기 — `rubric/v1` 기준마다 judge 에 한 번씩 묻는다 (계획 2026-09-28 L12 §2.1~§2.3 · D-L12-1~4·18).
+
+- **생성자(§2.1)** 가 rubric 구조를 표준 라이브러리만으로 본다 — 닫힌 키·라벨·기준 id·점수 부호(`rubric_points_sign`)·집계
+ 조합(`rubric_all_pass_with_negative`). 실패는 `ValueError(<고정 코드>: …)` 로 추론 전에 멈춘다. 계약 전부(RB0~RB4)는 발행
+ 직전 PB `parse_rubric_doc` 가 다시 본다(이 모듈은 runner 경로라 `poc_bundle` 을 import 하지 않는다).
+- **프롬프트(§2.2)** `rubric-pointwise@1` — 기준 하나 × 샘플 하나 = 메시지 둘. 치환은 한 번에(자리 이름 정규식) 해서 입력·출력에
+ 든 `{…}` 글자가 다른 자리를 건드리지 않는다.
+- **파서(§2.3)** `rubric-verdict-json@1` — 엄격 JSON(중복 키·NaN 거부), 통째로 코드 블록 하나만 벗긴다(중괄호 사냥 없음).
+- **판정(`judge`)** 기준 순서대로 순차 호출(temperature 0, `chat_completion` 원문). 파싱·호출 실패는 그 기준만 error 이고
+ 나머지 기준은 부른다. 출력이 없으면 부르지 않고 skipped. **레거시 어댑터(`evaluate`)** 는 보수적이다 — 한 기준이라도
+ error·라벨 미정이면 `RubricVerdictIncomplete(<코드>)`(부분 점수를 내면 fail-open 이다, D-L12-4).
+
+**폐포 규칙(검토 F9).** 이 모듈은 `solar_eval.evaluators.judge_config` 를 import 하지 않는다 — `impl_hash` 폐포가 함수 안 import 까지
+따라가므로, 설정 모듈 편집이 모든 rubric judge 구현 해시를 바꾸지 않게 한다. 기본 provider 는 레지스트리가 채워 넘긴다.
+그래서 provider 모듈 표(`_PROVIDER_MODULES`)는 `judge_config.PROVIDER_MODULES` 의 **인정된 사본**이다(시험이 대조).
+
+**누출 금지.** 로그·예외 메시지·`eval_details` 에는 코드·기준 id·`sample_idx` 만 싣는다 — judge 원문·근거·고객 문장은
+`CriterionVerdict.raw`·`rationale` 로만 흐르고, 채점 번들에서는 `payloads.jsonl` 에만 간다(보고서 R6, D-L12-8).
+
+runner 경로 모듈이다 — `poc_bundle`·`packaging` 을 import 하지 않는다(마스터 §4 규칙 11). 표준 라이브러리와 `solar_eval` 만.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import math
+import re
+from collections.abc import Collection, Mapping, Sequence
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import Any, Literal
+
+from solar_eval.evaluators.base import BaseEvaluator
+from solar_eval.models.sample import EvalSample
+from solar_eval.providers.base import BaseProvider
+
+_LOG = logging.getLogger(__name__)
+# ── 정체성 ──────────────────────────────────────────────────────────────
+
+RUBRIC_JUDGE_TYPE = "rubric_judge"
+#: 프롬프트 틀 id(§2.2) — 번들 `params.prompt.template`.
+TEMPLATE_ID = "rubric-pointwise@1"
+#: 응답 파서 id(§2.3) — 번들 `params.parser`.
+PARSER_ID = "rubric-verdict-json@1"
+#: 판정 행 `actor`(§2.4).
+ACTOR = "solar_eval.rubric_judge@1"
+#: 호출 방식 — 기준 하나 × 샘플 하나마다 호출 하나(D-L12-3).
+MODE = "pointwise_per_criterion"
+#: 파싱·호출 실패는 기준별 error 행으로 남긴다(D-L12-4) — 번들 `params.on_parse_failure`.
+ON_PARSE_FAILURE = "record_error"
+#: 라벨 → `passed`(D-L12-2 고정). 그 밖 라벨(예 `unknown`)은 `passed` 를 싣지 않는다.
+LABEL_PASSED: Mapping[str, bool] = MappingProxyType({"met": True, "not_met": False})
+REQUIRED_LABELS = ("met", "not_met")
+RUBRIC_SCHEMA = "rubric/v1"
+RUBRIC_KINDS = ("criteria", "checklist")
+RUBRIC_SCOPE = "task"
+POLARITIES = ("positive", "negative")
+AGGREGATIONS = ("points_clip@1", "all_pass@1")
+#: item 지표 키(D-L12-5).
+ITEM_METRIC_KEY = "rubric_score"
+
+# ── sampling ────────────────────────────────────────────────────────────
+
+TEMPERATURE = 0.0
+DEFAULT_MAX_TOKENS = 512
+MAX_TOKENS_LIMIT = 8000
+#: 닫힌 설정 키(§2.1) — 모르는 키는 `rubric_judge: unknown key(s)`.
+CONFIG_KEYS = frozenset({"type", "judge_provider", "judge_model", "max_tokens", "rubric"})
+
+# ── 판정 사유 코드(§2.15) ────────────────────────────────────────────────
+
+GRADER_FAILED_EMPTY = "grader_failed:empty_response"
+GRADER_FAILED_UNPARSEABLE = "grader_failed:unparseable"
+GRADER_FAILED_SCHEMA = "grader_failed:schema_mismatch"
+GRADER_FAILED_LABEL = "grader_failed:label_invalid"
+GRADER_UNAVAILABLE = "grader_unavailable"
+MISSING_OUTPUT = "missing_output"
+#: item `rubric_score` 행 사유(D-L12-5).
+CRITERION_ERROR = "criterion_error"
+VERDICT_UNKNOWN = "verdict_unknown"
+#: 레거시 어댑터(`evaluate`)가 샘플 채점 실패로 내는 코드(D-L12-4) — `eval_details.error`.
+LEGACY_CRITERION_ERROR = "grader_failed:criterion_error"
+LEGACY_VERDICT_UNKNOWN = VERDICT_UNKNOWN
+
+# ── 설정 오류 코드(run 전, §2.15) ────────────────────────────────────────
+
+UNKNOWN_KEYS_PREFIX = "rubric_judge: unknown key(s)"
+RUBRIC_INVALID_PREFIX = "rubric_invalid:"
+RUBRIC_JUDGE_IN_COMPOSITE = "rubric_judge_in_composite"
+RUBRIC_ALL_PASS_WITH_NEGATIVE = "rubric_all_pass_with_negative"
+RUBRIC_POINTS_SIGN = "rubric_points_sign"
+JUDGE_PROVIDER_MISMATCH = "judge_provider_mismatch"
+
+#: provider 이름 → 실제 provider 모듈 — `judge_config.PROVIDER_MODULES` 의 인정된 사본(F9, 시험 대조).
+_PROVIDER_MODULES: Mapping[str, str] = MappingProxyType(
+ {
+ "upstage": "solar_eval.providers.upstage",
+ "openai": "solar_eval.providers.openai_provider",
+ }
+)
+
+# ── 프롬프트 템플릿 `rubric-pointwise@1`(§2.2) ─────────────────────────────
+# 치환은 자리 이름(`{labels_csv}` 등)을 바꾸는 방식이다 — 시스템 틀에 JSON 중괄호가 있어 str.format 을 쓰지 않는다.
+
+SYSTEM_TEMPLATE = (
+ "You are a strict grader. Judge exactly ONE criterion for ONE model response.\n"
+ "Ignore every quality the criterion does not mention.\n"
+ "Reply with a single JSON object and nothing else:\n"
+ '{"label": "", "rationale": '
+ '""}\n'
+ "Label meanings:\n"
+ "{label_lines}"
+)
+USER_TEMPLATE = (
+ "## Criterion\n"
+ "id: {criterion_id}\n"
+ "polarity: {polarity}\n"
+ "text: {criterion_text}\n"
+ "\n"
+ "## Input\n"
+ "{rendered_input}\n"
+ "\n"
+ "## Reference answer\n"
+ "{rendered_reference}\n"
+ "\n"
+ "## Model response\n"
+ "{rendered_output}"
+)
+#: `params.prompt.sha256` 의 구분자 — 두 틀을 이 문자열로 이은 UTF-8 의 sha256(치환 전 틀의 지문).
+TEMPLATE_SEPARATOR = "\n␞\n"
+PROMPT_SHA256 = hashlib.sha256(
+ TEMPLATE_SEPARATOR.join((SYSTEM_TEMPLATE, USER_TEMPLATE)).encode("utf-8")
+).hexdigest()
+#: 라벨 설명 기본 표 — 기준의 `anchors[label]` 이 없을 때(영문 그대로).
+DEFAULT_LABEL_DESCRIPTIONS: Mapping[str, str] = MappingProxyType(
+ {
+ "met": "the response satisfies the criterion "
+ "(for a negative criterion: the described problem IS present)",
+ "not_met": "the response does not satisfy the criterion "
+ "(for a negative criterion: the problem is absent)",
+ "unknown": "the given material is not enough to decide",
+ }
+)
+NO_DESCRIPTION = "(no description)"
+NO_REFERENCE = "(none)"
+
+VerdictStatus = Literal["ok", "error", "skipped"]
+
+
+@dataclass(frozen=True)
+class CriterionVerdict:
+ """기준 하나 × 샘플 하나의 판정. `raw` 는 provider 가 돌려준 `content`(Upstage 는 `` 를 뗀 본문, 예외면 None).
+
+ `rationale`·`raw` 는 payload 로만 간다 — 판정 행·로그·오류 문자열에 싣지 않는다(D-L12-8).
+ """
+
+ sample_idx: int
+ criterion_id: str
+ status: VerdictStatus
+ label: str | None
+ passed: bool | None
+ reason: str | None
+ rationale: str | None
+ raw: str | None
+ exception: str | None
+ prompt_tokens: int | None
+ completion_tokens: int | None
+
+
+@dataclass(frozen=True)
+class SampleVerdicts:
+ """샘플 하나의 판정들 — rubric 항목 순서(retry 면 대상 기준만)."""
+
+ sample_idx: int
+ verdicts: tuple[CriterionVerdict, ...]
+
+
+class VerdictParseError(ValueError):
+ """응답을 판정으로 읽을 수 없다 — `reason` 은 `grader_failed:*` 코드. 메시지는 코드뿐이다(원문 없음)."""
+
+ def __init__(self, reason: str) -> None:
+ super().__init__(reason)
+ self.reason = reason
+
+
+class RubricVerdictIncomplete(ValueError):
+ """레거시 어댑터: 한 기준이라도 error·라벨 미정이면 샘플 채점 실패(점수 null) — 메시지 = 코드만(D-L12-4)."""
+
+ def __init__(self, code: str) -> None:
+ super().__init__(code)
+ self.code = code
+
+
+class JudgeProviderMismatch(ValueError):
+ """선언 provider 와 다른 **실제** provider 가 주입됐다 — 부르기 전에 멈춘다(설정 오류, D-L12-18)."""
+
+ def __init__(self, declared: str | None = None, actual_module: str | None = None) -> None:
+ # 메시지는 코드 + 이름(provider 이름·모듈 이름)만 — 설정 값·URL 은 싣지 않는다.
+ detail = f": declared {declared}, got {actual_module}" if declared and actual_module else ""
+ super().__init__(f"{JUDGE_PROVIDER_MISMATCH}{detail}")
+ self.declared = declared
+ self.actual_module = actual_module
+
+
+# ── 생성자 검사(§2.1 — 구조만, 표준 라이브러리) ─────────────────────────────
+
+#: rubric 문서·config·기준 항목의 닫힌 키 — PB 모델(`RubricDoc`·`RubricConfig`·`Criterion`)과 같은 집합.
+RUBRIC_KEYS = frozenset({"schema", "config", "provenance"})
+RUBRIC_CONFIG_KEYS = frozenset({"kind", "scope", "labels", "criteria", "aggregations"})
+CRITERION_KEYS = frozenset({"id", "text", "points", "polarity", "required", "anchors", "tags"})
+#: `provenance` 의 닫힌 키 — PB `RubricProvenance` 와 같은 집합.
+PROVENANCE_KEYS = frozenset({"alias", "supersedes"})
+#: item `rubric_score` 의 파생 — `criteria` 는 점수 합, `checklist` 는 전부 통과(D-L12-5).
+ITEM_AGGREGATION = MappingProxyType({"criteria": "points_clip@1", "checklist": "all_pass@1"})
+#: 생성자 설정 오류(§2.15 밖 — 평가기 인자) — 메시지 머리.
+JUDGE_MODEL_REQUIRED = "rubric_judge: judge_model is required (a non-empty string, no default)"
+MAX_TOKENS_INVALID = f"rubric_judge: max_tokens must be an integer in 1..{MAX_TOKENS_LIMIT}"
+#: `judge_config.JUDGE_PROVIDER_UNKNOWN` 의 사본(F9 — import 하지 않는다).
+JUDGE_PROVIDER_UNKNOWN = "judge_provider_unknown"
+JUDGE_PROVIDER_REQUIRED = "rubric_judge: a judge provider is required"
+#: 레거시 어댑터가 판정에 붙이는 샘플 번호 — `evaluate` 는 샘플 위치를 모른다(로그 전용).
+LEGACY_SAMPLE_IDX = -1
+
+_CRITERION_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
+_HEX64_RE = re.compile(r"[0-9a-f]{64}")
+#: JCS 가 정확히 표현하는 정수의 절댓값 상한 — PB `canonical.SAFE_INTEGER_MAX` 의 사본(import 하지 않는다).
+_SAFE_INTEGER_MAX = 2**53 - 1
+_CODE_BLOCK_RE = re.compile(r"\A```(?:json)?\s*(.*?)\s*```\Z", re.DOTALL)
+
+
+def _invalid(part: str, detail: str = "") -> ValueError:
+ """`rubric_invalid:<항목>` — 메시지에는 키·id·타입 이름만 싣는다(기준 문장 없음)."""
+ code = f"{RUBRIC_INVALID_PREFIX}{part}"
+ return ValueError(f"{code}: {detail}" if detail else code)
+
+
+def _is_number(value: Any) -> bool:
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
+
+
+def _closed(value: Any, allowed: frozenset[str], part: str) -> Mapping[str, Any]:
+ if not isinstance(value, Mapping):
+ raise _invalid(part, f"must be a mapping, got {type(value).__name__}")
+ unknown = sorted(str(k) for k in value if k not in allowed)
+ if unknown:
+ raise _invalid(part, f"unknown key(s) {unknown}")
+ return value
+
+
+@dataclass(frozen=True)
+class RubricCriterion:
+ """검사를 통과한 rubric 항목 — 문장은 프롬프트에만 쓴다(로그·오류에 싣지 않는다)."""
+
+ id: str
+ text: str
+ polarity: str
+ points: int | float | None
+ anchors: Mapping[str, str]
+
+
+@dataclass(frozen=True)
+class _Rubric:
+ document: dict[str, Any]
+ kind: str
+ labels: tuple[str, ...]
+ criteria: tuple[RubricCriterion, ...]
+
+
+def _json_copy(rubric: Any) -> dict[str, Any]:
+ """rubric 의 깊은 JSON 사본 — JSON 으로 옮길 수 없는 값(NaN·날짜 등)은 `rubric_invalid:document`."""
+ if not isinstance(rubric, Mapping):
+ raise _invalid("document", f"must be a mapping, got {type(rubric).__name__}")
+ try:
+ return json.loads(json.dumps(dict(rubric), ensure_ascii=False, allow_nan=False))
+ except (TypeError, ValueError):
+ raise _invalid("document", "is not JSON-representable") from None
+
+
+def _labels(value: Any) -> tuple[str, ...]:
+ ok = isinstance(value, list) and all(isinstance(v, str) and v.strip() for v in value)
+ if not ok or not value or len(set(value)) != len(value):
+ raise _invalid("labels", "must be a non-empty list of unique non-empty strings")
+ missing = [label for label in REQUIRED_LABELS if label not in value]
+ if missing:
+ raise _invalid("labels", f"must include {list(REQUIRED_LABELS)}, missing {missing}")
+ return tuple(value)
+
+
+def _anchors(value: Any, labels: Sequence[str], criterion_id: str) -> Mapping[str, str]:
+ if value is None:
+ return MappingProxyType({})
+ if not isinstance(value, Mapping) or not all(
+ isinstance(v, str) and v.strip() for v in value.values()
+ ):
+ raise _invalid("anchors", f"{criterion_id}: must map labels to non-empty strings")
+ outside = sorted(str(k) for k in value if k not in labels)
+ if outside:
+ raise _invalid("anchors", f"{criterion_id}: keys outside the labels {outside}")
+ return MappingProxyType(dict(value))
+
+
+def _points(raw: Mapping[str, Any], kind: str, polarity: str, criterion_id: str) -> Any:
+ points = raw.get("points")
+ if kind == "checklist":
+ if "points" in raw:
+ raise _invalid("points", f"{criterion_id}: checklist criteria carry no points (RB4)")
+ return None
+ if not _is_number(points):
+ raise _invalid("points", f"{criterion_id}: criteria rubrics need a finite number (RB4)")
+ if isinstance(points, int) and abs(points) > _SAFE_INTEGER_MAX:
+ raise _invalid("points", f"{criterion_id}: integer outside the JCS safe range (RB0)")
+ if (polarity == "positive") != (points > 0) or points == 0:
+ raise ValueError(f"{RUBRIC_POINTS_SIGN}: {criterion_id} is {polarity} with points {points}")
+ return points
+
+
+def _optional_fields(item: Mapping[str, Any], criterion_id: str) -> None:
+ """선택 칸의 타입 — PB `Criterion` 과 같은 규칙(RB0). 선택 칸은 생략만 뜻을 가지므로 `null` 도 거부한다."""
+ if "required" in item and not isinstance(item["required"], bool):
+ raise _invalid("required", f"{criterion_id}: must be a boolean (omit instead of null)")
+ tags = item.get("tags")
+ if "tags" in item and not (
+ isinstance(tags, list) and all(isinstance(tag, str) and tag for tag in tags)
+ ):
+ raise _invalid("tags", f"{criterion_id}: must be a list of non-empty strings")
+ if "anchors" in item and item["anchors"] is None:
+ raise _invalid("anchors", f"{criterion_id}: omit the key instead of null")
+
+
+def _criterion(raw: Any, kind: str, labels: Sequence[str]) -> RubricCriterion:
+ item = _closed(raw, CRITERION_KEYS, "criterion")
+ criterion_id = item.get("id")
+ if not isinstance(criterion_id, str) or _CRITERION_ID_RE.fullmatch(criterion_id) is None:
+ raise _invalid("criterion_id", f"must match {_CRITERION_ID_RE.pattern}")
+ _optional_fields(item, criterion_id)
+ text = item.get("text")
+ if not isinstance(text, str) or not text.strip():
+ raise _invalid("criterion_text", f"{criterion_id}: must be a non-empty string")
+ polarity = item.get("polarity")
+ if polarity not in POLARITIES:
+ raise _invalid("polarity", f"{criterion_id}: must be one of {list(POLARITIES)}")
+ return RubricCriterion(
+ id=criterion_id,
+ text=text,
+ polarity=polarity,
+ points=_points(item, kind, polarity, criterion_id),
+ anchors=_anchors(item.get("anchors"), labels, criterion_id),
+ )
+
+
+def _aggregations(value: Any, kind: str, criteria: Sequence[RubricCriterion]) -> None:
+ ok = isinstance(value, list) and value and all(v in AGGREGATIONS for v in value)
+ if not ok or len(set(value)) != len(value):
+ raise _invalid("aggregations", f"must be a non-empty unique subset of {list(AGGREGATIONS)}")
+ if kind == "checklist" and "points_clip@1" in value:
+ raise _invalid(
+ "aggregations", "checklist rubrics have no points — points_clip@1 is refused"
+ )
+ if "all_pass@1" in value and any(c.polarity == "negative" for c in criteria):
+ raise ValueError(RUBRIC_ALL_PASS_WITH_NEGATIVE)
+
+
+def _provenance(value: Any) -> None:
+ """rubric/v1 필수 칸(빈 매핑 가능) — PB `RubricProvenance` 와 같은 닫힌 키·타입(RB0)."""
+ provenance = _closed(value, PROVENANCE_KEYS, "provenance")
+ alias = provenance.get("alias")
+ if "alias" in provenance and not (isinstance(alias, str) and alias):
+ raise _invalid("provenance", "alias must be a non-empty string (omit instead of null)")
+ supersedes = provenance.get("supersedes")
+ if supersedes is not None and not (
+ isinstance(supersedes, str) and _HEX64_RE.fullmatch(supersedes)
+ ):
+ raise _invalid("provenance", "supersedes must be a 64-hex rubric hash or null")
+
+
+def _parse_rubric(rubric: Any) -> _Rubric:
+ """§2.1 생성자 검사 — 구조와 칸 타입은 PB `parse_rubric_doc`(RB0~RB4)가 거부하는 것을 모두 여기서 먼저 거부한다.
+
+ `runs start` 는 in-run 레거시 채점으로 judge 를 먼저 부르므로(D-L12-6), 발행 직전 PB 검사에서야 걸리면 유료 호출이 헛돈다.
+ PB 는 발행 직전에 한 번 더 본다(`publish_judge:rubric_invalid`).
+ """
+ document = _json_copy(rubric)
+ _closed(document, RUBRIC_KEYS, "document")
+ if document.get("schema") != RUBRIC_SCHEMA:
+ raise _invalid("schema", f"must be {RUBRIC_SCHEMA!r}")
+ config = _closed(document.get("config"), RUBRIC_CONFIG_KEYS, "config")
+ kind = config.get("kind")
+ if kind not in RUBRIC_KINDS:
+ raise _invalid("kind", f"must be one of {list(RUBRIC_KINDS)}")
+ if config.get("scope") != RUBRIC_SCOPE:
+ raise _invalid("scope", f"must be {RUBRIC_SCOPE!r}")
+ labels = _labels(config.get("labels"))
+ raw_criteria = config.get("criteria")
+ if not isinstance(raw_criteria, list) or not raw_criteria:
+ raise _invalid("criteria", "must be a non-empty list")
+ criteria = tuple(_criterion(raw, kind, labels) for raw in raw_criteria)
+ ids = [c.id for c in criteria]
+ if len(set(ids)) != len(ids):
+ raise _invalid("criterion_id", "criterion ids must be unique")
+ if kind == "criteria" and not any(c.points > 0 for c in criteria):
+ raise _invalid("points", "a criteria rubric needs at least one positive criterion")
+ _aggregations(config.get("aggregations"), kind, criteria)
+ _provenance(document.get("provenance"))
+ return _Rubric(document, kind, labels, criteria)
+
+
+# ── 프롬프트(§2.2) ─────────────────────────────────────────────────────────
+
+
+def _fill(template: str, values: Mapping[str, str]) -> str:
+ """자리 이름을 한 번에 치환한다 — 값 안의 `{…}` 글자가 다른 자리로 다시 치환되지 않는다."""
+ pattern = re.compile(r"\{(" + "|".join(re.escape(k) for k in values) + r")\}")
+ return pattern.sub(lambda m: values[m.group(1)], template)
+
+
+def _render(value: Any) -> str:
+ """값 규칙 — 문자열은 그대로, 그 밖은 `json.dumps(ensure_ascii=False, sort_keys=True)`."""
+ if isinstance(value, str):
+ return value
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
+
+
+def _render_input(value: Any) -> str:
+ """매핑이면 키 순서대로 `"{key}: {value}"` 줄, 아니면 값 규칙."""
+ if isinstance(value, Mapping):
+ return "\n".join(f"{key}: {_render(item)}" for key, item in value.items())
+ return _render(value)
+
+
+# ── 판정 ────────────────────────────────────────────────────────────────
+
+
+def _tokens(response: Any) -> tuple[int | None, int | None]:
+ usage = response.get("usage") if isinstance(response, Mapping) else None
+ if not isinstance(usage, Mapping):
+ return None, None
+
+ def count(key: str) -> int | None:
+ value = usage.get(key)
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
+
+ return count("prompt_tokens"), count("completion_tokens")
+
+
+def _verdict(
+ sample_idx: int, criterion_id: str, status: VerdictStatus, **fields: Any
+) -> CriterionVerdict:
+ """판정 하나 — 주지 않은 칸은 None."""
+ base: dict[str, Any] = dict.fromkeys(
+ ("label", "passed", "reason", "rationale", "raw", "exception"), None
+ )
+ base.update(prompt_tokens=None, completion_tokens=None)
+ base.update(fields)
+ return CriterionVerdict(sample_idx=sample_idx, criterion_id=criterion_id, status=status, **base)
+
+
+def incomplete_code(verdicts: Sequence[CriterionVerdict]) -> str | None:
+ """레거시 어댑터의 샘플 실패 코드 — error > 출력 없음 > 라벨 미정 순. 모두 판정됐으면 None(D-L12-4)."""
+ if any(v.status == "error" for v in verdicts):
+ return LEGACY_CRITERION_ERROR
+ if any(v.status == "skipped" for v in verdicts):
+ return MISSING_OUTPUT
+ if any(v.passed is None for v in verdicts):
+ return LEGACY_VERDICT_UNKNOWN
+ return None
+
+
+class RubricJudgeEvaluator(BaseEvaluator):
+ """rubric 기준마다 judge 에 묻는 평가기(§2.1~§2.3). 기본 provider 는 레지스트리가 채워 넘긴다(F9)."""
+
+ required_fields = frozenset({"input", "output"})
+
+ def __init__(
+ self,
+ *,
+ rubric: Mapping[str, Any],
+ judge_model: str,
+ judge_provider: str,
+ max_tokens: int = DEFAULT_MAX_TOKENS,
+ ) -> None:
+ self._rubric = _parse_rubric(rubric)
+ if not isinstance(judge_model, str) or not judge_model.strip():
+ raise ValueError(JUDGE_MODEL_REQUIRED)
+ if judge_provider not in _PROVIDER_MODULES:
+ shown = repr(judge_provider) if isinstance(judge_provider, str) else "non-string"
+ allowed = " · ".join(_PROVIDER_MODULES)
+ raise ValueError(f"{JUDGE_PROVIDER_UNKNOWN}: rubric_judge {shown} (allowed: {allowed})")
+ if isinstance(max_tokens, bool) or not isinstance(max_tokens, int):
+ raise ValueError(MAX_TOKENS_INVALID)
+ if not 1 <= max_tokens <= MAX_TOKENS_LIMIT:
+ raise ValueError(MAX_TOKENS_INVALID)
+ self._judge_model = judge_model
+ self._judge_provider = judge_provider
+ self._max_tokens = max_tokens
+ self._by_id = {c.id: c for c in self._rubric.criteria}
+
+ # ── 읽기 ──
+
+ @property
+ def judge_model(self) -> str:
+ return self._judge_model
+
+ @property
+ def judge_provider(self) -> str:
+ """선언 provider 이름(`upstage`·`openai`)."""
+ return self._judge_provider
+
+ @property
+ def max_tokens(self) -> int:
+ return self._max_tokens
+
+ @property
+ def kind(self) -> str:
+ return self._rubric.kind
+
+ @property
+ def labels(self) -> tuple[str, ...]:
+ return self._rubric.labels
+
+ @property
+ def criterion_ids(self) -> tuple[str, ...]:
+ return tuple(c.id for c in self._rubric.criteria)
+
+ @property
+ def item_aggregation(self) -> str:
+ """item `rubric_score` 의 파생 id(D-L12-5)."""
+ return ITEM_AGGREGATION[self._rubric.kind]
+
+ @property
+ def points(self) -> dict[str, int | float]:
+ """항목 id → 점수(`criteria` 만, `checklist` 는 빈 dict)."""
+ return {c.id: c.points for c in self._rubric.criteria if c.points is not None}
+
+ @property
+ def rubric_document(self) -> dict[str, Any]:
+ """rubric 문서의 깊은 사본(발행 `rubric.json`·`rubric_hash` 원천)."""
+ return json.loads(json.dumps(self._rubric.document, ensure_ascii=False))
+
+ # ── 프롬프트 ──
+
+ def _criterion(self, criterion_id: str) -> RubricCriterion:
+ try:
+ return self._by_id[criterion_id]
+ except KeyError:
+ raise ValueError(f"rubric_judge: unknown criterion id {criterion_id!r}") from None
+
+ def messages_for(self, sample: EvalSample, criterion_id: str) -> list[dict[str, str]]:
+ """기준 하나 × 샘플 하나의 메시지 둘(시스템·사용자) — 순수 함수."""
+ criterion = self._criterion(criterion_id)
+ lines = [
+ f"- {label}: "
+ f"{criterion.anchors.get(label) or DEFAULT_LABEL_DESCRIPTIONS.get(label, NO_DESCRIPTION)}"
+ for label in self.labels
+ ]
+ system = _fill(
+ SYSTEM_TEMPLATE, {"labels_csv": ", ".join(self.labels), "label_lines": "\n".join(lines)}
+ )
+ reference = NO_REFERENCE if sample.reference is None else _render(sample.reference)
+ user = _fill(
+ USER_TEMPLATE,
+ {
+ "criterion_id": criterion.id,
+ "polarity": criterion.polarity,
+ "criterion_text": criterion.text,
+ "rendered_input": _render_input(sample.input),
+ "rendered_reference": reference,
+ "rendered_output": _render(sample.output),
+ },
+ )
+ return [{"role": "system", "content": system}, {"role": "user", "content": user}]
+
+ # ── 호출 ──
+
+ def _check_provider(self, provider: Any) -> None:
+ """알려진 실제 provider 모듈인데 선언과 다르면 `JudgeProviderMismatch` — `judge`·`evaluate` 의 첫 줄(D-L12-18)."""
+ module = type(provider).__module__
+ if (
+ module in _PROVIDER_MODULES.values()
+ and module != _PROVIDER_MODULES[self._judge_provider]
+ ):
+ raise JudgeProviderMismatch(self._judge_provider, module)
+
+ def _selected(self, only: Collection[str] | None) -> tuple[RubricCriterion, ...]:
+ if only is None:
+ return self._rubric.criteria
+ if isinstance(only, str):
+ raise TypeError("rubric_judge: only must be a collection of criterion ids, not a str")
+ unknown = sorted(set(only) - set(self._by_id))
+ if unknown:
+ raise ValueError(f"rubric_judge: only names unknown criterion id(s) {unknown}")
+ wanted = set(only)
+ return tuple(c for c in self._rubric.criteria if c.id in wanted)
+
+ async def judge(
+ self,
+ sample: EvalSample,
+ provider: BaseProvider,
+ *,
+ sample_idx: int,
+ only: Collection[str] | None = None,
+ ) -> SampleVerdicts:
+ """기준 순서대로 순차 호출 — `only` 가 있으면 그 기준만(retry 대상). 나머지 기준은 결과에 넣지 않는다."""
+ self._check_provider(provider)
+ return await self._judge(sample, provider, sample_idx, only, self._judge_model)
+
+ async def _judge(
+ self,
+ sample: EvalSample,
+ provider: BaseProvider,
+ sample_idx: int,
+ only: Collection[str] | None,
+ model: str,
+ ) -> SampleVerdicts:
+ criteria = self._selected(only)
+ if sample.output is None: # 출력이 없으면 부르지 않는다(§2.3 마지막 행)
+ skipped = tuple(
+ _verdict(sample_idx, c.id, "skipped", reason=MISSING_OUTPUT) for c in criteria
+ )
+ return SampleVerdicts(sample_idx, skipped)
+ verdicts = [await self._ask(sample, provider, sample_idx, c, model) for c in criteria]
+ return SampleVerdicts(sample_idx, tuple(verdicts))
+
+ async def _ask(
+ self,
+ sample: EvalSample,
+ provider: BaseProvider,
+ sample_idx: int,
+ criterion: RubricCriterion,
+ model: str,
+ ) -> CriterionVerdict:
+ messages = self.messages_for(sample, criterion.id)
+ try:
+ response = await provider.chat_completion(
+ messages=messages, model=model, temperature=TEMPERATURE, max_tokens=self._max_tokens
+ )
+ except (
+ Exception
+ ) as exc: # 호출 실패는 그 기준만 error — 메시지 없이 타입 이름만(원문 누출 방지)
+ name = type(exc).__name__
+ _LOG.warning(
+ "rubric_judge: sample %s criterion %s → %s (%s)",
+ sample_idx,
+ criterion.id,
+ GRADER_UNAVAILABLE,
+ name,
+ )
+ return _verdict(
+ sample_idx, criterion.id, "error", reason=GRADER_UNAVAILABLE, exception=name
+ )
+ content = response.get("content") if isinstance(response, Mapping) else None
+ raw = content if isinstance(content, str) else None
+ prompt_tokens, completion_tokens = _tokens(response)
+ usage = {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens}
+ try:
+ label, rationale = parse_verdict(raw, self.labels)
+ except VerdictParseError as exc:
+ _LOG.warning(
+ "rubric_judge: sample %s criterion %s → %s", sample_idx, criterion.id, exc.reason
+ )
+ return _verdict(sample_idx, criterion.id, "error", reason=exc.reason, raw=raw, **usage)
+ passed = LABEL_PASSED.get(label)
+ return _verdict(
+ sample_idx,
+ criterion.id,
+ "ok",
+ label=label,
+ passed=passed,
+ rationale=rationale,
+ raw=raw,
+ **usage,
+ )
+
+ # ── 레거시 어댑터(D-L12-4) ──
+
+ def item_value(self, passed_by_id: Mapping[str, bool]) -> float:
+ """모든 기준이 판정됐을 때의 item 값 — PB `points_clip@1`·`all_pass@1` 과 같은 뜻의 표준 라이브러리 계산.
+
+ 채점 번들의 `rubric_score` 는 PB `derive` 가 정본이다(`scoring_publish.judge_bundle.item_score`) — 시험이 둘을 대조한다.
+ """
+ missing = sorted(set(self._by_id) - set(passed_by_id))
+ if missing:
+ raise ValueError(f"rubric_judge: verdicts do not cover criteria {missing}")
+ if self.kind == "checklist":
+ return 1.0 if all(passed_by_id[c] for c in self._by_id) else 0.0
+ points = self.points
+ earned = math.fsum(points[c] for c in points if passed_by_id[c])
+ possible = math.fsum(p for p in points.values() if p > 0)
+ return min(1.0, max(0.0, earned / possible))
+
+ def legacy_result(self, verdicts: Sequence[CriterionVerdict]) -> dict[str, Any]:
+ """판정들 → 레거시 `evaluate` 결과. 모든 기준이 ok·`passed` 있음일 때만, 아니면 `RubricVerdictIncomplete`."""
+ code = incomplete_code(verdicts)
+ if code is not None:
+ raise RubricVerdictIncomplete(code)
+ passed = {v.criterion_id: bool(v.passed) for v in verdicts}
+ score = self.item_value(passed)
+ categories = {c: 1.0 if passed[c] else 0.0 for c in self.criterion_ids}
+ return {"score": score, "category_scores": categories, "details": {}}
+
+ async def evaluate(
+ self,
+ sample: EvalSample,
+ provider: BaseProvider | None = None,
+ judge_model: str | None = None,
+ ) -> dict[str, Any]:
+ """레거시 어댑터(D-L12-4) — 모든 기준 ok·`passed` 있음일 때만 점수, 아니면 `RubricVerdictIncomplete`."""
+ if provider is None:
+ raise ValueError(JUDGE_PROVIDER_REQUIRED)
+ self._check_provider(provider)
+ model = judge_model or self._judge_model
+ verdicts = await self._judge(sample, provider, LEGACY_SAMPLE_IDX, None, model)
+ return self.legacy_result(verdicts.verdicts)
+
+ def aggregate(self, results: list[dict[str, Any]]) -> dict[str, Any]:
+ """`{"overall_score", "scores", "num_samples"}` — `llm_judge` 와 같은 모양. `scores` = 기준별 met 비율."""
+ if not results:
+ return {"overall_score": 0.0, "scores": {}, "num_samples": 0}
+ n = len(results)
+ overall = math.fsum(float(r["score"]) for r in results) / n
+ scores = {
+ c: math.fsum(float(r.get("category_scores", {}).get(c, 0.0)) for r in results) / n
+ for c in self.criterion_ids
+ }
+ return {"overall_score": overall, "scores": scores, "num_samples": n}
+
+
+# ── 파서(§2.3) ─────────────────────────────────────────────────────────
+
+
+def _reject_constant(name: str) -> Any:
+ raise ValueError(f"non-finite constant {name}")
+
+
+def _unique_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
+ keys = [key for key, _ in pairs]
+ if len(set(keys)) != len(keys):
+ raise ValueError("duplicate key")
+ return dict(pairs)
+
+
+def parse_verdict(content: str | None, labels: Sequence[str]) -> tuple[str, str | None]:
+ """`rubric-verdict-json@1`(§2.3) — (라벨, 근거). 실패는 `VerdictParseError()`(메시지 = 코드만)."""
+ text = content.strip() if isinstance(content, str) else ""
+ if not text:
+ raise VerdictParseError(GRADER_FAILED_EMPTY)
+ block = _CODE_BLOCK_RE.match(text)
+ if block is not None:
+ text = block.group(1)
+ try:
+ data = json.loads(text, parse_constant=_reject_constant, object_pairs_hook=_unique_pairs)
+ except (ValueError, RecursionError):
+ raise VerdictParseError(GRADER_FAILED_UNPARSEABLE) from None
+ if not isinstance(data, dict):
+ raise VerdictParseError(GRADER_FAILED_SCHEMA)
+ label, rationale = data.get("label"), data.get("rationale")
+ if not isinstance(label, str) or not (rationale is None or isinstance(rationale, str)):
+ raise VerdictParseError(GRADER_FAILED_SCHEMA)
+ label = label.strip()
+ if label not in labels:
+ raise VerdictParseError(GRADER_FAILED_LABEL)
+ return label, rationale
diff --git a/solar_eval/evaluators/rule_based.py b/solar_eval/evaluators/rule_based.py
index 04d0cd950c7dcd9f5873d33c386a9c399c28e99a..91d2bbacfc6460dd9cf0fbb90372cff2660ac82b 100644
--- a/solar_eval/evaluators/rule_based.py
+++ b/solar_eval/evaluators/rule_based.py
@@ -53,7 +53,7 @@ class RuleBasedEvaluator(BaseEvaluator):
self,
sample: EvalSample,
provider: BaseProvider | None = None,
- judge_model: str = "gpt-4o",
+ judge_model: str | None = None,
) -> dict[str, Any]:
rule_results = {}
for rule_name in self.rules:
diff --git a/solar_eval/hub_hook/__init__.py b/solar_eval/hub_hook/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c5f63aa303d2c831695982ae7059af3b059329e0
--- /dev/null
+++ b/solar_eval/hub_hook/__init__.py
@@ -0,0 +1,24 @@
+"""엔진 run 종료 훅 — 번들 다리(in-process) → hub 다리(opt-in) (계획 2026-09-27 L6a · P9b).
+
+`runs start`·`runs resume`(·`runs batch`·L6b `runs eval`)이 run 을 끝낸 뒤 `run_hook` 을 부른다. 훅은 레거시 적재를 부르지 않는다
+(마스터 D-M2-4 (b) — 레거시 화면은 L10 컷오버로 사라졌다). hub 다리 opt-in 은 `HUB_DATABASE_URL` 또는 사용자 설정 파일의
+`hub.database_url`(사람 셸만 — `optin.resolve_hub_optin`, 계획 2026-09-28 L10 §2.6)이다. 실패해도 run 명령의 종료 코드는 기본 0 이고
+`--strict-ingest`/`SOLAR_EVAL_STRICT_INGEST=1` 에서만 3 이다(D-M2-3).
+
+**import 경계(D-L6-14).** 이 패키지의 모듈 최상위는 표준 라이브러리·click·`solar_eval.cli`·`solar_eval.hub_hook.*` 만 import 한다.
+`poc_bundle`·`solar_eval.rescore` 는 함수 안에서. runner 경로(`RUNNER_PATH_MODULES`)는 이 패키지를 import 하지 않는다.
+"""
+
+from solar_eval.hub_hook.hook import exit_code_for, finish_batch, gated_hub_leg, run_hook
+from solar_eval.hub_hook.types import HookMode, HookOptions, HookResult, LegResult
+
+__all__ = [
+ "HookMode",
+ "HookOptions",
+ "HookResult",
+ "LegResult",
+ "exit_code_for",
+ "finish_batch",
+ "gated_hub_leg",
+ "run_hook",
+]
diff --git a/solar_eval/hub_hook/bundle_leg.py b/solar_eval/hub_hook/bundle_leg.py
new file mode 100644
index 0000000000000000000000000000000000000000..dfda35a978058b633a79bfc6cd5b6a1fc30052d4
--- /dev/null
+++ b/solar_eval/hub_hook/bundle_leg.py
@@ -0,0 +1,329 @@
+"""번들 다리 (계획 2026-09-27 L6 §2.5 · D-L6-2 · D-L6-8 · D-L6-9).
+
+case 잠금 **안**에서 hook 이 부른다. 순서:
+
+1. **백필** — `BackfillOptions(case=a/c, root=target.projects_dir, mirror_root=blob_mirror_root(), index_root=hub_index_root(),
+ repo_root=target.repo_root, run_ids=frozenset({run_id}))` → `backfill_case(opts, target.context)`(wrap → 미러 → 번들 색인 →
+ eval-wrap). `CaseReport.runs` 는 정확히 하나여야 한다(0 이면 `run_not_in_archive`). 부류 → 코드:
+ wrap `written·reused·rewrapped_input_fields` → 번들 있음 · `frozen:` → `bundle_frozen:`(hub 는 `no_bundle`) ·
+ `error:` → `bundle_error:` · 번들 색인 `error:` → `bundle_index:`(계속) · eval-wrap `error:*` →
+ `eval_wrap:`(계속 — 번들은 있다) · eval-wrap `skip:*`·`frozen:*` 는 정보 · `BackfillError` → `mirror_unadoptable`.
+2. **재채점**(번들이 있을 때만) — lcs_diff 계열(D-L6-8: run.json 스냅샷 `config.evaluator_config.config.type`, 스냅샷이 없으면 run
+ 디렉토리 평가 파일의 `poc_bundle.legacy_run.evaluator_family`)이면 `rescore_case(a, c, run_ids={run_id})`(case 모드 채택 규칙,
+ D-L6-9) → `written|exists` 가 ok · `no_results` skip → `rescore_empty` · 그 밖 skip → `rescore_skip:` · 오류 →
+ `rescore_error:`. 계열이 아니면 `not_proofread_family`(정보 — "이 평가기는 M2 채점 번들 생산자가 없다", Q-M2-1).
+3. **색인** — `indexes.refresh`(lock·템플릿·텍스트 모드·추출기·색인 코드 키가 바뀌었을 때만, D-L6-7).
+
+다리 결과는 하나의 `LegResult` 다. 실패가 여럿이면 **순서상 첫 실패**가 `code` 이고, 단계마다의 결과는 `detail` 에 모두 남는다
+(상태 파일 §2.7): `{"backfill": run_entry, "rescore": {"status", "code"?, "bundle_id"?}, "indexes": <코드>}`. 번들이 있으면 실패여도
+`detail.backfill.wrap` 이 남아 hook 이 `types.bundle_ready` 로 hub 다리를 돈다(D-L6-2). 색인 CLI 의 정합성 경고가 있으면
+`index_warnings: <건수>`, 치운 옛 색인 디렉토리가 있으면 `index_removed: [<이름>…]` 를 더한다(리뷰 지적 2·4 — 줄 자체는
+`indexes` 가 stderr 로 낸다).
+
+**단계 예외 봉쇄.** 재채점·색인 단계의 예기치 않은 예외는 그 단계만 `rescore_exception:`·`indexes_exception:` 실패로
+강등한다(타입 이름만) — 백필이 만든 번들을 버리지 않고 hub 다리까지 가게. 백필 자체의 예외는 hook 이 `bundle_exception:`
+으로 강등한다(번들 유무를 모른다). 원문·locals 는 싣지 않지만 **발생 위치**는 남긴다(리뷰 지적 5): 가장 안쪽 프레임의
+`<파일 basename>:<줄>:<함수>` 를 `detail.exception_at.<단계>` 에(고객 데이터가 없는 코드 좌표), 트레이스백 전체는 이 모듈 로거에
+(`hook` 과 같은 규칙 — 기본 DEBUG, `SOLAR_EVAL_HOOK_DEBUG=1` 이면 WARNING).
+
+`poc_bundle`·`solar_eval.rescore` 는 함수 안에서만 import 한다(D-L6-14). 출력·detail 에는 id·부류·코드만(고객 텍스트 없음).
+"""
+
+from __future__ import annotations
+
+import logging
+import traceback
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from solar_eval.hub_hook import indexes, optin, target
+from solar_eval.hub_hook.types import (
+ BUNDLE_ERROR_PREFIX,
+ BUNDLE_FROZEN_PREFIX,
+ BUNDLE_INDEX_FAILED_PREFIX,
+ EVAL_WRAP_FAILED_PREFIX,
+ MIRROR_UNADOPTABLE,
+ NO_BUNDLE,
+ NOT_PROOFREAD_FAMILY,
+ PROOFREAD_EVALUATOR_TYPE,
+ RESCORE_EMPTY,
+ RESCORE_ERROR_PREFIX,
+ RESCORE_NO_RESULTS,
+ RESCORE_OK,
+ RESCORE_SKIP_PREFIX,
+ RUN_NOT_IN_ARCHIVE,
+ STATUS_FAILED,
+ STATUS_NOT_RUN,
+ STATUS_SKIPPED,
+ STEP_BACKFILL,
+ STEP_INDEXES,
+ STEP_RESCORE,
+ WRAP_BUNDLE_READY,
+ WRAP_ERROR_PREFIX,
+ WRAP_FROZEN_PREFIX,
+ LegResult,
+ Target,
+ exception_code,
+)
+
+_RUN_JSON = "run.json"
+_LOG = logging.getLogger(__name__)
+# detail 키 `index_warnings`·`index_removed`·`exception_at`(리뷰 지적 2·4·5)는 코드 모양 문자열이라 이 모듈 최상위 상수로 두지
+# 않는다(`test_types_home` — 코드 문자열은 `types.py` 한 곳). 쓰는 자리에 한 번씩만 적었다 — l6a-verify 가 `types.py` 로 옮긴다
+# (계획 §2.5 구현 시 변경 메모).
+
+
+@dataclass(frozen=True)
+class _Backfilled:
+ """백필 결과 — `entry` 는 보고서 `run_entry`(없으면 run 이 없거나 case 오류), `failures` 는 순서대로의 실패 코드."""
+
+ entry: Mapping[str, Any] | None
+ run_id: str | None = None
+ bundle_id: str | None = None
+ failures: tuple[str, ...] = ()
+
+ @property
+ def ready(self) -> bool:
+ return self.entry is not None and self.entry.get("wrap") in WRAP_BUNDLE_READY
+
+
+@dataclass
+class _Steps:
+ """다리 안 단계들의 결과 — 첫 실패와 그 hint 를 모은다."""
+
+ detail: dict[str, Any] = field(default_factory=dict)
+ failures: list[str] = field(default_factory=list)
+ hint: str | None = None
+
+ def fail(self, code: str, hint: str | None = None) -> None:
+ if not self.failures:
+ self.hint = hint
+ self.failures.append(code)
+
+ def result(self) -> LegResult:
+ if self.failures:
+ return LegResult.failed(self.failures[0], self.hint, self.detail)
+ return LegResult.ok(self.detail)
+
+ def contain(self, step: str, exc: Exception) -> str:
+ """단계 예외 → 코드(타입 이름만). 위치 좌표는 detail 에, 트레이스백은 로거로(모듈 설명 · 리뷰 지적 5)."""
+ code = exception_code(step, exc)
+ where = exception_location(exc)
+ if where is not None:
+ self.detail.setdefault("exception_at", {})[step] = where
+ level = logging.WARNING if optin.hook_debug_requested() else logging.DEBUG
+ _LOG.log(level, "Experiment Hub 훅 번들 다리 단계 예외 봉쇄 — %s", code, exc_info=exc)
+ return code
+
+
+def exception_location(exc: BaseException) -> str | None:
+ """가장 안쪽 프레임의 `<파일 basename>:<줄>:<함수>` — 메시지·locals 는 싣지 않는다(고객 데이터가 없는 코드 좌표만)."""
+ frames = traceback.extract_tb(exc.__traceback__)
+ if not frames:
+ return None
+ last = frames[-1]
+ return f"{Path(last.filename).name}:{last.lineno}:{last.name}"
+
+
+def run_bundle_leg(target: Target, run_dir: Path, run_id: str) -> LegResult:
+ """run 하나의 백필 → 재채점 → 색인(§2.5). 백필 예외는 hook 이 `bundle_exception:` 으로 강등한다."""
+ backfilled = _backfill(target, run_id)
+ if backfilled.entry is None:
+ return LegResult.failed(backfilled.failures[0])
+ steps = _Steps({STEP_BACKFILL: dict(backfilled.entry)})
+ for code in backfilled.failures:
+ steps.fail(code)
+ if backfilled.ready:
+ _rescore_step(target, Path(run_dir), backfilled, steps)
+ else:
+ steps.detail[STEP_RESCORE] = {"status": STATUS_NOT_RUN, "code": NO_BUNDLE}
+ _indexes_step(target, steps)
+ return steps.result()
+
+
+def ensure_run_bundle(target: Target, run_id: str) -> tuple[LegResult, Path | None]:
+ """백필 부분만(L6b `runs eval` 새 경로 ①) — 채택된 미러 run 번들 경로를 함께 돌려준다. 번들이 없으면 None.
+
+ **전제: 호출자가 `lock.case_lock(target.account, target.case)` 를 쥔 채 부른다**(계획 §2.12 B4 "new_path (case 잠금 안)" ·
+ 리뷰 지적 6) — 백필은 미러·번들 색인 사이드카·eval-wrap 을 쓰므로 같은 case 의 훅과 겹치면 안 된다. 여기서 잠그지 않는
+ 까닭: L6b 는 ①~⑤(번들 확인·채점·발행·hub·상태 파일)를 한 잠금 안에서 돈다 — `flock` 은 같은 프로세스라도 따로 연 기술자끼리
+ 막으므로 안에서 다시 잡으면 자기 자신을 기다리다 한도에 걸린다.
+
+ 판정 집합은 `run_bundle_leg` 와 같다(`WRAP_BUNDLE_READY`) — 훅에서 통과한 run 이 여기서 실패하지 않게(검토 F12).
+ eval-wrap·번들 색인 실패는 `run_bundle_leg` 처럼 실패로 싣되 경로는 돌려준다(번들은 있다).
+ """
+ backfilled = _backfill(target, run_id)
+ if backfilled.entry is None:
+ return LegResult.failed(backfilled.failures[0]), None
+ detail = {STEP_BACKFILL: dict(backfilled.entry)}
+ path = _mirror_bundle_path(target, backfilled.bundle_id) if backfilled.ready else None
+ if backfilled.failures:
+ return LegResult.failed(backfilled.failures[0], detail=detail), path
+ return LegResult.ok(detail), path
+
+
+# ── 백필 ─────────────────────────────────────────────────────────────────
+
+
+def _backfill(target: Target, run_id: str) -> _Backfilled:
+ """`backfill_case` 를 run 하나로. 충돌 검사는 PB 가 case 의 run 디렉토리 전부로 한다."""
+ from poc_bundle.backfill.report import run_entry
+ from poc_bundle.backfill.runner import BackfillError, BackfillOptions, backfill_case
+ from poc_bundle.home import blob_mirror_root, hub_index_root
+
+ opts = BackfillOptions(
+ case=target.case_ref,
+ root=target.projects_dir,
+ mirror_root=blob_mirror_root(),
+ index_root=hub_index_root(),
+ repo_root=target.repo_root,
+ run_ids=frozenset({run_id}),
+ )
+ try:
+ report = backfill_case(opts, target.context)
+ except BackfillError:
+ return _Backfilled(None, failures=(MIRROR_UNADOPTABLE,))
+ if not report.runs:
+ return _Backfilled(None, failures=(RUN_NOT_IN_ARCHIVE,))
+ if len(report.runs) != 1:
+ raise RuntimeError(f"backfill returned {len(report.runs)} runs for one run directory")
+ run = report.runs[0]
+ return _Backfilled(run_entry(run), run.run_id, run.bundle_id, _backfill_failures(run))
+
+
+def _backfill_failures(run: Any) -> tuple[str, ...]:
+ """`RunReport` 부류 → 번들 다리 실패 코드(순서: 어댑터·wrap → 번들 색인 → eval-wrap)."""
+ failures: list[str] = []
+ wrap_failure = _wrap_failure(run.adapter, run.wrap)
+ if wrap_failure is not None:
+ failures.append(wrap_failure)
+ if isinstance(run.index, str) and run.index.startswith(WRAP_ERROR_PREFIX):
+ failures.append(f"{BUNDLE_INDEX_FAILED_PREFIX}{run.index.removeprefix(WRAP_ERROR_PREFIX)}")
+ if isinstance(run.eval_wrap, str) and run.eval_wrap.startswith(WRAP_ERROR_PREFIX):
+ detail = run.error_code or run.eval_wrap.removeprefix(WRAP_ERROR_PREFIX)
+ failures.append(f"{EVAL_WRAP_FAILED_PREFIX}{detail}")
+ return tuple(failures)
+
+
+def _wrap_failure(adapter: str | None, wrap: str | None) -> str | None:
+ """wrap 이 번들을 남기지 못했으면 그 코드. 어댑터가 막아 wrap 에 닿지 않았으면 어댑터 부류로."""
+ if wrap in WRAP_BUNDLE_READY:
+ return None
+ if isinstance(wrap, str) and wrap.startswith(WRAP_FROZEN_PREFIX):
+ return f"{BUNDLE_FROZEN_PREFIX}{wrap.removeprefix(WRAP_FROZEN_PREFIX)}"
+ stage = wrap if isinstance(wrap, str) else adapter
+ if isinstance(stage, str) and stage.startswith(WRAP_ERROR_PREFIX):
+ return f"{BUNDLE_ERROR_PREFIX}{stage.removeprefix(WRAP_ERROR_PREFIX)}"
+ return f"{BUNDLE_ERROR_PREFIX}{stage or 'none'}"
+
+
+def _mirror_bundle_path(t: Target, bundle_id: str | None) -> Path | None:
+ """미러의 `accounts///eval/bundles/`(있을 때만)."""
+ from solar_eval.rescore.paths import BUNDLES_AREA, mirror_area_dir
+
+ if not bundle_id:
+ return None
+ path = mirror_area_dir(None, t.account, t.case, BUNDLES_AREA) / bundle_id
+ return path if path.is_dir() else None
+
+
+# ── 재채점 ───────────────────────────────────────────────────────────────
+
+
+def _rescore_step(t: Target, run_dir: Path, backfilled: _Backfilled, steps: _Steps) -> None:
+ try:
+ if not is_proofread_family(run_dir):
+ steps.detail[STEP_RESCORE] = {"status": STATUS_SKIPPED, "code": NOT_PROOFREAD_FAMILY}
+ return
+ status, code, bundle_id = _rescore(t, backfilled.run_id or run_dir.name)
+ except Exception as exc: # 단계 경계 — 백필이 만든 번들로 hub 다리는 계속 간다(모듈 설명)
+ status, code, bundle_id = STATUS_FAILED, steps.contain(STEP_RESCORE, exc), None
+ entry: dict[str, Any] = {"status": status}
+ if code is not None:
+ entry["code"] = code
+ if bundle_id is not None:
+ entry["bundle_id"] = bundle_id
+ steps.detail[STEP_RESCORE] = entry
+ if status == STATUS_FAILED and code is not None:
+ steps.fail(code)
+
+
+def is_proofread_family(run_dir: Path) -> bool:
+ """재채점 대상인가(D-L6-8). 스냅샷 평가기 type 이 있으면 그것, 없으면 run 디렉토리 평가 파일의 계열(검토 F18).
+
+ run.json·평가 파일을 못 읽으면 계열을 모르므로 대상이 아니다(`not_proofread_family` — wrap 이 그 run 을 이미 가른다).
+ """
+ from poc_bundle.canonical import load_json_strict
+ from poc_bundle.legacy_run import (
+ FAMILY_LCS,
+ LegacyRunError,
+ evaluator_family,
+ read_eval_files,
+ split_rounds,
+ )
+
+ try:
+ run_json = load_json_strict((Path(run_dir) / _RUN_JSON).read_bytes(), safe_integers=False)
+ except (OSError, ValueError, RecursionError):
+ return False
+ if not isinstance(run_json, dict):
+ return False
+ kind = target.snapshot_evaluator_type(run_json)
+ if kind is not None:
+ return kind == PROOFREAD_EVALUATOR_TYPE
+ try:
+ evaluation, lines = read_eval_files(Path(run_dir))
+ except LegacyRunError:
+ return False
+ return evaluator_family(run_json, evaluation, split_rounds(evaluation, lines)) == FAMILY_LCS
+
+
+def _rescore(t: Target, run_id: str) -> tuple[str, str | None, str | None]:
+ """`rescore_case(run_ids={run_id})` → (상태, 코드, 채점 번들 id). 상태는 `written|exists`(ok) 또는 `failed`."""
+ from poc_bundle.home import blob_mirror_root
+
+ from solar_eval.rescore.case import rescore_case
+
+ summary = rescore_case(
+ t.account,
+ t.case,
+ mirror_root=blob_mirror_root(),
+ repo_root=t.repo_root,
+ run_ids=frozenset({run_id}),
+ )
+ if summary.failures:
+ return STATUS_FAILED, f"{RESCORE_ERROR_PREFIX}{summary.failures[0].code}", None
+ if len(summary.outcomes) != 1:
+ return STATUS_FAILED, RESCORE_EMPTY, None
+ outcome = summary.outcomes[0]
+ if outcome.status in RESCORE_OK:
+ bundle_id = None if outcome.path is None else Path(outcome.path).name
+ return outcome.status, None, bundle_id
+ if outcome.code == RESCORE_NO_RESULTS:
+ return STATUS_FAILED, RESCORE_EMPTY, None
+ return STATUS_FAILED, f"{RESCORE_SKIP_PREFIX}{outcome.code}", None
+
+
+# ── 색인 ─────────────────────────────────────────────────────────────────
+
+
+def _indexes_step(t: Target, steps: _Steps) -> None:
+ try:
+ outcome = indexes.refresh(t)
+ except Exception as exc: # 단계 경계 — 색인은 case 파생물이라 run 번들·hub 다리를 막지 않는다
+ code = steps.contain(STEP_INDEXES, exc)
+ steps.detail[STEP_INDEXES] = f"{STATUS_FAILED}:{code}"
+ steps.fail(code)
+ return
+ steps.detail[STEP_INDEXES] = outcome.code
+ if outcome.warnings:
+ steps.detail["index_warnings"] = len(outcome.warnings)
+ if outcome.removed:
+ steps.detail["index_removed"] = list(outcome.removed)
+ if outcome.failed:
+ steps.fail(outcome.code.removeprefix(f"{STATUS_FAILED}:"), outcome.reason)
+
+
+__all__ = ["ensure_run_bundle", "is_proofread_family", "run_bundle_leg"]
diff --git a/solar_eval/hub_hook/console.py b/solar_eval/hub_hook/console.py
new file mode 100644
index 0000000000000000000000000000000000000000..35dc2c0af914dcbaa9b55f816e30bbbed0061faa
--- /dev/null
+++ b/solar_eval/hub_hook/console.py
@@ -0,0 +1,271 @@
+"""훅 콘솔 — 다리별 한 줄과 되살리기 명령 문구 (계획 2026-09-27 L6 §2.6 표 · §2.7 · 검토 F16).
+
+되살리기 명령의 URL 자리는 늘 `HUB_DATABASE_URL=…` 이다 — 값은 어디에도 싣지 않는다(D-L6-4). 실패 줄은 빨강 + 되살리기.
+레거시 화면(`public`)은 L10 컷오버로 코드에서 사라졌다 — 레거시 수동 적재 줄은 없다(계획 2026-09-28 L10 §2.6 · L6b 후속 1).
+hub opt-in 이 없으면(`not_opted_in`) 빨간 줄로 "이 run 은 hub(/v1)에 없다" 와 켜는 법·지금 싣는 명령을 안내한다(마스터 D-M2-18).
+요약 머리에는 훅 전체 경과 초(상태 파일의 `started_at`→`finished_at`, 잠금 대기 포함)를 붙인다 — 첫 실데이터 run(U-L6a-1)에서
+콘솔만으로 hub 적재 시간을 알 수 없었기 때문이다. 시각이 없거나 형식이 다르면 붙이지 않는다.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable, Mapping
+from datetime import datetime
+from typing import Any
+
+import click
+
+from solar_eval.hub_hook import optin
+from solar_eval.hub_hook.types import (
+ CASE_BLOCKED_PREFIX,
+ CASE_FAILED_PREFIX,
+ FILTER_NO_MATCH,
+ HUB_ERR_CONFIRMATION_REQUIRED,
+ HUB_ERR_CONNECTION_FAILED,
+ HUB_FAILED_PREFIX,
+ LEG_BUNDLE,
+ LEG_HOOK,
+ LEG_HUB,
+ LEG_LOCK,
+ LEG_PUBLISH,
+ LOCK_TIMEOUT,
+ NOT_OPTED_IN,
+ RUN_NOT_OK_PREFIX,
+ SCHEMA_MISMATCH,
+ SKIP_CASE_UNMAPPED,
+ SKIP_DATA_HOME_INSIDE_REPO,
+ SKIP_DATA_HOME_MISMATCH,
+ SKIP_NO_REPO_ROOT,
+ SKIP_NOT_REPO_MANAGED,
+ SMOKE_RUN,
+ STAGE_CASE_LOCK,
+ STATUS_FAILED,
+ STATUS_OK,
+ STATUS_SKIPPED,
+ HookResult,
+ LegResult,
+ Skip,
+)
+
+URL_PLACEHOLDER = "HUB_DATABASE_URL=…"
+#: 상태 파일 시각 형식(`hook._now` 와 같다).
+HOOK_TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
+LEG_LABELS = {
+ LEG_BUNDLE: "번들",
+ LEG_HUB: "hub 적재",
+ LEG_LOCK: "case 잠금",
+ LEG_HOOK: "훅",
+ LEG_PUBLISH: "채점 발행",
+}
+SKIP_REASONS = {
+ SKIP_NOT_REPO_MANAGED: "레포 관리 프로젝트가 아니다",
+ SKIP_NO_REPO_ROOT: "레포 config 루트가 없다(--projects-dir 등)",
+ SKIP_CASE_UNMAPPED: "project 가 case 에 매핑되지 않는다 — make bundle-census 로 확인",
+ SKIP_DATA_HOME_MISMATCH: "엔진 아카이브가 데이터 홈 밖이다",
+ SKIP_DATA_HOME_INSIDE_REPO: "데이터 홈이 레포 안이다 — 훅 파생물은 git 밖에 둔다(CBS_DATA_HOME)",
+}
+#: 요약 줄에 싣는 번들 다리 detail 키(순서대로).
+_BUNDLE_SUMMARY_KEYS = (
+ (("backfill", "wrap"), "wrap"),
+ (("backfill", "eval_wrap"), "eval-wrap"),
+ (("rescore", "status"), "재채점"),
+ (("indexes",), "색인"),
+)
+
+
+# ── 되살리기 명령 ────────────────────────────────────────────────────────
+
+
+def hub_ingest_command(case_ref: str) -> str:
+ return f"{URL_PLACEHOLDER} make hub-ingest CASE={case_ref} CONFIRM=local"
+
+
+def hub_backfill_command(case_ref: str, run_id: str) -> str:
+ return f'make hub-backfill CASE={case_ref} ARGS="--run {run_id}"'
+
+
+def hub_recovery(code: str, case_ref: str, run_id: str) -> str:
+ """hub 다리 실패 코드 → 되살리기 문구(§2.6 표)."""
+ ingest = hub_ingest_command(case_ref)
+ if code == f"{HUB_FAILED_PREFIX}{HUB_ERR_CONFIRMATION_REQUIRED}":
+ return _confirmation_hint(ingest)
+ if code == f"{HUB_FAILED_PREFIX}{HUB_ERR_CONNECTION_FAILED}":
+ return f"make experiment-hub-up 뒤 {ingest}"
+ if code == f"{CASE_FAILED_PREFIX}{STAGE_CASE_LOCK}":
+ return f"앞 적재가 끝난 뒤 {ingest}"
+ if code.startswith((CASE_BLOCKED_PREFIX, CASE_FAILED_PREFIX)):
+ return f"{ingest} 후 이슈 표(/v1/{case_ref}) 확인"
+ if code == SCHEMA_MISMATCH:
+ return (
+ f"hub 스키마 판이 다르다 — {URL_PLACEHOLDER} make hub-m1-local CASE={case_ref} "
+ "CONFIRM=local SKIP_PULL=1 FROM=backfill (주의: 재구성은 모든 case 를 지운다, case 마다 다시)"
+ )
+ if code.startswith(RUN_NOT_OK_PREFIX):
+ return f"{hub_backfill_command(case_ref, run_id)} 뒤 {ingest}"
+ if code == FILTER_NO_MATCH:
+ return f"pnpm 워크스페이스에 @poc/eval-store 가 없다 — pnpm install 뒤 {ingest}"
+ return ingest
+
+
+#: 사용자 DB 쓰기 확인 안내(사람 셸) — 영속 `hub.confirm` 은 마스터 §4 규칙 9 의 의도된 예외다(L10 §2.6 · 검토 F1-3).
+CONFIRMATION_HINT_HUMAN = (
+ "사용자 DB 에 쓰려면 명령 앞에 SOLAR_EVAL_HUB_CONFIRM=local 을 붙이거나 설정 파일 hub.confirm: local"
+ "(사람 셸만 · URL 도 설정에 있을 때만 — 규칙 9 의 의도된 예외)"
+)
+
+
+#: `not_opted_in` 빨간 줄의 머리(사람·에이전트 공통).
+NOT_OPTED_IN_HEAD = "hub opt-in 없음 — 이 run 은 hub(/v1)에 없다."
+
+
+def not_opted_in_hint(case_ref: str) -> str:
+ """hub opt-in 이 없다 — 빨간 줄 안내(마스터 D-M2-18 · L10 §2.6 표 "둘 다 없음"). URL 값은 싣지 않는다.
+
+ 에이전트 세션(`optin.AGENT_SESSION_MARKERS`)은 설정 파일 `hub.database_url` 을 무시하므로(D-L10-6) "설정 파일로 켜라" 를
+ 말하지 않고 사람이 부를 명령만 준다 — 바로 앞 "설정 파일을 무시한다" 경고와 어긋나지 않게(리뷰 L10 지적 3,
+ `_confirmation_hint` 와 같은 갈래).
+ """
+ ingest = hub_ingest_command(case_ref)
+ if optin.is_agent_session():
+ return f"{NOT_OPTED_IN_HEAD} 에이전트 세션은 설정 파일 opt-in 을 따르지 않는다 — 사람이 직접: {ingest}"
+ return (
+ f"{NOT_OPTED_IN_HEAD} 켜려면 설정 파일 hub.database_url 또는 명령 앞 "
+ f"{URL_PLACEHOLDER}. 지금 싣기: {ingest}"
+ )
+
+
+def _confirmation_hint(ingest: str) -> str:
+ """hub 가 사용자 DB 쓰기 확인을 요구했다. 에이전트 세션(`optin.AGENT_SESSION_MARKERS`)은 확인을 무시하므로 사람이 부를 명령을 안내한다."""
+ if optin.is_agent_session():
+ return f"에이전트 세션은 사용자 DB 쓰기 확인을 넘기지 않는다 — 사람이 직접: {ingest}"
+ return CONFIRMATION_HINT_HUMAN
+
+
+def default_hint(leg_name: str, leg: LegResult, case_ref: str | None, run_id: str) -> str | None:
+ """다리가 hint 를 싣지 않았을 때 콘솔이 붙이는 안내."""
+ if leg.hint is not None or case_ref is None:
+ return leg.hint
+ if leg.status == STATUS_SKIPPED and leg_name == LEG_HUB and leg.code == NOT_OPTED_IN:
+ return not_opted_in_hint(case_ref)
+ if leg.status != STATUS_FAILED:
+ return None
+ if leg_name == LEG_HUB:
+ return hub_recovery(leg.code, case_ref, run_id)
+ if leg_name == LEG_LOCK and leg.code == LOCK_TIMEOUT:
+ return f"같은 case 의 다른 훅이 끝난 뒤 {hub_backfill_command(case_ref, run_id)} → {hub_ingest_command(case_ref)}"
+ return f"{hub_backfill_command(case_ref, run_id)} → {hub_ingest_command(case_ref)}"
+
+
+# ── 출력 ─────────────────────────────────────────────────────────────────
+
+
+def echo_no_ingest(project: str, run_id: str, case_ref: str | None) -> None:
+ """`--no-ingest` — hub 훅 전체를 건너뛴다는 한 줄(Q-M2-4). `project` 는 호출자 시그니처 유지용이다(L10 — 레거시 줄 삭제)."""
+ del project # 레거시 화면 수동 적재 줄이 L10 에서 사라졌다(계획 2026-09-28 L10 §2.6)
+ if case_ref is None:
+ click.secho("\nhub 훅 건너뜀(--no-ingest)", fg="yellow")
+ return
+ revive = f"{hub_backfill_command(case_ref, run_id)} → {hub_ingest_command(case_ref)}"
+ click.secho(f"\nhub 훅 건너뜀(--no-ingest) — 되살리기: {revive}", fg="yellow")
+
+
+def echo_skip(skip: Skip, project: str) -> None:
+ """hub 대상이 아닌 run — 한 줄(standalone 은 부르지 않는다)."""
+ reason = SKIP_REASONS.get(skip.code, skip.code)
+ paths = " · ".join(f"{k}={v}" for k, v in skip.detail.items())
+ suffix = f" ({paths})" if paths else ""
+ click.secho(f"\nhub 훅 건너뜀: {skip.code} — {reason}{suffix}", fg="yellow")
+
+
+def echo_smoke(case_ref: str) -> None:
+ """`--limit` 스모크 run — 한 줄(잠금·상태 파일 없음, D-L6-10 · 완료 정의 §0.4-2)."""
+ click.secho(
+ f"\nhub 훅 건너뜀: {SMOKE_RUN} — --limit 스모크 run 은 번들·hub 에 싣지 않는다 ({case_ref})",
+ fg="yellow",
+ )
+
+
+def elapsed_seconds(result: HookResult) -> int | None:
+ """훅 전체 경과 초(`started_at`→`finished_at`). 시각이 없거나 형식이 다르거나 음수면 None."""
+ if not result.started_at or not result.finished_at:
+ return None
+ try:
+ started = datetime.strptime(result.started_at, HOOK_TIME_FORMAT)
+ finished = datetime.strptime(result.finished_at, HOOK_TIME_FORMAT)
+ except ValueError:
+ return None
+ seconds = int((finished - started).total_seconds())
+ return seconds if seconds >= 0 else None
+
+
+def echo_summary(result: HookResult, project: str) -> None:
+ """다리마다 한 줄 + 실패면 되살리기(§2.7). 머리에 경과 초. 레거시 화면 줄은 L10 에서 없어졌다."""
+ elapsed = elapsed_seconds(result)
+ suffix = f" · 경과 {elapsed} s" if elapsed is not None else ""
+ click.secho(
+ f"\nExperiment Hub 훅 — {result.case or project} · {result.run_id}{suffix}", fg="blue", bold=True
+ )
+ for name, leg in result.legs.items():
+ _echo_leg(name, leg, result.case, result.run_id)
+
+
+def _echo_leg(name: str, leg: LegResult, case_ref: str | None, run_id: str) -> None:
+ label = f" {LEG_LABELS.get(name, name):<10}"
+ hint = default_hint(name, leg, case_ref, run_id)
+ if leg.status == STATUS_OK:
+ details = _ok_details(leg.detail)
+ click.secho(f"{label} ok{f' ({details})' if details else ''}", fg="green")
+ elif leg.status == STATUS_FAILED:
+ click.secho(f"{label} 실패 — {leg.code}", fg="red", bold=True)
+ if hint:
+ click.secho(f" {'':<10} 되살리기: {hint}", fg="red")
+ elif leg.status == STATUS_SKIPPED:
+ # hub opt-in 없음은 빨간 줄(D-M2-18 — 이 run 은 /v1 에 없다), 그 밖 건너뜀은 노랑
+ color = "red" if (name, leg.code) == (LEG_HUB, NOT_OPTED_IN) else "yellow"
+ click.secho(f"{label} 건너뜀 — {leg.code}{f'. {hint}' if hint else ''}", fg=color)
+ else:
+ click.secho(f"{label} 돌지 않음 — {leg.code}", fg="yellow")
+
+
+def _ok_details(detail: Mapping[str, Any]) -> str:
+ parts = []
+ for path, label in _BUNDLE_SUMMARY_KEYS:
+ value: Any = detail
+ for key in path:
+ value = value.get(key) if isinstance(value, Mapping) else None
+ if isinstance(value, str):
+ parts.append(f"{label} {value}")
+ return " · ".join(parts)
+
+
+def echo_batch_hub(leg: LegResult, case_ref: str, run_ids: Iterable[str]) -> None:
+ """batch 끝 hub 다리 한 줄(D-L6-12)."""
+ ids = list(run_ids)
+ _echo_leg(LEG_HUB, leg, case_ref, ids[0] if len(ids) == 1 else "")
+
+
+def echo_batch_summary(results: Iterable[HookResult | None]) -> None:
+ """batch 끝 요약 "훅: ok N · 실패 M · 건너뜀 K"(건너뜀 = 훅이 돌 다리가 없었던 run)."""
+ ok = failed = skipped = 0
+ for result in results:
+ if result is not None and not result.ok:
+ failed += 1
+ elif result is not None and any(leg.status == STATUS_OK for leg in result.legs.values()):
+ ok += 1
+ else:
+ skipped += 1
+ color = "red" if failed else "green"
+ click.secho(f"훅: ok {ok} · 실패 {failed} · 건너뜀 {skipped}", fg=color)
+
+
+def echo_warning(message: str) -> None:
+ click.secho(f" ⚠ {message}", fg="yellow", err=True)
+
+
+def echo_hook_crashed(code: str) -> None:
+ """훅 바깥 겹이 예외를 잡았다 — run 결과는 그대로다(D-L6-2)."""
+ click.secho(f"\nExperiment Hub 훅이 예기치 않게 멈췄다 — {code} (run 결과는 디스크에 있다)", fg="red")
+
+
+def echo_status_write_failed(code: str) -> None:
+ echo_warning(f"훅 상태 파일을 쓰지 못했다 — {code}")
diff --git a/solar_eval/hub_hook/hook.py b/solar_eval/hub_hook/hook.py
new file mode 100644
index 0000000000000000000000000000000000000000..58c658fd72e49dea5d201588e59f1194aaaf3758
--- /dev/null
+++ b/solar_eval/hub_hook/hook.py
@@ -0,0 +1,271 @@
+"""run 종료 훅 오케스트레이터 (계획 2026-09-27 L6 §2.1 · D-L6-2 · D-L6-3 · D-L6-11 · D-L6-12).
+
+순서 = **번들 → hub**(레거시 적재는 부르지 않는다, D-M2-4 (b)). hub 대상 run 은 case 잠금 **안**에서 두 다리를 돌고 상태 파일을 쓴
+뒤 잠금 밖에서 콘솔 요약을 낸다.
+
+**예외 봉쇄(D-L6-2 · 검토 F1).** 다리 호출마다 `except Exception` → `failed:_exception:`(타입 이름만). 상태 파일 쓰기
+실패는 콘솔 경고로 강등하고, 훅 전체도 한 겹 더 감싸 run 명령이 훅 때문에 종료 1·트레이스백이 되지 않게 한다. batch 에서
+훅 예외가 프롬프트 실패로 세지지도 않는다. `BaseException`(KeyboardInterrupt·pytest tripwire)은 잡지 않는다.
+
+**예외 상세는 로그로만(리뷰 지적 7).** 콘솔·상태 파일에는 타입 이름만 싣고(D-L6-2), 트레이스백은 이 모듈 로거에 남긴다 —
+기본은 DEBUG(보통 설정에서는 보이지 않는다), `SOLAR_EVAL_HOOK_DEBUG=1` 이면 WARNING(stderr). 트레이스백은 입력 조각을 담을 수
+있으니 현장 진단 때만 켠다.
+
+**스모크 run 은 잠그지 않는다(D-L6-3 "돌 다리가 없으면 잠그지 않는다" · D-L6-10).** hub 대상이어도 `--limit` 스모크면 잠금·상태
+파일 없이 두 다리 `skipped:smoke_run` 한 줄로 끝난다 — 다른 훅이 잠금을 쥐고 있어도 기다리거나 `lock_timeout` 이 되지 않는다.
+
+종료 코드는 호출자가 `exit_code_for(result, strict)` 로 정한다 — 기본 0, strict 이고 실패 다리가 있으면 3.
+
+**opt-in 한 곳(계획 2026-09-28 L10 §2.6 · D-L10-6).** `gated_hub_leg` 가 `optin.resolve_hub_optin(config=t.hub_config)` 로
+환경 변수 > 설정 파일 `hub:` 절을 푼다. 설정 출처면 URL 은 자식 환경(`child_env`)으로만 가고, 설정 값은 진단 가림·상태 파일
+거부 목록(`optin.config_secrets`)에 더한다. 호출자(`runs start`·`resume`·batch 끝·`runs eval` 발행) 시그니처는 그대로다.
+"""
+
+from __future__ import annotations
+
+import logging
+from collections.abc import Callable, Sequence
+from contextlib import ExitStack
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from solar_eval.hub_hook import bundle_leg, console, hub_leg, lock, optin, status, target
+from solar_eval.hub_hook.types import (
+ BATCH_PENDING,
+ EXIT_HOOK_FAILED,
+ HOOK_LOCK_TIMEOUT_SECONDS,
+ LEG_BUNDLE,
+ LEG_HOOK,
+ LEG_HUB,
+ LEG_LOCK,
+ LEG_STATUS_FILE,
+ LOCK_TIMEOUT,
+ NO_BUNDLE,
+ NO_WORKSPACE,
+ NOT_OPTED_IN,
+ SKIP_STANDALONE,
+ SMOKE_RUN,
+ HookMode,
+ HookOptions,
+ HookResult,
+ LegResult,
+ Skip,
+ Target,
+ bundle_ready,
+ exception_code,
+ hub_run_id,
+)
+
+if TYPE_CHECKING:
+ from solar_eval.cli.config import CLIConfig
+
+_LOG = logging.getLogger(__name__)
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def _contained_code(leg: str, exc: Exception) -> str:
+ """봉쇄한 예외 → 코드(타입 이름만, D-L6-2). 트레이스백은 로거로만 — 콘솔·상태 파일에는 싣지 않는다."""
+ code = exception_code(leg, exc)
+ level = logging.WARNING if optin.hook_debug_requested() else logging.DEBUG
+ _LOG.log(level, "Experiment Hub 훅 예외 봉쇄 — %s", code, exc_info=exc)
+ return code
+
+
+def _contained(leg: str, fn: Callable[..., LegResult], *args, **kwargs) -> LegResult:
+ """다리 하나를 돌리고 예기치 않은 예외를 `_exception:` 실패로 강등한다(D-L6-2)."""
+ try:
+ return fn(*args, **kwargs)
+ except Exception as exc: # 다리 경계 — 어떤 예외도 run 명령을 실패로 만들지 않는다
+ return LegResult.failed(_contained_code(leg, exc))
+
+
+def exit_code_for(result: HookResult | None, strict: bool) -> int:
+ """훅 결과 → run 명령 종료 코드(D-L6-11). 기본 0, strict 이고 실패 다리가 있으면 3."""
+ return EXIT_HOOK_FAILED if strict and result is not None and not result.ok else 0
+
+
+def run_hook(
+ cfg: CLIConfig, project: str, run_dir: Path, options: HookOptions
+) -> HookResult | None:
+ """run 하나가 끝난 뒤의 훅. hub 대상이 아니면(standalone·`--no-ingest`) None 또는 건너뜀 결과."""
+ run_id = Path(run_dir).name
+ try:
+ return _run_hook(cfg, project, Path(run_dir), run_id, options)
+ except Exception as exc: # 훅 바깥 겹(D-L6-2) — 콘솔에 알리고 결과로 돌려준다
+ code = _contained_code(LEG_HOOK, exc)
+ console.echo_hook_crashed(code)
+ return HookResult(run_id, None, {LEG_HOOK: LegResult.failed(code)})
+
+
+def _run_hook(
+ cfg: CLIConfig, project: str, run_dir: Path, run_id: str, options: HookOptions
+) -> HookResult | None:
+ if options.no_ingest:
+ if cfg.repo_root is not None:
+ console.echo_no_ingest(project, run_id, target.case_ref_hint(cfg, project))
+ return None
+ resolved = target.resolve_target(cfg, project)
+ if isinstance(resolved, Skip):
+ if resolved.code == SKIP_STANDALONE:
+ return None
+ console.echo_skip(resolved, project)
+ legs = {
+ LEG_BUNDLE: LegResult.skipped(resolved.code),
+ LEG_HUB: LegResult.skipped(resolved.code),
+ }
+ return HookResult(run_id, None, legs, _now(), _now())
+ if options.smoke:
+ # 돌 다리가 없다 — 잠그지 않고 상태 파일도 쓰지 않는다(D-L6-3 · D-L6-10, 리뷰 지적 1).
+ console.echo_smoke(resolved.case_ref)
+ smoke = {LEG_BUNDLE: LegResult.skipped(SMOKE_RUN), LEG_HUB: LegResult.skipped(SMOKE_RUN)}
+ return HookResult(run_id, resolved.case_ref, smoke, _now(), _now())
+ result = _run_target(resolved, run_dir, run_id, options)
+ console.echo_summary(result, project)
+ return result
+
+
+def _run_target(t: Target, run_dir: Path, run_id: str, options: HookOptions) -> HookResult:
+ """case 잠금 안에서 두 다리 → 상태 파일(D-L6-3). 잠금 한도를 넘기면 두 다리 모두 not_run."""
+ started = _now()
+ with ExitStack() as stack:
+ try:
+ held = stack.enter_context(
+ lock.case_lock(t.account, t.case, timeout=HOOK_LOCK_TIMEOUT_SECONDS)
+ )
+ except Exception as exc: # 잠금 파일을 만들 수 없다 — 다리를 돌리지 않는다
+ legs = _lock_failed(_contained_code(LEG_LOCK, exc))
+ else:
+ legs = (
+ _legs_in_lock(t, run_dir, run_id, options) if held else _lock_failed(LOCK_TIMEOUT)
+ )
+ result = HookResult(run_id, t.case_ref, legs, started, _now())
+ _write_status(t, result, options)
+ return result
+
+
+def _lock_failed(code: str) -> dict[str, LegResult]:
+ return {
+ LEG_LOCK: LegResult.failed(code),
+ LEG_BUNDLE: LegResult.not_run(),
+ LEG_HUB: LegResult.not_run(),
+ }
+
+
+def _legs_in_lock(
+ t: Target, run_dir: Path, run_id: str, options: HookOptions
+) -> dict[str, LegResult]:
+ bundle = _contained(LEG_BUNDLE, bundle_leg.run_bundle_leg, t, run_dir, run_id)
+ if options.mode is HookMode.BATCH_ITEM:
+ hub = LegResult.not_run(BATCH_PENDING)
+ elif not bundle_ready(bundle):
+ hub = LegResult.skipped(NO_BUNDLE)
+ else:
+ # `--expect-run` 은 번들의 run_id(run.json) — 디렉토리 이름과 다를 수 있다(리뷰 지적 3). 상태 파일 이름은 디렉토리 이름.
+ hub = gated_hub_leg(t, (hub_run_id(bundle, run_id),))
+ return {LEG_BUNDLE: bundle, LEG_HUB: hub}
+
+
+def gated_hub_leg(t: Target, run_ids: Sequence[str]) -> LegResult:
+ """hub 다리 게이트 한 곳 — opt-in(D-L6-4 · L10 D-L10-6)·워크스페이스(C-L6-9)를 본 뒤 `hub_leg.run_hub_leg`(예외 봉쇄).
+
+ 호출자가 case 잠금을 쥐고 번들 유무를 이미 봤다고 가정한다. L6b `runs eval` 새 경로 ④ 도 이 함수를 부른다.
+ opt-in 해석 경고(설정 모양 오류·에이전트 세션에서 설정 무시)는 건너뛰기 전에 먼저 찍는다 — 값은 싣지 않는다.
+ """
+ resolved = optin.resolve_hub_optin(config=t.hub_config)
+ for warning in resolved.warnings:
+ console.echo_warning(warning)
+ if not resolved.opted_in:
+ return LegResult.skipped(NOT_OPTED_IN)
+ if target.workspace_root() is None:
+ return LegResult.skipped(NO_WORKSPACE)
+ if resolved.confirm.warning is not None:
+ console.echo_warning(resolved.confirm.warning)
+ return _contained(
+ LEG_HUB,
+ hub_leg.run_hub_leg,
+ t,
+ tuple(run_ids),
+ confirm_local=resolved.confirm.confirm_local,
+ child_env=resolved.child_env,
+ secrets=resolved.secrets,
+ )
+
+
+def status_secrets(t: Target) -> tuple[str, ...]:
+ """상태 파일 거부 목록에 더할 설정 출처 비밀(§2.6 "비밀") — `runs eval` 발행도 같은 함수를 쓴다."""
+ return optin.config_secrets(t.hub_config)
+
+
+def _write_status(t: Target, result: HookResult, options: HookOptions) -> None:
+ try:
+ status.write_status(
+ t, result, options.command, strict=options.strict, secrets=status_secrets(t)
+ )
+ except Exception as exc: # 상태 파일은 보조 기록 — 쓰기 실패는 경고로 강등(D-L6-2)
+ console.echo_status_write_failed(_contained_code(LEG_STATUS_FILE, exc))
+
+
+def finish_batch(
+ cfg: CLIConfig,
+ project: str,
+ results: Sequence[HookResult | None],
+ options: HookOptions,
+) -> LegResult | None:
+ """batch 끝 hub 다리 한 번(D-L6-12) — 번들이 생긴 run 들을 `--expect-run` 으로, case 잠금 안에서.
+
+ 끝나면 run 마다 상태 파일의 `hub` 를 `status.update_legs` 로 다시 쓴다 — 번들이 없어 끝 다리에 들지 못한 run 은
+ `skipped:no_bundle` 로(상태 파일에 `batch_pending` 이 남지 않게). 끝 다리를 돌지 않았으면 None.
+ """
+ try:
+ return _finish_batch(cfg, project, results, options)
+ except Exception as exc: # 훅 바깥 겹(D-L6-2)
+ code = _contained_code(LEG_HOOK, exc)
+ console.echo_hook_crashed(code)
+ return LegResult.failed(code)
+
+
+def _finish_batch(
+ cfg: CLIConfig,
+ project: str,
+ results: Sequence[HookResult | None],
+ options: HookOptions,
+) -> LegResult | None:
+ targeted = [r for r in results if r is not None and r.case is not None]
+ ready = [r for r in targeted if bundle_ready(r.legs.get(LEG_BUNDLE))]
+ # 번들이 없어 끝 다리에 들지 못한 run — 상태 파일의 `batch_pending` 을 `no_bundle` 로 닫는다(l6a-verify).
+ no_bundle = [r.run_id for r in targeted if r not in ready and _hub_pending(r)]
+ if options.no_ingest or not (ready or no_bundle):
+ return None
+ resolved = target.resolve_target(cfg, project)
+ if isinstance(resolved, Skip):
+ return None
+ # `--expect-run` 은 번들의 run_id(run.json), 상태 파일은 디렉토리 이름(리뷰 지적 3).
+ expect = [hub_run_id(r.legs.get(LEG_BUNDLE), r.run_id) for r in ready]
+ leg: LegResult | None = None
+ with lock.case_lock(resolved.account, resolved.case, timeout=HOOK_LOCK_TIMEOUT_SECONDS) as held:
+ if ready:
+ leg = gated_hub_leg(resolved, expect) if held else LegResult.failed(LOCK_TIMEOUT)
+ for result in ready:
+ _update_hub_leg(resolved, result.run_id, leg)
+ for run_id in no_bundle:
+ _update_hub_leg(resolved, run_id, LegResult.skipped(NO_BUNDLE))
+ if leg is not None:
+ console.echo_batch_hub(leg, resolved.case_ref, expect)
+ return leg
+
+
+def _hub_pending(result: HookResult) -> bool:
+ """batch 한 run 의 hub 다리가 끝 다리를 기다리는 중인가(`not_run:batch_pending`)."""
+ hub = result.legs.get(LEG_HUB)
+ return hub is not None and hub.code == BATCH_PENDING
+
+
+def _update_hub_leg(t: Target, run_id: str, leg: LegResult) -> None:
+ try:
+ status.update_legs(t, run_id, {LEG_HUB: leg}, secrets=status_secrets(t))
+ except Exception as exc: # 상태 파일 보조 기록 — 경고로 강등(D-L6-2)
+ console.echo_status_write_failed(_contained_code(LEG_STATUS_FILE, exc))
diff --git a/solar_eval/hub_hook/hub_leg.py b/solar_eval/hub_hook/hub_leg.py
new file mode 100644
index 0000000000000000000000000000000000000000..e46701f57b0972a8fab9d539a79d3ca35e3be0e9
--- /dev/null
+++ b/solar_eval/hub_hook/hub_leg.py
@@ -0,0 +1,330 @@
+"""hub 다리 (계획 2026-09-27 L6 §2.6 · D-L6-4 · D-L6-5 · C-L6-1 · C-L6-3).
+
+- argv(`build_hub_argv`) = `["pnpm", "--silent", "--fail-if-no-match", "--filter", "@poc/eval-store", "hub", "ingest",
+ "--case", a/c, *("--expect-run", r …), "--repo", resolve(target.repo_root), "--mirror", blob_mirror_root()/<버킷>,
+ "--index", hub_index_root(), *(["--i-understand-local-data"] if confirm_local)]`. URL 은 argv 에 싣지 않는다 — 출처가
+ 환경 변수면 부모 환경 그대로(`env=None`), 설정 파일 `hub.database_url` 이면 `child_env`(부모 환경 + `HUB_DATABASE_URL`)로
+ **자식 환경으로만** 간다(계획 2026-09-28 L10 §2.6). 적재 하나뿐이다 — 이 모듈은 다른 hub 하위 명령을 짓지 않는다(`proc` 허용
+ 목록이 한 번 더 막는다).
+- 실행은 **`proc.run_pnpm`(모듈 속성으로)** 만 — `cwd=workspace_root()`(코드 체크아웃, C-L6-9), `timeout=HUB_INGEST_TIMEOUT_SECONDS`.
+ `FileNotFoundError` → `pnpm_missing`, `TimeoutExpired` → `timeout`.
+- 해석(`judge_hub`, 순수 — §2.6 표): 0 → ok(stdout JSON `expect.ok`, `ingest_id`) · 1 → `hub_)">`(필터 불일치면
+ `filter_no_match`) · 2 → `case_blocked:`·`case_failed:` · 3 → `schema_mismatch`(재구성하지 않는다, C-M2-3) ·
+ 4 → `run_not_ok:`(요청 순서로 처음 ok 가 아닌 run) · 그 밖 → `hub_exit_` · JSON 이 깨졌거나 모양이 다르면
+ `hub_output_unreadable`.
+- detail 에는 **코드·id 만** 싣는다(`ingest_id`·run 별 상태). hub 출력 원문(stderr 문구·요약의 다른 키)은 옮기지 않는다 —
+ URL 이나 입력 조각이 섞여 있을 수 있다(D-L6-4). 외부 출력에서 뽑은 토큰은 `_TOKEN`(실패 stage 는 `_STAGE`) 문법으로 검사한다.
+- 다리가 실패하면(시간 초과 포함) hub CLI stderr 의 마지막 `[hub]` 줄 몇 개를 **콘솔(stderr)에만** 옮긴다 — 비밀을 가리고
+ 길이를 자른 뒤(`diagnostic_lines`). 코드만으로는 무엇이 잘못됐는지 알 수 없어서다(리뷰 지적 6). 상태 파일에는 싣지 않는다.
+ 가림은 환경 변수 값에 더해 설정 출처 값(`secrets` — `optin.HubOptIn.secrets`)도 본다.
+- opt-in(`HUB_DATABASE_URL`·설정 `hub:` 절)·확인·번들 유무 게이트는 여기가 아니라 `hook.gated_hub_leg` 한 곳이다.
+
+`poc_bundle` 은 함수 안에서만 import 한다(D-L6-14).
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import subprocess
+from collections.abc import Mapping, Sequence
+from pathlib import Path
+from typing import Any
+
+import click
+
+from solar_eval.hub_hook import console, proc, status, target
+from solar_eval.hub_hook.optin import CONFIRM_FLAG
+from solar_eval.hub_hook.types import (
+ CASE_BLOCKED_PREFIX,
+ CASE_FAILED_PREFIX,
+ EXPECT_MISSING,
+ EXPECT_NO_USABLE_BUNDLE,
+ EXPECT_OK,
+ EXPECT_ORPHANED,
+ FILTER_NO_MATCH,
+ HUB_CASE_BLOCKED,
+ HUB_CASE_FAILED,
+ HUB_EXIT_CASE,
+ HUB_EXIT_EXPECT,
+ HUB_EXIT_OK,
+ HUB_EXIT_PREFIX,
+ HUB_EXIT_REFUSED,
+ HUB_EXIT_SCHEMA,
+ HUB_FAILED_PREFIX,
+ HUB_INGEST_TIMEOUT_SECONDS,
+ HUB_OUTPUT_UNREADABLE,
+ HUB_PACKAGE_FILTER,
+ NO_WORKSPACE,
+ PNPM_MISSING,
+ PNPM_NO_MATCH,
+ RUN_NOT_OK_PREFIX,
+ SCHEMA_MISMATCH,
+ STATUS_FAILED,
+ TIMEOUT,
+ LegResult,
+ Target,
+)
+
+#: pnpm 전역 옵션 — 출력은 스크립트 것만, 필터 불일치는 종료 1(pnpm 10.30.1 사실, §2.6).
+PNPM_PREFIX = ("pnpm", "--silent", "--fail-if-no-match", "--filter", HUB_PACKAGE_FILTER)
+HUB_INGEST = ("hub", "ingest")
+#: hub CLI stderr 오류 줄 `[hub] 오류(): …` 의 코드.
+_HUB_ERROR_LINE = re.compile(r"^\[hub\] 오류\(([a-z][a-z0-9_]{0,63})\)", re.MULTILINE)
+#: 외부 출력에서 뽑아 코드에 싣는 토큰(case 차단 코드·ingest_id)의 문법.
+_TOKEN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}")
+#: 실패 stage — ES `orchestrate.ts` 가 내는 단계 이름(`datasets`·`run-bundles`·`digest`·`case_lock`·`unknown`)과
+#: preflight 실패의 `preflight:<단계>`(리뷰 지적 5). `:` 은 한 번만 받는다.
+_STAGE = re.compile(r"[a-z][a-z0-9_-]{0,63}(?::[a-z][a-z0-9_-]{0,63})?")
+#: 실패한 hub 다리에서 콘솔(stderr)에 옮기는 hub CLI stderr 꼬리 — 줄 수와 한 줄 길이 상한(리뷰 지적 6).
+HUB_DIAG_MAX_LINES = 5
+HUB_DIAG_MAX_CHARS = 300
+_HUB_LOG_PREFIX = "[hub]"
+#: `--expect-run` 값 — 옵션으로 오인되지 않게 `-` 로 시작하지 않고 제어 문자가 없다.
+_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]")
+EXPECT_STATES = frozenset({EXPECT_OK, EXPECT_MISSING, EXPECT_NO_USABLE_BUNDLE, EXPECT_ORPHANED})
+
+
+class _Unreadable(ValueError):
+ """hub 출력이 기대한 모양이 아니다 — `hub_output_unreadable` 로 바뀐다(원문은 싣지 않는다)."""
+
+
+# ── argv(순수) ───────────────────────────────────────────────────────────
+
+
+def build_hub_argv(
+ target: Target,
+ run_ids: Sequence[str],
+ *,
+ confirm_local: bool,
+ env: Mapping[str, str] | None = None,
+) -> list[str]:
+ """hub 적재 argv(순수 — 경로는 `poc_bundle.home` 해석값). run_id 가 비었거나 옵션 모양이면 `ValueError`."""
+ from poc_bundle.home import blob_mirror_root, hub_index_root
+ from poc_bundle.mirror import DEFAULT_BUCKET
+
+ expect: list[str] = []
+ for run_id in _checked_run_ids(run_ids):
+ expect += ["--expect-run", run_id]
+ return [
+ *PNPM_PREFIX,
+ *HUB_INGEST,
+ "--case",
+ target.case_ref,
+ *expect,
+ "--repo",
+ # 절대 경로로 — 상대면 자식(pnpm 스크립트 cwd = ES 패키지 디렉토리)이 다른 곳으로 푼다(D-L6-5, 리뷰 지적 2).
+ str(Path(target.repo_root).expanduser().resolve()),
+ "--mirror",
+ str(blob_mirror_root(env) / DEFAULT_BUCKET),
+ "--index",
+ str(hub_index_root(env)),
+ *([CONFIRM_FLAG] if confirm_local else []),
+ ]
+
+
+def _checked_run_ids(run_ids: Sequence[str]) -> list[str]:
+ ids = list(run_ids)
+ if not ids:
+ raise ValueError("hub leg needs at least one run_id")
+ for run_id in ids:
+ if not isinstance(run_id, str) or not run_id or run_id.startswith("-"):
+ raise ValueError("hub leg run_id must be a non-empty string not starting with '-'")
+ if _CONTROL.search(run_id):
+ raise ValueError("hub leg run_id has a control character")
+ return ids
+
+
+# ── 판정(순수, §2.6 표) ──────────────────────────────────────────────────
+
+
+def judge_hub(result: subprocess.CompletedProcess[str], run_ids: Sequence[str]) -> LegResult:
+ """hub CLI 종료 코드·출력 → `LegResult`. 출력 원문은 싣지 않는다."""
+ code = result.returncode
+ stdout, stderr = result.stdout or "", result.stderr or ""
+ if code != HUB_EXIT_OK and PNPM_NO_MATCH in f"{stdout}\n{stderr}":
+ return LegResult.failed(FILTER_NO_MATCH)
+ try:
+ if code == HUB_EXIT_OK:
+ return _judge_ok(stdout, run_ids)
+ if code == HUB_EXIT_REFUSED:
+ return LegResult.failed(_refusal_code(stderr))
+ if code == HUB_EXIT_CASE:
+ return _judge_case(stdout, run_ids)
+ if code == HUB_EXIT_SCHEMA:
+ return LegResult.failed(SCHEMA_MISMATCH)
+ if code == HUB_EXIT_EXPECT:
+ return _judge_expect_failed(stdout, run_ids)
+ except _Unreadable:
+ return LegResult.failed(HUB_OUTPUT_UNREADABLE)
+ return LegResult.failed(f"{HUB_EXIT_PREFIX}{code}")
+
+
+def _judge_ok(stdout: str, run_ids: Sequence[str]) -> LegResult:
+ doc = _summary(stdout)
+ states = _expect_states(doc, run_ids)
+ if any(state != EXPECT_OK for state in states.values()):
+ raise _Unreadable("exit 0 with a run that is not ok")
+ return LegResult.ok({"ingest_id": _token(doc.get("ingest_id")), "expect": states})
+
+
+def _refusal_code(stderr: str) -> str:
+ match = _HUB_ERROR_LINE.search(stderr)
+ return (
+ f"{HUB_FAILED_PREFIX}{match.group(1)}" if match else f"{HUB_EXIT_PREFIX}{HUB_EXIT_REFUSED}"
+ )
+
+
+def _judge_case(stdout: str, run_ids: Sequence[str]) -> LegResult:
+ doc = _summary(stdout)
+ cases = doc.get("cases")
+ if not isinstance(cases, Mapping) or len(cases) != 1:
+ raise _Unreadable("exit 2 needs exactly one case summary")
+ [case] = cases.values()
+ if not isinstance(case, Mapping):
+ raise _Unreadable("case summary is not an object")
+ detail = {"expect": _expect_states(doc, run_ids)} if "expect" in doc else {}
+ blocked, failed = case.get("blocked"), case.get("failed")
+ if case.get("status") == HUB_CASE_BLOCKED and isinstance(blocked, Mapping):
+ code = f"{CASE_BLOCKED_PREFIX}{_token(blocked.get('code'))}"
+ elif case.get("status") == HUB_CASE_FAILED and isinstance(failed, Mapping):
+ code = f"{CASE_FAILED_PREFIX}{_token(failed.get('stage'), _STAGE)}"
+ else:
+ raise _Unreadable("exit 2 without a blocked or failed case")
+ return LegResult.failed(code, detail=detail)
+
+
+def _judge_expect_failed(stdout: str, run_ids: Sequence[str]) -> LegResult:
+ states = _expect_states(_summary(stdout), run_ids)
+ bad = next((state for state in states.values() if state != EXPECT_OK), None)
+ if bad is None:
+ raise _Unreadable("exit 4 with every run ok")
+ return LegResult.failed(f"{RUN_NOT_OK_PREFIX}{bad}", detail={"expect": states})
+
+
+def _summary(stdout: str) -> Mapping[str, Any]:
+ """stdout 의 요약 JSON. 앞에 다른 줄이 섞였으면 `{` 로 시작하는 첫 줄부터 읽는다."""
+ lines = stdout.splitlines(keepends=True)
+ start = next((i for i, line in enumerate(lines) if line.startswith("{")), None)
+ if start is None:
+ raise _Unreadable("no JSON object on stdout")
+ try:
+ doc = json.loads("".join(lines[start:]))
+ except json.JSONDecodeError as exc:
+ raise _Unreadable("summary is not JSON") from exc
+ if not isinstance(doc, Mapping):
+ raise _Unreadable("summary is not an object")
+ return doc
+
+
+def _expect_states(doc: Mapping[str, Any], run_ids: Sequence[str]) -> dict[str, str]:
+ """요청 순서대로 run → 상태. 요청한 run 이 없거나 모르는 상태면 읽을 수 없다."""
+ expect = doc.get("expect")
+ runs = expect.get("runs") if isinstance(expect, Mapping) else None
+ if not isinstance(runs, Mapping):
+ raise _Unreadable("summary has no expect block")
+ states: dict[str, str] = {}
+ for run_id in run_ids:
+ entry = runs.get(run_id)
+ state = entry.get("state") if isinstance(entry, Mapping) else None
+ if state not in EXPECT_STATES:
+ raise _Unreadable("expect block misses a requested run")
+ states[run_id] = state
+ return states
+
+
+def _token(value: Any, grammar: re.Pattern[str] = _TOKEN) -> str:
+ if not isinstance(value, str) or grammar.fullmatch(value) is None:
+ raise _Unreadable("summary token has an unexpected shape")
+ return value
+
+
+# ── 실행 ─────────────────────────────────────────────────────────────────
+
+
+def run_hub_leg(
+ target: Target,
+ run_ids: Sequence[str],
+ *,
+ confirm_local: bool,
+ child_env: Mapping[str, str] | None = None,
+ secrets: Sequence[str] = (),
+) -> LegResult:
+ """`hub ingest --case a/c --expect-run …` 한 번. 예외는 hook 이 `hub_exception:` 으로 강등한다.
+
+ `child_env` 는 설정 출처 opt-in 일 때만(부모 환경 + URL), 아니면 None(부모 환경 그대로). `secrets` 는 진단 가림 목록.
+ """
+ ids = _checked_run_ids(run_ids)
+ argv = build_hub_argv(target, ids, confirm_local=confirm_local, env=child_env)
+ cwd = _workspace()
+ if cwd is None:
+ return LegResult.skipped(NO_WORKSPACE)
+ try:
+ result = proc.run_pnpm(argv, cwd=cwd, env=child_env, timeout=HUB_INGEST_TIMEOUT_SECONDS)
+ except FileNotFoundError:
+ return LegResult.failed(PNPM_MISSING)
+ except subprocess.TimeoutExpired as exc:
+ _echo_diagnostics(exc.stderr, secrets)
+ return LegResult.failed(TIMEOUT)
+ leg = _with_run_hint(judge_hub(result, ids), target)
+ if leg.status == STATUS_FAILED:
+ _echo_diagnostics(result.stderr, secrets)
+ return leg
+
+
+def diagnostic_lines(stderr: str | bytes | None, secrets: Sequence[str] = ()) -> list[str]:
+ """hub CLI stderr → 콘솔에 옮길 꼬리(리뷰 지적 6). `[hub]` 줄이 있으면 그 마지막 몇 줄, 없으면 비지 않은 마지막 몇 줄.
+
+ 줄마다 비밀을 가린 뒤(`status.redact_secrets` — 자르기 전에 가려야 URL 이 중간에 잘려 비밀번호가 새지 않는다) 길이를 자른다.
+ `secrets` 는 환경 변수 밖의 비밀(설정 출처 URL 값). 상태 파일·detail 에는 싣지 않는다(D-L6-4 — 코드만).
+ """
+ if isinstance(stderr, bytes):
+ stderr = stderr.decode("utf-8", errors="replace")
+ lines = [line.rstrip() for line in (stderr or "").splitlines() if line.strip()]
+ hub_lines = [line for line in lines if line.startswith(_HUB_LOG_PREFIX)]
+ tail = (hub_lines or lines)[-HUB_DIAG_MAX_LINES:]
+ return [_clip(status.redact_secrets(line, secrets)) for line in tail]
+
+
+def _clip(line: str) -> str:
+ return line if len(line) <= HUB_DIAG_MAX_CHARS else f"{line[:HUB_DIAG_MAX_CHARS]}…"
+
+
+def _echo_diagnostics(stderr: str | bytes | None, secrets: Sequence[str] = ()) -> None:
+ lines = diagnostic_lines(stderr, secrets)
+ if not lines:
+ return
+ click.secho(f" hub CLI stderr 마지막 {len(lines)}줄(비밀은 가림):", fg="red", err=True)
+ for line in lines:
+ click.secho(f" {line}", fg="red", err=True)
+
+
+def _workspace():
+ # 모듈 속성으로 부른다 — 시험이 `target.workspace_root` 를 바꿀 수 있게.
+ return target.workspace_root()
+
+
+def _with_run_hint(leg: LegResult, t: Target) -> LegResult:
+ """`run_not_ok` 면 되살리기에 ok 가 아닌 첫 run 을 적는다(batch 에서 콘솔이 `` 으로 쓰지 않게)."""
+ if not leg.code.startswith(RUN_NOT_OK_PREFIX):
+ return leg
+ states = leg.detail.get("expect") or {}
+ bad = next((run for run, state in states.items() if state != EXPECT_OK), None)
+ if bad is None:
+ return leg
+ hint = console.hub_recovery(leg.code, t.case_ref, bad)
+ return LegResult.failed(leg.code, hint, leg.detail)
+
+
+def ingest_runs(
+ target: Target,
+ run_ids: Sequence[str],
+ *,
+ confirm_local: bool,
+ child_env: Mapping[str, str] | None = None,
+ secrets: Sequence[str] = (),
+) -> LegResult:
+ """L6b `runs eval` 새 경로 ④ 가 부르는 적재 — 판정·실행은 `run_hub_leg` 와 같다(게이트는 `hook.gated_hub_leg`)."""
+ return run_hub_leg(
+ target, run_ids, confirm_local=confirm_local, child_env=child_env, secrets=secrets
+ )
diff --git a/solar_eval/hub_hook/indexes.py b/solar_eval/hub_hook/indexes.py
new file mode 100644
index 0000000000000000000000000000000000000000..54e1e36502deec23b37cb5f190f27d13f0c07f37
--- /dev/null
+++ b/solar_eval/hub_hook/indexes.py
@@ -0,0 +1,355 @@
+"""데이터셋·TagSet·정책 색인 갱신 (계획 2026-09-27 L6 §2.5 · D-L6-7 · 검토 F2·F9).
+
+**키.** `sha256(JCS([lock_sha, template_sha, text_mode, INDEX_EXTRACTOR, index_code_sha]))` — `lock_sha` = lock 파일 바이트 sha256
+(없으면 null), `template_sha` = 정책 템플릿 바이트 sha256(없으면 null), `text_mode` = `optin.text_mode()`, `INDEX_EXTRACTOR` =
+`poc_bundle.index.row_tags.EXTRACTOR`, `index_code_sha` = **`poc_bundle` 패키지 전체**의 `*.py` (상대 경로, 길이, 바이트)를
+경로순으로 이은 sha256. 색인 출력은 `index/` 밖 모듈(`keys`·`scoring.policy`·`scoring.ids`·`snapshot`·`lock_match`·`canonical` …)
+에도 달려 있어 `index/*.py` 만 보면 그쪽 변경을 놓친다(리뷰 지적 3) — 넓게 잡아 PB 가 바뀌면 한 번 더 만드는 쪽을 택했다
+(`poc_bundle.__version__` 은 고정값이라 코드 변화를 못 잡는다). 키 파일은 `///hook/index-key.json`.
+
+**판정(순서대로).**
+
+1. lock 이 없으면(`load_existing_lock(layout) is None`) 세 명령 모두 건너뛴다 → `skipped:no_lock`(실패 아님, D-L6-11).
+ click 예외 문구는 파싱하지 않는다 — CLI 를 부르기 **전에** 가른다. 레이아웃·lock 을 읽지 못하면(깨진 lock 등) 가르지 않고
+ CLI 를 불러 그쪽이 실패를 보고하게 둔다.
+2. 정책 템플릿이 없으면 `tags`·`policy` 는 건너뛰고 `datasets` 만 — 결과 `written:datasets_only`.
+3. 키가 같고 색인 루트의 그 case 디렉토리(돌 명령의 것 — `datasets/`, 템플릿이 있으면 `tagsets/`·`policies/` 도)가 모두 있으면
+ `unchanged`. 아니면 **옛 키를 먼저 지우고** 명령을 차례로 부른 뒤, 건너뛰지 않은 명령이 모두 성공했을 때만 새 키를 쓴다
+ (원자적 쓰기). 옛 키를 남겨 두면 명령이 중간에 실패했을 때(datasets 는 새 입력으로 다시 쓰고 옛 파일을 지웠는데 tags 가
+ 실패) 입력이 옛 값으로 돌아오는 순간 `unchanged` 로 오판해 섞인 사이드카가 영영 남는다(리뷰 지적 1). 중간의
+ KeyboardInterrupt 도 같은 길로 막힌다.
+
+**남은 옛 출력 치우기(리뷰 지적 2).** 건너뛴 명령이 예전에 쓴 디렉토리는 hub 가 그대로 읽는다(`ES/src/hub/stages/tagsets.ts`
+`loadPolicies` — 없는 디렉토리는 빈 목록이다). 그래서 lock 이 없으면 `datasets/`·`tagsets/`·`policies/`(와 키 파일)를, 템플릿만
+없으면 `tagsets/`·`policies/` 를 색인 루트의 그 case 아래에서 지운다 — lock·템플릿에서만 나오는 파생물이다. 번들 색인
+(`bundles/`)은 건드리지 않는다. 지운 이름은 `IndexOutcome.removed` 와 stderr 한 줄로 알린다. 레이아웃·lock 을 못 읽어 세 명령을
+모두 부르는 경우에는 지우지 않는다(없다고 확정하지 못했다).
+
+**호출.** `poc_bundle.index.cli.index_group.main([...], prog_name="poc-bundle index", standalone_mode=False)` — 아카이브 루트
+(`--projects-dir target.projects_dir`)·색인 루트(`--index-root hub_index_root()`)·텍스트 모드(`--text`)를 **명시**한다. stdout·
+stderr 는 버퍼에 받는다(경로·건수뿐이라 훅 콘솔은 다리마다 한 줄이다). 다만 stderr 의 **`warning:` 줄은 버리지 않는다**(리뷰
+지적 4) — `index datasets` 의 "같은 dataset_version 을 나눠 가진 lock 항목"(hub 는 그중 하나만 싣는다)·`index policy` 의 "관측 0 인
+선언 태그" 같은 정합성 경고다. 명령 이름을 붙여 stderr 에 다시 내고 `IndexOutcome.warnings` 에 싣는다(번들 다리는 detail 에
+건수만 둔다 — 상태 파일은 코드·건수만). 실패는 `failed:index_<명령>:<종료 코드>` 이고 사람용 이유는 click 오류 문구(경로·코드만
+— `index/cli.py` 모듈 설명 "행 본문은 싣지 않는다")의 마지막 줄이다.
+
+돌려주는 값: `unchanged` · `written` · `written:datasets_only` · `skipped:no_lock` · `failed:index_<명령>:`.
+
+`poc_bundle` 은 함수 안에서만 import 한다(D-L6-14).
+"""
+
+from __future__ import annotations
+
+import contextlib
+import hashlib
+import io
+import json
+import os
+from collections.abc import Sequence
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+import click
+
+from solar_eval.hub_hook import optin
+from solar_eval.hub_hook.types import (
+ HOOK_DIRNAME,
+ INDEX_CMD_DATASETS,
+ INDEX_CMD_POLICY,
+ INDEX_CMD_TAGS,
+ INDEX_FAILED_PREFIX,
+ INDEX_KEY_FILENAME,
+ INDEX_UNCHANGED,
+ INDEX_WRITTEN,
+ INDEX_WRITTEN_DATASETS_ONLY,
+ NO_LOCK,
+ STATUS_FAILED,
+ STATUS_SKIPPED,
+ Target,
+)
+
+#: 색인 명령과 그 명령이 쓰는 색인 루트 아래 디렉토리(`/accounts///`).
+CMD_DATASETS, CMD_TAGS, CMD_POLICY = INDEX_CMD_DATASETS, INDEX_CMD_TAGS, INDEX_CMD_POLICY
+OUTPUT_DIRS = {CMD_DATASETS: "datasets", CMD_TAGS: "tagsets", CMD_POLICY: "policies"}
+PROG_NAME = "poc-bundle index"
+#: 키 파일 스키마 — 훅 내부 기록(사람·다른 도구가 읽지 않는다).
+INDEX_KEY_SCHEMA = "solar-eval-hook-index-key/v1"
+#: 색인 CLI stderr 에서 정합성 경고로 보는 줄 머리(`poc_bundle/index/cli.py` 의 `warning: …`).
+WARNING_PREFIX = "warning:"
+
+
+@dataclass(frozen=True)
+class IndexOutcome:
+ """`refresh` 결과 — `code` 는 모듈 설명의 반환 어휘, `reason` 은 실패일 때만 사람용 한 줄.
+
+ `warnings` 는 성공한 명령의 stderr `warning:` 줄(`index <명령>: ` 접두), `removed` 는 이번에 치운 옛 출력 디렉토리 이름.
+ """
+
+ code: str
+ reason: str | None = None
+ warnings: tuple[str, ...] = ()
+ removed: tuple[str, ...] = ()
+
+ @property
+ def failed(self) -> bool:
+ return self.code.startswith(f"{STATUS_FAILED}:")
+
+
+@dataclass(frozen=True)
+class IndexCall:
+ """색인 명령 한 번의 결과 — 종료 코드, 실패면 click 문구 마지막 줄, stderr 의 `warning:` 줄."""
+
+ exit_code: int
+ reason: str | None = None
+ warnings: tuple[str, ...] = ()
+
+
+@dataclass(frozen=True)
+class _Plan:
+ commands: tuple[str, ...]
+ key: str | None
+ parts: dict[str, Any] | None
+
+
+def refresh_case_indexes(target: Target) -> str:
+ """case 하나의 색인을 키가 바뀌었을 때만 다시 만든다(D-L6-7). 반환 어휘는 모듈 설명."""
+ return refresh(target).code
+
+
+def refresh(target: Target) -> IndexOutcome:
+ """`refresh_case_indexes` 와 같고 실패 이유(사람용 한 줄)를 함께 돌려준다."""
+ plan = _plan(target)
+ root = _index_root()
+ removed = _remove_stale_outputs(target, plan, root)
+ if not plan.commands:
+ # lock 이 없다 — 키도 이제 가리킬 색인이 없다(lock 이 돌아오면 디렉토리가 없어 다시 만든다)
+ key_path(target).unlink(missing_ok=True)
+ return _announce(target, IndexOutcome(f"{STATUS_SKIPPED}:{NO_LOCK}", removed=removed))
+ if plan.key is not None and _unchanged(target, plan, root):
+ return _announce(target, IndexOutcome(INDEX_UNCHANGED, removed=removed))
+ # 옛 키를 먼저 지운다 — 명령이 중간에 실패하면 사이드카가 섞인 채 옛 키가 남아 `unchanged` 로 오판한다(리뷰 지적 1)
+ key_path(target).unlink(missing_ok=True)
+ from poc_bundle.index.case import index_lock_derived
+
+ warnings: list[str] = []
+ for command, call in index_lock_derived(
+ target.case_ref,
+ root,
+ repo_root=target.repo_root,
+ commands=plan.commands,
+ text_mode=optin.text_mode(),
+ projects_dir=target.projects_dir,
+ invoke=invoke_index,
+ ):
+ warnings += [f"index {command}: {line}" for line in call.warnings]
+ if call.exit_code != 0:
+ code = f"{STATUS_FAILED}:{INDEX_FAILED_PREFIX}{command}:{call.exit_code}"
+ return _announce(target, IndexOutcome(code, call.reason, tuple(warnings), removed))
+ if plan.key is not None and plan.parts is not None:
+ _write_key(target, plan.key, plan.parts)
+ code = INDEX_WRITTEN if CMD_POLICY in plan.commands else INDEX_WRITTEN_DATASETS_ONLY
+ return _announce(target, IndexOutcome(code, warnings=tuple(warnings), removed=removed))
+
+
+def _announce(target: Target, outcome: IndexOutcome) -> IndexOutcome:
+ """치운 디렉토리·색인 경고를 stderr 에 한 줄씩(경로·태그 이름·건수뿐 — 행 본문은 CLI 가 싣지 않는다)."""
+ if outcome.removed:
+ names = "·".join(f"{name}/" for name in outcome.removed)
+ click.secho(
+ f" 색인({target.case_ref}): lock·정책 템플릿이 없어 옛 {names} 를 치웠다",
+ fg="yellow",
+ err=True,
+ )
+ for line in outcome.warnings:
+ click.secho(f" 색인 경고({target.case_ref}) — {line}", fg="yellow", err=True)
+ return outcome
+
+
+def _remove_stale_outputs(target: Target, plan: _Plan, root: Path) -> tuple[str, ...]:
+ """건너뛴 명령이 예전에 쓴 출력 디렉토리를 지운다(리뷰 지적 2). 지운 디렉토리 이름을 돌려준다.
+
+ lock·템플릿이 없다고 **확정**했을 때만(`plan.key` 가 있거나 명령이 0) — 못 읽어 세 명령을 모두 부르는 경우는 치울 것이 없다.
+ 심볼릭 링크는 링크만 지운다(가리키는 곳은 건드리지 않는다).
+ """
+ from poc_bundle.index.case import prune_skipped_lock_indexes
+
+ return prune_skipped_lock_indexes(root, target.account, target.case, plan.commands)
+
+
+# ── 선행 판정·키 ─────────────────────────────────────────────────────────
+
+
+def _plan(target: Target) -> _Plan:
+ """돌 명령과 키. lock 이 없으면 명령 0. 레이아웃·lock 을 못 읽으면 키 없이 세 명령(CLI 가 실패를 보고)."""
+ from poc_bundle.data.data_root import build_case_layout, load_existing_lock
+ from poc_bundle.data.errors import DataError
+
+ try:
+ layout = build_case_layout(
+ target.repo_root, target.case_ref, projects_dir=target.projects_dir
+ )
+ found = load_existing_lock(layout)
+ except (DataError, OSError):
+ return _Plan((CMD_DATASETS, CMD_TAGS, CMD_POLICY), None, None)
+ if found is None:
+ return _Plan((), None, None)
+ template = _template_bytes(target)
+ commands = (CMD_DATASETS,) if template is None else (CMD_DATASETS, CMD_TAGS, CMD_POLICY)
+ parts = _key_parts(found[1], template)
+ return _Plan(commands, _key_of(parts), parts)
+
+
+def _template_bytes(target: Target) -> bytes | None:
+ """정책 템플릿(`index policy` 기본 위치) 바이트. 없으면 None. 있는데 못 읽으면 `OSError` 를 올린다(번들 다리가 보고)."""
+ from poc_bundle.index.cli import TEMPLATE_DEFAULT
+
+ path = target.repo_root / "accounts" / target.account / target.case / TEMPLATE_DEFAULT
+ if not path.exists() and not path.is_symlink():
+ return None
+ return path.read_bytes()
+
+
+def _key_parts(lock_bytes: bytes, template: bytes | None) -> dict[str, Any]:
+ from poc_bundle.index.row_tags import EXTRACTOR
+
+ return {
+ "lock_sha": _sha256(lock_bytes),
+ "template_sha": None if template is None else _sha256(template),
+ "text_mode": optin.text_mode(),
+ "extractor": EXTRACTOR,
+ "index_code_sha": index_code_sha(),
+ }
+
+
+def _key_of(parts: dict[str, Any]) -> str:
+ from poc_bundle.canonical import jcs_bytes
+
+ ordered = [
+ parts["lock_sha"],
+ parts["template_sha"],
+ parts["text_mode"],
+ parts["extractor"],
+ parts["index_code_sha"],
+ ]
+ return _sha256(jcs_bytes(ordered))
+
+
+def index_code_sha(package_root: Path | None = None) -> str:
+ """`poc_bundle` 패키지 전체 `*.py` 의 (상대 경로, 길이, 바이트)를 경로순으로 이은 sha256(검토 F9 · 리뷰 지적 3).
+
+ 색인 출력은 `index/` 밖 PB 모듈(`keys.dataset_version` 등)에도 달려 있다 — 그래서 `index/` 만이 아니라 패키지 전체를 본다.
+ `package_root` 는 시험용(기본은 설치된 `poc_bundle` 패키지 디렉토리). 경로를 섞어 이름 바꾸기·옮기기도 잡는다.
+ """
+ if package_root is None:
+ import poc_bundle
+
+ package_root = Path(poc_bundle.__file__).parent
+ digest = hashlib.sha256()
+ sources = sorted(
+ (p.relative_to(package_root).as_posix(), p) for p in package_root.rglob("*.py")
+ )
+ for rel, path in sources:
+ data = path.read_bytes()
+ digest.update(f"{rel}\0{len(data)}\0".encode())
+ digest.update(data)
+ return digest.hexdigest()
+
+
+def _sha256(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def key_path(target: Target) -> Path:
+ """`///hook/index-key.json`."""
+ from poc_bundle.home import hub_reports_dir
+
+ return hub_reports_dir() / target.account / target.case / HOOK_DIRNAME / INDEX_KEY_FILENAME
+
+
+def _stored_key(target: Target) -> str | None:
+ """저장된 키. 없거나 깨졌으면 None — 다시 만든다(키 파일은 캐시일 뿐이다)."""
+ try:
+ doc = json.loads(key_path(target).read_text("utf-8"))
+ except (OSError, ValueError):
+ return None
+ key = doc.get("key") if isinstance(doc, dict) else None
+ return key if isinstance(key, str) else None
+
+
+def _write_key(target: Target, key: str, parts: dict[str, Any]) -> None:
+ """임시 파일 → `os.replace`(원자적). 실패는 호출자에게 올린다 — 조용히 삼키면 매번 다시 만든다는 사실이 숨는다."""
+ path = key_path(target)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ doc = {"schema": INDEX_KEY_SCHEMA, "key": key, "parts": parts}
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
+ tmp.write_text(json.dumps(doc, ensure_ascii=False, sort_keys=True) + "\n", "utf-8")
+ os.replace(tmp, path)
+
+
+def _index_root() -> Path:
+ from poc_bundle.home import hub_index_root
+
+ return hub_index_root()
+
+
+def _unchanged(target: Target, plan: _Plan, root: Path) -> bool:
+ if _stored_key(target) != plan.key:
+ return False
+ case_dir = root / "accounts" / target.account / target.case
+ return all((case_dir / OUTPUT_DIRS[c]).is_dir() for c in plan.commands)
+
+
+# ── click 호출 ───────────────────────────────────────────────────────────
+
+
+def command_args(target: Target, command: str, root: Path) -> list[str]:
+ """색인 명령 하나의 인자 — 루트는 모두 명시한다(CLI 기본값을 거치지 않는다)."""
+ from poc_bundle.index.case import lock_index_args
+
+ return lock_index_args(
+ command,
+ target.case_ref,
+ root,
+ text_mode=optin.text_mode(),
+ projects_dir=target.projects_dir,
+ )
+
+
+def invoke_index(args: Sequence[str]) -> IndexCall:
+ """`poc-bundle index ` 를 `standalone_mode=False` 로 — 종료 코드, 실패면 click 문구 마지막 줄, stderr 경고 줄."""
+ from poc_bundle.index.cli import index_group
+
+ sink_out, sink_err = io.StringIO(), io.StringIO()
+ try:
+ with contextlib.redirect_stdout(sink_out), contextlib.redirect_stderr(sink_err):
+ rv = index_group.main(list(args), prog_name=PROG_NAME, standalone_mode=False)
+ except click.ClickException as exc:
+ return _call(exc.exit_code, sink_err, _last_line(exc.format_message()))
+ except click.exceptions.Exit as exc:
+ return _call(exc.exit_code, sink_err)
+ except click.Abort:
+ return _call(1, sink_err, "aborted")
+ except SystemExit as exc:
+ return _call(exc.code if isinstance(exc.code, int) else 1, sink_err)
+ return _call(rv if isinstance(rv, int) and not isinstance(rv, bool) else 0, sink_err)
+
+
+def _call(exit_code: int, sink_err: io.StringIO, reason: str | None = None) -> IndexCall:
+ """버퍼에서 경고 줄을 뽑아 결과를 만든다. 실패인데 이유가 없으면 stderr 마지막 줄을 이유로."""
+ text = sink_err.getvalue()
+ if exit_code != 0 and reason is None:
+ reason = _last_line(text)
+ return IndexCall(exit_code, None if exit_code == 0 else reason, warning_lines(text))
+
+
+def warning_lines(text: str) -> tuple[str, ...]:
+ """stderr 버퍼에서 `warning:` 로 시작하는 줄(앞뒤 공백 제거)만."""
+ return tuple(
+ line.strip() for line in text.splitlines() if line.strip().startswith(WARNING_PREFIX)
+ )
+
+
+def _last_line(text: str) -> str | None:
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
+ return lines[-1] if lines else None
diff --git a/solar_eval/hub_hook/lock.py b/solar_eval/hub_hook/lock.py
new file mode 100644
index 0000000000000000000000000000000000000000..6e6271293827d1071c2a2af2fad10a6f9dc78841
--- /dev/null
+++ b/solar_eval/hub_hook/lock.py
@@ -0,0 +1,102 @@
+"""case 잠금 파일 (계획 2026-09-27 L6 §2.5 · D-L6-3 · 검토 F8).
+
+`///hook.lock` 에 `fcntl.flock(LOCK_EX)` — `LOCK_NB` 로 `LOCK_POLL_SECONDS` 마다 다시 시도하고, 한도
+`timeout`(기본 `HOOK_LOCK_TIMEOUT_SECONDS` 1200 s — hub 다리 한도 900 s + 번들 다리 여유)을 넘기면 잡지 못한 채 `False` 를 내준다
+(호출자가 `lock_timeout` 으로 보고한다). 대기 중 `LOCK_NOTICE_SECONDS` 마다 "같은 case 의 다른 훅을 기다리는 중… N 초째" 한 줄을
+stderr 에 낸다.
+
+- 잠금 파일 부모는 `mkdir(parents=True)` 로 만들고, 파일은 **지우지 않는다** — 지우면 새 파일에 잠금을 거는 쪽과 옛 파일에 거는
+ 쪽이 동시에 "잡았다" 고 믿는 경쟁이 생긴다.
+- `flock` 은 열린 파일 기술자에 걸린다 — 프로세스가 죽으면 커널이 푼다(잔여 잠금 없음). 같은 프로세스라도 따로 연 기술자끼리는
+ 서로 막는다. 데이터 홈은 로컬 디스크 전제다(NFS 에서는 `flock` 이 의미가 없다 — 계획 R-L6-11).
+- hub 대상 run 의 두 다리 전부와 batch 끝 hub 다리(`hook.finish_batch`)를 감싼다. L6b `runs eval` 새 경로도 ①~⑤ 전부를 이
+ 잠금 안에서 돈다(계획 §2.12 B4) — 그 경로가 부르는 `bundle_leg.ensure_run_bundle` 은 스스로 잠그지 않고 호출자가 쥔 잠금을
+ 전제한다(같은 프로세스에서 다시 잡으면 위 규칙대로 자기 자신을 기다린다). 잠금 파일에는 아무것도 쓰지 않는다.
+
+`poc_bundle` 은 함수 안에서만 import 한다(D-L6-14).
+"""
+
+from __future__ import annotations
+
+import fcntl
+import os
+import time
+from collections.abc import Iterator
+from contextlib import contextmanager
+from pathlib import Path
+
+import click
+
+from solar_eval.hub_hook.types import (
+ HOOK_LOCK_TIMEOUT_SECONDS,
+ LOCK_FILENAME,
+ LOCK_NOTICE_SECONDS,
+ LOCK_POLL_SECONDS,
+)
+
+#: 잠금 파일 권한(내용은 비어 있다 — 권한은 데이터 홈의 다른 파일과 같게).
+LOCK_FILE_MODE = 0o644
+
+
+def lock_path(account: str, case: str) -> Path:
+ """잠금 파일 위치 `///hook.lock`. 슬러그가 틀리면 `DataConfigError`(경로 조각으로 쓰기 전에 막는다)."""
+ from poc_bundle.data.case_ref import parse_case_ref
+ from poc_bundle.home import hub_reports_dir
+
+ account, case = parse_case_ref(f"{account}/{case}")
+ return hub_reports_dir() / account / case / LOCK_FILENAME
+
+
+@contextmanager
+def case_lock(
+ account: str, case: str, *, timeout: float = HOOK_LOCK_TIMEOUT_SECONDS
+) -> Iterator[bool]:
+ """case 하나의 배타 잠금. 한도 안에 잡으면 `True`, 넘기면 `False`(호출자가 `lock_timeout` 으로 보고).
+
+ Raises:
+ ValueError: `timeout` 이 음수일 때.
+ DataConfigError: case 슬러그가 틀렸을 때(`poc_bundle.data.errors`).
+ OSError: 잠금 파일을 만들거나 열 수 없을 때(hook 이 `lock_exception:` 으로 강등한다).
+ """
+ if timeout < 0:
+ raise ValueError(f"lock timeout must be >= 0, got {timeout}")
+ path = lock_path(account, case)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd = os.open(path, os.O_RDWR | os.O_CREAT, LOCK_FILE_MODE)
+ try:
+ held = _acquire(fd, timeout, f"{account}/{case}")
+ try:
+ yield held
+ finally:
+ if held:
+ fcntl.flock(fd, fcntl.LOCK_UN)
+ finally:
+ os.close(fd)
+
+
+def _acquire(fd: int, timeout: float, case_ref: str) -> bool:
+ """`LOCK_NB` 폴링으로 잠근다. 한도 안에 잡으면 True — 한도 0 이면 한 번만 시도한다."""
+ started = time.monotonic()
+ next_notice = LOCK_NOTICE_SECONDS
+ while True:
+ try:
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except BlockingIOError:
+ elapsed = time.monotonic() - started
+ else:
+ return True
+ if elapsed >= timeout:
+ return False
+ if elapsed >= next_notice:
+ _echo_waiting(case_ref, elapsed)
+ next_notice += LOCK_NOTICE_SECONDS
+ time.sleep(min(LOCK_POLL_SECONDS, max(timeout - elapsed, 0.0)))
+
+
+def _echo_waiting(case_ref: str, elapsed: float) -> None:
+ """대기 안내 한 줄(stderr) — case 이름과 경과 초만."""
+ click.secho(
+ f" 같은 case({case_ref})의 다른 훅을 기다리는 중… {int(elapsed)} 초째",
+ fg="yellow",
+ err=True,
+ )
diff --git a/solar_eval/hub_hook/optin.py b/solar_eval/hub_hook/optin.py
new file mode 100644
index 0000000000000000000000000000000000000000..0d1148c60a002f6735518521e912a486dba129a3
--- /dev/null
+++ b/solar_eval/hub_hook/optin.py
@@ -0,0 +1,193 @@
+"""훅 opt-in 해석 — 환경 변수 > 설정 파일 `hub:` 절, 한 곳·순수 (계획 2026-09-27 L6 §2.2 · 2026-09-28 L10 §2.6 · D-L10-6).
+
+| 입력 | 뜻 |
+| ------------------------------------- | -------------------------------------------------------------------------------------- |
+| `HUB_DATABASE_URL` | 있으면 hub 다리를 켠다(출처 `env`). 값은 읽지 않는다 — 부모 환경 그대로 자식에게 간다 |
+| 설정 파일 `hub.database_url` | 환경 변수가 없을 때만 opt-in(출처 `config`) — **사람 셸만**. 값은 자식 환경으로만 간다 |
+| `SOLAR_EVAL_HUB_CONFIRM` | `local` 이면 `--i-understand-local-data`. 다른 값은 무시 + 경고 한 줄 |
+| 설정 파일 `hub.confirm` | `local` 만 — **URL 출처가 설정일 때만**, 사람 셸만(아래 "규칙 9 의 의도된 예외") |
+| `SOLAR_EVAL_STRICT_INGEST` | `1`·`true`·`yes` 면 `--strict-ingest` 와 같다 |
+| `HUB_TEXT_MODE` | 색인 키에만 쓴다(기본 `inline`) |
+| `AGENT_SESSION_MARKERS`(아래 목록) | 에이전트 세션 표식 — 하나라도 있으면 확인(환경·설정)과 설정 출처 opt-in 을 무시한다 |
+| `SOLAR_EVAL_HOOK_DEBUG` | `1`·`true`·`yes` 면 봉쇄한 예외의 트레이스백을 WARNING 로그로(기본 DEBUG) |
+
+**에이전트 세션 가드(D-L6-16 · D-L10-6).** 표식 목록 `AGENT_SESSION_MARKERS` 는 이 모듈 한 곳이다 — Claude Code 의
+`CLAUDECODE` 와, Codex 가 셸 도구·Seatbelt 샌드박스 자식에 설정한다고 그 레포 `AGENTS.md` 가 적은 `CODEX_SANDBOX`·
+`CODEX_SANDBOX_NETWORK_DISABLED`(Q-L10-22 — 문서로 확인한 것만). 표식이 하나라도 비어 있지 않으면 `SOLAR_EVAL_HUB_CONFIRM`·
+`hub.confirm` 을 무시하고, 설정 파일 `hub.database_url` 도 무시한다(hub 다리 `skipped:not_opted_in` + 경고 한 줄). 사람 셸에는
+영향이 없다. 표식을 잘못 더해도 사람 셸에서 설정 opt-in 이 꺼질 뿐이라 안전 쪽이다.
+
+**규칙 9 의 의도된 예외(L10 검토 F1-3).** 마스터 §4 규칙 9 는 "`HUB_DATABASE_URL`·확인은 셸 프로필이 아니라 명령 앞에" 다.
+설정 파일 `hub.confirm: local` 은 그 규칙의 **의도된 예외**다 — 사람 셸에서, URL 도 설정 파일에 있을 때만 효력이 있다(환경 URL 과
+설정 confirm 을 섞지 않는다 — 환경에 테스트 URL 을 준 시험 자식이 사용자 확인을 줍지 않게, 검토 F1-1).
+
+URL 값은 어디에도 싣지 않는다 — 경고·`repr` 에 없고, 설정 출처 값은 `HubOptIn.secrets` 로 가림·거부 목록에만 간다.
+"""
+
+from __future__ import annotations
+
+import os
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from typing import Any, Literal
+
+ENV_HUB_DATABASE_URL = "HUB_DATABASE_URL"
+ENV_HUB_CONFIRM = "SOLAR_EVAL_HUB_CONFIRM"
+ENV_STRICT_INGEST = "SOLAR_EVAL_STRICT_INGEST"
+ENV_HUB_TEXT_MODE = "HUB_TEXT_MODE"
+ENV_HOOK_DEBUG = "SOLAR_EVAL_HOOK_DEBUG"
+#: 에이전트 세션 표식 — 한 곳(D-L10-6 · Q-L10-22). 첫 원소는 Claude Code.
+AGENT_SESSION_MARKERS: tuple[str, ...] = (
+ "CLAUDECODE",
+ "CODEX_SANDBOX",
+ "CODEX_SANDBOX_NETWORK_DISABLED",
+)
+#: 옛 이름(L6) — 첫 표식의 별칭.
+ENV_AGENT_SESSION = AGENT_SESSION_MARKERS[0]
+
+#: `SOLAR_EVAL_HUB_CONFIRM`·`hub.confirm` 이 받는 유일한 값.
+CONFIRM_LOCAL = "local"
+#: hub CLI 의 사용자 DB 쓰기 확인 플래그.
+CONFIRM_FLAG = "--i-understand-local-data"
+TRUTHY = frozenset({"1", "true", "yes"})
+DEFAULT_TEXT_MODE = "inline"
+#: 이보다 짧은 값은 우연히 겹칠 수 있어 가림·거부하지 않는다(URL 은 이보다 길다).
+MIN_SECRET_LENGTH = 8
+
+SOURCE_ENV = "env"
+SOURCE_CONFIG = "config"
+HubSource = Literal["env", "config"]
+
+_MARKERS_LABEL = "·".join(AGENT_SESSION_MARKERS)
+#: 사람이 직접 부를 적재 한 줄(URL 자리는 `…`).
+MANUAL_INGEST = "HUB_DATABASE_URL=… make hub-ingest CASE= CONFIRM=local"
+
+#: 콘솔 경고 문구(값은 싣지 않는다).
+WARN_CONFIRM_IGNORED_AGENT = (
+ f"{ENV_HUB_CONFIRM}·설정 파일 hub.confirm 을 무시한다 — 에이전트 세션({_MARKERS_LABEL})에서는 "
+ f"사용자 DB 쓰기 확인을 넘기지 않는다. 사람이 직접: {MANUAL_INGEST}"
+)
+WARN_CONFIRM_UNKNOWN_VALUE = (
+ f"{ENV_HUB_CONFIRM} 값이 '{CONFIRM_LOCAL}' 이 아니라 무시한다 (받는 값: {CONFIRM_LOCAL})"
+)
+WARN_CONFIG_CONFIRM_UNKNOWN_VALUE = (
+ f"설정 파일 hub.confirm 값이 '{CONFIRM_LOCAL}' 이 아니라 무시한다 (받는 값: {CONFIRM_LOCAL})"
+)
+WARN_CONFIG_OPTIN_IGNORED_AGENT = (
+ f"설정 파일 hub.database_url 을 무시한다 — 에이전트 세션({_MARKERS_LABEL})에서는 설정 파일 opt-in·확인을 "
+ f"쓰지 않는다. 사람이 직접: {MANUAL_INGEST}"
+)
+#: 설정 모양 오류 — `{problems}` 는 키 이름만 담은 문제 목록(`HubConfig.problems`).
+WARN_CONFIG_HUB_INVALID = "설정 파일 hub: 절을 무시한다 — {problems}"
+
+
+@dataclass(frozen=True)
+class ConfirmDecision:
+ """`--i-understand-local-data` 를 붙일지와, 무시했다면 그 이유(콘솔 경고 한 줄)."""
+
+ confirm_local: bool
+ warning: str | None = None
+
+
+@dataclass(frozen=True)
+class HubOptIn:
+ """opt-in 해석 결과. `child_env` 는 설정 출처일 때만(부모 환경 + URL), `secrets` 는 설정 출처 URL 값 — 둘 다 repr 밖."""
+
+ source: HubSource | None
+ child_env: Mapping[str, str] | None = field(default=None, repr=False)
+ confirm: ConfirmDecision = ConfirmDecision(False)
+ warnings: tuple[str, ...] = ()
+ secrets: tuple[str, ...] = field(default=(), repr=False)
+
+ @property
+ def opted_in(self) -> bool:
+ return self.source is not None
+
+
+def _env(env: Mapping[str, str] | None) -> Mapping[str, str]:
+ return os.environ if env is None else env
+
+
+def is_agent_session(env: Mapping[str, str] | None = None) -> bool:
+ """에이전트 세션인가 — `AGENT_SESSION_MARKERS` 중 하나라도 비어 있지 않다."""
+ source = _env(env)
+ return any(source.get(marker, "").strip() for marker in AGENT_SESSION_MARKERS)
+
+
+def resolve_hub_optin(env: Mapping[str, str] | None = None, config: Any | None = None) -> HubOptIn:
+ """환경 변수 > 설정 파일 `hub:` 절(§2.6 표). `config` 는 `cli.config.HubConfig`(없으면 None)."""
+ source_env = _env(env)
+ agent = is_agent_session(source_env)
+ url, confirm, problems = _config_fields(config)
+ warnings: list[str] = []
+ if problems:
+ warnings.append(WARN_CONFIG_HUB_INVALID.format(problems="; ".join(problems)))
+ secrets = _secrets_of(url)
+ if source_env.get(ENV_HUB_DATABASE_URL, "").strip():
+ decision = _env_confirm(source_env, agent) or ConfirmDecision(False)
+ return HubOptIn(SOURCE_ENV, None, decision, tuple(warnings), secrets)
+ if url is None or problems:
+ return HubOptIn(None, None, ConfirmDecision(False), tuple(warnings), secrets)
+ if agent:
+ warnings.append(WARN_CONFIG_OPTIN_IGNORED_AGENT)
+ return HubOptIn(None, None, ConfirmDecision(False), tuple(warnings), secrets)
+ child_env = {**source_env, ENV_HUB_DATABASE_URL: url}
+ decision = _env_confirm(source_env, agent) or _config_confirm(confirm)
+ return HubOptIn(SOURCE_CONFIG, child_env, decision, tuple(warnings), secrets)
+
+
+def config_secrets(config: Any | None) -> tuple[str, ...]:
+ """설정 출처 비밀(`hub.database_url` 값) — 상태 파일 거부·콘솔 가림 목록에 더한다(§2.6 "비밀"). 쓰지 않은 설정 값도 넣는다."""
+ return _secrets_of(_config_fields(config)[0])
+
+
+def _secrets_of(url: str | None) -> tuple[str, ...]:
+ return (url,) if url is not None and len(url) >= MIN_SECRET_LENGTH else ()
+
+
+def _config_fields(config: Any | None) -> tuple[str | None, str | None, tuple[str, ...]]:
+ """`HubConfig` 의 (URL, confirm, 문제) — 값이 문자열이 아니면 없는 것으로 본다."""
+ if config is None:
+ return None, None, ()
+ url = getattr(config, "database_url", None)
+ confirm = getattr(config, "confirm", None)
+ problems = tuple(getattr(config, "problems", ()) or ())
+ url = url.strip() if isinstance(url, str) and url.strip() else None
+ confirm = confirm.strip() if isinstance(confirm, str) and confirm.strip() else None
+ return url, confirm, problems
+
+
+def _env_confirm(env: Mapping[str, str], agent: bool) -> ConfirmDecision | None:
+ """`SOLAR_EVAL_HUB_CONFIRM` 해석 — 비어 있으면 None(설정 confirm 으로 넘어간다). 에이전트 세션이면 `local` 도 무시."""
+ raw = env.get(ENV_HUB_CONFIRM, "").strip()
+ if not raw:
+ return None
+ if raw != CONFIRM_LOCAL:
+ return ConfirmDecision(False, WARN_CONFIRM_UNKNOWN_VALUE)
+ if agent:
+ return ConfirmDecision(False, WARN_CONFIRM_IGNORED_AGENT)
+ return ConfirmDecision(True)
+
+
+def _config_confirm(confirm: str | None) -> ConfirmDecision:
+ """설정 `hub.confirm` — URL 출처가 설정이고 사람 셸일 때만 부른다."""
+ if confirm is None:
+ return ConfirmDecision(False)
+ if confirm != CONFIRM_LOCAL:
+ return ConfirmDecision(False, WARN_CONFIG_CONFIRM_UNKNOWN_VALUE)
+ return ConfirmDecision(True)
+
+
+def strict_requested(flag: bool, env: Mapping[str, str] | None = None) -> bool:
+ """`--strict-ingest` 또는 `SOLAR_EVAL_STRICT_INGEST` 가 참(`1`·`true`·`yes`, 대소문자 무시)."""
+ return flag or _env(env).get(ENV_STRICT_INGEST, "").strip().lower() in TRUTHY
+
+
+def hook_debug_requested(env: Mapping[str, str] | None = None) -> bool:
+ """`SOLAR_EVAL_HOOK_DEBUG` 가 참 — 봉쇄한 예외의 트레이스백을 WARNING 로그로 올린다(현장 진단용)."""
+ return _env(env).get(ENV_HOOK_DEBUG, "").strip().lower() in TRUTHY
+
+
+def text_mode(env: Mapping[str, str] | None = None) -> str:
+ """`HUB_TEXT_MODE` 해석값(비었으면 `inline`) — 색인 키에만 쓴다(D-L6-7)."""
+ return _env(env).get(ENV_HUB_TEXT_MODE, "").strip() or DEFAULT_TEXT_MODE
diff --git a/solar_eval/hub_hook/proc.py b/solar_eval/hub_hook/proc.py
new file mode 100644
index 0000000000000000000000000000000000000000..b80ab70a870ec0398a947af0657fba88a6653fd0
--- /dev/null
+++ b/solar_eval/hub_hook/proc.py
@@ -0,0 +1,124 @@
+"""pnpm 실행 — 한 곳 (계획 2026-09-27 L6 §2.6 · D-L6-13 · 검토 F8).
+
+`run_pnpm` 은 훅이 pnpm 을 부르는 **유일한** 진입점이다. 두 가지를 여기서 고정한다:
+
+- **프로세스 그룹째 죽이기.** `subprocess.run(timeout=)` 은 직계 pnpm 만 죽여 `tsx` 손자가 hub 세션 advisory lock 을 쥔 채
+ 남는다. 새 세션(`start_new_session=True`)으로 띄우고 한도를 넘기면 `os.killpg`(SIGTERM → 유예 뒤 SIGKILL) 한 뒤
+ `TimeoutExpired` 를 낸다 — 연결이 닫혀 서버가 잠금을 푼다.
+- **허용 목록(리뷰 지적 2).** argv 는 `hub_leg.build_hub_argv` 가 만드는 모양 그대로여야 한다 — 고정 머리
+ `pnpm --silent --fail-if-no-match --filter @poc/eval-store hub ingest` 뒤에 알려진 적재 옵션(`--case`·`--expect-run`·
+ `--repo`·`--mirror`·`--index` 값 하나씩, 확인 플래그)만. 다른 패키지·`exec`·다른 hub 하위 명령(파괴 명령 포함)·레거시 적재
+ 옵션·두 번째 `hub` 는 모두 `ValueError` 다. 훅은 적재 하나만 부른다(D-M2-4 (b) · 계획 §0.4-6).
+- **실행부는 비공개**(`_run_group`, 리뷰 지적 3·6) — 허용 목록 없이 argv 를 돌리므로 이 모듈 밖에서 부르지 않는다
+ (`test_scaffold.py` 가 소스로 막는다). 시험은 그룹 kill 확인에만 쓴다.
+
+테스트 tripwire(D-L6-13): `SE/tests/conftest.py` 가 이 모듈의 `run_pnpm` 을 "호출되면 `pytest.fail`" 로 바꾼다. 그러니 호출자는
+반드시 **모듈 속성으로** 부른다 — `from solar_eval.hub_hook import proc; proc.run_pnpm(...)`. 함수를 이름으로 import 하면
+패치를 비켜 간다(`test_scaffold.py` 가 소스 텍스트로 막는다).
+"""
+
+from __future__ import annotations
+
+import os
+import signal
+import subprocess
+from collections.abc import Mapping, Sequence
+from pathlib import Path
+
+from solar_eval.hub_hook.optin import CONFIRM_FLAG
+from solar_eval.hub_hook.types import HUB_PACKAGE_FILTER
+
+PNPM = "pnpm"
+#: 훅이 부를 수 있는 유일한 명령 머리 — pnpm 전역 옵션 + 적재 하나(§2.6 argv).
+ALLOWED_ARGV_HEAD = (
+ PNPM, "--silent", "--fail-if-no-match", "--filter", HUB_PACKAGE_FILTER, "hub", "ingest",
+) # fmt: skip
+#: 머리 뒤에 올 수 있는 값 옵션(값 하나씩 — 값은 `-` 로 시작하지 않는다).
+ALLOWED_VALUE_OPTIONS = frozenset({"--case", "--expect-run", "--repo", "--mirror", "--index"})
+#: 머리 뒤에 올 수 있는 플래그.
+ALLOWED_FLAGS = frozenset({CONFIRM_FLAG})
+#: SIGTERM 뒤 SIGKILL 까지 기다리는 초.
+KILL_GRACE_SECONDS = 5.0
+
+
+def run_pnpm(
+ argv: Sequence[str],
+ *,
+ cwd: Path,
+ env: Mapping[str, str] | None = None,
+ timeout: float,
+) -> subprocess.CompletedProcess[str]:
+ """허용 목록을 통과한 pnpm argv 를 새 세션으로 돌린다. 한도 초과는 그룹째 죽이고 `TimeoutExpired`.
+
+ `FileNotFoundError`(pnpm 없음)는 그대로 낸다 — 호출자(hub 다리)가 `pnpm_missing` 으로 바꾼다.
+ """
+ check_pnpm_argv(argv)
+ return _run_group(argv, cwd=cwd, env=env, timeout=timeout)
+
+
+def check_pnpm_argv(argv: Sequence[str]) -> None:
+ """고정 머리 + 알려진 적재 옵션만인 argv 가 아니면 `ValueError`(문구에 값을 싣지 않는다)."""
+ args = list(argv)
+ head = len(ALLOWED_ARGV_HEAD)
+ if tuple(args[:head]) != ALLOWED_ARGV_HEAD:
+ raise ValueError("run_pnpm: argv must start with the fixed eval-store 'hub ingest' head")
+ rest = args[head:]
+ index = 0
+ while index < len(rest):
+ token = rest[index]
+ if token in ALLOWED_FLAGS:
+ index += 1
+ continue
+ if token not in ALLOWED_VALUE_OPTIONS:
+ raise ValueError(f"run_pnpm: argument #{head + index} is not an allowed ingest option")
+ value = rest[index + 1] if index + 1 < len(rest) else None
+ if not value or value.startswith("-"):
+ raise ValueError(f"run_pnpm: option {token} needs a value")
+ index += 2
+
+
+def _run_group(
+ argv: Sequence[str],
+ *,
+ cwd: Path,
+ env: Mapping[str, str] | None = None,
+ timeout: float,
+) -> subprocess.CompletedProcess[str]:
+ """argv 를 새 프로세스 그룹으로 돌리고 출력을 모은다(허용 목록 없음 — `run_pnpm` 의 실행부, 모듈 밖에서 부르지 않는다)."""
+ process = subprocess.Popen(
+ list(argv),
+ cwd=str(cwd),
+ env=None if env is None else dict(env),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ start_new_session=True,
+ )
+ try:
+ stdout, stderr = process.communicate(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ stdout, stderr = _kill_group(process)
+ raise subprocess.TimeoutExpired(list(argv), timeout, stdout, stderr) from None
+ except BaseException:
+ # KeyboardInterrupt 등 — 손자가 잠금을 쥔 채 남지 않게 그룹을 정리하고 그대로 올린다.
+ _kill_group(process)
+ raise
+ return subprocess.CompletedProcess(list(argv), process.returncode, stdout, stderr)
+
+
+def _kill_group(process: subprocess.Popen[str]) -> tuple[str, str]:
+ """SIGTERM → 유예 → SIGKILL. 남은 출력을 돌려준다."""
+ _signal_group(process, signal.SIGTERM)
+ try:
+ return process.communicate(timeout=KILL_GRACE_SECONDS)
+ except subprocess.TimeoutExpired:
+ _signal_group(process, signal.SIGKILL)
+ return process.communicate()
+
+
+def _signal_group(process: subprocess.Popen[str], sig: signal.Signals) -> None:
+ try:
+ os.killpg(process.pid, sig)
+ except ProcessLookupError:
+ # 이미 끝난 그룹 — 보낼 곳이 없다.
+ return
diff --git a/solar_eval/hub_hook/status.py b/solar_eval/hub_hook/status.py
new file mode 100644
index 0000000000000000000000000000000000000000..6471d526e2c4d9d2a326861ac94b8660d184b43e
--- /dev/null
+++ b/solar_eval/hub_hook/status.py
@@ -0,0 +1,193 @@
+"""상태 파일 (계획 2026-09-27 L6 §2.7 · D-L6-12).
+
+`///hook/.json`, 스키마 `STATUS_SCHEMA`(`solar-eval-hook/v1`), 원자적 쓰기(같은 디렉토리의 임시
+파일 → fsync → `os.replace`). 키: `schema · run_id · project · case · command · started_at · finished_at · strict ·
+legs{: {status, code, …detail}} · ok`(`ok` = 실패 다리 0). batch run 은 run 마다 번들 다리 결과로 먼저 쓰고,
+`hook.finish_batch` 가 끝 다리 결과로 `update_legs` 를 부른다. hub 대상이 아닌 run 은 상태 파일이 없다.
+
+값은 코드·건수·id·시각만 — 다리의 `hint`(되살리기 문구)는 콘솔 몫이라 싣지 않고, 고객 텍스트·URL·예외 원문도 없다.
+마지막 방어로 직렬화한 문서에 hub·레거시 DB URL 값이 들어 있으면 쓰지 않고 `ValueError`(값은 문구에 싣지 않는다, D-L6-4).
+환경 변수 값만으로는 설정 파일 `hub.database_url` 값을 모르므로 호출자가 `secrets=`(`optin.config_secrets`)로 넘긴다 —
+그 값도 같은 규칙으로 거부·가린다(계획 2026-09-28 L10 §2.6 "비밀" · 검토 F1-4).
+
+쓰기 실패는 hook 이 콘솔 경고로 강등한다(D-L6-2) — 이 모듈은 예외를 그대로 낸다.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import tempfile
+from collections.abc import Mapping, Sequence
+from pathlib import Path
+from typing import Any
+
+from solar_eval.hub_hook.optin import ENV_HUB_DATABASE_URL
+from solar_eval.hub_hook.types import (
+ HOOK_DIRNAME,
+ STATUS_FAILED,
+ STATUS_SCHEMA,
+ Command,
+ HookResult,
+ LegResult,
+ Target,
+)
+
+#: 문서에 값이 새면 안 되는 환경 변수(자격증명을 담은 URL).
+# 옛 이름(EVALHUB_DATABASE_URL)은 더 이상 읽지 않지만, 개명 전 셸에 남아 있을 수 있어 값이 새지 않게 계속 가린다.
+SECRET_ENV_NAMES = (ENV_HUB_DATABASE_URL, "EXPERIMENT_HUB_DATABASE_URL", "EVALHUB_DATABASE_URL")
+#: 이보다 짧은 값은 우연히 겹칠 수 있어 검사하지 않는다(URL 은 이보다 길다).
+MIN_SECRET_LENGTH = 8
+#: 파일 이름이 되는 run_id — 경로 구분자·제어 문자·`.`/`..` 금지.
+_UNSAFE_RUN_ID = re.compile(r"[/\\\x00-\x1f\x7f]")
+#: 임시 파일 이름은 run_id 에 기대지 않는다 — `.json` 이 이름 한도(255바이트) 안이어도 `..json.XXXX.tmp`
+#: 는 넘칠 수 있다(리뷰 지적 1 — 237~250바이트 run_id 에서 ENAMETOOLONG).
+_TMP_PREFIX = ".hook-"
+_TMP_SUFFIX = ".tmp"
+#: URL 의 `사용자:비밀번호@` — 비밀번호 자리만 가린다.
+_URL_PASSWORD = re.compile(r"(?P[A-Za-z][A-Za-z0-9+.-]*://[^\s:/@]*:)[^\s@]*@")
+_REDACTED = "***"
+#: 설정 출처 비밀의 이름표 — 거부 문구·가림 자리에 값 대신 싣는다.
+CONFIG_SECRET_LABEL = "hub.database_url"
+
+
+def status_path(target: Target, run_id: str) -> Path:
+ """`///hook/.json`. run_id 가 파일 이름으로 안전하지 않으면 `ValueError`."""
+ from poc_bundle.home import hub_reports_dir
+
+ _check_run_id(run_id)
+ return hub_reports_dir() / target.account / target.case / HOOK_DIRNAME / f"{run_id}.json"
+
+
+def _check_run_id(run_id: str) -> None:
+ if not isinstance(run_id, str) or run_id in ("", ".", "..") or _UNSAFE_RUN_ID.search(run_id):
+ raise ValueError("hook status: run_id is not a safe file name")
+
+
+def leg_document(leg: LegResult) -> dict[str, Any]:
+ """다리 하나 → `{status, code, …detail}`. detail 이 `status`·`code` 를 덮지 못한다. hint 는 싣지 않는다."""
+ return {**dict(leg.detail), "status": leg.status, "code": leg.code}
+
+
+def _ok(legs: Mapping[str, Mapping[str, Any]]) -> bool:
+ return all(leg.get("status") != STATUS_FAILED for leg in legs.values())
+
+
+def status_document(
+ target: Target, result: HookResult, command: Command, *, strict: bool
+) -> dict[str, Any]:
+ """상태 파일 문서(순수)."""
+ legs = {name: leg_document(leg) for name, leg in result.legs.items()}
+ return {
+ "schema": STATUS_SCHEMA,
+ "run_id": result.run_id,
+ "project": target.project,
+ "case": target.case_ref,
+ "command": command,
+ "started_at": result.started_at,
+ "finished_at": result.finished_at,
+ "strict": bool(strict),
+ "legs": legs,
+ "ok": _ok(legs),
+ }
+
+
+def write_status(
+ target: Target,
+ result: HookResult,
+ command: Command,
+ *,
+ strict: bool,
+ secrets: Sequence[str] = (),
+) -> Path:
+ """run 하나의 훅 결과를 원자적으로 쓰고 경로를 돌려준다(같은 run 은 덮어쓴다). `secrets` = 설정 출처 비밀(거부 목록)."""
+ path = status_path(target, result.run_id)
+ _atomic_write(path, status_document(target, result, command, strict=strict), secrets)
+ return path
+
+
+def update_legs(
+ target: Target,
+ run_id: str,
+ legs: Mapping[str, LegResult],
+ *,
+ secrets: Sequence[str] = (),
+) -> None:
+ """이미 있는 상태 파일의 다리 몇 개를 원자적으로 바꿔 쓰고 `ok` 를 다시 센다(batch 끝 hub 다리, D-L6-12).
+
+ 파일이 없으면 `FileNotFoundError`, 스키마나 run_id 가 다르면 `ValueError` — 호출자(hook)가 경고로 강등한다.
+ """
+ path = status_path(target, run_id)
+ doc = json.loads(path.read_text("utf-8"))
+ if (
+ not isinstance(doc, dict)
+ or doc.get("schema") != STATUS_SCHEMA
+ or doc.get("run_id") != run_id
+ ):
+ raise ValueError(f"hook status: {path.name} is not a {STATUS_SCHEMA} file for this run")
+ current = doc.get("legs")
+ merged = dict(current) if isinstance(current, dict) else {}
+ merged.update({name: leg_document(leg) for name, leg in legs.items()})
+ _atomic_write(path, {**doc, "legs": merged, "ok": _ok(merged)}, secrets)
+
+
+def _atomic_write(path: Path, doc: Mapping[str, Any], secrets: Sequence[str] = ()) -> None:
+ """직렬화·비밀 검사 → 같은 디렉토리 임시 파일 → fsync → `os.replace`. 실패하면 임시 파일을 지우고 다시 낸다."""
+ text = json.dumps(doc, ensure_ascii=False, indent=2) + "\n"
+ _refuse_secrets(text, secrets)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=_TMP_PREFIX, suffix=_TMP_SUFFIX)
+ tmp = Path(tmp_name)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
+ fh.write(text)
+ fh.flush()
+ os.fsync(fh.fileno())
+ os.replace(tmp, path)
+ except BaseException:
+ tmp.unlink(missing_ok=True)
+ raise
+
+
+def _secret_values(extra: Sequence[str] = ()) -> list[tuple[str, str]]:
+ """(이름표, 값) — 환경 변수 값 + 설정 출처 값(`extra`, 이름표 `hub.database_url`). 검사할 만큼 긴 값만."""
+ pairs = [(name, os.environ.get(name, "").strip()) for name in SECRET_ENV_NAMES]
+ pairs += [(CONFIG_SECRET_LABEL, value.strip()) for value in extra if isinstance(value, str)]
+ return [(name, value) for name, value in pairs if len(value) >= MIN_SECRET_LENGTH]
+
+
+def _refuse_secrets(text: str, extra: Sequence[str] = ()) -> None:
+ for name, value in _secret_values(extra):
+ if value in text:
+ raise ValueError(f"hook status: refusing to store the value of {name}")
+
+
+def _mask_url_password(text: str) -> str:
+ return _URL_PASSWORD.sub(lambda m: f"{m.group('head')}{_REDACTED}@", text)
+
+
+def _whole_value_replacement(name: str, value: str) -> str:
+ """알려진 비밀 값 하나를 무엇으로 바꿀까. 비밀이 비밀번호 자리뿐이면(쿼리·조각 없음) 비밀번호만 가린 URL — 호스트·DB
+ 이름이 진단에 남는다. 그 밖(쿼리 문자열 비밀번호 등 — 어디가 비밀인지 모른다)은 통째로 `<이름표>`."""
+ masked = _mask_url_password(value)
+ only_password = masked != value and "?" not in value and "#" not in value
+ return masked if only_password else f"<{name}>"
+
+
+def redact_secrets(text: str, extra: Sequence[str] = ()) -> str:
+ """콘솔에 옮기는 외부 출력(hub CLI stderr)에서 비밀을 가린다(리뷰 지적 6).
+
+ **알려진 값을 먼저**(리뷰 L10 지적 4): `SECRET_ENV_NAMES` 값이나 설정 출처 값(`extra`)이 통째로 있으면
+ `_whole_value_replacement` 로 바꾸고, hub CLI `redactUrl` 이 비밀번호만 가린 모양(`u:***@…?sslpassword=…`)으로 있어도
+ 같은 이름표로 바꾼다. 그 **뒤에** 남은 모르는 URL 의 비밀번호 자리를 `***` 로(`redactUrl` 과 같은 모양 — 호스트·DB 이름은
+ 진단에 남는다). 순서가 거꾸로면 비밀번호를 가린 순간 값이 통째로 맞지 않아 쿼리 비밀이 샌다.
+ 상태 파일은 이 함수를 쓰지 않는다 — 거기에는 외부 출력을 아예 싣지 않는다(`_refuse_secrets` 가 마지막 방어).
+ """
+ for name, value in _secret_values(extra):
+ replacement = _whole_value_replacement(name, value)
+ text = text.replace(value, replacement)
+ masked = _mask_url_password(value)
+ if masked not in (value, replacement):
+ text = text.replace(masked, replacement)
+ return _mask_url_password(text)
diff --git a/solar_eval/hub_hook/target.py b/solar_eval/hub_hook/target.py
new file mode 100644
index 0000000000000000000000000000000000000000..51cf573aa6021a8b445d9542049e88f87109ed96
--- /dev/null
+++ b/solar_eval/hub_hook/target.py
@@ -0,0 +1,157 @@
+"""hub 대상 판정 — 한 곳 (계획 2026-09-27 L6 §2.3 · D-L6-1 · D-L6-10 · C-L6-8 · C-L6-9).
+
+`resolve_target(cfg, project)` 은 `Target` 이나 `Skip(code)` 를 돌려준다. 조건(순서대로):
+
+1. `cfg.repo_root` 없음 → `is_repo_managed` 도 거짓이면 `standalone`(조용히), 참이면 `no_repo_root`.
+2. `is_repo_managed(project)` 거짓 → `not_repo_managed`(번역·요약 등).
+3. `project_contexts(cfg.projects_dir, cfg.repo_root, only=[project])` 가 case 를 못 준다 → `case_unmapped`.
+4. resolve 한 `cfg.projects_dir` ≠ resolve 한 `data_home()/solar-eval-projects` → `data_home_mismatch`(검토 F4 —
+ `cfg.projects_dir` 는 resolve 되지 않은 경로다. 심볼릭 링크 홈은 양쪽을 풀어 같다).
+5. 훅이 **직접** 쓰는 루트(`hub_reports_dir()` — 잠금·상태 파일·색인 키)가 레포(링크된 워크트리면 메인 작업 트리도) 안 →
+ `data_home_inside_repo`(검토 F10 — PB 미러·사이드카 쓰기는 PB 가 막지만 이 셋은 PB 검사를 거치지 않는다).
+
+`poc_bundle` 은 함수 안에서만 import 한다(D-L6-14 — `solar-eval --help` 가 백필·미러를 로딩하지 않게).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+from solar_eval.hub_hook.types import (
+ PROJECTS_DIRNAME,
+ SKIP_CASE_UNMAPPED,
+ SKIP_DATA_HOME_INSIDE_REPO,
+ SKIP_DATA_HOME_MISMATCH,
+ SKIP_NO_REPO_ROOT,
+ SKIP_NOT_REPO_MANAGED,
+ SKIP_STANDALONE,
+ Skip,
+ Target,
+)
+
+if TYPE_CHECKING:
+ from solar_eval.cli.config import CLIConfig
+
+#: pnpm 워크스페이스 표식 — `workspace_root` 가 코드 체크아웃 루트를 찾는다(C-L6-9).
+WORKSPACE_MARKER = "pnpm-workspace.yaml"
+#: 훅 쓰기 위치가 레포 안인지 볼 때 `require_outside_repo` 에 주는 이름표.
+HOOK_WRITE_LABEL = "hook state"
+
+
+def resolve_target(
+ cfg: CLIConfig, project: str, env: Mapping[str, str] | None = None
+) -> Target | Skip:
+ """hub 대상 case 인가(§2.3). 레거시 적재는 어느 결과에서도 부르지 않는다(D-M2-4 (b))."""
+ repo_root = cfg.repo_root
+ if repo_root is None:
+ return Skip(SKIP_NO_REPO_ROOT if cfg.is_repo_managed(project) else SKIP_STANDALONE)
+ if not cfg.is_repo_managed(project):
+ return Skip(SKIP_NOT_REPO_MANAGED)
+ context = _project_context(cfg.projects_dir, repo_root, project)
+ if context is None or context.case_ref is None:
+ return Skip(SKIP_CASE_UNMAPPED)
+ mismatch = _data_home_mismatch(cfg.projects_dir, env)
+ if mismatch is not None:
+ return mismatch
+ inside = _hook_root_inside_repo(repo_root, env)
+ if inside is not None:
+ return inside
+ return Target(
+ project=project,
+ account=context.account,
+ case=context.case,
+ context=context,
+ repo_root=Path(repo_root),
+ projects_dir=Path(cfg.projects_dir),
+ # 설정 파일 `hub:` 절 — opt-in 해석은 `hook.gated_hub_leg` → `optin.resolve_hub_optin`(L10 §2.6).
+ hub_config=getattr(cfg, "hub", None),
+ )
+
+
+def _project_context(projects_dir: Path, repo_root: Path, project: str) -> Any | None:
+ from poc_bundle.census.scan import project_contexts
+
+ contexts = project_contexts(Path(projects_dir), Path(repo_root), only=[project])
+ return contexts[0] if contexts else None
+
+
+def _data_home_mismatch(projects_dir: Path, env: Mapping[str, str] | None) -> Skip | None:
+ from poc_bundle.home import data_home
+
+ actual = Path(projects_dir).expanduser().resolve()
+ expected = (data_home(env) / PROJECTS_DIRNAME).resolve()
+ if actual == expected:
+ return None
+ return Skip(SKIP_DATA_HOME_MISMATCH, {"projects_dir": str(actual), "expected": str(expected)})
+
+
+def _hook_root_inside_repo(repo_root: Path, env: Mapping[str, str] | None) -> Skip | None:
+ from poc_bundle.home import HomeError, hub_reports_dir, require_outside_repo
+ from poc_bundle.mirror import linked_main_work_tree
+
+ root = hub_reports_dir(env)
+ main = linked_main_work_tree(Path(repo_root))
+ for tree in (Path(repo_root),) if main is None else (Path(repo_root), main):
+ try:
+ require_outside_repo(root, tree, HOOK_WRITE_LABEL)
+ except HomeError:
+ return Skip(SKIP_DATA_HOME_INSIDE_REPO, {"hub_reports_dir": str(root)})
+ return None
+
+
+def case_ref_hint(cfg: CLIConfig, project: str) -> str | None:
+ """`--no-ingest` 안내용 case 이름 — `poc_bundle` 없이 config 디렉토리 모양(`accounts///03-evaluation`)에서.
+
+ 판정이 아니라 문구용이다. 레포를 모르면 None.
+ """
+ if cfg.repo_root is None:
+ return None
+ config_dir = cfg.config_dirs.get(project)
+ if config_dir is None:
+ return None
+ case_dir = Path(config_dir).parent
+ return f"{case_dir.parent.name}/{case_dir.name}"
+
+
+def workspace_root() -> Path | None:
+ """pnpm 을 돌릴 **코드 체크아웃** 루트 — `solar_eval` 패키지 파일에서 위로 `pnpm-workspace.yaml`(C-L6-9).
+
+ hub 의 `--repo` 는 config 루트 `cfg.repo_root` 다 — 합성 레포(`SOLAR_EVAL_CONFIG_ROOT`)는 pnpm 워크스페이스가 아니다.
+ """
+ import solar_eval
+
+ for parent in Path(solar_eval.__file__).resolve().parents:
+ if (parent / WORKSPACE_MARKER).is_file():
+ return parent
+ return None
+
+
+def is_smoke_run(source: Any) -> bool:
+ """`--limit` 스모크 run 인가(D-L6-10).
+
+ `source` 는 `RunPlan`(`.selection.limit` — `runs start`)이나 run.json 매핑(`dataset.selection.limit` — `runs resume`).
+ 양의 정수 limit 만 스모크다(불리언·음수·문자열은 아니다).
+ """
+ if isinstance(source, Mapping):
+ dataset = source.get("dataset")
+ selection = dataset.get("selection") if isinstance(dataset, Mapping) else None
+ limit = selection.get("limit") if isinstance(selection, Mapping) else None
+ else:
+ selection = getattr(source, "selection", None)
+ limit = getattr(selection, "limit", None)
+ return isinstance(limit, int) and not isinstance(limit, bool) and limit > 0
+
+
+def snapshot_evaluator_type(run_json: Mapping[str, Any]) -> str | None:
+ """run.json 스냅샷 `config.evaluator_config.config.type` — 모양이 하나라도 다르면 None(D-L6-8).
+
+ `poc_bundle.legacy_run._snapshot_evaluator_type` 과 같은 경로다(비공개 함수라 import 하지 않고 같은 규칙을 둔다).
+ """
+ node: Any = run_json.get("snapshot")
+ for key in ("config", "evaluator_config", "config", "type"):
+ if not isinstance(node, Mapping):
+ return None
+ node = node.get(key)
+ return node if isinstance(node, str) and node else None
diff --git a/solar_eval/hub_hook/types.py b/solar_eval/hub_hook/types.py
new file mode 100644
index 0000000000000000000000000000000000000000..48775849a4ebd53dfbfb2dfdb9dbca6521240767
--- /dev/null
+++ b/solar_eval/hub_hook/types.py
@@ -0,0 +1,332 @@
+"""run 종료 훅의 타입과 코드 상수 — 한 곳 (계획 2026-09-27 L6 §2.13 · §3.0 · 검토 F11).
+
+훅이 콘솔·상태 파일·종료 코드에 싣는 **모든** 코드 문자열과 접두어가 여기 있다. 병렬 태스크(L6a.1·L6a.2·L6b)가 새 코드가
+필요하면 제 모듈에 두지 않고 핸드오프에 적고, verify 가 이 파일로 모은다(계획 §6).
+
+표기: `LegResult.status` 는 `ok · failed · skipped · not_run` 넷이고 `code` 는 상태 접두 없이 싣는다 — 계획의 `failed:bundle_frozen:`
+는 `status="failed"`, `code="bundle_frozen:"` 이다. 값에는 코드·건수·id 만 둔다(고객 텍스트·URL·예외 원문 금지, D-L6-2·D-L6-4).
+
+이 모듈은 표준 라이브러리만 import 한다(D-L6-14).
+"""
+
+from __future__ import annotations
+
+import enum
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Literal
+
+# ── 상태·다리 ────────────────────────────────────────────────────────────
+
+STATUS_OK = "ok"
+STATUS_FAILED = "failed"
+STATUS_SKIPPED = "skipped"
+STATUS_NOT_RUN = "not_run"
+LegStatus = Literal["ok", "failed", "skipped", "not_run"]
+
+LEG_BUNDLE = "bundle"
+LEG_HUB = "hub"
+#: 잠금을 못 잡았을 때만 생기는 다리(두 다리는 not_run).
+LEG_LOCK = "lock"
+#: 훅 전체를 감싼 바깥 겹이 예외를 잡았을 때만 생기는 다리.
+LEG_HOOK = "hook"
+#: 상태 파일 쓰기 — 다리가 아니라 강등 코드(`status_exception:`)의 이름으로만 쓴다.
+LEG_STATUS_FILE = "status"
+#: L6b `runs eval` 새 경로의 발행(평가 디렉토리 → eval-wrap) 다리.
+LEG_PUBLISH = "publish"
+
+#: 예외 강등 코드의 가운데 — `bundle_exception:RuntimeError`(타입 이름만, D-L6-2).
+EXCEPTION_INFIX = "_exception:"
+
+# ── 명령·종료 코드 ───────────────────────────────────────────────────────
+
+COMMAND_START = "start"
+COMMAND_RESUME = "resume"
+COMMAND_BATCH = "batch"
+COMMAND_EVAL = "eval"
+Command = Literal["start", "resume", "batch", "eval"]
+
+#: `--strict-ingest` 에서 실패 다리가 하나라도 있으면(D-L6-11 · D-M2-3).
+EXIT_HOOK_FAILED = 3
+
+# ── hub 대상 판정(§2.3 — target.resolve_target) ─────────────────────────
+
+SKIP_STANDALONE = "standalone"
+SKIP_NOT_REPO_MANAGED = "not_repo_managed"
+SKIP_NO_REPO_ROOT = "no_repo_root"
+SKIP_CASE_UNMAPPED = "case_unmapped"
+SKIP_DATA_HOME_MISMATCH = "data_home_mismatch"
+SKIP_DATA_HOME_INSIDE_REPO = "data_home_inside_repo"
+TARGET_SKIP_CODES = frozenset({
+ SKIP_STANDALONE, SKIP_NOT_REPO_MANAGED, SKIP_NO_REPO_ROOT,
+ SKIP_CASE_UNMAPPED, SKIP_DATA_HOME_MISMATCH, SKIP_DATA_HOME_INSIDE_REPO,
+}) # fmt: skip
+
+#: 데이터 홈 아래 엔진 아카이브 디렉토리 이름(`cli/config.py` `default_projects_dir` 와 같다).
+PROJECTS_DIRNAME = "solar-eval-projects"
+
+# ── 다리 건너뜀 코드 ─────────────────────────────────────────────────────
+
+NO_INGEST = "no_ingest"
+SMOKE_RUN = "smoke_run"
+NOT_OPTED_IN = "not_opted_in"
+NO_BUNDLE = "no_bundle"
+NO_WORKSPACE = "no_workspace"
+NOT_PROOFREAD_FAMILY = "not_proofread_family"
+NO_LOCK = "no_lock"
+NO_POLICY_TEMPLATE = "no_policy_template"
+#: batch 한 run 의 hub 다리 — 끝 다리(`finish_batch`)가 채운다(D-L6-12).
+BATCH_PENDING = "batch_pending"
+#: 스캐폴드 다리 스텁(L6a.2 가 본문을 채운다).
+NOT_IMPLEMENTED = "not_implemented"
+
+# ── 잠금(D-L6-3) ─────────────────────────────────────────────────────────
+
+LOCK_TIMEOUT = "lock_timeout"
+HOOK_LOCK_TIMEOUT_SECONDS = 1200.0
+LOCK_POLL_SECONDS = 0.2
+#: 대기 중 "기다리는 중… N 초째" 안내 주기(검토 F8).
+LOCK_NOTICE_SECONDS = 5.0
+HOOK_DIRNAME = "hook"
+LOCK_FILENAME = "hook.lock"
+
+# ── 번들 다리(§2.5) ──────────────────────────────────────────────────────
+
+#: 백필 wrap 부류 중 "run 번들이 있다"(D-L6-2 — 아니면 hub 는 `no_bundle`).
+WRAP_WRITTEN = "written"
+WRAP_REUSED = "reused"
+WRAP_REWRAPPED = "rewrapped_input_fields"
+WRAP_BUNDLE_READY = frozenset({WRAP_WRITTEN, WRAP_REUSED, WRAP_REWRAPPED})
+#: 백필 부류 접두(`PB/src/poc_bundle/backfill/classes.py` 와 같은 값).
+WRAP_FROZEN_PREFIX = "frozen:"
+WRAP_ERROR_PREFIX = "error:"
+EVAL_WRAP_SKIP_PREFIX = "skip:"
+
+#: 번들 다리 단계 — 상태 파일 `legs.bundle` 의 detail 키이자 단계 예외 코드(`rescore_exception:`)의 이름
+#: (l6a-bundle-leg 가 `bundle_leg.py` 에 두었던 것을 verify 가 옮겼다).
+STEP_BACKFILL = "backfill"
+STEP_RESCORE = "rescore"
+STEP_INDEXES = "indexes"
+
+RUN_NOT_IN_ARCHIVE = "run_not_in_archive"
+BUNDLE_FROZEN_PREFIX = "bundle_frozen:"
+BUNDLE_ERROR_PREFIX = "bundle_error:"
+EVAL_WRAP_FAILED_PREFIX = "eval_wrap:"
+BUNDLE_INDEX_FAILED_PREFIX = "bundle_index:"
+MIRROR_UNADOPTABLE = "mirror_unadoptable"
+
+#: 재채점 대상 평가기 계열(D-L6-8 — 스냅샷 `config.evaluator_config.config.type`).
+PROOFREAD_EVALUATOR_TYPE = "lcs_diff"
+RESCORE_WRITTEN = "written"
+RESCORE_EXISTS = "exists"
+RESCORE_OK = frozenset({RESCORE_WRITTEN, RESCORE_EXISTS})
+RESCORE_EMPTY = "rescore_empty"
+RESCORE_SKIP_PREFIX = "rescore_skip:"
+RESCORE_ERROR_PREFIX = "rescore_error:"
+RESCORE_NO_RESULTS = "no_results"
+
+#: 색인 갱신 결과(D-L6-7). 실패는 `index_<명령>:`.
+INDEX_UNCHANGED = "unchanged"
+INDEX_WRITTEN = "written"
+INDEX_WRITTEN_DATASETS_ONLY = "written:datasets_only"
+INDEX_FAILED_PREFIX = "index_"
+INDEX_KEY_FILENAME = "index-key.json"
+#: 색인 명령(`poc-bundle index <명령>`) — 실패 코드 `index_<명령>:` 의 가운데(l6a-bundle-leg → verify 가 옮겼다).
+INDEX_CMD_DATASETS = "datasets"
+INDEX_CMD_TAGS = "tags"
+INDEX_CMD_POLICY = "policy"
+
+# ── hub 다리(§2.6) ───────────────────────────────────────────────────────
+
+HUB_INGEST_TIMEOUT_SECONDS = 900.0
+#: pnpm 필터 불일치 표식(`--fail-if-no-match` 종료 1 + 이 문구, pnpm 10.30.1).
+PNPM_NO_MATCH = "No projects matched"
+HUB_PACKAGE_FILTER = "@poc/eval-store"
+
+HUB_EXIT_OK = 0
+HUB_EXIT_REFUSED = 1
+HUB_EXIT_CASE = 2
+HUB_EXIT_SCHEMA = 3
+HUB_EXIT_EXPECT = 4
+
+HUB_FAILED_PREFIX = "hub_"
+HUB_EXIT_PREFIX = "hub_exit_"
+CASE_BLOCKED_PREFIX = "case_blocked:"
+CASE_FAILED_PREFIX = "case_failed:"
+SCHEMA_MISMATCH = "schema_mismatch"
+RUN_NOT_OK_PREFIX = "run_not_ok:"
+PNPM_MISSING = "pnpm_missing"
+TIMEOUT = "timeout"
+FILTER_NO_MATCH = "filter_no_match"
+HUB_OUTPUT_UNREADABLE = "hub_output_unreadable"
+#: hub ingest 요약의 `failed.stage` — 잠금 대기 한도 초과(D-L6-6).
+STAGE_CASE_LOCK = "case_lock"
+#: hub ingest 요약 `cases[].status` 중 종료 2 의 두 값(l6a-hub-ts-leg 가 `hub_leg.py` 에 적었던 것을 verify 가 옮겼다).
+HUB_CASE_BLOCKED = "blocked"
+HUB_CASE_FAILED = "failed"
+
+#: hub CLI stderr `[hub] 오류()` 의 코드 중 콘솔이 안내를 바꾸는 것.
+HUB_ERR_CONFIRMATION_REQUIRED = "confirmation_required"
+HUB_ERR_CONNECTION_FAILED = "connection_failed"
+HUB_ERR_UNKNOWN_CASE = "unknown_case"
+HUB_ERR_BAD_DATABASE_URL = "bad_database_url"
+HUB_ERR_BAD_ARGUMENT = "bad_argument"
+
+#: `--expect-run` 상태(§2.9).
+EXPECT_OK = "ok"
+EXPECT_MISSING = "missing"
+EXPECT_NO_USABLE_BUNDLE = "no_usable_bundle"
+EXPECT_ORPHANED = "orphaned"
+
+# ── L6b 발행(§2.12 B4) — `runs eval` 새 경로의 `publish` 다리(solar_eval.scoring_publish) ─────
+# 스캐폴드 제안값을 l6b-eval-dir 가 쓰며 확정했고 l6b-verify 가 detail 키까지 여기로 모았다. 발행 다리 code 어휘 전부:
+# publish_no_bundle:<번들 다리 code> — 번들이 없어 채점하지 않았다(예 `publish_no_bundle:bundle_frozen:input_keys_mismatch`)
+# publish_score_failed — 채점·평가 디렉토리 쓰기 실패(detail `exception: `, 끝나지 못한 디렉토리는 치운다)
+# publish_eval_wrap: · publish_eval_wrap:skip: — 채점은 평가 디렉토리에 남았고 발행만 못 했다
+# publish_exception: — 예기치 않은 예외(`exception_code(LEG_PUBLISH, exc)`, D-L6-2)
+
+PUBLISH_NO_BUNDLE_PREFIX = "publish_no_bundle:"
+PUBLISH_SCORE_FAILED = "publish_score_failed"
+PUBLISH_EVAL_WRAP_PREFIX = "publish_eval_wrap:"
+#: 발행 다리 detail 키(상태 파일 `legs.publish`) — 생산자는 `scoring_publish.publish`, 읽는 쪽은 콘솔·시험.
+PUBLISH_DETAIL_EVALUATION_ID = "evaluation_id"
+PUBLISH_DETAIL_EVALUATION_DIR_ID = "evaluation_dir_id"
+PUBLISH_DETAIL_SCORING_BUNDLE_IDS = "scoring_bundle_ids"
+PUBLISH_DETAIL_PURPOSE = "purpose"
+#: 채점 실패 건수(evaluation.json `eval_failed_count` — run.json `failed_sample_indices` 의 추론 실패와 다르다, §2.12 이름 주의).
+PUBLISH_DETAIL_EVAL_FAILED = "eval_failed"
+#: `publish_score_failed` 의 예외 타입 이름(원문 없음).
+PUBLISH_DETAIL_EXCEPTION = "exception"
+
+# ── 상태 파일(§2.7) ──────────────────────────────────────────────────────
+
+STATUS_SCHEMA = "solar-eval-hook/v1"
+
+
+class HookMode(enum.Enum):
+ """훅이 어떤 다리를 도는가."""
+
+ #: `runs start`·`runs resume` — 번들 → hub.
+ FULL = "full"
+ #: `runs batch` 한 run — 번들 다리만(hub 는 끝에 한 번, D-L6-12).
+ BATCH_ITEM = "batch_item"
+ #: L6b `runs eval` 새 경로.
+ EVAL = "eval"
+
+
+@dataclass(frozen=True)
+class LegResult:
+ """다리 하나의 결과. `detail` 은 상태 파일에 그대로 실린다(코드·건수·id 만)."""
+
+ status: LegStatus
+ code: str
+ detail: Mapping[str, Any] = field(default_factory=dict)
+ hint: str | None = None
+
+ @classmethod
+ def ok(cls, detail: Mapping[str, Any] | None = None, code: str = STATUS_OK) -> LegResult:
+ return cls(STATUS_OK, code, dict(detail or {}))
+
+ @classmethod
+ def failed(
+ cls, code: str, hint: str | None = None, detail: Mapping[str, Any] | None = None
+ ) -> LegResult:
+ return cls(STATUS_FAILED, code, dict(detail or {}), hint)
+
+ @classmethod
+ def skipped(cls, code: str, hint: str | None = None) -> LegResult:
+ return cls(STATUS_SKIPPED, code, {}, hint)
+
+ @classmethod
+ def not_run(cls, code: str = STATUS_NOT_RUN) -> LegResult:
+ return cls(STATUS_NOT_RUN, code)
+
+ @property
+ def is_failed(self) -> bool:
+ return self.status == STATUS_FAILED
+
+
+@dataclass(frozen=True)
+class HookResult:
+ """run 하나의 훅 결과. `case` 는 hub 대상이 아니면 None."""
+
+ run_id: str
+ case: str | None
+ legs: Mapping[str, LegResult]
+ started_at: str | None = None
+ finished_at: str | None = None
+
+ @property
+ def failed_legs(self) -> tuple[str, ...]:
+ return tuple(name for name, leg in self.legs.items() if leg.is_failed)
+
+ @property
+ def ok(self) -> bool:
+ return not self.failed_legs
+
+
+@dataclass(frozen=True)
+class HookOptions:
+ """run 명령이 훅에 넘기는 선택. `strict` 는 플래그와 환경 변수를 합친 값이다(optin.strict_requested)."""
+
+ command: Command
+ mode: HookMode = HookMode.FULL
+ no_ingest: bool = False
+ strict: bool = False
+ #: `--limit` 스모크 run — 번들·hub 다리 모두 `smoke_run`(D-L6-10).
+ smoke: bool = False
+
+
+@dataclass(frozen=True)
+class Skip:
+ """hub 대상이 아니다(§2.3). `detail` 에는 경로만(콘솔용)."""
+
+ code: str
+ detail: Mapping[str, str] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class Target:
+ """hub 대상 case. `context` 는 `poc_bundle.census.scan.ProjectContext`(lock 포함) — 타입은 import 경계 때문에 Any.
+
+ `hub_config` 는 설정 파일 `hub:` 절(`cli.config.HubConfig`, 없으면 None — L10 §2.6). 해석은 `optin.resolve_hub_optin`
+ 한 곳이고, 값(자격증명 URL)이 `repr` 에 실리지 않게 `repr=False` 다.
+ """
+
+ project: str
+ account: str
+ case: str
+ context: Any
+ repo_root: Path
+ projects_dir: Path
+ hub_config: Any = field(default=None, repr=False)
+
+ @property
+ def case_ref(self) -> str:
+ return f"{self.account}/{self.case}"
+
+
+def exception_code(leg: str, exc: BaseException) -> str:
+ """다리 예외를 코드로 — 타입 이름만 싣는다(트레이스백·원문은 입력 조각을 담을 수 있다, D-L6-2)."""
+ return f"{leg}{EXCEPTION_INFIX}{type(exc).__name__}"
+
+
+def bundle_ready(leg: LegResult | None) -> bool:
+ """번들 다리가 run 번들을 남겼는가 — `detail.backfill.wrap` 이 written·reused·rewrapped 중 하나."""
+ if leg is None:
+ return False
+ backfill = leg.detail.get("backfill")
+ return isinstance(backfill, Mapping) and backfill.get("wrap") in WRAP_BUNDLE_READY
+
+
+def hub_run_id(leg: LegResult | None, fallback: str) -> str:
+ """hub 에 적재되는 run 의 id — 번들 다리가 wrap 한 번들의 run_id(run.json `run_id`, PB `RunReport.run_id`).
+
+ hub.runs·미러 번들·재채점 필터는 run.json `run_id` 를 쓴다. 훅의 run_id(상태 파일 이름)는 run **디렉토리 이름**이라, 이름을 바꾼
+ 디렉토리나 사본 run 에서는 둘이 다르다 — hub `--expect-run` 은 이 값을 써야 적재가 된 run 을 `missing` 으로 보지 않는다(l6a-verify
+ 리뷰 지적 3). 번들 다리 detail 에 run_id 가 없으면 `fallback`(디렉토리 이름).
+ """
+ backfill = leg.detail.get(STEP_BACKFILL) if leg is not None else None
+ run_id = backfill.get("run_id") if isinstance(backfill, Mapping) else None
+ return run_id if isinstance(run_id, str) and run_id else fallback
diff --git a/solar_eval/models/__pycache__/__init__.cpython-312.pyc b/solar_eval/models/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..2cf0af6a18f571b28794e3294cf39bda70987e14
Binary files /dev/null and b/solar_eval/models/__pycache__/__init__.cpython-312.pyc differ
diff --git a/solar_eval/models/__pycache__/base.cpython-312.pyc b/solar_eval/models/__pycache__/base.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..050dd8e24e23e47361b722d954ce3c2664b35008
Binary files /dev/null and b/solar_eval/models/__pycache__/base.cpython-312.pyc differ
diff --git a/solar_eval/models/__pycache__/enums.cpython-312.pyc b/solar_eval/models/__pycache__/enums.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..6e5d473d9a9a28eccbcd47ceda85b6d2df75aa70
Binary files /dev/null and b/solar_eval/models/__pycache__/enums.cpython-312.pyc differ
diff --git a/solar_eval/models/__pycache__/prompt_version.cpython-312.pyc b/solar_eval/models/__pycache__/prompt_version.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c3c561aba070712ce0bb18ad796e29553a861a14
Binary files /dev/null and b/solar_eval/models/__pycache__/prompt_version.cpython-312.pyc differ
diff --git a/solar_eval/models/__pycache__/sample.cpython-312.pyc b/solar_eval/models/__pycache__/sample.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..e71310cdbfac48d8fb0c0e8660873c2916545125
Binary files /dev/null and b/solar_eval/models/__pycache__/sample.cpython-312.pyc differ
diff --git a/solar_eval/models/project.py b/solar_eval/models/project.py
index 46d5a067820e1cece3d07e108d305c44e0e85757..2df0a3c43b62b5335b92c6287bbc99577ebe346f 100644
--- a/solar_eval/models/project.py
+++ b/solar_eval/models/project.py
@@ -39,6 +39,11 @@ class TaskConfig(CustomBaseModel):
pipeline: PipelineType
pipeline_config: PipelineConfig | None = None
evaluator: EvaluatorConfig
+ # 행 필터 `{tags: [...], category: [...]}` -- 태스크에만 둔다(`defaults.where` 는 run
+ # 시작 시 거부). 검증 정본은 `core.row_filter.parse_where`.
+ where: dict[str, list[str]] | None = None
+ # results 행 `meta` 로 옮길 데이터 행 키 -- 검증 정본은 `core.dataset_prep.validate_meta_fields`.
+ meta_fields: list[str] | None = None
class DatasetConfig(CustomBaseModel):
diff --git a/solar_eval/models/prompt_version.py b/solar_eval/models/prompt_version.py
index 69cfe167b199880db5941c88e5b99427e04b017d..a630dd83201baa198df9f40f9f6592f0ed95c26e 100644
--- a/solar_eval/models/prompt_version.py
+++ b/solar_eval/models/prompt_version.py
@@ -141,13 +141,16 @@ def _load_prompt_messages_from_dir(dir_path: Path) -> list[dict[str, str]]:
def _detect_prompt_format_dir(dir_path: Path) -> str:
- """Detect format from a prompt directory."""
- step_files = list(dir_path.glob("step*.txt"))
- if step_files:
- return "multi_step"
+ """Detect format from a prompt directory.
+
+ system.txt 가 있으면 single_step, 그 밖의 .txt(step1.txt 든 solar_list.txt 든 - 파일 이름이 곧 prompt_key)가
+ 하나라도 있으면 multi_step. 2026-10-15: 목록 모드 파이프라인은 step*.txt 없이 solar_list·fact_audit 만 쓴다.
+ """
if (dir_path / "system.txt").exists():
return "single_step"
+ if any(f.stem not in ("system", "user") for f in dir_path.glob("*.txt")):
+ return "multi_step"
raise ValueError(
f"Cannot detect prompt format in {dir_path}: "
- "expected step*.txt files (multi_step) or system.txt (single_step)"
+ "expected step prompt .txt files (multi_step) or system.txt (single_step)"
)
diff --git a/solar_eval/models/run.py b/solar_eval/models/run.py
index 2c55b78e9ef01755c59ce9958cd79ee489e8248d..d50996b4df294e887ee3e68f71de5c55690fba63 100644
--- a/solar_eval/models/run.py
+++ b/solar_eval/models/run.py
@@ -31,9 +31,11 @@ class RunResultResponse(CustomBaseModel):
input: dict[str, Any]
output: str
golden: Any
- input_tokens: int
- output_tokens: int
- inference_time_ms: float
+ # 사용량·지연이 측정되지 않은 행은 null 이다 -- 0 은 "0 을 썼다" 는 측정값이라 구분한다
+ # (L6b B1 · D-M2-24, `core/run_records.py` `build_result_row`).
+ input_tokens: int | None = None
+ output_tokens: int | None = None
+ inference_time_ms: float | None = None
class EvaluationResponse(CustomBaseModel):
@@ -41,7 +43,8 @@ class EvaluationResponse(CustomBaseModel):
run_id: str
scores: dict[str, float]
overall_score: float
- eval_model: str
+ # judge 모델이 없는 평가기(lcs_diff·rule_based)는 null 을 보고한다 (계획 D11).
+ eval_model: str | None
evaluated_at: str
diff --git a/solar_eval/pipelines/__pycache__/__init__.cpython-312.pyc b/solar_eval/pipelines/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..4c0f81f063842d75bf2789869ab14e657d3d7299
Binary files /dev/null and b/solar_eval/pipelines/__pycache__/__init__.cpython-312.pyc differ
diff --git a/solar_eval/pipelines/__pycache__/base.cpython-312.pyc b/solar_eval/pipelines/__pycache__/base.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..e5bdfe1ca0c9b2d63a096fdc84c344b6101b7a30
Binary files /dev/null and b/solar_eval/pipelines/__pycache__/base.cpython-312.pyc differ
diff --git a/solar_eval/pipelines/__pycache__/corrections.cpython-312.pyc b/solar_eval/pipelines/__pycache__/corrections.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..6bdd1b44c85ef7db94fb163deb4c87b0536e00d1
Binary files /dev/null and b/solar_eval/pipelines/__pycache__/corrections.cpython-312.pyc differ
diff --git a/solar_eval/pipelines/__pycache__/errors.cpython-312.pyc b/solar_eval/pipelines/__pycache__/errors.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..25485f745b478126cd9a9cfef3d474a2a6242d00
Binary files /dev/null and b/solar_eval/pipelines/__pycache__/errors.cpython-312.pyc differ
diff --git a/solar_eval/pipelines/__pycache__/judge.cpython-312.pyc b/solar_eval/pipelines/__pycache__/judge.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..c468e20a4ba446e4cf0345a50250efb5062154b2
Binary files /dev/null and b/solar_eval/pipelines/__pycache__/judge.cpython-312.pyc differ
diff --git a/solar_eval/pipelines/__pycache__/pipeline.cpython-312.pyc b/solar_eval/pipelines/__pycache__/pipeline.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..a71beacb984888ab67cbad14a385321361724923
Binary files /dev/null and b/solar_eval/pipelines/__pycache__/pipeline.cpython-312.pyc differ
diff --git a/solar_eval/pipelines/__pycache__/registry.cpython-312.pyc b/solar_eval/pipelines/__pycache__/registry.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..48fddf8ab399ac5e41ab552e322efef59894ff4b
Binary files /dev/null and b/solar_eval/pipelines/__pycache__/registry.cpython-312.pyc differ
diff --git a/solar_eval/pipelines/__pycache__/rules.cpython-312.pyc b/solar_eval/pipelines/__pycache__/rules.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..26c0255eb923ebd79bfd6467940519a3f405a400
Binary files /dev/null and b/solar_eval/pipelines/__pycache__/rules.cpython-312.pyc differ
diff --git a/solar_eval/pipelines/corrections.py b/solar_eval/pipelines/corrections.py
new file mode 100644
index 0000000000000000000000000000000000000000..e11a2a4001e4f5fa1fc90c9e91e29bc0e6f99604
--- /dev/null
+++ b/solar_eval/pipelines/corrections.py
@@ -0,0 +1,247 @@
+"""교정 목록을 본문에 적용하는 결정론 규칙 — `apply_corrections` 스텝의 순수 함수.
+
+LLM 이 전문을 다시 쓰는 대신 `{"corrections": [{"original": …, "corrected": …}]}` 만 내면(`llm` 스텝 `output_mode: corrections`),
+여기서 각 항목을 본문에 치환한다. 규칙:
+
+- `original` 이 본문에 **정확히 한 번** 있어야 한다(공백 차이는 무시). 0번·2번 이상이면 건너뛴다(`skipped` 로 셈).
+- `original == corrected` 거나 둘 중 하나가 비면 건너뛴다. `corrected` 가 비어 있으면 삭제로 보되 `allow_delete` 일 때만.
+- 치환은 뒤에서부터 적용해 앞 치환이 뒤 위치를 흔들지 않게 한다. 두 항목의 구간이 겹치면 뒤 항목을 건너뛴다.
+- `max_items` 를 넘는 목록은 앞에서부터 그 수만 쓴다(폭주 방지).
+- `protect_digits` 면 숫자 글자 열이 달라지는 항목(연도·금액·비율 변경)을, `max_shrink` 면 공백 제외 길이가 그 비율 아래로 줄어드는
+ 항목(어절 삭제)을 건너뛴다(`skipped_protected`). 규범 교열 목록 스텝이 숫자를 고치거나 어절을 지우는 사고를 결정론으로 막는 용도다
+ (2026-10-15 이투데이 1·22번 재현: pro4 목록이 연도를 바꾸고 회사명을 지웠다).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass(frozen=True)
+class ApplyReport:
+ applied: int
+ skipped_not_found: int
+ skipped_ambiguous: int
+ skipped_overlap: int
+ skipped_invalid: int
+ skipped_protected: int = 0
+
+ def as_dict(self) -> dict[str, int]:
+ return dict(self.__dict__)
+
+
+def _normalize(text: str) -> tuple[str, list[int]]:
+ out: list[str] = []
+ idx: list[int] = []
+ for i, ch in enumerate(text):
+ if ch.isspace() or ch == " ":
+ continue
+ out.append(ch)
+ idx.append(i)
+ return "".join(out), idx
+
+
+def _locate(norm_text: str, raw_index: list[int], needle: str) -> tuple[int, int] | str:
+ """원본 [s, e) 또는 'not_found' | 'ambiguous'."""
+ n, _ = _normalize(needle)
+ if not n:
+ return "not_found"
+ first = norm_text.find(n)
+ if first < 0:
+ return "not_found"
+ if norm_text.find(n, first + 1) >= 0:
+ return "ambiguous"
+ return raw_index[first], raw_index[first + len(n) - 1] + 1
+
+
+@dataclass(frozen=True)
+class Hunk:
+ """원문 대비 바뀐 자리 하나 — 현재 본문의 `[start, end)` 를 `before` 로 되돌리면 원문과 같아진다."""
+
+ id: int
+ start: int
+ end: int
+ before: str # 원문 쪽 글자
+ after: str # 현재 본문 쪽 글자
+ left: str # 앞 문맥(현재 본문)
+ right: str # 뒤 문맥(현재 본문)
+
+ def as_dict(self) -> dict[str, Any]:
+ return dict(self.__dict__)
+
+
+def diff_hunks(
+ original: str,
+ current: str,
+ *,
+ context: int = 20,
+ max_hunks: int = 80,
+ skip_spacing: bool = False,
+) -> list[Hunk]:
+ """어절(공백 분리) 단위 SequenceMatcher 로 원문→현재 변경 구간을 뽑는다. 현재 본문 기준 문자 오프셋을 함께 준다.
+
+ `skip_spacing` 이면 띄어쓰기만 다른 변경(공백을 빼면 같은 글자열)은 목록에 넣지 않는다. 번호도 매기지 않으므로 되돌릴 수 없고,
+ 검증 LLM 이 읽을 목록이 짧아진다.
+ """
+ import difflib
+
+ o_tokens = _tokens(original)
+ c_tokens = _tokens(current)
+ sm = difflib.SequenceMatcher(
+ None, [t for t, _ in o_tokens], [t for t, _ in c_tokens], autojunk=False
+ )
+ hunks: list[Hunk] = []
+ for tag, i1, i2, j1, j2 in sm.get_opcodes():
+ if tag == "equal":
+ continue
+ if len(hunks) >= max_hunks:
+ break
+ if j1 < j2:
+ start, end = c_tokens[j1][1], c_tokens[j2 - 1][1] + len(c_tokens[j2 - 1][0])
+ else: # 삭제 — 현재 본문에는 자리만 있다
+ start = end = c_tokens[j1][1] if j1 < len(c_tokens) else len(current)
+ before = (
+ original[o_tokens[i1][1] : o_tokens[i2 - 1][1] + len(o_tokens[i2 - 1][0])]
+ if i1 < i2
+ else ""
+ )
+ after = current[start:end]
+ if (
+ skip_spacing
+ and "".join(before.split()) == "".join(after.split())
+ and before.count("\n") == after.count("\n") # 문단 합치기·나누기는 띄어쓰기가 아니다
+ ):
+ continue
+ hunks.append(
+ Hunk(
+ id=len(hunks) + 1,
+ start=start,
+ end=end,
+ before=before,
+ after=after,
+ left=current[max(0, start - context) : start],
+ right=current[end : end + context],
+ )
+ )
+ return hunks
+
+
+def _tokens(text: str) -> list[tuple[str, int]]:
+ """(어절, 시작 오프셋). 개행도 어절 하나로 둬 문단 경계 변경이 보이게 한다."""
+ out: list[tuple[str, int]] = []
+ i = 0
+ n = len(text)
+ while i < n:
+ ch = text[i]
+ if ch == "\n":
+ out.append(("\n", i))
+ i += 1
+ continue
+ if ch.isspace():
+ i += 1
+ continue
+ j = i
+ while j < n and not text[j].isspace():
+ j += 1
+ out.append((text[i:j], i))
+ i = j
+ return out
+
+
+def render_hunks(hunks: Sequence[Hunk]) -> str:
+ """검증 LLM 에 보여 줄 번호 목록. 개행은 ⏎ 로 보여 한 줄을 지킨다."""
+
+ def show(s: str) -> str:
+ return s.replace("\n", "⏎")
+
+ lines = [
+ f"#{h.id} …{show(h.left)}「{show(h.before) or '∅'}」→「{show(h.after) or '∅'}」{show(h.right)}…"
+ for h in hunks
+ ]
+ return "\n".join(lines) if lines else "(변경 없음)"
+
+
+def revert_hunks(
+ current: str, hunks: Sequence[Mapping[str, Any]], ids: Sequence[Any]
+) -> tuple[str, dict[str, Any]]:
+ """번호의 변경을 원문대로 되돌린다(현재 본문의 [start,end) ← before). 모르는 번호·겹침은 무시하고 보고한다."""
+ by_id = {int(h["id"]): h for h in hunks if isinstance(h, Mapping) and "id" in h}
+ wanted: list[int] = []
+ unknown: list[Any] = []
+ for raw in ids:
+ try:
+ i = int(raw)
+ except (TypeError, ValueError):
+ unknown.append(raw)
+ continue
+ if i in by_id and i not in wanted:
+ wanted.append(i)
+ else:
+ unknown.append(raw)
+ chosen = sorted((by_id[i] for i in wanted), key=lambda h: int(h["start"]), reverse=True)
+ out = current
+ reverted = 0
+ for h in chosen:
+ s, e = int(h["start"]), int(h["end"])
+ if out[s:e] != str(h.get("after", "")):
+ unknown.append(int(h["id"])) # 앞 되돌림이 자리를 흔들었거나 본문이 바뀜 — 건너뛴다
+ continue
+ before = str(h.get("before", ""))
+ if s == e and before and before != "\n" and e < len(out) and not out[e].isspace():
+ before += " " # 지워진 어절을 다시 넣을 때 다음 어절과 붙지 않게
+ out = out[:s] + before + out[e:]
+ reverted += 1
+ return out, {"requested": len(ids), "reverted": reverted, "ignored": unknown}
+
+
+def _digits(s: str) -> str:
+ return "".join(ch for ch in s if ch.isdigit())
+
+
+def apply_corrections(
+ text: str,
+ corrections: Sequence[Mapping[str, Any]],
+ *,
+ max_items: int = 50,
+ allow_delete: bool = False,
+ protect_digits: bool = False,
+ max_shrink: float | None = None,
+) -> tuple[str, ApplyReport]:
+ norm_text, raw_index = _normalize(text)
+ spans: list[tuple[int, int, str]] = []
+ not_found = ambiguous = overlap = invalid = protected = 0
+ for c in list(corrections)[:max_items]:
+ original = c.get("original") if isinstance(c, Mapping) else None
+ corrected = c.get("corrected") if isinstance(c, Mapping) else None
+ if not isinstance(original, str) or not isinstance(corrected, str) or not original.strip():
+ invalid += 1
+ continue
+ if original == corrected or (not corrected.strip() and not allow_delete):
+ invalid += 1
+ continue
+ if protect_digits and _digits(original) != _digits(corrected):
+ protected += 1
+ continue
+ if max_shrink is not None and len(_normalize(corrected)[0]) < max_shrink * len(
+ _normalize(original)[0]
+ ):
+ protected += 1
+ continue
+ loc = _locate(norm_text, raw_index, original)
+ if loc == "not_found":
+ not_found += 1
+ continue
+ if loc == "ambiguous":
+ ambiguous += 1
+ continue
+ s, e = loc # type: ignore[misc]
+ if any(s < e2 and s2 < e for s2, e2, _ in spans):
+ overlap += 1
+ continue
+ spans.append((s, e, corrected))
+ out = text
+ for s, e, rep in sorted(spans, key=lambda x: x[0], reverse=True):
+ out = out[:s] + rep + out[e:]
+ return out, ApplyReport(len(spans), not_found, ambiguous, overlap, invalid, protected)
diff --git a/solar_eval/pipelines/pipeline.py b/solar_eval/pipelines/pipeline.py
index 395320d1cb660fcf997fecc8f8f71f5d743c1181..81c7c10b6beb40eb75e1f3462c6d7a87cb03d923 100644
--- a/solar_eval/pipelines/pipeline.py
+++ b/solar_eval/pipelines/pipeline.py
@@ -14,13 +14,15 @@ rule 후처리 블록이 끼어드는 구조가 이 모델의 원형이다.
"""
import asyncio
+import inspect
import logging
+import math
import time
from abc import ABC, abstractmethod
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from pathlib import Path
-from collections.abc import Callable
-from typing import Any
+from typing import Any, ClassVar, Literal
from solar_eval.core.dataset_loader import DatasetLoader
from solar_eval.models.sample import EvalSample
@@ -30,6 +32,53 @@ from solar_eval.providers.base import BaseProvider
logger = logging.getLogger(__name__)
+#: 자원 라벨의 종류 — `config:`(레포 config 정본 상대) · `data:`(데이터 루트 상대) · `hf:`(원격 선언).
+ResourceKind = Literal["config", "data", "hf"]
+
+#: llm 스텝 분할 방식 — `PipelineEnv.text_split_mode` 가 받는 값.
+TEXT_SPLIT_MODES = ("chars", "lines")
+
+
+@dataclass(frozen=True)
+class ResourceRef:
+ """스텝이 실제로 읽을 파일 하나의 기술 — run.json 스냅샷의 `resources` 재료.
+
+ 순수 dataclass 다(파일을 읽지도 해시하지도 않는다). 해시는 `core/snapshot/resources.py`
+ 가 뜬다 — 이 모듈은 HF Space 벤더 사본도 import 하므로 poc-bundle 패키지에 기대면 안 된다
+ (계획 2026-09-24-phase1a D18).
+
+ Attributes:
+ kind: 라벨 종류. `hf` 는 네트워크를 타지 않으므로 `path` 가 None 이다.
+ rel: 라벨 본문 — `config` 는 config_dir 상대, `data` 는 데이터 루트 상대(풀 수 없으면
+ 절대) POSIX 경로, `hf` 는 `/`.
+ path: 해시할 로컬 파일. `hf` 면 None.
+ """
+
+ kind: ResourceKind
+ rel: str
+ path: Path | None
+
+ @property
+ def label(self) -> str:
+ return f"{self.kind}:{self.rel}"
+
+
+def relative_posix(path: Path, root: Path | str | None) -> str:
+ """`root` 기준 POSIX 상대경로 — `ResourceRef.rel` 을 만드는 규칙.
+
+ 둘 다 `resolve()` 한 뒤 비교한다(macOS 의 `/tmp` → `/private/tmp` 같은 심볼릭 링크 차이).
+ 풀 수 없으면(루트 밖·루트 없음) 절대 POSIX 경로다. `data:` 자원은 어차피 재현 판정에서
+ `resource_outside_repo` 로 드러나고, `config:` 자원은 config_dir 밖으로 풀리는 파일을
+ `rules._config_candidate` 가 먼저 거부하므로 절대경로 `config:` 라벨은 생기지 않는다.
+ """
+ if root is not None:
+ try:
+ return path.resolve().relative_to(Path(root).resolve()).as_posix()
+ except ValueError:
+ pass
+ return path.resolve().as_posix()
+
+
@dataclass
class PipelineEnv:
"""파이프라인 생성 시점에 고정되는 정적 환경. 스텝 생성자에 전달된다."""
@@ -37,14 +86,6 @@ class PipelineEnv:
prompts: dict[str, str] = field(default_factory=dict)
input_fields: list[str] = field(default_factory=list)
text_split_size: int = 500
- # bulk 분할 전략 -- "dp"(운영) | "greedy"(regex 사다리, ablation 전용). steps/chunking.py 참조.
- text_split_strategy: str = "dp"
- # 문서 단위 chunk (실험, 2026-09-10): 입구에서 한 번 DP 분할해 chunk 마다 **전 스텝**을 돌리고 원장으로 접합.
- # 켜면 분할 없이 전문을 받던 스텝(basic_correction 의 user_template)도 chunk 의 원문만 본다.
- document_chunking: bool = False
- # 문서 단위 chunk 를 동시에 몇 개까지 돌릴지. chunk 마다 LLM 호출이 순차로 이어지므로 이 값 × 러너 워커 수가
- # 동시 API 호출 상한이다 (solar-pro4 는 10 동시부터 429 가 났다 -- README "지뢰").
- document_chunk_concurrency: int = 4
dataset_loader: DatasetLoader | None = None
# 데이터 루트의 프로젝트 디렉토리. v24 의 tool_calling_judge 가 pmi_lookup
# (1.5MB 파생 통계 — 사람이 편집하지 않는 데이터 자산) 을 풀 때 쓰인다.
@@ -52,15 +93,32 @@ class PipelineEnv:
# 레포의 config 정본 디렉토리(`accounts///03-evaluation`). 치환 사전처럼
# **사람이 편집하는 지식 자산**은 데이터가 아니라 설정이라 여기서 읽는다.
config_dir: Path | None = None
-
-
-# 진행 콜백 -- (event, index, total, step_name, doc_chunk). 데모 UI 가 "지금 어느 단계인가" 를 보여주는 용도,
-# 결과에는 아무 영향이 없다.
-# event: "start" | "done" (스텝), "chunk" (LLM 스텝의 bulk 진행), "document" (문서 단위 chunk 시작, step_name 은 "")
-# doc_chunk: 문서 단위 chunk 모드에서 (chunk 번호, chunk 수), 아니면 None. chunk 들이 **병렬**로 돌므로
-# 이벤트가 섞여 들어온다 -- UI 는 이 값으로 줄을 나눠야 한다.
-DocChunk = tuple[int, int] | None
-StepCallback = Callable[[str, int, int, str, DocChunk], None]
+ # 전체 스텝 체인 공유 데드라인(초). None 이면 무제한 — 기존 동작 유지.
+ # run마다 새로 시작되며 스텝 사이에서 리셋되지 않는다.
+ timeout_seconds: float | None = None
+ # llm 스텝의 분할 방식. "chars" = text_split_size 글자마다 자른다(기존 동작 — 문장 중간에서도 끊긴다).
+ # "lines" = 개행 경계로만 묶는다(한 줄이 한도보다 길면 자르지 않고 그 줄 하나를 bulk 로).
+ text_split_mode: str = "chars"
+
+ def __post_init__(self) -> None:
+ if self.text_split_mode not in TEXT_SPLIT_MODES:
+ raise ValueError(
+ f"text_split_mode must be one of {TEXT_SPLIT_MODES}, got {self.text_split_mode!r}"
+ )
+ # None = 무제한. 그 외에는 "유한한 양수"만 받는다: bool 은 int 의
+ # 서브클래스라 명시적으로 거부하고, inf/nan/음수/0 은 즉시 ValueError.
+ value = self.timeout_seconds
+ if value is None:
+ return
+ if (
+ isinstance(value, bool)
+ or not isinstance(value, (int, float))
+ or not math.isfinite(value)
+ or value <= 0
+ ):
+ raise ValueError(
+ f"timeout_seconds must be None or a finite positive number, got {value!r}"
+ )
@dataclass
@@ -83,8 +141,6 @@ class StepContext:
usage: dict[str, int] = field(
default_factory=lambda: {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
)
- on_step: StepCallback | None = None
- doc_chunk: DocChunk = None # 문서 단위 chunk 모드에서 이 컨텍스트가 담당하는 chunk
def add_usage(self, usage: dict[str, Any]) -> None:
for k in self.usage:
@@ -100,11 +156,23 @@ class BaseStep(ABC):
모르는 이름은 API 호출 한 번 없이 즉시 실패한다.
"""
+ #: 이 스텝이 모델을 호출하는가 — run.json `reproducibility.deterministic` 판정 재료
+ #: (모든 스텝이 False 이고 평가기 judge 가 없으면 결정적 run 이다).
+ uses_model: ClassVar[bool] = False
+
def __init__(self, config: dict[str, Any], env: PipelineEnv) -> None:
self.config = config
self.env = env
self.name: str = config.get("name", config.get("type", type(self).__name__))
+ def resource_refs(self) -> list[ResourceRef]:
+ """이 스텝이 실행 중 읽을 지식 자산(사전·화이트리스트·PMI 등).
+
+ 스냅샷이 run 시작 **전에** 부른다 — 선언했는데 못 찾는 자원은 여기서
+ `KnowledgeAssetError` 로 죽는다(run 도중이 아니라). 자원이 없는 스텝은 기본값 [].
+ """
+ return []
+
@abstractmethod
async def run(self, sample: EvalSample, ctx: StepContext) -> None:
"""`ctx.text` 를 변환하고/하거나 `sample.artifacts` 를 채운다."""
@@ -131,125 +199,89 @@ class Pipeline(BasePipeline):
max_tokens: int = 8000,
reasoning_effort: str | None = None,
messages: list[dict[str, str]] | None = None,
- on_step: StepCallback | None = None,
+ on_step: Callable[[str, EvalSample, StepContext], Awaitable[None] | None] | None = None,
) -> EvalSample:
input_data = sample.input if isinstance(sample.input, dict) else {}
original_text = input_data.get("original", "")
+ ctx = StepContext(
+ provider=provider,
+ model=model,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ reasoning_effort=reasoning_effort,
+ system_prompt=prompts,
+ messages=messages,
+ original_text=original_text,
+ text=original_text,
+ shared=self._shared,
+ )
start = time.monotonic()
+ # 체인 전체에 걸리는 데드라인 하나 -- 스텝 사이에서 리셋되지 않는다.
+ # asyncio.timeout 은 취소를 스텝/프로바이더까지 전파해(예: BudgetedProvider,
+ # BatchRunner 의 실패 샘플 기록) 만료 후 어떤 성공 결과도 커밋되지 않게 한다.
+ timeout_seconds = self.env.timeout_seconds
+ deadline = start + timeout_seconds if timeout_seconds is not None else None
- def make_ctx(text: str, doc_chunk: DocChunk = None) -> StepContext:
- return StepContext(
- provider=provider,
- model=model,
- temperature=temperature,
- max_tokens=max_tokens,
- reasoning_effort=reasoning_effort,
- system_prompt=prompts,
- messages=messages,
- original_text=text,
- text=text,
- shared=self._shared,
- on_step=on_step,
- doc_chunk=doc_chunk,
- )
-
- if self.env.document_chunking and len(original_text) > self.env.text_split_size:
- await self._run_by_document_chunks(sample, input_data, original_text, make_ctx, on_step)
- else:
- ctx = make_ctx(original_text)
- await self._run_chain(sample, ctx, on_step)
- sample.output = ctx.text
- sample.artifacts["usage"] = ctx.usage
- sample.artifacts["step_outputs"] = ctx.step_outputs
-
- sample.artifacts["inference_time_ms"] = (time.monotonic() - start) * 1000
- # completion 스텝은 자기 finish_reason 을 직접 남긴다 -- 없을 때만 기본값.
- sample.artifacts.setdefault("finish_reason", "stop")
- return sample
-
- async def _run_chain(
- self, sample: EvalSample, ctx: StepContext, on_step: StepCallback | None
- ) -> None:
- """스텝 목록을 순서대로 한 번 돈다. `ctx.text` 가 결과, `ctx.step_outputs` 가 스냅숏."""
from datetime import datetime
- for step_i, step in enumerate(self.steps):
- ts = datetime.now().strftime("%H:%M:%S")
- logger.info(
- f" [{ts}] step {step_i + 1}/{len(self.steps)} "
- f"'{step.name}' ({step.config.get('type', '?')}) start"
- )
- if on_step:
- on_step("start", step_i + 1, len(self.steps), step.name, ctx.doc_chunk)
- try:
- await step.run(sample, ctx)
- except Exception as e:
+ step_timings: dict[str, float] = {}
+
+ async def _run_chain() -> None:
+ for step_i, step in enumerate(self.steps):
+ if deadline is not None:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError(
+ f"pipeline timeout_seconds={timeout_seconds} expired "
+ f"before step {step_i + 1}/{len(self.steps)} '{step.name}'"
+ )
ts = datetime.now().strftime("%H:%M:%S")
- logger.error(
- f" [{ts}] step {step_i + 1} '{step.name}' FAILED: {type(e).__name__}: {e}"
+ logger.info(
+ f" [{ts}] step {step_i + 1}/{len(self.steps)} "
+ f"'{step.name}' ({step.config.get('type', '?')}) start"
+ )
+ step_start = time.monotonic()
+ try:
+ await step.run(sample, ctx)
+ except Exception as e:
+ ts = datetime.now().strftime("%H:%M:%S")
+ logger.error(
+ f" [{ts}] step {step_i + 1} '{step.name}' FAILED: {type(e).__name__}: {e}"
+ )
+ raise
+ ctx.step_outputs[step.name] = ctx.text
+ step_timings[step.name] = round((time.monotonic() - step_start) * 1000, 1)
+ # 스텝 완료 훅 - 데모·서빙이 중간 결과(교정 목록 등)를 단계별로 내보내는 용도. 평가 run 은 주지 않는다.
+ if on_step is not None:
+ maybe = on_step(step.name, sample, ctx)
+ if inspect.isawaitable(maybe):
+ await maybe
+ ts = datetime.now().strftime("%H:%M:%S")
+ logger.info(
+ f" [{ts}] step {step_i + 1} '{step.name}' done, text_len={len(ctx.text)}"
+ )
+ # 동기 꼬리 작업이 데드라인을 넘겼다면 성공으로 조용히 돌려주지 않는다.
+ if deadline is not None and time.monotonic() > deadline:
+ raise TimeoutError(
+ f"pipeline timeout_seconds={timeout_seconds} expired before chain completed"
)
- raise
- ctx.step_outputs[step.name] = ctx.text
- ts = datetime.now().strftime("%H:%M:%S")
- logger.info(f" [{ts}] step {step_i + 1} '{step.name}' done, text_len={len(ctx.text)}")
- if on_step:
- on_step("done", step_i + 1, len(self.steps), step.name, ctx.doc_chunk)
-
- async def _run_by_document_chunks(
- self,
- sample: EvalSample,
- input_data: dict[str, Any],
- original_text: str,
- make_ctx: Callable[[str, DocChunk], StepContext],
- on_step: StepCallback | None,
- ) -> None:
- """문서를 입구에서 한 번 나눠 chunk 마다 전 스텝을 돌리고, 출력·스냅숏·사용량을 원장으로 합친다."""
- # steps/__init__ 가 llm.py 를 거쳐 이 모듈을 import 하므로 순환을 피해 지연 import
- from solar_eval.pipelines.steps.chunking import join_chunks, plan_chunks
-
- plan = plan_chunks(
- original_text, self.env.text_split_size, strategy=self.env.text_split_strategy
- )
- n_chunks = len(plan.chunks)
- limit = asyncio.Semaphore(max(1, int(self.env.document_chunk_concurrency)))
-
- async def run_chunk(ci: int, chunk: str) -> EvalSample:
- # chunk 끼리는 독립이라 동시에 돈다 -- 캐시(self._shared)는 읽기 전용으로만 공유
- doc_chunk: DocChunk = (ci + 1, n_chunks)
- async with limit:
- if on_step:
- on_step("document", ci + 1, n_chunks, "", doc_chunk)
- sub = EvalSample(input={**input_data, "original": chunk})
- ctx = make_ctx(chunk, doc_chunk)
- await self._run_chain(sub, ctx, on_step)
- sub.output = ctx.text
- sub.artifacts["usage"] = ctx.usage
- sub.artifacts["step_outputs"] = ctx.step_outputs
- return sub
-
- subs: list[EvalSample] = list(
- await asyncio.gather(*(run_chunk(ci, c) for ci, c in enumerate(plan.chunks)))
- )
- sample.output = join_chunks([sub.output for sub in subs], plan)
- sample.artifacts["step_outputs"] = {
- name: join_chunks([sub.artifacts["step_outputs"][name] for sub in subs], plan)
- for name in subs[0].artifacts["step_outputs"]
- }
- usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
- for sub in subs:
- for k in usage:
- usage[k] += sub.artifacts["usage"].get(k, 0)
- sample.artifacts["usage"] = usage
- if any(sub.artifacts.get("finish_reason", "stop") != "stop" for sub in subs):
- sample.artifacts["finish_reason"] = "length"
- corrections: dict[str, list[Any]] = {}
- for sub in subs:
- for step_name, items in sub.artifacts.get("corrections", {}).items():
- corrections.setdefault(step_name, []).extend(items)
- if corrections:
- sample.artifacts["corrections"] = corrections
- sample.artifacts["document_chunks"] = {
- "lens": [len(c) for c in plan.chunks],
- "kinds": list(plan.kinds),
- }
+ if timeout_seconds is None:
+ await _run_chain()
+ else:
+ # asyncio.timeout 은 만료 시점에 진행 중 await 을 취소하고 TimeoutError
+ # 로 바꾼다 -- 취소는 provider/BatchRunner 까지 전파된다.
+ async with asyncio.timeout(timeout_seconds):
+ await _run_chain()
+
+ elapsed_ms = (time.monotonic() - start) * 1000
+
+ sample.output = ctx.text
+ sample.artifacts["usage"] = ctx.usage
+ sample.artifacts["inference_time_ms"] = elapsed_ms
+ # 스텝별 벽시계(ms) — 지연 예산(기사당 N초) 배분용. 동시 실행 중이면 API 대기까지 포함된 값이다.
+ sample.artifacts["step_timings_ms"] = step_timings
+ # completion 스텝은 자기 finish_reason 을 직접 남긴다 -- 없을 때만 기본값.
+ sample.artifacts.setdefault("finish_reason", "stop")
+ sample.artifacts["step_outputs"] = ctx.step_outputs
+ return sample
diff --git a/solar_eval/pipelines/registry.py b/solar_eval/pipelines/registry.py
index 3dc4ccb7501503a32abb2033d4306985e9a8407c..e77641fb75e489d4119ec0bd34fc4de758d3e90d 100644
--- a/solar_eval/pipelines/registry.py
+++ b/solar_eval/pipelines/registry.py
@@ -70,20 +70,28 @@ def _ensure_builtin_steps() -> None:
from solar_eval.pipelines import steps # noqa: F401
+def step_key(config: dict[str, Any]) -> str:
+ """step config 의 레지스트리 조회 이름.
+
+ `{type: X}` 는 X, legacy `{type: rule, rule_type: X}` 는 X(없으면 `name`). 스냅샷의
+ `step_impl_hash` 가 같은 키를 쓴다 — 조회와 기록이 한 규칙을 공유한다.
+ """
+ step_type = config.get("type")
+ if step_type == "rule":
+ return config.get("rule_type", config.get("name", ""))
+ return step_type or ""
+
+
def resolve_step(config: dict[str, Any], env: PipelineEnv) -> BaseStep:
"""step config 하나를 등록된 스텝 인스턴스로 해석한다. 모르는 이름은 즉시 raise."""
_ensure_builtin_steps()
- step_type = config.get("type")
- if step_type == "rule":
- # legacy 표기: {type: rule, rule_type: X}. 레지스트리에서 X 를 찾는다.
- key = config.get("rule_type", config.get("name", ""))
- cls = STEP_REGISTRY.get(key)
- if cls is None:
+ key = step_key(config)
+ cls = STEP_REGISTRY.get(key)
+ if cls is None:
+ if config.get("type") == "rule":
+ # legacy 표기: {type: rule, rule_type: X}. 레지스트리에서 X 를 찾는다.
raise UnknownRuleTypeError(key, available=available_steps())
- else:
- cls = STEP_REGISTRY.get(step_type or "")
- if cls is None:
- raise UnknownStepTypeError(str(step_type), available=available_steps())
+ raise UnknownStepTypeError(str(config.get("type")), available=available_steps())
return cls(config, env)
@@ -113,12 +121,12 @@ def create_pipeline(
prompts=prompts or {},
input_fields=input_fields,
text_split_size=config.get("text_split_size", 500),
- text_split_strategy=config.get("text_split_strategy", "dp"),
- document_chunking=bool(config.get("document_chunking", False)),
- document_chunk_concurrency=int(config.get("document_chunk_concurrency", 4)),
+ text_split_mode=config.get("text_split_mode", "chars"),
dataset_loader=dataset_loader,
project_dir=Path(project_dir) if project_dir else None,
config_dir=Path(config_dir) if config_dir else None,
+ # 전체 체인 데드라인 -- 검증은 PipelineEnv.__post_init__ 이 한다.
+ timeout_seconds=config.get("timeout_seconds"),
)
if pipeline_type == "single_step":
diff --git a/solar_eval/pipelines/rules.py b/solar_eval/pipelines/rules.py
index e8254806840337201b62455c880e45e5f997c7b2..db46fd6e89524ce623299b5c568d74fa2cf60565 100644
--- a/solar_eval/pipelines/rules.py
+++ b/solar_eval/pipelines/rules.py
@@ -1,6 +1,6 @@
"""multi_step 파이프라인의 rule 스텝 구현체.
-`multi_step.py` 가 518줄이라 (`.claude/rules/code-style.md` 의 400줄 권장선을
+`multi_step.py` 가 518줄이라 (`.agents/rules/code-style.md` 의 400줄 권장선을
넘어) 룰 구현만 따로 뽑아냈다. vocabulary/post_process/correction_filter 는
동작을 바꾸지 않고 `MultiStepPipeline._apply_*` 메서드에서 그대로 옮겨온
것이다 — 순수 텍스트 변환 로직이라 `self` 상태가 필요 없다(`apply_vocabulary`
@@ -14,6 +14,7 @@ self-consistency 의 corrections 목록) `sample` 을 인자로 받는다 —
from __future__ import annotations
+import difflib
import logging
import re
from pathlib import Path
@@ -21,6 +22,7 @@ from typing import TYPE_CHECKING, Any
from solar_eval.models.sample import EvalSample
from solar_eval.pipelines.errors import KnowledgeAssetError
+from solar_eval.pipelines.pipeline import ResourceRef, relative_posix
if TYPE_CHECKING:
from solar_eval.core.dataset_loader import DatasetLoader
@@ -80,7 +82,7 @@ def _load_vocabulary(
# 1. config 정본 (레포의 03-evaluation/) — 치환 사전은 설정이다.
if config_dir is not None:
- candidate = Path(config_dir) / vocab_path
+ candidate = _config_candidate(config_dir, vocab_path)
searched.append(str(candidate))
if candidate.is_file() and dataset_loader is not None:
return dataset_loader.load_csv(str(config_dir), vocab_path, source="local")
@@ -100,6 +102,75 @@ def _load_vocabulary(
raise KnowledgeAssetError(vocab_path, searched)
+def _config_candidate(config_dir: Path | str, rel: str) -> Path:
+ """config 정본 기준 자산 경로 — 로더(`_load_vocabulary`)와 스냅샷 해석이 공유한다.
+
+ config 자산은 config_dir **안**에 있어야 한다: `../` 나 밖을 가리키는 심볼릭 링크로 풀리는
+ 파일은 커밋으로 복원되지 않고(dirty 범위 밖), 스냅샷 라벨이 기계마다 다른 절대경로가 된다.
+ 그런 파일이 실제로 있으면 쓰지 않고 `KnowledgeAssetError` 로 거부한다(로더·스냅샷 공통).
+ 없으면 경로만 돌려준다 — 호출자가 레거시 선언으로 넘어가거나 같은 오류를 낸다.
+
+ Raises:
+ KnowledgeAssetError: 후보 파일이 있는데 실제 위치가 config_dir 밖일 때.
+ """
+ candidate = Path(config_dir) / rel
+ if candidate.is_file():
+ resolved = candidate.resolve()
+ root = Path(config_dir).resolve()
+ if not resolved.is_relative_to(root):
+ raise KnowledgeAssetError(
+ rel, [f"{candidate} → {resolved} (config_dir {root} 밖 — config 자산은 그 안에)"]
+ )
+ return candidate
+
+
+def resolve_vocabulary_path(
+ config: dict[str, Any],
+ dataset_loader: DatasetLoader | None,
+ config_dir: Path | None,
+) -> ResourceRef | None:
+ """치환 사전 선언을 **읽지 않고** 어느 파일인지로 푼다 (run.json 스냅샷의 `resources`).
+
+ `_load_vocabulary` 와 같은 순서를 따른다: config 정본 → 레거시 선언(repo/source).
+ 레거시 `local` 은 로더의 경로 규칙(`DatasetLoader._resolve_local_path`)을 그대로 쓰고
+ 라벨은 데이터 루트 상대(`data:`), 원격(`huggingface` 등)은 네트워크를 타지 않고
+ 선언만으로 `hf:/`(path None)를 낸다.
+
+ Returns:
+ 선언이 없으면 None(정상적인 no-op).
+
+ Raises:
+ KnowledgeAssetError: 선언했는데 로컬에서 찾을 수 없을 때 — 로더가 run 도중에 낼
+ 오류를 run 시작 전으로 앞당긴다.
+ """
+ vocab_path = config.get("vocab_dataset", "")
+ if not vocab_path:
+ return None
+ searched: list[str] = []
+ if config_dir is not None:
+ candidate = _config_candidate(config_dir, vocab_path)
+ searched.append(str(candidate))
+ if candidate.is_file() and dataset_loader is not None:
+ return ResourceRef("config", relative_posix(candidate, config_dir), candidate)
+ if dataset_loader is None:
+ searched.append("(dataset_loader 없음)")
+ raise KnowledgeAssetError(vocab_path, searched)
+ repo = config.get("repo", "")
+ source = config.get("source", "huggingface")
+ if source != "local":
+ if not repo:
+ # repo 없는 원격 선언은 로더가 run 도중 다운로드에 실패한다(prod_251231 의 옛 사고) —
+ # 스냅샷이 null 로 넘기지 않고 시작 전에 같은 오류로 죽인다.
+ searched.append(f"{source}:{vocab_path}")
+ raise KnowledgeAssetError(vocab_path, searched)
+ return ResourceRef("hf", f"{repo}/{vocab_path}", None)
+ local = dataset_loader._resolve_local_path(repo, vocab_path)
+ searched.append(str(local))
+ if not local.is_file():
+ raise KnowledgeAssetError(vocab_path, searched)
+ return ResourceRef("data", relative_posix(local, dataset_loader.base_dir), local)
+
+
def apply_pronoun_postprocess(text: str, replacements: dict[str, str]) -> str:
"""대명사/직함 치환 + 한국어 조사 교정.
@@ -312,25 +383,129 @@ def apply_change_rate_guard(text: str, original: str, config: dict[str, Any]) ->
return text
+_UNIT_SYMBOLS = {
+ "mm²": "㎟",
+ "cm²": "㎠",
+ "km²": "㎢",
+ "m²": "㎡",
+ "mm": "㎜",
+ "cm": "㎝",
+ "km": "㎞",
+ "mg": "㎎",
+ "kg": "㎏",
+ "ml": "㎖",
+ "dl": "㎗",
+ "cc": "㏄",
+}
+# 숫자(공백 하나까지 허용) 바로 뒤에 오고 뒤에 영문자가 이어지지 않는 단위만 바꾼다. 예전에는 글자열 어디서나 바꿔
+# `Grammy`·`Programme`·`Baccalaureate`·`Nadler`·메일 주소의 mm·cc·dl 까지 ㎜·㏄·㎗ 로 망가뜨렸다(2026-10-20 현업 사례 비교).
+_UNIT_RE = re.compile(
+ r"(?<=\d)(\s?)("
+ + "|".join(sorted(map(re.escape, _UNIT_SYMBOLS), key=len, reverse=True))
+ + r")(?![A-Za-z])"
+)
+
+
def apply_unit_unicode(text: str) -> str:
- """단위 텍스트를 유니코드 결합 문자로 변환한다."""
- replacements = {
- "mm²": "㎟",
- "cm²": "㎠",
- "m²": "㎡",
- "km²": "㎢",
- "mm": "㎜",
- "cm": "㎝",
- "km": "㎞",
- "mg": "㎎",
- "kg": "㎏",
- "ml": "㎖",
- "dl": "㎗",
- "cc": "㏄",
- }
- for k, v in replacements.items():
- text = text.replace(k, v)
- return text
+ """숫자 뒤 단위 텍스트를 유니코드 결합 문자로 변환한다(`10mm`→`10㎜`, `5 kg`→`5 ㎏`)."""
+ return _UNIT_RE.sub(lambda m: m.group(1) + _UNIT_SYMBOLS[m.group(2)], text)
+
+
+_QUOTE_KINDS = (("'‘’", "‘", "’"), ('"“”', "“", "”"))
+_CLOSES_AFTER = ".,!?;%…’”"
+# 앞 글자만으로는 여닫이를 정할 수 없는 기호: 같은 줄에 열린 따옴표가 있거나 뒤가 공백·줄 끝이면 닫는 쪽, 아니면 여는 쪽.
+# `‘AA-’`·`‘송도는~’`·`‘개(犬)’` 은 닫고, 인터뷰 질문 머리 `-‘꽃’`·`[인터뷰]“딥 리딩”` 은 연다.
+_AMBIGUOUS_BEFORE = "-+~)]}>」』"
+
+
+def apply_quote_direction(text: str, original: str = "") -> str:
+ """둥근 따옴표의 여닫이 방향을 앞 글자와 같은 줄의 짝으로 다시 정한다.
+
+ FT 교열 모델이 곧은 따옴표(')를 조선일보 표기인 둥근 따옴표로 바꾸면서 여는 자리에 닫는 따옴표(’)를 넣거나 한쪽만 바꾸는
+ 사고를 낸다(2026-10-21 FT 약점 분석: 정답 밖 오교정의 약 1/4). 둥근 따옴표가 있는 줄에서만 그 줄의 같은 종류 따옴표를
+ 모두 둥근 따옴표로 맞추고, 글자·숫자·닫는 문장부호 뒤면 닫는(’ ”), 그 밖(줄 처음·공백·`◇`·`▶` 같은 머리 기호·여는 괄호·
+ 여는 따옴표 뒤)은 여는(‘ “) 쪽으로 둔다. `-`·`+`·`~` 뒤는 같은 줄에 열린 따옴표가 있거나 뒤가 공백·줄 끝일 때만 닫는다.
+ 열린 작은따옴표 뒤 숫자에 붙은 프라임(′)은 FT 가 닫는 따옴표를 바꾼 것이라 ’ 로 되돌린다(`‘하야부사2′가`). 단 여는
+ 따옴표부터 그 프라임까지가 `original` 에 그대로 있으면 둔다 - 조선일보 원문·게재본에는 `‘춘천사이로248′` 같은 표기가
+ 있고 교열자도 고치지 않는다(2026-10-27 기사 셋 확인).
+ 둥근 따옴표가 없는 줄의 곧은 따옴표는 건드리지 않는다.
+ """
+ lines = []
+ for line in text.split("\n"):
+ chars = list(line)
+ for kinds, opening, closing in _QUOTE_KINDS:
+ if opening not in line and closing not in line:
+ continue
+ # 아직 닫히지 않은 여는 따옴표 자리(겹따옴표 `‘…(‘…’)’` 의 바깥쪽을 잃지 않도록)
+ opened: list[int] = []
+ for i, ch in enumerate(chars):
+ prev = chars[i - 1] if i else ""
+ nxt = chars[i + 1] if i + 1 < len(chars) else ""
+ if ch == "′" and opening == "‘":
+ if opened and prev.isdigit() and not (nxt.isdigit() or nxt == "″"):
+ # 여는 따옴표 글자는 원문과 다를 수 있어(`’1988KY26′`) 안쪽 글자 + 프라임으로 원문을 찾는다.
+ inner = "".join(chars[opened.pop() + 1 : i + 1])
+ chars[i] = ch if inner in original else closing
+ continue
+ if ch not in kinds:
+ continue
+ if prev and prev in _AMBIGUOUS_BEFORE:
+ closes = bool(opened) or not nxt or nxt.isspace()
+ else:
+ closes = bool(prev) and (prev.isalnum() or prev in _CLOSES_AFTER)
+ chars[i] = closing if closes else opening
+ if closes:
+ if opened:
+ opened.pop()
+ else:
+ opened.append(i)
+ lines.append("".join(chars))
+ return "\n".join(lines)
+
+
+# 따옴표 글자를 종류별 대표 글자로 접어, 따옴표 모양만 다른 자리를 같은 글자로 정렬한다(′ ″ 는 FT 가 닫는 따옴표 대신 넣는다).
+_QUOTE_FOLD = str.maketrans({"‘": "'", "’": "'", "′": "'", "“": '"', "”": '"', "″": '"'})
+
+
+def apply_quote_preserve(text: str, original: str) -> str:
+ """원문에 있던 따옴표 글자를 원문 모양 그대로 되돌린다.
+
+ 조선일보 교열자는 따옴표 모양(곧은·둥근·여닫이·프라임)을 고치지 않는다 - 기사 셋 508 · 일반 문단 541 의 원문 → 정답에서
+ 따옴표 글자만 바뀐 자리는 0 이다. 반면 FT 는 곧은 따옴표를 둥근 따옴표로 바꾸고 방향·프라임 사고를 내서, FT+Opus 출력에는
+ 따옴표만 바뀐 자리가 기사 셋 508편 중 78편에서 246곳 있었다(2026-10-27). 따옴표를 종류별로 접은 글자열끼리 정렬해 원문과 맞물리는
+ 따옴표 자리만 원문 글자로 돌린다. 옆 글자 교정(`‘하야부사2호′`의 `호` 삭제)은 남고, 원문에 없던 따옴표는 그대로 둔다.
+ 글자 정렬은 줄 단위로 먼저 짝을 지은 뒤 바뀐 줄 묶음 안에서만 한다(기사 전체를 글자로 정렬하면 긴 기사에서 1초 가까이 걸린다).
+ """
+ if not original or text == original:
+ return text
+ orig_lines, text_lines = original.split("\n"), text.split("\n")
+ out_lines = list(text_lines)
+ matcher = difflib.SequenceMatcher(
+ None,
+ [line.translate(_QUOTE_FOLD) for line in orig_lines],
+ [line.translate(_QUOTE_FOLD) for line in text_lines],
+ autojunk=False,
+ )
+ for op, i1, i2, j1, j2 in matcher.get_opcodes():
+ if op == "equal":
+ # 접은 줄이 같으면 따옴표 글자만 다르다 - 원문 줄 그대로.
+ out_lines[j1:j2] = orig_lines[i1:i2]
+ elif op == "replace":
+ block = _restore_quotes("\n".join(text_lines[j1:j2]), "\n".join(orig_lines[i1:i2]))
+ out_lines[j1:j2] = block.split("\n")
+ return "\n".join(out_lines)
+
+
+def _restore_quotes(text: str, original: str) -> str:
+ out = list(text)
+ matcher = difflib.SequenceMatcher(
+ None, original.translate(_QUOTE_FOLD), text.translate(_QUOTE_FOLD), autojunk=False
+ )
+ for i, j, size in matcher.get_matching_blocks():
+ for k in range(size):
+ if original[i + k] != text[j + k] and original[i + k] in "'\"‘’“”′″":
+ out[j + k] = original[i + k]
+ return "".join(out)
def load_compound_whitelist(config: dict[str, Any], config_dir: Path | None) -> set[str]:
@@ -341,24 +516,39 @@ def load_compound_whitelist(config: dict[str, Any], config_dir: Path | None) ->
화이트리스트는 사람이 편집하는 지식 자산이라 config 정본(`03-evaluation/`)
기준 상대경로만 지원한다.
"""
- wl_path = config.get("whitelist_path", "")
- if not wl_path:
+ ref = resolve_whitelist_path(config, config_dir)
+ if ref is None:
return set()
+ if ref.path is None: # config 자산만 지원하므로 오지 않는다 — 온다면 해석 규칙이 바뀐 것
+ raise KnowledgeAssetError(ref.rel, [ref.label])
+ entries: set[str] = set()
+ for line in ref.path.read_text(encoding="utf-8").splitlines():
+ form = line.split("\t")[0].strip()
+ if form:
+ entries.add(form)
+ return entries
+
+
+def resolve_whitelist_path(config: dict[str, Any], config_dir: Path | None) -> ResourceRef | None:
+ """복합어 화이트리스트 선언을 파일로 푼다 — 로더(`load_compound_whitelist`)와 스냅샷이 공유.
+ Returns:
+ 선언이 없으면 None.
+
+ Raises:
+ KnowledgeAssetError: 선언했는데 config 정본에 파일이 없을 때(또는 config_dir 이 없을 때).
+ """
+ wl_path = config.get("whitelist_path", "")
+ if not wl_path:
+ return None
searched: list[str] = []
if config_dir is not None:
- candidate = Path(config_dir) / wl_path
+ candidate = _config_candidate(config_dir, wl_path)
searched.append(str(candidate))
if candidate.is_file():
- entries: set[str] = set()
- for line in candidate.read_text(encoding="utf-8").splitlines():
- form = line.split("\t")[0].strip()
- if form:
- entries.add(form)
- return entries
+ return ResourceRef("config", relative_posix(candidate, config_dir), candidate)
else:
searched.append("(config_dir 없음)")
-
raise KnowledgeAssetError(wl_path, searched)
diff --git a/solar_eval/pipelines/rules_josa.py b/solar_eval/pipelines/rules_josa.py
deleted file mode 100644
index 467fc43284dd28e729595b40a198dd1a51e5f9ca..0000000000000000000000000000000000000000
--- a/solar_eval/pipelines/rules_josa.py
+++ /dev/null
@@ -1,176 +0,0 @@
-"""기호·숫자·단위 뒤 조사 선택 결정론 교정 — `josa_fix` rule_type 구현체.
-
-한국어 조사(이/가·을/를·은/는·과/와·로/으로·였/이었)는 앞말의 **발음상**
-받침 유무를 따르는데, 앞말이 숫자·라틴 대문자·단위 기호·닫는 괄호/따옴표로
-끝나면 FT/LLM 이 일관되게 놓친다 (2026-09-17 chosun 풀셋 실측: gold 15건 중
-모델 적중 1건 — 예: `WSJ은→는`, `91㎏로→으로`, `차화정)이→가`).
-
-맞춤법 근거 두 가지가 anchor 결정을 가른다:
-- **괄호 안 삽입어는 읽지 않는다** → 조사는 괄호 *앞말*의 발음을 따른다
- (`교섭단체(국민의힘)가`). 그래서 닫는 괄호를 만나면 여는 괄호 앞으로 건너뛴다.
-- **따옴표 안은 읽는다** → 따옴표 안 *마지막 글자*의 발음을 따른다
- (`'MB.DRIVE'가`). 닫는 따옴표는 한 글자만 벗긴다.
-
-보수 가드: 순수 한글 어절(따옴표·괄호를 안 거친 한글 anchor)은 LLM 영역이라
-건드리지 않는다. 소문자 라틴(단어 독음 — iPhone=아이폰)과 R(-er 계 모음 독음)로
-끝나는 anchor 도 독음이 갈려서 제외한다.
-"""
-
-from __future__ import annotations
-
-import re
-
-#: 닫는 따옴표류 — 이 문자는 발음되지 않으므로 벗기고 안쪽 글자를 anchor 로 삼는다.
-_CLOSING_QUOTES = "'\"’”′″´`"
-
-#: 교정 대상 조사 표면형 → (받침형, 무받침형). 로/으로는 ㄹ받침 예외를 별도 처리.
-_JOSA_PAIRS: dict[str, tuple[str, str]] = {
- "이": ("이", "가"),
- "가": ("이", "가"),
- "을": ("을", "를"),
- "를": ("을", "를"),
- "은": ("은", "는"),
- "는": ("은", "는"),
- "과": ("과", "와"),
- "와": ("과", "와"),
- "으로": ("으로", "로"),
- "로": ("으로", "로"),
- "이었던": ("이었던", "였던"),
- "였던": ("이었던", "였던"),
- "이었다": ("이었다", "였다"),
- "였다": ("이었다", "였다"),
-}
-
-#: 어절 = stem + 조사 + 꼬리 문장부호. 긴 표면형을 먼저 시도한다 (으로 > 로).
-_TOKEN_RE = re.compile(
- r"^(?P.+?)(?P이었던|였던|이었다|였다|으로|이|가|을|를|은|는|과|와|로)"
- r"(?P[.,!?;:·…]*)$"
-)
-
-#: 숫자 독음의 받침 유무 (영일이삼사오육칠팔구) / ㄹ받침 숫자 (일·칠·팔).
-_DIGIT_BATCHIM = {
- "0": True,
- "1": True,
- "2": False,
- "3": True,
- "4": False,
- "5": False,
- "6": True,
- "7": True,
- "8": True,
- "9": False,
-}
-_DIGIT_RIEUL = frozenset("178")
-
-#: 라틴 대문자 낱자 독음에서 받침이 있는 것 (엘·엠·엔). R(알)은 단어 독음
-#: (-er = 어)과 갈려서 아예 제외한다.
-_LATIN_BATCHIM = frozenset("LMN")
-
-#: 단위 기호 독음의 받침 유무 — 그램 계열(ㅁ)만 받침이 있다. ASCII 'g'/'m' 은
-#: 영어 단어 꼬리(Dream 등)와 구분이 안 돼 넣지 않는다 — post_process 의
-#: unit_unicode 가 kg→㎏ 변환을 이 스텝 앞에서 이미 해 준다.
-_UNIT_BATCHIM = {
- "㎏": True,
- "㎎": True,
- "㎍": True,
- "㎜": False,
- "㎝": False,
- "㎞": False,
- "㎖": False,
- "㎗": False,
- "ℓ": False,
- "㏄": False,
- "%": False,
- "℃": False,
- "℉": False,
- "㎡": False,
- "㎢": False,
- "㎟": False,
- "㎠": False,
-}
-
-_MAX_SKIPS = 8 # 중첩 괄호/따옴표 스킵 한도 — 비정상 입력 무한루프 방지
-
-
-def _phoneme_of(ch: str, skipped: bool) -> tuple[bool, bool] | None:
- """anchor 글자의 (받침 유무, ㄹ받침 여부). 판정 불가/제외 대상이면 None."""
- if "가" <= ch <= "힣":
- if not skipped:
- return None # 순수 한글 어절은 LLM 영역 — 괄호/따옴표를 거쳤을 때만
- jong = (ord(ch) - 0xAC00) % 28
- return (jong != 0, jong == 8)
- if ch.isdigit() and ch in _DIGIT_BATCHIM:
- return (_DIGIT_BATCHIM[ch], ch in _DIGIT_RIEUL)
- if "A" <= ch <= "Z":
- if ch == "R":
- return None
- return (ch in _LATIN_BATCHIM, False)
- if ch in _UNIT_BATCHIM:
- return (_UNIT_BATCHIM[ch], False)
- return None # 소문자 라틴(단어 독음), 미지 기호
-
-
-def _resolve_anchor(line: str, stem_end: int) -> tuple[bool, bool] | None:
- """stem 끝에서 왼쪽으로 닫는 따옴표/괄호를 건너뛰며 발음 anchor 를 찾는다."""
- i = stem_end - 1
- skipped = False
- for _ in range(_MAX_SKIPS):
- while i >= 0 and line[i].isspace():
- i -= 1
- if i < 0:
- return None
- ch = line[i]
- if ch in _CLOSING_QUOTES:
- i -= 1 # 따옴표 안은 읽는다 — 한 글자만 벗긴다
- skipped = True
- continue
- if ch in ")]":
- opener = "(" if ch == ")" else "["
- depth, j = 1, i - 1
- while j >= 0 and depth:
- if line[j] == ch:
- depth += 1
- elif line[j] == opener:
- depth -= 1
- j -= 1
- if depth:
- return None # 짝 없는 괄호 — 판정 포기
- i = j # 괄호 안은 읽지 않는다 — 여는 괄호 앞말로
- skipped = True
- continue
- return _phoneme_of(ch, skipped)
- return None
-
-
-def _fixed_josa(josa: str, batchim: bool, rieul: bool) -> str:
- """anchor 발음에 맞는 조사 표면형. 로/으로는 ㄹ받침도 '로'를 취한다."""
- with_b, without_b = _JOSA_PAIRS[josa]
- if with_b == "으로":
- return without_b if (not batchim or rieul) else with_b
- return with_b if batchim else without_b
-
-
-def apply_josa_fix(text: str) -> str:
- """`josa_fix` rule_type 구현 — 줄 단위로 어절을 훑어 조사를 교정한다."""
- return "\n".join(_fix_line(line) for line in text.split("\n"))
-
-
-def _fix_line(line: str) -> str:
- edits: list[tuple[int, int, str]] = []
- for m in re.finditer(r"\S+", line):
- tok = m.group()
- tm = _TOKEN_RE.match(tok)
- if not tm:
- continue
- josa = tm.group("josa")
- stem = tm.group("stem")
- anchor = _resolve_anchor(line, m.start() + len(stem))
- if anchor is None:
- continue
- fixed = _fixed_josa(josa, *anchor)
- if fixed != josa:
- start = m.start() + len(stem)
- edits.append((start, start + len(josa), fixed))
- for start, end, fixed in reversed(edits):
- line = line[:start] + fixed + line[end:]
- return line
diff --git a/solar_eval/pipelines/rules_revert.py b/solar_eval/pipelines/rules_revert.py
deleted file mode 100644
index 1c450d69afba8547ff44ad8590a3457c7a991923..0000000000000000000000000000000000000000
--- a/solar_eval/pipelines/rules_revert.py
+++ /dev/null
@@ -1,121 +0,0 @@
-"""원문 대비 diff 를 클래스 판정해 되돌리는 정밀도 가드 — `diff_revert` rule_type.
-
-파이프라인(FT·LLM 스텝)이 만드는 오탐 중 큰 덩어리가 **기계적**이다
-(2026-09-17 chosun C1 풀셋 실측 — paragraph 오탐 410건 중):
-- 따옴표 글리프 변환 77건: LLM 이 텍스트를 다시 타이핑하며 `" '` 유니코드
- 따옴표를 ASCII 로 바꾼다. gold 가 이런 수정을 하는 경우는 5건뿐.
-- 쉼표 삽입 14건: `90일로` -> `90일로,`. gold 쪽 같은 유형 0건.
-- 금지 치환·격식 전환: `대통령실 -> 청와대`(기관 현재명을 과거명으로),
- `-다. -> -습니다.`(경어 전환). 교열의 권한 밖.
-
-원문과 현재 텍스트를 어절 단위로 정렬(difflib)해 바뀐 span 마다 위 클래스에
-해당하면 원문 span 으로 되돌린다. 화이트리스트 복원(v19/v32)과 같은 계보의
-"파이프라인이 만든 변경만 되돌리는" 규칙이라 새 교정을 만들지 않는다.
-
-config (전부 선택; 기본은 아래):
- glyph_restore: true # 글리프-only 치환 되돌림
- comma_insertion_revert: true
- forbidden_insertions: [] # 이 문자열을 새로 들여오는 치환 되돌림 (예: [청와대])
- register_guard: false # 평서문 -> 경어(-습니다/-에요) 전환 되돌림
- punct_deletion_revert: false # 원문의 따옴표/불릿 문자를 지운 span 되돌림
-"""
-
-from __future__ import annotations
-
-import difflib
-import re
-from collections import Counter
-from typing import Any
-
-#: 줄바꿈 보존용 sentinel — 어절 분할 전 개행을 이 토큰으로 바꾼다.
-#: NUL 은 뉴스 텍스트에 등장하지 않는다.
-_NL = chr(0)
-
-#: 따옴표류 글리프 정규화 — 이것'만' 같아지는 치환은 글리프 변환이다.
-#: NFKC 는 쓰지 않는다: 단위 문자(㎏ 등)까지 접어서 post_process 의 의도적
-#: unit_unicode 변환을 되돌려 버린다.
-_GLYPH_TRANS = str.maketrans(
- {
- "“": '"',
- "”": '"',
- "„": '"',
- "″": '"',
- "‘": "'",
- "’": "'",
- "‚": "'",
- "′": "'",
- "´": "'",
- "`": "'",
- }
-)
-
-# -습니다/-ㅂ니다(입니다·갑니다 등)·해요체. '아니다'는 평서형이라 제외한다.
-_REGISTER_RE = re.compile(r"(? str:
- return s.translate(_GLYPH_TRANS)
-
-
-def _tokens(text: str) -> list[str]:
- return text.replace("\n", f" {_NL} ").split()
-
-
-def _join(tokens: list[str]) -> str:
- return re.sub(f" ?{re.escape(_NL)} ?", "\n", " ".join(tokens))
-
-
-def _should_revert(orig: str, new: str, config: dict[str, Any]) -> bool:
- """바뀐 span (orig -> new, 어절 join 문자열) 이 되돌림 클래스인지 판정."""
- if orig and new:
- if config.get("glyph_restore", True) and _norm_glyph(orig) == _norm_glyph(new):
- return True
- if (
- config.get("comma_insertion_revert", True)
- and new.replace(",", "") == orig.replace(",", "")
- and new.count(",") > orig.count(",")
- ):
- return True
- if config.get("punct_deletion_revert", False) and orig and new:
- removed = Counter(orig) - Counter(new)
- added = Counter(new) - Counter(orig)
- if removed and not added and set(removed) <= _STRUCTURAL_MARKS:
- return True
- for forbidden in config.get("forbidden_insertions") or ():
- if forbidden in new and forbidden not in orig:
- return True
- return bool(
- config.get("register_guard", False)
- and _REGISTER_RE.search(new)
- and not _REGISTER_RE.search(orig)
- )
-
-
-def apply_diff_revert(text: str, original: str, config: dict[str, Any]) -> str:
- """`diff_revert` rule_type 구현 — 클래스 판정된 diff 를 원문으로 되돌린다."""
- orig_tokens = _tokens(original)
- out_tokens = _tokens(text)
- matcher = difflib.SequenceMatcher(None, orig_tokens, out_tokens, autojunk=False)
-
- rebuilt: list[str] = []
- for tag, i1, i2, j1, j2 in matcher.get_opcodes():
- if tag == "equal":
- rebuilt.extend(out_tokens[j1:j2])
- continue
- o_toks = orig_tokens[i1:i2]
- n_toks = out_tokens[j1:j2]
- if len(o_toks) == len(n_toks):
- # difflib 은 연속 변경 어절을 한 블록으로 합친다 — 글리프 변환과
- # 실제 교정이 섞인 블록을 통째로 판정하면 둘 다 놓치므로 쌍 단위로.
- for o, n in zip(o_toks, n_toks, strict=True):
- rebuilt.append(o if _should_revert(o, n, config) else n)
- elif _should_revert(" ".join(o_toks), " ".join(n_toks), config):
- rebuilt.extend(o_toks)
- else:
- rebuilt.extend(n_toks)
- return _join(rebuilt)
diff --git a/solar_eval/pipelines/steps/__init__.py b/solar_eval/pipelines/steps/__init__.py
index 026320a2fd89dbcda7a7d1ebee45fa830baa72e5..a0f0d9720aab812aa1b0fe5838d6db16a4ae5c34 100644
--- a/solar_eval/pipelines/steps/__init__.py
+++ b/solar_eval/pipelines/steps/__init__.py
@@ -6,6 +6,7 @@
from solar_eval.pipelines.steps import ( # noqa: F401
completion,
+ corrections_step,
judge_step,
llm,
rule_steps,
diff --git a/solar_eval/pipelines/steps/__pycache__/__init__.cpython-312.pyc b/solar_eval/pipelines/steps/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..2971efac1ff2890450c10b071f53b8f2668bcabf
Binary files /dev/null and b/solar_eval/pipelines/steps/__pycache__/__init__.cpython-312.pyc differ
diff --git a/solar_eval/pipelines/steps/__pycache__/completion.cpython-312.pyc b/solar_eval/pipelines/steps/__pycache__/completion.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..958c07078912be117e528a09deb7aab7c1a4028b
Binary files /dev/null and b/solar_eval/pipelines/steps/__pycache__/completion.cpython-312.pyc differ
diff --git a/solar_eval/pipelines/steps/__pycache__/corrections_step.cpython-312.pyc b/solar_eval/pipelines/steps/__pycache__/corrections_step.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..a0f4fd47b174915e7c541eb2d4bb7539fe8c3360
Binary files /dev/null and b/solar_eval/pipelines/steps/__pycache__/corrections_step.cpython-312.pyc differ
diff --git a/solar_eval/pipelines/steps/__pycache__/judge_step.cpython-312.pyc b/solar_eval/pipelines/steps/__pycache__/judge_step.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..57aa47b71cf8a474446b077af6b8528ebe19c64b
Binary files /dev/null and b/solar_eval/pipelines/steps/__pycache__/judge_step.cpython-312.pyc differ
diff --git a/solar_eval/pipelines/steps/__pycache__/llm.cpython-312.pyc b/solar_eval/pipelines/steps/__pycache__/llm.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..11ad85480e760eb61fe293fe16504abdd0bbe542
Binary files /dev/null and b/solar_eval/pipelines/steps/__pycache__/llm.cpython-312.pyc differ
diff --git a/solar_eval/pipelines/steps/__pycache__/rule_steps.cpython-312.pyc b/solar_eval/pipelines/steps/__pycache__/rule_steps.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..52278a6747d67fd3cda798e7cbd3ce00ca269f2e
Binary files /dev/null and b/solar_eval/pipelines/steps/__pycache__/rule_steps.cpython-312.pyc differ
diff --git a/solar_eval/pipelines/steps/chunking.py b/solar_eval/pipelines/steps/chunking.py
deleted file mode 100644
index 65de71a788ee4340b0e72e59ffbbecb62e23b1ec..0000000000000000000000000000000000000000
--- a/solar_eval/pipelines/steps/chunking.py
+++ /dev/null
@@ -1,294 +0,0 @@
-"""bulk 분할 계획 -- 상한 안에서 문단 > 문장 > 어절 경계만 자르고, 구분자를 원장으로 남겨 접합 때 복원.
-
-왜 이 모듈인가: 옛 `_split_text` 는 500번째 **문자**에서 무조건 잘랐다. 절단면이 공백이면
-`.rstrip()` + `"".join` 이 공백을 삼키고(`미국 제너럴다이내믹스`→`미국제너럴다이내믹스`), 어절
-중간이면 모델이 뒤 조각을 완성하거나(`들이`→`이들이`) 앞 조각을 버렸고(`이뤄졌다. 지난`→
-`이뤄졌다.난`), `\\n`→`∥PARA∥` 왕복은 마커 한가운데를 잘라 리터럴을 출력에 남겼다. 상세는
-`accounts/chosun/body-proofread/03-evaluation/insights/lab_notes_archive.md#260910_long_article_regression_set`.
-
-왜 greedy(상한까지 채우고 가장 가까운 경계) 가 아니라 DP 인가:
-- greedy 는 꼬리 chunk 를 수십 자로 남긴다 (1022자 → 500/500/22). 조각만 받은 모델은 헛손질한다.
-- 따옴표가 열린 채 끝나는 문장 경계는 약하다 -- 뒤 chunk 가 닫는 따옴표로 시작하면 모델이 "고친다".
- 후보 경계마다 종류별 비용을 두고 chunk 수·짧은 chunk 벌점까지 합쳐 전체 최소를 고르면 둘 다 풀린다.
-
-상한(`max_len`)은 파인튜닝 모델이 잘 도는 구간이라 **절대 넘기지 않는다**. 상한 이하 텍스트는 손대지
-않고 그대로 1개 chunk 로 돌려준다 (기존 평가셋 결과가 바이트 단위로 보존되는 조건).
-"""
-
-from __future__ import annotations
-
-import math
-import re
-from dataclasses import dataclass
-
-WS_RUN = re.compile(r"\s+")
-SENTENCE_END = ".!?…。!?"
-# 문장 끝 부호 뒤에 붙을 수 있는 닫는 기호 -- `했다.”`, `있다)`, `한다.’` 도 문장 경계로 본다
-TRAILING_CLOSERS = "”’\"')]」』》〉"
-
-# 비용은 "선호 순서와 교환 비율" 이다. v2 (2026-09-10 ablation 반영): 문단 정렬이 호출 1회보다 우선한다 --
-# v1(chunk 1.0 > sent 0.15) 은 호출을 아끼려 문장 경계로 ~450자까지 채웠는데, 그 큰 다문단 chunk 가
-# 문단 단위 chunk 보다 recall 을 5점 깎았다(long-article 매칭 ΔF1 -1.4 vs greedy +0.4). 어절 절단·꼬리·
-# 따옴표 안 절단 회피는 그대로다.
-KIND_COST: dict[str, float] = {
- "para": 0.0, # 문단 경계 (공백 런에 개행 포함) -- 공짜. 문단이 상한에 들어가는 한 문단째로 자른다
- "sent": 0.5, # 문장 끝 부호 + 공백 -- 호출 하나(0.1)보다 비싸다: 문단을 쪼개느니 chunk 를 하나 더 쓴다
- "quoted_sent": 1.0, # 따옴표/괄호가 열린 채 만난 문장 끝 -- 뒤 chunk 가 닫는 기호로 시작한다
- "word": 3.0, # 어절 사이 공백 -- 문장 한가운데
- "char": 5.0, # 공백 없는 런이 상한보다 길 때만 쓰는 강제 절단
-}
-CHUNK_COST = 0.1 # chunk 하나 = LLM 호출 하나. 같은 경계 품질이면 호출이 적은 쪽 (tie-breaker 수준)
-SHORT_PENALTY = 3.0 # `min_len` 미만 chunk 벌점 계수 (부족 비율의 제곱에 곱한다) -- 꼬리 방지
-DEFAULT_MIN_LEN = 150
-
-
-@dataclass(frozen=True)
-class Boundary:
- """chunk 사이 경계. `text[start:end]` 가 구분자(공백 런, 강제 절단이면 빈 문자열)."""
-
- start: int
- end: int
- kind: str
-
-
-@dataclass(frozen=True)
-class ChunkPlan:
- """`join_chunks(plan.chunks, plan)` 이 원문을 그대로 돌려주는 분할 계획."""
-
- chunks: list[str]
- seps: list[str] # chunks 와 같은 길이. seps[i] 는 chunks[i] 뒤 구분자, 마지막은 trailing 공백
- kinds: list[str] # 내부 경계 종류 (len == len(chunks) - 1)
- lead: str # 원문 leading 공백
-
- @property
- def is_split(self) -> bool:
- return len(self.chunks) > 1
-
-
-STRATEGIES = ("dp", "greedy")
-
-
-def plan_chunks(
- text: str, max_len: int, min_len: int = DEFAULT_MIN_LEN, strategy: str = "dp"
-) -> ChunkPlan:
- """`text` 를 `max_len` 이하 chunk 들로 나누는 계획을 세운다. 상한 이하면 그대로 1개.
-
- `strategy="greedy"` 는 "상한까지 채우고 그 안에서 가장 뒤의 좋은 경계" 라는 단순 사다리 —
- DP 와의 ablation 용이지 운영값이 아니다 (꼬리 chunk · 따옴표 안 절단을 못 막는다).
- """
- if max_len <= 0:
- raise ValueError(f"max_len 은 양수여야 한다: {max_len}")
- if strategy not in STRATEGIES:
- raise ValueError(f"strategy 는 {STRATEGIES} 중 하나여야 한다: {strategy!r}")
- if len(text) <= max_len:
- return ChunkPlan(chunks=[text], seps=[""], kinds=[], lead="")
-
- lead_len = len(text) - len(text.lstrip())
- body = text.strip()
- lead, trail = text[:lead_len], text[lead_len + len(body) :]
- if len(body) <= max_len:
- return ChunkPlan(chunks=[body], seps=[trail], kinds=[], lead=lead)
-
- boundaries = _candidate_boundaries(body)
- boundaries = _add_forced_cuts(body, boundaries, max_len)
- if strategy == "greedy":
- cut_ids = _solve_greedy(boundaries, len(body), max_len)
- else:
- cut_ids = _solve(body, boundaries, max_len, min_len)
-
- chunks, seps, kinds = [], [], []
- pos = 0
- for b in (boundaries[i] for i in cut_ids):
- chunks.append(body[pos : b.start])
- seps.append(body[b.start : b.end])
- kinds.append(b.kind)
- pos = b.end
- chunks.append(body[pos:])
- seps.append(trail)
- return ChunkPlan(chunks=chunks, seps=seps, kinds=kinds, lead=lead)
-
-
-def join_chunks(outputs: list[str], plan: ChunkPlan) -> str:
- """LLM 을 거친 chunk 출력들을 원장의 구분자로 다시 잇는다. 출력 가장자리 공백은 모델 잡음이라 버린다."""
- if len(outputs) != len(plan.chunks):
- raise ValueError(f"chunk 수 불일치: outputs={len(outputs)} plan={len(plan.chunks)}")
- if not plan.is_split:
- return outputs[0]
- return plan.lead + "".join(
- out.strip() + sep for out, sep in zip(outputs, plan.seps, strict=True)
- )
-
-
-# ── 출력 위생 ────────────────────────────────────────────────────────────
-
-# 토큰 앞뒤의 공백·개행까지 흡수한다 -- 모델이 토큰과 개행을 같이 내면 개행이 겹친다 (실측: 문단 +1)
-SEPARATOR_LITERAL = re.compile(r"\s*\s*")
-LITERAL = ""
-ANCHOR_LEN = 20
-
-
-def normalize_separator_literals(text: str, source: str | None = None) -> str:
- """모델이 지어낸 `` 리터럴을 그 자리의 원래 구분자로 되돌린다.
-
- step1~3 프롬프트는 이 토큰으로 문단 경계를 표시하라고 하지만 엔진은 주입도 제거도 하지 않는다
- (계약 불일치 -- insight 260910 참조). 그래서 모델이 가끔 토큰을 그대로 출력에 남기고, 문단 정렬
- chunk(DP v2) 에서 빈도가 올라간다(실기사 6건 run 당 3건). 계약을 (A) 주입/복원으로 살리든 (B)
- 프롬프트에서 빼든, 최종 텍스트에 리터럴이 남는 건 어느 쪽에서도 옳지 않다 -- 이 가드는 그 결정과
- 독립이다.
-
- 무엇으로 바꾸나: `source`(그 chunk 의 입력)가 있으면 리터럴 직전 20자를 source 에서 찾아 그 뒤에 오는
- 원래 공백 런을 쓴다 -- 원문이 개행이면 개행, 문장 사이 공백이면 공백. 모델이 문장 사이에 토큰을
- 지어냈을 때 무조건 개행으로 바꾸면 문단이 늘어난다(실측: 기사 2건에서 +3·+2 문단). 앵커를 못 찾으면
- 개행(토큰의 계약상 의미).
- """
- if LITERAL not in text:
- return text
- if source is None:
- return SEPARATOR_LITERAL.sub("\n", text)
- parts = SEPARATOR_LITERAL.split(text)
- out = parts[0]
- for nxt in parts[1:]:
- out += _original_separator(source, out) + nxt
- return out
-
-
-def _original_separator(source: str, left: str) -> str:
- anchor = left[-ANCHOR_LEN:].rstrip()
- if anchor:
- i = source.rfind(anchor)
- if i >= 0:
- m = WS_RUN.match(source, i + len(anchor))
- if m:
- return "\n" if ("\n" in m.group() or "\r" in m.group()) else " "
- return " " # 원문엔 공백조차 없던 자리 -- 붙여 쓰진 않고 최소 구분자
- return "\n"
-
-
-# ── 후보 경계 ────────────────────────────────────────────────────────────
-
-
-def _candidate_boundaries(body: str) -> list[Boundary]:
- open_depth = _open_quote_depth(body)
- out: list[Boundary] = []
- for m in WS_RUN.finditer(body):
- start, end = m.span()
- if start == 0 or end == len(body):
- continue # strip() 했으므로 없지만 방어
- if "\n" in m.group() or "\r" in m.group():
- kind = "para"
- elif _ends_sentence(body, start, end):
- kind = "quoted_sent" if open_depth[start] > 0 else "sent"
- else:
- kind = "word"
- out.append(Boundary(start, end, kind))
- return out
-
-
-def _ends_sentence(body: str, ws_start: int, ws_end: int) -> bool:
- i = ws_start - 1
- while i >= 0 and body[i] in TRAILING_CLOSERS:
- i -= 1
- if i < 0 or body[i] not in SENTENCE_END:
- return False
- # `3. 4.` 같은 번호 매김 -- 숫자 사이의 마침표는 문장 끝이 아니다
- numbered = (
- body[i] == "." and i > 0 and body[i - 1].isdigit() and body[ws_end : ws_end + 1].isdigit()
- )
- return not numbered
-
-
-def _open_quote_depth(body: str) -> list[int]:
- """각 위치 직전까지 열려 있는 따옴표/괄호 수. 곧은따옴표(")는 홀수 개면 열린 것으로 본다."""
- pairs = {"“": 1, "”": -1, "‘": 1, "’": -1, "(": 1, ")": -1, "「": 1, "」": -1}
- depth: list[int] = [0] * (len(body) + 1)
- curly = 0
- straight = 0
- for i, ch in enumerate(body):
- depth[i] = max(curly, 0) + straight
- if ch == '"':
- straight ^= 1
- else:
- curly += pairs.get(ch, 0)
- depth[len(body)] = max(curly, 0) + straight
- return depth
-
-
-def _add_forced_cuts(body: str, boundaries: list[Boundary], max_len: int) -> list[Boundary]:
- """공백 없는 런이 `max_len` 보다 길면 그 안에 강제 절단점을 넣는다 (URL 덩어리 같은 예외)."""
- anchors = [0] + [b.end for b in boundaries]
- starts = [b.start for b in boundaries] + [len(body)]
- forced: list[Boundary] = []
- for prev_end, next_start in zip(anchors, starts, strict=True):
- pos = prev_end + max_len
- while pos < next_start:
- forced.append(Boundary(pos, pos, "char"))
- pos += max_len
- return sorted(boundaries + forced, key=lambda b: b.start)
-
-
-# ── DP ───────────────────────────────────────────────────────────────────
-
-
-def _solve(body: str, boundaries: list[Boundary], max_len: int, min_len: int) -> list[int]:
- """전체 비용 최소인 경계 부분집합(인덱스, 오름차순)을 고른다.
-
- 노드 0 = 본문 시작, 노드 k(1..K) = boundaries[k-1], 노드 K+1 = 본문 끝.
- chunk(i→j) 비용 = CHUNK_COST + 짧은 chunk 벌점 + (j 가 내부 경계면) 경계 종류 비용.
- """
- n_b = len(boundaries)
- node_end = [0] + [b.end for b in boundaries] # chunk 시작 위치 (노드별)
- node_start = [b.start for b in boundaries] + [len(body)] # chunk 끝 위치 (노드 1..K+1)
- best = [math.inf] * (n_b + 2)
- back = [-1] * (n_b + 2)
- best[0] = 0.0
- for j in range(1, n_b + 2):
- end_pos = node_start[j - 1]
- edge_cost = KIND_COST[boundaries[j - 1].kind] if j <= n_b else 0.0
- for i in range(j - 1, -1, -1):
- length = end_pos - node_end[i]
- if length > max_len:
- break
- if length <= 0 or best[i] == math.inf:
- continue
- cost = best[i] + CHUNK_COST + _short_penalty(length, min_len) + edge_cost
- if cost < best[j]:
- best[j], back[j] = cost, i
- if best[n_b + 1] == math.inf:
- raise RuntimeError("분할 계획 실패 -- 강제 절단점이 부족하다 (버그)")
- cut_ids: list[int] = []
- j = n_b + 1
- while back[j] > 0:
- j = back[j]
- cut_ids.append(j - 1)
- return sorted(cut_ids)
-
-
-GREEDY_LADDER = (("para",), ("sent", "quoted_sent"), ("word",), ("char",))
-
-
-def _solve_greedy(boundaries: list[Boundary], n: int, max_len: int) -> list[int]:
- """regex 사다리 재현: 창(pos, pos+max_len] 안에서 문단 > 문장 > 어절 > 강제 순으로, 가장 뒤 경계."""
- cut_ids: list[int] = []
- pos = 0
- while n - pos > max_len:
- window = [i for i, b in enumerate(boundaries) if pos < b.start <= pos + max_len]
- pick = next(
- (
- cands[-1]
- for kinds in GREEDY_LADDER
- if (cands := [i for i in window if boundaries[i].kind in kinds])
- ),
- None,
- )
- if pick is None:
- raise RuntimeError("greedy 분할 실패 -- 강제 절단점이 부족하다 (버그)")
- cut_ids.append(pick)
- pos = boundaries[pick].end
- return cut_ids
-
-
-def _short_penalty(length: int, min_len: int) -> float:
- if min_len <= 0 or length >= min_len:
- return 0.0
- deficit = 1.0 - length / min_len
- return SHORT_PENALTY * deficit * deficit
diff --git a/solar_eval/pipelines/steps/completion.py b/solar_eval/pipelines/steps/completion.py
index a1d26e3e218ab6e2e1398fdb159204ecfc5e8f07..a72e9f624cb4a51109a508e70f6872496b40e0a4 100644
--- a/solar_eval/pipelines/steps/completion.py
+++ b/solar_eval/pipelines/steps/completion.py
@@ -19,6 +19,8 @@ logger = logging.getLogger(__name__)
class LLMCompletionStep(BaseStep):
"""Single LLM call step. Used for translation and summarization."""
+ uses_model = True
+
async def run(self, sample: EvalSample, ctx: StepContext) -> None:
input_data = self._input_fields_dict(sample.input)
diff --git a/solar_eval/pipelines/steps/corrections_step.py b/solar_eval/pipelines/steps/corrections_step.py
new file mode 100644
index 0000000000000000000000000000000000000000..8acdfee6e32968102a6425ccf3b78beb57d55098
--- /dev/null
+++ b/solar_eval/pipelines/steps/corrections_step.py
@@ -0,0 +1,112 @@
+"""`apply_corrections` 스텝 — `llm` 스텝(`output_mode: corrections`)이 남긴 교정 목록을 본문에 적용한다.
+
+자기 모듈에 둔 이유: `rule_steps.py` 에 넣으면 그 모듈을 쓰는 모든 규칙 스텝의 impl 지문(`pipelines/corrections.py` 가
+closure 에 들어감)이 바뀐다. 이 스텝을 쓰는 파이프라인만 새 지문을 갖게 한다.
+"""
+
+from __future__ import annotations
+
+from solar_eval.models.sample import EvalSample
+from solar_eval.pipelines.corrections import (
+ apply_corrections,
+ diff_hunks,
+ render_hunks,
+ revert_hunks,
+)
+from solar_eval.pipelines.pipeline import BaseStep, StepContext
+from solar_eval.pipelines.registry import register_step
+
+
+@register_step("diff_hunks")
+class DiffHunksStep(BaseStep):
+ """원문(`ctx.original_text`)과 현재 본문의 어절 diff 를 번호 목록으로 만든다 — 검증(verify) LLM 에 보여 줄 "바뀐 자리" 다.
+
+ 목록 텍스트는 `ctx.step_outputs[config.key]`(기본 `diff_list`)에 두어 뒤 스텝의 `user_template` 이 `{steps.diff_list}` 로 읽고,
+ 구조화 목록은 `sample.artifacts["hunks"][<이 스텝 이름>]` 에 남겨 `revert_hunks` 가 번호로 되돌린다. 본문은 바꾸지 않는다.
+ config: `key`(기본 diff_list) · `context`(앞뒤 문맥 글자 수, 기본 20) · `max_hunks`(기본 80) · `skip_spacing`(기본 false, 띄어쓰기만
+ 다른 변경은 목록에서 뺀다).
+ """
+
+ async def run(self, sample: EvalSample, ctx: StepContext) -> None:
+ config = self.config.get("config", {})
+ hunks = diff_hunks(
+ ctx.original_text,
+ ctx.text,
+ context=int(config.get("context", 20)),
+ max_hunks=int(config.get("max_hunks", 80)),
+ skip_spacing=bool(config.get("skip_spacing", False)),
+ )
+ sample.artifacts.setdefault("hunks", {})[self.name] = [h.as_dict() for h in hunks]
+ ctx.step_outputs[str(config.get("key", "diff_list"))] = render_hunks(hunks)
+
+
+@register_step("revert_hunks")
+class RevertHunksStep(BaseStep):
+ """`llm(output_mode: json)` 스텝이 낸 `{"revert": [번호…]}` 를 받아 그 번호의 변경을 원문대로 되돌린다.
+
+ config: `hunks_from`(diff_hunks 스텝 이름, 필수) · `from_step`(판정 llm 스텝 이름, 필수) · `field`(기본 revert).
+ 되돌린 수·무시한 번호는 `sample.artifacts["reverted"][<이 스텝 이름>]` 에 남는다.
+ """
+
+ async def run(self, sample: EvalSample, ctx: StepContext) -> None:
+ config = self.config.get("config", {})
+ hunks_from, from_step = config.get("hunks_from"), config.get("from_step")
+ if not hunks_from or not from_step:
+ raise ValueError(
+ f"revert_hunks '{self.name}': config.hunks_from 와 config.from_step 가 필요하다"
+ )
+ hunks = (sample.artifacts.get("hunks") or {}).get(hunks_from) or []
+ decision = (sample.artifacts.get("json") or {}).get(from_step) or {}
+ ids = (
+ decision.get(str(config.get("field", "revert"))) if isinstance(decision, dict) else None
+ )
+ ctx.text, report = revert_hunks(ctx.text, hunks, ids if isinstance(ids, list) else [])
+ sample.artifacts.setdefault("reverted", {})[self.name] = {
+ "hunks_from": hunks_from,
+ "from_step": from_step,
+ "hunks": len(hunks),
+ **report,
+ }
+
+
+@register_step("apply_corrections")
+class ApplyCorrectionsStep(BaseStep):
+ """`sample.artifacts["corrections"][from_step]` 의 `{original, corrected}` 항목을 본문에 치환한다.
+
+ config: `from_step`(필수) · `max_items`(기본 50) · `allow_delete`(기본 false) · `protect_digits`(기본 false, 숫자 열이 바뀌는
+ 항목 거부) · `max_shrink`(기본 없음, 공백 제외 길이가 이 비율 아래로 줄면 거부). 적용 보고는
+ `sample.artifacts["corrections_applied"][<이 스텝 이름>]` 에 남는다(proposed · applied · skipped_* 수).
+ """
+
+ async def run(self, sample: EvalSample, ctx: StepContext) -> None:
+ config = self.config.get("config", {})
+ misplaced = [
+ k
+ for k in ("max_items", "allow_delete", "protect_digits", "max_shrink")
+ if k in self.config
+ ]
+ if misplaced:
+ # override 경로를 `steps..protect_digits` 처럼 적으면 스텝 최상위에 놓여 조용히 무시된다(2026-10-17 F4g 사고).
+ raise ValueError(
+ f"apply_corrections '{self.name}': {misplaced} 는 config 아래에 둔다 - "
+ f"override 는 steps.{self.name}.config.<키> 로"
+ )
+ from_step = config.get("from_step")
+ if not from_step:
+ raise ValueError(f"apply_corrections '{self.name}': config.from_step 가 필요하다")
+ items = (sample.artifacts.get("corrections") or {}).get(from_step) or []
+ ctx.text, report = apply_corrections(
+ ctx.text,
+ items,
+ max_items=int(config.get("max_items", 50)),
+ allow_delete=bool(config.get("allow_delete", False)),
+ protect_digits=bool(config.get("protect_digits", False)),
+ max_shrink=(
+ float(config["max_shrink"]) if config.get("max_shrink") is not None else None
+ ),
+ )
+ sample.artifacts.setdefault("corrections_applied", {})[self.name] = {
+ "from_step": from_step,
+ "proposed": len(items),
+ **report.as_dict(),
+ }
diff --git a/solar_eval/pipelines/steps/judge_step.py b/solar_eval/pipelines/steps/judge_step.py
index 908484ea7f02327be5030ee4426c35d7e5911205..d0a8a67bb1d2999aba33ae656f0f38879dc0ef86 100644
--- a/solar_eval/pipelines/steps/judge_step.py
+++ b/solar_eval/pipelines/steps/judge_step.py
@@ -11,10 +11,12 @@ artifacts 배선만 한다:
"""
import logging
+from pathlib import Path
from solar_eval.models.sample import EvalSample
from solar_eval.pipelines import judge
-from solar_eval.pipelines.pipeline import BaseStep, StepContext
+from solar_eval.pipelines.errors import KnowledgeAssetError
+from solar_eval.pipelines.pipeline import BaseStep, ResourceRef, StepContext, relative_posix
from solar_eval.pipelines.registry import register_step
logger = logging.getLogger(__name__)
@@ -22,6 +24,27 @@ logger = logging.getLogger(__name__)
@register_step("tool_calling_judge")
class ToolCallingJudgeStep(BaseStep):
+ uses_model = True
+
+ def resource_refs(self) -> list[ResourceRef]:
+ """`pmi_lookup`(데이터 루트 기준 파생 통계) — 실행 때와 같은 `judge.resolve_pmi_path` 로 푼다.
+
+ 라벨은 데이터 루트(로더의 `base_dir`, 없으면 프로젝트 디렉토리의 부모) 상대 `data:`.
+ 못 풀면 run 시작 전에 `KnowledgeAssetError` 로 죽는다.
+ """
+ pmi_lookup = self.config.get("pmi_lookup")
+ if not pmi_lookup:
+ return []
+ try:
+ path = judge.resolve_pmi_path(self.env.project_dir, pmi_lookup)
+ except (ValueError, FileNotFoundError) as exc:
+ raise KnowledgeAssetError(pmi_lookup, [str(exc)]) from exc
+ loader = self.env.dataset_loader
+ root = loader.base_dir if loader is not None and loader.base_dir else None
+ if root is None and self.env.project_dir is not None:
+ root = self.env.project_dir.parent
+ return [ResourceRef("data", relative_posix(path, root), path)]
+
async def run(self, sample: EvalSample, ctx: StepContext) -> None:
step = self.config
step_model = step.get("model", ctx.model)
diff --git a/solar_eval/pipelines/steps/llm.py b/solar_eval/pipelines/steps/llm.py
index a93ccb08032e07b5dbff9d3e3aaab429b509979a..be1e46b952ed0e0bf5bb97893e2f4edbecd841e4 100644
--- a/solar_eval/pipelines/steps/llm.py
+++ b/solar_eval/pipelines/steps/llm.py
@@ -5,9 +5,11 @@ bulk 분할 · JSON output 추출 · fallback · repeat/intersection(self-consis
corrections 캡처를 담당한다.
"""
+import asyncio
import json
import logging
import re
+from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
@@ -15,32 +17,32 @@ from solar_eval.models.sample import EvalSample
from solar_eval.pipelines import judge
from solar_eval.pipelines.pipeline import BaseStep, StepContext
from solar_eval.pipelines.registry import register_step
-from solar_eval.pipelines.steps.chunking import (
- ChunkPlan,
- join_chunks,
- normalize_separator_literals,
- plan_chunks,
-)
logger = logging.getLogger(__name__)
-# 출력 길이 가드 -- 교열은 길이를 거의 바꾸지 않는다. 입력 대비 ±25% 또는 40자(짧은 입력용) 를 넘는 변동은
-# 모델이 문장을 지어냈거나(실측 49자→321자) bulk 를 통째로 버린 것(490자→7자)이라 폴백/입력 유지로 처리한다.
-# 종전 규칙("5배 초과")은 길이 급감과 5배 미만의 환각을 그대로 통과시켰다 (insight 260910).
-LENGTH_TOL_REL = 0.25
-LENGTH_TOL_ABS = 40
+# 문단 경계 보존용 마커 (bulk 분할 시) -- multi_step.py 원본 값 그대로
+PARAGRAPH_SEP = "∥PARA∥"
-def plausible_length(source: str, output: str) -> bool:
- if not source:
- return True
- return abs(len(output) - len(source)) <= max(LENGTH_TOL_ABS, LENGTH_TOL_REL * len(source))
+@dataclass
+class _BulkResult:
+ """묶음 하나의 결과. 묶음을 동시에 부를 때 `sample.artifacts` 목록 순서가 흔들리지 않게 모아 두었다가 묶음 순서대로 반영한다."""
+
+ text: str
+ usages: list[dict[str, Any]] = field(default_factory=list)
+ finish: str | None = None
+ completion_tokens: int | None = None
+ corrections: list[dict[str, Any]] = field(default_factory=list)
+ sc_runs: list[dict[str, Any]] | None = None
+ parsed_json: dict[str, Any] | None = None
@register_step("llm")
class LLMStep(BaseStep):
"""체인 텍스트 위에서 도는 LLM 스텝 (prod/v24 파이프라인의 기본 부품)."""
+ uses_model = True
+
async def run(self, sample: EvalSample, ctx: StepContext) -> None:
step = self.config
step_name = self.name
@@ -66,168 +68,273 @@ class LLMStep(BaseStep):
# Split into bulks if needed
# For include_original/user_template steps, skip splitting
if include_original or user_template:
- plan = ChunkPlan(chunks=[text], seps=[""], kinds=[], lead="")
+ bulks = [text]
else:
- plan = plan_chunks(
- text, self.env.text_split_size, strategy=self.env.text_split_strategy
- )
- bulks = plan.chunks
- processed_bulks = []
+ bulks = self._split_text(text)
logger.debug(f" [{ts}] {len(bulks)} bulk(s), text_len={len(text)}")
- if plan.is_split:
- # 절단면 진단용 -- 어디를 어떤 경계로 잘랐는지 (insight 260910 의 GLUED/LOST 분석이 읽는다)
- chunk_plans = sample.artifacts.setdefault("chunk_plans", {})
- chunk_plans[step_name] = {
- "lens": [len(b) for b in bulks],
- "kinds": list(plan.kinds),
- }
-
- for bulk_i, bulk in enumerate(bulks):
- if ctx.on_step and len(bulks) > 1:
- ctx.on_step("chunk", bulk_i + 1, len(bulks), step_name, ctx.doc_chunk)
- if step_repeat > 1:
- (
- final_text,
- run_usage,
- runs,
- survivors,
- ) = await self._run_self_consistency_bulk(
- bulk,
- step,
- step_model=step_model,
- step_re=step_re,
- step_max_tokens=step_max_tokens,
- step_response_format=step_response_format,
- step_prompt=step_prompt,
- user_template=user_template,
- include_original=include_original,
- original_text=original_text,
- step_outputs=ctx.step_outputs,
- ctx=ctx,
- )
- processed_bulks.append(final_text)
- ctx.add_usage(run_usage)
+
+ # `bulk_concurrency`(기본 1 = 예전처럼 한 묶음씩): 문단 단위 FT 처럼 묶음이 서로 독립인 스텝을 동시에 불러
+ # 기사 지연을 묶음 수만큼 늘리지 않는다. 결과 반영은 항상 묶음 순서다.
+ concurrency = max(1, int(step.get("bulk_concurrency", 1) or 1))
+ call: dict[str, Any] = {
+ "step": step,
+ "step_name": step_name,
+ "step_model": step_model,
+ "step_re": step_re,
+ "step_max_tokens": step_max_tokens,
+ "step_temperature": step_temperature,
+ "step_response_format": step_response_format,
+ "step_prompt": step_prompt,
+ "step_repeat": step_repeat,
+ "include_original": include_original,
+ "user_template": user_template,
+ "original_text": original_text,
+ "many": len(bulks) > 1,
+ "ctx": ctx,
+ }
+ if concurrency == 1:
+ results = [await self._run_bulk(i, b, **call) for i, b in enumerate(bulks)]
+ else:
+ sem = asyncio.Semaphore(concurrency)
+
+ async def _guarded(i: int, b: str) -> _BulkResult:
+ async with sem:
+ return await self._run_bulk(i, b, **call)
+
+ results = list(await asyncio.gather(*(_guarded(i, b) for i, b in enumerate(bulks))))
+
+ processed_bulks = []
+ for res in results:
+ processed_bulks.append(res.text)
+ for usage in res.usages:
+ ctx.add_usage(usage)
+ if res.sc_runs is not None:
# 교집합 이전 run 별 제안도 남긴다 -- 진단 문서가 요구한 가시성
# (§5-F 지시 4). 교집합 결과(survivors)는 downstream
# tool_calling_judge/apply_judge_decisions 가 "corrections" 로 읽는다.
sc_runs = sample.artifacts.setdefault("self_consistency_runs", {})
- sc_runs.setdefault(step_name, []).extend(runs)
+ sc_runs.setdefault(step_name, []).extend(res.sc_runs)
+ if res.parsed_json is not None:
+ sample.artifacts.setdefault("json", {})[step_name] = res.parsed_json
+ if res.finish is not None:
+ # 스텝별 finish_reason·completion 토큰(묶음마다 한 값). `length` 면 max_tokens 에 잘린 것 -
+ # 목록·판정 스텝은 JSON 이 잘려 교정 0건이 되므로 결과 행에서 바로 셀 수 있게 남긴다(2026-10-16).
+ sample.artifacts.setdefault("step_finish", {}).setdefault(step_name, []).append(
+ res.finish
+ )
+ sample.artifacts.setdefault("step_usage", {}).setdefault(step_name, []).append(
+ res.completion_tokens or 0
+ )
+ if res.corrections or res.sc_runs is not None:
+ # self-consistency 는 살아남은 교정이 0건이어도 키를 남긴다(downstream judge 가 빈 목록을 읽는다).
step_corrs = sample.artifacts.setdefault("corrections", {})
- step_corrs.setdefault(step_name, []).extend(survivors)
- continue
+ step_corrs.setdefault(step_name, []).extend(res.corrections)
- if user_template:
- user_content = self._format_user_template(
- user_template,
- original_text,
- bulk,
- ctx.step_outputs,
- )
- elif include_original:
- user_content = f"[원문]\n{original_text}\n\n[교정본]\n{bulk}"
- else:
- user_content = bulk
+ ctx.text = self._join_bulks(processed_bulks)
- ts2 = datetime.now().strftime("%H:%M:%S")
- logger.debug(
- f" [{ts2}] bulk {bulk_i + 1}/{len(bulks)} calling API (len={len(bulk)})..."
+ async def _run_bulk(
+ self,
+ bulk_i: int,
+ bulk: str,
+ *,
+ step: dict[str, Any],
+ step_name: str,
+ step_model: str,
+ step_re: str | None,
+ step_max_tokens: int,
+ step_temperature: float,
+ step_response_format: dict[str, Any] | None,
+ step_prompt: str,
+ step_repeat: int,
+ include_original: bool,
+ user_template: str | None,
+ original_text: str,
+ many: bool,
+ ctx: StepContext,
+ ) -> _BulkResult:
+ """묶음 하나를 부른다. 공유 상태(`sample.artifacts`)는 건드리지 않고 결과만 돌려준다."""
+ # lines 모드: 묶음 앞뒤 개행(빈 줄)은 LLM 에 보내지 않고 출력에 다시 붙인다 — 출력 rstrip 이 먹지 않게.
+ lead = trail = ""
+ if self.env.text_split_mode == "lines" and many:
+ core = bulk.strip("\n")
+ if not core:
+ return _BulkResult(text=bulk)
+ lead = bulk[: len(bulk) - len(bulk.lstrip("\n"))]
+ trail = bulk[len(bulk.rstrip("\n")) :]
+ bulk = core
+ if step_repeat > 1:
+ (
+ final_text,
+ run_usage,
+ runs,
+ survivors,
+ ) = await self._run_self_consistency_bulk(
+ bulk,
+ step,
+ step_model=step_model,
+ step_re=step_re,
+ step_max_tokens=step_max_tokens,
+ step_response_format=step_response_format,
+ step_prompt=step_prompt,
+ user_template=user_template,
+ include_original=include_original,
+ original_text=original_text,
+ step_outputs=ctx.step_outputs,
+ ctx=ctx,
)
- msgs: list[dict[str, str]] = []
- if step_prompt:
- msgs.append({"role": "system", "content": step_prompt})
- msgs.append({"role": "user", "content": user_content})
- result = await ctx.provider.chat_completion(
- messages=msgs,
- model=step_model,
- temperature=step_temperature,
- max_tokens=step_max_tokens,
- reasoning_effort=step_re,
- response_format=step_response_format,
+ return _BulkResult(
+ text=lead + final_text + trail,
+ usages=[run_usage],
+ corrections=list(survivors),
+ sc_runs=list(runs),
)
- ts3 = datetime.now().strftime("%H:%M:%S")
- raw_len = len(result.get("content", ""))
- output = normalize_separator_literals(
- self._extract_output(result["content"]).rstrip(), source=bulk
+
+ if user_template:
+ user_content = self._format_user_template(
+ user_template,
+ original_text,
+ bulk,
+ ctx.step_outputs,
)
+ elif include_original:
+ user_content = f"[원문]\n{original_text}\n\n[교정본]\n{bulk}"
+ else:
+ user_content = bulk
- # 길이 가드: JSON 파싱 실패(설명이 통째로 output 이 됨) · 환각 · bulk 삭제를 한 규칙으로 잡는다
- if not plausible_length(bulk, output):
- raw_content = result.get("content", "")
- logger.warning(
- f" [{ts3}] [{step_name}] bulk {bulk_i + 1} output length implausible "
- f"(in={len(bulk)}, out={len(output)}), attempting fallback"
+ ts2 = datetime.now().strftime("%H:%M:%S")
+ logger.debug(f" [{ts2}] bulk {bulk_i + 1} calling API (len={len(bulk)})...")
+ msgs: list[dict[str, str]] = []
+ if step_prompt:
+ msgs.append({"role": "system", "content": step_prompt})
+ msgs.append({"role": "user", "content": user_content})
+ result = await ctx.provider.chat_completion(
+ messages=msgs,
+ model=step_model,
+ temperature=step_temperature,
+ max_tokens=step_max_tokens,
+ reasoning_effort=step_re,
+ response_format=step_response_format,
+ )
+ res = _BulkResult(text="")
+ ts3 = datetime.now().strftime("%H:%M:%S")
+ raw_len = len(result.get("content", ""))
+ output = self._extract_output(result["content"]).rstrip()
+ output_mode = step.get("output_mode")
+ if output_mode in ("corrections", "json"):
+ # 목록·판정만 받는 스텝 — 텍스트는 그대로 두고 부산물만 남긴다. `corrections` 는 아래 캡처가
+ # `artifacts["corrections"][step]` 에, `json` 은 파싱한 객체 전체를 `artifacts["json"][step]` 에.
+ # 적용은 결정론 스텝(`apply_corrections` · `revert_hunks`)이 한다.
+ output = bulk
+ if output_mode == "json":
+ res.parsed_json = judge._parse_json_robust(result.get("content", "")) or {}
+
+ # JSON 파싱 실패 시 fallback:
+ # output이 입력 대비 5배 이상 길면 파싱 실패로 간주
+ if len(output) > len(bulk) * 5 and len(bulk) > 0:
+ raw_content = result.get("content", "")
+ logger.warning(
+ f" [{ts3}] [{step_name}] bulk {bulk_i + 1} output too long "
+ f"({len(output)} vs input {len(bulk)}), "
+ f"attempting fallback"
+ )
+
+ # Try fallback LLM call if configured
+ fallback_config = step.get("fallback")
+ fallback_prompt = (
+ self.env.prompts.get(fallback_config["prompt_key"], "") if fallback_config else ""
+ )
+ if fallback_prompt:
+ fallback_user = (
+ f"<원문>\n{original_text}\n원문>\n\n"
+ f"<교정_결과_JSON>\n{raw_content}\n교정_결과_JSON>"
)
- guard_log = sample.artifacts.setdefault("length_guard", [])
- guard_log.append(
- {"step": step_name, "bulk": bulk_i + 1, "in": len(bulk), "out": len(output)}
+ fallback_msgs: list[dict[str, str]] = [
+ {"role": "system", "content": fallback_prompt},
+ {"role": "user", "content": fallback_user},
+ ]
+ fallback_rf = fallback_config.get("response_format")
+ fallback_result = await ctx.provider.chat_completion(
+ messages=fallback_msgs,
+ model=step_model,
+ temperature=step_temperature,
+ max_tokens=step_max_tokens,
+ reasoning_effort=step_re,
+ response_format=fallback_rf,
)
+ fallback_output = self._extract_output(fallback_result["content"]).rstrip()
+ ts4 = datetime.now().strftime("%H:%M:%S")
- # Try fallback LLM call if configured
- fallback_config = step.get("fallback")
- fallback_prompt = (
- self.env.prompts.get(fallback_config["prompt_key"], "")
- if fallback_config
- else ""
- )
- if fallback_prompt:
- fallback_user = (
- f"<원문>\n{original_text}\n원문>\n\n"
- f"<교정_결과_JSON>\n{raw_content}\n교정_결과_JSON>"
- )
- fallback_msgs: list[dict[str, str]] = [
- {"role": "system", "content": fallback_prompt},
- {"role": "user", "content": fallback_user},
- ]
- fallback_rf = fallback_config.get("response_format")
- fallback_result = await ctx.provider.chat_completion(
- messages=fallback_msgs,
- model=step_model,
- temperature=step_temperature,
- max_tokens=step_max_tokens,
- reasoning_effort=step_re,
- response_format=fallback_rf,
- )
- fallback_output = normalize_separator_literals(
- self._extract_output(fallback_result["content"]).rstrip(), source=bulk
- )
- ts4 = datetime.now().strftime("%H:%M:%S")
-
- if plausible_length(bulk, fallback_output):
- output = fallback_output
- logger.info(
- f" [{ts4}] [{step_name}] fallback succeeded, len={len(output)}"
- )
- ctx.add_usage(fallback_result.get("usage", {}))
- else:
- output = bulk
- logger.warning(
- f" [{ts4}] [{step_name}] fallback length also implausible, using input text"
- )
+ if len(fallback_output) <= len(bulk) * 5:
+ output = fallback_output
+ logger.info(f" [{ts4}] [{step_name}] fallback succeeded, len={len(output)}")
+ res.usages.append(fallback_result.get("usage", {}))
else:
output = bulk
logger.warning(
- f" [{ts3}] [{step_name}] no fallback configured, using input text"
+ f" [{ts4}] [{step_name}] fallback also too long, using input text"
)
+ else:
+ output = bulk
+ logger.warning(f" [{ts3}] [{step_name}] no fallback configured, using input text")
+
+ logger.debug(f" [{ts3}] bulk {bulk_i + 1} done, raw={raw_len}, extracted={len(output)}")
+ res.text = lead + output + trail
+ res.usages.append(result.get("usage", {}))
+ res.finish = result.get("finish_reason") or "stop"
+ res.completion_tokens = int((result.get("usage") or {}).get("completion_tokens") or 0)
+
+ # response_format 이 "corrections" 배열을 담고 있으면(예:
+ # basic_correction) repeat 없이도 뽑아 둔다 -- downstream
+ # tool_calling_judge/apply_judge_decisions 가 읽을 수 있게.
+ # output 계산과 무관한 순수 추가이므로 기존 텍스트 동작은 바뀌지 않는다.
+ res.corrections = judge.extract_corrections(result.get("content", "")) or []
+ return res
- logger.debug(
- f" [{ts3}] bulk {bulk_i + 1} done, raw={raw_len}, extracted={len(output)}"
- )
- processed_bulks.append(output)
- ctx.add_usage(result.get("usage", {}))
-
- # response_format 이 "corrections" 배열을 담고 있으면(예:
- # basic_correction) repeat 없이도 뽑아 둔다 -- downstream
- # tool_calling_judge/apply_judge_decisions 가 읽을 수 있게.
- # output 계산과 무관한 순수 추가이므로 기존 텍스트 동작은 바뀌지 않는다.
- step_corrections = judge.extract_corrections(result.get("content", ""))
- if step_corrections:
- step_corrs = sample.artifacts.setdefault("corrections", {})
- step_corrs.setdefault(step_name, []).extend(step_corrections)
+ # ── helpers (multi_step.py 에서 그대로 이동) ─────────────────────────
- # 구분자 원장으로 접합 -- 절단면의 공백/개행을 그대로 복원한다. 분할·접합 규칙은 chunking.py.
- ctx.text = join_chunks(processed_bulks, plan)
+ def _split_text(self, text: str) -> list[str]:
+ """Split text into bulks of max text_split_size chars, preserving line breaks.
- # ── helpers (multi_step.py 에서 그대로 이동) ─────────────────────────
+ `text_split_mode: lines` 면 개행 경계로만 묶는다 — bulk 는 줄 사이 개행을 빼고 담고 `_join_bulks` 가
+ 개행으로 다시 잇는다(LLM 출력의 rstrip 이 끝 개행을 먹어도 줄이 붙지 않게). 한도보다 긴 줄은 자르지 않는다.
+ """
+ if len(text) <= self.env.text_split_size:
+ return [text]
+ if self.env.text_split_mode == "lines":
+ return self._split_lines(text)
+
+ text_with_markers = text.replace("\n", PARAGRAPH_SEP)
+ bulks = []
+ current = ""
+
+ for char in text_with_markers:
+ current += char
+ if len(current) >= self.env.text_split_size:
+ bulks.append(current.replace(PARAGRAPH_SEP, "\n"))
+ current = ""
+
+ if current:
+ bulks.append(current.replace(PARAGRAPH_SEP, "\n"))
+
+ return bulks
+
+ def _split_lines(self, text: str) -> list[str]:
+ bulks: list[str] = []
+ current: list[str] = []
+ size = 0
+ for line in text.split("\n"):
+ added = len(line) + (1 if current else 0)
+ if current and size + added > self.env.text_split_size:
+ bulks.append("\n".join(current))
+ current, size = [], 0
+ added = len(line)
+ current.append(line)
+ size += added
+ bulks.append("\n".join(current))
+ return bulks
+
+ def _join_bulks(self, bulks: list[str]) -> str:
+ return ("\n" if self.env.text_split_mode == "lines" else "").join(bulks)
def _extract_output(self, content: str) -> str:
"""Extract output from LLM response. Tries JSON parsing first.
diff --git a/solar_eval/pipelines/steps/rule_steps.py b/solar_eval/pipelines/steps/rule_steps.py
index e7122f5186652864247d4b048e9d36ea38f0ce91..29e896f855b3d2f3e12cec55bf75e05c3fa936c6 100644
--- a/solar_eval/pipelines/steps/rule_steps.py
+++ b/solar_eval/pipelines/steps/rule_steps.py
@@ -9,7 +9,7 @@ v24 파이프라인이 LLM 스텝 사이사이에 끼워 넣는 "code block 후
import logging
from solar_eval.models.sample import EvalSample
-from solar_eval.pipelines.pipeline import BaseStep, StepContext
+from solar_eval.pipelines.pipeline import BaseStep, ResourceRef, StepContext
from solar_eval.pipelines.registry import register_step
from solar_eval.pipelines.rules import (
apply_change_rate_guard,
@@ -19,13 +19,15 @@ from solar_eval.pipelines.rules import (
apply_currency_compact,
apply_judge_decisions,
apply_pronoun_postprocess,
+ apply_quote_direction,
+ apply_quote_preserve,
apply_unit_unicode,
apply_vocabulary,
apply_yul_ryul_normalize,
load_compound_whitelist,
+ resolve_vocabulary_path,
+ resolve_whitelist_path,
)
-from solar_eval.pipelines.rules_josa import apply_josa_fix
-from solar_eval.pipelines.rules_revert import apply_diff_revert
logger = logging.getLogger(__name__)
@@ -40,6 +42,12 @@ class VocabularyStep(BaseStep):
prod/v24 처럼 같은 CSV 를 두 번 쓰는 파이프라인은 여전히 1회 로드.
"""
+ def resource_refs(self) -> list[ResourceRef]:
+ ref = resolve_vocabulary_path(
+ self.config.get("config", {}), self.env.dataset_loader, self.env.config_dir
+ )
+ return [ref] if ref is not None else []
+
async def run(self, sample: EvalSample, ctx: StepContext) -> None:
config = self.config.get("config", {})
cache_key = (
@@ -82,6 +90,10 @@ class CompoundWhitelistRestoreStep(BaseStep):
다른 두 스텝이 캐시를 잘못 공유하지 않도록 경로를 캐시 키에 넣는다.
"""
+ def resource_refs(self) -> list[ResourceRef]:
+ ref = resolve_whitelist_path(self.config.get("config", {}), self.env.config_dir)
+ return [ref] if ref is not None else []
+
async def run(self, sample: EvalSample, ctx: StepContext) -> None:
config = self.config.get("config", {})
cache_key = ("compound_whitelist", config.get("whitelist_path", ""))
@@ -106,6 +118,22 @@ class YulRyulNormalizeStep(BaseStep):
ctx.text = apply_yul_ryul_normalize(ctx.text, exceptions)
+@register_step("quote_direction")
+class QuoteDirectionStep(BaseStep):
+ """둥근 따옴표가 있는 줄에서 따옴표 여닫이 방향을 다시 정한다(`rules.apply_quote_direction`, 원문에 있던 프라임은 둔다)."""
+
+ async def run(self, sample: EvalSample, ctx: StepContext) -> None:
+ ctx.text = apply_quote_direction(ctx.text, ctx.original_text)
+
+
+@register_step("quote_preserve")
+class QuotePreserveStep(BaseStep):
+ """원문에 있던 따옴표 글자를 원문 모양 그대로 되돌린다(`rules.apply_quote_preserve`, 교열자는 따옴표 모양을 고치지 않는다)."""
+
+ async def run(self, sample: EvalSample, ctx: StepContext) -> None:
+ ctx.text = apply_quote_preserve(ctx.text, ctx.original_text)
+
+
@register_step("correction_filter")
class CorrectionFilterStep(BaseStep):
async def run(self, sample: EvalSample, ctx: StepContext) -> None:
@@ -122,28 +150,6 @@ class ApplyJudgeDecisionsStep(BaseStep):
ctx.text = apply_judge_decisions(ctx.text, config, sample)
-@register_step("josa_fix")
-class JosaFixStep(BaseStep):
- """기호·숫자·단위 뒤 조사 결정론 교정 (2026-09-18, 상세는 `rules_josa` docstring)."""
-
- async def run(self, sample: EvalSample, ctx: StepContext) -> None:
- ctx.text = apply_josa_fix(ctx.text)
-
-
-@register_step("diff_revert")
-class DiffRevertStep(BaseStep):
- """원문 대비 diff 를 클래스 판정해 되돌리는 정밀도 가드 (2026-09-18).
-
- 글리프 변환·쉼표 삽입·금지 치환·격식 전환만 되돌린다 — 상세와 실측 근거는
- `rules_revert` docstring 참조. 파이프라인 꼬리(post_process 뒤)에 두는 것을
- 전제로 설계했다.
- """
-
- async def run(self, sample: EvalSample, ctx: StepContext) -> None:
- config = self.config.get("config", {})
- ctx.text = apply_diff_revert(ctx.text, ctx.original_text, config)
-
-
@register_step("change_rate_guard")
class ChangeRateGuardStep(BaseStep):
"""원문 대비 변경률이 임계를 넘으면 원문으로 되돌리는 안전망 (v17 계열)."""
diff --git a/solar_eval/providers/__init__.py b/solar_eval/providers/__init__.py
index 17eec83a9132311224889ba7d639ec99051a9efb..a73b3d3bcd7d30158e199c1a49b059244340482a 100644
--- a/solar_eval/providers/__init__.py
+++ b/solar_eval/providers/__init__.py
@@ -1,7 +1,20 @@
"""LLM provider abstractions."""
from solar_eval.providers.base import BaseProvider
+from solar_eval.providers.openai_compat import OpenAICompatProvider, TimelyProvider
from solar_eval.providers.openai_provider import OpenAIProvider
+from solar_eval.providers.routed import RoutedProvider
from solar_eval.providers.upstage import UpstageProvider
-__all__ = ["BaseProvider", "OpenAIProvider", "UpstageProvider"]
+#: 추론 제공자 라우트 — 스텝 `model` 의 접두어. 외부(Upstage 밖) 라우트는 egress 정책 `external-allowed` 가 필요하다.
+INFERENCE_ROUTES: dict[str, type[BaseProvider]] = {"timely": TimelyProvider}
+
+__all__ = [
+ "INFERENCE_ROUTES",
+ "BaseProvider",
+ "OpenAICompatProvider",
+ "OpenAIProvider",
+ "RoutedProvider",
+ "TimelyProvider",
+ "UpstageProvider",
+]
diff --git a/solar_eval/providers/__pycache__/__init__.cpython-312.pyc b/solar_eval/providers/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..2269794845d6cd1628f822f42d6c39a0096ced8b
Binary files /dev/null and b/solar_eval/providers/__pycache__/__init__.cpython-312.pyc differ
diff --git a/solar_eval/providers/__pycache__/base.cpython-312.pyc b/solar_eval/providers/__pycache__/base.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..ed1110a6c7c7a23fba83f1658645a3608a1cb50a
Binary files /dev/null and b/solar_eval/providers/__pycache__/base.cpython-312.pyc differ
diff --git a/solar_eval/providers/__pycache__/openai_compat.cpython-312.pyc b/solar_eval/providers/__pycache__/openai_compat.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..27321e766e2cd37db343bc7028f81e9483332223
Binary files /dev/null and b/solar_eval/providers/__pycache__/openai_compat.cpython-312.pyc differ
diff --git a/solar_eval/providers/__pycache__/openai_provider.cpython-312.pyc b/solar_eval/providers/__pycache__/openai_provider.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..959e7deabe3c33aa4ebfed5c432bf1587437cb9b
Binary files /dev/null and b/solar_eval/providers/__pycache__/openai_provider.cpython-312.pyc differ
diff --git a/solar_eval/providers/__pycache__/routed.cpython-312.pyc b/solar_eval/providers/__pycache__/routed.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..48aae8b673b182890644989f2b11efc08f881614
Binary files /dev/null and b/solar_eval/providers/__pycache__/routed.cpython-312.pyc differ
diff --git a/solar_eval/providers/__pycache__/upstage.cpython-312.pyc b/solar_eval/providers/__pycache__/upstage.cpython-312.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..2542dda5f43d196b4bf21ebf85f503a1f393985e
Binary files /dev/null and b/solar_eval/providers/__pycache__/upstage.cpython-312.pyc differ
diff --git a/solar_eval/providers/budgeted.py b/solar_eval/providers/budgeted.py
new file mode 100644
index 0000000000000000000000000000000000000000..b17d5c808df60213ecdb05d4939b345e547d6236
--- /dev/null
+++ b/solar_eval/providers/budgeted.py
@@ -0,0 +1,1000 @@
+"""Opt-in, text-only paid inference with a durable, shared spending ceiling.
+
+Use one ``BudgetLedger`` outside the checkout and close it after all providers
+have closed. Its lifetime advisory lock rejects overlapping owners; sequential
+processes recover reservations but never replay requests. A request with an
+unknown outcome cannot be submitted again with the same payload. Completed
+responses may be deliberately repeated, including missing-usage responses whose
+full reservation remains committed separately for every call.
+
+Amounts are conservative estimates, not invoices: UTF-8 bytes plus protocol
+allowances bound input, the requested output limit is fully reserved, all input
+uses the higher input/cache-write rate, and every amount includes 10% tax safety.
+Missing usage and failed calls keep their full reservation. No cache discounts
+are assumed. The ledger contains only pricing, token counts and safe metadata,
+never prompts, completions or credentials.
+
+Ledger and provider intentionally share a module: native runtime snapshots hash
+provider files directly rather than following their import closures.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import fcntl
+import hashlib
+import json
+import math
+import os
+import re
+import ssl
+import stat
+import threading
+import uuid
+from collections import Counter
+from collections.abc import Mapping
+from dataclasses import dataclass
+from decimal import Decimal, InvalidOperation
+from pathlib import Path
+from typing import Any
+from urllib.parse import urlsplit
+
+import httpx
+
+from solar_eval.config import get_provider_config
+from solar_eval.providers.base import BaseProvider
+
+_TAX = Decimal("1.10")
+_MILLION = Decimal(1_000_000)
+_MODEL = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}\Z")
+_METADATA = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}\Z")
+_PROVIDER_PIN = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,200}\Z")
+_DIGEST = re.compile(r"[0-9a-f]{64}\Z")
+_THINK = re.compile(r"(.*?)", re.DOTALL)
+_MODERN_OPENAI = re.compile(r"gpt-?[56](?:\D|$)", re.IGNORECASE)
+_HTTP_CODES = frozenset(
+ {
+ "invalid_api_key",
+ "insufficient_quota",
+ "model_not_found",
+ "rate_limit_exceeded",
+ "context_length_exceeded",
+ "invalid_request_error",
+ "unsupported_parameter",
+ "unsupported_value",
+ "authentication_error",
+ "permission_denied",
+ "billing_hard_limit_reached",
+ "server_error",
+ "not_found",
+ "content_policy_violation",
+ "invalid_model",
+ "insufficient_balance",
+ "internal_server_error",
+ }
+)
+_STATUSES = frozenset(
+ {
+ "success",
+ "unknown_usage",
+ "incomplete_output",
+ "http_error",
+ "transport_error",
+ "timeout",
+ "cancelled",
+ "invalid_response",
+ }
+)
+
+
+class BudgetError(RuntimeError):
+ """Fail-closed budget, configuration or input error; contains no request data."""
+
+
+class BudgetExceededError(BudgetError):
+ """The next reservation would exceed the cap, or a prior bound was breached."""
+
+
+class BudgetLedgerError(BudgetError):
+ """The ledger is locked, unsafe, corrupt, closed or cannot be made durable."""
+
+
+class DuplicateRequestError(BudgetError):
+ """An identical request has an unresolved charge/outcome; it will not replay."""
+
+
+class UnknownModelError(BudgetError):
+ """The exact model ID has no explicitly configured rate and route."""
+
+
+class ProviderRequestError(BudgetError):
+ """Sanitized failure exposing only provider, HTTP status and a safe code."""
+
+ def __init__(self, provider: str, status_code: int | None, code: str) -> None:
+ self.provider = provider
+ self.status_code = status_code
+ self.code = code
+ super().__init__(f"{provider}: status={status_code or 'unavailable'} code={code}")
+
+
+def _money(value: Any) -> Decimal:
+ if isinstance(value, bool):
+ raise ValueError("invalid_amount")
+ try:
+ amount = Decimal(str(value))
+ except (InvalidOperation, ValueError):
+ raise ValueError("invalid_amount") from None
+ if not amount.is_finite() or amount < 0:
+ raise ValueError("invalid_amount")
+ return amount
+
+
+def _token_count(value: Any) -> bool:
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
+
+
+def _check_rate_authorization(
+ input_rate: Decimal, output_rate: Decimal, authorization: Any
+) -> None:
+ """A zero tariff is an explicit assumption, never an unpriced-model default."""
+ if input_rate == output_rate == 0:
+ if (
+ not isinstance(authorization, str)
+ or not authorization.strip()
+ or len(authorization) > 512
+ ):
+ raise ValueError("zero_cost_requires_authorization")
+ elif authorization is not None:
+ raise ValueError("zero_cost_authorization_requires_zero_rates")
+
+
+@dataclass(frozen=True)
+class ModelRate:
+ provider: str
+ input_usd_per_million: float
+ output_usd_per_million: float
+ input_reserve_usd_per_million: float | None = None
+ provider_pin: str | None = None
+ zero_cost_authorization: str | None = None
+
+ def __post_init__(self) -> None:
+ if self.provider not in {"upstage", "openai", "timely"}:
+ raise BudgetError("unapproved_provider")
+ if self.provider_pin is not None and (
+ self.provider != "timely"
+ or not isinstance(self.provider_pin, str)
+ or not _PROVIDER_PIN.fullmatch(self.provider_pin)
+ ):
+ raise BudgetError("invalid_provider_pin")
+ try:
+ input_rate = _money(self.input_usd_per_million)
+ output_rate = _money(self.output_usd_per_million)
+ reserve_rate = (
+ _money(self.input_reserve_usd_per_million)
+ if self.input_reserve_usd_per_million is not None
+ else input_rate
+ )
+ _check_rate_authorization(
+ max(input_rate, reserve_rate), output_rate, self.zero_cost_authorization
+ )
+ except ValueError:
+ raise BudgetError("invalid_model_rate") from None
+
+ @property
+ def effective_input_rate(self) -> Decimal:
+ """No cache-read discount; include a possible cache-write premium."""
+ return max(
+ _money(self.input_usd_per_million),
+ _money(
+ self.input_reserve_usd_per_million
+ if self.input_reserve_usd_per_million is not None
+ else self.input_usd_per_million
+ ),
+ )
+
+
+def _charge(
+ input_tokens: int, output_tokens: int, input_rate: Decimal, output_rate: Decimal
+) -> Decimal:
+ return (input_tokens * input_rate + output_tokens * output_rate) / _MILLION * _TAX
+
+
+def _usage_counts(usage: Any) -> dict[str, int] | None:
+ if not isinstance(usage, dict):
+ return None
+ prompt, completion = usage.get("prompt_tokens"), usage.get("completion_tokens")
+ if not _token_count(prompt) or not _token_count(completion):
+ return None
+ total = usage.get("total_tokens")
+ if "total_tokens" in usage and (not _token_count(total) or total != prompt + completion):
+ return None
+ # Details are not added again: reasoning is already included in completion
+ # tokens, and cached tokens are already included in prompt tokens.
+ for name, bound in (
+ ("prompt_tokens_details", prompt),
+ ("completion_tokens_details", completion),
+ ):
+ details = usage.get(name)
+ if details is not None and (
+ not isinstance(details, dict)
+ or any(
+ not _token_count(value) or value > bound
+ for key, value in details.items()
+ if key.endswith("_tokens") and value is not None
+ )
+ ):
+ return None
+ counts = {"prompt_tokens": prompt, "completion_tokens": completion}
+ if total is not None:
+ counts["total_tokens"] = total
+ return counts
+
+
+class BudgetLedger:
+ """Append-only JSONL ledger, mode 0600, protected by a lifetime POSIX lock.
+
+ ``read_summary()`` returns float USD for reporting; all decisions and durable
+ amounts use decimal arithmetic. ``charged_estimate_usd`` includes only calls
+ with valid observed token counts. ``committed_upper_usd`` adds every pending
+ or unknown-cost reservation to those estimates. Resume may lower the ceiling;
+ increases require ``approve_limit_increase`` with explicit authorization.
+ A malformed/partial log fails closed, not repaired.
+ """
+
+ def __init__(self, path: Path, limit_usd: float = 50.0) -> None:
+ self._fd: int | None = None
+ self._pid = os.getpid()
+ self._lock = threading.Lock()
+ self._records: dict[str, dict[str, Any]] = {}
+ self._blocked_keys: set[str] = set()
+ self._active: set[str] = set()
+ self._halted = False
+ self._poisoned = False
+ self._limit: Decimal | None = None
+ try:
+ requested_limit = _money(limit_usd)
+ if requested_limit == 0:
+ raise ValueError("invalid_limit")
+ requested_path = Path(path).expanduser().absolute()
+ self.path = requested_path.parent.resolve() / requested_path.name
+ if any((parent / ".git").exists() for parent in self.path.parents):
+ raise ValueError("ledger_inside_repository")
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ self._fd = os.open(
+ self.path, os.O_RDWR | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o600
+ )
+ fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ info = os.fstat(self._fd)
+ if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != os.getuid():
+ raise ValueError("unsafe_ledger_file")
+ os.fchmod(self._fd, 0o600)
+ with os.fdopen(os.dup(self._fd), "r", encoding="utf-8") as stream:
+ for line in stream:
+ if not line.endswith("\n"):
+ raise ValueError("partial_ledger_event")
+ self._apply(json.loads(line))
+ if self._limit is None:
+ self._append(
+ {
+ "event": "open",
+ "version": 1,
+ "limit_usd": str(requested_limit),
+ "tax_multiplier": str(_TAX),
+ }
+ )
+ elif requested_limit > self._limit:
+ raise ValueError("cannot_raise_existing_cap")
+ elif requested_limit < self._limit:
+ if requested_limit < self._committed():
+ raise ValueError("cap_below_committed_spend")
+ self._append({"event": "limit", "limit_usd": str(requested_limit)})
+ # Persist creation of the directory entry as well as the log contents.
+ directory_fd = os.open(self.path.parent, os.O_RDONLY)
+ try:
+ os.fsync(directory_fd)
+ finally:
+ os.close(directory_fd)
+ except Exception:
+ self._release()
+ raise BudgetLedgerError("cannot_open_safe_budget_ledger") from None
+
+ def _ensure_open(self) -> None:
+ if self._fd is None or self._pid != os.getpid() or self._poisoned:
+ raise BudgetLedgerError("budget_ledger_unavailable")
+
+ def _release(self) -> None:
+ if self._fd is not None:
+ os.close(self._fd)
+ self._fd = None
+
+ def close(self) -> None:
+ """Release the process lock; close providers and await requests first."""
+ if self._active:
+ raise BudgetLedgerError("requests_still_in_flight")
+ self._release()
+
+ def approve_limit_increase(self, new_limit_usd: float, *, authorization: str) -> None:
+ """Audit an explicitly authorized increase without releasing prior charges.
+
+ Open an existing ledger at its current cap, approve while no requests are
+ in flight, then use the new cap when reopening it. Recovered pending
+ reservations remain committed; a prior safety halt is never cleared.
+ Authorization is persisted verbatim: supply no credentials or customer text.
+ """
+ if not self._lock.acquire(blocking=False):
+ raise BudgetLedgerError("budget_update_in_progress")
+ try:
+ self._ensure_open()
+ if self._active:
+ raise BudgetLedgerError("requests_still_in_flight")
+ try:
+ new_limit = _money(new_limit_usd)
+ except ValueError:
+ raise BudgetLedgerError("invalid_limit_increase") from None
+ if (
+ new_limit <= self._limit
+ or not isinstance(authorization, str)
+ or not authorization.strip()
+ ):
+ raise BudgetLedgerError("invalid_limit_increase")
+ self._append(
+ {
+ "event": "limit_increase",
+ "old_limit_usd": str(self._limit),
+ "new_limit_usd": str(new_limit),
+ "authorization": authorization,
+ }
+ )
+ finally:
+ self._lock.release()
+
+ def __enter__(self) -> BudgetLedger:
+ self._ensure_open()
+ return self
+
+ def __exit__(self, *_: Any) -> None:
+ self.close()
+
+ def _committed(self) -> Decimal:
+ return sum(
+ (
+ record["cost"] if record["cost"] is not None else record["upper"]
+ for record in self._records.values()
+ ),
+ Decimal(0),
+ )
+
+ def _apply(self, event: dict[str, Any]) -> None:
+ kind = event["event"]
+ if kind == "open":
+ if (
+ self._limit is not None
+ or event["version"] != 1
+ or _money(event["tax_multiplier"]) != _TAX
+ ):
+ raise ValueError("invalid_ledger_header")
+ self._limit = _money(event["limit_usd"])
+ if self._limit == 0:
+ raise ValueError("invalid_ledger_limit")
+ return
+ if self._limit is None:
+ raise ValueError("missing_ledger_header")
+ if kind == "limit":
+ limit = _money(event["limit_usd"])
+ if limit == 0 or limit > self._limit or limit < self._committed():
+ raise ValueError("invalid_ledger_limit")
+ self._limit = limit
+ elif kind == "limit_increase":
+ old_limit = _money(event["old_limit_usd"])
+ new_limit = _money(event["new_limit_usd"])
+ authorization = event["authorization"]
+ if (
+ old_limit != self._limit
+ or new_limit <= old_limit
+ or not isinstance(authorization, str)
+ or not authorization.strip()
+ ):
+ raise ValueError("invalid_limit_increase")
+ self._limit = new_limit
+ elif kind == "reserve":
+ request_id, key = event["id"], event["request_key"]
+ if (
+ not isinstance(request_id, str)
+ or not re.fullmatch(r"[0-9a-f]{32}", request_id)
+ or not isinstance(key, str)
+ or not _DIGEST.fullmatch(key)
+ or request_id in self._records
+ or key in self._blocked_keys
+ or event["provider"] not in {"upstage", "openai", "timely"}
+ or not isinstance(event["model"], str)
+ or not _MODEL.fullmatch(event["model"])
+ ):
+ raise ValueError("invalid_reservation")
+ pin = event.get("provider_pin")
+ if pin is not None and (
+ event["provider"] != "timely"
+ or not isinstance(pin, str)
+ or not _PROVIDER_PIN.fullmatch(pin)
+ ):
+ raise ValueError("invalid_provider_pin")
+ input_upper, output_upper = event["input_tokens_upper"], event["output_tokens_upper"]
+ if not _token_count(input_upper) or not _token_count(output_upper) or output_upper == 0:
+ raise ValueError("invalid_token_bound")
+ input_rate = _money(event["input_rate_usd_per_million"])
+ output_rate = _money(event["output_rate_usd_per_million"])
+ zero_cost_authorization = event.get("zero_cost_authorization")
+ _check_rate_authorization(input_rate, output_rate, zero_cost_authorization)
+ upper = _money(event["upper_usd"])
+ if (
+ upper != _charge(input_upper, output_upper, input_rate, output_rate)
+ or self._committed() + upper > self._limit
+ or self._halted
+ ):
+ raise ValueError("invalid_reservation_amount")
+ self._records[request_id] = {
+ "id": request_id,
+ "request_key": key,
+ "provider": event["provider"],
+ "model": event["model"],
+ "provider_pin": pin,
+ "zero_cost_authorization": zero_cost_authorization,
+ "upper": upper,
+ "cost": None,
+ "status": "pending",
+ "input_rate": input_rate,
+ "output_rate": output_rate,
+ "usage": None,
+ "observed_model": None,
+ "observed_provider": None,
+ "provider_request_id": None,
+ }
+ self._blocked_keys.add(key)
+ elif kind == "settle":
+ record = self._records[event["id"]]
+ if record["status"] != "pending" or event["status"] not in _STATUSES:
+ raise ValueError("invalid_settlement")
+ if event.get("provider_pin") != record["provider_pin"]:
+ raise ValueError("invalid_settlement_pin")
+ counts = _usage_counts(event["usage"]) if event["usage"] is not None else None
+ if event["usage"] is not None and counts is None:
+ raise ValueError("invalid_usage")
+ cost = (
+ None
+ if counts is None
+ else _charge(
+ counts["prompt_tokens"],
+ counts["completion_tokens"],
+ record["input_rate"],
+ record["output_rate"],
+ )
+ )
+ logged_cost = event["charged_estimate_usd"]
+ if (cost is None) != (logged_cost is None) or (
+ cost is not None and cost != _money(logged_cost)
+ ):
+ raise ValueError("invalid_settlement_amount")
+ for name in ("observed_model", "provider_request_id", "observed_provider"):
+ value = event.get(name)
+ pattern = _PROVIDER_PIN if name == "observed_provider" else _METADATA
+ if value is not None and (
+ not isinstance(value, str) or not pattern.fullmatch(value)
+ ):
+ raise ValueError("invalid_response_metadata")
+ record[name] = value
+ record.update(status=event["status"], cost=cost, usage=counts)
+ # A valid completed response has a known outcome even when its token
+ # accounting is incomplete. Keep its full charge reservation, but
+ # permit an explicit new replicate with a fresh reservation.
+ if cost is not None or event["status"] == "unknown_usage":
+ self._blocked_keys.remove(record["request_key"])
+ if cost is not None:
+ self._halted |= cost > record["upper"] or self._committed() > self._limit
+ else:
+ raise ValueError("unknown_ledger_event")
+
+ def _append(self, event: dict[str, Any]) -> None:
+ self._ensure_open()
+ encoded = (json.dumps(event, separators=(",", ":"), allow_nan=False) + "\n").encode()
+ try:
+ view = memoryview(encoded)
+ while view:
+ written = os.write(self._fd, view)
+ if written <= 0:
+ raise OSError("incomplete_write")
+ view = view[written:]
+ os.fsync(self._fd)
+ self._apply(event)
+ except Exception:
+ self._poisoned = True
+ raise BudgetLedgerError("budget_event_not_durable") from None
+
+ async def _reserve(
+ self, model: str, rate: ModelRate, request_key: str, input_upper: int, output_upper: int
+ ) -> str:
+ with self._lock:
+ self._ensure_open()
+ if self._halted:
+ raise BudgetExceededError("prior_reservation_bound_exceeded")
+ if request_key in self._blocked_keys:
+ raise DuplicateRequestError("request_has_unknown_prior_outcome")
+ input_rate, output_rate = rate.effective_input_rate, _money(rate.output_usd_per_million)
+ upper = _charge(input_upper, output_upper, input_rate, output_rate)
+ if self._committed() + upper > self._limit:
+ raise BudgetExceededError("insufficient_uncommitted_budget")
+ request_id = uuid.uuid4().hex
+ self._append(
+ {
+ "event": "reserve",
+ "id": request_id,
+ "request_key": request_key,
+ "provider": rate.provider,
+ "model": model,
+ "provider_pin": rate.provider_pin,
+ "upper_usd": str(upper),
+ "input_tokens_upper": input_upper,
+ "output_tokens_upper": output_upper,
+ "input_rate_usd_per_million": str(input_rate),
+ "output_rate_usd_per_million": str(output_rate),
+ "zero_cost_authorization": rate.zero_cost_authorization,
+ }
+ )
+ self._active.add(request_id)
+ return request_id
+
+ async def _settle(
+ self,
+ request_id: str,
+ status: str,
+ usage: dict[str, int] | None = None,
+ observed_model: str | None = None,
+ provider_request_id: str | None = None,
+ observed_provider: str | None = None,
+ ) -> Decimal | None:
+ with self._lock:
+ self._ensure_open()
+ record = self._records[request_id]
+ cost = (
+ None
+ if usage is None
+ else _charge(
+ usage["prompt_tokens"],
+ usage["completion_tokens"],
+ record["input_rate"],
+ record["output_rate"],
+ )
+ )
+ try:
+ self._append(
+ {
+ "event": "settle",
+ "id": request_id,
+ "status": status,
+ "usage": usage,
+ "charged_estimate_usd": str(cost) if cost is not None else None,
+ "observed_model": observed_model,
+ "provider_request_id": provider_request_id,
+ "provider_pin": record["provider_pin"],
+ "observed_provider": observed_provider,
+ }
+ )
+ finally:
+ self._active.discard(request_id)
+ return cost
+
+ def read_summary(self) -> dict[str, Any]:
+ """Return token-accounted estimates separately from unresolved upper bounds."""
+ charged = sum(
+ (record["cost"] for record in self._records.values() if record["cost"] is not None),
+ Decimal(0),
+ )
+ committed = self._committed()
+ records = [
+ {
+ "id": record["id"],
+ "provider": record["provider"],
+ "model": record["model"],
+ "provider_pin": record["provider_pin"],
+ "status": record["status"],
+ "reserved_upper_usd": float(record["upper"]),
+ "charged_estimate_usd": float(record["cost"])
+ if record["cost"] is not None
+ else None,
+ "usage": dict(record["usage"]) if record["usage"] is not None else None,
+ "observed_model": record["observed_model"],
+ "observed_provider": record["observed_provider"],
+ "provider_request_id": record["provider_request_id"],
+ "zero_cost_authorization": record["zero_cost_authorization"],
+ }
+ for record in self._records.values()
+ ]
+ return {
+ "limit_usd": float(self._limit),
+ "tax_multiplier": float(_TAX),
+ "charged_estimate_usd": float(charged),
+ "committed_upper_usd": float(committed),
+ "unknown_upper_usd": float(committed - charged),
+ "remaining_usd": float(max(self._limit - committed, Decimal(0))),
+ "requests": len(records),
+ "status_counts": dict(Counter(record["status"] for record in records)),
+ "halted": self._halted or self._poisoned,
+ "request_details": records,
+ }
+
+
+def _upstage_base(base: str) -> str:
+ try:
+ parsed = urlsplit(base)
+ valid = (
+ parsed.scheme == "https"
+ and parsed.hostname == "api.upstage.ai"
+ and parsed.port in {None, 443}
+ and parsed.username is None
+ and parsed.password is None
+ and not parsed.query
+ and not parsed.fragment
+ and not any(character.isspace() for character in base)
+ )
+ except (TypeError, ValueError):
+ valid = False
+ if not valid:
+ raise BudgetError("unapproved_upstage_endpoint")
+ return "https://api.upstage.ai" + parsed.path.rstrip("/")
+
+
+class BudgetedProvider(BaseProvider):
+ """Reusable HTTP client with no redirects, proxies, hidden retries or streaming.
+
+ Routing comes solely from the exact ``rates`` map. Upstage uses its configured
+ HTTPS base only on api.upstage.ai; OpenAI and Timely use fixed HTTPS endpoints.
+ Timely reads only TIMELY_API_KEY and never falls back from a provider pin.
+ A transport injection is for offline testing, not a second routing mechanism.
+ """
+
+ def __init__(
+ self,
+ ledger: BudgetLedger,
+ rates: Mapping[str, ModelRate],
+ *,
+ request_timeout: float = 45.0,
+ transport: httpx.AsyncBaseTransport | None = None,
+ ssl_context: ssl.SSLContext | None = None,
+ ) -> None:
+ if (
+ isinstance(request_timeout, bool)
+ or not isinstance(request_timeout, (int, float))
+ or not math.isfinite(request_timeout)
+ or request_timeout <= 0
+ ):
+ raise BudgetError("invalid_request_timeout")
+ self._ssl_context = ssl_context
+ self._validate_tls_context()
+ if not rates or any(
+ not isinstance(model, str)
+ or not _MODEL.fullmatch(model)
+ or not isinstance(rate, ModelRate)
+ for model, rate in rates.items()
+ ):
+ raise BudgetError("invalid_model_rates")
+ config_error = False
+ try:
+ config = get_provider_config()
+ except Exception:
+ config_error = True
+ if config_error:
+ raise BudgetError("invalid_provider_configuration")
+ self._urls = {
+ "openai": "https://api.openai.com/v1/chat/completions",
+ "timely": "https://api.timelyrouter.ai/v1/chat/completions",
+ }
+ if any(rate.provider == "upstage" for rate in rates.values()):
+ self._urls["upstage"] = _upstage_base(config.upstage_api_base) + "/chat/completions"
+ self._keys = {
+ "upstage": config.upstage_api_key,
+ "openai": config.openai_api_key,
+ "timely": os.environ.get("TIMELY_API_KEY"),
+ }
+ if any(
+ rate.provider_pin is not None
+ and self._metadata(rate.provider_pin, pattern=_PROVIDER_PIN) is None
+ for rate in rates.values()
+ ):
+ raise BudgetError("invalid_provider_pin")
+ self.ledger = ledger
+ self._rates = dict(rates)
+ self._timeout = float(request_timeout)
+ self._client = httpx.AsyncClient(
+ timeout=request_timeout,
+ transport=transport,
+ follow_redirects=False,
+ trust_env=False,
+ verify=ssl_context if ssl_context is not None else True,
+ )
+ self._closed = False
+ self._request_ids: list[str] = []
+
+ def _validate_tls_context(self) -> None:
+ context = self._ssl_context
+ if context is not None and (
+ not isinstance(context, ssl.SSLContext)
+ or context.verify_mode != ssl.CERT_REQUIRED
+ or not context.check_hostname
+ ):
+ raise BudgetError("unsafe_tls_context")
+
+ @property
+ def request_ids(self) -> tuple[str, ...]:
+ """Reservations owned by this provider instance, including failed calls."""
+ return tuple(self._request_ids)
+
+ async def aclose(self) -> None:
+ """Close the HTTP pool; the caller owns and separately closes the ledger."""
+ self._closed = True
+ await self._client.aclose()
+
+ def _metadata(self, value: Any, *, pattern: re.Pattern[str] = _METADATA) -> str | None:
+ if (
+ not isinstance(value, str)
+ or not pattern.fullmatch(value)
+ or any(key and key in value for key in self._keys.values())
+ ):
+ return None
+ return value
+
+ async def chat_completion(
+ self,
+ messages: list[dict[str, str]],
+ model: str,
+ temperature: float | None = 0.0,
+ max_tokens: int = 8000,
+ reasoning_effort: str | None = None,
+ response_format: dict[str, Any] | None = None,
+ tools: list[dict[str, Any]] | None = None,
+ tool_choice: str | dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ if model not in self._rates:
+ raise UnknownModelError("model_has_no_approved_rate")
+ if self._closed:
+ raise BudgetError("provider_closed")
+ self._validate_tls_context()
+ rate = self._rates[model]
+ if not self._keys[rate.provider]:
+ raise ProviderRequestError(rate.provider, None, "missing_api_key")
+ if not _token_count(max_tokens) or max_tokens == 0:
+ raise BudgetError("invalid_output_token_limit")
+ # Byte bounds apply only to text. Media references can incur token costs
+ # unrelated to their URL/JSON length and therefore must fail closed.
+ allowed_fields = {"role", "content", "name", "tool_calls", "tool_call_id", "refusal"}
+ if (
+ not isinstance(messages, list)
+ or not messages
+ or any(
+ not isinstance(message, dict)
+ or set(message) - allowed_fields
+ or message.get("role") not in {"system", "developer", "user", "assistant", "tool"}
+ or (message.get("content") is not None and not isinstance(message["content"], str))
+ for message in messages
+ )
+ ):
+ raise BudgetError("only_text_messages_have_supported_token_bounds")
+ if tools is not None and (
+ not isinstance(tools, list)
+ or any(
+ not isinstance(tool, dict)
+ or tool.get("type") != "function"
+ or not isinstance(tool.get("function"), dict)
+ for tool in tools
+ )
+ ):
+ raise BudgetError("only_local_function_tools_have_approved_pricing")
+ payload: dict[str, Any] = {"model": model, "messages": messages}
+ if rate.provider in {"openai", "timely"} and _MODERN_OPENAI.match(model):
+ payload["max_completion_tokens"] = max_tokens
+ if reasoning_effort is not None:
+ payload["reasoning_effort"] = reasoning_effort
+ # Unspecified reasoning may use the model's reasoning-on default.
+ if reasoning_effort == "none" and temperature is not None:
+ payload["temperature"] = temperature
+ elif rate.provider == "timely":
+ payload["max_completion_tokens"] = max_tokens
+ if temperature is not None:
+ payload["temperature"] = temperature
+ if reasoning_effort is not None:
+ payload["reasoning_effort"] = reasoning_effort
+ else:
+ payload.update(max_tokens=max_tokens, temperature=temperature)
+ if rate.provider == "upstage" and reasoning_effort:
+ payload["reasoning_effort"] = reasoning_effort
+ if rate.provider == "timely":
+ payload["stream"] = False
+ if response_format:
+ payload["response_format"] = response_format
+ if tools:
+ payload["tools"] = tools
+ if tool_choice is not None:
+ payload["tool_choice"] = tool_choice
+ invalid_payload = False
+ try:
+ body = json.dumps(
+ payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
+ ).encode("utf-8")
+ except (TypeError, ValueError, UnicodeError):
+ invalid_payload = True
+ if invalid_payload:
+ raise BudgetError("invalid_text_request_payload")
+ # Includes full schema/tool JSON bytes, not just visible message text.
+ # One token per UTF-8 byte is conservative for byte-based tokenizers;
+ # protocol/special-token overhead is reserved independently per message.
+ input_upper = len(body) + 1024 + 256 * len(messages)
+ digest_prefix = rate.provider.encode() + b"\0"
+ if rate.provider == "timely":
+ digest_prefix += (rate.provider_pin or "").encode() + b"\0"
+ digest = hashlib.sha256(digest_prefix + body).hexdigest()
+ reservation = await self.ledger._reserve(model, rate, digest, input_upper, max_tokens)
+ self._request_ids.append(reservation)
+ data: Any = None
+ status_code: int | None = None
+ request_id: str | None = None
+ observed_provider: str | None = None
+ provider_mismatch = False
+ error_code: str | None = None
+ failure_status = "transport_error"
+ headers = {
+ "Authorization": f"Bearer {self._keys[rate.provider]}",
+ "Content-Type": "application/json",
+ }
+ if rate.provider_pin is not None:
+ headers["X-Provider-Pin"] = rate.provider_pin
+ try:
+ # httpx's timeout alone bounds inactivity, not total elapsed time.
+ async with asyncio.timeout(self._timeout):
+ response = await self._client.post(
+ self._urls[rate.provider],
+ headers=headers,
+ content=body,
+ )
+ status_code = response.status_code
+ request_id = self._metadata(
+ response.headers.get("x-request-id")
+ or response.headers.get("request-id")
+ or response.headers.get("x-upstage-request-id")
+ )
+ if rate.provider == "timely":
+ observed_provider = self._metadata(
+ response.headers.get("x-provider"), pattern=_PROVIDER_PIN
+ )
+ provider_mismatch = (
+ rate.provider_pin is not None and observed_provider != rate.provider_pin
+ )
+ try:
+ data = response.json()
+ except (ValueError, UnicodeError):
+ data = None
+ if not 200 <= status_code < 300:
+ failure_status, error_code = "http_error", "http_error"
+ if isinstance(data, dict) and isinstance(data.get("error"), dict):
+ remote_code = data["error"].get("code")
+ if isinstance(remote_code, str) and remote_code in _HTTP_CODES:
+ error_code = remote_code
+ elif not isinstance(data, dict):
+ failure_status, error_code = "invalid_response", "invalid_response"
+ except asyncio.CancelledError:
+ await self.ledger._settle(reservation, "cancelled")
+ raise
+ except (TimeoutError, httpx.TimeoutException):
+ failure_status, error_code = "timeout", "timeout"
+ except Exception:
+ failure_status, error_code = "transport_error", "transport_error"
+ # Raise outside the exception handler: __context__ must not retain an
+ # httpx exception containing a credential-bearing request or response.
+ if error_code is not None:
+ # If routing violated a pin, account any supplied valid usage even
+ # when the response itself failed. Otherwise retain the historical
+ # unknown-cost treatment of failed requests.
+ error_usage = (
+ _usage_counts(data.get("usage"))
+ if provider_mismatch and isinstance(data, dict)
+ else None
+ )
+ await self.ledger._settle(
+ reservation,
+ failure_status,
+ error_usage,
+ provider_request_id=request_id,
+ observed_provider=observed_provider,
+ )
+ raise ProviderRequestError(rate.provider, status_code, error_code)
+ usage = data.get("usage")
+ counts = _usage_counts(usage)
+ observed_model = self._metadata(data.get("model"))
+ content, think_content, finish_reason, tool_calls = "", "", None, []
+ try:
+ choice = data["choices"][0]
+ message = choice["message"]
+ content = message.get("content") or ""
+ finish_reason = choice.get("finish_reason")
+ tool_calls = message.get("tool_calls") or []
+ if not isinstance(content, str) or not isinstance(tool_calls, list):
+ error_code = "invalid_response"
+ elif message.get("refusal"):
+ error_code = "completion_refused"
+ elif finish_reason == "length":
+ error_code = "completion_length"
+ elif finish_reason == "content_filter":
+ error_code = "completion_filtered"
+ elif tool_calls or finish_reason == "tool_calls":
+ if (
+ not tools
+ or finish_reason != "tool_calls"
+ or not tool_calls
+ or any(not isinstance(call, dict) for call in tool_calls)
+ ):
+ error_code = "unexpected_tool_calls"
+ elif finish_reason != "stop":
+ error_code = "invalid_finish_reason"
+ if isinstance(content, str) and rate.provider == "upstage":
+ match = _THINK.search(content)
+ if match:
+ think_content = match.group(1).strip()
+ content = _THINK.sub("", content).strip()
+ if error_code is None and not tool_calls and not content.strip():
+ error_code = "empty_completion"
+ except (KeyError, IndexError, TypeError, AttributeError):
+ error_code = "invalid_response"
+ if provider_mismatch:
+ error_code = "provider_pin_mismatch"
+ settlement_status = (
+ "invalid_response"
+ if provider_mismatch
+ else "incomplete_output"
+ if error_code
+ else "success"
+ if counts is not None
+ else "unknown_usage"
+ )
+ cost = await self.ledger._settle(
+ reservation, settlement_status, counts, observed_model, request_id, observed_provider
+ )
+ if self.ledger._halted:
+ raise BudgetExceededError("observed_charge_exceeded_reservation")
+ if error_code:
+ raise ProviderRequestError(rate.provider, status_code, error_code)
+ result = {
+ "content": content,
+ "usage": usage if isinstance(usage, dict) else {},
+ "finish_reason": finish_reason,
+ "tool_calls": tool_calls,
+ "observed_model": observed_model,
+ "provider_pin": rate.provider_pin,
+ "observed_provider": observed_provider,
+ "provider_request_id": request_id,
+ "cost_usd": float(cost) if cost is not None else None,
+ }
+ if rate.provider == "upstage":
+ result["think_content"] = think_content
+ return result
+
+ async def chat_completion_json(
+ self,
+ messages: list[dict[str, str]],
+ model: str,
+ temperature: float = 0.0,
+ max_tokens: int = 8000,
+ ) -> dict[str, Any]:
+ result = await self.chat_completion(messages, model, temperature, max_tokens)
+ content = result["content"]
+ invalid_json = False
+ try:
+ try:
+ parsed = json.loads(content)
+ except json.JSONDecodeError:
+ match = re.search(r"```(?:json)?\s*(.*?)```", content, re.DOTALL)
+ if not match:
+ raise
+ parsed = json.loads(match.group(1).strip())
+ if not isinstance(parsed, dict):
+ invalid_json = True
+ except (ValueError, TypeError):
+ invalid_json = True
+ if invalid_json:
+ raise ProviderRequestError(self._rates[model].provider, 200, "invalid_json_object")
+ return parsed
diff --git a/solar_eval/providers/openai_compat.py b/solar_eval/providers/openai_compat.py
new file mode 100644
index 0000000000000000000000000000000000000000..589693e5bda432a2a4cadf4ac13d43741c04754b
--- /dev/null
+++ b/solar_eval/providers/openai_compat.py
@@ -0,0 +1,201 @@
+"""OpenAI 호환 chat/completions 제공자 — Timely Router(https://api.timelyrouter.ai/v1) 처럼 여러 모델을 한 엔드포인트로 묶은 라우터용.
+
+Upstage 제공자와 같은 반환 모양(`{content, usage, finish_reason, tool_calls, think_content}`)을 지킨다. 라우터 뒤의 모델마다
+받는 인자가 달라서(예: Claude Sonnet 5.5·GPT-5.6 계열은 `temperature` 를 거부한다) 400 응답 본문이 그 인자를 지목하면
+그 인자를 빼고 한 번 다시 보낸다 — 모델별 사전을 두지 않는다. 429 는 `Retry-After` 를 따른다.
+
+파이프라인 스텝은 `model: timely:claude-opus-5-5` 처럼 접두어로 이 제공자를 고른다(`providers.routed.RoutedProvider`).
+키는 env `TIMELY_API_KEY`(또는 solar-eval.yaml `timely.api_key`). 고객 텍스트가 Upstage 밖으로 나가므로, 써도 되는가는
+case 의 데이터 정책(계약)이 정한다 — 레포 기본은 외부 API 허용(SECURITY.md «데이터 처리»), 엔진에 게이트는 없다.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import json
+import logging
+import re
+from typing import Any
+
+import httpx
+
+from solar_eval.config import get_provider_config
+from solar_eval.providers.base import BaseProvider
+
+logger = logging.getLogger(__name__)
+
+MAX_RETRIES = 4
+RETRY_DELAYS = (5, 10, 20, 30) # seconds — Retry-After 가 없을 때
+MAX_RETRY_AFTER = 60.0
+TIMEOUT = 300.0
+#: 400 본문에서 "이 인자를 거부한다" 를 읽어 빼고 다시 보낼 수 있는 인자들
+DROPPABLE = ("temperature", "reasoning_effort", "response_format", "top_p")
+_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.S)
+
+
+class OpenAICompatProvider(BaseProvider):
+ """`base_url` 의 `/chat/completions` 를 Bearer 키로 부르는 제공자."""
+
+ name = "openai-compat"
+
+ def __init__(self, base_url: str, api_key: str, name: str | None = None) -> None:
+ self.api_base = base_url.rstrip("/")
+ self.api_key = api_key
+ if name:
+ self.name = name
+ #: 거부당한 (model, 인자) — 같은 모델에 같은 인자를 다시 보내지 않는다
+ self._dropped: set[tuple[str, str]] = set()
+
+ async def chat_completion(
+ self,
+ messages: list[dict[str, str]],
+ model: str,
+ temperature: float = 0.0,
+ max_tokens: int = 8000,
+ reasoning_effort: str | None = None,
+ response_format: dict[str, Any] | None = None,
+ tools: list[dict[str, Any]] | None = None,
+ tool_choice: str | dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ if not self.api_key:
+ raise RuntimeError(
+ f"{self.name}: API 키가 없다 — TIMELY_API_KEY 또는 solar-eval.yaml timely.api_key"
+ )
+ payload: dict[str, Any] = {
+ "model": model,
+ "messages": messages,
+ "temperature": temperature,
+ "max_tokens": max_tokens,
+ }
+ if reasoning_effort:
+ payload["reasoning_effort"] = reasoning_effort
+ if response_format:
+ payload["response_format"] = response_format
+ if tools:
+ payload["tools"] = tools
+ if tool_choice is not None:
+ payload["tool_choice"] = tool_choice
+ for key in DROPPABLE:
+ if (model, key) in self._dropped:
+ payload.pop(key, None)
+
+ data = await self._post_with_retries(payload)
+ choice = data["choices"][0]
+ message = choice.get("message") or {}
+ return {
+ "content": message.get("content") or "",
+ "think_content": "",
+ "usage": _normalize_usage(data.get("usage") or {}),
+ "finish_reason": choice.get("finish_reason", "stop"),
+ "tool_calls": message.get("tool_calls") or [],
+ }
+
+ async def chat_completion_json(
+ self,
+ messages: list[dict[str, str]],
+ model: str,
+ temperature: float = 0.0,
+ max_tokens: int = 8000,
+ ) -> dict[str, Any]:
+ result = await self.chat_completion(messages, model, temperature, max_tokens)
+ content = _FENCE_RE.sub("", (result.get("content") or "").strip())
+ try:
+ return json.loads(content)
+ except json.JSONDecodeError as exc:
+ raise RuntimeError(f"{self.name}: JSON 응답을 파싱할 수 없다 ({model})") from exc
+
+ # ── 내부 ────────────────────────────────────────────────────────────
+
+ async def _post_with_retries(self, payload: dict[str, Any]) -> dict[str, Any]:
+ model = str(payload.get("model"))
+ attempt = 0
+ while True:
+ try:
+ async with httpx.AsyncClient(timeout=TIMEOUT) as client:
+ response = await client.post(
+ f"{self.api_base}/chat/completions",
+ headers={"Authorization": f"Bearer {self.api_key}"},
+ json=payload,
+ )
+ except httpx.HTTPError as exc:
+ attempt = await self._backoff(attempt, model, f"{type(exc).__name__}")
+ continue
+ if response.status_code == 200:
+ return response.json()
+ if response.status_code == 400:
+ dropped = _droppable_param(response.text)
+ if dropped and dropped in payload:
+ logger.warning(f"{self.name}: {model} 이 {dropped} 를 거부해 빼고 다시 보낸다")
+ self._dropped.add((model, dropped))
+ payload.pop(dropped)
+ continue
+ raise RuntimeError(f"{self.name}: 400 ({model}): {_error_message(response.text)}")
+ if response.status_code in (401, 402, 403, 404):
+ raise RuntimeError(
+ f"{self.name}: {response.status_code} ({model}): {_error_message(response.text)}"
+ )
+ if response.status_code == 429:
+ attempt = await self._backoff(
+ attempt, model, "429", retry_after=response.headers.get("Retry-After")
+ )
+ continue
+ attempt = await self._backoff(attempt, model, f"HTTP {response.status_code}")
+
+ async def _backoff(
+ self, attempt: int, model: str, why: str, *, retry_after: str | None = None
+ ) -> int:
+ if attempt >= MAX_RETRIES:
+ raise RuntimeError(f"{self.name}: {model} 호출이 {MAX_RETRIES}회 실패했다 ({why})")
+ delay = float(RETRY_DELAYS[min(attempt, len(RETRY_DELAYS) - 1)])
+ if retry_after:
+ with contextlib.suppress(ValueError):
+ delay = min(float(retry_after), MAX_RETRY_AFTER)
+ logger.warning(
+ f"{self.name}: {model} {why} — {delay:.0f}s 뒤 재시도 ({attempt + 1}/{MAX_RETRIES})"
+ )
+ await asyncio.sleep(delay)
+ return attempt + 1
+
+
+def _droppable_param(body: str) -> str | None:
+ """400 본문이 지목한 인자 — `param` 필드가 있으면 그것, 없으면 메시지에 이름이 나오는 인자."""
+ try:
+ err = (json.loads(body) or {}).get("error") or {}
+ except (json.JSONDecodeError, AttributeError):
+ err = {}
+ param = err.get("param") if isinstance(err, dict) else None
+ if isinstance(param, str) and param in DROPPABLE:
+ return param
+ message = str(err.get("message", "") if isinstance(err, dict) else body)
+ for key in DROPPABLE:
+ # `temperature` · 'temperature' · "temperature" · field temperature … — 어떤 인용 부호든 단어 경계로 본다
+ if re.search(rf"(? str:
+ try:
+ err = (json.loads(body) or {}).get("error") or {}
+ return str(err.get("message") or err.get("code") or body)[:300]
+ except (json.JSONDecodeError, AttributeError):
+ return body[:300]
+
+
+def _normalize_usage(usage: dict[str, Any]) -> dict[str, int]:
+ return {
+ "prompt_tokens": int(usage.get("prompt_tokens") or 0),
+ "completion_tokens": int(usage.get("completion_tokens") or 0),
+ "total_tokens": int(usage.get("total_tokens") or 0),
+ }
+
+
+class TimelyProvider(OpenAICompatProvider):
+ """Timely Router — 키·엔드포인트는 `ProviderConfig`(env `TIMELY_API_KEY`·`TIMELY_API_BASE`)."""
+
+ name = "timely"
+
+ def __init__(self) -> None:
+ cfg = get_provider_config()
+ super().__init__(cfg.timely_api_base, cfg.timely_api_key, name="timely")
diff --git a/solar_eval/providers/routed.py b/solar_eval/providers/routed.py
new file mode 100644
index 0000000000000000000000000000000000000000..55871e3f0d279cba27051344b29071986978a173
--- /dev/null
+++ b/solar_eval/providers/routed.py
@@ -0,0 +1,75 @@
+"""모델 접두어로 제공자를 고르는 래퍼 — 스텝마다 다른 제공자를 쓰기 위한 유일한 통로.
+
+`model: timely:claude-opus-5-5` → `routes["timely"].chat_completion(model="claude-opus-5-5", …)`.
+접두어가 등록돼 있지 않으면(`ft:solar-news-correction` 처럼 콜론이 있어도) 기본 제공자에 모델 문자열 그대로 넘긴다.
+라우팅은 추론 제공자에만 쓰고, judge 제공자는 `judge_config` 선언으로 따로 고른다.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any
+
+from solar_eval.providers.base import BaseProvider
+
+ROUTE_SEP = ":"
+
+
+def split_route(
+ model: str, routes: Mapping[str, BaseProvider] | Mapping[str, Any]
+) -> tuple[str | None, str]:
+ """(접두어 | None, 제공자에 넘길 모델 이름). 등록된 접두어만 뗀다."""
+ head, sep, rest = model.partition(ROUTE_SEP)
+ if sep and rest and head in routes:
+ return head, rest
+ return None, model
+
+
+def route_prefixes(
+ models: list[str] | tuple[str, ...], routes: Mapping[str, Any]
+) -> tuple[str, ...]:
+ """모델 목록 가운데 외부 라우트를 타는 것들(`접두어:모델`) — egress 사전 거부에 쓴다."""
+ return tuple(m for m in models if split_route(m, routes)[0] is not None)
+
+
+class RoutedProvider(BaseProvider):
+ def __init__(self, default: BaseProvider, routes: Mapping[str, BaseProvider]) -> None:
+ self.default = default
+ self.routes = dict(routes)
+
+ def resolve(self, model: str) -> tuple[BaseProvider, str]:
+ prefix, name = split_route(model, self.routes)
+ return (self.routes[prefix] if prefix else self.default), name
+
+ async def chat_completion(
+ self,
+ messages: list[dict[str, str]],
+ model: str,
+ temperature: float = 0.0,
+ max_tokens: int = 8000,
+ reasoning_effort: str | None = None,
+ response_format: dict[str, Any] | None = None,
+ tools: list[dict[str, Any]] | None = None,
+ tool_choice: str | dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ provider, name = self.resolve(model)
+ return await provider.chat_completion(
+ messages=messages,
+ model=name,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ reasoning_effort=reasoning_effort,
+ response_format=response_format,
+ tools=tools,
+ tool_choice=tool_choice,
+ )
+
+ async def chat_completion_json(
+ self,
+ messages: list[dict[str, str]],
+ model: str,
+ temperature: float = 0.0,
+ max_tokens: int = 8000,
+ ) -> dict[str, Any]:
+ provider, name = self.resolve(model)
+ return await provider.chat_completion_json(messages, name, temperature, max_tokens)
diff --git a/solar_eval/providers/upstage.py b/solar_eval/providers/upstage.py
index 9bf2b75193f35fdd7534079d2b5a212bf44b64a7..336fb2f4046b5a3586782510f47fd5d3732a09e9 100644
--- a/solar_eval/providers/upstage.py
+++ b/solar_eval/providers/upstage.py
@@ -1,9 +1,7 @@
import asyncio
import json
import logging
-import random
import re
-import time
from typing import Any
import httpx
@@ -17,34 +15,6 @@ THINK_PATTERN = re.compile(r"(.*?)", re.DOTALL)
MAX_RETRIES = 3
RETRY_DELAYS = [5, 10, 20] # seconds
-# 429 is expected on low-quota models (e.g. solar-mini4: 100 RPM / 50k TPM),
-# so rate-limit waits don't consume the generic retry budget -- otherwise the
-# sample is dropped from results.jsonl and the score is computed on survivors.
-RATE_LIMIT_MAX_WAITS = 40
-RATE_LIMIT_MIN_DELAY = 2.0
-RATE_LIMIT_MAX_DELAY = 65.0
-RATE_LIMIT_RESET_HEADERS = (
- "X-Upstage-Ratelimit-Reset-Tokens",
- "X-Upstage-Ratelimit-Reset-Requests",
-)
-# Transient network failures (DNS/connect) can last minutes; wait them out
-# instead of failing the sample.
-CONNECT_MAX_WAITS = 40
-CONNECT_RETRY_DELAY = 15.0
-
-
-def _rate_limit_delay(response: httpx.Response) -> float:
- """Seconds to wait before retrying a 429, from Upstage reset headers (epoch secs)."""
- now = time.time()
- resets = []
- for header in RATE_LIMIT_RESET_HEADERS:
- try:
- resets.append(float(response.headers[header]) - now)
- except (KeyError, ValueError):
- continue
- base = max(resets) if resets else RETRY_DELAYS[0]
- jittered = base + random.uniform(0, 3)
- return min(max(jittered, RATE_LIMIT_MIN_DELAY), RATE_LIMIT_MAX_DELAY)
class UpstageProvider(BaseProvider):
@@ -53,21 +23,6 @@ class UpstageProvider(BaseProvider):
self.api_key = cfg.upstage_api_key
self.api_base = cfg.upstage_api_base
- @staticmethod
- async def _backoff_or_raise(error: Exception, attempt: int) -> int:
- """Sleep before the next generic retry, or raise once retries are exhausted."""
- delay = RETRY_DELAYS[attempt] if attempt < len(RETRY_DELAYS) else RETRY_DELAYS[-1]
- logger.warning(
- f"Upstage API attempt {attempt + 1}/{MAX_RETRIES} failed: {error}. "
- f"Retrying in {delay}s..."
- )
- if attempt == MAX_RETRIES - 1:
- raise RuntimeError(
- f"Upstage API failed after {MAX_RETRIES} retries: {error}"
- ) from error
- await asyncio.sleep(delay)
- return attempt + 1
-
async def chat_completion(
self,
messages: list[dict[str, str]],
@@ -94,10 +49,7 @@ class UpstageProvider(BaseProvider):
if tool_choice is not None:
payload["tool_choice"] = tool_choice
- attempt = 0
- rate_limit_waits = 0
- connect_waits = 0
- while True:
+ for attempt in range(MAX_RETRIES):
try:
async with httpx.AsyncClient(timeout=300.0) as client:
response = await client.post(
@@ -108,31 +60,17 @@ class UpstageProvider(BaseProvider):
response.raise_for_status()
data = response.json()
break
- except httpx.HTTPStatusError as e:
- if e.response.status_code == 429 and rate_limit_waits < RATE_LIMIT_MAX_WAITS:
- rate_limit_waits += 1
- delay = _rate_limit_delay(e.response)
- logger.debug(
- f"Upstage API rate limited (model={model}, "
- f"wait {rate_limit_waits}/{RATE_LIMIT_MAX_WAITS}). "
- f"Retrying in {delay:.1f}s..."
- )
- await asyncio.sleep(delay)
- continue
- attempt = await self._backoff_or_raise(e, attempt)
- except httpx.TimeoutException as e:
- attempt = await self._backoff_or_raise(e, attempt)
- except httpx.TransportError as e:
- if connect_waits >= CONNECT_MAX_WAITS:
- raise RuntimeError(
- f"Upstage API unreachable after {CONNECT_MAX_WAITS} waits: {e}"
- ) from e
- connect_waits += 1
+ except (httpx.HTTPStatusError, httpx.TimeoutException) as e:
+ delay = RETRY_DELAYS[attempt] if attempt < len(RETRY_DELAYS) else RETRY_DELAYS[-1]
logger.warning(
- f"Upstage API connection error (wait {connect_waits}/"
- f"{CONNECT_MAX_WAITS}): {e}. Retrying in {CONNECT_RETRY_DELAY}s..."
+ f"Upstage API attempt {attempt + 1}/{MAX_RETRIES} failed: {e}. "
+ f"Retrying in {delay}s..."
)
- await asyncio.sleep(CONNECT_RETRY_DELAY)
+ if attempt == MAX_RETRIES - 1:
+ raise RuntimeError(
+ f"Upstage API failed after {MAX_RETRIES} retries: {e}"
+ ) from e
+ await asyncio.sleep(delay)
choice = data["choices"][0]
message = choice["message"]
diff --git a/solar_eval/rescore/__init__.py b/solar_eval/rescore/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..55759047209a61bcf87578846fde2482a6189b9b
--- /dev/null
+++ b/solar_eval/rescore/__init__.py
@@ -0,0 +1,26 @@
+"""오프라인 재채점기 `proofread_diff@2` (계획 2026-09-26 L3b §3.2).
+
+모듈 구성(D1 — 순수 채점과 I/O 를 가른다):
+
+- `proofread_diff_v2` — 결과 행 하나의 순수 채점(표준 라이브러리 + `evaluators.scoring` 만). 평가기 `impl_hash` 폐포의 시작점.
+- `evaluator_doc` — `evaluator/v1` 문서·`implementation_hash`.
+- `drafts` — 판정 초안·`producer_aggregates.json`.
+- `from_bundle` — run 번들 하나 → 채점 번들(`rescore_run_bundle`).
+- `case` — 미러의 case 전체(`rescore_case`).
+- `paths` — 미러 영역·기본 출력 위치.
+
+이 패키지 `__init__` 은 순수 모듈의 이름만 다시 내보낸다 — `poc_bundle` 을 끌어오는 I/O 모듈은 쓰는 쪽이 직접 import 한다
+(CLI 는 명령 함수 안에서 import 한다, `cli/commands/score.py`).
+"""
+
+from solar_eval.rescore.proofread_diff_v2 import (
+ NORMALIZERS,
+ REASONS,
+ RowError,
+ RowScore,
+ label_of,
+ normalize,
+ score_row,
+)
+
+__all__ = ["NORMALIZERS", "REASONS", "RowError", "RowScore", "label_of", "normalize", "score_row"]
diff --git a/solar_eval/rescore/case.py b/solar_eval/rescore/case.py
new file mode 100644
index 0000000000000000000000000000000000000000..d7aa0fd5c5602d4060fc2b5d5cff1a6304946c2c
--- /dev/null
+++ b/solar_eval/rescore/case.py
@@ -0,0 +1,190 @@
+"""case 전체 재채점 — `solar-eval score proofread-diff --case a/c --all` (계획 2026-09-26 L3b §3.2·D6).
+
+미러의 `accounts///eval/bundles` 에서 run_id 별 최신 커밋 번들을 `poc_bundle.adopt` 로 고르고(최신이 invalid 면 이전으로
+넘어가지 않고 `latest_bundle_invalid` skip), 채택된 번들마다 `rescore_run_bundle` 을 부른다.
+
+- `bundles` 영역 디렉토리가 없으면 case 오류 `case_area_missing`(해석한 경로를 싣는다) — case 오타나 `--mirror-root` 층
+ 어긋남(§2.1 "미러 루트 층")이 "할 일 없음·종료 0" 으로 숨지 않게.
+- 영역은 있는데 커밋된 run 번들이 0 이면 case 오류 `case_area_empty`(해석한 경로) — 같은 이유다(계획 2026-09-27 L6 §2.11,
+ L3b 후속 3). run 필터가 있어도 이 판정이 먼저다.
+- 영역에 매니페스트를 못 읽는 번들이 하나라도 있으면(`AdoptError`) case 전체가 오류다 — 어느 run 의 번들인지 모르는 채로
+ 채택하지 않는다(fail closed). run 필터가 있어도 영역 전체를 읽는다 — 필터가 fail closed 를 약하게 만들지 않게.
+- **run 필터**(`run_ids`, L6 §2.11 — 엔진 종료 훅이 run 하나만 재채점한다): 채택 목록(run_id 별 커밋 번들)을 거른다. 채택
+ 규칙은 필터 없는 case 모드와 같다. 요청한 run 이 영역에 없으면 run 마다 오류 `run_not_in_mirror`. 값 규칙
+ (`is_valid_run_id` — 공백만이 아님·제어 문자 없음·512자 이하)은 CLI `--run` 과 이 함수 경계가 함께 쓴다.
+- run 하나의 오류(`RescoreError`)는 그 run 만 오류로 세고 다음 run 을 계속한다 — 종료 코드는 CLI 가 `errors` 로 정한다.
+- 평가기 문서는 case 한 번에 한 번 뜬다(모든 run 이 같은 `evaluator_config_hash` 를 쓴다).
+"""
+
+from __future__ import annotations
+
+import re
+from collections import Counter
+from collections.abc import Collection, Mapping, Sequence
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from poc_bundle.adopt import AdoptError, Committed, adopt_run_bundle, committed_run_bundles
+
+from solar_eval.rescore.evaluator_doc import current_evaluator_document
+from solar_eval.rescore.from_bundle import (
+ ERR_ADOPT,
+ SKIP_LATEST_INVALID,
+ RescoreError,
+ RescoreOutcome,
+ rescore_run_bundle,
+)
+from solar_eval.rescore.paths import BUNDLES_AREA, mirror_area_dir, resolve_mirror_root
+
+#: case 오류 — 미러 `bundles` 영역이 없다(case 오타·`--mirror-root` 층 어긋남). 아무것도 안 한 채 성공하지 않게.
+ERR_CASE_AREA_MISSING = "case_area_missing"
+#: case 오류 — `bundles` 영역은 있는데 커밋된 run 번들이 0 이다(L6 §2.11).
+ERR_CASE_AREA_EMPTY = "case_area_empty"
+#: run 오류 — run 필터로 요청한 run 의 커밋 번들이 미러 영역에 없다(L6 §2.11).
+ERR_RUN_NOT_IN_MIRROR = "run_not_in_mirror"
+#: run 필터 값 하나의 글자 수 상한(코드 포인트) — ES `hub ingest --expect-run` 과 같다(L6 §2.9).
+MAX_RUN_ID_LENGTH = 512
+# C0·DEL·C1 제어 문자 — ES `--expect-run` 과 같은 범위. stderr `error …` 줄을 위조하지 못하게 한다
+_CONTROL_CHARACTER = re.compile(r"[\x00-\x1f\x7f-\x9f]")
+
+
+def is_valid_run_id(value: object) -> bool:
+ """run 필터 값 하나 — 공백만이 아닌 문자열, 제어 문자 없음, `MAX_RUN_ID_LENGTH` 자 이하(CLI `--run` 과 같은 규칙)."""
+ return (
+ isinstance(value, str)
+ and bool(value.strip())
+ and len(value) <= MAX_RUN_ID_LENGTH
+ and _CONTROL_CHARACTER.search(value) is None
+ )
+
+
+@dataclass(frozen=True)
+class RunFailure:
+ """run 하나(또는 case 전체, `run_id=None`)의 오류 — id·코드와, 있으면 경로만(메시지 원문은 싣지 않는다)."""
+
+ run_id: str | None
+ code: str
+ detail: str | None = None
+
+
+@dataclass
+class CaseSummary:
+ """case 재채점 결과 — run 결과 목록과 오류."""
+
+ outcomes: list[RescoreOutcome] = field(default_factory=list)
+ failures: list[RunFailure] = field(default_factory=list)
+
+ def to_json(self) -> dict[str, Any]:
+ """§2.1 요약 줄 `{written, exists, skipped:{code:n}, errors, n_original_missing}`."""
+ statuses = Counter(o.status for o in self.outcomes)
+ skipped = Counter(o.code for o in self.outcomes if o.status == "skip")
+ return {
+ "written": statuses.get("written", 0),
+ "exists": statuses.get("exists", 0),
+ "skipped": dict(sorted(skipped.items())),
+ "errors": len(self.failures),
+ "n_original_missing": sum(o.n_original_missing for o in self.outcomes),
+ }
+
+
+def rescore_case(
+ account: str,
+ case: str,
+ *,
+ mirror_root: Path | None = None,
+ out_dir: Path | None = None,
+ now: datetime | None = None,
+ repo_root: Path | None = None,
+ run_ids: Collection[str] | None = None,
+) -> CaseSummary:
+ """미러의 한 case 를 재채점한다. `out_dir` 가 None 이면 미러 `evaluations` 에 놓는다(`mirror_bundle`).
+
+ `mirror_root` 는 미러 버킷의 부모(기본 데이터 홈 `blob-mirror`) — 입력 run 번들도 여기서 읽는다.
+ `run_ids` 를 주면 그 run 들만 재채점한다(없는 run 은 `run_not_in_mirror`). 문자열 하나(`TypeError`)나 빈 모음·
+ `is_valid_run_id` 를 못 넘는 원소(`ValueError`)는 호출자 버그라 즉시 거부한다 — 글자 단위 필터나 "할 일 없음·성공",
+ stderr 줄 위조가 되지 않게.
+ """
+ wanted = _checked_run_ids(run_ids)
+ summary = CaseSummary()
+ by_run = _committed_by_run(mirror_root, account, case, summary)
+ if by_run is None:
+ return summary
+ selected = sorted(by_run) if wanted is None else sorted(wanted & by_run.keys())
+ if wanted is not None:
+ missing = sorted(wanted - by_run.keys())
+ summary.failures.extend(RunFailure(r, ERR_RUN_NOT_IN_MIRROR) for r in missing)
+ evaluator = current_evaluator_document()
+ root = resolve_mirror_root(mirror_root)
+ for run_id in selected:
+ _rescore_one(run_id, by_run[run_id], summary, out_dir, root, now, evaluator, repo_root)
+ return summary
+
+
+def _checked_run_ids(run_ids: Collection[str] | None) -> frozenset[str] | None:
+ """run 필터 경계 검사 — None(필터 없음) 또는 `is_valid_run_id` 를 넘는 값의 비어 있지 않은 모음."""
+ if run_ids is None:
+ return None
+ if isinstance(run_ids, str | bytes):
+ raise TypeError("run_ids must be a collection of run ids, not a single string")
+ wanted = frozenset(run_ids)
+ if not wanted:
+ raise ValueError("run_ids must not be empty (pass None for every run)")
+ if not all(is_valid_run_id(r) for r in wanted):
+ raise ValueError(
+ "run_ids must be non-blank strings without control characters,"
+ f" at most {MAX_RUN_ID_LENGTH} characters"
+ )
+ return wanted
+
+
+def _committed_by_run(
+ mirror_root: Path | None, account: str, case: str, summary: CaseSummary
+) -> Mapping[str, Sequence[Committed]] | None:
+ """영역의 커밋 run 번들(run_id 별). case 오류면 `summary` 에 싣고 None."""
+ bundles_dir = mirror_area_dir(mirror_root, account, case, BUNDLES_AREA)
+ if not bundles_dir.is_dir():
+ # `committed_run_bundles` 는 없는 영역을 빈 dict 로 본다 — 그대로 두면 요약이 전부 0 인 채 종료 0 이다
+ summary.failures.append(RunFailure(None, ERR_CASE_AREA_MISSING, str(bundles_dir)))
+ return None
+ try:
+ by_run = committed_run_bundles(bundles_dir)
+ except AdoptError:
+ summary.failures.append(RunFailure(None, ERR_ADOPT))
+ return None
+ if not by_run:
+ # 있는데 비었다 — 백필 전이거나 영역 경로가 어긋났다. 할 일 없음·종료 0 으로 숨기지 않는다
+ summary.failures.append(RunFailure(None, ERR_CASE_AREA_EMPTY, str(bundles_dir)))
+ return None
+ return by_run
+
+
+def _rescore_one(
+ run_id: str,
+ cands: Sequence[Committed],
+ summary: CaseSummary,
+ out_dir: Path | None,
+ root: Path,
+ now: datetime | None,
+ evaluator: Mapping[str, Any],
+ repo_root: Path | None,
+) -> None:
+ """run 하나 — 최신 번들 채택(invalid 면 skip) 뒤 재채점. 결과·오류를 `summary` 에 싣는다."""
+ adoption = adopt_run_bundle(cands)
+ if adoption.status != "ok":
+ summary.outcomes.append(RescoreOutcome("skip", run_id, code=SKIP_LATEST_INVALID))
+ return
+ try:
+ outcome = rescore_run_bundle(
+ adoption.committed.path,
+ out_dir,
+ mirror_root=None if out_dir is not None else root,
+ now=now,
+ evaluator=evaluator,
+ repo_root=repo_root,
+ )
+ except RescoreError as exc:
+ summary.failures.append(RunFailure(exc.run_id or run_id, exc.code))
+ return
+ summary.outcomes.append(outcome)
diff --git a/solar_eval/rescore/drafts.py b/solar_eval/rescore/drafts.py
new file mode 100644
index 0000000000000000000000000000000000000000..b5ba6e7781bfb916a47df402b1b98cf7bc280244
--- /dev/null
+++ b/solar_eval/rescore/drafts.py
@@ -0,0 +1,108 @@
+"""판정 초안·생산자 집계 조립 (계획 2026-09-26 L3b §2.4·D2·D4).
+
+- 결과 행마다 판정 3개, 순서 = `exact` → `lcs_f1` → `spans`. 실패 행은 세 지표 모두 `status="error"`·같은 `reason` 이다.
+- `producer_aggregates.json`: `exact`·`lcs_f1` 의 `mean` 은 파생 참조 구현 `derive("mean")`, `spans` 의 `micro_prf@1:{f1,precision,
+ recall}` 은 **레거시 구현** `LCSDiffEvaluator().aggregate()` 에 ok 행 카운트를 넣은 값이다(D4) — hub 적재의
+ `producer_aggregate_mismatch` 대조가 "SQL 파생 ↔ 레거시 코드" 대조가 된다. ok 행이 0 이면 `spans` 항목을 싣지 않는다
+ (레거시의 0.0 은 "측정 없음" 과 구별되지 않는다).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from typing import Any
+
+from poc_bundle.scoring.derivations import derive
+from poc_bundle.scoring.writer import JudgmentDraft
+
+from solar_eval.evaluators.lcs_diff import LCSDiffEvaluator
+from solar_eval.rescore.evaluator_doc import ACTOR
+from solar_eval.rescore.proofread_diff_v2 import LABEL_CORRECT, RowError, RowScore
+
+EXACT, LCS_F1, SPANS = "exact", "lcs_f1", "spans"
+#: 판정 파일 안 지표 순서.
+CRITERIA: tuple[str, ...] = (EXACT, LCS_F1, SPANS)
+PRODUCER_AGGREGATES_SCHEMA = "producer-aggregates/v1"
+#: 레거시 `aggregate()["scores"]` 키 → `micro_prf@1` 출력 이름.
+SPAN_OUTPUTS: tuple[str, ...] = ("f1", "precision", "recall")
+
+
+@dataclass(frozen=True)
+class ScoredRow:
+ """채점한 결과 행 하나 — `sample_idx`, run 번들 `items.jsonl` 의 키 쌍, 채점 결과."""
+
+ sample_idx: int
+ input_key: str
+ golden_key: str
+ outcome: RowScore | RowError
+
+
+def judgment_drafts(run_id: str, rows: Sequence[ScoredRow], created_at: str) -> list[JudgmentDraft]:
+ """행마다 `exact`·`lcs_f1`·`spans` 초안 3개(행 순서 그대로)."""
+ drafts: list[JudgmentDraft] = []
+ for row in rows:
+ common = {
+ "level": "item",
+ "run_id": run_id,
+ "sample_idx": row.sample_idx,
+ "input_key": row.input_key,
+ "golden_key": row.golden_key,
+ "source_kind": "code",
+ "actor": ACTOR,
+ "created_at": created_at,
+ }
+ for criterion in CRITERIA:
+ fields = {**common, "criterion_id": criterion, **_result_fields(criterion, row.outcome)}
+ drafts.append(JudgmentDraft(fields))
+ return drafts
+
+
+def _result_fields(criterion: str, outcome: RowScore | RowError) -> dict[str, Any]:
+ if isinstance(outcome, RowError):
+ return {"status": "error", "reason": outcome.reason}
+ if criterion == EXACT:
+ return {
+ "score": outcome.score_exact,
+ "label": outcome.label,
+ "passed": outcome.label == LABEL_CORRECT,
+ "status": "ok",
+ }
+ if criterion == LCS_F1:
+ return {"score": outcome.lcs_f1, "status": "ok"}
+ return {"value": dict(outcome.spans), "status": "ok"}
+
+
+def producer_aggregates(
+ rows: Sequence[ScoredRow], drafts: Sequence[JudgmentDraft]
+) -> dict[str, Any]:
+ """`producer-aggregates/v1` 문서 — 값은 (metric_key, aggregation) 사전순."""
+ values = [_mean_value(EXACT, drafts), _mean_value(LCS_F1, drafts)]
+ ok = [row.outcome for row in rows if isinstance(row.outcome, RowScore)]
+ if ok:
+ scores = legacy_span_scores(ok)
+ for output in SPAN_OUTPUTS:
+ values.append(_value(SPANS, f"micro_prf@1:{output}", scores[output]))
+ values.sort(key=lambda v: (v["metric_key"], v["aggregation"]))
+ return {"schema": PRODUCER_AGGREGATES_SCHEMA, "values": values}
+
+
+def legacy_span_scores(ok: Sequence[RowScore]) -> Mapping[str, Any]:
+ """레거시 `LCSDiffEvaluator.aggregate` 가 ok 행 카운트로 내는 run 층 `scores`(퍼센트)."""
+ aggregated = LCSDiffEvaluator().aggregate([{"details": dict(row.spans)} for row in ok])
+ return aggregated["scores"]
+
+
+def _mean_value(criterion: str, drafts: Sequence[JudgmentDraft]) -> dict[str, Any]:
+ rows = [d.fields for d in drafts if d.fields.get("criterion_id") == criterion]
+ return _value(criterion, "mean", derive("mean", rows)["value"])
+
+
+def _value(metric_key: str, aggregation: str, value: Any) -> dict[str, Any]:
+ return {
+ "metric_key": metric_key,
+ "aggregation": aggregation,
+ "variant": "raw",
+ "error_policy": "exclude",
+ "value": value,
+ }
diff --git a/solar_eval/rescore/evaluator_doc.py b/solar_eval/rescore/evaluator_doc.py
new file mode 100644
index 0000000000000000000000000000000000000000..c6545dfdc35fa1a95b0efdc00e4b34beb15552c9
--- /dev/null
+++ b/solar_eval/rescore/evaluator_doc.py
@@ -0,0 +1,110 @@
+"""`proofread_diff@2` 평가기 문서와 구현 해시 (계획 2026-09-26 L3b §2.4·MB1).
+
+- `impl_hash` = `json_fingerprint(["proofread-diff-impl/v1", closure_hash])` — `closure_hash` 는 `proofread_diff_v2` 에서 시작한
+ AST import 폐포(`core/snapshot/impl_hash.py`, 스텝·평가기 해시와 같은 규칙)의 파일 바이트 지문이다. `dependency_lock` 은
+ 섞지 않는다: 폐포가 표준 라이브러리·`solar_eval` 밖을 import 하지 않음을 테스트가 고정하고(`test_impl_hash.py`), 서드파티가
+ 들어오는 날 섞는다(열린 질문 Q-L3b-1).
+- **폐포는 넓히지 않는다**(계획 2026-09-27 L6 C-L6-7·Q-L6-8, L3b 후속 6). ① `drafts.py` 는 `poc_bundle` 을 import 해 넣으면
+ 서드파티가 폐포에 들어와 `dependency_lock` 이 필요하다(U-L3b-4 로 넣지 않기로 했다). `evaluators/lcs_diff.py` 는 폐포 밖이지만
+ 재채점 경로 **안**이다 — `drafts.legacy_span_scores` 가 `LCSDiffEvaluator.aggregate` 로 `producer_aggregates.json` 의 `spans`
+ 집계를 낸다. ② 폐포를 바꾸면 `evaluator_config_hash` 가 바뀌어 기존 재채점이 새 평가기로 갈라진다. ③ 두 파일의 변화는
+ `from_bundle._same_as` 가 판정 행과 `producer_aggregates.json` 바이트를 함께 비교해 같은 `evaluation_run_id` 안의
+ `supersedes` 로 잡는다 — `test_impl_hash.py` 가 셋을 고정한다(판정 행만 바뀐 경우·집계만 바뀐 경우 각각).
+- `metrics` 는 벡터 `scoring/bundles/02-proofread-diff-v2-shape` 와 **같은 배열**이다(테스트가 벡터와 대조한다). 문서가 벡터와
+ 다른 점은 `impl_hash`·`params.original_field`·`provenance` 셋이다.
+"""
+
+from __future__ import annotations
+
+import copy
+from typing import Any
+
+from poc_bundle.canonical import json_fingerprint
+
+from solar_eval._version import __version__
+from solar_eval.core.snapshot.impl_hash import ImplRoots, closure_hash, default_roots
+from solar_eval.rescore.proofread_diff_v2 import (
+ LABEL_CORRECT,
+ LABEL_SCORES,
+ LABELS,
+ NORMALIZERS,
+ ORIGINAL_FIELD,
+)
+
+#: 폐포 시작 모듈 — 평가기 문서 `impl.name` 과 같다.
+IMPL_START = "solar_eval.rescore.proofread_diff_v2"
+#: `impl_hash` 튜플 머리의 도메인 태그. 튜플을 바꾸면 새 태그다.
+IMPL_TAG = "proofread-diff-impl/v1"
+IMPL_VERSION = "2"
+EVALUATOR_KIND = "proofread_diff"
+#: 사람이 부르는 평가기 이름 — 판정 `actor` 는 `solar_eval.`.
+ALIAS = "proofread_diff@2"
+ACTOR = f"solar_eval.{ALIAS}"
+#: `params.noanswer` — 정규화한 출력이 빈 문자열이면 `noanswer`(D3).
+NOANSWER_RULE = "empty_output"
+
+#: 지표 선언 — 벡터 02 와 같은 배열(키 순서까지). `exact` 는 categorical, `lcs_f1` 은 continuous, `spans` 는 counts.
+METRICS: list[dict[str, Any]] = [
+ {
+ "key": "exact",
+ "level": "item",
+ "score_type": "categorical",
+ "unit": "ratio",
+ "levels": list(LABELS),
+ "label_scores": dict(LABEL_SCORES),
+ "passing_labels": [LABEL_CORRECT],
+ "aggregations": ["mean"],
+ "primary": True,
+ "primary_aggregation": "mean",
+ },
+ {
+ "key": "lcs_f1",
+ "level": "item",
+ "score_type": "continuous",
+ "unit": "percent",
+ "min": 0,
+ "max": 100,
+ "aggregations": ["mean"],
+ },
+ {
+ "key": "spans",
+ "level": "item",
+ "score_type": "counts",
+ "unit": "percent",
+ "count_roles": {"tp": "tp", "fp": "fd", "missing": "fn", "redundant": "fa"},
+ "empty_policy": "one",
+ "scale": 100,
+ "aggregations": ["micro_prf@1:f1", "micro_prf@1:precision", "micro_prf@1:recall"],
+ },
+]
+
+
+def implementation_hash(roots: ImplRoots | None = None) -> tuple[str, list[str]]:
+ """(`impl_hash`, 정렬된 폐포 라벨). `roots` 를 주면 그 해석 루트로 뜬다(테스트가 tmp 사본을 끼운다)."""
+ closure, labels = closure_hash([IMPL_START], roots or default_roots())
+ return json_fingerprint([IMPL_TAG, closure]), labels
+
+
+def evaluator_document(impl_hash: str) -> dict[str, Any]:
+ """`evaluator/v1` 문서(§2.4). 해시는 `config` 에만 뜨므로 `provenance` 의 버전 표기는 id 를 바꾸지 않는다."""
+ return {
+ "schema": "evaluator/v1",
+ "config": {
+ "kind": EVALUATOR_KIND,
+ "source_kind": "code",
+ "impl": {"name": IMPL_START, "version": IMPL_VERSION, "impl_hash": impl_hash},
+ "params": {
+ "exact_compare": {"normalizers": list(NORMALIZERS)},
+ "noanswer": NOANSWER_RULE,
+ "original_field": ORIGINAL_FIELD,
+ },
+ "metrics": copy.deepcopy(METRICS),
+ },
+ "provenance": {"alias": ALIAS, "version_label": f"solar-eval {__version__}"},
+ }
+
+
+def current_evaluator_document() -> dict[str, Any]:
+ """지금 디스크의 구현으로 뜬 평가기 문서 — 재채점 한 번(case 전체)에 한 번 부른다."""
+ impl_hash, _ = implementation_hash()
+ return evaluator_document(impl_hash)
diff --git a/solar_eval/rescore/from_bundle.py b/solar_eval/rescore/from_bundle.py
new file mode 100644
index 0000000000000000000000000000000000000000..6868ede6f60bc4fc856037d3a67a7246aaa2dbae
--- /dev/null
+++ b/solar_eval/rescore/from_bundle.py
@@ -0,0 +1,428 @@
+"""run 번들 하나 → `proofread_diff@2` 채점 번들 (계획 2026-09-26 L3b §2.4·D5).
+
+순서: subject verify → 생산자 판별(`is_legacy_results_producer`) → `results.jsonl` 레코드 줄 순서대로 읽기 → 행마다 채점(순수
+모듈) → 판정 초안·생산자 집계 → 같은 `evaluation_run_id` 의 기존 최신과 비교 → 새 번들 쓰기(`write_scoring_bundle`, subject
+를 넘겨 E1·E2 까지 커밋 전에 본다) → (`mirror_root` 가 있으면) `mirror_bundle(area="evaluations")`.
+
+- `evaluation_run_id` = `code_rescore_run_id(subject bundle_id, evaluator_config_hash)` — subject 번들이 바뀌면 새 id 다.
+- **멱등(D5).** 기존 최신(같은 id)이 subject 와 함께 `verify_scoring_bundle` 을 통과하고(E4 로 `evaluator_config.json` 파일의
+ config 해시 = 매니페스트 값까지 본다), 평가기 해시가 같고, `producer_aggregates.json` 바이트가 같고, `judgments.jsonl` 행에서
+ `created_at`(재채점 시각)을 뺀 목록이 새 목록과 같으면 새 번들을 쓰지 않고 기존 경로를 `exists` 로 돌려준다. 하나라도
+ 다르면(기존 최신이 깨졌어도) `supersedes` = 기존 최신으로 새 ULID 번들을 쓴다 — 새 ULID ≤ 기존이면 `clock_skew`(eval-wrap·
+ wrap 과 같은 규칙). 비교가 id 순서 검사보다 먼저다. 생산자 집계는 폐포(`impl_hash`) 밖의 레거시 집계 코드가 내므로 판정만
+ 보면 그 코드가 바뀐 것을 놓친다.
+- **subject 경계.** `manifest.json` 이 없는 디렉토리는 `not_a_run_bundle` 오류(번들이 아니다 — 경로 실수), verify 실패는
+ `latest_bundle_invalid` skip(`items.jsonl` 이 깨진 경우 포함 — case 모드의 채택과 같은 분류).
+- 출력·오류 메시지에는 id·코드·경로만 싣는다. 입력·출력·정답 원문은 어디에도 쓰지 않는다(census D-04 와 같은 규칙).
+"""
+
+from __future__ import annotations
+
+import re
+import shutil
+import tempfile
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any, Literal
+
+from poc_bundle.adopt import AdoptError, Committed, committed_scoring_bundles, latest
+from poc_bundle.bundle import verify_bundle
+from poc_bundle.canonical import load_json_strict
+from poc_bundle.jsonl import iter_record_lines
+from poc_bundle.legacy_run import is_legacy_results_producer
+from poc_bundle.mirror import MirrorError, mirror_bundle
+from poc_bundle.scoring.ids import code_rescore_run_id, evaluator_config_hash
+from poc_bundle.scoring.verify import RunBundleRef, load_run_bundle_ref, verify_scoring_bundle
+from poc_bundle.scoring.writer import JudgmentDraft, ScoringWriteError, write_scoring_bundle
+from ulid import ULID
+
+from solar_eval._version import __version__
+from solar_eval.rescore.drafts import ScoredRow, judgment_drafts, producer_aggregates
+from solar_eval.rescore.evaluator_doc import current_evaluator_document
+from solar_eval.rescore.paths import EVALUATIONS_AREA, RescorePathError, mirror_area_dir
+from solar_eval.rescore.proofread_diff_v2 import has_original, score_row
+
+PRODUCER_NAME = "solar-eval score"
+PURPOSE = "rescore_after_fix"
+RESULTS_FILE = "results.jsonl"
+JUDGMENTS_FILE = "judgments.jsonl"
+AGGREGATES_FILE = "producer_aggregates.json"
+#: 멱등 비교용 임시 번들 id — 가장 큰 ULID 라 어떤 `supersedes` 보다도 뒤에 온다. 출력 디렉토리에는 쓰지 않는다.
+STAGING_BUNDLE_ID = "7ZZZZZZZZZZZZZZZZZZZZZZZZZ"
+
+# run 단위 skip 코드(종료 0 에 포함)와 오류 코드(종료 1) — 계획 §2.2.
+SKIP_LATEST_INVALID = "latest_bundle_invalid"
+SKIP_UNKNOWN_FORMAT = "unknown_results_format"
+SKIP_NO_RESULTS = "no_results"
+ERR_ADOPT = "adopt_unreadable_manifest"
+ERR_CLOCK_SKEW = "clock_skew"
+ERR_MIRROR = "mirror_failed"
+ERR_RESULTS = "results_unreadable"
+ERR_OUT_INSIDE = "out_inside_bundle"
+ERR_NOT_A_BUNDLE = "not_a_run_bundle"
+WRITE_FAILED_PREFIX = "write_failed:"
+_RULE_ID = re.compile(r"^\[([A-Z]+[0-9]*)\]")
+
+Status = Literal["written", "exists", "skip"]
+
+
+class RescoreError(RuntimeError):
+ """이 run 을 재채점할 수 없다(종료 1). `code` 는 §2.2 오류 어휘, `run_id` 는 알면 싣는다."""
+
+ def __init__(self, code: str, message: str, run_id: str | None = None) -> None:
+ super().__init__(f"{code}: {message}")
+ self.code = code
+ self.run_id = run_id
+
+
+@dataclass(frozen=True)
+class RescoreOutcome:
+ """run 하나의 결과 — `written`·`exists` 는 번들 경로, `skip` 은 코드."""
+
+ status: Status
+ run_id: str | None
+ path: Path | None = None
+ code: str | None = None
+ n_original_missing: int = 0
+
+
+class _Skip(Exception):
+ def __init__(self, code: str) -> None:
+ super().__init__(code)
+ self.code = code
+
+
+@dataclass(frozen=True)
+class _Subject:
+ bundle_dir: Path
+ run_id: str
+ bundle_id: str
+ case: dict[str, Any]
+ ref: RunBundleRef
+
+
+def rescore_run_bundle(
+ run_bundle_dir: Path,
+ out_dir: Path | None = None,
+ *,
+ mirror_root: Path | None = None,
+ now: datetime | None = None,
+ bundle_id: str | None = None,
+ evaluator: Mapping[str, Any] | None = None,
+ repo_root: Path | None = None,
+) -> RescoreOutcome:
+ """run 번들 하나를 재채점한다. `out_dir`(채점 번들들의 부모) 와 `mirror_root` 중 **정확히 하나**를 준다.
+
+ Args:
+ mirror_root: 미러 버킷의 부모 — 주면 출력 영역은 subject `case` 의 미러 `evaluations` 이고 `mirror_bundle` 로 놓는다.
+ now · bundle_id: 재현 가능한 테스트 전용. 비우면 지금 시각·새 ULID.
+ evaluator: 평가기 문서(case 전체에 한 번 뜬 것). 비우면 지금 디스크의 구현으로 뜬다.
+ repo_root: `mirror_bundle` 의 레포 밖 검사 기준(기본: 레포를 찾아 본다).
+
+ Raises:
+ RescoreError: §2.2 오류(채택 영역 매니페스트·시계 역행·쓰기·미러·results 읽기).
+ """
+ if (out_dir is None) == (mirror_root is None):
+ raise ValueError("give exactly one of out_dir / mirror_root")
+ try:
+ subject = _load_subject(Path(run_bundle_dir))
+ rows, n_missing = _scored_rows(subject)
+ except _Skip as skip:
+ run_id = _run_id_or_none(Path(run_bundle_dir))
+ return RescoreOutcome("skip", run_id, code=skip.code)
+ doc = dict(evaluator) if evaluator is not None else current_evaluator_document()
+ at = _utc_seconds(now)
+ drafts = judgment_drafts(subject.run_id, rows, at)
+ target = out_dir if out_dir is not None else _mirror_evaluations(mirror_root, subject)
+ _check_out_dir(Path(target), subject)
+ plan = _Plan(subject, doc, drafts, producer_aggregates(rows, drafts), at)
+ status, path = _commit(plan, Path(target), bundle_id, mirror_root, repo_root)
+ return RescoreOutcome(status, subject.run_id, path=path, n_original_missing=n_missing)
+
+
+# ── 읽기 ─────────────────────────────────────────────────────────────────────
+
+
+def _load_subject(bundle_dir: Path) -> _Subject:
+ ref = _subject_ref(bundle_dir)
+ report = ref.verify_report
+ if not report.is_bundle:
+ raise RescoreError(ERR_NOT_A_BUNDLE, f"no manifest.json in {bundle_dir}")
+ if not report.ok or report.manifest is None:
+ raise _Skip(SKIP_LATEST_INVALID)
+ manifest = report.manifest
+ if not is_legacy_results_producer(manifest.producer.name):
+ raise _Skip(SKIP_UNKNOWN_FORMAT)
+ return _Subject(
+ bundle_dir=bundle_dir,
+ run_id=manifest.run_id,
+ bundle_id=manifest.bundle_id,
+ case=manifest.case.model_dump(mode="json"),
+ ref=ref,
+ )
+
+
+def _subject_ref(bundle_dir: Path) -> RunBundleRef:
+ """`RunBundleRef` — `items.jsonl` 을 못 읽으면 verify 결과로 가른다: 실패면 skip, 통과인데 못 읽으면 오류.
+
+ `load_run_bundle_ref` 는 verify 결과와 무관하게 items 파싱 오류를 던지므로, 그대로 두면 깨진 번들이 case 모드(채택이
+ verify 를 먼저 본다)와 달리 `results_unreadable` 로 분류된다.
+ """
+ try:
+ return load_run_bundle_ref(bundle_dir)
+ except ValueError as exc:
+ if not verify_bundle(bundle_dir).ok:
+ raise _Skip(SKIP_LATEST_INVALID) from exc
+ raise RescoreError(ERR_RESULTS, f"items.jsonl unreadable in {bundle_dir.name}") from exc
+
+
+def _scored_rows(subject: _Subject) -> tuple[list[ScoredRow], int]:
+ """results 레코드 줄 순서대로 채점한 행과 `original` 이 없는 행 수. 레코드가 0이면 `no_results`."""
+ rows: list[ScoredRow] = []
+ missing = 0
+ path = subject.bundle_dir / RESULTS_FILE
+ for line_no, raw in iter_record_lines(path):
+ result = _parse_result(raw, line_no, subject)
+ sample_idx = result["sample_idx"]
+ input_key, golden_key = subject.ref.items[sample_idx]
+ rows.append(ScoredRow(sample_idx, input_key, golden_key, score_row(result)))
+ missing += 0 if has_original(result) else 1
+ if not rows:
+ raise _Skip(SKIP_NO_RESULTS)
+ return rows, missing
+
+
+def _parse_result(raw: bytes, line_no: int, subject: _Subject) -> Mapping[str, Any]:
+ """레코드 한 줄 → 객체. 메시지에는 줄 번호만 싣는다(행 내용 없음)."""
+ where = f"{RESULTS_FILE}:{line_no} of run bundle {subject.bundle_id}"
+ try:
+ row = load_json_strict(raw, safe_integers=False)
+ except (ValueError, RecursionError) as exc:
+ raise RescoreError(ERR_RESULTS, f"{where} is not strict JSON", subject.run_id) from exc
+ sample_idx = row.get("sample_idx") if isinstance(row, dict) else None
+ if isinstance(sample_idx, bool) or not isinstance(sample_idx, int):
+ raise RescoreError(ERR_RESULTS, f"{where} has no integer sample_idx", subject.run_id)
+ if sample_idx not in subject.ref.items:
+ raise RescoreError(ERR_RESULTS, f"{where} sample_idx is not in items.jsonl", subject.run_id)
+ return row
+
+
+def _run_id_or_none(bundle_dir: Path) -> str | None:
+ """skip 줄에 실을 run_id — 매니페스트를 읽을 수 있을 때만(검증 실패여도)."""
+ try:
+ data = load_json_strict((bundle_dir / "manifest.json").read_bytes())
+ except (OSError, ValueError, RecursionError):
+ return None
+ run_id = data.get("run_id") if isinstance(data, dict) else None
+ return run_id if isinstance(run_id, str) else None
+
+
+def _mirror_evaluations(mirror_root: Path | None, subject: _Subject) -> Path:
+ try:
+ return mirror_area_dir(
+ mirror_root, subject.case["account"], subject.case["case"], EVALUATIONS_AREA
+ )
+ except RescorePathError as exc:
+ raise RescoreError(ERR_MIRROR, str(exc), subject.run_id) from exc
+
+
+def _check_out_dir(out: Path, subject: _Subject) -> None:
+ target, run_abs = out.resolve(), subject.bundle_dir.resolve()
+ if target == run_abs or run_abs in target.parents:
+ raise RescoreError(
+ ERR_OUT_INSIDE, f"output {target} is inside the run bundle", subject.run_id
+ )
+
+
+def _utc_seconds(now: datetime | None) -> str:
+ moment = (now or datetime.now(UTC)).astimezone(UTC)
+ return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+# ── 멱등·커밋 ─────────────────────────────────────────────────────────────────
+
+
+@dataclass(frozen=True)
+class _Plan:
+ subject: _Subject
+ evaluator: Mapping[str, Any]
+ drafts: Sequence[JudgmentDraft]
+ aggregates: Mapping[str, Any]
+ created_at: str
+
+ @property
+ def config_hash(self) -> str:
+ return evaluator_config_hash(self.evaluator)
+
+ @property
+ def evaluation_run_id(self) -> str:
+ return code_rescore_run_id(self.subject.bundle_id, self.config_hash)
+
+
+def _commit(
+ plan: _Plan,
+ out: Path,
+ bundle_id: str | None,
+ mirror_root: Path | None,
+ repo_root: Path | None,
+) -> tuple[Status, Path]:
+ previous = _latest_committed(out, plan)
+ if previous is not None and _same_as(previous, plan):
+ return "exists", previous.path
+ new_id = bundle_id or str(ULID())
+ if previous is not None and previous.bundle_id >= new_id:
+ raise RescoreError(
+ ERR_CLOCK_SKEW,
+ f"new bundle_id {new_id} is not newer than committed {previous.bundle_id}",
+ plan.subject.run_id,
+ )
+ supersedes = None if previous is None else previous.bundle_id
+ fields = _manifest_fields(plan, new_id, supersedes)
+ if mirror_root is None:
+ dest = out / new_id
+ _write(dest, fields, plan)
+ return "written", dest
+ return "written", _write_and_mirror(fields, plan, new_id, Path(mirror_root), repo_root)
+
+
+def _latest_committed(out: Path, plan: _Plan) -> Committed | None:
+ try:
+ committed = committed_scoring_bundles(out)
+ except AdoptError as exc:
+ raise RescoreError(ERR_ADOPT, str(exc), plan.subject.run_id) from exc
+ cands = committed.get(plan.evaluation_run_id, [])
+ return latest(cands) if cands else None
+
+
+def _same_as(previous: Committed, plan: _Plan) -> bool:
+ """D5 — 기존 최신이 온전하고 평가기 해시·생산자 집계·`created_at` 을 뺀 판정 행이 같은가.
+
+ 자리표시 id 로 써 본 번들과 비교한다. 기존 최신이 verify 에 실패하면 같지 않다 — `exists` 로 넘기면 깨진 번들이 영영
+ 최신으로 남는다(hub 는 최신이 invalid 면 이전으로 넘어가지 않는다).
+ """
+ evaluator_ref = previous.manifest.get("evaluator")
+ if (
+ not isinstance(evaluator_ref, Mapping)
+ or evaluator_ref.get("config_hash") != plan.config_hash
+ or not _previous_verifies(previous, plan)
+ ):
+ return False
+ fields = _manifest_fields(plan, STAGING_BUNDLE_ID, previous.bundle_id)
+ with tempfile.TemporaryDirectory(prefix="solar-eval-score-") as tmp:
+ staged = Path(tmp) / STAGING_BUNDLE_ID
+ _write(staged, fields, plan)
+ new_rows = _rows_without_time(staged / JUDGMENTS_FILE)
+ new_aggregates = _bytes_or_none(staged / AGGREGATES_FILE)
+ old_rows = _rows_without_time(previous.path / JUDGMENTS_FILE)
+ old_aggregates = _bytes_or_none(previous.path / AGGREGATES_FILE)
+ return (
+ old_rows is not None
+ and old_rows == new_rows
+ and old_aggregates is not None
+ and old_aggregates == new_aggregates
+ )
+
+
+def _previous_verifies(previous: Committed, plan: _Plan) -> bool:
+ """기존 최신을 subject 와 함께 검증한다 — SB1(파일 sha)·E4(`evaluator_config.json` config 해시)·E1·E2 까지."""
+ subject = plan.subject
+ report = verify_scoring_bundle(previous.path, run_bundles={subject.bundle_id: subject.ref})
+ return report.ok
+
+
+def _bytes_or_none(path: Path) -> bytes | None:
+ """파일 바이트. 못 읽으면 None(비교가 거짓 → 새 번들)."""
+ try:
+ return path.read_bytes()
+ except OSError:
+ return None
+
+
+def _rows_without_time(path: Path) -> list[dict[str, Any]] | None:
+ """판정 행에서 `created_at` 을 뺀 목록. 못 읽으면 None(비교가 거짓 → 새 번들)."""
+ try:
+ rows = [load_json_strict(raw) for _, raw in iter_record_lines(path)]
+ except (OSError, ValueError, RecursionError):
+ return None
+ if not all(isinstance(row, dict) for row in rows):
+ return None
+ return [{k: v for k, v in row.items() if k != "created_at"} for row in rows]
+
+
+def _manifest_fields(plan: _Plan, bundle_id: str, supersedes: str | None) -> dict[str, Any]:
+ subject = plan.subject
+ return {
+ "bundle_id": bundle_id,
+ "evaluation_run_id": plan.evaluation_run_id,
+ "id_origin": "deterministic",
+ "supersedes": supersedes,
+ "created_at": plan.created_at,
+ "producer": {"name": PRODUCER_NAME, "version": __version__},
+ "case": subject.case,
+ "subject": {
+ "kind": "run",
+ "runs": [{"run_id": subject.run_id, "bundle_id": subject.bundle_id, "role": "subject"}],
+ },
+ "purpose": PURPOSE,
+ "retry_of": None,
+ "golden_basis": "run",
+ "status": "completed",
+ "finished_at": plan.created_at,
+ "reproducible": {"snapshot_complete": True, "missing": []},
+ "known_defects": [],
+ "observed": None,
+ "usage": None,
+ }
+
+
+def _write(dest: Path, fields: Mapping[str, Any], plan: _Plan) -> None:
+ try:
+ write_scoring_bundle(
+ dest,
+ fields=fields,
+ evaluator=plan.evaluator,
+ drafts=plan.drafts,
+ producer_aggregates=plan.aggregates,
+ run_bundle=plan.subject.ref,
+ )
+ except ScoringWriteError as exc:
+ _remove_uncommitted(dest)
+ raise RescoreError(_write_code(exc.issues), str(exc), plan.subject.run_id) from exc
+ except OSError as exc:
+ _remove_uncommitted(dest)
+ raise RescoreError(f"{WRITE_FAILED_PREFIX}io_error", str(exc), plan.subject.run_id) from exc
+
+
+def _write_code(issues: Sequence[str]) -> str:
+ """`write_failed:<규칙 id 목록>` — 이슈 문장의 `[]` 머리만 모은다(없으면 `input`)."""
+ ids = sorted({m.group(1) for issue in issues if (m := _RULE_ID.match(issue))})
+ return WRITE_FAILED_PREFIX + (",".join(ids) if ids else "input")
+
+
+def _write_and_mirror(
+ fields: Mapping[str, Any], plan: _Plan, new_id: str, mirror_root: Path, repo_root: Path | None
+) -> Path:
+ """임시 디렉토리에 쓴 뒤 미러에 놓는다 — 미러의 커밋 규칙(manifest 마지막)·레포 밖 검사를 `mirror_bundle` 이 맡는다."""
+ case = plan.subject.case
+ with tempfile.TemporaryDirectory(prefix="solar-eval-score-") as tmp:
+ staged = Path(tmp) / new_id
+ _write(staged, fields, plan)
+ try:
+ return mirror_bundle(
+ staged,
+ account=case["account"],
+ case=case["case"],
+ area=EVALUATIONS_AREA,
+ root=mirror_root,
+ repo_root=repo_root,
+ )
+ except (MirrorError, OSError) as exc:
+ raise RescoreError(ERR_MIRROR, str(exc), plan.subject.run_id) from exc
+
+
+def _remove_uncommitted(staging: Path) -> None:
+ """writer 가 manifest 없이 남긴 디렉토리를 지운다 — 커밋된 번들(manifest 있음)은 건드리지 않는다."""
+ if staging.is_dir() and not (staging / "manifest.json").exists():
+ shutil.rmtree(staging)
diff --git a/solar_eval/rescore/paths.py b/solar_eval/rescore/paths.py
new file mode 100644
index 0000000000000000000000000000000000000000..b5bfdc5965c32c099c114fb5936e60e5d6d7f2bc
--- /dev/null
+++ b/solar_eval/rescore/paths.py
@@ -0,0 +1,63 @@
+"""재채점 번들이 놓일 곳 (계획 2026-09-26 L3b §2.1 "미러 루트 층").
+
+- 미러 영역 = `//accounts///eval/` — `mirror_root` 는 **버킷의 부모**(`poc_bundle.mirror.mirror_bundle(root=)`
+ 와 같은 뜻, 기본 `blob_mirror_root()`). 경로 조각은 `poc_bundle.data.layout` 의 규칙으로만 만든다(슬러그 검사 포함).
+- `` 모드 기본 출력: 번들이 `…/eval/bundles/` 에 있으면 `…/eval/evaluations`(eval-wrap 과 같은 규칙),
+ 아니면 호출자가 `--out` 을 줘야 한다.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from poc_bundle.data.errors import DataConfigError
+from poc_bundle.data.layout import SUBPREFIX, case_prefix
+from poc_bundle.home import blob_mirror_root
+from poc_bundle.mirror import DEFAULT_BUCKET
+
+BUNDLES_AREA = "bundles"
+EVALUATIONS_AREA = "evaluations"
+
+
+class RescorePathError(ValueError):
+ """재채점 입력·출력 위치를 정할 수 없다(잘못된 case 슬러그·기본 출력 위치 없음)."""
+
+
+def parse_case_key(case_key: str) -> tuple[str, str]:
+ """`a/c` → (account, case). 슬러그 규칙은 `case_prefix` 가 본다."""
+ parts = case_key.split("/") if isinstance(case_key, str) else []
+ if len(parts) != 2:
+ raise RescorePathError(f"case must be /, got {case_key!r}")
+ account, case = parts
+ try:
+ case_prefix(account, case)
+ except DataConfigError as exc:
+ raise RescorePathError(str(exc)) from exc
+ return account, case
+
+
+def mirror_area_dir(
+ mirror_root: Path | None, account: str, case: str, area: str, *, bucket: str = DEFAULT_BUCKET
+) -> Path:
+ """미러의 한 case 영역 디렉토리(`bundles` 또는 `evaluations`)."""
+ if area not in (BUNDLES_AREA, EVALUATIONS_AREA):
+ raise RescorePathError(f"unknown mirror area {area!r}")
+ try:
+ prefix = case_prefix(account, case)
+ except DataConfigError as exc:
+ raise RescorePathError(str(exc)) from exc
+ return resolve_mirror_root(mirror_root) / bucket / prefix / SUBPREFIX[area]
+
+
+def resolve_mirror_root(mirror_root: Path | None) -> Path:
+ """미러 버킷의 부모 절대 경로 — None 이면 데이터 홈의 `blob-mirror`."""
+ return blob_mirror_root() if mirror_root is None else Path(mirror_root).expanduser().resolve()
+
+
+def default_evaluations_dir(run_bundle_dir: Path) -> Path | None:
+ """`…/eval/bundles/` → `…/eval/evaluations`. 그 모양이 아니면 None(호출자가 `--out` 을 요구한다)."""
+ resolved = Path(run_bundle_dir).resolve()
+ parent = resolved.parent
+ if parent.name == Path(SUBPREFIX[BUNDLES_AREA]).name and parent.parent.name == "eval":
+ return parent.parent / Path(SUBPREFIX[EVALUATIONS_AREA]).name
+ return None
diff --git a/solar_eval/rescore/proofread_diff_v2.py b/solar_eval/rescore/proofread_diff_v2.py
new file mode 100644
index 0000000000000000000000000000000000000000..0ef430edb1ce12e298936d73e8984ecce80359d2
--- /dev/null
+++ b/solar_eval/rescore/proofread_diff_v2.py
@@ -0,0 +1,153 @@
+"""`proofread_diff@2` — 결과 행 하나의 순수 채점 (계획 2026-09-26 L3b D1·D2·D3).
+
+**이 모듈은 표준 라이브러리와 `solar_eval.evaluators.scoring` 만 import 한다.** 평가기 `impl_hash` 는 이 모듈에서 시작한
+import 폐포(`core/snapshot/impl_hash.py`)로 뜨므로, 번들 읽기·writer·미러 같은 I/O 코드는 `from_bundle.py`·`case.py` 에 둔다 —
+그래야 입출력 코드를 고쳐도 평가기 해시가 바뀌지 않고, 채점 규칙이 바뀌면 반드시 바뀐다(MB1).
+
+채점 규칙은 레거시 `lcs_diff` 와 **같은 함수**(`compute_proofread_metrics`)다 — 두 벌 두지 않는다. 전제조건도 레거시
+`runs eval`(`cli/commands/run_eval.py` `_score_all` → `validate_required_fields` → `LCSDiffEvaluator.evaluate`)과 같게 맞춘다:
+
+- `output is None` → `missing_output`, `golden is None` → `missing_golden`(레거시 `validate_required_fields` 의 ValueError).
+- `output` 이 문자열이 아님 → `output_not_text`(레거시는 `.split()` 의 AttributeError 로 실패).
+- 그 밖의 예외(입력이 객체가 아님, `original` 이 문자열이 아님 등) → `scoring_failed`.
+- `input` 에 `original` 이 없으면 `""`, `golden` 이 문자열이 아니면 `str(golden)` — 레거시가 내는 값과 같게.
+
+`exact` 라벨(D3): 정규화(`strip@1` → `ws_collapse@1`)한 출력이 빈 문자열 → `noanswer`, 정규화한 출력 = 정규화한 정답 →
+`correct`, `spans.tp > 0` → `partial`, 나머지 → `incorrect`. 정규화기 정의의 정본은 이 모듈 하나다(문서는
+`PS/SCORING.md` §4.4 가 같은 말을 적고, 테스트가 두 어휘를 대조한다).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import Any
+
+from solar_eval.evaluators.scoring import compute_proofread_metrics
+
+#: 정규화기 이름 — 평가기 문서 `params.exact_compare.normalizers` 에 이 순서로 실린다(해시 재료).
+NORMALIZERS: tuple[str, ...] = ("strip@1", "ws_collapse@1")
+#: 원문을 읽는 입력 필드 — 평가기 문서 `params.original_field`.
+ORIGINAL_FIELD = "original"
+
+LABEL_CORRECT = "correct"
+LABEL_PARTIAL = "partial"
+LABEL_INCORRECT = "incorrect"
+LABEL_NOANSWER = "noanswer"
+#: `exact` 지표 `levels` 순서(벡터 `scoring/bundles/02` 와 같다).
+LABELS: tuple[str, ...] = (LABEL_CORRECT, LABEL_PARTIAL, LABEL_INCORRECT, LABEL_NOANSWER)
+#: 라벨 → 점수(`label_scores`). correct 만 1.
+LABEL_SCORES: dict[str, int] = {
+ LABEL_CORRECT: 1,
+ LABEL_PARTIAL: 0,
+ LABEL_INCORRECT: 0,
+ LABEL_NOANSWER: 0,
+}
+
+REASON_MISSING_OUTPUT = "missing_output"
+REASON_MISSING_GOLDEN = "missing_golden"
+REASON_OUTPUT_NOT_TEXT = "output_not_text"
+REASON_SCORING_FAILED = "scoring_failed"
+#: 실패 판정 `reason` 어휘(행 단위).
+REASONS: tuple[str, ...] = (
+ REASON_MISSING_OUTPUT,
+ REASON_MISSING_GOLDEN,
+ REASON_OUTPUT_NOT_TEXT,
+ REASON_SCORING_FAILED,
+)
+#: `spans` 판정 `value` 의 키 — 레거시 `compute_proofread_metrics` 카운트 이름 그대로.
+SPAN_KEYS: tuple[str, ...] = ("tp", "fp", "missing", "redundant")
+
+
+@dataclass(frozen=True)
+class RowScore:
+ """채점 성공 — `exact` 라벨·점수, `lcs_f1`(레거시 `f1`, 0~100), `spans` 카운트."""
+
+ label: str
+ score_exact: int
+ lcs_f1: float
+ spans: dict[str, int]
+
+
+@dataclass(frozen=True)
+class RowError:
+ """채점 실패 — 세 지표 모두 `status="error"` 이고 같은 `reason` 이다(D2)."""
+
+ reason: str
+
+
+def strip_ws(text: str) -> str:
+ """`strip@1` — 앞뒤의 `str.isspace()` 문자를 지운다(U+3000 전각 공백 포함)."""
+ start, end = 0, len(text)
+ while start < end and text[start].isspace():
+ start += 1
+ while end > start and text[end - 1].isspace():
+ end -= 1
+ return text[start:end]
+
+
+def collapse_ws(text: str) -> str:
+ """`ws_collapse@1` — `str.isspace()` 문자의 연속을 U+0020 하나로 바꾼다."""
+ out: list[str] = []
+ in_space = False
+ for ch in text:
+ if ch.isspace():
+ if not in_space:
+ out.append(" ")
+ in_space = True
+ else:
+ out.append(ch)
+ in_space = False
+ return "".join(out)
+
+
+def normalize(text: str) -> str:
+ """`NORMALIZERS` 순서(strip → collapse)로 정규화한다."""
+ return collapse_ws(strip_ws(text))
+
+
+def label_of(norm_out: str, norm_gold: str, tp: int) -> str:
+ """D3 라벨 규칙 — 정규화한 값을 받는다. 순서가 규칙이다(빈 출력이 먼저)."""
+ if norm_out == "":
+ return LABEL_NOANSWER
+ if norm_out == norm_gold:
+ return LABEL_CORRECT
+ if tp > 0:
+ return LABEL_PARTIAL
+ return LABEL_INCORRECT
+
+
+def has_original(result_row: Mapping[str, Any]) -> bool:
+ """입력 객체에 `original` 키가 있는가 — 없으면 레거시처럼 `""` 로 채점한다(보고용 건수)."""
+ source = result_row.get("input")
+ return isinstance(source, Mapping) and ORIGINAL_FIELD in source
+
+
+def score_row(result_row: Mapping[str, Any]) -> RowScore | RowError:
+ """`results.jsonl` 행 하나를 채점한다. 입력을 바꾸지 않는다.
+
+ 레거시가 실패로 모으는 행은 여기서도 `RowError` 다 — 실패 집합이 같아야 parity 가 뜻을 가진다.
+ """
+ output = result_row.get("output")
+ golden = result_row.get("golden")
+ if output is None:
+ return RowError(REASON_MISSING_OUTPUT)
+ if golden is None:
+ return RowError(REASON_MISSING_GOLDEN)
+ if not isinstance(output, str):
+ return RowError(REASON_OUTPUT_NOT_TEXT)
+ reference = str(golden)
+ try:
+ # 레거시 `LCSDiffEvaluator.evaluate` 와 같은 식 — `sample.input.get("original", "")`.
+ original = result_row.get("input").get(ORIGINAL_FIELD, "") # type: ignore[union-attr]
+ metrics = compute_proofread_metrics(original, reference, output)
+ except Exception: # noqa: BLE001 — 레거시 `_score_all` 도 모든 예외를 실패 샘플로 모은다.
+ return RowError(REASON_SCORING_FAILED)
+ spans = {key: int(metrics[key]) for key in SPAN_KEYS}
+ label = label_of(normalize(output), normalize(reference), spans["tp"])
+ return RowScore(
+ label=label,
+ score_exact=LABEL_SCORES[label],
+ lcs_f1=float(metrics["f1"]),
+ spans=spans,
+ )
diff --git a/solar_eval/scoring_publish/__init__.py b/solar_eval/scoring_publish/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0468477ae56e94a300f82e2bec8b21f9551b7e05
--- /dev/null
+++ b/solar_eval/scoring_publish/__init__.py
@@ -0,0 +1,38 @@
+"""`runs eval` 새 경로 — 평가 디렉토리 + 채점 번들 발행 (계획 2026-09-27 L6b §2.12 B4 · 마스터 D-M2-6 · C-M2-10).
+
+hub 대상 lcs_diff 계열 run 을 다시 채점할 때 **run 디렉토리를 건드리지 않는다**. 채점 결과는 데이터 홈의 평가 디렉토리
+(`eval_dir`, D-L6-15)에 남기고, PB eval-wrap 이 그것을 `purpose=rescore_after_fix` 채점 번들로 감싸 미러에 발행한다(`publish`).
+그래서 재채점 뒤에도 run 번들이 바뀌지 않는다(다음 백필은 `reused`). 그 밖의 run 은 오늘 동작(in-place) + 안내 한 줄이다.
+
+**judge 경로(계획 2026-09-28 L12 §2.5 · D-L12-6).** 태스크 평가기가 `rubric_judge` 인 hub 대상 완료 run 은 경로 판정이
+`family=FAMILY_JUDGE` 를 돌려준다. 잠금·번들 확인·hub·상태 파일은 위와 같고, 채점·발행만 `judge` 가 맡는다 — 기준마다 judge 판정을
+`poc-scoring/v1` 채점 번들(기준 행 + item `rubric_score` 행, 근거·원 응답은 `payloads.jsonl` 에만)로 낸다. `runs eval --purpose`
+(`primary`·`variance_check`)·`--retry-of ` 는 이 경로에서만 뜻이 있다.
+
+- `eval_dir` — 평가 디렉토리 자리·레거시 모양 평가 파일·`eval-run.json`.
+- `publish` — 경로 판정(`decide_route`)과 새 경로 오케스트레이션(`publish_run_eval`: 잠금 → 번들 확인 → 채점 → eval-wrap → hub → 상태 파일).
+- `judge` — judge 경로의 채점·발행(`score_and_publish`)과 발행 코드.
+
+`hub_hook` 은 읽기만 한다(대상 판정·잠금·번들 확인·hub 게이트·상태 파일). `poc_bundle` 은 함수 안에서만 import 한다 —
+이 패키지는 runner 경로(`RUNNER_PATH_MODULES`)에 들어가지 않는다.
+"""
+
+from solar_eval.scoring_publish.publish import (
+ FAMILY_JUDGE,
+ EvalJob,
+ PublishOutcome,
+ Route,
+ Scored,
+ decide_route,
+ publish_run_eval,
+)
+
+__all__ = [
+ "FAMILY_JUDGE",
+ "EvalJob",
+ "PublishOutcome",
+ "Route",
+ "Scored",
+ "decide_route",
+ "publish_run_eval",
+]
diff --git a/solar_eval/scoring_publish/eval_dir.py b/solar_eval/scoring_publish/eval_dir.py
new file mode 100644
index 0000000000000000000000000000000000000000..63ed9c09b957fea0d727f4b96b6fd41e4e430625
--- /dev/null
+++ b/solar_eval/scoring_publish/eval_dir.py
@@ -0,0 +1,275 @@
+"""평가 디렉토리 — `runs eval` 새 경로의 채점 결과 자리 (계획 2026-09-27 L6 §2.12 B4 ② · D-L6-15).
+
+자리는 `/solar-eval-evaluations////` 다. 레거시 아카이브 트리(`solar-eval-projects/`)
+**밖**이라 레거시 적재기가 프로젝트 자원으로 오인하지 않고, run 디렉토리 밖이라 run 번들이 그대로다.
+
+- `evaluation.json`·`eval_details.jsonl` 은 레거시 모양 그대로다 — run 디렉토리와 같은 `JsonlStore` 로 쓴다
+ (`build_evaluation_doc`·`build_eval_detail_docs` 재사용). eval-wrap 이 이 디렉토리를 평가 원천으로 읽는다(B3).
+- `eval-run.json` 은 이 평가의 정체 — 평가 시각·평가기 설정·judge 모델·원천 run 번들 id. 원자적 쓰기(임시 → `os.replace`).
+ 다른 두 파일 뒤에 마지막으로 쓴다 — 이 파일이 없는 디렉토리는 끝나지 않은 평가다.
+- judge 경로(계획 2026-09-28 L12 §2.7)는 `verdicts.jsonl`(기준 판정 전부 — 근거·원 응답 포함, 데이터 홈 전용)을 더 쓰고
+ `eval-run.json` 에 `purpose`·`judge{evaluation_run_id, scoring_bundle_id, retry_of}` 를 싣는다(발행 **뒤**에 쓴다). lcs 경로는
+ 선택 인자를 주지 않아 바이트가 그대로다.
+- 이름 조각(project·run_id)은 경로 구분자·제어 문자·`.`/`..` 를 거부하고, 루트가 레포(링크된 워크트리면 메인 작업 트리도)
+ 안이면 쓰기 전에 멈춘다 — hub 대상 판정(`hub_hook.target`, D-L6-1)이 이미 보지만 이 모듈이 직접 쓰므로 한 번 더 본다.
+
+`poc_bundle`·`ulid` 는 함수 안에서만 import 한다(마스터 §4 규칙 11 · D-L6-14).
+"""
+
+from __future__ import annotations
+
+import dataclasses
+import json
+import os
+import re
+import shutil
+import tempfile
+from collections.abc import Iterable, Mapping, Sequence
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from solar_eval.core.run_records import build_eval_detail_docs, build_evaluation_doc
+from solar_eval.stores import JsonlStore
+
+#: 데이터 홈 아래 평가 디렉토리 루트 이름(D-L6-15).
+EVALUATIONS_DIRNAME = "solar-eval-evaluations"
+EVAL_RUN_FILE = "eval-run.json"
+EVAL_RUN_SCHEMA = "solar-eval-eval-run/v1"
+#: 이 디렉토리가 싣는 평가의 목적 — eval-wrap 매니페스트 `purpose` 와 같은 값(PB `PURPOSE_RESCORE_AFTER_FIX`).
+EVAL_RUN_PURPOSE = "rescore_after_fix"
+#: judge 경로(계획 2026-09-28 L12 §2.7) — 기준 판정 전부(근거·원 응답 포함)와 `eval-run.json` 의 `judge` 칸 키.
+VERDICTS_FILE = "verdicts.jsonl"
+JUDGE_BLOCK_KEYS = ("evaluation_run_id", "scoring_bundle_id", "retry_of")
+#: 레포 안 쓰기 검사에 주는 이름표.
+WRITE_LABEL = "evaluation directory"
+_UNSAFE_SEGMENT = re.compile(r"[/\\\x00-\x1f\x7f]")
+_TMP_PREFIX = ".eval-run-"
+_TMP_SUFFIX = ".tmp"
+
+#: `EvalDirError.reason` — 발행 다리 코드 `publish_eval_dir:` 의 꼬리. 모두 설정·입력 오류라 다시 돌려도 같다.
+REASON_UNSAFE_SEGMENT = "unsafe_segment"
+REASON_INSIDE_REPO = "inside_repo"
+REASON_BAD_DOCUMENT = "bad_document"
+REASON_OUTSIDE_ROOT = "outside_root"
+
+
+class EvalDirError(ValueError):
+ """평가 디렉토리를 만들거나 쓸 수 없다(이름 조각·레포 안·잘못된 문서). `reason` 은 위 `REASON_*` 하나."""
+
+ def __init__(self, message: str, reason: str) -> None:
+ super().__init__(message)
+ self.reason = reason
+
+
+#: 콘솔 보고용 집계의 채점 입력 행 수(성공 + 실패) — 채택된 run 번들의 정규화 사본 기준(l6b-verify 리뷰 지적 4).
+REPORT_SAMPLES = "input_samples"
+
+
+@dataclass(frozen=True)
+class WrittenEvaluation:
+ """`write_evaluation` 결과 — 새 평가 id 와 콘솔 보고용 집계(`overall_score`·`failed`·`REPORT_SAMPLES` 포함)."""
+
+ evaluation_id: str
+ aggregated: Mapping[str, Any]
+
+
+def evaluations_root(env: Mapping[str, str] | None = None) -> Path:
+ """`/solar-eval-evaluations`(절대 경로)."""
+ from poc_bundle.home import data_home
+
+ return data_home(env) / EVALUATIONS_DIRNAME
+
+
+def _check_segment(label: str, value: str) -> str:
+ if not isinstance(value, str) or value in ("", ".", "..") or _UNSAFE_SEGMENT.search(value):
+ raise EvalDirError(
+ f"evaluation directory: {label} is not a safe path segment", REASON_UNSAFE_SEGMENT
+ )
+ return value
+
+
+def _require_outside_repo(root: Path, repo_root: Path | None) -> None:
+ """루트가 레포(링크된 워크트리면 메인 작업 트리도) 안이면 `EvalDirError`."""
+ from poc_bundle.home import HomeError, require_outside_repo
+ from poc_bundle.mirror import linked_main_work_tree
+
+ if repo_root is None:
+ return
+ main = linked_main_work_tree(Path(repo_root))
+ for tree in (Path(repo_root),) if main is None else (Path(repo_root), main):
+ try:
+ require_outside_repo(root, tree, WRITE_LABEL)
+ except HomeError as exc:
+ raise EvalDirError(
+ f"evaluation directory is inside the repo: {exc}", REASON_INSIDE_REPO
+ ) from None
+
+
+def create_evaluation_dir(
+ project: str,
+ run_id: str,
+ *,
+ repo_root: Path | None,
+ env: Mapping[str, str] | None = None,
+) -> Path:
+ """새 평가 디렉토리(빈 디렉토리)를 만들어 돌려준다. 이름은 새 ULID — 같은 run 의 재평가는 시각 순으로 쌓인다."""
+ from ulid import ULID
+
+ parent = (
+ evaluations_root(env)
+ / _check_segment("project", project)
+ / _check_segment("run_id", run_id)
+ )
+ _require_outside_repo(evaluations_root(env), repo_root)
+ path = parent / str(ULID())
+ path.mkdir(parents=True, exist_ok=False)
+ return path
+
+
+async def write_evaluation(
+ path: Path,
+ run_id: str,
+ evaluator: Any,
+ eval_results: Sequence[Mapping[str, Any]],
+ eval_failures: Sequence[Mapping[str, Any]],
+) -> WrittenEvaluation:
+ """레거시 모양 `evaluation.json`·`eval_details.jsonl` 을 쓴다(`run_eval._evaluate_run` 과 같은 조립)."""
+ aggregated = evaluator.aggregate(list(eval_results))
+ doc = build_evaluation_doc(
+ run_id, aggregated, eval_results, eval_failures, evaluator.judge_model
+ )
+ store = JsonlStore(path)
+ evaluation_id = await store.create_evaluation(doc)
+ await store.insert_eval_details(
+ build_eval_detail_docs(evaluation_id, eval_results, eval_failures)
+ )
+ report = {
+ **aggregated,
+ "overall_score": doc["overall_score"],
+ "failed": len(eval_failures),
+ REPORT_SAMPLES: len(eval_results) + len(eval_failures),
+ }
+ return WrittenEvaluation(str(evaluation_id), report)
+
+
+def eval_run_document(
+ *,
+ project: str,
+ run_id: str,
+ run_bundle_id: str,
+ evaluation_id: str,
+ evaluated_at: str,
+ evaluator_config: Mapping[str, Any] | None,
+ eval_model: str | None,
+ purpose: str = EVAL_RUN_PURPOSE,
+ judge: Mapping[str, Any] | None = None,
+) -> dict[str, Any]:
+ """`eval-run.json` 문서(순수). 시각은 오프셋이 있어야 하고 평가기 설정은 JSON 매핑이어야 한다.
+
+ `purpose`·`judge` 는 judge 경로(계획 2026-09-28 L12 §2.7)만 준다 — lcs 경로 호출은 인자를 주지 않아 바이트가 그대로다.
+ `judge` 가 있으면 끝에 `{"evaluation_run_id", "scoring_bundle_id", "retry_of"}` 를 더한다(발행 실패면 bundle id 는 null).
+ """
+ _check_offset(evaluated_at)
+ doc: dict[str, Any] = {
+ "schema": EVAL_RUN_SCHEMA,
+ "project": project,
+ "run_id": run_id,
+ "run_bundle_id": run_bundle_id,
+ "evaluation_id": evaluation_id,
+ "evaluated_at": evaluated_at,
+ "purpose": purpose,
+ "evaluator_config": _json_mapping(evaluator_config),
+ "eval_model": eval_model,
+ }
+ if judge is not None:
+ doc["judge"] = _judge_block(judge)
+ return doc
+
+
+def _judge_block(judge: Mapping[str, Any]) -> dict[str, Any]:
+ """`eval-run.json` 의 `judge` 칸 — 닫힌 키 셋, 값은 문자열 id 또는 null."""
+ if not isinstance(judge, Mapping) or set(judge) != set(JUDGE_BLOCK_KEYS):
+ raise EvalDirError(
+ f"eval-run: judge must have exactly the keys {list(JUDGE_BLOCK_KEYS)}",
+ REASON_BAD_DOCUMENT,
+ )
+ if not all(judge[k] is None or isinstance(judge[k], str) for k in JUDGE_BLOCK_KEYS):
+ raise EvalDirError("eval-run: judge ids must be strings or null", REASON_BAD_DOCUMENT)
+ return {key: judge[key] for key in JUDGE_BLOCK_KEYS}
+
+
+def write_verdicts(path: Path, verdicts: Iterable[Any]) -> Path:
+ """`verdicts.jsonl` — 기준 판정 전부(근거·원 응답 포함, 데이터 홈 전용 — 계획 L12 §2.7 · D-L12-8).
+
+ 한 줄 = 판정 dataclass 의 JSON(키 순서 = 필드 순서, compact, `ensure_ascii=False`). UTF-8 로 옮길 수 없는 문자열(짝 없는
+ 서로게이트)이 든 줄만 `ensure_ascii=True` 로 적는다 — 유료 판정 한 건이 파일 전체 쓰기를 깨뜨리지 않게. 레포 밖 검사는
+ 디렉토리를 만들 때 이미 했다(`create_evaluation_dir`).
+ """
+ lines: list[str] = []
+ for verdict in verdicts:
+ record = dataclasses.asdict(verdict)
+ line = json.dumps(record, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
+ try:
+ line.encode("utf-8")
+ except UnicodeEncodeError:
+ line = json.dumps(record, ensure_ascii=True, separators=(",", ":"), allow_nan=False)
+ lines.append(line + "\n")
+ target = Path(path) / VERDICTS_FILE
+ target.write_text("".join(lines), encoding="utf-8")
+ return target
+
+
+def _check_offset(value: str) -> None:
+ try:
+ parsed = datetime.fromisoformat(value)
+ except (TypeError, ValueError):
+ raise EvalDirError(
+ "eval-run: evaluated_at is not an ISO 8601 time", REASON_BAD_DOCUMENT
+ ) from None
+ if parsed.tzinfo is None:
+ raise EvalDirError("eval-run: evaluated_at needs a UTC offset", REASON_BAD_DOCUMENT)
+
+
+def _json_mapping(value: Mapping[str, Any] | None) -> dict[str, Any] | None:
+ """평가기 설정을 JSON 으로 옮길 수 있는 사본으로. 설정이 없으면(기본 평가기) None."""
+ if value is None:
+ return None
+ if not isinstance(value, Mapping):
+ raise EvalDirError("eval-run: evaluator_config must be a mapping", REASON_BAD_DOCUMENT)
+ try:
+ return json.loads(json.dumps(dict(value), ensure_ascii=False))
+ except (TypeError, ValueError):
+ raise EvalDirError(
+ "eval-run: evaluator_config is not JSON serializable", REASON_BAD_DOCUMENT
+ ) from None
+
+
+def write_eval_run(path: Path, doc: Mapping[str, Any]) -> Path:
+ """`eval-run.json` 원자적 쓰기(직렬화 → 같은 디렉토리 임시 파일 → fsync → `os.replace`)."""
+ text = json.dumps(doc, ensure_ascii=False, indent=2) + "\n"
+ target = Path(path) / EVAL_RUN_FILE
+ fd, tmp_name = tempfile.mkstemp(dir=path, prefix=_TMP_PREFIX, suffix=_TMP_SUFFIX)
+ tmp = Path(tmp_name)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
+ fh.write(text)
+ fh.flush()
+ os.fsync(fh.fileno())
+ os.replace(tmp, target)
+ except BaseException:
+ tmp.unlink(missing_ok=True)
+ raise
+ return target
+
+
+def remove_partial(path: Path, env: Mapping[str, str] | None = None) -> None:
+ """끝나지 못한 평가 디렉토리를 치운다 — 평가 루트 아래의 디렉토리만(다른 곳은 `EvalDirError`)."""
+ resolved = Path(path).resolve()
+ root = evaluations_root(env).resolve()
+ if root not in resolved.parents:
+ raise EvalDirError(
+ "evaluation directory: refusing to remove a path outside the root", REASON_OUTSIDE_ROOT
+ )
+ shutil.rmtree(resolved)
diff --git a/solar_eval/scoring_publish/judge.py b/solar_eval/scoring_publish/judge.py
new file mode 100644
index 0000000000000000000000000000000000000000..28774d2c7c5892849d9aec95db9daac03444ae29
--- /dev/null
+++ b/solar_eval/scoring_publish/judge.py
@@ -0,0 +1,522 @@
+"""`runs eval` judge 경로 — rubric judge 채점 → `poc-scoring/v1` 채점 번들 발행 (계획 2026-09-28 L12 §2.5 · D-L12-6~9·17).
+
+`publish._legs_in_lock` 이 `job.family == FAMILY_JUDGE` 면 L6b 의 `_score_and_publish` 대신 `score_and_publish` 를 부른다.
+잠금·번들 확인(①)·hub(④)·상태 파일(⑤)은 L6b 그대로이고 ②③(채점·발행)만 judge 판이다. 순서(§2.5):
+
+0. 준비 — 채택 run 번들 참조 · PB `parse_rubric_doc`(실패 `publish_judge:rubric_invalid`) · 평가기 문서·설정 해시 · 미러 영역.
+1. purpose — `--retry-of` 면 `retry.plan_retry`(거부 `publish_retry:<사유>`, 대상 0 이면 skipped `retry_nothing_to_do`), purpose 를
+ 주지 않았으면(기본 primary) 같은 subject·같은 설정의 primary 가 이미 있을 때 `publish_judge:primary_exists`. 여기까지 judge 호출 0.
+2. 평가 디렉토리(채점 **전에** — 이름 조각·레포 밖 검사가 채점 비용 없이 멈춘다, `publish_eval_dir:`).
+3. 채점 — 채택 run 번들 결과(retry 면 대상 샘플만)를 샘플마다 `evaluator.judge` → `verdicts.jsonl`·레거시 모양 평가 파일. 실패면
+ 디렉토리를 치우고 `publish_score_failed`(선언과 다른 실제 provider 는 `publish_judge:judge_provider_mismatch`).
+4. ok 기준 행이 0 이면 발행하지 않는다(`publish_judge:skip:all_failed`, 디렉토리는 남긴다 — L6b 규칙).
+5. 번들 — 판정 초안·payload → 임시 디렉토리에 writer → 미러(`publish_judge:write_failed:<규칙>`·`publish_judge:mirror_failed`).
+6. `eval-run.json`(발행 뒤 — 번들 id 를 안다. 발행 실패면 id 는 null)과 ok 다리.
+
+**종료 코드·hub 다리(검토 F8).** 사전 거부(`publish_judge:{primary_exists,rubric_invalid,mirror_unadoptable,
+judge_provider_mismatch,judge_provider_unknown,judge_endpoint_invalid}`·`publish_retry:*` — judge 호출 0)와
+`retry_nothing_to_do` 는 새 번들이 없으므로 hub 다리가 `skipped(publish_refused)` 다(`is_refusal`). 채점 뒤 실패(`write_failed`·
+`mirror_failed`·`skip:all_failed`)는 L6b 규칙 그대로 hub 다리를 돈다(run 번들이 이번에 새로 만들어졌을 수 있다).
+
+구현 시 변경(계획 §2.5 대비): 미러 채택 영역을 읽을 수 없으면(`AdoptError`) `publish_judge:mirror_unadoptable`(hub 코드
+`MIRROR_UNADOPTABLE` 재사용), 예기치 않은 예외는 `publish_exception:`(D-L6-2) — 둘 다 judge 호출 전이면 호출 0 이다.
+
+`poc_bundle` 은 함수 안에서만 import 한다(D-L6-14). 로그·코드·힌트에 judge 원문·근거·고객 문장을 싣지 않는다.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+from solar_eval.core.run_records import build_eval_sample_from_result
+from solar_eval.evaluators.judge_config import JUDGE_ENDPOINT_INVALID, JUDGE_PROVIDER_UNKNOWN
+from solar_eval.evaluators.rubric_judge import (
+ JUDGE_PROVIDER_MISMATCH,
+ CriterionVerdict,
+ JudgeProviderMismatch,
+ RubricJudgeEvaluator,
+ RubricVerdictIncomplete,
+ SampleVerdicts,
+)
+from solar_eval.hub_hook.types import (
+ LEG_PUBLISH,
+ MIRROR_UNADOPTABLE,
+ PUBLISH_DETAIL_EVAL_FAILED,
+ PUBLISH_DETAIL_EVALUATION_DIR_ID,
+ PUBLISH_DETAIL_EVALUATION_ID,
+ PUBLISH_DETAIL_EXCEPTION,
+ PUBLISH_DETAIL_PURPOSE,
+ PUBLISH_DETAIL_SCORING_BUNDLE_IDS,
+ PUBLISH_SCORE_FAILED,
+ STATUS_FAILED,
+ STATUS_SKIPPED,
+ LegResult,
+ Target,
+ exception_code,
+)
+from solar_eval.scoring_publish import eval_dir, judge_bundle, retry
+from solar_eval.scoring_publish.publish import (
+ PUBLISH_EVAL_DIR_PREFIX,
+ EvalJob,
+ Scored,
+ load_bundle_results,
+)
+
+_LOG = logging.getLogger(__name__)
+
+# ── purpose(D-L12-9) ────────────────────────────────────────────────────
+
+PURPOSE_PRIMARY = "primary"
+PURPOSE_VARIANCE_CHECK = "variance_check"
+PURPOSE_RETRY = "retry"
+#: `runs eval --purpose` 가 받는 값 — retry 는 `--retry-of` 로만 연다.
+CLI_PURPOSES = (PURPOSE_PRIMARY, PURPOSE_VARIANCE_CHECK)
+
+# ── 발행 다리 코드(§2.5 — 상태 파일 `legs.publish.code`, command=eval) ──────────
+
+PUBLISH_JUDGE_PREFIX = "publish_judge:"
+PUBLISH_RETRY_PREFIX = "publish_retry:"
+JUDGE_PRIMARY_EXISTS = "primary_exists"
+JUDGE_RUBRIC_INVALID = "rubric_invalid"
+JUDGE_WRITE_FAILED = "write_failed"
+JUDGE_MIRROR_FAILED = "mirror_failed"
+#: ok 기준 행 0 — 발행하지 않는다(`publish_judge:skip:all_failed`).
+JUDGE_SKIP_ALL_FAILED = "skip:all_failed"
+#: retry 대상이 0 — 발행 다리 skipped, hub 다리 skipped(publish_refused), 종료 0.
+RETRY_NOTHING_TO_DO = "retry_nothing_to_do"
+#: 사전 거부·retry_nothing_to_do 뒤의 hub 다리 skipped 코드(§2.15).
+PUBLISH_REFUSED = "publish_refused"
+
+#: judge 경로 ok 다리 detail 의 추가 키(§2.5 ⑥).
+PUBLISH_DETAIL_JUDGE_CALLS = "judge_calls"
+PUBLISH_DETAIL_CRITERION_ERRORS = "criterion_errors"
+PUBLISH_DETAIL_EVALUATION_RUN_ID = "evaluation_run_id"
+PUBLISH_DETAIL_RETRY_OF = "retry_of"
+
+HINT_PRIMARY_EXISTS = (
+ "같은 run 번들·같은 판정 설정의 primary 채점 번들이 이미 있다 — 판정 분산을 재려면 --purpose variance_check, "
+ "대표를 바꾸려면 --purpose primary, error 만 다시 부르려면 --retry-of "
+)
+HINT_RUBRIC_INVALID = (
+ "rubric 이 rubric/v1 계약을 어긴다 — poc-bundle 의 rubric 검사(RB0~RB4)로 확인한다"
+)
+HINT_RETRY = "--retry-of 가 가리키는 평가 실행을 다시 부를 수 없다({code}) — 설정이 바뀌었으면 새 primary 로 채점한다"
+HINT_RETRY_NOTHING = "원 평가 실행의 error 판정이 이미 모두 풀렸다"
+HINT_EVAL_DIR = (
+ "평가 디렉토리를 만들거나 쓸 수 없다({reason}) — 입력·설정 오류라 다시 돌려도 같다. "
+ "CBS_DATA_HOME·solar-eval-evaluations 위치를 확인한다"
+)
+HINT_ALL_FAILED = "기준 판정이 하나도 나지 않았다 — 발행하지 않았다(hub 대표 채점본 유지). 판정: {path}/verdicts.jsonl"
+HINT_NOT_PUBLISHED = "판정은 평가 디렉토리에 남았다({path}/verdicts.jsonl) — 발행만 못 했다"
+HINT_RESCORE = "solar-eval runs eval {project} -r {run} 를 다시"
+HINT_MIRROR_UNADOPTABLE = (
+ "미러 evaluations 영역에 채택을 믿을 수 없는 번들이 있다 — poc-bundle 로 영역을 확인한다"
+)
+
+#: judge 호출 전에 멈춘 발행 거부 — `publish_judge:<이 값>`. judge 가지만의 사전 거부 전부다: rubric·purpose(§2.5) 에 더해
+#: 미러 채택 불가·judge 설정(provider 이름·endpoint)·선언과 다른 실제 provider(첫 호출 전에 멈춘다, D-L12-18).
+#: L6b 와 같은 코드(`publish_eval_dir:*`·`publish_exception:*`)는 lcs 가지처럼 hub 다리를 돈다.
+_REFUSED_JUDGE_CODES = frozenset(
+ f"{PUBLISH_JUDGE_PREFIX}{tail}"
+ for tail in (
+ JUDGE_PRIMARY_EXISTS,
+ JUDGE_RUBRIC_INVALID,
+ MIRROR_UNADOPTABLE,
+ JUDGE_PROVIDER_MISMATCH,
+ JUDGE_PROVIDER_UNKNOWN,
+ JUDGE_ENDPOINT_INVALID,
+ )
+)
+
+
+def is_refusal(leg: LegResult) -> bool:
+ """발행 다리가 "새 번들 없음" 부류인가 — 사전 거부(실패) 또는 `retry_nothing_to_do`(건너뜀). hub 다리를 돌지 않는다."""
+ if leg.status == STATUS_SKIPPED:
+ return leg.code == RETRY_NOTHING_TO_DO
+ if leg.status != STATUS_FAILED:
+ return False
+ return leg.code in _REFUSED_JUDGE_CODES or leg.code.startswith(PUBLISH_RETRY_PREFIX)
+
+
+# ── 준비·purpose(⓪①) ────────────────────────────────────────────────────
+
+
+class _Stop(Exception):
+ """judge 를 부르기 전에 멈춘다 — `leg` 가 발행 다리 결과다."""
+
+ def __init__(self, leg: LegResult) -> None:
+ super().__init__(leg.code)
+ self.leg = leg
+
+
+@dataclass(frozen=True)
+class _Prepared:
+ evaluator: RubricJudgeEvaluator
+ ref: Any
+ subject_run_id: str
+ subject_bundle_id: str
+ rubric: dict[str, Any]
+ doc: dict[str, Any]
+ config_hash: str
+ area: Path
+
+
+def _judge_code(tail: str) -> str:
+ return f"{PUBLISH_JUDGE_PREFIX}{tail}"
+
+
+def _prepare(t: Target, job: EvalJob, bundle_dir: Path) -> _Prepared:
+ from poc_bundle.scoring.evaluator import evaluator_document as parse_evaluator
+ from poc_bundle.scoring.ids import evaluator_config_hash
+ from poc_bundle.scoring.manifest import ScoringContractError
+ from poc_bundle.scoring.rubric import parse_rubric_doc
+ from poc_bundle.scoring.verify import load_run_bundle_ref
+
+ from solar_eval.evaluators.judge_config import JudgeConfigError, judge_endpoint
+ from solar_eval.rescore.paths import EVALUATIONS_AREA, mirror_area_dir
+
+ evaluator = job.evaluator
+ if not isinstance(evaluator, RubricJudgeEvaluator):
+ raise TypeError(
+ f"judge path needs a rubric_judge evaluator, got {type(evaluator).__name__}"
+ )
+ ref = load_run_bundle_ref(Path(bundle_dir))
+ manifest = ref.verify_report.manifest
+ if manifest is None:
+ raise ValueError("the adopted run bundle has no readable manifest")
+ rubric = evaluator.rubric_document
+ try:
+ parse_rubric_doc(rubric)
+ except ScoringContractError:
+ raise _Stop(
+ LegResult.failed(_judge_code(JUDGE_RUBRIC_INVALID), HINT_RUBRIC_INVALID)
+ ) from None
+ try:
+ endpoint = judge_endpoint(evaluator.judge_provider)
+ except JudgeConfigError as exc:
+ raise _Stop(LegResult.failed(_judge_code(exc.reason))) from None
+ impl_hash, _ = judge_bundle.implementation_hash()
+ doc = judge_bundle.evaluator_document(evaluator, endpoint=endpoint, impl_hash=impl_hash)
+ area = mirror_area_dir(None, t.account, t.case, EVALUATIONS_AREA)
+ return _Prepared(
+ evaluator=evaluator,
+ ref=ref,
+ subject_run_id=manifest.run_id,
+ subject_bundle_id=manifest.bundle_id,
+ rubric=rubric,
+ doc=doc,
+ config_hash=evaluator_config_hash(parse_evaluator(doc)),
+ area=area,
+ )
+
+
+def _primary_exists(prep: _Prepared) -> bool:
+ """같은 case 미러에 같은 subject run 번들·같은 평가기 설정 해시의 primary 번들이 있는가(D-L12-9 (a))."""
+ from poc_bundle.adopt import committed_scoring_bundles
+
+ for cands in committed_scoring_bundles(prep.area).values():
+ for bundle in cands:
+ manifest = bundle.manifest
+ if (
+ manifest.get("purpose") == PURPOSE_PRIMARY
+ and retry.manifest_config_hash(manifest) == prep.config_hash
+ and retry.manifest_subject(manifest)[1] == prep.subject_bundle_id
+ ):
+ return True
+ return False
+
+
+def _purpose_plan(prep: _Prepared, job: EvalJob) -> retry.RetryPlan | None:
+ """① — retry 계획(또는 None). 거부·대상 없음·primary 중복은 `_Stop`(judge 호출 0)."""
+ from poc_bundle.adopt import AdoptError
+
+ try:
+ if job.retry_of is not None:
+ plan = retry.plan_retry(
+ prep.area,
+ job.retry_of,
+ subject=prep.ref,
+ run_id=prep.subject_run_id,
+ run_bundle_id=prep.subject_bundle_id,
+ config_hash=prep.config_hash,
+ )
+ elif job.purpose is None and _primary_exists(prep):
+ raise _Stop(LegResult.failed(_judge_code(JUDGE_PRIMARY_EXISTS), HINT_PRIMARY_EXISTS))
+ else:
+ return None
+ except AdoptError:
+ leg = LegResult.failed(_judge_code(MIRROR_UNADOPTABLE), HINT_MIRROR_UNADOPTABLE)
+ raise _Stop(leg) from None
+ if isinstance(plan, retry.RetryRefusal):
+ code = f"{PUBLISH_RETRY_PREFIX}{plan.code}"
+ raise _Stop(LegResult.failed(code, HINT_RETRY.format(code=plan.code)))
+ if not plan.targets:
+ raise _Stop(LegResult.skipped(RETRY_NOTHING_TO_DO, HINT_RETRY_NOTHING))
+ return plan
+
+
+# ── 채점(②③) ───────────────────────────────────────────────────────────
+
+
+@dataclass(frozen=True)
+class _Judged:
+ """이번에 부른 판정(샘플 순)과 item 값의 바탕(샘플 → 기준 판정 전부 — retry 면 원 ok + 새 판정)."""
+
+ verdicts: tuple[SampleVerdicts, ...]
+ basis: Mapping[int, tuple[CriterionVerdict, ...]]
+
+ @property
+ def flat(self) -> list[CriterionVerdict]:
+ return [v for sample in self.verdicts for v in sample.verdicts]
+
+
+def _kept_verdict(sample_idx: int, criterion_id: str, kept: retry.Kept) -> CriterionVerdict:
+ label, passed = kept
+ return CriterionVerdict(
+ sample_idx, criterion_id, "ok", label, passed, None, None, None, None, None, None
+ )
+
+
+def _basis(
+ evaluator: RubricJudgeEvaluator, sample: SampleVerdicts, plan: retry.RetryPlan | None
+) -> tuple[CriterionVerdict, ...]:
+ if plan is None:
+ return sample.verdicts
+ fresh = {v.criterion_id: v for v in sample.verdicts}
+ kept = plan.kept.get(sample.sample_idx, {})
+ merged = [
+ fresh[c] if c in fresh else _kept_verdict(sample.sample_idx, c, kept[c])
+ for c in evaluator.criterion_ids
+ if c in fresh or c in kept
+ ]
+ return tuple(merged)
+
+
+async def _judge_rows(
+ evaluator: RubricJudgeEvaluator,
+ provider: Any,
+ rows: Sequence[Mapping[str, Any]],
+ plan: retry.RetryPlan | None,
+) -> _Judged:
+ """샘플마다 순차로 judge(retry 면 대상 기준만). 호출 실패는 판정 안의 error 행이지 예외가 아니다."""
+ samples: list[SampleVerdicts] = []
+ for row in rows:
+ idx = row["sample_idx"]
+ sample = build_eval_sample_from_result(dict(row), reference=row.get("golden"))
+ only = plan.targets[idx] if plan is not None else None
+ samples.append(await evaluator.judge(sample, provider, sample_idx=idx, only=only))
+ basis = {s.sample_idx: _basis(evaluator, s, plan) for s in samples}
+ return _Judged(tuple(samples), basis)
+
+
+def _rows_to_judge(bundle_dir: Path, plan: retry.RetryPlan | None) -> list[dict[str, Any]]:
+ rows = load_bundle_results(bundle_dir)
+ for row in rows:
+ idx = row.get("sample_idx")
+ if isinstance(idx, bool) or not isinstance(idx, int):
+ raise ValueError("run bundle results.jsonl row has no integer sample_idx")
+ if plan is not None:
+ rows = [row for row in rows if row["sample_idx"] in plan.targets]
+ if {row["sample_idx"] for row in rows} != set(plan.targets):
+ raise ValueError("retry targets are missing from the run bundle results")
+ if not rows:
+ raise ValueError("run bundle results.jsonl has no rows")
+ return rows
+
+
+async def _judge_and_write(
+ path: Path, run_id: str, prep: _Prepared, provider: Any, rows: Sequence[Mapping[str, Any]], plan
+) -> tuple[_Judged, Scored]:
+ """③ — 판정 → `verdicts.jsonl` → 레거시 모양 `evaluation.json`·`eval_details.jsonl`(텍스트 없음, 코드만)."""
+ judged = await _judge_rows(prep.evaluator, provider, rows, plan)
+ eval_dir.write_verdicts(path, judged.flat)
+ results: list[dict[str, Any]] = []
+ failures: list[dict[str, Any]] = []
+ for idx, basis in judged.basis.items():
+ try:
+ results.append({**prep.evaluator.legacy_result(basis), "sample_idx": idx})
+ except RubricVerdictIncomplete as exc:
+ failures.append({"sample_idx": idx, "error": exc.code})
+ evaluated_at = datetime.now(timezone.utc).isoformat()
+ written = await eval_dir.write_evaluation(path, run_id, prep.evaluator, results, failures)
+ return judged, Scored(path, written.evaluation_id, evaluated_at, written.aggregated)
+
+
+def _remove_partial(path: Path) -> None:
+ """끝나지 못한 평가 디렉토리를 치운다 — 치우기 실패는 원래 오류를 가리지 않게 경고 한 줄로."""
+ try:
+ eval_dir.remove_partial(path)
+ except Exception as exc: # 원래 실패가 이어서 보고된다
+ _LOG.warning("judge 평가 디렉토리를 치우지 못했다 — %s: %s", type(exc).__name__, path)
+
+
+def _contained(exc: Exception) -> str:
+ code = exception_code(LEG_PUBLISH, exc)
+ _LOG.debug("runs eval judge 경로 예외 봉쇄 — %s", code, exc_info=exc)
+ return code
+
+
+def _score(
+ path: Path, run_id: str, prep: _Prepared, job: EvalJob, bundle_dir: Path, plan
+) -> tuple[_Judged, Scored]:
+ """③ 을 돌리고, 실패면 디렉토리를 치운 뒤 `_Stop`(채점 못 함)."""
+ hint = HINT_RESCORE.format(project=job.project, run=job.run_dir_name)
+ try:
+ rows = _rows_to_judge(bundle_dir, plan)
+ return asyncio.run(_judge_and_write(path, run_id, prep, job.judge_provider, rows, plan))
+ except JudgeProviderMismatch:
+ _remove_partial(path)
+ raise _Stop(LegResult.failed(_judge_code(JUDGE_PROVIDER_MISMATCH))) from None
+ except Exception as exc: # 채점·쓰기 실패 — run 디렉토리·미러는 그대로다
+ _contained(exc)
+ _remove_partial(path)
+ detail = {PUBLISH_DETAIL_EXCEPTION: type(exc).__name__}
+ raise _Stop(LegResult.failed(PUBLISH_SCORE_FAILED, hint, detail)) from None
+
+
+# ── 발행(④⑤⑥) ──────────────────────────────────────────────────────────
+
+
+@dataclass(frozen=True)
+class _Ids:
+ evaluation_run_id: str
+ bundle_id: str
+ purpose: str
+ retry_of: str | None
+
+
+def _new_ids(job: EvalJob, plan: retry.RetryPlan | None) -> _Ids:
+ from ulid import ULID
+
+ purpose = PURPOSE_RETRY if plan is not None else (job.purpose or PURPOSE_PRIMARY)
+ retry_of = job.retry_of if plan is not None else None
+ return _Ids(str(ULID()), str(ULID()), purpose, retry_of)
+
+
+def _utc_seconds() -> str:
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def _publish(t: Target, prep: _Prepared, judged: _Judged, ids: _Ids) -> LegResult | None:
+ """⑤ — 번들을 쓰고 미러에 놓는다. 성공이면 None, 실패면 발행 다리 결과."""
+ at = _utc_seconds()
+ try:
+ drafts, payloads = judge_bundle.build(
+ prep.ref,
+ prep.subject_run_id,
+ judged.verdicts,
+ judged.basis,
+ evaluator=prep.evaluator,
+ created_at=at,
+ )
+ fields = judge_bundle.scoring_fields(
+ prep.ref,
+ evaluation_run_id=ids.evaluation_run_id,
+ bundle_id=ids.bundle_id,
+ purpose=ids.purpose,
+ retry_of=ids.retry_of,
+ at=at,
+ usage=judge_bundle.usage_of(judged.flat),
+ )
+ judge_bundle.write_and_mirror(t, fields, prep.doc, drafts, payloads, prep.rubric, prep.ref)
+ except judge_bundle.BundleWriteFailed as exc:
+ return LegResult.failed(_judge_code(f"{JUDGE_WRITE_FAILED}:{exc.rule}"))
+ except judge_bundle.BundleMirrorFailed:
+ return LegResult.failed(_judge_code(JUDGE_MIRROR_FAILED))
+ except Exception as exc: # 초안 조립 실패 — 판정은 평가 디렉토리에 남는다
+ _contained(exc)
+ rule = judge_bundle.WRITE_RULE_EXCEPTION
+ return LegResult.failed(_judge_code(f"{JUDGE_WRITE_FAILED}:{rule}"))
+ return None
+
+
+def _finish(
+ path: Path, run_id: str, prep: _Prepared, job: EvalJob, scored: Scored, ids: _Ids, bundle_id
+) -> None:
+ """⑥ `eval-run.json` — 발행 뒤에 쓴다(발행 못 했으면 `scoring_bundle_id` 는 null, §2.7)."""
+ doc = eval_dir.eval_run_document(
+ project=job.project,
+ run_id=run_id,
+ run_bundle_id=prep.subject_bundle_id,
+ evaluation_id=scored.evaluation_id,
+ evaluated_at=scored.evaluated_at,
+ evaluator_config=job.evaluator_config,
+ eval_model=prep.evaluator.judge_model,
+ purpose=ids.purpose,
+ judge={
+ "evaluation_run_id": ids.evaluation_run_id,
+ "scoring_bundle_id": bundle_id,
+ "retry_of": ids.retry_of,
+ },
+ )
+ eval_dir.write_eval_run(path, doc)
+
+
+def _ok_detail(scored: Scored, judged: _Judged, ids: _Ids) -> dict[str, Any]:
+ flat = judged.flat
+ return {
+ PUBLISH_DETAIL_EVALUATION_ID: scored.evaluation_id,
+ PUBLISH_DETAIL_EVALUATION_DIR_ID: scored.path.name,
+ PUBLISH_DETAIL_SCORING_BUNDLE_IDS: [ids.bundle_id],
+ PUBLISH_DETAIL_PURPOSE: ids.purpose,
+ PUBLISH_DETAIL_EVAL_FAILED: scored.aggregated.get("failed", 0),
+ PUBLISH_DETAIL_JUDGE_CALLS: judge_bundle.usage_of(flat)["judge_calls"],
+ PUBLISH_DETAIL_CRITERION_ERRORS: sum(1 for v in flat if v.status == "error"),
+ PUBLISH_DETAIL_EVALUATION_RUN_ID: ids.evaluation_run_id,
+ PUBLISH_DETAIL_RETRY_OF: ids.retry_of,
+ }
+
+
+def _after_scoring(
+ t: Target, job: EvalJob, prep: _Prepared, judged: _Judged, scored: Scored, run_id: str, plan
+) -> LegResult:
+ """④⑤⑥ — 채점이 끝난 뒤. 어떤 실패든 판정은 평가 디렉토리에 남는다."""
+ ids = _new_ids(job, plan)
+ path = scored.path
+ if not any(v.status == "ok" for v in judged.flat):
+ leg = LegResult.failed(
+ _judge_code(JUDGE_SKIP_ALL_FAILED), HINT_ALL_FAILED.format(path=path)
+ )
+ bundle_id = None
+ else:
+ failed = _publish(t, prep, judged, ids)
+ if failed is not None:
+ leg = LegResult.failed(failed.code, HINT_NOT_PUBLISHED.format(path=path))
+ bundle_id = None
+ else:
+ leg = LegResult.ok(_ok_detail(scored, judged, ids))
+ bundle_id = ids.bundle_id
+ try:
+ _finish(path, run_id, prep, job, scored, ids, bundle_id)
+ except Exception as exc: # 평가 기록만 못 남겼다 — 다리 경계(D-L6-2)
+ return LegResult.failed(_contained(exc), HINT_NOT_PUBLISHED.format(path=path))
+ return leg
+
+
+def score_and_publish(
+ t: Target, job: EvalJob, bundle: LegResult, bundle_dir: Path, run_id: str
+) -> tuple[Scored | None, LegResult]:
+ """case 잠금 안, 채택 run 번들이 있을 때 — ⓪ 준비 → ① purpose → ② 평가 디렉토리 → ③ 채점 → ④ 전부 실패 → ⑤ 번들 → ⑥ ok(§2.5).
+
+ 반환 = (평가 디렉토리 채점 | None, 발행 다리). 채점하지 못했으면 None 이다. `bundle` 은 L6b 와 같은 자리(쓰지 않는다).
+ """
+ try:
+ prep = _prepare(t, job, bundle_dir)
+ plan = _purpose_plan(prep, job)
+ try:
+ path = eval_dir.create_evaluation_dir(job.project, run_id, repo_root=t.repo_root)
+ except eval_dir.EvalDirError as exc:
+ hint = HINT_EVAL_DIR.format(reason=exc.reason)
+ return None, LegResult.failed(f"{PUBLISH_EVAL_DIR_PREFIX}{exc.reason}", hint)
+ judged, scored = _score(path, run_id, prep, job, bundle_dir, plan)
+ except _Stop as stop:
+ return None, stop.leg
+ except Exception as exc: # 다리 경계(D-L6-2) — judge 를 부르기 전의 예기치 않은 실패
+ return None, LegResult.failed(_contained(exc))
+ return scored, _after_scoring(t, job, prep, judged, scored, run_id, plan)
diff --git a/solar_eval/scoring_publish/judge_bundle.py b/solar_eval/scoring_publish/judge_bundle.py
new file mode 100644
index 0000000000000000000000000000000000000000..286b8c3c2c66ce80a79c9de753510be22394faea
--- /dev/null
+++ b/solar_eval/scoring_publish/judge_bundle.py
@@ -0,0 +1,396 @@
+"""rubric judge 채점 번들의 모양 — 평가기 문서·판정 행·payload·매니페스트 필드·쓰기와 미러 (계획 2026-09-28 L12 §2.4 · D-L12-5·7·8).
+
+- **평가기 문서**(`evaluator_document`) — `kind=label_model`·`source_kind=llm`, `params` 는 호출 방식·judge(provider·model·정규화
+ endpoint)·sampling·프롬프트 틀 지문·파서·실패 처리·`rubric_hash`·라벨→passed·item 파생. 지표는 item `rubric_score` 하나.
+- **`impl_hash`**(`implementation_hash`) = `json_fingerprint(["rubric-judge-impl/v1", closure_hash(["…rubric_judge"])])` — 폐포는
+ `rubric_judge` 한 파일이다(평가기 기반·provider·config 는 제외 목록). `judge_config` 는 폐포 밖이다(검토 F9).
+- **판정 행**(`build`) — 샘플마다 기준 행(rubric 순서) → item `rubric_score` 행. 행은 허용 목록 키로만 만든다 — 근거·원 응답·예외
+ 이름은 `payloads.jsonl` 레코드 `{"kind":"judge_response","value":{rationale, raw, exception}}` 에만 간다(보고서 R6).
+ JSON/JCS 로 표현할 수 없는 칸은 null 로 바꾸고 `dropped` 에 이름을 적는다 — 판정 한 건이 유료 번들 전체를 버리지 않게(F14).
+- **item 값**(`item_score`) 은 PB `derive` 가 정본이다 — 모든 기준이 ok·`passed` 있음일 때만 값, error 가 있으면 `criterion_error`,
+ 출력이 없으면 `missing_output`, 라벨 미정이 있으면 `verdict_unknown`(D-L12-5).
+
+`poc_bundle`·`core.snapshot` 은 함수 안에서만 import 한다(D-L6-14). 로그·오류 문자열에 판정 원문·근거를 싣지 않는다.
+"""
+
+from __future__ import annotations
+
+import copy
+import json
+import re
+import tempfile
+from collections.abc import Iterable, Mapping, Sequence
+from pathlib import Path
+from typing import Any
+
+from solar_eval._version import __version__
+from solar_eval.evaluators.rubric_judge import (
+ ACTOR,
+ CRITERION_ERROR,
+ ITEM_METRIC_KEY,
+ LABEL_PASSED,
+ MISSING_OUTPUT,
+ MODE,
+ ON_PARSE_FAILURE,
+ PARSER_ID,
+ PROMPT_SHA256,
+ TEMPERATURE,
+ TEMPLATE_ID,
+ VERDICT_UNKNOWN,
+ CriterionVerdict,
+ RubricJudgeEvaluator,
+ SampleVerdicts,
+)
+from solar_eval.hub_hook.types import Target
+
+#: 폐포 시작 모듈 — 평가기 문서 `impl.name` 과 같다.
+IMPL_START = "solar_eval.evaluators.rubric_judge"
+#: `impl_hash` 튜플 머리의 도메인 태그. 튜플을 바꾸면 새 태그다.
+IMPL_TAG = "rubric-judge-impl/v1"
+IMPL_VERSION = "1"
+EVALUATOR_KIND = "label_model"
+SOURCE_KIND = "llm"
+DEFAULT_ALIAS = "rubric-judge"
+PRODUCER_NAME = "solar-eval runs eval"
+PAYLOAD_KIND = "judge_response"
+#: payload 값의 세 칸(늘 있다)과 표현 불가 칸 목록 키(있을 때만).
+PAYLOAD_FIELDS = ("rationale", "raw", "exception")
+DROPPED_KEY = "dropped"
+LEVEL_CRITERION = "criterion"
+LEVEL_ITEM = "item"
+#: 반복 없음(n=1) — `params.sampling.n`.
+SAMPLING_N = 1
+#: `write_failed:<규칙>` 의 꼬리 — ScoringWriteError 에 규칙 id 가 없으면 `input`, 그 밖 예외는 `exception`.
+WRITE_RULE_INPUT = "input"
+WRITE_RULE_EXCEPTION = "exception"
+_RULE_ID = re.compile(r"^\[([A-Z]+[0-9]*)\]")
+
+#: item 지표 선언(D-L12-5) — `evaluator.py` 의 기본값 선택 키는 쓰지 않는다(SCORING §2).
+METRICS: list[dict[str, Any]] = [
+ {
+ "key": ITEM_METRIC_KEY,
+ "level": LEVEL_ITEM,
+ "score_type": "continuous",
+ "unit": "ratio",
+ "min": 0,
+ "max": 1,
+ "aggregations": ["mean"],
+ "primary": True,
+ "primary_aggregation": "mean",
+ }
+]
+
+
+class BundleWriteFailed(RuntimeError):
+ """writer 가 번들을 커밋하지 않았다 — `rule` 은 첫 규칙 id 소문자 · `input` · `exception`."""
+
+ def __init__(self, rule: str) -> None:
+ super().__init__(rule)
+ self.rule = rule
+
+
+class BundleMirrorFailed(RuntimeError):
+ """임시 번들을 미러에 놓지 못했다."""
+
+
+# ── 평가기 문서 ─────────────────────────────────────────────────────────
+
+
+def implementation_hash(roots: Any = None) -> tuple[str, list[str]]:
+ """(`impl_hash`, 정렬된 폐포 라벨). `roots`(`ImplRoots`)를 주면 그 해석 루트로 뜬다(시험이 사본을 끼운다)."""
+ from poc_bundle.canonical import json_fingerprint
+
+ from solar_eval.core.snapshot.impl_hash import closure_hash, default_roots
+
+ closure, labels = closure_hash([IMPL_START], roots or default_roots())
+ return json_fingerprint([IMPL_TAG, closure]), labels
+
+
+def rubric_hash_of(rubric: Mapping[str, Any]) -> str:
+ """writer 가 매니페스트에 싣는 값과 같은 `rubric_hash` — PB 가 검증한 모양(생략 규칙 적용)의 config 지문."""
+ from poc_bundle.scoring.ids import rubric_hash
+ from poc_bundle.scoring.rubric import parse_rubric_doc
+
+ return rubric_hash(parse_rubric_doc(rubric).to_json_dict())
+
+
+def _json_number(value: float) -> int | float:
+ return int(value) if float(value).is_integer() else value
+
+
+def evaluator_document(
+ evaluator: RubricJudgeEvaluator, *, endpoint: str, impl_hash: str
+) -> dict[str, Any]:
+ """`evaluator/v1` 문서(§2.4, 순수). `endpoint` 는 정규화 값이어야 한다(`judge_config.judge_endpoint`)."""
+ rubric = evaluator.rubric_document
+ provenance = rubric.get("provenance")
+ alias = provenance.get("alias") if isinstance(provenance, Mapping) else None
+ return {
+ "schema": "evaluator/v1",
+ "config": {
+ "kind": EVALUATOR_KIND,
+ "source_kind": SOURCE_KIND,
+ "impl": {"name": IMPL_START, "version": IMPL_VERSION, "impl_hash": impl_hash},
+ "params": {
+ "mode": MODE,
+ "judge": {
+ "provider": evaluator.judge_provider,
+ "model": evaluator.judge_model,
+ "endpoint": endpoint,
+ },
+ "sampling": {
+ "temperature": _json_number(TEMPERATURE),
+ "n": SAMPLING_N,
+ "max_tokens": evaluator.max_tokens,
+ },
+ "prompt": {"template": TEMPLATE_ID, "sha256": PROMPT_SHA256},
+ "parser": PARSER_ID,
+ "on_parse_failure": ON_PARSE_FAILURE,
+ "rubric_hash": rubric_hash_of(rubric),
+ "label_passed": dict(LABEL_PASSED),
+ "item_score": evaluator.item_aggregation,
+ },
+ "metrics": copy.deepcopy(METRICS),
+ },
+ "provenance": {
+ "alias": alias if isinstance(alias, str) and alias else DEFAULT_ALIAS,
+ "version_label": f"solar-eval {__version__}",
+ },
+ }
+
+
+# ── item 값·payload ────────────────────────────────────────────────────
+
+
+def item_score(
+ evaluator: RubricJudgeEvaluator, verdicts: Sequence[CriterionVerdict]
+) -> tuple[str, int | float | None, str | None]:
+ """(status, score, reason) — item `rubric_score`(D-L12-5). 값은 PB `derive` 로만 계산한다."""
+ if any(v.status == "error" for v in verdicts):
+ return "error", None, CRITERION_ERROR
+ if any(v.status == "skipped" for v in verdicts):
+ return "skipped", None, MISSING_OUTPUT
+ if any(v.passed is None for v in verdicts):
+ return "skipped", None, VERDICT_UNKNOWN
+ ids = [v.criterion_id for v in verdicts]
+ if sorted(ids) != sorted(evaluator.criterion_ids):
+ raise ValueError("rubric_score: verdicts must cover every criterion exactly once")
+ from poc_bundle.scoring.derivations import derive
+
+ rows = [{"status": "ok", "criterion_id": v.criterion_id, "passed": v.passed} for v in verdicts]
+ fn = evaluator.item_aggregation
+ params = {"points": evaluator.points} if fn == "points_clip@1" else None
+ value = derive(fn, rows, params)["value"]
+ if value is None:
+ raise ValueError(f"rubric_score: {fn} gave no value")
+ return "ok", value, None
+
+
+def representable(value: Any) -> bool:
+ """엄격 JSON·JCS 로 표현되는가 — writer `_require_representable` 와 같은 규칙(시험이 같은 표로 대조)."""
+ from poc_bundle.canonical import jcs_bytes
+
+ try:
+ jcs_bytes(value)
+ json.dumps(value, allow_nan=False)
+ except (ValueError, TypeError): # CanonicalizationError 는 ValueError 의 하위 클래스다.
+ return False
+ return True
+
+
+def payload_record(verdict: CriterionVerdict) -> dict[str, Any]:
+ """기준 행 하나의 payload — `{kind: judge_response, value: {rationale, raw, exception[, dropped]}}`(D-L12-8)."""
+ value: dict[str, Any] = {name: getattr(verdict, name) for name in PAYLOAD_FIELDS}
+ dropped = [
+ name
+ for name in ("rationale", "raw")
+ if value[name] is not None and not representable(value[name])
+ ]
+ for name in dropped:
+ value[name] = None
+ if dropped:
+ value[DROPPED_KEY] = dropped
+ return {"kind": PAYLOAD_KIND, "value": value}
+
+
+def usage_of(verdicts: Iterable[CriterionVerdict]) -> dict[str, int | None]:
+ """매니페스트 `usage` — judge 호출 수(출력 없음 skipped 는 호출 아님)와 응답을 받은 호출의 토큰 합.
+
+ 응답을 받은 호출 중 하나라도 토큰 수를 모르면 그 키는 null 이다(부분 합을 전체처럼 적지 않는다).
+ """
+ called = [v for v in verdicts if v.status != "skipped"]
+ answered = [v for v in called if v.exception is None]
+
+ def total(name: str) -> int | None:
+ values = [getattr(v, name) for v in answered]
+ if not values or any(v is None for v in values):
+ return None
+ return sum(values)
+
+ return {
+ "judge_calls": len(called),
+ "prompt_tokens": total("prompt_tokens"),
+ "completion_tokens": total("completion_tokens"),
+ }
+
+
+# ── 판정 행 ─────────────────────────────────────────────────────────────
+
+
+def _criterion_fields(base: Mapping[str, Any], verdict: CriterionVerdict) -> dict[str, Any]:
+ """기준 행 — 허용 목록 키만(근거·원 응답·예외는 payload 로만, §2.4 누출 방지)."""
+ fields = {**base, "level": LEVEL_CRITERION, "criterion_id": verdict.criterion_id}
+ if verdict.status == "ok":
+ fields["label"] = verdict.label
+ fields["passed"] = verdict.passed # 라벨 미정이면 None → writer 가 생략한다
+ else:
+ fields["reason"] = verdict.reason
+ fields["status"] = verdict.status
+ return fields
+
+
+def _item_fields(
+ base: Mapping[str, Any], status: str, score: int | float | None, reason: str | None
+) -> dict[str, Any]:
+ fields = {**base, "level": LEVEL_ITEM, "criterion_id": ITEM_METRIC_KEY}
+ fields["score"] = score if status == "ok" else None
+ fields["reason"] = reason
+ fields["status"] = status
+ return fields
+
+
+def build(
+ ref: Any,
+ run_id: str,
+ verdicts: Sequence[SampleVerdicts],
+ item_basis: Mapping[int, Sequence[CriterionVerdict]],
+ *,
+ evaluator: RubricJudgeEvaluator,
+ created_at: str,
+) -> tuple[list[Any], list[dict[str, Any]]]:
+ """(판정 초안, payload 레코드) — 샘플 오름차순, 샘플마다 기준 행 → item 행(§2.4 표).
+
+ `item_basis[sample_idx]` 는 item 값을 계산할 기준 판정 전부다 — 첫 채점이면 그 샘플의 판정, retry 면 원 ok 판정 + 새 판정.
+ `ref` 는 채택 run 번들의 `RunBundleRef`(`items` 로 `input_key`·`golden_key` 를 찾는다).
+ """
+ from poc_bundle.scoring.writer import JudgmentDraft
+
+ drafts: list[Any] = []
+ payloads: list[dict[str, Any]] = []
+ for sample in sorted(verdicts, key=lambda s: s.sample_idx):
+ keys = ref.items.get(sample.sample_idx)
+ if keys is None:
+ raise ValueError(f"sample_idx {sample.sample_idx} is not in the run bundle items")
+ base = {
+ "run_id": run_id,
+ "sample_idx": sample.sample_idx,
+ "input_key": keys[0],
+ "golden_key": keys[1],
+ "source_kind": SOURCE_KIND,
+ "actor": ACTOR,
+ "created_at": created_at,
+ }
+ for verdict in sample.verdicts:
+ payloads.append(payload_record(verdict))
+ drafts.append(JudgmentDraft(_criterion_fields(base, verdict), len(payloads) - 1))
+ status, score, reason = item_score(evaluator, item_basis[sample.sample_idx])
+ drafts.append(JudgmentDraft(_item_fields(base, status, score, reason)))
+ return drafts, payloads
+
+
+# ── 매니페스트·쓰기 ──────────────────────────────────────────────────────
+
+
+def scoring_fields(
+ ref: Any,
+ *,
+ evaluation_run_id: str,
+ bundle_id: str,
+ purpose: str,
+ retry_of: str | None,
+ at: str,
+ usage: Mapping[str, Any] | None,
+) -> dict[str, Any]:
+ """writer 입력 매니페스트 필드(D-L12-7). case·subject 는 채택 run 번들 매니페스트에서 온다."""
+ manifest = ref.verify_report.manifest
+ if manifest is None:
+ raise ValueError("the adopted run bundle has no readable manifest")
+ return {
+ "bundle_id": bundle_id,
+ "evaluation_run_id": evaluation_run_id,
+ "id_origin": "ulid",
+ "supersedes": None,
+ "created_at": at,
+ "producer": {"name": PRODUCER_NAME, "version": __version__},
+ "case": manifest.case.model_dump(mode="json"),
+ "subject": {
+ "kind": "run",
+ "runs": [
+ {"run_id": manifest.run_id, "bundle_id": manifest.bundle_id, "role": "subject"}
+ ],
+ },
+ "purpose": purpose,
+ "retry_of": retry_of,
+ "golden_basis": "run",
+ "status": "completed",
+ "finished_at": at,
+ "reproducible": {"snapshot_complete": True, "missing": []},
+ "known_defects": [],
+ "observed": None,
+ "usage": dict(usage) if usage is not None else None,
+ }
+
+
+def _rule(issues: Sequence[str]) -> str:
+ for issue in issues:
+ match = _RULE_ID.match(issue)
+ if match:
+ return match.group(1).lower()
+ return WRITE_RULE_INPUT
+
+
+def write_and_mirror(
+ t: Target,
+ fields: Mapping[str, Any],
+ doc: Mapping[str, Any],
+ drafts: Sequence[Any],
+ payloads: Sequence[Mapping[str, Any]],
+ rubric: Mapping[str, Any],
+ ref: Any,
+) -> Path:
+ """임시 디렉토리에 쓴 뒤(writer 가 E1~E8 까지 커밋 전에 본다) 미러 `evaluations` 에 놓는다 — 선례 `rescore.from_bundle`.
+
+ Raises:
+ BundleWriteFailed: writer 가 커밋하지 않았다. BundleMirrorFailed: 미러에 놓지 못했다.
+ """
+ from poc_bundle.mirror import MirrorError, mirror_bundle
+ from poc_bundle.scoring.writer import ScoringWriteError, write_scoring_bundle
+
+ from solar_eval.rescore.paths import EVALUATIONS_AREA
+
+ with tempfile.TemporaryDirectory(prefix="solar-eval-judge-") as tmp:
+ staged = Path(tmp) / str(fields["bundle_id"])
+ try:
+ write_scoring_bundle(
+ staged,
+ fields=fields,
+ evaluator=doc,
+ drafts=drafts,
+ payloads=payloads,
+ rubric=rubric,
+ run_bundle=ref,
+ )
+ except ScoringWriteError as exc:
+ raise BundleWriteFailed(_rule(exc.issues)) from exc
+ except OSError as exc:
+ raise BundleWriteFailed(WRITE_RULE_EXCEPTION) from exc
+ try:
+ return mirror_bundle(
+ staged,
+ account=t.account,
+ case=t.case,
+ area=EVALUATIONS_AREA,
+ repo_root=t.repo_root,
+ )
+ except (MirrorError, OSError) as exc:
+ raise BundleMirrorFailed(type(exc).__name__) from exc
diff --git a/solar_eval/scoring_publish/publish.py b/solar_eval/scoring_publish/publish.py
new file mode 100644
index 0000000000000000000000000000000000000000..6ed774ce6b87c82c6beabfeede3ea6c035b2e0c4
--- /dev/null
+++ b/solar_eval/scoring_publish/publish.py
@@ -0,0 +1,463 @@
+"""`runs eval` 새 경로 — 번들 확인 → 평가 디렉토리 채점 → eval-wrap → hub (계획 2026-09-27 L6 §2.12 B4 · D-M2-6 · C-M2-10).
+
+**경로 판정(`decide_route`).** 새 경로 = hub 대상(`hub_hook.target.resolve_target` → `Target`) ∧ 태스크 평가기 `lcs_diff` ∧ run 이
+lcs_diff 계열(`bundle_leg.is_proofread_family` — 스냅샷 평가기 type, 스냅샷이 없는 옛 run 은 평가 파일 계열) ∧ run.json
+`status == "completed"`. 아니면 오늘 동작(run 디렉토리 in-place) + 안내 한 줄 — standalone 은 조용히. hub 대상 판정 자체가
+예외를 내면 멈춘다(run 디렉토리를 고치기 전에 — lock·case 설정을 고친 뒤 다시). 옛 동작으로 가던 탈출구 플래그는 L10 컷오버에서
+지웠다(계획 2026-09-28 L10 §2.6 · D-L10-7 — 레거시 화면이 사라져 hub 대상 run 디렉토리를 고칠 이유가 없다).
+
+**judge 가지(계획 2026-09-28 L12 D-L12-6).** 앞선 검사들 뒤 태스크 평가기가 `rubric_judge` 면 run 계열은 보지 않고 완료 상태만 본다 →
+`Route(family=FAMILY_JUDGE)`. 새 경로의 ②③(채점·발행)은 `scoring_publish.judge.score_and_publish` 가 맡고, 발행 거부면 hub 다리를 돌지 않는다.
+
+**새 경로(`publish_run_eval`) — 전부 case 잠금 안(`hub_hook.lock.case_lock`).**
+
+1. 번들 확인 — `bundle_leg.ensure_run_bundle(target, run_id)`. wrap 이 written·reused·rewrapped_input_fields 가 아니면(동결·오류)
+ `publish_no_bundle:<번들 코드>` 실패 — **채점하지 않는다**(hub 도 `no_bundle`).
+2. 채점 — 평가 디렉토리(`eval_dir`, D-L6-15)를 **채점 전에** 만든다 — 이름 조각·레포 밖 검사가 설정 오류를 채점 비용 없이
+ 잡는다(`publish_eval_dir:`, 다시 돌려도 같으므로 재실행 대신 데이터 홈을 고치라고 안내). 그다음 채택된 run 번들의
+ `results.jsonl`(last-wins 어댑터가 정규화한 사본, 검토 F12)을 `score_all` 로 → 그 디렉토리에 `evaluation.json`·
+ `eval_details.jsonl`·`eval-run.json`. run.json 은 쓰지 않는다. 실패는 `publish_score_failed`(타입 이름만 detail 에) —
+ 끝나지 못한 평가 디렉토리는 치운다.
+3. 발행 — `eval_wrap_run_bundle(<미러 run 번들>, <미러 evaluations>, evaluation_dir=…, purpose="rescore_after_fix",
+ judged_at=<평가 시각>)`. 건너뜀·오류는 `publish_eval_wrap:<코드>`(점수는 평가 디렉토리에 남는다). **전 샘플 채점 실패**
+ (`overall_score` None)는 발행하지 않는다(`publish_eval_wrap:skip:all_failed`) — 측정 없는 `rescore_after_fix` 번들이
+ hub 대표 채점본(`evaluation_default` — 최신 `finished_at`)을 차지해 유효한 첫 채점을 가리지 않게. 실패 행은 평가 디렉토리에 남는다.
+4. hub — `hook.gated_hub_leg(target, [run_id])`(opt-in·워크스페이스 게이트·예외 봉쇄, L6a 와 같은 함수). `--no-ingest` 면 `no_ingest`.
+ 번들이 있으면 발행이 실패해도 돈다(훅과 같은 규칙, D-L6-2).
+5. 상태 파일 `status.write_status(target, result, "eval", strict=…, secrets=…)` — 설정 출처 비밀(`hook.status_secrets`)도
+ 거부 목록에 넣는다(L10 §2.6 "비밀"). 쓰기 실패는 경고로 강등.
+
+다리마다 예기치 않은 예외는 `_exception:` 으로 강등하고(D-L6-2) 트레이스백은 이 모듈 로거로만 남긴다. 코드 문자열은
+`hub_hook.types` 한 곳의 것을 쓴다(검토 F11). `poc_bundle`·`solar_eval.rescore` 는 함수 안에서만 import 한다(D-L6-14).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from collections.abc import Awaitable, Callable, Mapping
+from contextlib import ExitStack
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+import click
+
+from solar_eval.hub_hook import bundle_leg, console, hook, lock, optin, status
+from solar_eval.hub_hook import target as hub_target
+from solar_eval.hub_hook.types import (
+ COMMAND_EVAL,
+ EVAL_WRAP_SKIP_PREFIX,
+ HOOK_LOCK_TIMEOUT_SECONDS,
+ LEG_BUNDLE,
+ LEG_HUB,
+ LEG_LOCK,
+ LEG_PUBLISH,
+ LEG_STATUS_FILE,
+ LOCK_TIMEOUT,
+ NO_BUNDLE,
+ NO_INGEST,
+ PROOFREAD_EVALUATOR_TYPE,
+ PUBLISH_EVAL_WRAP_PREFIX,
+ PUBLISH_DETAIL_EVAL_FAILED,
+ PUBLISH_DETAIL_EVALUATION_DIR_ID,
+ PUBLISH_DETAIL_EVALUATION_ID,
+ PUBLISH_DETAIL_EXCEPTION,
+ PUBLISH_DETAIL_PURPOSE,
+ PUBLISH_DETAIL_SCORING_BUNDLE_IDS,
+ PUBLISH_NO_BUNDLE_PREFIX,
+ PUBLISH_SCORE_FAILED,
+ SKIP_STANDALONE,
+ STEP_BACKFILL,
+ HookResult,
+ LegResult,
+ Skip,
+ Target,
+ bundle_ready,
+ exception_code,
+ hub_run_id,
+)
+from solar_eval.evaluators.judge_config import RUBRIC_JUDGE_TYPE
+from solar_eval.scoring_publish import eval_dir
+
+_LOG = logging.getLogger(__name__)
+_RESULTS = "results.jsonl"
+#: run.json 의 완료 상태 — 이 값이 아니면 옛 동작이 status 를 되살린다(Q-L6-15).
+RUN_COMPLETED = "completed"
+
+NOTICE_NOT_TARGET = "hub 대상이 아니다({code}) — run 디렉토리에 평가를 쓴다(오늘 동작)"
+NOTICE_NOT_PROOFREAD = (
+ "이 평가기({kind})는 M2 채점 번들 생산자가 없다 — run 디렉토리에 평가를 쓴다"
+ "(오늘 동작, 다음 백필이 run 번들을 다시 wrap 한다)"
+)
+NOTICE_INCOMPLETE = "미완료 run(status={status})은 옛 동작으로 평가해 status 를 되살린다 — 다음 백필이 run 번들을 다시 wrap 한다"
+#: hub 대상 판정 실패 — 멈춘다(D-L10-7). 예외 원문은 싣지 않는다(타입 이름만).
+ERROR_RESOLVE_FAILED = (
+ "hub 대상 판정 실패({kind}) — run 디렉토리는 그대로다. "
+ "lock·case 설정을 고친 뒤 다시(make check-registry)"
+)
+_RUN_FAMILY_KIND = "run 이 lcs_diff 계열이 아니다"
+
+# 아래 둘은 hub_hook/types.py 에 아직 없다(L6b 에서 hub_hook/** 는 읽기만) — l6b-verify 가 types.py 로 옮길 후보.
+#: 평가 디렉토리 설정·입력 오류 — `publish_eval_dir:`(채점 전에 멈춘다).
+PUBLISH_EVAL_DIR_PREFIX = "publish_eval_dir:"
+#: 전 샘플 채점 실패 — `publish_eval_wrap:skip:all_failed`(평가 디렉토리는 남기고 발행하지 않는다).
+EVAL_SKIP_ALL_FAILED = "all_failed"
+HINT_ALL_FAILED = "전 샘플 채점 실패 — 발행하지 않았다(hub 대표 채점본 유지). 실패 사유: {path}/eval_details.jsonl"
+HINT_EVAL_DIR_INSIDE_REPO = (
+ "평가 디렉토리 루트({root})가 레포 안이다 — CBS_DATA_HOME 이나 solar-eval-evaluations 링크를 레포 밖으로 고친다"
+ "(다시 돌려도 같다)"
+)
+HINT_EVAL_DIR = (
+ "평가 디렉토리를 만들거나 쓸 수 없다({reason}) — 입력·설정 오류라 다시 돌려도 같다. "
+ "CBS_DATA_HOME·solar-eval-evaluations 를 고친다"
+)
+#: 번들 없음 — 채점하지 않는다. `{backfill}` = `make hub-backfill …`(부류 확인).
+HINT_NO_BUNDLE = "{backfill} 로 부류 확인 — 번들이 없으면 채점하지 않는다(run 디렉토리는 그대로다)"
+
+#: judge 가지의 번들 없음 안내 — 채택된 run 번들이 없으면 judge 채점 번들도 없다. 백필로 부류를 확인하고 다시 돌린다.
+HINT_JUDGE_NO_BUNDLE = (
+ "채택된 run 번들이 없다 — {command} 로 부류를 확인한 뒤 runs eval 을 다시 돌린다"
+)
+
+ScoreAll = Callable[[Any, Any, list[dict[str, Any]]], Awaitable[tuple[list[dict], list[dict]]]]
+
+#: 새 경로의 채점 계열(계획 2026-09-28 L12 D-L12-6) — lcs_diff 교열(L6b eval-wrap)과 rubric judge(`scoring_publish.judge`).
+FAMILY_PROOFREAD = "proofread"
+FAMILY_JUDGE = "judge"
+
+
+# ── 경로 판정 ───────────────────────────────────────────────────────────
+
+
+@dataclass(frozen=True)
+class Route:
+ """새 경로면 `target`, 아니면 None + 안내 한 줄(standalone 은 None — 조용히, `warning` 이면 경고색). `family` 는 새 경로의 채점 계열."""
+
+ target: Target | None
+ notice: str | None = None
+ warning: bool = False
+ family: str = FAMILY_PROOFREAD
+
+
+def evaluator_type(config: Any) -> str | None:
+ """태스크 평가기 type — 키가 없으면 기본 평가기(`run_eval._make_evaluator` 와 같은 규칙)."""
+ from solar_eval.evaluators.registry import DEFAULT_EVALUATOR_CONFIG
+
+ raw = DEFAULT_EVALUATOR_CONFIG if config is None else config
+ kind = raw.get("type") if isinstance(raw, Mapping) else None
+ return kind if isinstance(kind, str) and kind else None
+
+
+def decide_route(
+ cfg: Any,
+ project: str,
+ run_dir: Path,
+ run_meta: Mapping[str, Any],
+ task_config: Mapping[str, Any],
+) -> Route:
+ """새 경로인가(모듈 설명). hub 대상 판정이 예외를 내면 멈춘다(run 디렉토리를 고치기 전에 — D-L10-7)."""
+ try:
+ resolved = hub_target.resolve_target(cfg, project)
+ except Exception as exc: # 판정 실패를 "대상 아님" 으로 숨겨 run 디렉토리를 고치지 않는다
+ _LOG.debug("hub 대상 판정 실패", exc_info=exc)
+ raise click.ClickException(ERROR_RESOLVE_FAILED.format(kind=type(exc).__name__)) from None
+ if isinstance(resolved, Skip):
+ notice = (
+ None
+ if resolved.code == SKIP_STANDALONE
+ else NOTICE_NOT_TARGET.format(code=resolved.code)
+ )
+ return Route(None, notice)
+ kind = evaluator_type(task_config.get("evaluator"))
+ if kind == RUBRIC_JUDGE_TYPE:
+ return _judge_route(resolved, run_meta)
+ if kind != PROOFREAD_EVALUATOR_TYPE:
+ return Route(None, NOTICE_NOT_PROOFREAD.format(kind=kind))
+ if not bundle_leg.is_proofread_family(Path(run_dir)):
+ return Route(None, NOTICE_NOT_PROOFREAD.format(kind=_RUN_FAMILY_KIND))
+ run_status = run_meta.get("status")
+ if run_status != RUN_COMPLETED:
+ return Route(None, NOTICE_INCOMPLETE.format(status=run_status))
+ return Route(resolved)
+
+
+def _judge_route(resolved: Target, run_meta: Mapping[str, Any]) -> Route:
+ """rubric judge 가지(D-L12-6) — 앞선 검사 뒤, run 계열은 보지 않고 완료 상태만 본다. 미완료는 오늘 동작 + 안내."""
+ run_status = run_meta.get("status")
+ if run_status != RUN_COMPLETED:
+ return Route(None, NOTICE_INCOMPLETE.format(status=run_status))
+ return Route(resolved, family=FAMILY_JUDGE)
+
+
+# ── 새 경로 ─────────────────────────────────────────────────────────────
+
+
+@dataclass(frozen=True)
+class EvalJob:
+ """새 경로 입력. `score_all` 은 `run_eval._score_all`(샘플별 채점 — 순환 import 를 피해 주입한다).
+
+ `family`·`purpose`·`retry_of` 는 judge 경로(L12 D-L12-9)만 쓴다 — 기본값이라 lcs 경로 호출·시험은 그대로다.
+ """
+
+ project: str
+ run_dir_name: str
+ evaluator: Any
+ evaluator_config: Mapping[str, Any] | None
+ judge_provider: Any
+ score_all: ScoreAll
+ no_ingest: bool = False
+ strict: bool = False
+ family: str = FAMILY_PROOFREAD
+ purpose: str | None = None
+ retry_of: str | None = None
+
+
+@dataclass(frozen=True)
+class Scored:
+ """평가 디렉토리에 남은 채점 — `aggregated` 는 콘솔 보고용(`overall_score`·`failed` 포함)."""
+
+ path: Path
+ evaluation_id: str
+ evaluated_at: str
+ aggregated: Mapping[str, Any]
+
+
+@dataclass(frozen=True)
+class PublishOutcome:
+ """훅 결과(상태 파일과 같은 다리들)와 채점(없으면 None — 채점하지 못했다)."""
+
+ result: HookResult
+ scored: Scored | None
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def _contained_code(leg: str, exc: Exception) -> str:
+ code = exception_code(leg, exc)
+ level = logging.WARNING if optin.hook_debug_requested() else logging.DEBUG
+ _LOG.log(level, "runs eval 발행 예외 봉쇄 — %s", code, exc_info=exc)
+ return code
+
+
+def publish_run_eval(t: Target, job: EvalJob) -> PublishOutcome:
+ """case 잠금 안에서 ①~⑤(모듈 설명). 잠금 한도를 넘기면 다리를 돌지 않는다(`lock_timeout`)."""
+ started = _now()
+ scored: Scored | None = None
+ with ExitStack() as stack:
+ try:
+ held = stack.enter_context(
+ lock.case_lock(t.account, t.case, timeout=HOOK_LOCK_TIMEOUT_SECONDS)
+ )
+ except Exception as exc: # 잠금 파일을 만들 수 없다 — 다리를 돌리지 않는다
+ legs = _lock_failed(_contained_code(LEG_LOCK, exc))
+ else:
+ if held:
+ legs, scored = _legs_in_lock(t, job)
+ else:
+ legs = _lock_failed(LOCK_TIMEOUT)
+ result = HookResult(job.run_dir_name, t.case_ref, legs, started, _now())
+ _write_status(t, result, job.strict)
+ console.echo_summary(result, job.project)
+ return PublishOutcome(result, scored)
+
+
+def _lock_failed(code: str) -> dict[str, LegResult]:
+ return {
+ LEG_LOCK: LegResult.failed(code),
+ LEG_BUNDLE: LegResult.not_run(),
+ LEG_PUBLISH: LegResult.not_run(),
+ LEG_HUB: LegResult.not_run(),
+ }
+
+
+def _legs_in_lock(t: Target, job: EvalJob) -> tuple[dict[str, LegResult], Scored | None]:
+ bundle, bundle_dir = _ensure_bundle(t, job.run_dir_name)
+ if bundle_dir is None or not bundle_ready(bundle):
+ code = bundle.code if bundle.is_failed else NO_BUNDLE
+ hint = HINT_NO_BUNDLE.format(
+ backfill=console.hub_backfill_command(t.case_ref, job.run_dir_name)
+ )
+ # judge 가지는 탈출 플래그를 권하지 않는다(L12 §2.5 · M-L12-14).
+ if job.family == FAMILY_JUDGE:
+ hint = HINT_JUDGE_NO_BUNDLE.format(
+ command=console.hub_backfill_command(t.case_ref, job.run_dir_name)
+ )
+ publish = LegResult.failed(f"{PUBLISH_NO_BUNDLE_PREFIX}{code}", hint)
+ return {
+ LEG_BUNDLE: bundle,
+ LEG_PUBLISH: publish,
+ LEG_HUB: LegResult.skipped(NO_BUNDLE),
+ }, None
+ # 번들의 run_id(run.json) — hub `--expect-run`·평가 디렉토리 이름. 디렉토리 이름과 다를 수 있다(l6a 리뷰 지적 3).
+ run_id = hub_run_id(bundle, job.run_dir_name)
+ if job.family == FAMILY_JUDGE:
+ return _judge_legs(t, job, bundle, bundle_dir, run_id)
+ scored, publish = _score_and_publish(t, job, bundle, bundle_dir, run_id)
+ hub = LegResult.skipped(NO_INGEST) if job.no_ingest else hook.gated_hub_leg(t, (run_id,))
+ return {LEG_BUNDLE: bundle, LEG_PUBLISH: publish, LEG_HUB: hub}, scored
+
+
+def _judge_legs(
+ t: Target, job: EvalJob, bundle: LegResult, bundle_dir: Path, run_id: str
+) -> tuple[dict[str, LegResult], Scored | None]:
+ """judge 경로의 ②③ + hub(§2.5 · 검토 F8). 발행 거부·`retry_nothing_to_do` 면 새 번들이 없어 hub 다리를 돌지 않는다.
+
+ `judge` 는 함수 안에서 import 한다 — 그 모듈이 이 모듈의 `EvalJob`·`Scored` 를 쓴다(순환 방지).
+ """
+ from solar_eval.scoring_publish import judge
+
+ scored, publish = judge.score_and_publish(t, job, bundle, bundle_dir, run_id)
+ if judge.is_refusal(publish):
+ hub = LegResult.skipped(judge.PUBLISH_REFUSED)
+ elif job.no_ingest:
+ hub = LegResult.skipped(NO_INGEST)
+ else:
+ hub = hook.gated_hub_leg(t, (run_id,))
+ return {LEG_BUNDLE: bundle, LEG_PUBLISH: publish, LEG_HUB: hub}, scored
+
+
+def _ensure_bundle(t: Target, run_dir_name: str) -> tuple[LegResult, Path | None]:
+ try:
+ return bundle_leg.ensure_run_bundle(t, run_dir_name)
+ except Exception as exc: # 다리 경계(D-L6-2) — 번들 유무를 모르므로 채점하지 않는다
+ return LegResult.failed(_contained_code(LEG_BUNDLE, exc)), None
+
+
+def _score_and_publish(
+ t: Target, job: EvalJob, bundle: LegResult, bundle_dir: Path, run_id: str
+) -> tuple[Scored | None, LegResult]:
+ """② 채점 → ③ eval-wrap. 채점을 못 하면 (None, `publish_eval_dir:` | `publish_score_failed`)."""
+ backfill = bundle.detail.get(STEP_BACKFILL)
+ bundle_id = backfill.get("bundle_id") if isinstance(backfill, Mapping) else None
+ try:
+ scored = asyncio.run(
+ _score_into_dir(t, job, bundle_dir, run_id, str(bundle_id or bundle_dir.name))
+ )
+ except eval_dir.EvalDirError as exc: # 설정·입력 오류 — 재실행이 아니라 고칠 곳을 안내한다
+ _contained_code(LEG_PUBLISH, exc)
+ return None, LegResult.failed(f"{PUBLISH_EVAL_DIR_PREFIX}{exc.reason}", _eval_dir_hint(exc))
+ except Exception as exc: # 채점·쓰기 실패 — run 디렉토리는 그대로다
+ _contained_code(LEG_PUBLISH, exc)
+ hint = f"solar-eval runs eval {job.project} -r {job.run_dir_name} 를 다시"
+ detail = {PUBLISH_DETAIL_EXCEPTION: type(exc).__name__}
+ return None, LegResult.failed(PUBLISH_SCORE_FAILED, hint, detail)
+ return scored, _eval_wrap(t, job, bundle_dir, scored)
+
+
+def _eval_dir_hint(exc: eval_dir.EvalDirError) -> str:
+ if exc.reason == eval_dir.REASON_INSIDE_REPO:
+ return HINT_EVAL_DIR_INSIDE_REPO.format(root=eval_dir.evaluations_root())
+ return HINT_EVAL_DIR.format(reason=exc.reason)
+
+
+def load_bundle_results(bundle_dir: Path) -> list[dict[str, Any]]:
+ """run 번들의 `results.jsonl`(정규화 사본) — 빈 줄은 건너뛰고, 객체가 아닌 줄은 `ValueError`(줄 번호만)."""
+ rows: list[dict[str, Any]] = []
+ text = (Path(bundle_dir) / _RESULTS).read_text("utf-8")
+ for number, line in enumerate(text.splitlines(), start=1):
+ if not line.strip():
+ continue
+ row = json.loads(line)
+ if not isinstance(row, dict):
+ raise ValueError(f"run bundle {_RESULTS}: line {number} is not an object")
+ rows.append(row)
+ return rows
+
+
+async def _score_into_dir(
+ t: Target, job: EvalJob, bundle_dir: Path, run_id: str, bundle_id: str
+) -> Scored:
+ results = load_bundle_results(bundle_dir)
+ if not results:
+ raise ValueError(f"run bundle {_RESULTS} has no rows")
+ # 채점 **전에** 만든다 — 이름 조각·레포 밖 검사(EvalDirError)가 채점 비용 없이 멈춘다(리뷰 지적 2).
+ path = eval_dir.create_evaluation_dir(job.project, run_id, repo_root=t.repo_root)
+ try:
+ eval_results, eval_failures = await job.score_all(
+ job.evaluator, job.judge_provider, results
+ )
+ evaluated_at = datetime.now(timezone.utc).isoformat()
+ written = await eval_dir.write_evaluation(
+ path, run_id, job.evaluator, eval_results, eval_failures
+ )
+ doc = eval_dir.eval_run_document(
+ project=job.project,
+ run_id=run_id,
+ run_bundle_id=bundle_id,
+ evaluation_id=written.evaluation_id,
+ evaluated_at=evaluated_at,
+ evaluator_config=job.evaluator_config,
+ eval_model=job.evaluator.judge_model,
+ )
+ eval_dir.write_eval_run(path, doc)
+ except BaseException:
+ _remove_partial(path)
+ raise
+ return Scored(path, written.evaluation_id, evaluated_at, written.aggregated)
+
+
+def _remove_partial(path: Path) -> None:
+ """끝나지 못한 평가 디렉토리를 치운다 — 치우기 실패는 원래 오류를 가리지 않게 경고 한 줄로."""
+ try:
+ eval_dir.remove_partial(path)
+ except Exception as exc: # 원래 예외가 이어서 올라간다
+ console.echo_warning(
+ f"끝나지 못한 평가 디렉토리를 치우지 못했다 — {type(exc).__name__}: {path}"
+ )
+
+
+def _eval_wrap(t: Target, job: EvalJob, bundle_dir: Path, scored: Scored) -> LegResult:
+ """③ 평가 디렉토리 → 채점 번들(`purpose=rescore_after_fix`, 판정 시각 = 평가 시각 — 검토 F13).
+
+ 전 샘플 채점 실패면 발행하지 않는다(모듈 설명 3, 리뷰 지적 1). import 도 다리 경계 안이다 — 깨진 설치가
+ 상태 파일 없이 트레이스백으로 새지 않게(D-L6-2, 리뷰 지적 3).
+ """
+ if scored.aggregated.get("overall_score") is None:
+ code = f"{PUBLISH_EVAL_WRAP_PREFIX}{EVAL_WRAP_SKIP_PREFIX}{EVAL_SKIP_ALL_FAILED}"
+ return LegResult.failed(code, HINT_ALL_FAILED.format(path=scored.path))
+ hint = f"solar-eval runs eval {job.project} -r {job.run_dir_name} 를 다시 (평가: {scored.path})"
+ try:
+ from poc_bundle.backfill.classes import eval_wrap_error_code
+ from poc_bundle.evalwrap import legacy
+
+ from solar_eval.rescore.paths import EVALUATIONS_AREA, mirror_area_dir
+ except Exception as exc: # 다리 경계(D-L6-2) — 점수는 평가 디렉토리에 남는다
+ return LegResult.failed(_contained_code(LEG_PUBLISH, exc), hint)
+ try:
+ out = mirror_area_dir(None, t.account, t.case, EVALUATIONS_AREA)
+ paths = legacy.eval_wrap_run_bundle(
+ bundle_dir,
+ out,
+ evaluation_dir=scored.path,
+ purpose=legacy.PURPOSE_RESCORE_AFTER_FIX,
+ judged_at=scored.evaluated_at,
+ )
+ except legacy.EvalWrapSkip as exc:
+ return LegResult.failed(
+ f"{PUBLISH_EVAL_WRAP_PREFIX}{EVAL_WRAP_SKIP_PREFIX}{exc.reason}", hint
+ )
+ except legacy.EvalWrapError as exc:
+ return LegResult.failed(f"{PUBLISH_EVAL_WRAP_PREFIX}{eval_wrap_error_code(str(exc))}", hint)
+ except Exception as exc: # 다리 경계(D-L6-2) — 점수는 평가 디렉토리에 남는다
+ return LegResult.failed(_contained_code(LEG_PUBLISH, exc), hint)
+ return LegResult.ok(
+ {
+ PUBLISH_DETAIL_EVALUATION_ID: scored.evaluation_id,
+ PUBLISH_DETAIL_EVALUATION_DIR_ID: scored.path.name,
+ PUBLISH_DETAIL_SCORING_BUNDLE_IDS: [Path(p).name for p in paths],
+ PUBLISH_DETAIL_PURPOSE: legacy.PURPOSE_RESCORE_AFTER_FIX,
+ PUBLISH_DETAIL_EVAL_FAILED: scored.aggregated.get("failed", 0),
+ }
+ )
+
+
+def _write_status(t: Target, result: HookResult, strict: bool) -> None:
+ try:
+ status.write_status(t, result, COMMAND_EVAL, strict=strict, secrets=hook.status_secrets(t))
+ except Exception as exc: # 상태 파일은 보조 기록 — 쓰기 실패는 경고로 강등(D-L6-2)
+ console.echo_status_write_failed(_contained_code(LEG_STATUS_FILE, exc))
diff --git a/solar_eval/scoring_publish/retry.py b/solar_eval/scoring_publish/retry.py
new file mode 100644
index 0000000000000000000000000000000000000000..bf225c9bc9041691225f94403a08c3eb885ee2b1
--- /dev/null
+++ b/solar_eval/scoring_publish/retry.py
@@ -0,0 +1,188 @@
+"""`runs eval --retry-of ` — error 판정만 다시 부르는 계획 (계획 2026-09-28 L12 §2.6 · D-L12-9).
+
+`plan_retry` 는 judge 를 부르기 전에 원 평가 실행을 찾아 거부하거나(`RetryRefusal(code)`) 대상을 돌려준다(`RetryPlan`):
+
+1. 같은 case 미러 `evaluations/` 에서 `retry_of` 의 최신 번들 — 없으면 `retry_target_not_found`.
+2. 매니페스트: `purpose == "retry"` → `retry_of_retry` · subject(run_id·bundle_id)가 이 run 의 채택 번들과 다르면 `retry_subject_mismatch`
+ (옛 run 번들을 채점한 원 실행 — hub 가 `stale_subject` 로 뺀다) · `evaluator.config_hash` 가 다르면 `retry_evaluator_mismatch`
+ (설정이 바뀌었으면 retry 가 아니라 새 primary).
+3. 채택 run 번들을 subject 로 `verify_scoring_bundle` — 실패면 `retry_target_invalid`.
+4. 대상 = 원 번들의 `level=criterion ∧ status=error` 행 − 같은 `retry_of` 의 **유효한** retry 번들이 같은 `target_sig` 에 ok 행을 이미
+ 낸 것. `skipped`(출력 없음)는 대상이 아니다. `kept` = 대상이 있는 샘플의 원 ok 판정 + 이미 풀린 판정 — item 행 재계산용.
+
+구현 시 변경(계획 §2.6 대비): (a) 매니페스트 검사(2)를 verify(3)보다 먼저 한다 — 옛 subject 를 채점한 원 실행은 verify 가
+E1 로 먼저 실패해 `retry_subject_mismatch` 에 닿지 못한다. (b) `kept` 에 앞선 retry 가 푼 판정도 싣는다 — 한 샘플의 error 가 둘이고
+하나만 먼저 풀렸을 때 item 값을 원 ok 판정만으로 다시 계산하면 기준이 빠진다(hub `judgments_current` 와 같은 병합판).
+
+`poc_bundle` 은 함수 안에서만 import 한다(D-L6-14). 채택 영역을 읽을 수 없으면(`AdoptError`) 호출자에게 그대로 올린다.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+PURPOSE_RETRY = "retry"
+LEVEL_CRITERION = "criterion"
+STATUS_OK = "ok"
+STATUS_ERROR = "error"
+JUDGMENTS_FILE = "judgments.jsonl"
+
+#: 거부 사유(§2.15 — `publish_retry:<코드>`).
+RETRY_TARGET_NOT_FOUND = "retry_target_not_found"
+RETRY_TARGET_INVALID = "retry_target_invalid"
+RETRY_OF_RETRY = "retry_of_retry"
+RETRY_SUBJECT_MISMATCH = "retry_subject_mismatch"
+RETRY_EVALUATOR_MISMATCH = "retry_evaluator_mismatch"
+
+#: 기준 판정 한 건의 (label, passed) — 라벨 미정이면 passed 가 None.
+Kept = tuple[str | None, bool | None]
+
+
+@dataclass(frozen=True)
+class RetryRefusal:
+ """retry 를 할 수 없다 — judge 를 부르기 전에 멈춘다."""
+
+ code: str
+
+
+@dataclass(frozen=True)
+class RetryPlan:
+ """retry 대상(`sample_idx` → 기준 id 들, 원 행 순서)과 item 재계산용 판정(`sample_idx` → 기준 id → (label, passed))."""
+
+ targets: Mapping[int, tuple[str, ...]]
+ kept: Mapping[int, Mapping[str, Kept]] = field(default_factory=dict)
+
+ @property
+ def n_targets(self) -> int:
+ return sum(len(ids) for ids in self.targets.values())
+
+
+# ── 매니페스트 읽기(검증 전 dict — 모양이 틀리면 None) ────────────────────────
+
+
+def manifest_subject(manifest: Mapping[str, Any]) -> tuple[Any, Any]:
+ """(subject run_id, subject bundle_id) — 모양이 틀리면 (None, None)."""
+ subject = manifest.get("subject")
+ runs = subject.get("runs") if isinstance(subject, Mapping) else None
+ first = runs[0] if isinstance(runs, list) and runs else None
+ if not isinstance(first, Mapping):
+ return None, None
+ return first.get("run_id"), first.get("bundle_id")
+
+
+def manifest_config_hash(manifest: Mapping[str, Any]) -> Any:
+ evaluator = manifest.get("evaluator")
+ return evaluator.get("config_hash") if isinstance(evaluator, Mapping) else None
+
+
+def _manifest_refusal(
+ manifest: Mapping[str, Any], run_id: str, run_bundle_id: str, config_hash: str
+) -> RetryRefusal | None:
+ if manifest.get("purpose") == PURPOSE_RETRY:
+ return RetryRefusal(RETRY_OF_RETRY)
+ if manifest_subject(manifest) != (run_id, run_bundle_id):
+ return RetryRefusal(RETRY_SUBJECT_MISMATCH)
+ if manifest_config_hash(manifest) != config_hash:
+ return RetryRefusal(RETRY_EVALUATOR_MISMATCH)
+ return None
+
+
+# ── 번들 읽기 ───────────────────────────────────────────────────────────
+
+
+def _verifies(path: Path, subject: Any, run_bundle_id: str) -> bool:
+ from poc_bundle.scoring.verify import verify_scoring_bundle
+
+ return verify_scoring_bundle(path, run_bundles={run_bundle_id: subject}).ok
+
+
+def _criterion_rows(path: Path) -> list[dict[str, Any]]:
+ """검증을 통과한 번들의 기준 행(파일 순서)."""
+ from poc_bundle.canonical import load_json_strict
+ from poc_bundle.jsonl import iter_record_lines
+
+ rows = [load_json_strict(raw) for _, raw in iter_record_lines(path / JUDGMENTS_FILE)]
+ return [row for row in rows if isinstance(row, dict) and row.get("level") == LEVEL_CRITERION]
+
+
+def _resolved(
+ committed: Mapping[str, Any],
+ retry_of: str,
+ subject: Any,
+ identity: tuple[str, str, str],
+) -> dict[str, dict[str, Any]]:
+ """같은 `retry_of` 의 유효한 retry 번들이 낸 ok 기준 행 — `target_sig` → 행. 나중 번들이 이긴다(bundle_id 순)."""
+ from poc_bundle.adopt import latest
+
+ run_id, run_bundle_id, config_hash = identity
+ retries = []
+ for cands in committed.values():
+ newest = latest(cands)
+ manifest = newest.manifest
+ same = (
+ manifest.get("purpose") == PURPOSE_RETRY
+ and manifest.get("retry_of") == retry_of
+ and manifest_subject(manifest) == (run_id, run_bundle_id)
+ and manifest_config_hash(manifest) == config_hash
+ )
+ if same and _verifies(newest.path, subject, run_bundle_id):
+ retries.append(newest)
+ resolved: dict[str, dict[str, Any]] = {}
+ for bundle in sorted(retries, key=lambda c: c.bundle_id):
+ for row in _criterion_rows(bundle.path):
+ if row.get("status") == STATUS_OK:
+ resolved[row["target_sig"]] = row
+ return resolved
+
+
+def _plan(rows: list[dict[str, Any]], resolved: Mapping[str, Mapping[str, Any]]) -> RetryPlan:
+ targets: dict[int, list[str]] = {}
+ kept: dict[int, dict[str, Kept]] = {}
+ for row in rows:
+ idx, criterion_id = row["sample_idx"], row["criterion_id"]
+ if row.get("status") == STATUS_OK:
+ kept.setdefault(idx, {})[criterion_id] = (row.get("label"), row.get("passed"))
+ elif row.get("status") == STATUS_ERROR:
+ fix = resolved.get(row["target_sig"])
+ if fix is None:
+ targets.setdefault(idx, []).append(criterion_id)
+ else:
+ kept.setdefault(idx, {})[criterion_id] = (fix.get("label"), fix.get("passed"))
+ return RetryPlan(
+ targets={idx: tuple(ids) for idx, ids in targets.items()},
+ kept={idx: dict(kept.get(idx, {})) for idx in targets},
+ )
+
+
+def plan_retry(
+ area: Path,
+ retry_of: str,
+ *,
+ subject: Any,
+ run_id: str,
+ run_bundle_id: str,
+ config_hash: str,
+) -> RetryPlan | RetryRefusal:
+ """§2.6 — 거부 또는 대상. `subject` 는 채택 run 번들의 `RunBundleRef`, `area` 는 case 미러 `evaluations/`.
+
+ Raises:
+ poc_bundle.adopt.AdoptError: 영역에 채택을 믿을 수 없게 하는 번들이 있다.
+ """
+ from poc_bundle.adopt import committed_scoring_bundles, latest
+
+ committed = committed_scoring_bundles(Path(area))
+ cands = committed.get(retry_of)
+ if not cands:
+ return RetryRefusal(RETRY_TARGET_NOT_FOUND)
+ original = latest(cands)
+ refusal = _manifest_refusal(original.manifest, run_id, run_bundle_id, config_hash)
+ if refusal is not None:
+ return refusal
+ if not _verifies(original.path, subject, run_bundle_id):
+ return RetryRefusal(RETRY_TARGET_INVALID)
+ identity = (run_id, run_bundle_id, config_hash)
+ resolved = _resolved(committed, retry_of, subject, identity)
+ return _plan(_criterion_rows(original.path), resolved)