@chrono-meta/fh-gate 3.0.0 → 3.1.0

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.
Files changed (111) hide show
  1. package/.claude/regression/probes_live.yaml +137 -0
  2. package/.claude/rules/.residency-patterns.defaults +7 -0
  3. package/.claude/rules/fh_4axis_gate.md +50 -1
  4. package/.claude-plugin/marketplace.json +8 -2
  5. package/AGENTS.md +27 -0
  6. package/CATALOG.md +17 -0
  7. package/CLAUDE.md +12 -2
  8. package/README.ja.md +51 -7
  9. package/README.ko.md +48 -7
  10. package/README.md +37 -5
  11. package/README.zh.md +45 -8
  12. package/docs/STANDARDS_ALIGNMENT.md +61 -0
  13. package/docs/USER_GUIDE.md +3 -0
  14. package/docs/USE_CASES.md +50 -0
  15. package/docs/model_tier_expectations.md +60 -0
  16. package/knowledge/shared/harness-core/fh_three_layer_canon.md +30 -10
  17. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +8 -0
  18. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +9 -0
  19. package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +139 -0
  20. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +20 -0
  21. package/knowledge/shared/learnings/subagent_invocations_log.yaml +441 -4
  22. package/package.json +40 -2
  23. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  24. package/plugins/fh-commons/skills/preprep/README.md +4 -1
  25. package/plugins/fh-commons/skills/preprep/SKILL.md +99 -3
  26. package/plugins/fh-commons/skills/preprep/diagram_from_json.py +154 -0
  27. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
  28. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
  29. package/plugins/fh-commons/skills/preprep/fixtures/mk_slide_fixtures.py +179 -0
  30. package/plugins/fh-commons/skills/preprep/interslide_deps.py +98 -9
  31. package/plugins/fh-commons/skills/preprep/lane_adjacent_dup.py +4 -1
  32. package/plugins/fh-commons/skills/preprep/lane_diagram.py +95 -0
  33. package/plugins/fh-commons/skills/preprep/lane_geometry.py +181 -0
  34. package/plugins/fh-commons/skills/preprep/lane_promise.py +4 -1
  35. package/plugins/fh-commons/skills/preprep/lane_slide_refs.py +134 -0
  36. package/plugins/fh-commons/skills/preprep/lane_slide_relations.py +300 -0
  37. package/plugins/fh-commons/skills/preprep/preprep.py +129 -8
  38. package/plugins/fh-commons/skills/preprep/preprep_wire.py +210 -0
  39. package/plugins/fh-commons/skills/preprep/presentation_checklist.md +4 -0
  40. package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +18 -0
  41. package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +184 -0
  42. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  43. package/plugins/fh-meta/CHANGELOG.md +43 -1
  44. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +87 -2
  45. package/plugins/fh-meta/skills/frontier-digest/SKILL.md +2 -2
  46. package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +25 -5
  47. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL.md +11 -0
  48. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL_detail.md +3 -0
  49. package/plugins/fh-qp/.claude-plugin/plugin.json +22 -0
  50. package/plugins/fh-qp/README.md +71 -0
  51. package/plugins/fh-qp/fixtures/evidence_known_clean.txt +3 -0
  52. package/plugins/fh-qp/fixtures/evidence_known_dirty.txt +6 -0
  53. package/plugins/fh-qp/fixtures/reach_known_partial.tsv +4 -0
  54. package/plugins/fh-qp/fixtures/reach_known_reached.tsv +4 -0
  55. package/plugins/fh-qp/fixtures/reach_known_wall.tsv +3 -0
  56. package/plugins/fh-qp/fixtures/verdicts_known_bad_branch.tsv +2 -0
  57. package/plugins/fh-qp/fixtures/verdicts_known_bad_vacuous_machine.tsv +2 -0
  58. package/plugins/fh-qp/fixtures/verdicts_known_good.tsv +4 -0
  59. package/plugins/fh-qp/fixtures/verdicts_verify_only.tsv +3 -0
  60. package/plugins/fh-qp/qp_profile.example.yaml +29 -0
  61. package/plugins/fh-qp/scripts/qp_tools.sh +216 -0
  62. package/plugins/fh-qp/skills/qp/SKILL.md +65 -0
  63. package/plugins/fh-qp/skills/qp-plan/SKILL.md +42 -0
  64. package/plugins/fh-qp/skills/qp-regress/SKILL.md +45 -0
  65. package/plugins/fh-qp/skills/qp-run/SKILL.md +49 -0
  66. package/scripts/chamber_run.sh +14 -5
  67. package/scripts/com.forge-harness.live-eval.plist +84 -0
  68. package/scripts/compaction_probe.sh +9 -35
  69. package/scripts/directional_diff_gate.sh +14 -2
  70. package/scripts/frontier_digest_autopilot.sh +4 -1
  71. package/scripts/map_postprocess.py +90 -0
  72. package/scripts/outbound_query_guard.sh +131 -0
  73. package/scripts/outbound_query_hook.sh +373 -0
  74. package/scripts/package_coverage_check.sh +55 -16
  75. package/scripts/pipe_verdict_guard.sh +41 -1
  76. package/scripts/probe_live_eval.sh +240 -0
  77. package/scripts/probe_live_eval_lib.py +579 -0
  78. package/scripts/proposal_hook.sh +120 -17
  79. package/scripts/push_zone_check.sh +78 -0
  80. package/scripts/residency_closure_scan.py +252 -0
  81. package/scripts/selfcheck.sh +41 -1
  82. package/scripts/session_close_check.sh +100 -0
  83. package/scripts/sim_isolated_run.sh +98 -2
  84. package/scripts/test_action_yml_lanes.sh +97 -0
  85. package/scripts/test_fh_qp_lanes.sh +105 -0
  86. package/scripts/test_gate_two_verdicts_lanes.sh +139 -0
  87. package/scripts/test_map_postprocess_lanes.sh +143 -0
  88. package/scripts/test_marker_affected_lanes.sh +93 -0
  89. package/scripts/test_marker_crossfamily_lanes.sh +90 -6
  90. package/scripts/test_marker_oracle_lanes.sh +136 -0
  91. package/scripts/test_outbound_query_hook_lanes.sh +433 -0
  92. package/scripts/test_outbound_query_lanes.sh +87 -0
  93. package/scripts/test_pipe_verdict_guard_lanes.sh +26 -0
  94. package/scripts/test_preprep_diagram_lanes.sh +87 -0
  95. package/scripts/test_preprep_drift_anchor.sh +3 -3
  96. package/scripts/test_preprep_slide_refs_lanes.sh +169 -0
  97. package/scripts/test_probe_live_eval_lanes.sh +437 -0
  98. package/scripts/test_proposal_hook_lanes.sh +22 -1
  99. package/scripts/test_push_zone_lanes.sh +304 -0
  100. package/scripts/test_residency_closure_lanes.sh +70 -0
  101. package/scripts/test_sim_isolated_run_lanes.sh +119 -0
  102. package/scripts/test_utterance_intake_lanes.sh +414 -0
  103. package/scripts/test_worktree_reclaim_lanes.sh +70 -0
  104. package/scripts/transcript_utterances.py +222 -0
  105. package/scripts/utterance_intake.sh +424 -0
  106. package/scripts/validate_yaml.sh +27 -0
  107. package/scripts/worktree_reclaim.sh +95 -0
  108. package/templates/.git-hooks/pre-commit +353 -1
  109. package/templates/.git-hooks/pre-push +91 -0
  110. package/templates/RED_TEAM_REPORT.md +49 -0
  111. package/templates/settings.PreToolUse.snippet.json +65 -1
