← 미리디 아카이브 / 병합 대치 정렬 문제

내용시각화 post-replace-relayout batch/ 작성 2026-08-28

테스트 케이스 제작 로직

재배치 코퍼스는 커밋된 sheetJson 폴더다. 케이스 한 벌이 무엇으로 이루어지고, 두 입구(addTestCase·importCase)가 어디서 갈라져 어디서 합류하는지, 왜 글자 축소를 끄고 렌더하는지, 그리고 굽기 전의 라벨이 왜 거짓말인지를 코드에서 정리했다.

miricanvas-iui · src/post-replace-relayout/batch 브랜치 feat/post-replace-relayout 커밋 9f1dfef7 코퍼스 96
§0

30초 요약

  • 케이스 한 벌eval-out/cases/<id>/ 폴더 하나. 폴더 이름은 언제나 design_object_id 라 한 컴포넌트에 케이스가 하나뿐이다.
  • 입구 둘addTestCase(글 + 컴포넌트 id) · importCase(이미 있는 페이로드 폴더). 갈라지는 것은 replaced.json 을 만드느냐 하나뿐이고, 렌더부터는 같은 길이다.
  • 축소를 끈다렌더 메시지의 shrinkConfigs 로 폰트를 {min:1,max:1} 에 잠근다. 못 담을 글이 「작아진 것」이 아니라 「넘친 것」으로 남아야 재배치가 고칠 것을 찾는다.
  • 굽기가 먼저tight/*·outline.json 이 없으면 판정 박스가 없어 넘침이 0 으로 잡힌다 — 가장 망가진 케이스가 SUCCESS 로 떨어진다(5628689 실측).
  • 정답은 밖expected_result.json 은 대치 결과에서 기계적으로 안 나온다. 규칙 빌더가 만들고, 채점과 같은 잣대로 다시 재서 못 넘으면 지운다.
  • 사람의 몫verdict·problems 는 기계가 채운다. 손으로 쓰는 것은 note 하나다.

이 글의 근거

브랜치 feat/post-replace-relayout · 커밋 9f1dfef7작업트리다. batch/pruneCases.tsscripts/prune-test-cases.ts 두 파일에 미커밋 변경이 있고 (git diff --stat HEAD), 이 커밋은 원격에 없다 — GitHub permalink 가 404가 되므로 코드 링크는 vscode://file/… 편집기 링크로 건다.

숫자는 2026-08-28 이 저장소의 eval-out/cases 실측이다. 문서 (docs/*.md)가 더 큰 코퍼스(119건·137건·1,177건)를 인용하는 자리가 있는데, 그건 다른 시점·다른 브랜치의 값이다. 어긋나면 여기 적힌 실측이 지금의 값이다.

§1

케이스 한 벌이란 무엇인가

뷰어(PostReplaceRelayout.tsx)는 eval-out/cases/*/case.jsonimport.meta.glob 으로 읽는다. 따로 등록하는 단계가 없다 — 폴더가 생기면 화면이 바로 집는다. 그래서 「케이스를 만든다」는 곧 이 폴더를 채운다는 뜻이고, 아래가 그 목록이다.

