genetics-mcp 2.9.1 → 2.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,459 +0,0 @@
1
- #!/usr/bin/env python3
2
- """일러스트 시각 검수 — **정본 8장이 기준이다.**
3
-
4
- 왜 있는가
5
- ---------
6
- 2026-08-17 까지 일러스트 검수는 계약 §7 의 「2–8%」라는 숫자로 했다. 그런데 그 숫자를
7
- **정본으로 검증한 적이 없었다.** 실제로 재 보니 정본은 0.75–1.91% 로 한 장도 그 구간에
8
- 들지 않는다 — 틀린 것은 그림이 아니라 기준이었다. 그 기준을 맞추려던 프롬프트 튜닝이
9
- 정본의 조형 문법을 통째로 지웠다.
10
-
11
- 그래서 기준을 **문장이 아니라 정본 실물에서 뜬다.** 계약 §7.1 이 이미 그 8장을
12
- 「회귀 기준물로만 보관」이라고 규정해 두었는데, 규정만 있고 **재는 절차가 없었다.**
13
-
14
- 쓰는 법
15
- -------
16
- python3 tools/visual-baseline.py freeze # 정본에서 기준선을 뜬다
17
- python3 tools/visual-baseline.py check <png>... # 새 그림을 판정한다
18
- python3 tools/visual-baseline.py selftest # 항등 + 뮤테이션 대조군
19
-
20
- `selftest` 가 이 도구의 증명이다 — 정본을 넣으면 **8/8 통과**해야 하고(항등),
21
- 색을 흐트러뜨린 뮤테이션본은 **떨어져야** 한다. 한쪽만 보면 증명이 아니다.
22
- """
23
- from __future__ import annotations
24
-
25
- import json
26
- import os
27
- import sys
28
-
29
- try:
30
- from PIL import Image
31
- except ImportError: # 검수는 선택 도구가 아니다
32
- sys.exit('Pillow 가 필요하다: python3 -m pip install Pillow')
33
-
34
- HERE = os.path.dirname(os.path.abspath(__file__))
35
- ROOT = os.getcwd() # 표시용 상대경로 기준. 프로젝트마다 다르다.
36
-
37
- SAMPLE = 400 # 이 크기로 줄여 세면 충분하다(계약 §7 실측과 같은 조건)
38
-
39
- # **팔레트는 문서마다 다르다.** `lim` 은 강조색이 라임 `#B9FF66` 이지만 `korean-navy` 는
40
- # 금색 `#C9A961` 이다. 색을 상수로 박으면 그 서식에서 전량 오판정한다 —
41
- # 그래서 네 색은 `private/builder/palette.py` 에서 받는다.
42
- DEFAULT_PALETTE = None # **기본 팔레트를 두지 않는다** — 프로젝트마다 다르다.
43
- DEFAULT_REF = None # 참조 세트도 마찬가지. 안 주면 물어보게 만든다.
44
-
45
-
46
- def palette_colors(name: str, module: str | None = None) -> dict:
47
- """그 팔레트의 네 색(hex).
48
-
49
- **어느 한 저장소의 팔레트에 묶이지 않는다.** 찾는 순서:
50
- ① `--palette-module <경로>` 로 준 파일 ② 이 스크립트 옆의 `palette.py`
51
- 둘 다에 그 이름이 없으면 `--accent/--ink/--surface/--white` 로 직접 주면 된다.
52
- """
53
- import importlib.util
54
- cands = [module] if module else []
55
- cands.append(os.path.join(HERE, 'palette.py'))
56
- seen = []
57
- for c in cands:
58
- if not c or not os.path.exists(c):
59
- continue
60
- spec = importlib.util.spec_from_file_location('_pal', c)
61
- m = importlib.util.module_from_spec(spec)
62
- spec.loader.exec_module(m)
63
- pals = getattr(m, 'PALETTES', {})
64
- seen += sorted(pals)
65
- if name in pals:
66
- q = pals[name]
67
- return {'white': q.get('bg', '#FFFFFF'), 'surface': q.get('surface', '#F3F3F3'),
68
- 'ink': q.get('ink', q['primary']), 'accent': q['secondary']}
69
- sys.exit(f'그런 팔레트가 없다: {name}\n'
70
- f' 찾아본 곳: {", ".join(c for c in cands if c)}\n'
71
- f' 있는 것: {", ".join(sorted(set(seen))) or "(없음)"}\n'
72
- f' 또는 색을 직접: --accent #RRGGBB --ink #RRGGBB --surface #RRGGBB --white #RRGGBB')
73
-
74
-
75
- def _rgb(h: str) -> tuple[int, int, int]:
76
- h = h.lstrip('#')
77
- return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
78
-
79
-
80
- # ── 픽셀을 네 색으로 가른다 ────────────────────────────────────────────────
81
- # 4색 선화이므로 분류가 곧 계약 §1 의 면적 규칙이다. **거리로 가른다** —
82
- # 색상(hue) 조건을 손으로 적으면 그 팔레트에서만 맞는다.
83
-
84
- def make_classifier(cols: dict):
85
- ref = {k: _rgb(v) for k, v in cols.items()}
86
-
87
- def classify(px: tuple[int, int, int]) -> str:
88
- best, bd = None, 1e9
89
- for k, c in ref.items():
90
- d = sum((a - b) ** 2 for a, b in zip(px, c)) ** .5
91
- if d < bd:
92
- best, bd = k, d
93
- # 생성물은 원본 색을 그대로 안 낸다. 너무 멀면 「그 밖」으로 센다.
94
- if best == 'white' and bd > 18:
95
- return 'other' if bd > 40 else 'white'
96
- return best if bd <= 70 else 'other'
97
-
98
- return classify
99
-
100
-
101
- # 시작값 — freeze/check 가 실제 팔레트로 **덮어쓴다**(상수로 쓰라고 둔 것이 아니다).
102
- _BOOT = {'white': '#FFFFFF', 'surface': '#F3F3F3', 'ink': '#191A23', 'accent': '#B9FF66'}
103
- classify = make_classifier(_BOOT)
104
- ACCENT = _rgb(_BOOT['accent'])
105
-
106
-
107
- def measure(path: str) -> dict:
108
- """한 장에서 판정에 쓸 지표를 뽑는다."""
109
- im = Image.open(path).convert('RGB')
110
- w, h = im.size
111
- im.thumbnail((SAMPLE, SAMPLE))
112
- px = list(im.getdata())
113
- W, H = im.size
114
- n = len(px)
115
-
116
- count: dict[str, int] = {}
117
- acc_px = []
118
- for p in px:
119
- k = classify(p)
120
- count[k] = count.get(k, 0) + 1
121
- if k == 'accent':
122
- acc_px.append(p)
123
-
124
- pct = {k: round(100 * count.get(k, 0) / n, 3)
125
- for k in ('white', 'surface', 'accent', 'ink', 'other')}
126
-
127
- # 대표 강조색 — 목표 hex 와 얼마나 떨어졌나
128
- if acc_px:
129
- rep = tuple(round(sum(c[i] for c in acc_px) / len(acc_px)) for i in range(3))
130
- dist = round(sum((a - b) ** 2 for a, b in zip(rep, ACCENT)) ** .5, 1)
131
- else:
132
- rep, dist = (0, 0, 0), 999.0
133
-
134
- # 글자리 — 글이 **그림 위에 얹히므로**(계약 4.2절 v1.3) 그 자리가 읽을 수 있어야 한다.
135
- #
136
- # 「비어 있는가」로 재면 안 된다. 정본 챕터 6장 중 4장이 좌40% 에 잉크를 갖는다
137
- # (최대 14.94%) — 그래도 글은 읽힌다. **연하고 고르기** 때문이다.
138
- # 그래서 재는 것은 공백이 아니라 **밝기와 균질함**이다:
139
- # · 평균 밝기가 높아야 ink(#191A23, 밝기 25) 글자가 대비를 얻는다
140
- # · 표준편차가 낮아야 글줄이 얼룩 위를 지나지 않는다
141
- # 정본 실측(좌46%): 평균 237.7~251.8 · 편차 11.8~51.4.
142
- #
143
- # 글은 **좌·우 한쪽**에 앉고 장마다 바뀐다(렌더러의 `data-side`). 그래서 두 쪽을 다
144
- # 재고, **읽히는 쪽이 하나라도 있으면** 그 그림은 쓸 수 있다. 어느 쪽이 비었는지는
145
- # `text_side` 가 말해 주고, 브리프가 지정한 쪽과 같아야 한다.
146
- g = im.convert('L')
147
- w46 = int(W * .46)
148
-
149
- def band(box):
150
- d = list(g.crop(box).getdata())
151
- mu = sum(d) / len(d)
152
- return round(mu, 1), round((sum((x - mu) ** 2 for x in d) / len(d)) ** .5, 1)
153
-
154
- bg_l, sd_l = band((0, 0, w46, H))
155
- bg_r, sd_r = band((W - w46, 0, W, H))
156
- left_ink = round(100 * sum(1 for p in list(im.crop((0, 0, int(W * .4), H)).getdata())
157
- if max(p) < 235) / max(1, (int(W * .4) * H)), 2)
158
-
159
- return {
160
- 'size': [w, h],
161
- 'aspect': round(w / h, 3),
162
- 'pct': pct,
163
- 'accent_rgb': '#%02X%02X%02X' % rep,
164
- 'accent_dist': dist,
165
- 'left40_ink': left_ink,
166
- 'text_bg_l': bg_l, 'text_sd_l': sd_l, # 왼쪽 46% — 글이 읽히는가
167
- 'text_bg_r': bg_r, 'text_sd_r': sd_r, # 오른쪽 46%
168
- 'text_bg': max(bg_l, bg_r), # 밝은 쪽 = 글을 앉힐 수 있는 쪽
169
- 'text_side': 'l' if bg_l >= bg_r else 'r',
170
- }
171
-
172
-
173
- # ── 판정 밴드 ─────────────────────────────────────────────────────────────
174
- # 정본 실측에서 유도한다. **손으로 고른 숫자를 쓰지 않는다** — 그것이 지난 사고의 원인이다.
175
- # 여유(margin)는 정본 폭에 비례해 준다.
176
-
177
- def derive_bands(rows: dict[str, dict]) -> dict:
178
- art = {k: v for k, v in rows.items() if not k.startswith('eof')}
179
-
180
- def span(getter, floor=0.0, ceil=100.0, pad=0.25):
181
- vals = [getter(v) for v in art.values()]
182
- lo, hi = min(vals), max(vals)
183
- gap = max((hi - lo) * pad, 0.3)
184
- return [round(max(floor, lo - gap), 2), round(min(ceil, hi + gap), 2)]
185
-
186
- out = {
187
- 'accent': span(lambda v: v['pct']['accent']),
188
- 'white': span(lambda v: v['pct']['white'], ceil=100),
189
- 'surface': span(lambda v: v['pct']['surface']),
190
- 'ink': span(lambda v: v['pct']['ink']),
191
- 'accent_dist_max': round(max(v['accent_dist'] for v in art.values()) * 1.6, 1),
192
- }
193
- # hero 안전 영역은 **hero 가 있을 때만** 잰다. 참조 세트에 hero 가 없는 문서도 있고,
194
- # 없다고 죽으면 그 프로젝트에서 도구를 아예 못 쓴다(2026-08-17 실측으로 걸렸다).
195
- # 글자리 하한 — 두 축을 뜬다. 「비어 있는가」가 아니라 「읽히는가」다
196
- # (정본 6장 중 4장은 좌40% 에 잉크가 있고도 글이 읽힌다).
197
- #
198
- # ① 밝은 쪽 — 어느 쪽이든 하나는 글을 받을 수 있어야 한다.
199
- # ② 앉히는 쪽 — 브리프가 정한 쪽이 실제로 그만큼 밝아야 한다.
200
- #
201
- # ②의 하한을 ①과 같은 값으로 두면 **정본이 자기 기준에 떨어진다** — 정본
202
- # chapter-05 는 글이 왼쪽(정본 HTML 의 `chapter-opener` 에 `is-reverse` 없음)인데
203
- # 그 왼쪽이 238.9 다. 계약 §7 의 「Lime 2–8%」가 정본 0/7 이었던 것과 같은 함정이라
204
- # ②는 **정본이 실제로 글을 앉힌 쪽**의 최솟값에서 뜬다.
205
- #
206
- # 정본의 좌우는 추정이 아니라 정본 HTML 실측이다:
207
- # 1·3·5장 = 글 왼쪽(is-reverse 없음) · 2·4·6장 = 글 오른쪽(is-reverse) · hero = 왼쪽
208
- CANON_SIDE = {'1': 'l', '2': 'r', '3': 'l', '4': 'r', '5': 'l', '6': 'r'}
209
-
210
- bgs = [v['text_bg'] for v in art.values()]
211
- if bgs:
212
- out['text_bg_min'] = round(min(bgs) - 1.0, 1)
213
-
214
- used = []
215
- for k, v in art.items():
216
- if k.startswith('hero'):
217
- sd = 'l'
218
- else:
219
- digits = [c for c in k if c.isdigit()]
220
- sd = CANON_SIDE.get(digits[-1] if digits else '', None)
221
- if sd:
222
- used.append(v['text_bg_l'] if sd == 'l' else v['text_bg_r'])
223
- if used:
224
- out['text_side_bg_min'] = round(min(used) - 1.0, 1)
225
-
226
- heroes = [v['left40_ink'] for k, v in art.items() if k.startswith('hero')]
227
- if heroes:
228
- out['hero_left40_ink_max'] = round(max(heroes) + 0.5, 2)
229
- return out
230
-
231
-
232
- def load_baseline(base: str) -> dict:
233
- if not os.path.exists(base):
234
- sys.exit(f'기준선이 없다: {base}\n 먼저: python3 tools/visual-baseline.py freeze')
235
- d = json.load(open(base, encoding='utf-8'))
236
- # 기준선이 **자기 팔레트를 지고 다닌다** — 판정은 뜰 때와 같은 색으로 해야 한다.
237
- global classify, ACCENT
238
- if d.get('colors'):
239
- classify = make_classifier(d['colors'])
240
- ACCENT = _rgb(d['colors']['accent'])
241
- return d
242
-
243
-
244
- def cmd_freeze(ref: str, pal: str, base: str, module: str | None = None) -> int:
245
- return cmd_freeze_cols(ref, palette_colors(pal, module), pal, base)
246
-
247
-
248
- def cmd_freeze_cols(ref: str, cols: dict, pal: str, base: str) -> int:
249
- import glob
250
- global classify
251
- globals()['ACCENT'] = _rgb(cols['accent'])
252
- classify = make_classifier(cols)
253
- files = sorted(glob.glob(os.path.join(ref, '*.png')))
254
- if not files:
255
- sys.exit(f'참조 세트가 없다: {ref}')
256
- rows = {}
257
- for f in files:
258
- name = os.path.basename(f)[:-4].replace('lim-report-', '').replace('lim-', '')
259
- rows[name] = measure(f)
260
- p = rows[name]['pct']
261
- print(f" {name:14} W{p['white']:6.2f}% S{p['surface']:5.2f}% "
262
- f"A{p['accent']:5.2f}% I{p['ink']:5.2f}% {rows[name]['accent_rgb']}")
263
-
264
- out = {
265
- 'note': '참조 세트에서 뜬 시각 기준선. 손으로 고친 값을 넣지 말 것 — freeze 로만 갱신한다.',
266
- 'source': os.path.relpath(ref, ROOT) + '/*.png',
267
- 'palette': pal,
268
- 'colors': cols,
269
- 'sample_px': SAMPLE,
270
- 'assets': rows,
271
- 'bands': derive_bands(rows),
272
- }
273
- with open(base, 'w', encoding='utf-8') as fh:
274
- json.dump(out, fh, ensure_ascii=False, indent=2)
275
- fh.write('\n')
276
- b = out['bands']
277
- print(f"\n → {os.path.relpath(base, ROOT)}")
278
- print(f" 강조 {b['accent'][0]}–{b['accent'][1]}% · White {b['white'][0]}–{b['white'][1]}% "
279
- f"· Surface {b['surface'][0]}–{b['surface'][1]}% · Ink {b['ink'][0]}–{b['ink'][1]}%")
280
- return 0
281
-
282
-
283
- def judge(path: str, bands: dict, is_hero: bool | None = None) -> tuple[bool, list[str]]:
284
- m = measure(path)
285
- name = os.path.basename(path)
286
- if is_hero is None:
287
- is_hero = 'hero' in name.lower()
288
- bad = []
289
-
290
- for key, label in (('accent', '강조'), ('white', 'White'), ('surface', 'Surface'), ('ink', 'Ink')):
291
- lo, hi = bands[key]
292
- v = m['pct'][key]
293
- if not (lo <= v <= hi):
294
- bad.append(f'{label} {v}% (밴드 {lo}–{hi}%)')
295
-
296
- if m['accent_dist'] > bands['accent_dist_max']:
297
- bad.append(f"강조색 {m['accent_rgb']} 거리 {m['accent_dist']} (한도 {bands['accent_dist_max']})")
298
-
299
- # ⚠ 글자리(밝기·쪽) 두 축은 **뺐다**(2026-08-18).
300
- #
301
- # 그 축은 글이 그림 **위에** 얹힐 때만 뜻이 있었다. 배치가 좌우 분리로 정정되면서
302
- # (계약 4.2절 v1.4) 그림 위에는 아무것도 앉지 않는다 — 잴 것이 없다.
303
- # 안 재는 것을 재는 척하는 관문은 없는 관문보다 나쁘다: 초록불이 근거로 인용된다.
304
- #
305
- # 측정 자체(`text_bg_l`·`text_bg_r`·`text_side`)는 남겨 둔다. 판정에는 안 쓰지만
306
- # 「이 그림은 어느 쪽이 밝은가」는 배치를 고를 때 사람에게 쓸모가 있다.
307
-
308
- return (not bad), bad
309
-
310
-
311
- def cmd_check(paths: list[str], base: str) -> int:
312
- bands = load_baseline(base)['bands']
313
- if not paths:
314
- sys.exit('검사할 파일을 달라')
315
- # `그림.png:r` 은 **더 이상 판정에 안 쓴다**(위 judge 주석). 옛 명령이 죽지 않게
316
- # 받아서 떼기만 한다.
317
- sides = {}
318
- clean = []
319
- for raw in paths:
320
- if raw.endswith((':l', ':r')) and not os.path.exists(raw):
321
- f, sd = raw[:-2], raw[-1]
322
- sides[f] = sd
323
- clean.append(f)
324
- else:
325
- clean.append(raw)
326
- paths = clean
327
- fail = 0
328
- for p in paths:
329
- # 없는 파일에 예외를 던지면 **관문이 죽는다** — 죽은 관문은 통과로 오독된다.
330
- # 생성이 덜 끝났을 때 실제로 그렇게 죽었다(2026-08-17). 없는 것도 실패로 센다.
331
- if not os.path.exists(p):
332
- print(f' [실패] {os.path.basename(p)}\n ✗ 파일이 없다 — 생성이 안 끝났다')
333
- fail += 1
334
- continue
335
- ok, bad = judge(p, bands)
336
- mark = '통과' if ok else '실패'
337
- m = measure(p)
338
- print(f" [{mark}] {os.path.basename(p)} (밝은 쪽 {m['text_side']})")
339
- for b in bad:
340
- print(f' ✗ {b}')
341
- fail += 0 if ok else 1
342
- print(f"\n {len(paths)-fail}/{len(paths)} 통과")
343
- return 1 if fail else 0
344
-
345
-
346
- def cmd_selftest(ref: str, base: str) -> int:
347
- """대조군 없는 검사는 증명이 아니다 — 양성과 음성을 둘 다 본다."""
348
- import glob
349
- import io
350
- bands = load_baseline(base)['bands']
351
- files = sorted(glob.glob(os.path.join(ref, '*.png')))
352
- art = [f for f in files if 'eof' not in os.path.basename(f)]
353
-
354
- print(' ① 항등 — 정본을 넣으면 전부 통과해야 한다')
355
- ok_n = 0
356
- for f in art:
357
- ok, bad = judge(f, bands)
358
- ok_n += ok
359
- if not ok:
360
- print(f" ✗ {os.path.basename(f)}: {'; '.join(bad)}")
361
- print(f' {ok_n}/{len(art)} 통과')
362
-
363
- print(' ② 뮤테이션 — 흐트러뜨린 것은 떨어져야 한다')
364
- muts = {
365
- '강조색을 흐리게': lambda p: (p[0] - 20, p[1] - 40, p[2] - 10) if classify(p) == 'accent' else p,
366
- '강조색을 넓은 면으로': None, # 아래에서 따로 만든다
367
- '전체를 회색으로': lambda p: tuple(min(255, int(c * .55 + 90)) for c in p),
368
- }
369
- caught = 0
370
- total = 0
371
- src = art[0]
372
- tmp = os.path.join(HERE, '.mutation.png')
373
- for label, fn in muts.items():
374
- im = Image.open(src).convert('RGB')
375
- if fn is None: # 라임을 프레임의 큰 띠로 칠한다
376
- w, h = im.size
377
- for y in range(int(h * .40), int(h * .62)):
378
- for x in range(0, w):
379
- im.putpixel((x, y), ACCENT)
380
- else:
381
- im = Image.eval(im, lambda v: v) if False else Image.merge(
382
- 'RGB', im.split()) # 사본 확보
383
- px = im.load()
384
- w, h = im.size
385
- for y in range(h):
386
- for x in range(w):
387
- px[x, y] = tuple(max(0, min(255, c)) for c in fn(px[x, y]))
388
- im.save(tmp)
389
- ok, bad = judge(tmp, bands, is_hero=False)
390
- total += 1
391
- caught += (not ok)
392
- print(f" [{'잡음' if not ok else '놓침'}] {label}"
393
- + (f" ({bad[0]})" if bad else ''))
394
- if os.path.exists(tmp):
395
- os.remove(tmp)
396
- print(f' {caught}/{total} 잡음')
397
-
398
- good = ok_n == len(art) and caught == total
399
- print(f"\n 판정: {'통과' if good else '실패'} "
400
- f"— 항등 {ok_n}/{len(art)} · 뮤테이션 {caught}/{total}")
401
- return 0 if good else 1
402
-
403
-
404
- def main() -> int:
405
- """**어느 프로젝트에서도 돌아간다.** 참조 세트와 색을 인자로 받는다.
406
-
407
- # 팔레트 이름으로 (그 프로젝트의 palette.py 를 가리킨다)
408
- python3 visual-baseline.py freeze --ref <원본폴더> \\
409
- --palette-module <프로젝트>/palette.py --palette <이름> --base <기준선.json>
410
-
411
- # 팔레트 모듈이 없으면 색을 직접
412
- python3 visual-baseline.py freeze --ref <원본폴더> \\
413
- --accent '#C9A961' --ink '#141413' --surface '#FAF9F5' --base <기준선.json>
414
-
415
- python3 visual-baseline.py check --base <기준선.json> <png>...
416
- python3 visual-baseline.py selftest --ref <원본폴더> --base <기준선.json>
417
-
418
- `check` 는 기준선이 지고 있는 색으로 판정하므로 인자가 `--base` 하나면 된다.
419
- """
420
- argv = sys.argv[1:]
421
- cmd = argv[0] if argv else ''
422
- opt = {'--ref': DEFAULT_REF, '--palette': DEFAULT_PALETTE, '--base': None,
423
- '--palette-module': None, '--accent': None, '--ink': None,
424
- '--surface': None, '--white': None}
425
- files, i = [], 1
426
- while i < len(argv):
427
- a = argv[i]
428
- if a in opt and i + 1 < len(argv):
429
- opt[a] = argv[i + 1]; i += 2; continue
430
- files.append(a); i += 1
431
-
432
- if cmd in ('freeze', 'selftest') and not opt['--ref']:
433
- sys.exit('참조 세트를 달라: --ref <원본 PNG 들이 있는 폴더>')
434
- if not opt['--base']:
435
- if cmd == 'freeze':
436
- sys.exit('기준선 파일 경로를 달라: --base <어디에 쓸지>.json')
437
- sys.exit('기준선 파일을 달라: --base <freeze 로 만든>.json')
438
-
439
- if cmd == 'freeze':
440
- if opt['--accent']: # 색을 직접 준 경우
441
- cols = {'white': opt['--white'] or '#FFFFFF',
442
- 'surface': opt['--surface'] or '#F3F3F3',
443
- 'ink': opt['--ink'] or '#191A23', 'accent': opt['--accent']}
444
- return cmd_freeze_cols(opt['--ref'], cols, opt['--palette'] or '(직접 지정)',
445
- opt['--base'])
446
- if not opt['--palette']:
447
- sys.exit('팔레트 이름(--palette) 이나 색(--accent …) 중 하나는 줘야 한다')
448
- return cmd_freeze(opt['--ref'], opt['--palette'], opt['--base'],
449
- opt['--palette-module'])
450
- if cmd == 'check':
451
- return cmd_check(files, opt['--base'])
452
- if cmd == 'selftest':
453
- return cmd_selftest(opt['--ref'], opt['--base'])
454
- print(__doc__)
455
- return 2
456
-
457
-
458
- if __name__ == '__main__':
459
- raise SystemExit(main())
@@ -1,111 +0,0 @@
1
- # 교과서 모드 — 가르치는 문서의 공식
2
-
3
- 기술문서·인수인계·사후분석처럼 **독자가 배워야 하는** 문서의 구조 사양이다.
4
- 검수 보고서(독자가 판정하는 문서)와는 뼈대가 다르다 — 같은 빌더, 다른 서사.
5
-
6
- ## 0. 이 모드가 생긴 사건 (왜 공식이 필요한가)
7
-
8
- 어느 기술보고서(2026-08-14)에서
9
- 검수 보고서 뼈대(요약→판정표→건별 카드→확인 요청)에 타이포·여백·색만 갈아입힌 판을 냈다가
10
- 반려됐다. 지적은 정확히 심슨의 역설이었다: **부분(활자·색·여백)마다 좋아졌는데 전체(문서가
11
- 읽히는 방식)는 그대로였다.**
12
-
13
- > 베스트셀러 교과서는 같은 내용을 더 예쁘게 실어서 팔리는 게 아니다.
14
- > **가르치는 구조 자체가 다르다.** 구조가 제품이다.
15
-
16
- 그러므로 이 모드에서 스타일 작업은 마지막이다. 먼저 물을 것: **이 문서는 무엇을 어떤
17
- 순서로 가르치는가.** 그 답이 절 구성이 된다.
18
-
19
- ## 1. 모드 판별 — 독자가 누구인가 (SKILL.md 0단계의 연장)
20
-
21
- | 독자의 일 | 모드 | 뼈대 |
22
- |---|---|---|
23
- | 판정한다 (검수·회신·결재) | 검수 보고서 | 요약 → 등급·판정표 → 건별 카드 → 확인 요청 |
24
- | 배운다 (유지보수·인수·온보딩) | **교과서** | 학습목표 → 원리 → 붕괴 → 처방 → 적용 → 연습 → 부록 대장 |
25
-
26
- 트리거: 기술보고서, 인수인계, 온보딩 문서, 사후분석(포스트모템), "다음 사람이 읽고
27
- 이해해야", "왜 이렇게 됐는지 설명하는 문서".
28
-
29
- ## 2. 서사 공식 — 7단계 장 구성
30
-
31
- 의존 순서로 가르친다. **각 장은 앞 장 없이는 성립하지 않아야 한다** — 그래야 스크롤이
32
- 학습 곡선이 된다. 병렬 나열(어디서 읽어도 같은 문서)이 되는 순간 이 모드가 아니다.
33
-
34
- | 장 | 역할 | 규칙 |
35
- |---|---|---|
36
- | 01 학습목표 | "다 읽고 나면 할 수 있는 것" 2~4개 | **문서의 성패 기준**이라고 명시한다. recap 블록으로 |
37
- | 02 원리 | 시스템이 성립하는 핵심 명제 하나 | 개념 박스 하나로 압축될 것. 안 되면 아직 원리를 못 찾은 것 |
38
- | 03 붕괴 | 그 원리가 깨지는(깨졌던) 구조 | **실제 사건을 예제 풀이로.** 단계마다 "왜"를 단다 |
39
- | 04 처방 | 붕괴를 구조적으로 불가능하게 만든 결정 | "더 좋은 검사"가 아니라 "그 부류가 존재할 수 없게" 프레임 |
40
- | 05·06 적용 | 원리가 실제 작업에 적용된 사례들 | 각 적용이 02~04 를 콜백해야 한다. 독립 사례면 부록감이다 |
41
- | 07 연습 | 수정·운영 전 스스로 답할 물음 | 아래 4절 형식 |
42
- | 08+ 부록 대장 | 사실 원장 (문제·원인·구현·재현 명령) | 본문에서 뺀 건별 데이터가 전부 여기 산다 |
43
-
44
- **본문/대장 분리 원칙**: 본문은 가르치고, 대장은 참조를 받친다. 본문이 대장을 `§8.1`
45
- 로 가리키면 재호출 팝오버(data-recall)가 그 자리에서 제목·등급을 되살린다 — 독자가
46
- 본문 흐름을 잃지 않고 근거를 확인한다. 건별 카드를 본문에 두면 병렬 나열로 되돌아간다.
47
-
48
- **장 내부 리듬**: 도입 스트립(lede, "이 절에서") → 본문 → **정리(recap, 요점 3개 안팎)**.
49
- 정리 박스가 장마다 누적되며 그 자체로 커리큘럼 요약이 된다. 리듬은 깨지 않는다 —
50
- 독자가 장치 문법을 한 번 배우면 끝까지 통해야 한다(신호 이론).
51
-
52
- ## 3. 장치 문법 — `kind:"lesson"` 블록 스키마
53
-
54
- ```json
55
- {"no":"03","id":"sec-3","name":"불변식이 조용히 깨지는 구조","kind":"lesson",
56
- "lede":"이 절에서 무엇을 왜 배우는지 한두 문장.",
57
- "blocks":[
58
- {"type":"p","text":"본문 문단. §8.1 참조 가능."},
59
- {"type":"concept","title":"침묵 실패","text":"정의 한 문단.","label":"개념(선택)"},
60
- {"type":"example","title":"치환표만 영어로 바꾸면",
61
- "steps":[{"k":"원문","v":"It was pure goodwill.","note":"왜 이 단계가 이렇게 되는가"}],
62
- "takeaway":"이 예제가 증명하는 것 한 문장 (굵게 렌더)"},
63
- {"type":"recap","items":["요점 1","요점 2","요점 3"]},
64
- {"type":"code","text":"재현 명령 또는 코드"},
65
- {"type":"figure","path":"figure.html"}
66
- ]}
67
- ```
68
-
69
- | 블록 | 언제 | 어기면 생기는 일 |
70
- |---|---|---|
71
- | `concept` | 문서 전체가 기대는 명제. 장당 0~1개 | 남발하면 신호가 소음이 된다. 박스가 셋이면 원리가 아니라 목록이다 |
72
- | `example` | **실제 있었던 일**의 단계 풀이. 지어낸 예 금지 | note 없는 step 은 표다. 각 단계에 "왜"가 없으면 worked example 이 아니다 |
73
- | `recap` | 장 끝. 항목 2~4개 | 5개 넘으면 요점이 아니다. 본문에 없는 새 사실을 넣지 않는다 |
74
- | `figure` | 개념 직후, 그 개념의 구조를 그림으로 | SVG 색은 전부 var(--토큰) — design-system.md 규칙 그대로 |
75
-
76
- ## 4. 연습문제 형식 (`kind:"asks"` 재해석)
77
-
78
- `open_items` 를 확인 요청이 아니라 **물음–답–복습 지침**으로 쓴다.
79
-
80
- ```json
81
- {"title":"골든 지문이 어긋났을 때, 왜 Rust 쪽 결과로 갱신하면 안 됩니까?",
82
- "body":"(답) … 참조 구조가 자기 인증이 되기 때문입니다. … 답이 바로 나오지
83
- 않으면 05를 다시 읽으십시오.",
84
- "refs":["§9.1"]}
85
- ```
86
-
87
- - `title` 은 **완전한 물음문**. "골든 지문 관리" 같은 명사구는 심각도를 감춘다.
88
- - `body` 는 답을 준다 — 시험이 아니라 자기 점검이다. 끝에 "N을 다시 읽으십시오" 복습 지침.
89
- - `cta` 는 이 문서 전체에서 독자가 지킬 단 하나의 행동 규칙 (게이트 재실행 등).
90
-
91
- ## 5. 검수 기준 (기존 qa.py 에 더해 손으로 볼 것)
92
-
93
- - [ ] 장 순서를 뒤섞어도 문서가 성립하는가? **성립하면 실패다** (병렬 나열이라는 뜻)
94
- - [ ] 각 장의 recap 만 이어 읽어도 문서 요지가 재구성되는가?
95
- - [ ] 본문에 건별 사실(재현 명령·수치 표)이 남아 있는가? 대장으로 내릴 것
96
- - [ ] 예제의 모든 step 에 note("왜")가 있는가?
97
- - [ ] 연습문제 답에 복습 지침("N을 다시 읽으십시오")이 있는가?
98
- - [ ] 스타일부터 만지고 있지 않은가? 절 구성이 확정되기 전의 CSS 작업은 전부 낭비다
99
-
100
- ## 6. 근거 (리서치 요약)
101
-
102
- - **신호 이론(SARA)** — 장치 문법을 일관되게 유지하면 독자가 레이아웃 해석을 멈추고
103
- 내용만 해석한다. explorersdesign.com/designing-signals
104
- - **Worked examples** — 완성된 풀이 제시가 실증적으로 기억을 향상. arxiv 2602.16806
105
- - **여백 노트** — 참조를 문서 끝이 아니라 참조된 자리 옆에. Tufte CSS, gwern.net/sidenote
106
- - **측정폭** — 본문 66ch 제한. 긴 행은 시선 복귀 비용. typographicwebdesign.com
107
-
108
- ## 7. 표본
109
-
110
- 그 프로젝트의 `docs/tech-report/` — report.json(교과서 서사)·cases.json(부록 대장)·
111
- figure.html(토큰 SVG). 새 기술문서는 이 셋을 복사해 내용만 갈아끼우는 것으로 시작한다.