@@ -66,7 +66,7 @@ model: sonnet
66
66
  | **위치가 어긋나 있다** | 🟢 덮인다 | `ooxml/gate.py` ⑥ 화면 밖 도형 ⚠️ 그 검사에 붙은 사고 수치(`26p · 1,854,400 EMU`)는 **`gate.py` 자신의 인라인 주석 말고 대조할 기록이 없다** — 거짓이라는 뜻이 아니라 독립 출처가 없다는 뜻이다 |
67
67
  | **번역투** | 🟢 덮인다 | ⑤에서 `ko-tech-writer` Step 2 |
68
68
  | **주어가 빠져 있다** | 🔴 **미구현** (판별자는 있다) | 🟥 «패턴만으로는 못 한다»고 적었던 것을 정정한다 — 판별자가 이미 실재한다: **주어 생략의 가부는 문장이 아니라 화면이 정한다.** 그 문장이 뜨는 화면에 참조 대상이 있으면 생략이 맞고, **참조 대상이 사라지는 장에서 복원**한다. 화면 텍스트는 이미 추출되므로 이건 judged 가 아니라 **measured 후보**다. 다만 복원/유지는 4등급으로 갈린다 (목적어=복원 · 주어+신정보=복원 · **주어+구정보=그대로 둔다** · SOV 재배열) — 전부 복원하면 번역투를 **새로 만든다**. 미구현 상태다 |
69
- | **화면이 너무 비어 보인다** | 🔴 **미구축** | gate.py 는 «밖으로 나갔나»를 보지 «안이 비었나»를 안 본다. 🟥 **정정 2026-09-01 — 「pptx→PDF 가 끊긴다」는 거짓이었고, 그 오진이 이 항목을 막고 있었다.** known-pair: `soffice` 부재는 **참**(`which soffice` → not found · LibreOffice.app 없음) · `pdftoppm` 실재는 **참**(`/opt/homebrew/bin/pdftoppm`) — **그런데 PowerPoint AppleScript `save as PDF` 로 120장 덱이 실제로 12회 렌더됐다**(`/tmp/v11*.pdf` 12개, 최신본 120페이지 `%PDF-1.3`, 2026-09-01 실측). ⇒ 끊긴 것은 **「LibreOffice 경로」**지 **「pptx→PDF」**가 아니다. 🟥 **미구축 사유가 바뀐다**: 「렌더가 안 된다」(장애물 실재) → **「기준선을 아직 안 쟀다」**(장애물 없음). 두 상태는 다르고, 전자는 «못 한다»로 후자는 «안 했다»로 읽힌다. [[feedback_rule_misdescribes_its_own_machine]] 기준선 형태는 정해져 있다: **레퍼런스 덱을 실측해 장 유형별 화면 자수 기준선을 세우고 미달 장을 센다** — 값은 발표마다 다시 재야 한다 |
69
+ | **화면이 너무 비어 보인다** | 🔴 **미구축** | gate.py 는 «밖으로 나갔나»를 보지 «안이 비었나»를 안 본다. 🟥 **정정 2026-09-01 — 「pptx→PDF 가 끊긴다」는 거짓이었고, 그 오진이 이 항목을 막고 있었다.** known-pair: `soffice` 부재는 **참**(`which soffice` → not found · LibreOffice.app 없음) · `pdftoppm` 실재는 **참**(`/opt/homebrew/bin/pdftoppm`) — **그런데 PowerPoint AppleScript `save as PDF` 로 120장 덱이 실제로 12회 렌더됐다**(`/tmp/v11*.pdf` 12개, 최신본 120페이지 `%PDF-1.3`, 2026-09-01 실측). ⇒ 끊긴 것은 **「LibreOffice 경로」**지 **「pptx→PDF」**가 아니다. 🟥 **미구축 사유가 바뀐다**: 「렌더가 안 된다」(장애물 실재) → **「기준선을 아직 안 쟀다」**(장애물 없음). 두 상태는 다르고, 전자는 «못 한다»로 후자는 «안 했다»로 읽힌다. [[feedback_rule_misdescribes_its_own_machine]] 기준선 형태는 정해져 있다: **레퍼런스 덱을 실측해 장 유형별 화면 자수 기준선을 세우고 미달 장을 센다** — 값은 발표마다 다시 재야 한다. 🟥 **정정 2026-09-04 — 기준선을 실제로 쟀다, 다만 반대편(과밀)이다.** 121장 한국어 덱 실측: 화면 자수 중앙 118 · 평균 133 · P90 207 · 최대 808. 읽는 부담(신규 글자 ÷ 발화 초) 중앙 10.0 · P90 16.9(§레인 R4·R5). ⚠️ 값은 발표마다 다시 재야 한다 — 임계를 코드에 안 박고 **그 덱 자기 분포**에서 뽑는 이유가 이것이다. 그리고 이건 게이트가 아니라 **순위표**다 — 정의상 항상 약 10%가 뜬다, 「후보 0건」을 목표로 삼으면 오독이다(§정직한 한계 참조). **「비어 보인다」쪽(하한)은 여전히 기준선이 없다** — 잰 것은 과밀(상한) 뿐이라 이 행은 아직 🔴 미구축으로 남는다 |
70
70
  | **흐름이 매끄럽지 않다** | 🟡 **절반 덮인다** | L8 은 «지시 대상이 해소되나»를 볼 뿐 «논지가 서나»를 못 본다. 🟢 그 부분집합 하나는 이제 덮인다 — **L9 progression** 이 «선언된 단계가 화면에서 빠졌나»를 본다(압축에서 첫 칸이 떨어지는 실사고). ⚠️ «논지가 서나» 자체는 여전히 미해결이고, 되읊음 대조(리뷰어가 요약한 뜻이 저자 의도와 반대인가)는 **사람이 한다** |
71
71
 
72
72
  🟥 **미구축 셋을 「advisory 라서 괜찮다」로 덮지 않는다.** 지금은 ④·⑤ 단계에서 **사람이** 하고
@@ -117,13 +117,30 @@ model: sonnet
117
117
  뒤 단계 전부의 근거다. 여기가 비면 뒤가 전부 기본값으로 채워진다
118
118
  ② 레퍼런스 「참고하려는 것이 있나」를 **먼저 묻는다**(ko-tech-writer §Step 1 과 같은 규율:
119
119
  표본 없이 톤을 단정하지 않는다). 행사 공식 장표가이드 · 좋다고 본 남의 덱 ·
120
- 조직 템플릿. 있으면 /corpus-grounding-expander 로 그 실물을 근거로 세운다
120
+ 조직 템플릿. 있으면 /corpus-grounding-expander 로 그 실물을 근거로 세운다.
121
+ 🟥 «위반»은 원문 **문장**을 인용할 수 있을 때만 낸다 — 스와치·템플릿 실측값과의
122
+ 차이는 «위반»이 아니라 «차이»로만 낸다(§방법론 ⓖ, 숫자가 없는 원문에서 「위반」을
123
+ 냈다가 정정하고, 스와치만 근거로 「위반」을 냈다가 철회한 실사고)
121
124
  ③ 프런티어 ②가 비었거나 더 넓히고 싶을 때만. persona-innovator(Mode F) · /frontier-digest