파일무엇누가 만드나드는 비용
page.md대치한 요약 글. 뷰어는 안 읽는다 — 사람용addTestCase / importCase
original.json대치 이전 원안 = 채점 기준DB 조회 / 받은 폴더
replaced.json대치 요청 페이로드(ThumbnailLoad)addTestCase 가 만든다
importCase 는 받는다
Supabase
rendered.json대치·렌더가 끝난 문서. 오토핏 끔 = 재배치의 입력두 함수 모두 — iframe 렌더브라우저
tight/*.json엔진이 잰 glyph extent = 채점의 판정 박스bakeTightBoxes브라우저
outline.json배경 도형의 실루엣 — 컨테이너 모양bakeOutlines네트워크
case.jsonid·variant·page·verdict·problems·notelabelCase + 사람(note)
expected_result.json정답 — 후보가 겨눌 목표선expected-result-build / 사람브라우저(검증)
*.webp눈으로 볼 때만. 뷰어는 안 읽는다(라이브 iframe)compositeBytes

이름이 헷갈리기 쉽다 — replaced 는 「대치된」이 아니다

세 파일의 관계batch/addTestCase.ts 머리말
original.json ──(대치 지시)──▶ replaced.json ──(iframe 렌더)──▶ rendered.json 원본 sheetJson 렌더 '전' 페이로드 오토핏·재배치 끝난 Doc = 원본 sheetJson + nodeTextMap = 재배치 알고리즘의 입력

replaced.json 안의 sheetJson대치가 안 된 원본 그대로다 — 넣을 텍스트는 nodeTextMap 에 따로 실린다. 이름의 «replaced» 는 대치 요청이 담겼다는 뜻이다.

eval-out/cases 는 불변 입력만 두는 자리다

eval-out/ 은 원래 .gitignore 대상인데 이 브랜치에서만 제외를 풀어 추적한다. 재생성 가능한 산출물(eval-out/runs/)을 섞지 않는다 — 재배치 결과(After 장)는 애초에 파일이 없다. 뷰어가 화면에서 그때그때 돌린다.

§2

입구가 둘이고, 렌더에서 합류한다

두 함수가 하는 일은 딱 한 군데에서만 다르다 — 대치 페이로드 (replaced.json)를 만드느냐 받느냐. 그 뒤로는 같은 함수를 부르고 같은 파일을 쓴다.

두 입구 · 한 합류점

① ADDTESTCASE — 글부터 ② IMPORTCASE — 페이로드부터 요약 markdown + 컴포넌트 id/key --text-file · --id / --key original.json + replaced.json 폴더 --from <dir> · --all <dir> Supabase 조회 · 구조 해시 매칭 matchSignatures() · variantLabel() buildContentReplacement() case id 만 해석한다 resolveCaseId() — §id 4단계 Supabase 는 uuid 일 때만 replaced.json 을 만든다 받은 것을 그대로 옮긴다 여기서 합류한다 withoutFontShrink(payload) shrinkConfigs font {min:1, max:1} preview iframe 렌더 — 브라우저 필수 rendered.json 재배치 알고리즘의 유일한 입력 그 뒤 labelCase() 가 case.json 을 쓴다 webp 2장은 --no-images 로 끈다
합류점이 하나인 것이 규율이다 — 어느 입구로 들어와도 rendered.json같은 렌더 설정으로 나온다. 받아 온 폴더에 rendered.json 이 들어 있어도 쓰지 않는다: 그건 제품 파이프라인이 오토핏을 켜고 낸 다른 문서다(§5).
addTestCase
글 하나 + 컴포넌트 하나로 페이로드부터 만든다. Supabase 와 comp_signs 시그니처가 필요하다. 원래 .claude/commands/comp-thumbnail.md 의 수동 6단계였고, 그 절차를 레포 코드로 옮긴 것이다.
importCase
남이 이미 뽑아 둔 페이로드 폴더를 케이스로 승격시킨다. 대치 페이로드를 만들지 않으니 Supabase 도 시그니처도 필요 없다 — case id 를 uuid 로만 알 때는 예외(§4).
§3

addTestCase — 글 + 컴포넌트에서 페이로드를 만든다

1. 참조 고르기 — pickRef

componentId(= design_objects.id)와 componentKey (= design_objects.uuid)는 1:1 이 아니다. 한 key 에 적재분이 여러 개 붙는다.

준 것어떻게 되나
둘 다 없다예외 — 하나는 반드시 준다
둘 다 줬다componentId 를 쓰고 경고를 찍는다
id 가 숫자가 아니다예외 — 「uuid 라면 componentKey 로 준다」
key 가 uuid 모양이 아니다예외 — 「숫자 id 라면 componentId 로 준다」
key 하나resolveDesignObjectRef어느 적재분을 골랐는지 stdout 에 찍는다

2. 시그니처 매칭 — 답이 안 나오면 케이스가 성립하지 않는다

글의 구조 해시가 컴포넌트의 구조 해시와 맞아야 대치가 성립한다. parseSummaryTree(markdown) 로 요약 트리를 만들고 buildSearchVariations 가 변형들을 편다. 매칭은 두 출처를 순서대로 본다.

matchSignatures — 두 출처, 하나의 실패

comp_signs 테이블 조회 stored
design_object_id 로 적재된 structure_hash 를 읽어 글의 변형 해시와 맞춰 본다. 맞은 것이 있으면 여기서 끝이다
doc + rlsc 로 라이브 빌드 live
createCompSignatureBuilder()design_object_sheet_jsonstructure_json 에서 시그니처를 그 자리에서 만든다. structure_jsonnull 이면 예외
매칭 실패 — 예외
「이 글로는 이 컴포넌트를 채울 수 없다」. 다른 컴포넌트를 고르거나 글을 손봐야 하므로 양쪽 해시를 다 찍는다 — 어느 쪽이 어긋났는지 사람이 보게
맞은 변형이 여럿이면 라벨로 dedup 해 하나만 남긴다 — 케이스 폴더가 하나라 여러 변형을 담을 자리가 없다. 기본은 base 이고, 없으면 매칭된 첫 번째다.

3. 변형 라벨 — variantLabel

라벨은 dump-sheet-json.ts·comp-replace-render.ts 와 같은 규칙으로 만든다. 네 조각을 + 로 잇고, 아무것도 없으면 base 다.

exp<expandedChildCount> // 자식을 몇 개 펼쳤나 (>0 일 때만) unwrap // 한 겹 벗겼나 kv // key-value 로 변환했나 (transformed) merge<mergeStep> // 몇 단계 병합했나 → 'base' · 'exp1+kv' · 'unwrap+merge2' …

4. 대치 페이로드 — 라이브러리 기본값을 그대로 쓴다

buildContentReplacement 의 두 번째 인자를 비워서 부른다 (relieveTopBias + ensureBgMargin, 여백 12). comp-match UI 기본값과 같아야 케이스가 그 화면에서 본 것과 같은 그림이 되기 때문이다.

overwrite 의 기본값이 false 인 이유

rendered.json 이 이미 있으면 멈춘다. 사람이 손본 expected_result.* 와 구워 둔 tight/* 는 어느 경우에도 안 건드리지만, 입력이 조용히 바뀌면 그것들의 채점이 뜻을 잃는다. 「이미 있다」는 것은 누군가 그 자리에 정답·판정 박스·note 를 쌓아 뒀다는 뜻이다.

§4

importCase — 폴더 이름을 추측하지 않는다

케이스 폴더 이름은 언제나 design_object_id(숫자) 다. 받은 폴더 이름은 그게 아닐 수 있으니 아래 순서로 찾고, 다 실패하면 멈춘다 — 틀린 폴더에 케이스를 만드는 것이 제일 나쁘다.

resolveCaseId() — §id 해석 순서

case id 를 찾는다 id 인자를 줬나 숫자면 그 값 · uuid 면 Supabase 로 해석 source = «id 인자» 아니오 ② 폴더 이름이 숫자 인가 그 숫자 아니오 ③ 형제 _report.json 이 폴더 stem 행이 있나 행의 id · variant Supabase 를 안 묻는 지름길 아니오 ④ 폴더 이름에 uuid 가 있나 여기서만 Supabase 를 쓴다 resolveDesignObjectRef 시그니처 있는 적재분 우선 아니오 예외 — id 를 직접 달라고 한다 찾아본 곳을 전부 적어 던진다
④ 에 «시그니처 있는 적재분 우선» 이 붙어 있는 것이 실측으로 얻은 규칙이다 — 이 옵션을 안 물리면 resolveDesignObjectRef최신 적재분을 집는데, 코퍼스가 쓰는 적재분과 다른 id 가 나온다. MOR-2018/TC 반입 준비에서 uuid 로만 아는 140 폴더 중 116건이 이미 코퍼스에 있는 컴포넌트였고, 적재분별로 sheetJson 을 대조하니 최신 적재분만 2건에서 내용이 달라 그 id 로 라벨하면 오라벨이었다.

배치에서는 id 를 먼저 선점한다

「이미 있으면 거부」는 파일 존재 검사라, 병렬로 돌리면 같은 id 를 가리키는 두 폴더가 둘 다 통과해 뒤엣것이 앞엣것을 덮는다. MOR-2018/TC 에서 실제로 6쌍이 그렇게 겹쳐 썼다. 그래서 importCases 는 배치를 돌리기 전에 모든 폴더의 id 를 순서대로 확정하고, 먼저 온 폴더가 그 자리를 선점한다.

// importCase.ts — 선점 단계 const claimed = new Map<number, string>(); for (…) { const found = await resolveCaseId(rest, dir); if (claimed.has(found.id)) { outcomes[i] = { ok: false, error: '건너뜀 — … 가 먼저 그 자리를 차지했다' }; continue; } claimed.set(found.id, dir); }

어느 글을 케이스로 남길지는 사람이 고를 일이지 실행 순서가 정할 일이 아니다. 건너뛴 목록은 보고에 남는다.

!

--concurrency 기본값은 1 이고, 올리면 크롬이 죽는다

importCase 는 폴더마다 렌더를 한 번 하고, 기본 렌더 포트는 그때마다 브라우저를 열고 닫는다. 4 로 올려 11 폴더를 돌렸더니 「Failed to open a new tab」·「browser has been closed」로 9건이 깨졌다(2026-08-26 실측). 올리려면 브라우저를 살려 두는 renderDoc 포트를 함께 넘긴다.

§5

렌더 — 축소를 끄는 레버는 하나다

오토핏을 켜면 못 담을 글이 넘침이 아니라 축소로 박힌다. 5628689 실측: 원안 38.7px → 27.1px(= 70%)에 넘침 노드 0. 그 문서를 재배치에 넣으면 후보가 고칠 것을 못 찾고, 「가독성이 떨어지게 작아졌다」는 아무도 손대지 않는다. 그래서 축소를 끈 채 굽는다.

ThumbnailLoad.fontScaleRange 는 배포된 렌더러가 읽지 않는다

공유 타입에는 fontScaleRange?: {min, max} 가 있고 주석도 default {min:0.7, max:1} 이지만, 배포된 preview 페이지가 메시지에서 읽는 필드는 shrinkConfigs 다. 번들의 수신부가 그렇게 적혀 있다.

a.shrinkConfigs !== void 0 && f(Wn(a.shrinkConfigs)) // ← fontScaleRange 는 안 읽는다

5628689 로 실측 — 같은 페이로드로 필드만 바꿔 네 번 렌더했다.

보낸 것나온 폰트판정
안 보냄27.07원안의 0.700 — 기본 오토핏
fontScaleRange {min:1, max:1}27.07안 바뀐다
fontScaleRange {min:0.4, max:1}27.07안 바뀐다
shrinkConfigs font {min:1, max:1}38.67원안 그대로 ← 우리가 쓰는 것
shrinkConfigs font {min:0.4, max:1}22.41더 줄어든다

축은 셋이고 우리가 잠그는 것은 폰트 하나다

배포된 기본 rangestep
lineSpacing{min: 1}0.05
letterSpacing{min: 0}1
font{min: 0.7, max: 1}0.98
NO_FONT_SHRINK
폰트만 {min:1, max:1} 로 잠그고 행간·자간은 배포된 기본값을 그대로 적어 보낸다 (안 보내면 축 목록 자체가 기본으로 돌아간다).

되찾으려는 것이 「원안 글자 크기」 라서다 — 행간·자간까지 잠그면 렌더러가 줄바꿈을 다르게 흘려 원안과 다른 이유로 문서가 달라진다.

지금 코퍼스에서 확인된다

96건의 rendered.jsonoriginal.json 과 노드 id 로 짝지어 fontSize 를 견주면 텍스트 745쌍 중 줄어든 것이 0개다 — 최소 폰트 배율이 전 케이스에서 1.000 이다 (2026-08-28 실측). 축소가 실제로 꺼져 있다는 뜻이고, 못 담은 글은 전부 넘침으로 남아 있다.

§6

굽기 — 판정 박스와 컨테이너 모양

렌더까지 끝나도 케이스는 아직 채점할 수 없다. 채점이 쓰는 두 값이 문서에 없기 때문이다 — 하나는 엔진만 알고, 하나는 리소스 원본만 안다.

브라우저
bakeTightBoxes → tight/<문서>.json
tight bounding box(= glyph extent)는 미리캔버스 엔진의 TextBoundingBoxCalculator 만이 값을 정의하고, 그것은 실제 폰트를 적재한 브라우저에서만 돈다. headless Chrome 에 엔진을 올려 재고 결과를 커밋할 수 있는 파일로 남긴다 — 뷰어는 DB 도 브라우저도 없이 그 파일만 읽는다.
대상
rendered.json · expected_result.json · original.json
좌표
프레임 원점 기준 — 쓰는 쪽이 노드 절대 박스를 더한다
status
ok / empty(빈 텍스트) / failed(계산 예외)
네트워크
bakeOutlines → outline.json
도형의 모양은 문서에 없다. 노드가 든 것은 리소스 key 하나뿐(props.shape.fill.resource.key)이고 실루엣은 리소스 SVG/webp 를 래스터화해 등고선을 따야 나온다. 무인증 공개 API 라 자격 증명은 필요 없다.
단위
케이스당 한 장 — 네 문서의 노드 id 가 같다
좌표
리소스 로컬 — 배치(9-slice·코너·crop·flip)는 소비 시점에
dedup
리소스 key 단위. 케이스를 넘어서도 재사용한다

After 장(재배치 결과)은 구울 수 없다

뷰어가 브라우저에서 그때그때 만드는 문서라 파일이 없다. 그 장은 rendered.json 의 tight bounding box 를 물려받되 크기가 그대로인 노드만 가져다 쓴다 (carryTightBoxes). 크기가 달라진 노드는 줄바꿈이 달라졌을 수 있어 물려받지 않고 비운다 — 없는 값을 박스로 대신 채우면 한 표 안에 두 기준이 섞인다.

굽기 전의 라벨은 거짓말이다

이것이 순서를 지켜야 하는 이유다. 판정 박스가 없으면 텍스트가 전부 「못 잰 노드」가 되고, 넘침을 세는 지표는 그 노드를 판정에서 빼므로 넘침이 0 으로 잡힌다.

5628689 를 재면판정까닭
tight/* 없이SUCCESS넘침 0 — 잰 적이 없어서 0 이다
구운 값으로FAIL넘침 3노드 54,486px² · 신규 겹침 5쌍

2026-08-28 실측 · batch/pruneCases.ts 머리말

그래서 케이스를 들인 직후 case.json 에 적힌 verdict·problems못 믿는다 — 굽고 나서 label-test-cases.ts다시 붙인다.

§7

라벨 — 케이스를 들이면 함께 붙는다

판정은 여태 사람이 따로 스크립트를 돌려야 나왔다. 케이스가 백 건을 넘고 계속 느는데 들일 때마다 손으로 확인하는 것은 곧 아무도 안 하는 일이 된다. 그래서 case.json 을 쓸 때 판정과 problems 를 같은 계산에서 채운다.

labelCase() — 케이스 폴더 하나 → 라벨 한 벌

읽는 것 (파일 4종)
original.json · rendered.json
tight/original.json · tight/rendered.json
outline.json
없으면 빈 지도 / undefined — 던지지 않는다. 「안 구운 케이스」가 정상 상태이기 때문이다
judgeDocs(rendered, tight, original, originalTight)
채점 함수는 뷰어·터미널·프루너와 같은 것 하나다
verdict.rendered
{ label, reasons[] } — 렌더본이 재배치의 입력이자 채점 대상이라 판정은 하나
verdict.rule
{ excessiveFontScale, box }무엇으로 쟀나. 없으면 이 값이 낡았는지 알 수 없다
verdict.at
잰 날(YYYY-MM-DD)
problems[]
overflow · shrink · idle-margin — 셋 다 숫자 비교다
파일에 적은 값은 정본이 아니다. 뷰어는 고른 알고리즘·채점 상자·「가독성 기준 폰트 배율」에 따라 그때그때 잰다. 여기 굽는 것은 들일 때의 before 판정 하나이고, 그것이 답하는 질문은 「이 케이스를 왜 들였나 = 재배치가 고칠 것이 있나」다.

problems 세 어휘와 그 잣대

언제 붙나상수왜 그 잣대인가
overflowbefore 넘침 면적 > 원안 넘침 면적 원안 자체에 디자이너가 의도한 오버행이 있다. 원안과의 차이가 대치의 책임이다
shrink최소 폰트 배율 < 0.995SHRUNK_BELOW 「조금이라도 줄었나」를 묻는다. 1.0 이 아닌 것은 반올림·부동소수 잡음을 걸러내려고
idle-margin유휴 여백 ≥ 0.65IDLE_MARGIN_AT_LEAST 절대값이다 — 사람이 본 것은 「늘었나」가 아니라 「남아 있나」 였다

idle-margin 이 절대값인 것도 실측 결과다

손으로 라벨한 3건(5628689 · 5825135 · 6134703)의 유휴 여백은 0.672 · 0.920 · 0.778 인데 원안 대비 증가분은 +0.001 · +0.163 · +0.000 으로 일정하지 않다.

problems.overflow 와 판정 게이트는 잣대가 다르다

판정은 넘침 노드 수를, problems면적을 원안과 견준다. 맞추지 않은 것은 답하는 질문이 달라서다 — 판정은 「몇 개가 새로 삐져나왔나」이고 problems 는 「대치가 넘침을 얼마나 키웠나」다.

사람이 준 값이 이긴다 — 그리고 그래서 낡는다

writeCaseLabel 은 이미 채워져 있는 problems그대로 둔다(keepProblems 기본 true). 눈으로 보고 붙인 라벨이 기계값에 덮이면 그 판단이 있었다는 것조차 다음 사람이 알 수 없기 때문이다. 대가가 있다.

실측

지금 코퍼스의 shrink 77건은 낡은 값이다

확인됨

96건의 rendered.json 을 지금 재면 줄어든 텍스트가 745쌍 중 0개다(§5). 그런데 case.jsonproblems 에는 shrink77건 남아 있다.

verdict 는 2026-08-27 에 box: "tight"전건 다시 구워졌는데 (96건 전부 그 날짜다) problems 만 그대로인 것은, writeCaseLabel이미 값이 있으면 안 덮기 때문이다. 렌더본을 축소 끈 판으로 다시 구웠을 때 그 값이 함께 갱신될 길이 없었다.

  • 고치려면 label-test-cases.ts --overwrite-problems 를 명시적으로 준다.
  • 기계값과 사람값을 파일에서 구분할 수 없는 것이 근본 원인이다 — verdict.rule 처럼 problems 에도 출처를 적으면 이 낙차가 보인다.
2026-08-28 · 96건건수메모
판정 FAIL85넘침·신규 겹침·과도 축소 중 하나 이상
판정 ACCEPTABLE11게이트는 넘었는데 원안만큼은 아니다
판정 SUCCESS0프루너가 이미 뺐다(§9)
problems: overflow21라벨은 들일 때의 값이라 낡을 수 있다(위 실측)
problems: shrink77
problems: idle-margin58
problems: []3기계가 채우므로 정말로 문제 없음
§8

Expected 장 — 이 파이프라인 밖에서 만든다

addTestCaseimportCase 도 이 파일을 만들지 않고, 반환값 skipped그 사실만 담아 호출자가 안내하게 한다. 새 케이스는 Expected 장이 빈 상태로 뜨는 것이 정상이다.

왜 기계적으로 안 나오나

Expected 는 「정답」이라 대치 결과에서 유도되지 않는다. 판정 규칙은 before-after-rules.md §2(불변 조건)와 evaluation-rules.md §2(판정 지표)에 있고, 그 규칙대로 문서를 짜는 것은 사람 또는 규칙 빌더의 몫이다.

정답 만들기 — 네 걸음

① build
expected-result-build.ts
규칙(§2 불변 조건 + §4 허용 변경)으로 정답을 쓴다
② 굽기
tight-box-cases.ts
--documents expected_result.json
정답의 판정 박스를 잰다
③ verify
expected-result-verify.ts
채점과 같은 잣대로 재서 못 넘으면 지운다
④ thumb
sheetjson-thumb.ts
눈으로 볼 webp
--gate 가 아니라 ③ 인가 — 빌더의 --gate 는 답을 쓰기 전에 거르는데, 그때는 정답의 tight bounding box 가 없어 프레임을 끼워 넣는다. 프레임에는 오토핏 여백이 들어 있어 넘침·겹침을 과대 보고한다 — 2026-08-26 실측 111건에서 프레임으로 ⑤ 위반 70건 vs 구운 tight 로 55건, 즉 16건이 억울했다. 그래서 순서를 바꿨다: 일단 쓰고 → 굽고 → 같은 잣대로 재서 못 넘은 것만 지운다.

지운 자리는 「아직 안 만듦」이 아니다

③ 이 정답을 지우면 case.jsonexpectedResult: { status: "unsolvable", reason, at } 를 적는다. 그 표시가 있는 케이스는 판정에서 UNSOLVABLE 이고, 없으면 UNKNOWN 이다.

라벨2026-08-28 · 96건
UNSOLVABLE알아본 결과 규칙 안에서 답이 없다51
UNKNOWN아직 알아보지 못했다 — 통과로 뭉개지 않는다45 (정답 파일 있음)

정답 파일이 있는 45건에는 expectedResult 필드가 없다 — 지워진 적이 없다는 뜻이다.

틀린 목표선은 없는 목표선보다 나쁘다

Expected 열은 후보가 겨눌 과녁이라, 과녁이 틀리면 후보를 고칠 때마다 틀린 방향으로 끌려간다. ⑤ 신규 겹침이 1쌍이라도 있거나 §2.2 내접 이탈이 원안보다 늘면 지운다. 지운 자리는 「재배치로 못 고치는 것」이고 그것이 정답이다.

§9

코퍼스에서 빼는 길 — pruneCases

케이스를 들이는 것만큼 빼는 것도 제작 로직이다. before 판정이 SUCCESS 인 케이스는 대치가 아무것도 망가뜨리지 않아 재배치가 고칠 것이 없다 — 어떤 후보를 넣어도 점수가 안 움직이므로 표만 길어지고 후보 사이의 차이를 희석한다.

pruneCases() — 두 갈래로 뺀다

케이스 폴더 하나 rendered.json 이 있나 아니오 UNKNOWN — 남긴다 tight/* · outline.json 을 구웠나 아니오 UNKNOWN — 남긴다 무엇을 구워야 하는지 적는다 judgeDocs(before, tight, original, …) 뷰어·터미널과 같은 함수 갈래① 판정이 pruneVerdicts 에 들어 있나 기본 = [SUCCESS] 폴더를 통째로 지운다 prunedBy = 'verdict' 아니오 갈래② FAIL 아님 ∧ 폰트 = 원안 ∧ 유휴 여백 > 원안 idleOnlyGrowth() 지운다 — 이슈 범위 밖 prunedBy = 'idle-growth' 아니오 코퍼스에 남는다
UNKNOWN 은 뺄 수 없다 — 판정할 재료가 없다는 뜻이라 지울 근거도 없고, pruneVerdicts 에 넣으면 예외로 던진다. keepWithExpected: true 를 주면 expected_result.json 이 있는 케이스는 지우지 않는다(기본은 함께 지우고, 지운 것 중 정답이 있던 건은 prunedWithExpected 로 따로 세서 조용히 사라지지 않게 한다).
갈래② 는 왜 따로 있나
넘침도 신규 겹침도 과도 축소도 없는데 원안보다 유휴 여백만 늘어난 케이스가 있다. 대치된 글이 원안보다 짧아 그 자리가 비어 버린 것이다(5825141 실측: 0.780 → 0.933). 고칠 레이아웃 문제가 아니라 들어온 글의 길이가 만든 결과라 이 feature 의 범위 밖이다.
「유휴 여백만」이 조건이다
폰트가 원안보다 조금이라도 줄었으면 ② 축소를 고칠 여지가 남아 안 뺀다. FAIL 은 여백이 함께 늘었어도 넘침·겹침을 고칠 것이 있으니 남긴다 — 2026-08-28 코퍼스에서 유휴 여백이 늘어난 26건 중 15건이 FAIL 이고, 이 갈래가 빼는 것은 나머지 11건이다.

폴더를 통째로 지운다 — 반쯤 지우면 목록에서 안 사라진다

뷰어는 case.json 으로 케이스를 세므로, 반쯤 지워 case.json 만 남으면 그 케이스가 계속 목록에 오른다. 그래서 eval-out/cases/<id>/ 를 통째로 지운다 — 되돌리려면 다시 들여야 한다. dryRun 이 기본이 아닌 이유가 없다: 호출자가 먼저 --dry-run 으로 목록을 보게 하는 것이 이 모듈의 사용법이다.

§10

전체 순서 — 한 장

tsx 는 레포의 전이 의존성으로만 있어 pnpm exec tsx 가 안 먹는다. 실행기를 먼저 잡는다.

TSX=$(ls -d node_modules/.pnpm/tsx@*/node_modules/tsx/dist/cli.mjs | head -1) node "$TSX" --version

폴더 하나 → 채점 가능한 케이스

1들이기 + 렌더 브라우저
import-test-case.ts --from <dir> --skip-existing
add-test-case.ts --id <n> --text-file page.md
original·replaced·rendered·page.md·case.json 이 생긴다. --skip-existing 을 늘 붙인다
2판정 박스 굽기 브라우저
tight-box-cases.ts --only $ID
tight/rendered.json · tight/original.json
3컨테이너 모양 굽기 네트워크
outline-cases.ts --only $ID
outline.json. 없으면 캡슐·원·말풍선의 넘침이 실제보다 작게 잡힌다
4라벨 다시 붙이기
label-test-cases.ts --only $ID
1 에서 구운 verdict·problems 는 판정 박스가 없던 시점의 값이라 못 믿는다(§6)
5정답 브라우저
expected-result-build.ts --only $ID
tight-box-cases.ts --only $ID --documents expected_result.json
expected-result-verify.ts --only $ID
빌더 결과가 나쁘면 webp 를 눈으로 보고 손으로 만든다
6숫자 확인 · note 쓰기
relayout-metrics.ts --only $ID
손으로 쓰는 것은 note 하나. problems 는 눈으로 본 판단이 기계값과 다를 때만 고친다
7정리 — 필요할 때만
prune-test-cases.ts --dry-run
prune-test-cases.ts
2~4 를 끝낸 뒤에만 돌린다(§9)
브라우저(playwright)가 필요한 자리는 이다 — 1 렌더 · 2 굽기 · 5 정답의 굽기. 3 은 브라우저 대신 네트워크를 쓰고, 4·6·7 은 둘 다 없이 돈다. Supabase 자격증명(.env.local)은 addTestCasecase id 를 uuid 로 해석할 때만 읽는다.
§11