122
125
  로 세련된 방법을 찾아온다. 🟥 찾아온 것은 **후보**지 결정이 아니다
123
126
  ④ 구성 기승전결. 하네스는 배치안을 «몇 개» 내고 사람이 고른다.
124
127
  🟥 하나만 내면 그건 제안이 아니라 결정이다
125
128
  ⑤ 제작·퇴고 마이크로매니징. 문체는 /ko-tech-writer 로 — 레지스터 · 실물 캘리브레이션 ·
126
- 번역투 제거 · 수치 게이트 · 지각 QA
129
+ 번역투 제거 · 수치 게이트 · 지각 QA.
130
+ 🟥 **도해를 건드렸으면 렌더한다** — 연결선 잔해 · 도형 종류 이탈 · 빌드 프레임
131
+ 텍스트 소실 · 부연-본문 밝기 경쟁 넷 다 렌더 없이는 구조적으로 안 보였다(§방법론 ⓒ).
132
+ 🟥 **장을 뺄 때는 뒤 장의 전제인지 먼저 본다** — 지운 장이 다음 장 부제의
133
+ 접속사(「그러나」 류)가 받는 말이었던 실사고가 있다(§방법론 ⓔ)
134
+ 🔷 **도해는 타입 있는 JSON 에서 굽는다**(2026-09-05 신설, 5단계 안의 갈래이지 6단계가
135
+ 아니다). `python3 diagram_from_json.py <spec.json> --out <png> --css <덱 토큰.css>
136
+ --no-legend` — 외부 렌더러 archify(MIT, 로컬 설치·소스 복사 0)의 showcase validate 를
137
+ **못 통과하면 굽기를 거부**하고, 통과하면 deliver → 뷰어 크롬 없는 SVG 단독 PNG(≥3840px)
138
+ → 굽기 영수증. 덱 자기 규약(배경·강조색·폰트)은 archify CSS 변수 override 로 꽂는다 —
139
+ archify 색을 덱에 가져오는 게 아니라 **덱 규약을 렌더러에 꽂는다**(§방법론 ⓓ). 가이드 뷰
140
+ 인터랙션은 장표에 없으므로 `--focus id,…` 빌드 프레임으로 번역한다(§B3 좌표 고정).
141
+ 🟥 archify 는 글자 크기 토큰이 없어 **viewBox 폭이 글자 pt 를 정한다**(720 → 라벨 29pt·
142
+ 부제 19pt / 1137 → 12pt) — 그래서 폭 상한이 굽기 거부 조건이다. 대응표·차이·취향 판정
143
+ 항목: `tracks-meta/dispatch/2026-09-05_archify-preprep/DESIGN_TOKENS.md`(운영자 로컬)
127
144
  ─────────────────────────────────────────────────────────────────
128
145
  바닥 `python3 preprep.py` — 아래 레인. 걸리면 ⑤ 로 되돌아간다