함정 다섯

1

--overwrite 로 밀지 않는다

겹치면 새로 넣지 않고 건너뛴다. 케이스가 이미 있다는 것은 누군가 그 자리에 정답·판정 박스·note 를 쌓아 뒀다는 뜻이고, 입력을 조용히 갈아치우면 그 위의 채점이 전부 뜻을 잃는다. 겹치는 길이 둘이다 — 코퍼스에 이미 있거나(폴더 존재), 한 배치 안에서 두 폴더가 같은 id 로 가거나 (MOR-2018/TC 에 7조 15폴더). 둘 다 건너뛴다.

2

굽기 전에 prune-test-cases.ts 를 돌리지 않는다

안 구운 케이스는 넘침이 0 으로 잡혀 SUCCESS 로 떨어지고 통째로 지워진다. 지금은 프루너가 안 구운 폴더를 UNKNOWN 으로 남기고 막지만(--allow-unbaked 로만 밀린다), 순서를 지키는 것이 먼저다.

3

받아 온 rendered.json 을 쓰지 않는다

제품 파이프라인이 오토핏을 켜고 낸 문서라 글자가 이미 줄어 있다. 렌더본은 언제나 여기서 축소를 끄고 새로 낸다 — 그래서 폴더마다 브라우저를 한 번은 쓴다. 「폴더만 읽고 끝나는 갈래」는 없다.

4

problems 는 저절로 안 낡는다 — 낡은 채로 남는다

keepProblems 기본값이 사람 판단을 지키느라 기계값도 함께 지킨다. 렌더본을 다시 굽거나 판정 규칙을 바꿨으면 --overwrite-problems명시적으로 준다 (§7 실측: 지금 shrink 77건이 그 상태다).

5

파일이 생겼다고 성공이 아니다 — webp 를 눈으로 본다

이 경로는 검색(comp-match)의 글자 용량 하한(cap_chars/cap_lines) 필터를 건너뛰므로, 구조 해시가 맞았다는 것이 들어갈 자리가 넉넉하다는 뜻은 아니다 — 넘침·과축소가 흔하다. 특히 정답은 지표가 「넘침 0」이라도 그림에서 삐져나올 수 있다 (ShapeItem 중에는 boundingBox 만 키워도 렌더 크기가 안 따라오는 것이 있다, 6869327 실측).

§12

코드 어디에 있나

무엇파일진입점
글 + 컴포넌트 → 케이스batch/addTestCase.ts:327addTestCase
페이로드 폴더 → 케이스batch/importCase.ts:290importCase · importCases
case id 해석(§id)batch/importCase.ts:208resolveCaseId
축소 끄는 레버batch/renderShrink.ts:65NO_FONT_SHRINK · withoutFontShrink
판정 박스 굽기batch/bakeTightBoxes.ts:89bakeTightBoxes
컨테이너 모양 굽기batch/bakeOutlines.ts:121bakeOutlines
구운 값 읽기batch/caseTight.ts · caseOutline.tsreadCaseTight · readCaseOutlines
라벨 붙이기batch/labelCases.ts:122labelCase · problemsOf · labelCases
코퍼스 정리batch/pruneCases.ts:242 미커밋pruneCases · idleOnlyGrowth
렌더 재검증 포트batch/tightBoxProbe.tscreateTightBoxProbe

batch/ 아래는 브라우저가 아니라 Node 에서 도는 코드다 (src/text-tight-box/batch·src/resource-outline/batch 와 같은 규약). 뷰어 쪽 코드는 이 폴더를 import 하지 않는다 — 브라우저는 디스크에 못 쓰니 파일을 만드는 일은 애초에 Node 몫이다. CLI 는 scripts/껍데기이고 순서와 계약은 전부 모듈에 있다.

채점 쪽은 따로 정리했다

여기서 만든 케이스를 무엇으로 어떻게 채점하는지알고리즘 적용 결과 채점 로직 에 있다 — 판정 박스 셋, 지표 ①~⑦, 그리고 SUCCESS/ACCEPTABLE/FAIL 게이트.