129
146
  ```
@@ -164,12 +181,28 @@ python3 "$SKILL/preprep.py"
164
181
  | **L11 promise** | **앞에서 예고한 것이 뒤에서 상환되나** — 후보를 나열하고 «예고 N 회 vs 뒤에서 M 회»를 센다. 실사고: 「세 번」 예고에 상환 1건 | 🟥 advisory · **판정은 사람** |
165
182
  | **L10 adjacent-dup** | **인접 장이 같은 문장을 다시 읽나** — 쪼갤 때 뒤 프레임에 원본을 통째로 남기면 앞 장에서 읽은 문장을 또 읽는다. 실측 117자 중 82자(70%) | 차단 (임계는 사람이 적는다. 미기재면 측정만) |
166
183
  | **L9 progression** | **선언된 단계 중 하나가 화면에서 빠졌나** — 실사고: ①②③④ 중 ①이 화면에서 떨어져 리뷰어가 논지를 **정반대로** 읽었다. 개별 문장은 전부 옳았다 | 차단 (선언된 것만 본다) |
184
+ | **L12 diagram** | **타입 JSON 도해가 «지금 JSON» 에서 validate 를 거쳐 구워졌나** — `kind: diagram_source` 표면의 굽기 영수증(`*.receipt.json`)으로 JSON 지문 일치 · showcase validate ok · PNG 실제 폭(IHDR 직독) ≥ 3840 · viewBox 폭 ≤ 720 · 여백. 실사고 후보 셋: JSON 고치고 PNG 안 구움 · 미검증 JSON 손렌더 · 픽셀은 충분한데 글자 12pt | 차단 (선언된 표면만 · 영수증 없는 손그림은 **UNMEASURED**, 0 아님) |
167
185
  | **G ooxml** | pptx 구조 9종 — 관계 참조 무결성 · 죽은 Content_Types Override · **화면 밖 도형** · 열린 도형 채움 · 선 굵기 토큰 래칫 | 차단 (`python3 ooxml/gate.py <풀린 pptx 트리>`) |
186
+ | **R1 orphan-connective** | 선두 줄이 «그러나/그래서»로 뒤집는데 그 문장의 주어가 **직전 장 어디에도 없다** | 🟥 advisory · **실물 known-positive**(실제 백업에서 뜬 결함) |
187
+ | **R2 enum-dropped** | 앞 장이 ①②③ 으로 센 것을, 이 장이 «이 셋 중…» 이라 부르며 **번호 없이** 재편한다 — 1:1 대응이 안 보인다 | 🟥 advisory · **실물 known-positive** |
188
+ | **R3 lead-term-unbacked** | 선두 줄이 **강조색으로 선언한 낱말**이 그 장 다른 어디에도 없다 — 화면이 자기 부제를 못 받친다 | 🟥 advisory · **합성 뮤턴트 known-pair**(자연 발생본이 파일로 안 남아 실물 2장을 줄여 만든 픽스처로만 계량됨 — R1·R2·P1·P3 과 증거 등급이 다르다) |
189
+ | **R4 screen-heavy** | 화면 자수가 **이 덱 자기 분포**(P90)의 상위권 — 한 장에 너무 많이 얹었다 | 🟥 advisory · **순위표**(게이트가 아니다 — 늘 자기 덱의 상위 약 10%가 뜬다, 「후보 0」이 목표가 아니다) |
190
+ | **R5 read-load** | **직전 장 대비 새로 뜬 글자** ÷ 발화 초 — 화면은 늘었는데 말은 짧다. 무대 지시([간지]/[진행])는 발화로 안 센다 | 🟥 advisory · **순위표**(R4 와 같은 성질) |
191
+ | **P1 build-jitter** | 같은 이름 도형이 **연속한 두 장**에서 조금 다른 자리에 있다 — 넘길 때 튄다 | 🟥 advisory · **실물 known-positive** — 🟥 대조된 도형-쌍이 0 건이면 「튐 없음」이 아니라 **UNMEASURED**(이름 중복 도형은 대조 못 해 통째로 뺀다) |
192
+ | **P3 adjacency** | 한 도형의 **오른쪽 끝**과 다른 도형의 **왼쪽 끝**이 맞닿으려다 어긋났다 | 🟥 advisory · **실물 known-positive** · 🟥 절대 목록으로 읽지 마라(기저 오탐 76건 실측) — `surfaces.yaml` 의 `geometry.baseline`(이전 판 pptx)을 주면 **편집 전/후 델타**로 돈다. **P2(near-miss, 같은 종류 모서리만 대조)는 미채택** — 구조적으로 실제 결함(다른 종류 모서리의 인접)을 못 잡아서다 |
168
193
 
169
194
  🟥 **L8 이 advisory 인 이유**: 두 계기의 오탐을 **최저비용으로 무마하는 길이
170
195
  「그 문장을 지우는 것」**이라, 종료코드에 태우면 원고를 나쁜 방향으로 미는 압력이 된다.
171
196
  후보를 내고 판정은 사람이 한다. 오탐 다수가 정상이다.
172
197
 
198
+ 🟥 **R1~R5·P1·P3 이 전부 advisory 인 이유**: L8·L11 과 같다 — 오탐의 최저비용 무마가 원고를
199
+ 망가뜨리는 방향(문장 삭제·형식 왜곡)이라 종료코드에 태우지 않는다. `preprep.py` 배선도 findings 를
200
+ 버리고 notes 로만 낸다(L8·L11 관례). `--lane R1,R2,R3,R4,R5,P1,P3`(또는 그룹 `R`·`P`)로 이 일곱만
201
+ 골라 돌릴 수 있다 — 🟥 **L1~L11 은 아직 이 `--lane` 필터를 안 탄다**(늘 전부 돈다). 정본:
202
+ `tracks-meta/fh_signal_2026-09-04_preprep-evolution.md` §1·§2 · 코드: `lane_slide_relations.py` ·
203
+ `lane_geometry.py` · 픽스처: `fixtures/mk_slide_fixtures.py`(테스트 시 생성) +
204
+ `fixtures/fixture_R3_{positive,negative}.pptx`(실물 2장) · self-test: `test_preprep_lanes_rp.py`.
205
+
173
206
  ## 문체는 레인이 아니다 — 레퍼런스와 스킬이 한다
174
207
 
175
208
  초판에는 `ko-tech-writer` Step 2 를 레인으로 복제한 **L9** 가 있었다. **뺐다.** 이유가 둘이다.
@@ -198,6 +231,68 @@ python3 "$SKILL/preprep.py"
198
231
  - **생성면 ↔ 사람 편집면** — 재생성은 사람 손질을 전량 덮어쓴다. 실측: 발표자 노트 **20곳**이
199
232
  소리 없이 사라졌고 빌드는 성공했으며 원고↔빌드 대조는 «원고대로»였다. L7 의 자리다.
200
233
 
234
+ ## 방법론 — 실사용 하루치에서 역산 (2026-09-04)
235
+
236
+ 121장 한국어 발표 덱 한 벌을 하루 동안 실사용하며 나온 판단 축이다. 코드가 된 것은 §레인의
237
+ 신규 레인들이고, 아래는 코드만으로는 안 닫히는(또는 아직 코드가 아닌) 것들이다.
238
+
239
+ ### ⓐ 장을 지워도 시간은 안 줄어든다
240
+ 장 삭제만으로는 발화 시간이 0초 줄었다. 시간은 **대사**에 있고, 대사는 **화면 글자**에
241
+ 묶여 있다(화면에 적혀 있으면 안 말할 수가 없다). 실제로 줄어든 것은 화면 자수를 깎았을 때뿐이다.
242
+ ⇒ **분량 정리의 1순위 대상은 «장 수»가 아니라 «화면 글자»다.**
243
+
244
+ ### ⓑ 전역 축은 한 장짜리 렌더로 판정하면 안 된다
245
+ 색을 여러 면 바꾸고 한 장씩 렌더해서 「개선」이라 판정했는데, 전체를 이어서 넘겨 본 판정은
246
+ 전달력 저하로 되돌렸다. 두 판정은 다른 것을 본다 — 한 장 안의 대비와, 여러 장을 흐르는 동안
247
+ 「여기가 문제다」가 꽂히는 일관성은 같은 속성이 아니다. **후자는 한 장짜리 렌더에 구조적으로
248
+ 안 나타난다.**
249
+ ⇒ 색·타이포 같은 전역 축은 **연속 구간을 이어서** 보거나 사람이 넘겨 봐야 한다.
250
+ ⇒ 반대 방향도 같이: 한 장에서 보인 것을 **전역으로 밀지도 마라** — 다른 장은 이미 다른
251
+ 요소(예: 테두리 있는 카드)가 같은 문제를 막고 있을 수 있다.
252
+
253
+ ### ⓒ 렌더 없이는 구조적으로 안 보이는 것들
254
+ 같은 하루에 렌더 없이는 놓쳤던 것 넷 — 옛 도해의 연결선 잔해(도형만 지우고 선을 안 지움) ·
255
+ 도형 종류가 덱 규약과 다름 · 빌드 프레임 전환에서 텍스트가 사라지는 자리 · 부연이 본문과
256
+ 같은 밝기로 경쟁하는 자리. ⇒ **도해를 건드렸으면 렌더한다** — ⑤ 단계 규율.
257
+
258
+ ### ⓓ 새 도형을 넣기 전에 «같은 역할의 기존 도형»을 세라
259
+ 새로 넣을 도형이 덱 전체와 혼자 달랐던 사고는, 덱에 이미 그 역할의 규약이 백 회 넘게
260
+ 있었기 때문이었다 — 색도 형태도. 희귀한 값(전체 중 여섯 번뿐)이라도 **그 여섯 번이 전부
261
+ 같은 역할**이면 그게 답이다. ⇒ **덱 자기 규약을 먼저 추출**한다. 지어내는 것은 마지막 수단.
262
+
263
+ ### ⓔ 장을 뺄 때 «그 장이 받는 말이었나»를 본다
264
+ 한 장을 지우자 다음 장 부제의 「그러나」가 받을 말을 잃었다. 삭제 전 **뒤 장의 전제인지**
265
+ 확인한다 — ⑤ 단계 규율.
266
+
267
+ ### ⓕ 「알고도 남긴 것」과 「몰라서 남은 것」을 갈라 적어라
268
+ 팔레트 밖 색을 정해서 남긴 자리가 있었다. 그렇게 적지 않으면 다음 감사자가 다시 «발견»한다
269
+ (실측: 지운 색이 다른 색번호로 되살아나 있었다). 체크리스트 항목: `presentation_checklist.md` C6.
270
+
271
+ ### ⓖ 외부 가이드를 인용할 때 — «정의»는 «금지문»이 아니다
272
+ 같은 레퍼런스를 두고 두 판정이 갈렸다: 폰트 크기 「위반」을 유보했는데 원문에 **숫자가 한
273
+ 개도 없어서** 위반이 아닌 것으로 확정됐고, 색 「위반」은 스와치(정의)를 근거로 올렸다가
274
+ **철회**했다 — 색에 대한 유일한 **문장**은 「같은 위계에 같은 색으로 통일」이라 오히려 만족하고
275
+ 있었다. ⇒ 레퍼런스에서 «위반»을 낼 때는 **원문 문장을 인용**하고, 스와치·템플릿 실측값은
276
+ «차이»로만 낸다 — ② 단계 규율.
277
+
278
+ ## 정직한 한계 (2026-09-04 실측분, 인용 전에 읽어라)
279
+
280
+ §레인의 신규 텍스트 레인(orphan-connective · enum-dropped · lead-term-unbacked · screen-heavy ·
281
+ read-load)과 신규 기하 레인(build-jitter · adjacency)에 대한 캘리브레이션 잔여다. **이 절
282
+ 없이 그 레인들의 수치를 인용하면 과계상이다.**
283
+
284
+ - **reps=1.** 텍스트·기하 신규 레인은 모두 known-positive 한 팔씩이다. 이 저장소 자기 바(≥3)
285
+ 미달.
286
+ - **lead-term-unbacked 의 known-positive 는 합성 뮤턴트다**(자연 발생본이 파일로 안 남았다).
287
+ 나머지는 실제 백업에서 떴으니 **증거 등급이 다르다 — 묶어 말하지 마라.**
288
+ - **오탐 0 은 n=1 코퍼스에서의 수치다.** 다른 덱에 옮기면 임계(선두 줄 판별 · 강조색 비교 ·
289
+ 활용 어미 제외 · 자기분포 P90)를 다시 재야 한다.
290
+ - **enum-dropped 는 표에서 한 칸 어긋난 프레임에서 뜬다** — 같은 표의 이웃 프레임이라 부류는
291
+ 맞지만 쪽수가 어긋난다.
292
+ - **adjacency 기저 오탐이 76건.** 절대 목록으로 쓰면 안 된다 — 편집 전/후 델타 용법이 전제다.
293
+ - **L8 오탐 진단(«저희»·«그대로»)은 이 한 덱에서 나왔다.** 다른 한국어 코퍼스에서 같은 비율인지는
294
+ 미측정.
295
+
201
296
  ## Done When
202
297
 
203
298
  | 조건 | check-class |
@@ -207,6 +302,7 @@ python3 "$SKILL/preprep.py"
207
302
  | **레퍼런스를 물었다** — 참고할 실물이 있나를 묻고, 있으면 그것을 근거로 세웠다. 없으면 «없다»고 기록했다(안 물은 것과 구분) | mandatory-pass |
208
303
  | 단일 소스 드리프트 앵커가 통과한다 (`scripts/test_preprep_drift_anchor.sh`) | measured |
209
304
  | 발견 목록을 사람이 판정했고, advisory 후보 중 **남긴 것에는 남긴 이유**가 적혔다 | judged — 적대 짝: 그 판정을 저자가 아닌 사람이 다시 읽는다(격리 그라운딩). 자기 판정으로 대체 금지 |
305
+ | **도해를 건드렸으면 렌더 결과를 첨부했다**(§방법론 ⓒ) | judged — 적대 짝: 렌더 파일이 실제로 존재하는지(경로·확장자·이번 편집 이후의 mtime)는 별도로 measured 로 확인한다. 「첨부했다」는 자기신고만으론 인정하지 않는다 |
210
306
 
211
307
  ## 이 하네스가 못 하는 것
212
308
 
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env python3
2
+ """diagram_from_json.py — 타입 있는 JSON 한 장 → 검증된 다이어그램 PNG(장표용) + 굽기 영수증.
3
+
4
+ ⑤ 제작·퇴고 단계의 «도해» 갈래. 도해를 영점부터 그리는 시행착오를 줄이는 자리다(SKILL.md §제작 흐름).
5
+
6
+ 파이프라인 (한 번에, 중간 산출은 전부 남긴다):
7
+ JSON ──archify validate(showcase)──▶ 못 통과 = **굽기 거부(exit 1)**, PNG 를 안 만든다
8
+ ──archify deliver───────────▶ <out>.html (뷰어 포함 정본 산출)
9
+ ──SVG 단독 추출 + 토큰 CSS──▶ headless Chrome 스크린샷 <out>.png (뷰어 크롬 0)
10
+ ──해상도 하한·여백·viewBox 폭 검사──▶ <out>.receipt.json (레인 L12 가 읽는 채널)
11
+
12
+ 🟥 archify 소스는 복사하지 않는다 — 외부 렌더러(MIT)를 **쓴다**. 위치는 --archify 또는
13
+ $PREPREP_ARCHIFY_BIN, 없으면 cwd 기준 .claude/skills/archify/bin/archify.mjs.
14
+ 🟥 해상도 하한: 장표 폭 26.67in(1920pt) 기준 2× = 3840px. 미달이면 굽기 거부.
15
+ 🟥 viewBox 폭 상한(기본 720): archify 는 글자 크기 토큰이 없어 **viewBox 폭이 pt 를 정한다**
16
+ (pt = unit × 1728/viewBox폭). 720 이면 라벨 29pt·부제 19pt, 1137 이면 12pt 로 안 읽힌다
17
+ (tracks-meta/dispatch/2026-09-05_archify-preprep/DESIGN_TOKENS.md §3). 넘으면 거부 — --max-viewbox-w 로 조정.
18
+ 🟥 여백(--pad): 그림 가장자리 비율. 0 이면 장표 가장자리와 선이 붙는다.
19
+
20
+ exit 0 굽음 · 1 거부(validate 실패 · 해상도 · viewBox · deliver 실패) · 10 계기 오류(node/archify/Chrome 부재)
21
+ usage: diagram_from_json.py <spec.json> --out <name.png> [--css tokens.css] [--theme dark|light]
22
+ [--focus id,id] [--no-legend] [--size 3840x2160] [--pad 0.03] [--min-px 3840] [--max-viewbox-w 720] [--archify BIN]
23
+ """
24
+ import argparse, hashlib, json, os, re, shutil, struct, subprocess, sys, tempfile, zlib
25
+
26
+ def sha256_file(p):
27
+ h = hashlib.sha256()
28
+ with open(p, 'rb') as f: h.update(f.read())
29
+ return h.hexdigest()
30
+
31
+ def png_size(p):
32
+ with open(p, 'rb') as f: head = f.read(24)
33
+ if head[:8] != b'\x89PNG\r\n\x1a\n': raise ValueError('PNG 아님')
34
+ w, h = struct.unpack('>II', head[16:24]); return w, h
35
+
36
+ def run_json(cmd, env):
37
+ r = subprocess.run(cmd, capture_output=True, text=True, env=env)
38
+ try: return json.loads(r.stdout), r
39
+ except Exception: return None, r
40
+
41
+ def main():
42
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
43
+ ap.add_argument('spec'); ap.add_argument('--out', required=True, help='산출 PNG 경로(.png). HTML·영수증은 같은 이름으로 옆에')
44
+ ap.add_argument('--css', default=None, help='디자인 토큰 override CSS(archify CSS 변수 재정의)')
45
+ ap.add_argument('--theme', default='dark', choices=['dark', 'light'])
46
+ ap.add_argument('--focus', default=None, help='빌드 프레임: 켤 노드 id(콤마). 나머지는 흐림 — 좌표는 불변(§B3)')
47
+ ap.add_argument('--dim', type=float, default=0.22)
48
+ ap.add_argument('--opaque', action='store_true', help='배경을 칠한다(기본은 투명 — 장표 배경이 그대로 비친다. 색 관리 차이로 «패널»이 보이던 실측 결함의 처방)')
49
+ ap.add_argument('--strip-lane-prefix', action='store_true', help='레인 제목의 «01 / »·«EX / » 접두를 뺀다(archify 고정 어휘 — 장표에선 뜻이 없다). 글자만 짧아지고 기하는 불변')
50
+ ap.add_argument('--no-crop', action='store_true', help='내용 경계로 자르지 않는다(기본은 자른다 — archify 가 범례 행·채널 여백을 남겨 장표에서 그림이 작아진다)')
51
+ ap.add_argument('--no-legend', action='store_true', help='SVG 범례 행 숨김(영어 고정이라 D1 위반)')
52
+ ap.add_argument('--size', default='auto', help="'auto' = 폭 3840 · 높이는 SVG 비율대로(레터박스 0 — 장표에 넣을 때 글자 배율이 안 죽는다) 또는 WxH"); ap.add_argument('--pad', type=float, default=0.03)
53
+ ap.add_argument('--min-px', type=int, default=3840); ap.add_argument('--max-viewbox-w', type=int, default=720)
54
+ ap.add_argument('--archify', default=os.environ.get('PREPREP_ARCHIFY_BIN', '.claude/skills/archify/bin/archify.mjs'))
55
+ ap.add_argument('--chrome', default=os.environ.get('PREPREP_CHROME', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'))
56
+ a = ap.parse_args()
57
+
58
+ if not os.path.exists(a.spec): print('HARNESS ERROR: spec 없음', a.spec); return 10
59
+ if not os.path.exists(a.archify): print('HARNESS ERROR: archify 없음 —', a.archify, '(npx -y skills add tt-a1i/archify --skill archify --agent claude-code --copy --yes)'); return 10
60
+ if shutil.which('node') is None: print('HARNESS ERROR: node 없음'); return 10
61
+ if not os.path.exists(a.chrome) and shutil.which(a.chrome) is None: print('HARNESS ERROR: Chrome 없음 —', a.chrome); return 10
62
+ env = dict(os.environ, ARCHIFY_UPDATE_CHECK_DISABLED='1') # 외부 통신 차단
63
+ spec = json.load(open(a.spec, encoding='utf-8'))
64
+ dtype = spec.get('diagram_type')
65
+ if dtype not in ('architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'):
66
+ print('REFUSE: diagram_type 없음/미지원:', dtype); return 1
67
+ out_png = os.path.abspath(a.out); stem = re.sub(r'\.png$', '', out_png)
68
+ out_html, out_receipt = stem + '.html', stem + '.receipt.json'
69
+
70
+ # ① validate — showcase 9 검사 전부. 못 통과하면 여기서 끝(PNG 안 만든다)
71
+ v, r = run_json(['node', a.archify, 'validate', dtype, a.spec, '--quality', 'showcase', '--json'], env)
72
+ if not v or not v.get('ok'):
73
+ print('REFUSE: archify validate 실패 — 굽지 않는다')
74
+ for d in (v or {}).get('diagnostics') or (((v or {}).get('composition') or {}).get('diagnostics') or []):
75
+ print(' ·', d.get('code'), d.get('message', '')[:300])
76
+ if not v: print(' ·', (r.stderr or r.stdout)[-600:])
77
+ return 1
78
+ comp = v.get('composition') or {}
79
+ if (comp.get('summary') or {}).get('errors', 0) or (comp.get('summary') or {}).get('warnings', 0):
80
+ print('REFUSE: showcase 구성 오류/경고 있음', comp.get('summary')); return 1
81
+ # ② deliver
82
+ d, r = run_json(['node', a.archify, 'deliver', dtype, a.spec, out_html, '--quality', 'showcase', '--json'], env)
83
+ if not d or not d.get('ok'):
84
+ print('REFUSE: deliver 실패', (r.stderr or r.stdout)[-600:]); return 1
85
+ html = open(out_html, encoding='utf-8').read()
86
+ svgs = re.findall(r'<svg[\s\S]*?</svg>', html)
87
+ if len(svgs) != 1: print('REFUSE: <svg> 블록이 1개가 아님:', len(svgs)); return 1
88
+ vb = re.search(r'viewBox="([^"]+)"', svgs[0]); vbw = float(vb.group(1).split()[2]) if vb else None
89
+ if vbw is None or vbw > a.max_viewbox_w:
90
+ print(f'REFUSE: viewBox 폭 {vbw} > 상한 {a.max_viewbox_w} — 글자가 {12*1728/(vbw or 1):.0f}pt 로 떨어진다. 노드/레인을 줄이거나 --max-viewbox-w 를 근거와 함께 올려라')
91
+ return 1
92
+ # ③ SVG 단독 PNG
93
+ styles = ''.join(re.findall(r'<style[^>]*>[\s\S]*?</style>', html))
94
+ svg_out = svgs[0]
95
+ if a.strip_lane_prefix:
96
+ # 레인 제목 <text …font-size="10"…>01 / 사람</text> — 접두만 지운다. 텍스트가 짧아질 뿐 어떤 좌표도 안 바뀐다.
97
+ svg_out = re.sub(r'(<text[^>]*font-size="10"[^>]*>)(?:\d{2}|EX) / ', r'\1', svg_out)
98
+ vbh = float(vb.group(1).split()[3])
99
+ if a.size.lower() == 'auto':
100
+ # 🟥 16:9 고정 캔버스에 1.9:1 도해를 넣으면 레터박스가 생겨 장표에서 그림이 작아진다(실측:
101
+ # 후보 1차가 라벨 18pt 로 떨어졌다). 캔버스를 SVG 비율에 맞춰 여백 0 으로 굽는다.
102
+ W = max(a.min_px, 3840); H = int(round(W * vbh / vbw))
103
+ else:
104
+ W, H = map(int, a.size.lower().split('x'))
105
+ focus_css = ''
106
+ if a.no_legend: focus_css += '[data-legend],[data-legend-kind]{display:none !important}\n'
107
+ if a.focus:
108
+ ids = [x.strip() for x in a.focus.split(',') if x.strip()]
109
+ keep_n = ','.join(f'g[data-node-id="{i}"]' for i in ids)
110
+ keep_e = ','.join(f'[data-edge-from="{x}"][data-edge-to="{y}"]' for x in ids for y in ids if x != y)
111
+ focus_css += f'g[data-node-id],[data-edge-from]{{opacity:{a.dim}}}\n{keep_n}{{opacity:1}}\n' + (f'{keep_e}{{opacity:1}}\n' if keep_e else '')
112
+ token_css = ('<style>' + open(a.css, encoding='utf-8').read() + '</style>') if a.css else ''
113
+ bg_css = '' if a.opaque else 'html,body{background:transparent !important}'
114
+ doc = (f'<!doctype html><html lang="ko" data-theme="{a.theme}" data-preset="classic"><head><meta charset="utf-8">{styles}'
115
+ f'<style>{focus_css}html,body{{margin:0;padding:0;width:{W}px;height:{H}px;overflow:hidden}}{bg_css}'
116
+ f'body{{display:flex;align-items:center;justify-content:center}}'
117
+ f'.stage{{width:{W*(1-2*a.pad):.0f}px;height:{H*(1-2*a.pad):.0f}px;display:flex;align-items:center;justify-content:center}}'
118
+ f'.stage>svg{{width:100%;height:100%;max-width:100%;max-height:100%}}</style>{token_css}</head>'
119
+ f'<body><div class="stage">{svg_out}</div></body></html>')
120
+ tmpd = tempfile.mkdtemp(prefix='preprep_diagram_'); src = os.path.join(tmpd, 'svg_only.html')
121
+ open(src, 'w', encoding='utf-8').write(doc)
122
+ subprocess.run([a.chrome, '--headless=new', '--disable-gpu', '--hide-scrollbars', '--no-first-run'] +
123
+ ([] if a.opaque else ['--default-background-color=00000000']) +
124
+ [f'--window-size={W},{H}', f'--screenshot={out_png}', 'file://' + src], capture_output=True, text=True, timeout=180)
125
+ shutil.rmtree(tmpd, ignore_errors=True)
126
+ if not os.path.exists(out_png): print('HARNESS ERROR: Chrome 스크린샷 없음'); return 10
127
+ cropped = None
128
+ if not a.no_crop and not a.opaque:
129
+ try:
130
+ from PIL import Image
131
+ im = Image.open(out_png).convert('RGBA'); bb = im.getbbox() # 알파 0 이 아닌 픽셀의 경계
132
+ if bb:
133
+ m = int(round(a.pad * W)); bb2 = (max(0, bb[0]-m), max(0, bb[1]-m), min(W, bb[2]+m), min(H, bb[3]+m))
134
+ im.crop(bb2).save(out_png); cropped = bb2
135
+ except ImportError:
136
+ cropped = 'PIL 없음 — 자르지 못함(UNMEASURED)'
137
+ pw, ph = png_size(out_png)
138
+ if pw < a.min_px and not cropped:
139
+ os.remove(out_png); print(f'REFUSE: PNG 폭 {pw} < 하한 {a.min_px} — 지웠다'); return 1
140
+ if cropped and W < a.min_px:
141
+ os.remove(out_png); print(f'REFUSE: 캔버스 폭 {W} < 하한 {a.min_px} — 지웠다'); return 1
142
+ # ④ 영수증 — L12 가 읽는 채널. JSON 지문으로 «지금 JSON 의 그림인가»를 묶는다
143
+ receipt = {'schema': 'preprep.diagram.receipt/1', 'spec': os.path.abspath(a.spec), 'spec_sha256': sha256_file(a.spec),
144
+ 'diagram_type': dtype, 'validate': {'ok': True, 'profile': 'showcase', 'checks': len(v.get('checks', [])),
145
+ 'composition': comp.get('summary')}, 'deliver': {'artifact_sha256': (d.get('artifact') or {}).get('sha256')},
146
+ 'html': out_html, 'png': out_png, 'png_size': [pw, ph], 'viewbox_w': vbw, 'viewbox_h': vbh, 'canvas': a.size, 'transparent': not a.opaque, 'crop': cropped, 'pad': a.pad, 'theme': a.theme,
147
+ 'css': os.path.abspath(a.css) if a.css else None, 'focus': a.focus, 'legend_hidden': bool(a.no_legend), 'lane_prefix_stripped': bool(a.strip_lane_prefix),
148
+ 'text_pt_estimate': {'label12': round(12*1728/vbw, 1), 'sublabel8': round(8*1728/vbw, 1)}}
149
+ json.dump(receipt, open(out_receipt, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
150
+ print(f'OK {out_png} {pw}x{ph} viewBox_w={vbw:.0f} (라벨≈{receipt["text_pt_estimate"]["label12"]}pt · 부제≈{receipt["text_pt_estimate"]["sublabel8"]}pt) 영수증 {out_receipt}')
151
+ return 0
152
+
153
+ if __name__ == '__main__':
154
+ sys.exit(main())
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """R1·R2·R4·R5·P1·P3 의 known-pair pptx 를 **테스트 시점에** 만든다 — 레포에 커밋하지 않는다.
3
+
4
+ 🟥 R3 는 여기서 안 만든다. 선두 줄 판별이 절대좌표(LEAD_MAX_Y·LEAD_MIN_CX)와 srgbClr 런 구조에
5
+ 기대므로, 검출기 가정에 맞춰 python-pptx 로 «생성»한 픽스처는 검출기 자신을 검증 못 한다
6
+ (자기참조). 대신 실물 구조를 그대로 2장으로 줄인 `fixture_R3_positive.pptx` ·
7
+ `fixture_R3_negative.pptx` 를 쓴다(이 디렉터리에 실물로 있다).
8
+
9
+ 슬라이드 크기는 원 코퍼스와 같은 1920pt 계열(24,384,000 × 13,716,000 EMU)로 맞춘다 — P1/P3 는
10
+ 상대좌표 비교라 크기 자체는 판정에 안 들어가지만, R1/R3 의 `LEAD_MIN_CX`(12,000,000 EMU) 가
11
+ «슬라이드 절반 폭» 정도를 가정하므로 실물 규모에서 벗어나면 그 가정을 검증 못 한다.
12
+ """
13
+ import os
14
+ from pptx import Presentation
15
+ from pptx.util import Emu
16
+ from pptx.dml.color import RGBColor
17
+
18
+ SLIDE_W, SLIDE_H = Emu(24384000), Emu(13716000)
19
+ ACCENT = RGBColor(0xFA, 0xE1, 0x00)
20
+
21
+
22
+ def _new_deck():
23
+ prs = Presentation()
24
+ prs.slide_width, prs.slide_height = SLIDE_W, SLIDE_H
25
+ return prs
26
+
27
+
28
+ def _blank(prs):
29
+ return prs.slides.add_slide(prs.slide_layouts[6])
30
+
31
+
32
+ def _box(slide, name, x, y, w, h, text=None, accent=False):
33
+ tb = slide.shapes.add_textbox(Emu(x), Emu(y), Emu(w), Emu(h))
34
+ tb.name = name
35
+ if text is not None:
36
+ p = tb.text_frame.paragraphs[0]
37
+ r = p.add_run()
38
+ r.text = text
39
+ if accent:
40
+ r.font.color.rgb = ACCENT
41
+ return tb
42
+
43
+
44
+ def _notes(slide, text):
45
+ slide.notes_slide.notes_text_frame.text = text
46
+
47
+
48
+ # ── R1 orphan-connective ─────────────────────────────────────────────────────
49
+ # 제목은 폭을 좁혀 lead 자격에서 뺀다(LEAD_MIN_CX=12,000,000 미만) — 선두 줄만 lead 로 잡히게.
50
+ def r1(path, positive):
51
+ prs = _new_deck()
52
+ s0 = _blank(prs)
53
+ _box(s0, 'title0', 100000, 100000, 5000000, 500000, '첫 장')
54
+ prev_body = ('오늘은 깃허브 사용법을 설명합니다' if not positive
55
+ else '오늘은 배포 절차를 설명합니다')
56
+ _box(s0, 'body0', 100000, 2000000, 8000000, 800000, prev_body)
57
+ s1 = _blank(prs)
58
+ _box(s1, 'title1', 100000, 100000, 5000000, 500000, '둘째 장')
59
+ _box(s1, 'lead1', 100000, 900000, 14000000, 900000, '그러나 깃허브는 막을 자리를 줄 뿐입니다')
60
+ _box(s1, 'body1', 100000, 2500000, 8000000, 800000, '본문 내용입니다')
61
+ prs.save(path)
62
+
63
+
64
+ # ── R2 enum-dropped ──────────────────────────────────────────────────────────
65
+ def r2(path, positive):
66
+ prs = _new_deck()
67
+ s0 = _blank(prs)
68
+ _box(s0, 'enum0', 100000, 100000, 14000000, 900000,
69
+ '① 속도 개선 ② 품질 향상 ③ 비용 절감')
70
+ s1 = _blank(prs)
71
+ _box(s1, 'title1', 100000, 100000, 14000000, 900000, '이 셋 중 무엇이 가장 중요한가')
72
+ body = ('① 속도 ② 품질 ③ 비용을 함께 고려합니다' if not positive
73
+ else '속도와 품질과 비용을 함께 고려합니다')
74
+ _box(s1, 'body1', 100000, 2000000, 14000000, 900000, body)
75
+ prs.save(path)
76
+
77
+
78
+ # ── R4 screen-heavy (자기 분포 P90, N=12) ────────────────────────────────────
79
+ # 🟥 negative 는 «값을 살짝 낮춘 아웃라이어」가 아니라 **전부 동값**으로 짠다 — N=12 처럼 작은
80
+ # 표본에서는 살짝 낮춰도 sorted 순서상 «둘째로 큰 값」이 바뀌어 p90 인덱스가 다른 정상값을
81
+ # 가리키고, 그 값이 다시 «최댓값이라 p90 초과」가 되는 자기충족 오탐이 난다(실측으로 확인).
82
+ # 전부 동값이면 어떤 값도 p90 을 **엄격히** 못 넘어(같음은 초과가 아니다) 후보가 구조적으로 0.
83
+ def r4(path, positive):
84
+ prs = _new_deck()
85
+ for i in range(12):
86
+ s = _blank(prs)
87
+ n = 500 if (positive and i == 6) else 30
88
+ _box(s, f'body{i}', 100000, 100000, 14000000, 900000, '가' * n)
89
+ prs.save(path)
90
+
91
+
92
+ # ── R5 read-load (신규 글자 ÷ 발화 초, 자기 분포 P90, N=12) ─────────────────
93
+ # 같은 이유로 negative 는 «전부 동일 비율」로 짠다(위 R4 와 같은 원리).
94
+ def r5(path, positive):
95
+ """정상 슬라이드: 신규 64자 · 발화 12초(64/5.36≈12) → 비율 5.33.
96
+ 아웃라이어(positive 만, 7번째 장): 신규 150자 · 발화 5초(27/5.36≈5) → 비율 30.0."""
97
+ prs = _new_deck()
98
+ for i in range(12):
99
+ s = _blank(prs)
100
+ if positive and i == 6:
101
+ _box(s, 'body_outlier', 100000, 100000, 14000000, 2000000,
102
+ '완전히새로운화면내용' * 15) # 150자, 이전 장과 겹치지 않는 새 문자열
103
+ _notes(s, '가' * 27) # round(27/5.36) = 5초
104
+ else:
105
+ # 접두 2자리 인덱스로 매 장을 다르게 만든다(집합 차집합 비교라 «신규»로 잡히려면
106
+ # 이전 장과 텍스트가 겹치면 안 된다) — 나머지 62자는 동일해 길이는 항상 64자다
107
+ _box(s, f'body{i}', 100000, 100000, 14000000, 2000000, f'{i:02d}' + '가' * 62)
108
+ _notes(s, '가' * 64) # round(64/5.36) = 12초
109
+ prs.save(path)
110
+
111
+
112
+ # ── P1 build-jitter (같은 이름 도형, 연속 4장) ───────────────────────────────
113
+ def p1(path):
114
+ """4장 · 이름 «gate_bar» 공유. 쌍(1→2)=임계 안 소폭 이동(뜸) · (2→3)=임계 밖 큰 이동(안 뜸) ·
115
+ (3→4)=무이동(안 뜸). compared=3 이어야 «비교 안 함»이 아니다."""
116
+ prs = _new_deck()
117
+ xs = [1000000, 1030000, 6030000, 6030000] # +30,000(JIT 200,000 안) · +5,000,000(밖) · +0
118
+ for x in xs:
119
+ s = _blank(prs)
120
+ _box(s, 'gate_bar', x, 2000000, 1000000, 500000, '게이트 막대')
121
+ prs.save(path)
122
+
123
+
124
+ # ── P3 adjacency (오른끝 ↔ 왼끝) ─────────────────────────────────────────────
125
+ def p3(path):
126
+ """slide1 = 임계 안 어긋남(뜸) · slide2 = 정확히 맞닿음(0, 안 뜸) + 임계 밖 큰 틈(안 뜸)."""
127
+ prs = _new_deck()
128
+ s1 = _blank(prs)
129
+ _box(s1, 'stub_a', 1000000, 2000000, 1000000, 500000, 'A') # 오른끝 2,000,000
130
+ _box(s1, 'bar_b', 2020000, 2000000, 1000000, 500000, 'B') # 왼끝 2,020,000 · 틈 20,000(<ALN)
131
+ s2 = _blank(prs)
132
+ _box(s2, 'stub_c', 1000000, 2000000, 1000000, 500000, 'C') # 오른끝 2,000,000
133
+ _box(s2, 'bar_d', 2000000, 2000000, 1000000, 500000, 'D') # 왼끝 2,000,000 · 틈 0 (맞닿음)
134
+ _box(s2, 'stub_e', 1000000, 4000000, 1000000, 500000, 'E') # 오른끝 2,000,000
135
+ _box(s2, 'bar_f', 3000000, 4000000, 1000000, 500000, 'F') # 왼끝 3,000,000 · 틈 1,000,000(>ALN)
136
+ prs.save(path)
137
+
138
+
139
+ # ── P1/P3 --baseline 델타 쌍 ──────────────────────────────────────────────────
140
+ def geometry_baseline_pair(base_path, edited_path):
141
+ """base = 깨끗한 2장(후보 0). edited = 2장째 도형 하나를 소폭(30,000 EMU) 옮긴 사본
142
+ (새 P1 후보 정확히 1건)."""
143
+ prs = _new_deck()
144
+ s1 = _blank(prs)
145
+ _box(s1, 'gate_bar', 1000000, 2000000, 1000000, 500000, '게이트')
146
+ s2 = _blank(prs)
147
+ _box(s2, 'gate_bar', 1000000, 2000000, 1000000, 500000, '게이트')
148
+ prs.save(base_path)
149
+
150
+ prs2 = _new_deck()
151
+ t1 = _blank(prs2)
152
+ _box(t1, 'gate_bar', 1000000, 2000000, 1000000, 500000, '게이트')
153
+ t2 = _blank(prs2)
154
+ _box(t2, 'gate_bar', 1030000, 2000000, 1000000, 500000, '게이트') # +30,000 EMU
155
+ prs2.save(edited_path)
156
+
157
+
158
+ def build_all(outdir):
159
+ os.makedirs(outdir, exist_ok=True)
160
+ r1(os.path.join(outdir, 'r1_pos.pptx'), True)
161
+ r1(os.path.join(outdir, 'r1_neg.pptx'), False)
162
+ r2(os.path.join(outdir, 'r2_pos.pptx'), True)
163
+ r2(os.path.join(outdir, 'r2_neg.pptx'), False)
164
+ r4(os.path.join(outdir, 'r4_pos.pptx'), True)
165
+ r4(os.path.join(outdir, 'r4_neg.pptx'), False)
166
+ r5(os.path.join(outdir, 'r5_pos.pptx'), True)
167
+ r5(os.path.join(outdir, 'r5_neg.pptx'), False)
168
+ p1(os.path.join(outdir, 'p1.pptx'))
169
+ p3(os.path.join(outdir, 'p3.pptx'))
170
+ geometry_baseline_pair(os.path.join(outdir, 'geo_base.pptx'),
171
+ os.path.join(outdir, 'geo_edited.pptx'))
172
+ return outdir
173
+
174
+
175
+ if __name__ == '__main__':
176
+ import sys, tempfile
177
+ d = sys.argv[1] if len(sys.argv) > 1 else tempfile.mkdtemp(prefix='preprep_fixtures_')
178
+ build_all(d)
179
+ print('built into', d)